use crate::case::{BenchmarkCase, Candle, StrategySpec};
use crate::error::{Error, Result};
use crate::hash::{canonicalize, hash};
use crate::suite::{CaseResult, Suite, SuiteReport};
use std::collections::BTreeMap;
use std::path::Path;
use wickra_backtest_core::run;
pub fn run_case(case: &BenchmarkCase, data: &[Candle]) -> Result<CaseResult> {
case.validate()?;
let spec: StrategySpec =
serde_json::from_value(case.strategy.clone()).map_err(|e| Error::BadSpec(e.to_string()))?;
let report = run(&spec, data).map_err(|e| Error::Backtest(e.to_string()))?;
let recomputed = serde_json::to_value(&report).map_err(|e| Error::BadCase(e.to_string()))?;
let recomputed_canon = canonicalize(&recomputed)?;
let hash = hash(&recomputed_canon);
let hash_match = hash == case.expected_hash;
let passed = recomputed_canon == canonicalize(&case.expected)?;
Ok(CaseResult {
id: case.id.clone(),
passed,
hash_match,
recomputed,
hash,
})
}
pub fn run_suite(suite: &Suite, data_root: &Path) -> Result<SuiteReport> {
suite.validate()?;
let mut loaded: Vec<(&BenchmarkCase, Vec<Candle>)> = Vec::with_capacity(suite.cases.len());
for case in &suite.cases {
let candles = load_candles(&data_root.join(&case.dataset_ref))?;
loaded.push((case, candles));
}
Ok(tally(execute(&loaded)?))
}
pub fn run_suite_inline(
suite: &Suite,
datasets: &BTreeMap<String, Vec<Candle>>,
) -> Result<SuiteReport> {
suite.validate()?;
let mut loaded: Vec<(&BenchmarkCase, Vec<Candle>)> = Vec::with_capacity(suite.cases.len());
for case in &suite.cases {
let candles = datasets
.get(&case.dataset_ref)
.ok_or_else(|| Error::Data(format!("no dataset supplied for {}", case.dataset_ref)))?
.clone();
loaded.push((case, candles));
}
Ok(tally(execute(&loaded)?))
}
fn tally(mut results: Vec<CaseResult>) -> SuiteReport {
results.sort_by(|a, b| a.id.cmp(&b.id));
let passed = results.iter().filter(|r| r.passed && r.hash_match).count();
SuiteReport {
failed: results.len() - passed,
passed,
results,
}
}
#[cfg(feature = "parallel")]
fn execute(loaded: &[(&BenchmarkCase, Vec<Candle>)]) -> Result<Vec<CaseResult>> {
use rayon::prelude::*;
loaded
.par_iter()
.map(|(case, candles)| run_case(case, candles))
.collect()
}
#[cfg(not(feature = "parallel"))]
fn execute(loaded: &[(&BenchmarkCase, Vec<Candle>)]) -> Result<Vec<CaseResult>> {
loaded
.iter()
.map(|(case, candles)| run_case(case, candles))
.collect()
}
pub fn load_candles(path: &Path) -> Result<Vec<Candle>> {
let text = std::fs::read_to_string(path)
.map_err(|e| Error::Data(format!("{}: {e}", path.display())))?;
parse_candles_csv(&text).map_err(|e| Error::Data(format!("{}: {e}", path.display())))
}
fn parse_candles_csv(text: &str) -> std::result::Result<Vec<Candle>, String> {
let mut out = Vec::new();
for (idx, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() {
continue;
}
let cols: Vec<&str> = line.split(',').map(str::trim).collect();
if cols.len() < 5 {
return Err(format!("line {}: expected >= 5 columns", idx + 1));
}
let Ok(time) = cols[0].parse::<i64>() else {
if out.is_empty() {
continue;
}
return Err(format!("line {}: non-numeric time {:?}", idx + 1, cols[0]));
};
let field = |i: usize, name: &str| -> std::result::Result<f64, String> {
cols[i]
.parse::<f64>()
.map_err(|_| format!("line {}: bad {name} {:?}", idx + 1, cols[i]))
};
let volume = if cols.len() >= 6 {
field(5, "volume")?
} else {
0.0
};
out.push(Candle {
time,
open: field(1, "open")?,
high: field(2, "high")?,
low: field(3, "low")?,
close: field(4, "close")?,
volume,
});
}
Ok(out)
}