import '../questions_db.dart'; import 'market_data_db.dart'; import 'trading_config.dart'; import 'trading_config_db.dart'; import 'trading_pipeline.dart'; /// Snapshot row seeded by [TradingDevActions.forceFireDip], returned for UI/CLI feedback. class SeededSnapshot { SeededSnapshot({ required this.symbol, required this.metric, required this.price, required this.asOf, required this.created, }); final String symbol; final String metric; final num price; final DateTime asOf; /// `true` if this snapshot was newly inserted; `false` if a fresh one already /// existed and was reused. final bool created; Map toJson() => { 'symbol': symbol, 'metric': metric, 'price': price, 'asOf': asOf.toIso8601String(), 'created': created, }; } /// Outcome of [TradingDevActions.forceFireDip]. class ForceFireResult { ForceFireResult({ required this.snapshots, required this.evaluation, this.skipReason, }); /// Snapshots that were inserted or reused for the forced dip. final List snapshots; /// `null` when the config was disabled or had no dip rule. final TradingEvaluationResult? evaluation; /// Non-null when the action short-circuited (e.g. config disabled). final String? skipReason; Map toJson() => { 'snapshots': snapshots.map((SeededSnapshot s) => s.toJson()).toList(), 'evaluation': evaluation == null ? null : { 'questionsCreated': evaluation!.questionsCreated, 'rulesFired': evaluation!.rulesFired, 'rulesSkipped': evaluation!.rulesSkipped, }, if (skipReason != null) 'skipReason': skipReason, }; } /// Dev-only utilities that bypass market hours / real Alpaca data to exercise /// the trading flow end-to-end through the UI. /// /// Used by [tradingDevHandler] when `TRADING_DEV_ENDPOINTS_ENABLED=true`. class TradingDevActions { TradingDevActions({ required QuestionsDb questionsDb, required MarketDataDb marketDataDb, required TradingConfigDb tradingConfigDb, required TradingPipeline tradingPipeline, DateTime Function()? clock, num syntheticRefPrice = 500, num syntheticOvershootPct = 0.5, }) : _questionsDb = questionsDb, _marketDataDb = marketDataDb, _tradingConfigDb = tradingConfigDb, _tradingPipeline = tradingPipeline, _clock = clock ?? DateTime.now, _syntheticRefPrice = syntheticRefPrice, _syntheticOvershootPct = syntheticOvershootPct; final QuestionsDb _questionsDb; final MarketDataDb _marketDataDb; final TradingConfigDb _tradingConfigDb; final TradingPipeline _tradingPipeline; final DateTime Function() _clock; final num _syntheticRefPrice; final num _syntheticOvershootPct; /// Seeds dipped snapshots for every `price_below_pct_of_ref` rule in the /// user's effective config, then immediately runs [TradingPipeline.evaluate]. /// /// For each rule, the dipped `last_trade` is positioned /// [syntheticOvershootPct] percentage points beyond the rule's threshold /// so the rule unambiguously fires. The `ref_metric` snapshot is reused when /// a fresh one already exists, otherwise a synthetic one is inserted. Future forceFireDip(String firebaseUid) async { final EffectiveTradingConfig? config = await _tradingConfigDb.resolveEffectiveConfig(firebaseUid); if (config == null) { return ForceFireResult( snapshots: [], evaluation: null, skipReason: 'no_config', ); } if (!config.enabled) { return ForceFireResult( snapshots: [], evaluation: null, skipReason: 'disabled', ); } final List seeded = []; final DateTime now = _clock().toUtc(); // Ensure no stale unanswered trading question is parked on the queue so // the open-question guard in TradingPipeline doesn't suppress the dev fire. await _cancelOpenTradingQuestions(firebaseUid); for (final TradingRuleConfig rule in config.rules) { if (rule.type != 'price_below_pct_of_ref') continue; final MarketDataSnapshot? existingRef = await _marketDataDb.latestForSymbol(rule.symbol, rule.refMetric); late final num refPrice; if (existingRef != null && existingRef.price != null) { refPrice = existingRef.price!; seeded.add(SeededSnapshot( symbol: rule.symbol, metric: rule.refMetric, price: refPrice, asOf: existingRef.asOf, created: false, )); } else { refPrice = _syntheticRefPrice; final DateTime refAsOf = now.subtract(const Duration(days: 1)); await _marketDataDb.insertSnapshot( symbol: rule.symbol, metric: rule.refMetric, price: refPrice, asOf: refAsOf, ); seeded.add(SeededSnapshot( symbol: rule.symbol, metric: rule.refMetric, price: refPrice, asOf: refAsOf, created: true, )); } // Compute a last_trade price `overshoot` pct beyond the threshold so // the rule fires unambiguously. Example: thresholdPct=-1.5, // overshoot=0.5 → last_trade is 2.0% below ref. final num targetPct = rule.thresholdPct - _syntheticOvershootPct; final num lastTradePrice = refPrice * (1 + targetPct / 100); final DateTime tradeAsOf = now.subtract(const Duration(seconds: 30)); await _marketDataDb.insertSnapshot( symbol: rule.symbol, metric: 'last_trade', price: lastTradePrice, asOf: tradeAsOf, ); seeded.add(SeededSnapshot( symbol: rule.symbol, metric: 'last_trade', price: lastTradePrice, asOf: tradeAsOf, created: true, )); } final TradingEvaluationResult evaluation = await _tradingPipeline.evaluate(firebaseUid); return ForceFireResult(snapshots: seeded, evaluation: evaluation); } /// Marks any unanswered `pipeline_key=trading` question with `user_response=0` /// so a re-fire isn't blocked by the open-question guard. (Skipped answers /// don't stage orders.) Future _cancelOpenTradingQuestions(String firebaseUid) async { final List> open = await _questionsDb.listUnansweredQuestions(firebaseUid); for (final Map q in open) { if (q['pipelineKey'] != 'trading') continue; await _questionsDb.submitAnswer( questionId: q['id']! as String, assignedUserId: firebaseUid, userResponse: 0, ); } } }