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

108 lines
3.2 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'alpaca_env.dart';
import 'alpaca_models.dart';
/// REST client for Alpaca Trading API v2 (paper by default).
///
/// Step 9 (`TRADING_TDD_PLAN.md`) — wraps `POST /v2/orders` and
/// `GET /v2/orders:by_client_order_id` with idempotent semantics.
class AlpacaTradingClient {
AlpacaTradingClient({
required AlpacaEnv env,
http.Client? httpClient,
}) : _env = env,
_client = httpClient ?? http.Client();
final AlpacaEnv _env;
final http.Client _client;
Map<String, String> get _headers => <String, String>{
..._env.authHeaders,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
/// `POST /v2/orders` — places a market order.
///
/// Throws [AlpacaTradingDuplicateClientOrderIdException] when Alpaca rejects
/// the request because [request.clientOrderId] was already used. Callers
/// should resolve the existing order with [getOrderByClientOrderId].
Future<AlpacaOrderResponse> submitOrder(AlpacaOrderRequest request) async {
_env.requireCredentials();
_env.assertPaperOnly();
final Uri uri = Uri.parse('${_env.tradingBaseUrl}/v2/orders');
final http.Response response = await _client.post(
uri,
headers: _headers,
body: jsonEncode(request.toJson()),
);
if (response.statusCode == 200 || response.statusCode == 201) {
return AlpacaOrderResponse.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
if (response.statusCode == 422 &&
response.body.toLowerCase().contains('client_order_id')) {
throw AlpacaTradingDuplicateClientOrderIdException(
clientOrderId: request.clientOrderId,
body: response.body,
);
}
throw AlpacaTradingException(
'submitOrder failed: ${response.statusCode} ${response.body}',
);
}
/// `GET /v2/orders:by_client_order_id?client_order_id=...` — returns null
/// when Alpaca has no order under that id (404).
Future<AlpacaOrderResponse?> getOrderByClientOrderId(
String clientOrderId,
) async {
_env.requireCredentials();
final Uri uri =
Uri.parse('${_env.tradingBaseUrl}/v2/orders:by_client_order_id').replace(
queryParameters: <String, String>{'client_order_id': clientOrderId},
);
final http.Response response = await _client.get(uri, headers: _headers);
if (response.statusCode == 200) {
return AlpacaOrderResponse.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
if (response.statusCode == 404) {
return null;
}
throw AlpacaTradingException(
'getOrderByClientOrderId failed: ${response.statusCode} ${response.body}',
);
}
void close() => _client.close();
}
class AlpacaTradingException implements Exception {
AlpacaTradingException(this.message);
final String message;
@override
String toString() => message;
}
class AlpacaTradingDuplicateClientOrderIdException
extends AlpacaTradingException {
AlpacaTradingDuplicateClientOrderIdException({
required this.clientOrderId,
required this.body,
}) : super('duplicate client_order_id=$clientOrderId: $body');
final String clientOrderId;
final String body;
}