108 lines
3.2 KiB
Dart
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;
|
|
}
|