/// Alpaca API credentials and endpoints (server-only). class AlpacaEnv { AlpacaEnv({ required this.apiKeyId, required this.apiSecretKey, required this.tradingBaseUrl, required this.dataBaseUrl, required this.dataFeed, required this.allowLive, }); static const String defaultPaperTradingUrl = 'https://paper-api.alpaca.markets'; static const String defaultDataUrl = 'https://data.alpaca.markets'; static const String liveTradingHost = 'api.alpaca.markets'; final String apiKeyId; final String apiSecretKey; final String tradingBaseUrl; final String dataBaseUrl; final String dataFeed; final bool allowLive; bool get hasCredentials => apiKeyId.isNotEmpty && apiSecretKey.isNotEmpty; /// HTTP headers that authenticate every Alpaca REST call. /// /// Shared across `AlpacaMarketDataClient`, `AlpacaTradingClient`, and /// `AlpacaAssetsClient`; spread it (`...env.authHeaders`) and add /// content-type/accept where the request body needs them. Map get authHeaders => { 'APCA-API-KEY-ID': apiKeyId, 'APCA-API-SECRET-KEY': apiSecretKey, }; bool get isPaperUrl => tradingBaseUrl.contains('paper-api') || !tradingBaseUrl.contains(liveTradingHost); factory AlpacaEnv.fromMap(Map env) { return AlpacaEnv( apiKeyId: env['ALPACA_API_KEY_ID'] ?? '', apiSecretKey: env['ALPACA_API_SECRET_KEY'] ?? '', tradingBaseUrl: _normalizeBaseUrl( env['ALPACA_TRADING_BASE_URL'] ?? defaultPaperTradingUrl, ), dataBaseUrl: _normalizeBaseUrl( env['ALPACA_DATA_BASE_URL'] ?? defaultDataUrl, ), dataFeed: env['ALPACA_DATA_FEED'] ?? 'iex', allowLive: (env['ALPACA_ALLOW_LIVE'] ?? 'false').toLowerCase() == 'true', ); } /// Strips a trailing `/v2` (or `/v2/`) and trailing slashes from a base URL /// so the clients can append `/v2/...` without producing `/v2/v2/...`. static String _normalizeBaseUrl(String raw) { String url = raw.trim(); while (url.endsWith('/')) { url = url.substring(0, url.length - 1); } if (url.endsWith('/v2')) { url = url.substring(0, url.length - 3); } return url; } /// Refuses live trading host unless [allowLive] is true. void assertPaperOnly() { final Uri uri = Uri.parse(tradingBaseUrl); final bool isLiveHost = uri.host == liveTradingHost; if (isLiveHost && !allowLive) { throw StateError( 'Live Alpaca trading URL is not allowed when ALPACA_ALLOW_LIVE=false', ); } } /// Requires non-empty API credentials before outbound Alpaca calls. void requireCredentials() { if (!hasCredentials) { throw StateError( 'ALPACA_API_KEY_ID and ALPACA_API_SECRET_KEY are required', ); } } }