cyberhybridhub/server/lib/trading/rule_engine.dart

179 lines
4.8 KiB
Dart

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<String, MarketDataSnapshot> 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);
}
}