344 lines
11 KiB
Dart
344 lines
11 KiB
Dart
@Tags(['integration', 'postgres'])
|
|
library;
|
|
|
|
import 'package:cyberhybridhub_server/alpaca/alpaca_models.dart';
|
|
import 'package:cyberhybridhub_server/pipeline/question_pipeline.dart';
|
|
import 'package:cyberhybridhub_server/trading/market_history_config.dart';
|
|
import 'package:cyberhybridhub_server/trading/market_history_query.dart';
|
|
import 'package:cyberhybridhub_server/trading/tradable_assets_db.dart';
|
|
import 'package:cyberhybridhub_server/trading/prospective_answer_scoring.dart';
|
|
import 'package:cyberhybridhub_server/trading/trading_pipeline.dart';
|
|
import 'package:postgres/postgres.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import '../helpers/test_db.dart';
|
|
|
|
|
|
void main() {
|
|
TestDb? testDb;
|
|
|
|
setUpAll(() async {
|
|
testDb = await TestDb.open();
|
|
ProspectiveAnswerScoring.closenessExtraPointsEnabled = true;
|
|
});
|
|
|
|
tearDown(() async {
|
|
if (testDb != null) {
|
|
await testDb!.truncateTradingTables();
|
|
}
|
|
});
|
|
|
|
tearDownAll(() async {
|
|
ProspectiveAnswerScoring.closenessExtraPointsEnabled = false;
|
|
await testDb?.close();
|
|
});
|
|
|
|
final DateTime _testNow = DateTime.utc(2026, 5, 26, 12);
|
|
|
|
Future<void> _seedGuessUniverse() async {
|
|
await TradableAssetsDb(testDb!.connection).upsertAll(
|
|
<AlpacaAsset>[
|
|
AlpacaAsset(
|
|
symbol: 'SPY',
|
|
assetClass: 'us_equity',
|
|
tradable: true,
|
|
fractionable: true,
|
|
status: 'active',
|
|
),
|
|
],
|
|
now: _testNow,
|
|
);
|
|
final List<num> closes = <num>[500, 501, 502, 503, 504, 505, 510];
|
|
for (int i = 0; i < closes.length; i++) {
|
|
await testDb!.marketDataDb.upsertSnapshot(
|
|
symbol: 'SPY',
|
|
metric: 'bar',
|
|
timeframe: MarketHistoryConfig.barTimeframe,
|
|
asOf: _testNow.subtract(Duration(hours: 4 * (closes.length - 1 - i))),
|
|
price: closes[i],
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<TradingPipeline> _guessPipeline() async {
|
|
return TradingPipeline(
|
|
questionsDb: testDb!.questionsDb,
|
|
questionService: testDb!.questionService(),
|
|
marketDataDb: testDb!.marketDataDb,
|
|
tradingConfigDb: testDb!.tradingConfigDb,
|
|
tradingStateDb: testDb!.userTradingStateDb,
|
|
marketHistoryQuery: MarketHistoryQuery(connection: testDb!.connection),
|
|
clock: () => _testNow,
|
|
);
|
|
}
|
|
|
|
Future<void> _enableGuessRule(String uid) async {
|
|
await testDb!.seedUser(uid);
|
|
await testDb!.tradingConfigDb.upsertUserConfig(
|
|
firebaseUid: uid,
|
|
templateName: 'default_paper_watchlist',
|
|
enabled: true,
|
|
config: <String, dynamic>{
|
|
'rules': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'id': 'guess_weekly_move',
|
|
'type': 'guess_weekly_move',
|
|
'question_template':
|
|
'{{token}} was {{ref_price}} {{ref_days_ago}} days ago. Swipe +10 if up, -10 if down.',
|
|
},
|
|
<String, dynamic>{
|
|
'id': 'dip_confirm',
|
|
'threshold_pct': 0,
|
|
},
|
|
],
|
|
},
|
|
);
|
|
}
|
|
|
|
group('guess_weekly_move pipeline', () {
|
|
test('evaluate creates obfuscated question with metadata.guess_symbol',
|
|
() async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const String uid = 'guess-eval-uid';
|
|
await _enableGuessRule(uid);
|
|
await _seedGuessUniverse();
|
|
|
|
final TradingPipeline pipeline = await _guessPipeline();
|
|
final TradingEvaluationResult result = await pipeline.evaluate(uid);
|
|
|
|
expect(result.questionsCreated, 1);
|
|
expect(result.rulesFired, <String>['guess_weekly_move']);
|
|
|
|
final Result rows = await testDb!.connection.execute(
|
|
Sql.named(
|
|
'''
|
|
SELECT pipeline_key, pipeline_step, question_text, metadata
|
|
FROM questions
|
|
WHERE assigned_user_id = @uid
|
|
''',
|
|
),
|
|
parameters: <String, dynamic>{'uid': uid},
|
|
);
|
|
expect(rows, hasLength(1));
|
|
expect(rows.first[0], PipelineKeys.trading);
|
|
expect(rows.first[1], 'guess_weekly_move:${TradingPhases.awaitAnswer}');
|
|
final String text = rows.first[2]! as String;
|
|
expect(text, contains('ASSET_A'));
|
|
expect(text, isNot(contains('SPY')));
|
|
final Map<String, dynamic> metadata =
|
|
rows.first[3]! as Map<String, dynamic>;
|
|
expect(metadata['guess_symbol'], 'SPY');
|
|
});
|
|
|
|
test('matching direction and within ±1 records full 2-point score', () async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const String uid = 'guess-score-match-uid';
|
|
await _enableGuessRule(uid);
|
|
await _seedGuessUniverse();
|
|
|
|
final TradingPipeline pipeline = await _guessPipeline();
|
|
await pipeline.evaluate(uid);
|
|
|
|
final List<Map<String, dynamic>> open =
|
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
|
final Map<String, dynamic>? updated =
|
|
await testDb!.questionsDb.submitAnswer(
|
|
questionId: open.single['id'] as String,
|
|
assignedUserId: uid,
|
|
userResponse: 9,
|
|
);
|
|
|
|
await pipeline.handleAnswer(
|
|
firebaseUid: uid,
|
|
answeredQuestion: updated!,
|
|
userResponse: 9,
|
|
);
|
|
|
|
final Map<String, dynamic>? score =
|
|
await testDb!.userTradingStateDb.getGuessScore(uid);
|
|
expect(score, isNotNull);
|
|
expect(score!['total'], 2);
|
|
expect((score['last'] as Map)['score_delta'], 2);
|
|
|
|
final Map<String, dynamic>? ruleState =
|
|
await testDb!.userTradingStateDb.getRuleState(uid, 'guess_weekly_move');
|
|
expect(ruleState!['direction_point'], 1);
|
|
expect(ruleState['closeness_point'], 1);
|
|
expect(ruleState['answer_score'], 2);
|
|
});
|
|
|
|
test('matching direction but farther magnitude records fractional score',
|
|
() async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const String uid = 'guess-score-miss-uid';
|
|
await _enableGuessRule(uid);
|
|
await _seedGuessUniverse();
|
|
|
|
final TradingPipeline pipeline = await _guessPipeline();
|
|
await pipeline.evaluate(uid);
|
|
|
|
final List<Map<String, dynamic>> open =
|
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
|
final Map<String, dynamic>? updated =
|
|
await testDb!.questionsDb.submitAnswer(
|
|
questionId: open.single['id'] as String,
|
|
assignedUserId: uid,
|
|
userResponse: 6,
|
|
);
|
|
|
|
await pipeline.handleAnswer(
|
|
firebaseUid: uid,
|
|
answeredQuestion: updated!,
|
|
userResponse: 6,
|
|
);
|
|
|
|
final Map<String, dynamic>? score =
|
|
await testDb!.userTradingStateDb.getGuessScore(uid);
|
|
expect(score!['total'], closeTo(1.7, 0.001));
|
|
expect((score['last'] as Map)['score_delta'], closeTo(1.7, 0.001));
|
|
});
|
|
|
|
test('non-matching direction records -2 score and ignores closeness', () async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const String uid = 'guess-score-miss-uid';
|
|
await _enableGuessRule(uid);
|
|
await _seedGuessUniverse();
|
|
|
|
final TradingPipeline pipeline = await _guessPipeline();
|
|
await pipeline.evaluate(uid);
|
|
|
|
final List<Map<String, dynamic>> open =
|
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
|
final Map<String, dynamic>? updated =
|
|
await testDb!.questionsDb.submitAnswer(
|
|
questionId: open.single['id'] as String,
|
|
assignedUserId: uid,
|
|
userResponse: -10,
|
|
);
|
|
|
|
await pipeline.handleAnswer(
|
|
firebaseUid: uid,
|
|
answeredQuestion: updated!,
|
|
userResponse: -10,
|
|
);
|
|
|
|
final Map<String, dynamic>? score =
|
|
await testDb!.userTradingStateDb.getGuessScore(uid);
|
|
expect(score!['total'], -2);
|
|
expect((score['last'] as Map)['score_delta'], -2);
|
|
|
|
final Map<String, dynamic>? ruleState =
|
|
await testDb!.userTradingStateDb.getRuleState(uid, 'guess_weekly_move');
|
|
expect(ruleState!['direction_point'], -2);
|
|
expect(ruleState['closeness_point'], 0);
|
|
expect(ruleState['answer_score'], -2);
|
|
});
|
|
|
|
test('handleAnswer never stages pending orders; actuator not invoked',
|
|
() async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const String uid = 'guess-no-actuator-uid';
|
|
await _enableGuessRule(uid);
|
|
await _seedGuessUniverse();
|
|
|
|
final TradingPipeline pipeline = await _guessPipeline();
|
|
await pipeline.evaluate(uid);
|
|
|
|
final List<Map<String, dynamic>> open =
|
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
|
final Map<String, dynamic>? updated =
|
|
await testDb!.questionsDb.submitAnswer(
|
|
questionId: open.single['id'] as String,
|
|
assignedUserId: uid,
|
|
userResponse: 10,
|
|
);
|
|
|
|
await pipeline.handleAnswer(
|
|
firebaseUid: uid,
|
|
answeredQuestion: updated!,
|
|
userResponse: 10,
|
|
);
|
|
|
|
final List<Map<String, dynamic>> pending =
|
|
await testDb!.userTradingStateDb.listPendingOrders(uid);
|
|
expect(pending, isEmpty);
|
|
|
|
final Result orders = await testDb!.connection.execute(
|
|
Sql.named(
|
|
'SELECT COUNT(*)::int FROM trade_orders WHERE firebase_uid = @uid',
|
|
),
|
|
parameters: <String, dynamic>{'uid': uid},
|
|
);
|
|
expect(orders.first[0], 0);
|
|
});
|
|
|
|
test('symbol cooldown prevents re-pick within GUESS_COOLDOWN_HOURS',
|
|
() async {
|
|
if (testDb == null) {
|
|
markTestSkipped(
|
|
'Set DATABASE_URL or TEST_DATABASE_URL for integration tests',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const String uid = 'guess-cooldown-uid';
|
|
await _enableGuessRule(uid);
|
|
await _seedGuessUniverse();
|
|
|
|
final TradingPipeline pipeline = await _guessPipeline();
|
|
await pipeline.evaluate(uid);
|
|
|
|
final List<Map<String, dynamic>> open =
|
|
await testDb!.questionsDb.listUnansweredQuestions(uid);
|
|
final Map<String, dynamic>? updated =
|
|
await testDb!.questionsDb.submitAnswer(
|
|
questionId: open.single['id'] as String,
|
|
assignedUserId: uid,
|
|
userResponse: 10,
|
|
);
|
|
await pipeline.handleAnswer(
|
|
firebaseUid: uid,
|
|
answeredQuestion: updated!,
|
|
userResponse: 10,
|
|
);
|
|
|
|
final TradingEvaluationResult again = await pipeline.evaluate(uid);
|
|
expect(again.questionsCreated, 0);
|
|
expect(
|
|
again.rulesSkipped,
|
|
contains('guess_weekly_move(insufficientBars)'),
|
|
);
|
|
});
|
|
});
|
|
}
|