169 lines
5.1 KiB
Dart
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_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<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': 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<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;
|
|
}
|