wickra-benchmark-core 0.1.2

Deterministic core of wickra-benchmark: recompute a curated (strategy, dataset) case with the pinned wickra-backtest engine and confirm its report and hash against the frozen expectation.
Documentation
//! Pin the indicator behaviour the corpus depends on.
//!
//! Nothing here computes indicators. The recompute path goes
//! `run_case` -> `wickra-backtest-core` -> its own registry, which resolves a
//! strategy's indicator names internally; `wickra-benchmark-core` never sees an
//! `Indicator`. But every frozen `expected_hash` in `cases/` is downstream of
//! this arithmetic, so when a hash moves after a dependency bump there are two
//! candidates and no way to tell them apart from the report alone:
//!
//!   * the engine changed how it turns signals into trades, or
//!   * an indicator changed the numbers those signals are made of.
//!
//! Diffing a recomputed report answers neither. This does: it drives the nine
//! indicator families the committed cases actually name, straight from
//! `wickra-core`, against hand-checkable inputs. If it fails alongside a moved
//! hash, the cause is the indicator core. If it passes, the cause is above it.
//!
//! Deliberately only those nine. A test that pinned the whole catalogue would
//! fail on indicators no case here uses, which is somebody else's regression
//! reported in the wrong repository. The list grows when a case introduces a
//! family, and `assert_families_are_covered` is what makes that happen: it reads
//! `cases/` and fails if a case names a family this file does not pin.

// Every float compared here is exact by construction, not the result of
// accumulated arithmetic: Donchian *selects* an input high or low rather than
// computing one, and the moving averages are checked on inputs whose means are
// exactly representable ((1+2+3)/3, (1*0+2*0+3*6)/6, and the mean of a
// constant). The rest are zeroes and integers reached the same way: a constant
// true range averages to itself, a flat series has no rate of change and no
// deviation, and two averages of one constant cannot differ. An epsilon would be
// the weaker assertion -- it would pass on a value that drifted.
#![allow(clippy::float_cmp)]

use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::Value;
use wickra_core::{
    Atr, BollingerBands, Candle, Donchian, Ema, Indicator, MacdIndicator, Roc, Rsi, Sma, Wma,
};

/// Every family this file pins. Kept beside the tests that pin them so the two
/// cannot drift apart silently.
///
/// These are the names a case writes in its `type` field, which is the spec's
/// vocabulary rather than the crate's: `"Macd"` is the spec alias the engine
/// resolves to `wickra_core::MacdIndicator`.
const PINNED: [&str; 9] = [
    "Atr",
    "BollingerBands",
    "Donchian",
    "Ema",
    "Macd",
    "Roc",
    "Rsi",
    "Sma",
    "Wma",
];

fn repo_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}

/// A flat series is the one input whose every indicator output can be reasoned
/// about without a spreadsheet: a moving average of a constant is that constant,
/// and a series that never moves has no gains and no losses.
const FLAT: f64 = 100.0;

#[test]
fn sma_averages_its_window() {
    let mut sma = Sma::new(3).expect("period 3 is valid");
    assert_eq!(sma.warmup_period(), 3);
    assert_eq!(sma.update(1.0), None, "no value before the window is full");
    assert_eq!(sma.update(2.0), None);
    // (1 + 2 + 3) / 3
    assert_eq!(sma.update(3.0), Some(2.0));
    // The window slides: (2 + 3 + 4) / 3
    assert_eq!(sma.update(4.0), Some(3.0));

    let mut flat = Sma::new(10).expect("period 10 is valid");
    for _ in 0..9 {
        flat.update(FLAT);
    }
    assert_eq!(
        flat.update(FLAT),
        Some(FLAT),
        "the mean of a constant is that constant"
    );
}

#[test]
fn ema_starts_at_the_seed_and_weights_the_newest_input() {
    let mut ema = Ema::new(5).expect("period 5 is valid");
    assert_eq!(ema.warmup_period(), 5);

    // A constant series must stay at that constant, whatever the smoothing.
    for _ in 0..4 {
        ema.update(FLAT);
    }
    assert_eq!(ema.update(FLAT), Some(FLAT));

    // Then a step up moves the average toward it without reaching it: the
    // property the crossover cases in the corpus depend on.
    let stepped = ema.update(200.0).expect("ready");
    assert!(
        stepped > FLAT && stepped < 200.0,
        "an EMA must move toward a step without reaching it, got {stepped}"
    );
}

