cyberhybridhub/server/lib/trading/trading_dev_actions.dart

203 lines
6.7 KiB
Dart

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<String, dynamic> toJson() => <String, dynamic>{
'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<SeededSnapshot> 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<String, dynamic> toJson() => <String, dynamic>{
'snapshots': snapshots.map((SeededSnapshot s) => s.toJson()).toList(),
'evaluation': evaluation == null
? null
: <String, dynamic>{
'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<ForceFireResult> forceFireDip(String firebaseUid) async {
final EffectiveTradingConfig? config =
await _tradingConfigDb.resolveEffectiveConfig(firebaseUid);
if (config == null) {
return ForceFireResult(
snapshots: <SeededSnapshot>[],
evaluation: null,
skipReason: 'no_config',
);
}
if (!config.enabled) {
return ForceFireResult(
snapshots: <SeededSnapshot>[],
evaluation: null,
skipReason: 'disabled',
);
}
final List<SeededSnapshot> seeded = <SeededSnapshot>[];
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<void> _cancelOpenTradingQuestions(String firebaseUid) async {
final List<Map<String, dynamic>> open =
await _questionsDb.listUnansweredQuestions(firebaseUid);
for (final Map<String, dynamic> q in open) {
if (q['pipelineKey'] != 'trading') continue;
await _questionsDb.submitAnswer(
questionId: q['id']! as String,
assignedUserId: firebaseUid,
userResponse: 0,
);
}
}
}