cyberhybridhub/server/test/trading/rule_engine_test.dart

177 lines
5.3 KiB
Dart

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(<String, dynamic>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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: <String, MarketDataSnapshot>{
'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(
<String, dynamic>{
'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: <String, MarketDataSnapshot>{},
now: now,
);
expect(result.fired, isFalse);
expect(result.skipReason, RuleSkipReason.unknownType);
});
});
}