#[test]
fn rsi_is_bounded_and_saturates() {
    let mut rsi = Rsi::new(14).expect("period 14 is valid");
    // 15, not 14: RSI(14) is computed from 14 deltas, and 14 deltas need 15
    // prices. Worth pinning precisely because it is the one warm-up here that
    // does not equal its period -- a case sized against the period alone would
    // be one bar short.
    assert_eq!(rsi.warmup_period(), 15);

    // A strictly rising series has no losses, so RSI pins to its ceiling.
    let mut last = None;
    for i in 0..40 {
        last = rsi.update(100.0 + f64::from(i));
    }
    let value = last.expect("ready after 40 inputs");
    assert!(
        (value - 100.0).abs() < 1e-9,
        "a series with no losses must sit at 100, got {value}"
    );

    // And the range holds on a series that moves both ways.
    let mut mixed = Rsi::new(14).expect("period 14 is valid");
    for i in 0..60 {
        if let Some(v) = mixed.update(100.0 + (f64::from(i) * 0.4).sin() * 8.0) {
            assert!((0.0..=100.0).contains(&v), "RSI left its range: {v}");
        }
    }
}

#[test]
fn donchian_tracks_the_extremes_of_its_window() {
    let mut donchian = Donchian::new(3).expect("period 3 is valid");
    assert_eq!(donchian.warmup_period(), 3);

    // Candle is non-exhaustive and validated, so it is built through its
    // constructor rather than as a struct literal.
    let candle = |high: f64, low: f64| {
        Candle::new(low, high, low, high, 0.0, 0).expect("high >= low is a valid candle")
    };

    assert!(donchian.update(candle(10.0, 5.0)).is_none());
    assert!(donchian.update(candle(12.0, 4.0)).is_none());
    let out = donchian
        .update(candle(11.0, 6.0))
        .expect("ready on the third");
    assert_eq!(out.upper, 12.0, "upper is the highest high in the window");
    assert_eq!(out.lower, 4.0, "lower is the lowest low in the window");

    // The window slides, so the old extremes leave it.
    let out = donchian.update(candle(9.0, 7.0)).expect("ready");
    assert_eq!(out.upper, 12.0);
    assert_eq!(out.lower, 4.0);
    let out = donchian.update(candle(9.5, 7.5)).expect("ready");
    assert_eq!(out.upper, 11.0, "the 12.0 bar has left the window");
    assert_eq!(out.lower, 6.0, "so has the 4.0 bar");
}

#[test]
fn reset_returns_an_indicator_to_its_starting_state() {
    // A case is recomputed from a fresh handle every time, so this is the
    // property that lets the suite runner reuse nothing between cases.
    let mut sma = Sma::new(3).expect("period 3 is valid");
    for value in [1.0, 2.0, 3.0] {
        sma.update(value);
    }
    assert!(sma.is_ready());
    sma.reset();
    assert!(!sma.is_ready(), "reset must undo readiness");
    assert_eq!(sma.update(1.0), None, "and the window with it");
}

#[test]
fn wma_weights_the_newest_input_hardest() {
    let mut wma = Wma::new(3).expect("period 3 is valid");
    assert_eq!(wma.warmup_period(), 3);

    // Weights 1, 2, 3 over inputs 0, 0, 6: (1*0 + 2*0 + 3*6) / (1 + 2 + 3) = 3.
    // Chosen so the result is exact rather than a repeating fraction -- the
    // whole point of this file is that the value can be checked by hand.
    assert_eq!(wma.update(0.0), None, "no value before the window is full");
    assert_eq!(wma.update(0.0), None);
    assert_eq!(wma.update(6.0), Some(3.0));

    // And the constant property the crossover cases lean on.
    let mut flat = Wma::new(10).expect("period 10 is valid");
    for _ in 0..9 {
        flat.update(FLAT);
    }
    assert_eq!(
        flat.update(FLAT),
        Some(FLAT),
        "a weighted mean of a constant is that constant"
    );
}

#[test]
fn roc_is_a_percentage_of_the_earlier_price() {
    let mut roc = Roc::new(1).expect("period 1 is valid");
    // period + 1: a rate of change over one bar needs two prices.
    assert_eq!(roc.warmup_period(), 2);

    assert_eq!(roc.update(100.0), None, "one price is not a change");
    // (200 - 100) / 100 * 100 -- a doubling is +100 percent, not +1.
    assert_eq!(roc.update(200.0), Some(100.0));
    // And back down: (100 - 200) / 200 * 100.
    assert_eq!(roc.update(100.0), Some(-50.0));

    let mut flat = Roc::new(14).expect("period 14 is valid");
    let mut last = None;
    for _ in 0..20 {
        last = flat.update(FLAT);
    }
    assert_eq!(
        last,
        Some(0.0),
        "a series that never moves has no rate of change"
    );
}

