Skip to main content

yuzu_research/
sweep.rs

1//! Single run and parameter sweep: run one strategy, or many variants in
2//! parallel ranked into a leaderboard.
3
4use std::path::Path;
5
6use rayon::prelude::*;
7use serde::Serialize;
8use yuzu_core::backtest::BacktestConfig;
9use yuzu_core::report::Report;
10use yuzu_core::run_backtest;
11
12use crate::ctx::{load_ctx, referenced_series};
13
14/// Which metric to rank by in a sweep (also the walk-forward selection metric).
15#[derive(Clone, Copy)]
16pub enum SortKey {
17    Sharpe,
18    TotalReturn,
19    Cagr,
20    Calmar,
21}
22
23/// One row in the sweep leaderboard.
24#[derive(Serialize)]
25pub struct SweepEntry {
26    pub name: String,
27    pub ok: bool,
28    pub error: Option<String>,
29    pub total_return: f64,
30    pub cagr: f64,
31    pub sharpe: f64,
32    pub sortino: f64,
33    pub max_drawdown: f64,
34    pub calmar: f64,
35}
36
37fn failed(name: &str, err: String) -> SweepEntry {
38    SweepEntry {
39        name: name.to_string(),
40        ok: false,
41        error: Some(err),
42        total_return: f64::NAN,
43        cagr: f64::NAN,
44        sharpe: f64::NAN,
45        sortino: f64::NAN,
46        max_drawdown: f64::NAN,
47        calmar: f64::NAN,
48    }
49}
50
51/// Run one strategy over the full universe, or over an explicit `symbols`
52/// subset (`None` = every symbol under `prices/`). Scoping changes what every
53/// cross-sectional op sees, so a requested symbol missing from the data tree
54/// is an error, not a silent drop. Note: a symbol list frozen *today* implies
55/// survivorship bias in a historical run — see `docs/strategy-envelope.md`.
56pub fn run_single(
57    root: &Path,
58    spec_json: &str,
59    from: i32,
60    to: i32,
61    cfg: &BacktestConfig,
62    price_key: &str,
63    symbols: Option<&[String]>,
64) -> Result<Report, String> {
65    let ctx = load_ctx(
66        root,
67        from,
68        to,
69        cfg,
70        price_key,
71        symbols,
72        &referenced_series(&[spec_json]),
73    )?;
74    run_backtest(spec_json, &ctx, price_key, cfg).map_err(|e| e.to_string())
75}
76
77/// Run many strategy variants in parallel (Rayon) and return a ranked leaderboard.
78///
79/// The panel is loaded once and shared across all parallel workers.
80/// Successful entries come first, sorted descending by `sort_by`; failures sink last.
81pub fn run_sweep(
82    root: &Path,
83    variants: &[(String, String)],
84    from: i32,
85    to: i32,
86    cfg: &BacktestConfig,
87    price_key: &str,
88    sort_by: SortKey,
89) -> Vec<SweepEntry> {
90    let specs: Vec<&str> = variants.iter().map(|(_, s)| s.as_str()).collect();
91    let ctx = match load_ctx(
92        root,
93        from,
94        to,
95        cfg,
96        price_key,
97        None,
98        &referenced_series(&specs),
99    ) {
100        Ok(v) => v,
101        Err(e) => return variants.iter().map(|(n, _)| failed(n, e.clone())).collect(),
102    };
103
104    let mut board: Vec<SweepEntry> = variants
105        .par_iter()
106        .map(
107            |(name, spec)| match run_backtest(spec, &ctx, price_key, cfg) {
108                Ok(r) => SweepEntry {
109                    name: name.clone(),
110                    ok: true,
111                    error: None,
112                    total_return: r.metrics.total_return,
113                    cagr: r.metrics.cagr,
114                    sharpe: r.metrics.sharpe,
115                    sortino: r.metrics.sortino,
116                    max_drawdown: r.metrics.max_drawdown,
117                    calmar: r.metrics.calmar,
118                },
119                Err(e) => failed(name, e.to_string()),
120            },
121        )
122        .collect();
123
124    let key = |e: &SweepEntry| match sort_by {
125        SortKey::Sharpe => e.sharpe,
126        SortKey::TotalReturn => e.total_return,
127        SortKey::Cagr => e.cagr,
128        SortKey::Calmar => e.calmar,
129    };
130    // ok entries first, then non-NaN metrics before NaN, then by metric descending;
131    // failures and NaN-metric runs sink to the bottom.
132    board.sort_by(|a, b| {
133        b.ok.cmp(&a.ok)
134            .then(key(a).is_nan().cmp(&key(b).is_nan()))
135            .then(
136                key(b)
137                    .partial_cmp(&key(a))
138                    .unwrap_or(std::cmp::Ordering::Equal),
139            )
140    });
141    board
142}