179 lines
4.8 KiB
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);
|
|
}
|
|
}
|