import 'market_data_db.dart'; import 'trading_config.dart'; /// Why a rule did not fire. `null` means the rule fired. enum RuleSkipReason { unknownType, missingMetric, staleData, aboveThreshold, cooldown, zeroReferencePrice, } /// Result of evaluating a single [TradingRuleConfig] against snapshots. class RuleEvaluation { RuleEvaluation({ required this.rule, required this.fired, this.skipReason, this.pricePct, this.refPrice, this.observedPrice, this.questionText, this.asOf, }); final TradingRuleConfig rule; final bool fired; final RuleSkipReason? skipReason; /// Signed pct change between observed and reference (e.g. -1.6 for a 1.6% dip). final num? pricePct; final num? refPrice; final num? observedPrice; /// Template-substituted question text (only when [fired] is true). final String? questionText; /// Most recent `as_of` across the snapshots used. final DateTime? asOf; } /// Pure evaluation of trading rules over [MarketDataSnapshot] inputs. /// /// Inputs are pre-fetched snapshots so this layer never touches the network /// or DB. The caller (`TradingPipeline`) decides what to do with results. class RuleEngine { RuleEngine({DateTime Function()? clock}) : _clock = clock ?? DateTime.now; final DateTime Function() _clock; /// Evaluates [rule] against [snapshots] keyed by metric. /// /// [lastFiredAt] is the last time this rule fired for the user; pass `null` /// when the rule has never fired (or in tests). [lastFiredAt] within the /// same UTC date as `now` means the rule is on cooldown. RuleEvaluation evaluate({ required TradingRuleConfig rule, required Map snapshots, DateTime? lastFiredAt, DateTime? now, }) { final DateTime evaluatedAt = (now ?? _clock()).toUtc(); if (rule.type != 'price_below_pct_of_ref') { return RuleEvaluation( rule: rule, fired: false, skipReason: RuleSkipReason.unknownType, ); } if (_isCooldown(lastFiredAt, evaluatedAt)) { return RuleEvaluation( rule: rule, fired: false, skipReason: RuleSkipReason.cooldown, ); } final MarketDataSnapshot? observed = snapshots['last_trade']; final MarketDataSnapshot? reference = snapshots[rule.refMetric]; if (observed == null || reference == null || observed.price == null || reference.price == null) { return RuleEvaluation( rule: rule, fired: false, skipReason: RuleSkipReason.missingMetric, ); } if (reference.price == 0) { return RuleEvaluation( rule: rule, fired: false, skipReason: RuleSkipReason.zeroReferencePrice, ); } final num refPrice = reference.price!; final num observedPrice = observed.price!; final Duration age = evaluatedAt.difference(observed.asOf); if (age.inSeconds > rule.maxStalenessSeconds || age.isNegative && age.inSeconds.abs() > rule.maxStalenessSeconds) { return RuleEvaluation( rule: rule, fired: false, skipReason: RuleSkipReason.staleData, refPrice: refPrice, observedPrice: observedPrice, asOf: observed.asOf, ); } final num pricePct = ((observedPrice - refPrice) / refPrice) * 100; final bool fires = pricePct <= rule.thresholdPct; if (!fires) { return RuleEvaluation( rule: rule, fired: false, skipReason: RuleSkipReason.aboveThreshold, pricePct: pricePct, refPrice: refPrice, observedPrice: observedPrice, asOf: observed.asOf, ); } final String questionText = _renderTemplate( rule.questionTemplate, symbol: rule.symbol, price: observedPrice, pct: pricePct, refPrice: refPrice, ); return RuleEvaluation( rule: rule, fired: true, pricePct: pricePct, refPrice: refPrice, observedPrice: observedPrice, questionText: questionText, asOf: observed.asOf, ); } bool _isCooldown(DateTime? lastFiredAt, DateTime now) { if (lastFiredAt == null) { return false; } final DateTime last = lastFiredAt.toUtc(); return last.year == now.year && last.month == now.month && last.day == now.day; } String _renderTemplate( String template, { required String symbol, required num price, required num pct, required num refPrice, }) { return template .replaceAll('{{symbol}}', symbol) .replaceAll('{{price}}', _formatPrice(price)) .replaceAll('{{pct}}', _formatPct(pct)) .replaceAll('{{ref_price}}', _formatPrice(refPrice)); } String _formatPrice(num value) => value.toStringAsFixed(2); String _formatPct(num value) { final num abs = value.abs(); return abs.toStringAsFixed(abs < 10 ? 2 : 1); } }