use std::collections::HashMap;
use std::path::Path;
use rayon::prelude::*;
use serde::Serialize;
use yuzu_core::backtest::BacktestConfig;
use yuzu_core::EvalContext;
use crate::ctx::{load_ctx, referenced_series};
use crate::sweep::SortKey;
#[derive(Serialize)]
pub struct WalkForwardWindow {
pub train_from: i32,
pub train_to: i32,
pub test_from: i32,
pub test_to: i32,
pub chosen: String,
pub in_sample_metric: f64,
pub oos_return: f64,
}
#[derive(Serialize)]
pub struct WalkForwardReport {
pub windows: Vec<WalkForwardWindow>,
pub dates: Vec<i32>,
pub equity: Vec<f64>,
pub total_return: f64,
pub cagr: f64,
pub sharpe: f64,
pub max_drawdown: f64,
}
fn slice_ctx(ctx: &EvalContext, from: i32, to: i32) -> EvalContext {
EvalContext {
panels: ctx
.panels
.iter()
.map(|(k, p)| (k.clone(), p.slice_dates(from, to)))
.collect(),
industry: ctx.industry.clone(),
}
}
fn metric_from_curve(dates: &[i32], equity: &[f64], key: SortKey) -> f64 {
match key {
SortKey::Sharpe => yuzu_core::metrics::sharpe(equity),
SortKey::TotalReturn => yuzu_core::metrics::total_return(equity),
SortKey::Cagr => yuzu_core::metrics::cagr(equity, dates),
SortKey::Calmar => yuzu_core::metrics::calmar(equity, dates),
}
}
pub fn max_lookback(spec: &serde_json::Value) -> usize {
match spec {
serde_json::Value::Object(map) => map
.iter()
.map(|(k, v)| {
let own = if matches!(k.as_str(), "n" | "nwindow" | "d") {
v.as_u64().unwrap_or(0) as usize
} else {
0
};
own.max(max_lookback(v))
})
.max()
.unwrap_or(0),
serde_json::Value::Array(arr) => arr.iter().map(max_lookback).max().unwrap_or(0),
_ => 0,
}
}
#[allow(clippy::type_complexity)]
fn run_windowed(
ctx: &EvalContext,
spec: &str,
cfg: &BacktestConfig,
eval_from: i32,
nav_from: i32,
to: i32,
initial_weights: Option<&HashMap<String, f64>>,
) -> Result<(Vec<i32>, Vec<f64>, HashMap<String, f64>), String> {
let eval_ctx = slice_ctx(ctx, eval_from, to);
let positions = yuzu_core::run_strategy(spec, &eval_ctx).map_err(|e| e.to_string())?;
let positions = positions.slice_dates(nav_from, to);
let prices = eval_ctx
.panels
.get("close")
.ok_or("no close panel")?
.slice_dates(nav_from, to);
let volume = eval_ctx
.panels
.get("volume")
.map(|p| p.slice_dates(nav_from, to));
let run = yuzu_core::backtest::run_with_initial(
&positions,
&prices,
None, None,
None,
volume.as_ref(),
cfg,
initial_weights,
);
Ok((run.dates, run.equity, run.terminal_weights))
}
pub struct WalkForwardParams {
pub from: i32,
pub to: i32,
pub train_days: usize,
pub test_days: usize,
pub sort_by: SortKey,
pub warmup_days: Option<usize>,
}
pub fn run_walkforward(
root: &Path,
variants: &[(String, String)],
params: &WalkForwardParams,
cfg: &BacktestConfig,
) -> Result<WalkForwardReport, String> {
let WalkForwardParams {
from,
to,
train_days,
test_days,
sort_by,
warmup_days,
} = *params;
if variants.is_empty() {
return Err("no variants to select from".into());
}
if train_days == 0 || test_days == 0 {
return Err("train_days and test_days must be > 0".into());
}
let warmup = match warmup_days {
Some(n) => n,
None => variants
.iter()
.filter_map(|(_, spec)| serde_json::from_str(spec).ok())
.map(|v: serde_json::Value| max_lookback(&v))
.max()
.unwrap_or(0),
};
let specs: Vec<&str> = variants.iter().map(|(_, s)| s.as_str()).collect();
let ctx = load_ctx(
root,
from,
to,
cfg,
"close",
None,
&referenced_series(&specs),
)?;
let dates = ctx
.panels
.get("close")
.ok_or("no close panel")?
.dates
.clone();
if dates.len() < train_days + 1 {
return Err(format!(
"only {} trading days loaded; need > train_days ({train_days})",
dates.len()
));
}
let mut windows = Vec::new();
let mut oos_dates: Vec<i32> = Vec::new();
let mut oos_equity: Vec<f64> = Vec::new();
let mut scale = 1.0_f64;
let mut carry: Option<HashMap<String, f64>> = None;
let mut start = 0usize;
while start + train_days < dates.len() {
let train_from = dates[start];
let train_to = dates[start + train_days - 1];
let test_start = start + train_days;
let test_end = (test_start + test_days).min(dates.len());
let test_from = dates[test_start];
let test_to = dates[test_end - 1];
let train_eval_from = dates[start.saturating_sub(warmup)];
let scored: Vec<(usize, f64)> = variants
.par_iter()
.enumerate()
.filter_map(|(i, (_, spec))| {
run_windowed(&ctx, spec, cfg, train_eval_from, train_from, train_to, None)
.ok()
.map(|(d, e, _)| (i, metric_from_curve(&d, &e, sort_by)))
.filter(|(_, m)| !m.is_nan())
})
.collect();
let (best, in_sample_metric) = scored
.into_iter()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.ok_or_else(|| format!("window {train_from}..{train_to}: every variant failed"))?;
let test_eval_from = dates[test_start.saturating_sub(warmup)];
let nav_from = dates[test_start - 1];
let (seg_dates, seg_equity, seg_terminal) = run_windowed(
&ctx,
&variants[best].1,
cfg,
test_eval_from,
nav_from,
test_to,
carry.as_ref(),
)?;
carry = Some(seg_terminal);
windows.push(WalkForwardWindow {
train_from,
train_to,
test_from,
test_to,
chosen: variants[best].0.clone(),
in_sample_metric,
oos_return: seg_equity.last().unwrap() - 1.0,
});
for (d, e) in seg_dates.iter().zip(&seg_equity) {
if *d < test_from {
continue; }
oos_dates.push(*d);
oos_equity.push(scale * e);
}
scale = *oos_equity.last().unwrap();
start = test_end;
}
if windows.is_empty() {
return Err("date range too short for one train+test window".into());
}
Ok(WalkForwardReport {
total_return: yuzu_core::metrics::total_return(&oos_equity),
cagr: yuzu_core::metrics::cagr(&oos_equity, &oos_dates),
sharpe: yuzu_core::metrics::sharpe(&oos_equity),
max_drawdown: yuzu_core::metrics::max_drawdown(&oos_equity),
windows,
dates: oos_dates,
equity: oos_equity,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn max_lookback_finds_the_largest_window_arg() {
let spec = json!({"op": "sub", "a": {"op": "sma", "n": 50}, "b": {"op": "sma", "n": 200}});
assert_eq!(max_lookback(&spec), 200);
assert_eq!(max_lookback(&json!({"nwindow": 30})), 30);
assert_eq!(max_lookback(&json!([{"d": 5}, {"n": 12}])), 12);
assert_eq!(max_lookback(&json!({"other": 999})), 0);
}
}