import 'dart:convert'; import 'package:http/http.dart' as http; import 'alpaca_env.dart'; import 'alpaca_models.dart'; import '../trading/market_history_session_slot.dart'; /// REST client for Alpaca Market Data API v2 (IEX feed on Basic plan). class AlpacaMarketDataClient { AlpacaMarketDataClient({ required AlpacaEnv env, http.Client? httpClient, Future Function()? beforeHttpRequest, }) : _env = env, _client = httpClient ?? http.Client(), _beforeHttpRequest = beforeHttpRequest; final AlpacaEnv _env; final http.Client _client; final Future Function()? _beforeHttpRequest; Future _throttle() async { final Future Function()? hook = _beforeHttpRequest; if (hook != null) { await hook(); } } /// `GET /v2/stocks/{symbol}/trades/latest` Future getLatestTrade(String symbol) async { _env.requireCredentials(); final Uri uri = Uri.parse( '${_env.dataBaseUrl}/v2/stocks/${Uri.encodeComponent(symbol)}/trades/latest', ).replace(queryParameters: {'feed': _env.dataFeed}); await _throttle(); final http.Response response = await _client.get(uri, headers: _env.authHeaders); if (response.statusCode != 200) { throw AlpacaMarketDataException( 'getLatestTrade($symbol) failed: ${response.statusCode} ${response.body}', ); } return AlpacaLatestTradeResponse.fromJson( jsonDecode(response.body) as Map, ); } /// `GET /v2/stocks/bars` — batched symbols, daily bars newest last. Future getDailyBars( List symbols, { int limit = 2, }) async { _env.requireCredentials(); if (symbols.isEmpty) { return AlpacaBarsResponse(barsBySymbol: >{}); } final Uri uri = Uri.parse('${_env.dataBaseUrl}/v2/stocks/bars').replace( queryParameters: { 'symbols': symbols.join(','), 'timeframe': '1Day', 'limit': limit.toString(), 'feed': _env.dataFeed, }, ); await _throttle(); final http.Response response = await _client.get(uri, headers: _env.authHeaders); if (response.statusCode != 200) { throw AlpacaMarketDataException( 'getDailyBars failed: ${response.statusCode} ${response.body}', ); } return AlpacaBarsResponse.fromJson( jsonDecode(response.body) as Map, ); } /// `GET /v2/stocks/bars` over a time range with pagination. /// /// Follows `next_page_token` until exhausted or [maxPages] is reached. /// HTTP 429 responses throw [AlpacaMarketDataException] with `rate` in /// the message so callers can back off. Future getBarsRange({ required List symbols, required String timeframe, required DateTime start, required DateTime end, int maxPages = 20, int limit = 10000, }) async { _env.requireCredentials(); if (symbols.isEmpty) { return AlpacaBarsResponse(barsBySymbol: >{}); } AlpacaBarsResponse merged = AlpacaBarsResponse(barsBySymbol: >{}); String? pageToken; int pagesFetched = 0; while (pagesFetched < maxPages) { final Map query = { 'symbols': symbols.join(','), 'timeframe': timeframe, 'start': MarketHistorySessionSlot.wireUtc(start), 'end': MarketHistorySessionSlot.wireUtc(end), 'feed': _env.dataFeed, 'limit': limit.toString(), if (pageToken != null && pageToken.isNotEmpty) 'page_token': pageToken, }; final Uri uri = Uri.parse('${_env.dataBaseUrl}/v2/stocks/bars') .replace(queryParameters: query); await _throttle(); final http.Response response = await _client.get(uri, headers: _env.authHeaders); if (response.statusCode == 429) { throw AlpacaMarketDataException.rateLimited( 'getBarsRange rate limited: ${response.statusCode} ${response.body}', ); } if (response.statusCode != 200) { throw AlpacaMarketDataException( 'getBarsRange failed: ${response.statusCode} ${response.body}', ); } final Map decoded = jsonDecode(response.body) as Map; final AlpacaBarsResponse page = AlpacaBarsResponse.fromJson(decoded); merged = merged.merge(page); pagesFetched++; pageToken = page.nextPageToken; if (pageToken == null || pageToken.isEmpty) { break; } } return merged; } void close() => _client.close(); } class AlpacaMarketDataException implements Exception { AlpacaMarketDataException(this.message, {this.statusCode}); AlpacaMarketDataException.rateLimited(this.message) : statusCode = 429; final String message; final int? statusCode; bool get isRateLimited => statusCode == 429 || message.toLowerCase().contains('rate limited') || message.contains('429'); @override String toString() => message; }