#[test]
fn atr_averages_a_constant_true_range_to_itself() {
    let mut atr = Atr::new(14).expect("period 14 is valid");
    assert_eq!(atr.warmup_period(), 14);

    // open == close == 100, high 101, low 99. The first bar has no previous
    // close, so its true range is high - low = 2. Every later bar takes the
    // largest of high - low (2), |high - prev close| (1) and |low - prev close|
    // (1), which is 2 again -- so the seed mean and every smoothed value are 2.
    let bar = Candle::new(100.0, 101.0, 99.0, 100.0, 0.0, 0).expect("high >= low");
    let mut last = None;
    for _ in 0..14 {
        last = atr.update(bar);
    }
    assert_eq!(
        last,
        Some(2.0),
        "the average of a constant range is that range"
    );
    assert_eq!(atr.update(bar), Some(2.0), "and smoothing does not move it");
}

#[test]
fn bollinger_bands_collapse_onto_a_flat_series() {
    let mut bands = BollingerBands::new(20, 2.0).expect("period 20, multiplier 2 are valid");
    assert_eq!(bands.warmup_period(), 20);

    let mut last = None;
    for _ in 0..20 {
        last = bands.update(FLAT);
    }
    let out = last.expect("ready after 20 inputs");
    assert_eq!(out.middle, FLAT, "the middle band is the mean");
    assert_eq!(out.stddev, 0.0, "a constant series has no deviation");
    assert_eq!(out.upper, FLAT, "so both bands sit on the mean");
    assert_eq!(out.lower, FLAT);

    // On a series that does move, the ordering is what the breakout case reads.
    let mut moving = BollingerBands::new(20, 2.0).expect("period 20 is valid");
    for i in 0..60 {
        if let Some(out) = moving.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0) {
            assert!(
                out.lower <= out.middle && out.middle <= out.upper,
                "bands crossed: {} {} {}",
                out.lower,
                out.middle,
                out.upper
            );
        }
    }
}

#[test]
fn macd_is_zero_while_both_averages_agree() {
    let mut macd = MacdIndicator::new(12, 26, 9).expect("12/26/9 is valid");
    // The slow EMA needs 26 inputs to seed, and the signal EMA another 8 on top.
    assert_eq!(macd.warmup_period(), 34);

    let mut last = None;
    for _ in 0..34 {
        last = macd.update(FLAT);
    }
    let out = last.expect("ready after 34 inputs");
    assert_eq!(out.macd, 0.0, "two averages of one constant cannot differ");
    assert_eq!(out.signal, 0.0);
    assert_eq!(out.histogram, 0.0, "and their difference is zero");

    // A step up must lift the fast average first, which is the sign the
    // crossover case in the corpus trades on.
    let stepped = macd.update(200.0).expect("ready");
    assert!(
        stepped.macd > 0.0,
        "the fast average must lead on a step up, got {}",
        stepped.macd
    );
}

#[test]
fn assert_families_are_covered() {
    let dir = repo_root().join("cases");
    let mut named: BTreeSet<String> = BTreeSet::new();

    for entry in fs::read_dir(&dir).expect("cases/") {
        let path = entry.expect("dir entry").path();
        if path.extension().and_then(|e| e.to_str()) != Some("json") {
            continue;
        }
        let text = fs::read_to_string(&path).expect("read case");
        let value: Value = serde_json::from_str(&text).expect("parse case");
        // Both a bare case and suite.json, which nests them under `cases`.
        let strategies = value.get("strategy").map_or_else(
            || {
                value
                    .get("cases")
                    .and_then(Value::as_array)
                    .map(|cases| cases.iter().filter_map(|c| c.get("strategy")).collect())
                    .unwrap_or_default()
            },
            |s| vec![s],
        );
        for strategy in strategies {
            let Some(indicators) = strategy.get("indicators").and_then(Value::as_object) else {
                continue;
            };
            for indicator in indicators.values() {
                if let Some(kind) = indicator.get("type").and_then(Value::as_str) {
                    named.insert(kind.to_string());
                }
            }
        }
    }

    assert!(!named.is_empty(), "no indicators found under cases/");
    let unpinned: Vec<&String> = named
        .iter()
        .filter(|k| !PINNED.contains(&k.as_str()))
        .collect();
    assert!(
        unpinned.is_empty(),
        "cases/ names indicator families this file does not pin: {unpinned:?}. \
         Add a test for each, then list it in PINNED -- otherwise a change to one \
         of them moves a committed hash with nothing here to say so."
    );
}