281 lines
7.5 KiB
Dart
281 lines
7.5 KiB
Dart
import 'market_data_db.dart';
|
|
import 'market_history_query.dart';
|
|
import 'trading_config.dart';
|
|
|
|
/// Why a rule did not fire. `null` means the rule fired.
|
|
enum RuleSkipReason {
|
|
unknownType,
|
|
missingMetric,
|
|
staleData,
|
|
aboveThreshold,
|
|
cooldown,
|
|
zeroReferencePrice,
|
|
insufficientBars,
|
|
}
|
|
|
|
/// 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,
|
|
this.symbolToken,
|
|
this.guessSymbol,
|
|
this.correctAnswer,
|
|
this.refDaysAgo,
|
|
});
|
|
|
|
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;
|
|
|
|
/// Obfuscated ticker token for `guess_weekly_move` (e.g. `ASSET_A`).
|
|
final String? symbolToken;
|
|
|
|
/// Real symbol stored server-side only (not in [questionText]).
|
|
final String? guessSymbol;
|
|
|
|
/// Expected swipe answer: `10` (up) or `-10` (down) for guess rules.
|
|
final num? correctAnswer;
|
|
|
|
/// Days ago label for the reference close in guess templates.
|
|
final int? refDaysAgo;
|
|
}
|
|
|
|
/// 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,
|
|
WeeklyMover? weeklyMover,
|
|
String? symbolToken,
|
|
}) {
|
|
final DateTime evaluatedAt = (now ?? _clock()).toUtc();
|
|
|
|
if (rule.type == 'guess_weekly_move') {
|
|
return evaluateGuessWeeklyMove(
|
|
rule: rule,
|
|
mover: weeklyMover,
|
|
symbolToken: symbolToken,
|
|
lastFiredAt: lastFiredAt,
|
|
now: now,
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
/// Evaluates [guess_weekly_move] using a pre-selected [mover].
|
|
RuleEvaluation evaluateGuessWeeklyMove({
|
|
required TradingRuleConfig rule,
|
|
required WeeklyMover? mover,
|
|
required String? symbolToken,
|
|
DateTime? lastFiredAt,
|
|
DateTime? now,
|
|
}) {
|
|
final DateTime evaluatedAt = (now ?? _clock()).toUtc();
|
|
|
|
if (rule.type != 'guess_weekly_move') {
|
|
return RuleEvaluation(
|
|
rule: rule,
|
|
fired: false,
|
|
skipReason: RuleSkipReason.unknownType,
|
|
);
|
|
}
|
|
|
|
if (mover == null || symbolToken == null) {
|
|
return RuleEvaluation(
|
|
rule: rule,
|
|
fired: false,
|
|
skipReason: RuleSkipReason.insufficientBars,
|
|
);
|
|
}
|
|
|
|
final num refPrice = mover.openClose;
|
|
final num currentClose = mover.currentClose;
|
|
if (refPrice == 0) {
|
|
return RuleEvaluation(
|
|
rule: rule,
|
|
fired: false,
|
|
skipReason: RuleSkipReason.zeroReferencePrice,
|
|
);
|
|
}
|
|
|
|
final num correctAnswer =
|
|
currentClose > refPrice ? 10 : (currentClose < refPrice ? -10 : 10);
|
|
final int refDaysAgo = mover.days;
|
|
|
|
final String questionText = _renderGuessTemplate(
|
|
rule.questionTemplate,
|
|
token: symbolToken,
|
|
refPrice: refPrice,
|
|
refDaysAgo: refDaysAgo,
|
|
);
|
|
|
|
return RuleEvaluation(
|
|
rule: rule,
|
|
fired: true,
|
|
refPrice: refPrice,
|
|
observedPrice: currentClose,
|
|
questionText: questionText,
|
|
symbolToken: symbolToken,
|
|
guessSymbol: mover.symbol,
|
|
correctAnswer: correctAnswer,
|
|
refDaysAgo: refDaysAgo,
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
String _renderGuessTemplate(
|
|
String template, {
|
|
required String token,
|
|
required num refPrice,
|
|
required int refDaysAgo,
|
|
}) {
|
|
return template
|
|
.replaceAll('{{token}}', token)
|
|
.replaceAll('{{ref_price}}', _formatPrice(refPrice))
|
|
.replaceAll('{{ref_days_ago}}', refDaysAgo.toString());
|
|
}
|
|
}
|