177 lines
5.3 KiB
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);
|
|
});
|
|
});
|
|
}
|