import 'package:cyberhybridhub_server/trading/market_data_db.dart'; import 'package:cyberhybridhub_server/trading/rule_engine.dart'; import 'package:cyberhybridhub_server/trading/trading_config.dart'; import 'package:test/test.dart'; void main() { late TradingRuleConfig dipRule; late DateTime now; setUp(() { dipRule = TradingRuleConfig.fromJson({ 'id': 'dip_confirm', 'type': 'price_below_pct_of_ref', 'symbol': 'SPY', 'ref_metric': 'prev_close', 'threshold_pct': -1.5, 'question_template': '{{symbol}} is down {{pct}}% at \${{price}}. Buy?', 'max_staleness_seconds': 600, }); now = DateTime.utc(2026, 5, 25, 14, 30); }); MarketDataSnapshot snapshot( String metric, num price, { DateTime? asOf, }) { return MarketDataSnapshot( symbol: 'SPY', metric: metric, asOf: (asOf ?? now).toUtc(), price: price, ); } group('RuleEngine.evaluate price_below_pct_of_ref', () { test('fires when last_trade is 1.6% below prev_close', () { final RuleEngine engine = RuleEngine(); final RuleEvaluation result = engine.evaluate( rule: dipRule, snapshots: { 'last_trade': snapshot('last_trade', 492), 'prev_close': snapshot('prev_close', 500), }, now: now, ); expect(result.fired, isTrue); expect(result.pricePct, closeTo(-1.6, 0.01)); expect(result.refPrice, 500); expect(result.observedPrice, 492); expect( result.questionText, 'SPY is down 1.60% at \$492.00. Buy?', ); }); test('does not fire above threshold (-0.5%)', () { final RuleEngine engine = RuleEngine(); final RuleEvaluation result = engine.evaluate( rule: dipRule, snapshots: { 'last_trade': snapshot('last_trade', 497.5), 'prev_close': snapshot('prev_close', 500), }, now: now, ); expect(result.fired, isFalse); expect(result.skipReason, RuleSkipReason.aboveThreshold); expect(result.pricePct, closeTo(-0.5, 0.01)); }); test('does not fire when last_trade snapshot missing', () { final RuleEngine engine = RuleEngine(); final RuleEvaluation result = engine.evaluate( rule: dipRule, snapshots: { 'prev_close': snapshot('prev_close', 500), }, now: now, ); expect(result.fired, isFalse); expect(result.skipReason, RuleSkipReason.missingMetric); }); test('does not fire when last_trade is older than max_staleness_seconds', () { final RuleEngine engine = RuleEngine(); final RuleEvaluation result = engine.evaluate( rule: dipRule, snapshots: { 'last_trade': snapshot( 'last_trade', 492, asOf: now.subtract(const Duration(minutes: 30)), ), 'prev_close': snapshot('prev_close', 500), }, now: now, ); expect(result.fired, isFalse); expect(result.skipReason, RuleSkipReason.staleData); }); test('does not fire when cooldown matches same UTC day', () { final RuleEngine engine = RuleEngine(); final RuleEvaluation result = engine.evaluate( rule: dipRule, snapshots: { 'last_trade': snapshot('last_trade', 492), 'prev_close': snapshot('prev_close', 500), }, lastFiredAt: now.subtract(const Duration(hours: 2)), now: now, ); expect(result.fired, isFalse); expect(result.skipReason, RuleSkipReason.cooldown); }); test('fires when cooldown was on a previous UTC day', () { final RuleEngine engine = RuleEngine(); final RuleEvaluation result = engine.evaluate( rule: dipRule, snapshots: { 'last_trade': snapshot('last_trade', 492), 'prev_close': snapshot('prev_close', 500), }, lastFiredAt: now.subtract(const Duration(days: 1, hours: 2)), now: now, ); expect(result.fired, isTrue); }); test('refuses zero reference price (would divide by zero)', () { final RuleEngine engine = RuleEngine(); final RuleEvaluation result = engine.evaluate( rule: dipRule, snapshots: { 'last_trade': snapshot('last_trade', 492), 'prev_close': snapshot('prev_close', 0), }, now: now, ); expect(result.fired, isFalse); expect(result.skipReason, RuleSkipReason.zeroReferencePrice); }); test('unknown rule type is skipped', () { final TradingRuleConfig unknown = TradingRuleConfig.fromJson( { 'id': 'foo', 'type': 'momentum_breakout', 'symbol': 'SPY', 'threshold_pct': 1, 'question_template': 'n/a', }, ); final RuleEngine engine = RuleEngine(); final RuleEvaluation result = engine.evaluate( rule: unknown, snapshots: {}, now: now, ); expect(result.fired, isFalse); expect(result.skipReason, RuleSkipReason.unknownType); }); }); }