203 lines
6.7 KiB
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,
|
|
);
|
|
}
|
|
}
|
|
}
|