rsta 0.1.0

Technical analysis indicators, streaming signals, and a single-asset backtesting engine for Rust
Documentation
//! Shared helpers for golden-data based integration tests.
//!
//! Two datasets are bundled:
//!
//! - `tests/data/sample_ohlcv.csv` — 30 synthetic candles with closes 10..=39,
//!   used for trivially hand-verifiable golden values (no Python required).
//! - `tests/data/btc_usd_daily.csv` — Kraken XBTUSD daily OHLCV, ~4.5k bars
//!   from 2013-10-06 to 2026-04-21. Used as the reference input for goldens
//!   generated by `scripts/gen_golden.py` (pandas-ta).

use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};

use rsta::indicators::Candle;

/// Default absolute tolerance for `f64` comparisons.
pub const DEFAULT_TOL: f64 = 1e-9;

fn data_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/data")
}

/// Load the bundled OHLCV sample as a `Vec<Candle>` (timestamps as row index).
pub fn load_sample() -> Vec<Candle> {
    let path = data_dir().join("sample_ohlcv.csv");
    load_candles(&path)
}

/// Load the bundled Kraken XBTUSD daily dataset (~4.5k candles, 2013-10-06 →
/// 2026-04-21). Schema is the raw Kraken OHLCV CSV format:
/// `unix_timestamp,open,high,low,close,volume,trade_count` with no header.
/// `trade_count` is dropped.
pub fn load_btc_daily() -> Vec<Candle> {
    let path = data_dir().join("btc_usd_daily.csv");
    let file = File::open(&path).unwrap_or_else(|e| panic!("open {path:?}: {e}"));
    let mut out = Vec::new();
    for line in BufReader::new(file).lines() {
        let line = line.unwrap();
        if line.is_empty() {
            continue;
        }
        let mut cols = line.split(',');
        let timestamp: u64 = cols.next().expect("timestamp").parse().expect("ts u64");
        let open: f64 = cols.next().expect("open").parse().expect("open f64");
        let high: f64 = cols.next().expect("high").parse().expect("high f64");
        let low: f64 = cols.next().expect("low").parse().expect("low f64");
        let close: f64 = cols.next().expect("close").parse().expect("close f64");
        let volume: f64 = cols.next().expect("volume").parse().expect("volume f64");
        out.push(Candle {
            timestamp,
            open,
            high,
            low,
            close,
            volume,
        });
    }
    out
}

/// Load any OHLCV CSV with the expected `date,open,high,low,close,volume`
/// schema. Dates are not parsed (timestamps are set to the row index).
pub fn load_candles(path: &Path) -> Vec<Candle> {
    let file = File::open(path).unwrap_or_else(|e| panic!("open {path:?}: {e}"));
    let mut out = Vec::new();
    for (i, line) in BufReader::new(file).lines().enumerate() {
        let line = line.unwrap();
        if i == 0 {
            continue; // header
        }
        let mut cols = line.split(',');
        let _date = cols.next().expect("date");
        let open: f64 = cols.next().expect("open").parse().expect("open f64");
        let high: f64 = cols.next().expect("high").parse().expect("high f64");
        let low: f64 = cols.next().expect("low").parse().expect("low f64");
        let close: f64 = cols.next().expect("close").parse().expect("close f64");
        let volume: f64 = cols.next().expect("volume").parse().expect("volume f64");
        out.push(Candle {
            timestamp: (i as u64) - 1,
            open,
            high,
            low,
            close,
            volume,
        });
    }
    out
}

/// Load a golden CSV of the form `index,value` (header included).
pub fn load_golden(name: &str) -> Vec<(usize, f64)> {
    let path = data_dir().join(name);
    let file = File::open(&path).unwrap_or_else(|e| panic!("open {path:?}: {e}"));
    parse_golden_reader(file)
}

/// Try to load a golden CSV. Returns `None` if the file is absent — used by
/// pandas-ta-driven tests that should silently skip when the user hasn't
/// regenerated goldens yet (the script lives in `scripts/gen_golden.py`).
pub fn try_load_golden(name: &str) -> Option<Vec<(usize, f64)>> {
    let path = data_dir().join(name);
    let file = File::open(&path).ok()?;
    Some(parse_golden_reader(file))
}

fn parse_golden_reader(file: File) -> Vec<(usize, f64)> {
    let mut out = Vec::new();
    for (i, line) in BufReader::new(file).lines().enumerate() {
        let line = line.unwrap();
        if i == 0 {
            continue;
        }
        let mut cols = line.split(',');
        let idx: usize = cols.next().expect("index").parse().expect("index usize");
        let val: f64 = cols.next().expect("value").parse().expect("value f64");
        out.push((idx, val));
    }
    out
}

/// Compare a computed series `produced` against a golden series, aligning by
/// **source row index**. Only rows where both series have a value are
/// compared, so two implementations with different warmup windows still match
/// on their overlap.
///
/// `n_input` is the length of the source series; `produced` is right-aligned
/// (warmup pads at the head). `golden[i].0` is the row index into the source.
///
/// Panics if the overlap is empty (something is wrong with the test setup),
/// or if any pair of values differs by more than `tol` in absolute value.
pub fn assert_matches_golden(n_input: usize, produced: &[f64], golden: &[(usize, f64)], tol: f64) {
    let warmup = n_input - produced.len();
    let golden_by_idx: std::collections::HashMap<usize, f64> = golden.iter().copied().collect();

    let mut compared = 0usize;
    let mut max_abs_err = 0.0_f64;
    for (i, &got) in produced.iter().enumerate() {
        let row = warmup + i;
        let Some(&expected) = golden_by_idx.get(&row) else {
            continue;
        };
        let err = (got - expected).abs();
        if err > max_abs_err {
            max_abs_err = err;
        }
        assert!(
            err <= tol,
            "mismatch at source row {row} (produced[{i}]): got {got}, expected {expected}, abs err {err}, tol {tol}",
        );
        compared += 1;
    }
    assert!(
        compared > 0,
        "no overlap between produced ({} rows, warmup {}) and golden ({} rows)",
        produced.len(),
        warmup,
        golden.len(),
    );
}