324 lines
10 KiB
Dart
324 lines
10 KiB
Dart
@Tags(['integration', 'postgres'])
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
|
|
import 'package:cyberhybridhub_server/alpaca/alpaca_env.dart';
|
|
import 'package:cyberhybridhub_server/alpaca/alpaca_trading_client.dart';
|
|
import 'package:cyberhybridhub_server/trading/guardrails.dart';
|
|
import 'package:cyberhybridhub_server/trading/trade_actuator.dart';
|
|
import 'package:cyberhybridhub_server/trading/trade_orders_db.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:test/test.dart';
|
|
|
|
import '../helpers/fixture_loader.dart';
|
|
import '../helpers/mock_http_client.dart';
|
|
import '../helpers/test_db.dart';
|
|
|
|
void main() {
|
|
TestDb? testDb;
|
|
|
|
setUpAll(() async {
|
|
testDb = await TestDb.open();
|
|
});
|
|
|
|
tearDown(() async {
|
|
if (testDb != null) {
|
|
await testDb!.truncateTradingTables();
|
|
}
|
|
});
|
|
|
|
tearDownAll(() async {
|
|
await testDb?.close();
|
|
});
|
|
|
|
Future<void> _seedConfig(String uid) async {
|
|
await testDb!.seedUser(uid);
|
|
await testDb!.tradingConfigDb.upsertUserConfig(
|
|
firebaseUid: uid,
|
|
templateName: 'default_paper_watchlist',
|
|
enabled: true,
|
|
);
|
|
}
|
|
|
|
Future<void> _stagePending(
|
|
String uid, {
|
|
required String clientOrderId,
|
|
required String questionId,
|
|
required String ruleId,
|
|
String symbol = 'SPY',
|
|
String side = 'buy',
|
|
num notional = 10,
|
|
}) async {
|
|
await testDb!.userTradingStateDb.addPendingOrder(
|
|
firebaseUid: uid,
|
|
order: <String, dynamic>{
|
|
'rule_id': ruleId,
|
|
'question_id': questionId,
|
|
'symbol': symbol,
|
|
'side': side,
|
|
'order_type': 'market',
|
|
'notional_usd': notional,
|
|
'client_order_id': clientOrderId,
|
|
'staged_at': DateTime.utc(2026, 5, 25).toIso8601String(),
|
|
},
|
|
);
|
|
}
|
|
|
|
TradeActuator _testModeActuator({Guardrails? guardrails}) {
|
|
return TradeActuator(
|
|
tradingConfigDb: testDb!.tradingConfigDb,
|
|
tradingStateDb: testDb!.userTradingStateDb,
|
|
tradeOrdersDb: testDb!.tradeOrdersDb,
|
|
questionsDb: testDb!.questionsDb,
|
|
guardrails: guardrails ?? Guardrails(),
|
|
);
|
|
}
|
|
|
|
group('TradeActuator (test mode)', () {
|
|
test('drains pending order → inserts trade_orders + removes from pending',
|
|
() async {
|
|
if (testDb == null) {
|
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
|
return;
|
|
}
|
|
const String uid = 'actuator-test-mode-uid';
|
|
await _seedConfig(uid);
|
|
|
|
// Real questions row (FK target for trade_orders.question_id).
|
|
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
|
|
assignedUserId: uid,
|
|
questionText: 'Buy SPY?',
|
|
correctAnswer: 10,
|
|
sourceTag: 'trading:rule:dip_confirm',
|
|
pipelineKey: 'trading',
|
|
pipelineStep: 'dip_confirm:await_confirm',
|
|
);
|
|
final String questionId = question['id']! as String;
|
|
final String clientOrderId = '$uid-dip_confirm-$questionId';
|
|
|
|
await _stagePending(
|
|
uid,
|
|
clientOrderId: clientOrderId,
|
|
questionId: questionId,
|
|
ruleId: 'dip_confirm',
|
|
);
|
|
|
|
final TradeActuator actuator = _testModeActuator();
|
|
final TradeActuatorResult result =
|
|
await actuator.processPendingOrders(uid);
|
|
|
|
expect(result.submitted, <String>[clientOrderId]);
|
|
expect(result.rejected, isEmpty);
|
|
expect(result.errors, isEmpty);
|
|
|
|
final TradeOrder? saved =
|
|
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId);
|
|
expect(saved, isNotNull);
|
|
expect(saved!.alpacaOrderId, 'test-$clientOrderId');
|
|
expect(saved.status, 'test_accepted');
|
|
expect(saved.symbol, 'SPY');
|
|
expect(saved.side, 'buy');
|
|
expect(saved.notionalUsd, 10);
|
|
expect(saved.questionId, questionId);
|
|
expect(saved.ruleId, 'dip_confirm');
|
|
|
|
final List<Map<String, dynamic>> remaining =
|
|
await testDb!.userTradingStateDb.listPendingOrders(uid);
|
|
expect(remaining, isEmpty);
|
|
});
|
|
|
|
test('idempotent: existing trade_orders row → no second insert', () async {
|
|
if (testDb == null) {
|
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
|
return;
|
|
}
|
|
const String uid = 'actuator-idempotent-uid';
|
|
await _seedConfig(uid);
|
|
|
|
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
|
|
assignedUserId: uid,
|
|
questionText: 'Buy SPY?',
|
|
correctAnswer: 10,
|
|
sourceTag: 'trading:rule:dip_confirm',
|
|
pipelineKey: 'trading',
|
|
pipelineStep: 'dip_confirm:await_confirm',
|
|
);
|
|
final String questionId = question['id']! as String;
|
|
final String clientOrderId = '$uid-dip_confirm-$questionId';
|
|
|
|
// Pre-insert a trade_orders row to simulate a prior crashed actuator run.
|
|
await testDb!.tradeOrdersDb.insertOrder(
|
|
firebaseUid: uid,
|
|
clientOrderId: clientOrderId,
|
|
symbol: 'SPY',
|
|
side: 'buy',
|
|
orderType: 'market',
|
|
status: 'test_accepted',
|
|
alpacaOrderId: 'test-$clientOrderId',
|
|
notionalUsd: 10,
|
|
questionId: questionId,
|
|
ruleId: 'dip_confirm',
|
|
);
|
|
|
|
await _stagePending(
|
|
uid,
|
|
clientOrderId: clientOrderId,
|
|
questionId: questionId,
|
|
ruleId: 'dip_confirm',
|
|
);
|
|
|
|
final TradeActuator actuator = _testModeActuator();
|
|
final TradeActuatorResult result =
|
|
await actuator.processPendingOrders(uid);
|
|
|
|
expect(result.submitted, <String>[clientOrderId]);
|
|
expect(result.rejected, isEmpty);
|
|
|
|
// Still exactly one trade_orders row.
|
|
expect(
|
|
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId),
|
|
isNotNull,
|
|
);
|
|
final List<Map<String, dynamic>> pending =
|
|
await testDb!.userTradingStateDb.listPendingOrders(uid);
|
|
expect(pending, isEmpty);
|
|
});
|
|
|
|
test('guardrail rejects when notional exceeds server ceiling', () async {
|
|
if (testDb == null) {
|
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
|
return;
|
|
}
|
|
const String uid = 'actuator-guardrail-uid';
|
|
await _seedConfig(uid);
|
|
|
|
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
|
|
assignedUserId: uid,
|
|
questionText: 'Buy SPY?',
|
|
correctAnswer: 10,
|
|
sourceTag: 'trading:rule:dip_confirm',
|
|
pipelineKey: 'trading',
|
|
pipelineStep: 'dip_confirm:await_confirm',
|
|
);
|
|
final String questionId = question['id']! as String;
|
|
final String clientOrderId = '$uid-dip_confirm-$questionId';
|
|
|
|
await _stagePending(
|
|
uid,
|
|
clientOrderId: clientOrderId,
|
|
questionId: questionId,
|
|
ruleId: 'dip_confirm',
|
|
notional: 999,
|
|
);
|
|
|
|
final TradeActuator actuator = _testModeActuator(
|
|
guardrails: Guardrails(serverMaxNotionalUsd: 50),
|
|
);
|
|
final TradeActuatorResult result =
|
|
await actuator.processPendingOrders(uid);
|
|
|
|
expect(result.submitted, isEmpty);
|
|
expect(result.rejected, hasLength(1));
|
|
expect(
|
|
result.rejected.single.reason,
|
|
GuardrailRejectionReason.serverMaxNotionalUsdExceeded,
|
|
);
|
|
|
|
expect(
|
|
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId),
|
|
isNull,
|
|
);
|
|
final List<Map<String, dynamic>> pending =
|
|
await testDb!.userTradingStateDb.listPendingOrders(uid);
|
|
expect(pending, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('TradeActuator with mocked Alpaca client', () {
|
|
test('POSTs once and persists Alpaca order id + status', () async {
|
|
if (testDb == null) {
|
|
markTestSkipped('Set DATABASE_URL or TEST_DATABASE_URL for integration tests');
|
|
return;
|
|
}
|
|
const String uid = 'actuator-alpaca-mock-uid';
|
|
await _seedConfig(uid);
|
|
|
|
final Map<String, dynamic> question = await testDb!.questionsDb.createQuestion(
|
|
assignedUserId: uid,
|
|
questionText: 'Buy SPY?',
|
|
correctAnswer: 10,
|
|
sourceTag: 'trading:rule:dip_confirm',
|
|
pipelineKey: 'trading',
|
|
pipelineStep: 'dip_confirm:await_confirm',
|
|
);
|
|
final String questionId = question['id']! as String;
|
|
final String clientOrderId = '$uid-dip_confirm-$questionId';
|
|
|
|
await _stagePending(
|
|
uid,
|
|
clientOrderId: clientOrderId,
|
|
questionId: questionId,
|
|
ruleId: 'dip_confirm',
|
|
);
|
|
|
|
final FixtureLoader fixtures = FixtureLoader();
|
|
final Map<String, dynamic> orderJson =
|
|
await fixtures.loadJson('alpaca_order_accepted.json');
|
|
orderJson['client_order_id'] = clientOrderId;
|
|
|
|
final MockHttpClient mock = MockHttpClient();
|
|
mock.whenPost(
|
|
'/v2/orders',
|
|
http.Response(
|
|
jsonEncode(orderJson),
|
|
201,
|
|
headers: <String, String>{'content-type': 'application/json'},
|
|
),
|
|
);
|
|
|
|
final AlpacaEnv env = AlpacaEnv.fromMap(<String, String>{
|
|
'ALPACA_API_KEY_ID': 'k',
|
|
'ALPACA_API_SECRET_KEY': 's',
|
|
'ALPACA_TRADING_BASE_URL': AlpacaEnv.defaultPaperTradingUrl,
|
|
});
|
|
final AlpacaTradingClient client =
|
|
AlpacaTradingClient(env: env, httpClient: mock);
|
|
|
|
final TradeActuator actuator = TradeActuator(
|
|
tradingConfigDb: testDb!.tradingConfigDb,
|
|
tradingStateDb: testDb!.userTradingStateDb,
|
|
tradeOrdersDb: testDb!.tradeOrdersDb,
|
|
questionsDb: testDb!.questionsDb,
|
|
guardrails: Guardrails(),
|
|
alpacaClient: client,
|
|
);
|
|
|
|
final TradeActuatorResult result =
|
|
await actuator.processPendingOrders(uid);
|
|
expect(result.submitted, <String>[clientOrderId]);
|
|
expect(mock.requests, hasLength(1));
|
|
expect(mock.requests.single.method, 'POST');
|
|
|
|
final TradeOrder? saved =
|
|
await testDb!.tradeOrdersDb.findByClientOrderId(clientOrderId);
|
|
expect(saved, isNotNull);
|
|
expect(saved!.alpacaOrderId, '904837e3-3b76-47ec-b432-046db621571b');
|
|
expect(saved.status, 'accepted');
|
|
expect(saved.symbol, 'SPY');
|
|
|
|
// Re-running should NOT POST again (idempotency via findByClientOrderId).
|
|
await _stagePending(
|
|
uid,
|
|
clientOrderId: clientOrderId,
|
|
questionId: questionId,
|
|
ruleId: 'dip_confirm',
|
|
);
|
|
final TradeActuatorResult repeat =
|
|
await actuator.processPendingOrders(uid);
|
|
expect(repeat.submitted, <String>[clientOrderId]);
|
|
expect(mock.requests, hasLength(1));
|
|
});
|
|
});
|
|
}
|