Skip to main content

wickra_backtest_core/
rules.rs

1//! Evaluation of the strategy DSL ([`Operand`] / [`Condition`]) against the
2//! per-bar history. Operands resolve to a number (or `None` when an indicator
3//! is still warming up); conditions resolve to a boolean (false when any
4//! operand is missing). Cross conditions compare the current bar with the
5//! previous one.
6
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use crate::data::Candle;
11use crate::spec::{Condition, IntPredicate, Operand, OperandExpr, PriceField};
12
13/// One bar's computed state: the bar plus every ready indicator's value.
14#[derive(Debug, Clone)]
15pub struct BarRow {
16    /// The bar.
17    pub candle: Candle,
18    /// Values of indicators that have produced output this bar.
19    ///
20    /// The keys are shared with the engine rather than owned per row: a long run
21    /// otherwise allocated one fresh `String` per value per bar, and cloning an
22    /// `Arc<str>` is a refcount bump. Lookup is unchanged -- `Arc<str>` borrows
23    /// as `str`, so `values.get("ema_fast")` still works.
24    pub values: BTreeMap<Arc<str>, f64>,
25}
26
27/// Engine state the stateful conditions read.
28#[derive(Debug, Clone, Copy)]
29pub struct RuleState {
30    /// Whether a position is currently open.
31    pub in_position: bool,
32    /// Bars since the current position was entered (`None` when flat).
33    pub bars_since_entry: Option<u32>,
34}
35
36fn price(candle: &Candle, field: PriceField) -> f64 {
37    match field {
38        PriceField::Open => candle.open,
39        PriceField::High => candle.high,
40        PriceField::Low => candle.low,
41        PriceField::Close => candle.close,
42        PriceField::Volume => candle.volume,
43        PriceField::Hlc3 => candle.hlc3(),
44        PriceField::Ohlc4 => candle.ohlc4(),
45    }
46}
47
48/// Evaluate an operand at bar `idx`, or `None` if it cannot be resolved.
49pub fn eval_operand(op: &Operand, history: &[BarRow], idx: usize) -> Option<f64> {
50    let row = history.get(idx)?;
51    match op {
52        Operand::Const(v) => Some(*v),
53        // The engine inserts the primary value under the indicator name and each
54        // field under "name.field", so a full-name lookup resolves both.
55        Operand::Ref(name) => row.values.get(name.as_str()).copied(),
56        Operand::Expr(expr) => match expr.as_ref() {
57            OperandExpr::Price(field) => Some(price(&row.candle, *field)),
58            OperandExpr::Prev((inner, n)) => {
59                let i = idx.checked_sub(*n as usize)?;
60                eval_operand(inner, history, i)
61            }
62            OperandExpr::Add((a, b)) => binary(a, b, history, idx, |x, y| x + y),
63            OperandExpr::Sub((a, b)) => binary(a, b, history, idx, |x, y| x - y),
64            OperandExpr::Mul((a, b)) => binary(a, b, history, idx, |x, y| x * y),
65            OperandExpr::Div((a, b)) => binary(a, b, history, idx, |x, y| {
66                if y.abs() < f64::EPSILON {
67                    f64::NAN
68                } else {
69                    x / y
70                }
71            }),
72        },
73    }
74}
75
76fn binary(
77    lhs: &Operand,
78    rhs: &Operand,
79    history: &[BarRow],
80    idx: usize,
81    combine: impl Fn(f64, f64) -> f64,
82) -> Option<f64> {
83    Some(combine(
84        eval_operand(lhs, history, idx)?,
85        eval_operand(rhs, history, idx)?,
86    ))
87}
88
89fn compare(
90    lhs: &Operand,
91    rhs: &Operand,
92    history: &[BarRow],
93    idx: usize,
94    predicate: impl Fn(f64, f64) -> bool,
95) -> bool {
96    match (
97        eval_operand(lhs, history, idx),
98        eval_operand(rhs, history, idx),
99    ) {
100        (Some(left), Some(right)) => predicate(left, right),
101        _ => false,
102    }
103}
104
105fn cross(a: &Operand, b: &Operand, history: &[BarRow], idx: usize, above: bool) -> bool {
106    if idx == 0 {
107        return false;
108    }
109    let (Some(an), Some(bn)) = (eval_operand(a, history, idx), eval_operand(b, history, idx))
110    else {
111        return false;
112    };
113    let (Some(ap), Some(bp)) = (
114        eval_operand(a, history, idx - 1),
115        eval_operand(b, history, idx - 1),
116    ) else {
117        return false;
118    };
119    if above {
120        ap <= bp && an > bn
121    } else {
122        ap >= bp && an < bn
123    }
124}
125
126fn int_pred(pred: IntPredicate, n: u32) -> bool {
127    match pred {
128        IntPredicate::Gt(k) => n > k,
129        IntPredicate::Lt(k) => n < k,
130        IntPredicate::Ge(k) => n >= k,
131        IntPredicate::Le(k) => n <= k,
132        IntPredicate::Eq(k) => n == k,
133    }
134}
135
136/// Evaluate a condition at bar `idx`. Missing operands make a comparison false.
137pub fn eval_condition(cond: &Condition, history: &[BarRow], idx: usize, state: RuleState) -> bool {
138    match cond {
139        Condition::Gt((a, b)) => compare(a, b, history, idx, |x, y| x > y),
140        Condition::Lt((a, b)) => compare(a, b, history, idx, |x, y| x < y),
141        Condition::Ge((a, b)) => compare(a, b, history, idx, |x, y| x >= y),
142        Condition::Le((a, b)) => compare(a, b, history, idx, |x, y| x <= y),
143        Condition::Eq((a, b)) => compare(a, b, history, idx, |x, y| (x - y).abs() < f64::EPSILON),
144        Condition::Ne((a, b)) => compare(a, b, history, idx, |x, y| (x - y).abs() >= f64::EPSILON),
145        Condition::CrossAbove((a, b)) => cross(a, b, history, idx, true),
146        Condition::CrossBelow((a, b)) => cross(a, b, history, idx, false),
147        Condition::Between((a, lo, hi)) => {
148            match (
149                eval_operand(a, history, idx),
150                eval_operand(lo, history, idx),
151                eval_operand(hi, history, idx),
152            ) {
153                (Some(x), Some(l), Some(h)) => l <= x && x <= h,
154                _ => false,
155            }
156        }
157        Condition::Rising((a, n)) => compare_prev(a, *n, history, idx, |now, then| now > then),
158        Condition::Falling((a, n)) => compare_prev(a, *n, history, idx, |now, then| now < then),
159        Condition::All(cs) => cs.iter().all(|c| eval_condition(c, history, idx, state)),
160        Condition::Any(cs) => cs.iter().any(|c| eval_condition(c, history, idx, state)),
161        Condition::Not(c) => !eval_condition(c, history, idx, state),
162        Condition::InPosition(want) => state.in_position == *want,
163        Condition::BarsSinceEntry(pred) => {
164            state.bars_since_entry.is_some_and(|n| int_pred(*pred, n))
165        }
166    }
167}
168
169fn compare_prev(
170    a: &Operand,
171    n: u32,
172    history: &[BarRow],
173    idx: usize,
174    f: impl Fn(f64, f64) -> bool,
175) -> bool {
176    let Some(prev_idx) = idx.checked_sub(n as usize) else {
177        return false;
178    };
179    match (
180        eval_operand(a, history, idx),
181        eval_operand(a, history, prev_idx),
182    ) {
183        (Some(now), Some(then)) => f(now, then),
184        _ => false,
185    }
186}
187
188/// How many bars *before* the current one an operand can reach.
189///
190/// The evaluator indexes backwards from the bar being evaluated, so this is what
191/// a caller must retain to answer the same questions from a bounded window. It
192/// is defined here, beside the code that does the indexing, so the two cannot
193/// drift apart: a new backward-looking form has to be given a depth to compile.
194#[must_use]
195pub fn operand_lookback(op: &Operand) -> usize {
196    match op {
197        Operand::Ref(_) | Operand::Const(_) => 0,
198        Operand::Expr(expr) => match expr.as_ref() {
199            OperandExpr::Price(_) => 0,
200            // Nested `prev` compounds: prev(prev(x, 2), 3) reaches back five bars.
201            OperandExpr::Prev((inner, n)) => *n as usize + operand_lookback(inner),
202            OperandExpr::Add((a, b))
203            | OperandExpr::Sub((a, b))
204            | OperandExpr::Mul((a, b))
205            | OperandExpr::Div((a, b)) => operand_lookback(a).max(operand_lookback(b)),
206        },
207    }
208}
209
210/// How many bars *before* the current one a condition can reach.
211#[must_use]
212pub fn condition_lookback(cond: &Condition) -> usize {
213    match cond {
214        Condition::Gt((a, b))
215        | Condition::Lt((a, b))
216        | Condition::Ge((a, b))
217        | Condition::Le((a, b))
218        | Condition::Eq((a, b))
219        | Condition::Ne((a, b)) => operand_lookback(a).max(operand_lookback(b)),
220        // A cross compares this bar with the one before it, on both sides.
221        Condition::CrossAbove((a, b)) | Condition::CrossBelow((a, b)) => {
222            1 + operand_lookback(a).max(operand_lookback(b))
223        }
224        Condition::Between((a, lo, hi)) => operand_lookback(a)
225            .max(operand_lookback(lo))
226            .max(operand_lookback(hi)),
227        Condition::Rising((a, n)) | Condition::Falling((a, n)) => *n as usize + operand_lookback(a),
228        Condition::All(cs) | Condition::Any(cs) => {
229            cs.iter().map(condition_lookback).max().unwrap_or(0)
230        }
231        Condition::Not(c) => condition_lookback(c),
232        // Read from RuleState, not from history.
233        Condition::InPosition(_) | Condition::BarsSinceEntry(_) => 0,
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::spec::StrategySpec;
241
242    fn row(close: f64, vals: &[(&str, f64)]) -> BarRow {
243        BarRow {
244            candle: Candle {
245                time: 0,
246                open: close,
247                high: close,
248                low: close,
249                close,
250                volume: 0.0,
251            },
252            values: vals.iter().map(|(k, v)| (Arc::from(*k), *v)).collect(),
253        }
254    }
255
256    const STATE: RuleState = RuleState {
257        in_position: false,
258        bars_since_entry: None,
259    };
260
261    #[test]
262    fn const_and_ref() {
263        let h = vec![row(10.0, &[("ema", 5.0)])];
264        assert_eq!(eval_operand(&Operand::Const(3.0), &h, 0), Some(3.0));
265        assert_eq!(eval_operand(&Operand::Ref("ema".into()), &h, 0), Some(5.0));
266        assert_eq!(eval_operand(&Operand::Ref("missing".into()), &h, 0), None);
267    }
268
269    #[test]
270    fn price_and_prev() {
271        let h = vec![row(10.0, &[]), row(20.0, &[])];
272        let close = StrategySpec::parse(
273            r#"{"symbol":"x","timeframe":"1h","indicators":{},
274                "entry":{"gt":[{"price":"close"},{"prev":[{"price":"close"},1]}]},
275                "exit":{"in_position":true},"sizing":{"type":"fixed_qty","qty":1}}"#,
276        )
277        .unwrap();
278        assert!(eval_condition(&close.entry, &h, 1, STATE));
279        assert!(!eval_condition(&close.entry, &h, 0, STATE)); // no prev at idx 0
280    }
281
282    #[test]
283    fn cross_above_detects_crossing() {
284        // fast crosses slow from below at idx 1.
285        let h = vec![
286            row(0.0, &[("f", 1.0), ("s", 2.0)]),
287            row(0.0, &[("f", 3.0), ("s", 2.0)]),
288        ];
289        let cond = Condition::CrossAbove((Operand::Ref("f".into()), Operand::Ref("s".into())));
290        assert!(!eval_condition(&cond, &h, 0, STATE));
291        assert!(eval_condition(&cond, &h, 1, STATE));
292    }
293
294    #[test]
295    fn all_any_not() {
296        let h = vec![row(0.0, &[("a", 5.0)])];
297        let gt = Condition::Gt((Operand::Ref("a".into()), Operand::Const(1.0)));
298        let lt = Condition::Lt((Operand::Ref("a".into()), Operand::Const(1.0)));
299        assert!(eval_condition(
300            &Condition::All(vec![gt.clone()]),
301            &h,
302            0,
303            STATE
304        ));
305        assert!(eval_condition(
306            &Condition::Any(vec![lt.clone(), gt.clone()]),
307            &h,
308            0,
309            STATE
310        ));
311        assert!(eval_condition(&Condition::Not(Box::new(lt)), &h, 0, STATE));
312    }
313
314    fn ohlcv_row(open: f64, high: f64, low: f64, close: f64, volume: f64) -> BarRow {
315        BarRow {
316            candle: Candle {
317                time: 0,
318                open,
319                high,
320                low,
321                close,
322                volume,
323            },
324            values: BTreeMap::new(),
325        }
326    }
327
328    fn px(field: PriceField) -> Operand {
329        Operand::Expr(Box::new(OperandExpr::Price(field)))
330    }
331
332    fn expr(e: OperandExpr) -> Operand {
333        Operand::Expr(Box::new(e))
334    }
335
336    #[test]
337    fn all_price_fields_resolve() {
338        let h = vec![ohlcv_row(10.0, 20.0, 5.0, 15.0, 100.0)];
339        let at = |f| eval_operand(&px(f), &h, 0).unwrap();
340        assert!((at(PriceField::Open) - 10.0).abs() < 1e-9);
341        assert!((at(PriceField::High) - 20.0).abs() < 1e-9);
342        assert!((at(PriceField::Low) - 5.0).abs() < 1e-9);
343        assert!((at(PriceField::Close) - 15.0).abs() < 1e-9);
344        assert!((at(PriceField::Volume) - 100.0).abs() < 1e-9);
345        assert!((at(PriceField::Hlc3) - (20.0 + 5.0 + 15.0) / 3.0).abs() < 1e-9);
346        assert!((at(PriceField::Ohlc4) - (10.0 + 20.0 + 5.0 + 15.0) / 4.0).abs() < 1e-9);
347    }
348
349    #[test]
350    fn arithmetic_operands() {
351        let h = vec![ohlcv_row(0.0, 0.0, 0.0, 0.0, 0.0)];
352        let c = |v| Operand::Const(v);
353        let ev = |e| eval_operand(&expr(e), &h, 0);
354        assert_eq!(
355            ev(OperandExpr::Add((Box::new(c(2.0)), Box::new(c(3.0))))),
356            Some(5.0)
357        );
358        assert_eq!(
359            ev(OperandExpr::Sub((Box::new(c(2.0)), Box::new(c(3.0))))),
360            Some(-1.0)
361        );
362        assert_eq!(
363            ev(OperandExpr::Mul((Box::new(c(2.0)), Box::new(c(3.0))))),
364            Some(6.0)
365        );
366        assert_eq!(
367            ev(OperandExpr::Div((Box::new(c(6.0)), Box::new(c(3.0))))),
368            Some(2.0)
369        );
370        // Division by zero yields NaN, not a panic.
371        let nan = ev(OperandExpr::Div((Box::new(c(1.0)), Box::new(c(0.0))))).unwrap();
372        assert!(nan.is_nan());
373    }
374
375    #[test]
376    fn arithmetic_with_missing_operand_is_none() {
377        let h = vec![ohlcv_row(0.0, 0.0, 0.0, 0.0, 0.0)];
378        let miss = Box::new(Operand::Ref("nope".into()));
379        let got = eval_operand(
380            &expr(OperandExpr::Add((miss, Box::new(Operand::Const(1.0))))),
381            &h,
382            0,
383        );
384        assert_eq!(got, None);
385    }
386
387    #[test]
388    fn prev_underflow_is_none() {
389        let h = vec![ohlcv_row(0.0, 0.0, 0.0, 0.0, 0.0)];
390        let got = eval_operand(
391            &expr(OperandExpr::Prev((Box::new(px(PriceField::Close)), 3))),
392            &h,
393            0,
394        );
395        assert_eq!(got, None);
396    }
397
398    #[test]
399    fn comparisons_ge_le_eq_ne() {
400        let h = vec![row(0.0, &[("a", 2.0)])];
401        let a = Operand::Ref("a".into());
402        let two = Operand::Const(2.0);
403        let three = Operand::Const(3.0);
404        assert!(eval_condition(
405            &Condition::Ge((a.clone(), two.clone())),
406            &h,
407            0,
408            STATE
409        ));
410        assert!(eval_condition(
411            &Condition::Le((a.clone(), two.clone())),
412            &h,
413            0,
414            STATE
415        ));
416        assert!(eval_condition(
417            &Condition::Eq((a.clone(), two.clone())),
418            &h,
419            0,
420            STATE
421        ));
422        assert!(eval_condition(
423            &Condition::Ne((a.clone(), three)),
424            &h,
425            0,
426            STATE
427        ));
428        // A missing operand makes any comparison false.
429        let miss = Operand::Ref("x".into());
430        assert!(!eval_condition(&Condition::Gt((miss, two)), &h, 0, STATE));
431    }
432
433    #[test]
434    fn cross_below_detects_crossing() {
435        let h = vec![
436            row(0.0, &[("f", 3.0), ("s", 2.0)]),
437            row(0.0, &[("f", 1.0), ("s", 2.0)]),
438        ];
439        let cond = Condition::CrossBelow((Operand::Ref("f".into()), Operand::Ref("s".into())));
440        assert!(!eval_condition(&cond, &h, 0, STATE));
441        assert!(eval_condition(&cond, &h, 1, STATE));
442    }
443
444    #[test]
445    fn between_inside_and_outside() {
446        let h = vec![row(0.0, &[("a", 5.0)])];
447        let a = Operand::Ref("a".into());
448        let inside = Condition::Between((a.clone(), Operand::Const(1.0), Operand::Const(10.0)));
449        let outside = Condition::Between((a.clone(), Operand::Const(6.0), Operand::Const(10.0)));
450        assert!(eval_condition(&inside, &h, 0, STATE));
451        assert!(!eval_condition(&outside, &h, 0, STATE));
452        // Missing operand -> false.
453        let miss = Condition::Between((
454            Operand::Ref("x".into()),
455            Operand::Const(1.0),
456            Operand::Const(10.0),
457        ));
458        assert!(!eval_condition(&miss, &h, 0, STATE));
459    }
460
461    #[test]
462    fn rising_and_falling() {
463        let up = vec![row(0.0, &[("a", 1.0)]), row(0.0, &[("a", 2.0)])];
464        let down = vec![row(0.0, &[("a", 2.0)]), row(0.0, &[("a", 1.0)])];
465        let a = Operand::Ref("a".into());
466        assert!(eval_condition(
467            &Condition::Rising((a.clone(), 1)),
468            &up,
469            1,
470            STATE
471        ));
472        assert!(eval_condition(
473            &Condition::Falling((a.clone(), 1)),
474            &down,
475            1,
476            STATE
477        ));
478        // Not enough history -> false.
479        assert!(!eval_condition(&Condition::Rising((a, 5)), &up, 1, STATE));
480    }
481
482    #[test]
483    fn in_position_and_bars_since_entry() {
484        let h = vec![row(0.0, &[])];
485        let open_state = RuleState {
486            in_position: true,
487            bars_since_entry: Some(5),
488        };
489        assert!(eval_condition(
490            &Condition::InPosition(true),
491            &h,
492            0,
493            open_state
494        ));
495        assert!(!eval_condition(&Condition::InPosition(true), &h, 0, STATE));
496
497        // Each integer predicate against bars_since_entry = 5.
498        let cases = [
499            (IntPredicate::Gt(4), true),
500            (IntPredicate::Lt(6), true),
501            (IntPredicate::Ge(5), true),
502            (IntPredicate::Le(5), true),
503            (IntPredicate::Eq(5), true),
504            (IntPredicate::Eq(4), false),
505        ];
506        for (pred, want) in cases {
507            assert_eq!(
508                eval_condition(&Condition::BarsSinceEntry(pred), &h, 0, open_state),
509                want
510            );
511        }
512        // Flat: bars_since_entry is None -> always false.
513        assert!(!eval_condition(
514            &Condition::BarsSinceEntry(IntPredicate::Ge(0)),
515            &h,
516            0,
517            STATE
518        ));
519    }
520}