cyberhybridhub/server/lib/alpaca/alpaca_market_data_client.dart
2026-05-31 11:17:12 -05:00

169 lines
5.1 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'alpaca_env.dart';
import 'alpaca_models.dart';
import '../trading/market_history_four_hour_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<void> Function()? beforeHttpRequest,
}) : _env = env,
_client = httpClient ?? http.Client(),
_beforeHttpRequest = beforeHttpRequest;
final AlpacaEnv _env;
final http.Client _client;
final Future<void> Function()? _beforeHttpRequest;
Future<void> _throttle() async {
final Future<void> Function()? hook = _beforeHttpRequest;
if (hook != null) {
await hook();
}
}
/// `GET /v2/stocks/{symbol}/trades/latest`
Future<AlpacaLatestTradeResponse> getLatestTrade(String symbol) async {
_env.requireCredentials();
final Uri uri = Uri.parse(
'${_env.dataBaseUrl}/v2/stocks/${Uri.encodeComponent(symbol)}/trades/latest',
).replace(queryParameters: <String, String>{'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<String, dynamic>,
);
}
/// `GET /v2/stocks/bars` — batched symbols, daily bars newest last.
Future<AlpacaBarsResponse> getDailyBars(
List<String> symbols, {
int limit = 2,
}) async {
_env.requireCredentials();
if (symbols.isEmpty) {
return AlpacaBarsResponse(barsBySymbol: <String, List<AlpacaBar>>{});
}
final Uri uri = Uri.parse('${_env.dataBaseUrl}/v2/stocks/bars').replace(
queryParameters: <String, String>{
'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<String, dynamic>,
);
}
/// `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<AlpacaBarsResponse> getBarsRange({
required List<String> 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: <String, List<AlpacaBar>>{});
}
AlpacaBarsResponse merged =
AlpacaBarsResponse(barsBySymbol: <String, List<AlpacaBar>>{});
String? pageToken;
int pagesFetched = 0;
while (pagesFetched < maxPages) {
final Map<String, String> query = <String, String>{
'symbols': symbols.join(','),
'timeframe': timeframe,
'start': MarketHistoryFourHourSlot.wireUtc(start),
'end': MarketHistoryFourHourSlot.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<String, dynamic> decoded =
jsonDecode(response.body) as Map<String, dynamic>;
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;
}