a56ab7859d
C28 phase 2 (Stratification); realizes item 1 of the deferred #147. The engine's production surface no longer names a backtest-metric type: - RunReport becomes generic over its metric payload M; sweep/mc/walkforward/ blueprint thread the parameter (SweepPoint<M>, SweepFamily<M>, WindowRun<M>, WalkForwardResult<M>). RunManifest stays concrete and engine-owned (its selection: Option<FamilySelection> embeds the foundation-grade analysis type). - summarize and the MC assembly (McDraw/McFamily/McAggregate/RBootstrap/ r_bootstrap/monte_carlo) move to aura-backtest - McAggregate::from_draws reads RunMetrics fields by name, so generifying it is the phase-6 metric-vocabulary abstraction (#147 item 2), still deferred; wholesale relocation is the honest cut. The concrete instantiation lives in aura-backtest as `type RunReport = aura_engine::RunReport<RunMetrics>` + sibling aliases. - the statistics kernel (MetricStats/quantile/resample_block/SplitMix64) moves to the aura-analysis foundation; the engine re-imports it (inner->foundation, legal) and re-exports it so existing consumers stay source-compatible. Dependency inversion in one commit: aura-engine drops aura-backtest from [dependencies] (back to dev-deps for its SimBroker/RunMetrics test fixtures); aura-backtest gains aura-engine. Cycle-free for lib targets - the cycle closes only through the engine's dev-dep edge, the pattern aura-vocabulary already uses. aura-backtest reaches the kernel transitively through the engine re-export, so no aura-backtest -> aura-analysis edge exists (the C28 ladder permits backtest -> {core, engine} only). run_indexed / SplitMix64::next_f64 widened pub(crate) -> pub for cross-crate use. Consumers (registry/campaign/cli/composites/ingest/bench) rewired by import path only, no call-site logic changed. The c28_layering structural test extends to the full ladder: aura-analysis (no aura-* deps), aura-engine ⊆ {core, analysis}, aura-backtest ⊆ {core, engine}. Behaviour-preserving: 1448/0 tests, clippy -D warnings clean, serde shapes byte-identical (C18 - RunReport<M> keeps field order manifest,metrics; the CLI pre-serialized-splice contract unchanged), moved code traceable via git rename detection. Cycle-introduced broken intra-doc links fixed. closes #292
238 lines
8.3 KiB
Rust
238 lines
8.3 KiB
Rust
//! Runnable World-family demo: **`walk_forward`** the GER40 session-breakout
|
||
//! `Composite` blueprint across multi-year real GER40 history, with a
|
||
//! per-window in-sample optimize of its two genuine tuning knobs
|
||
//! (`entry_bar.target` × `exit_bar.target`). This is the executable #97
|
||
//! resolution: the roll is NON-DEGENERATE — a real, non-empty `param_space()` is
|
||
//! optimized per window, so every window's `chosen_params` is populated (contrast
|
||
//! the earlier fieldtest's empty-space degenerate roll over the raw FlatGraph, on
|
||
//! which no in-sample tuning was possible).
|
||
//!
|
||
//! Run:
|
||
//! ```text
|
||
//! cargo run -p aura-ingest --example ger40_breakout_walkforward
|
||
//! ```
|
||
//! Prints each IS/OOS window with the entry/exit it chose in-sample and its OOS
|
||
//! pips, plus the stitched out-of-sample equity curve. Skips cleanly where the
|
||
//! archive is absent.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use aura_core::{Cell, Scalar, Timestamp};
|
||
use aura_backtest::{summarize, RunReport, WindowRun};
|
||
use aura_engine::{
|
||
sweep, walk_forward, GridSpace, RollMode, RunManifest, WindowBounds,
|
||
WindowRoller,
|
||
};
|
||
use chrono::TimeZone;
|
||
use chrono_tz::Europe::Berlin;
|
||
use aura_ingest::{default_data_server, open_ohlc, DataServer, DEFAULT_DATA_PATH};
|
||
|
||
#[path = "shared/breakout_real.rs"]
|
||
mod breakout_real;
|
||
use breakout_real::*;
|
||
|
||
// The in-sample grid the per-window optimize searches: entry ∈ {2,3,4}, exit ∈
|
||
// {4,5,6} — the two EqConst targets that ARE the blueprint's param_space().
|
||
const ENTRY_BARS: &[i64] = &[2, 3, 4];
|
||
const EXIT_BARS: &[i64] = &[4, 5, 6];
|
||
|
||
/// Bootstrap the blueprint at one grid point, run it over the epoch-ns
|
||
/// [`Timestamp`] window `[from, to]` of real GER40 OHLC, and fold the recorded
|
||
/// equity/held taps into `(RunReport, equity-segment)`. Fresh blueprint per call
|
||
/// (C1).
|
||
fn run_point(
|
||
server: &Arc<DataServer>,
|
||
point: &[Cell],
|
||
from: Timestamp,
|
||
to: Timestamp,
|
||
) -> (RunReport, Vec<(Timestamp, f64)>) {
|
||
let (bp, taps) = ger40_breakout_blueprint(
|
||
pip_size_of(SYMBOL),
|
||
BAR_MINUTES,
|
||
SESSION_HOUR,
|
||
SESSION_MINUTE,
|
||
Berlin,
|
||
);
|
||
let mut h = bp
|
||
.bootstrap_with_cells(point)
|
||
.expect("point kind-checked against param_space");
|
||
let sources =
|
||
open_ohlc(server, SYMBOL, from, to).expect("window overlaps GER40 data");
|
||
h.run(sources);
|
||
drop(h);
|
||
let equity: Vec<(Timestamp, f64)> = taps
|
||
.equity
|
||
.try_iter()
|
||
.map(|(t, r)| (t, as_f64(&r[0])))
|
||
.collect();
|
||
let exposure: Vec<(Timestamp, f64)> = taps
|
||
.held
|
||
.try_iter()
|
||
.map(|(t, r)| (t, as_f64(&r[0])))
|
||
.collect();
|
||
let report = RunReport {
|
||
manifest: RunManifest {
|
||
commit: "ger40-breakout-wfo".to_string(),
|
||
params: vec![],
|
||
defaults: vec![],
|
||
window: (from, to),
|
||
seed: 0,
|
||
broker: "sim-optimal(pip_size=1)".to_string(),
|
||
selection: None,
|
||
instrument: None,
|
||
topology_hash: None,
|
||
project: None,
|
||
},
|
||
metrics: summarize(&equity, &exposure),
|
||
};
|
||
(report, equity)
|
||
}
|
||
|
||
fn main() {
|
||
let server = default_data_server();
|
||
if !server.has_symbol(SYMBOL) {
|
||
println!("skip: no local data at {DEFAULT_DATA_PATH} (symbol {SYMBOL} absent).");
|
||
return;
|
||
}
|
||
|
||
println!("=== GER40 session-breakout — World `walk_forward` (2018..2024) ===");
|
||
|
||
// The roll span: 2018-01 .. 2024-12. is=12mo, oos=3mo, step=3mo.
|
||
let day_ns: i64 = 86_400_000 * 1_000_000;
|
||
let month_ns: i64 = 30 * day_ns;
|
||
let origin = aura_ingest::unix_ms_to_epoch_ns(
|
||
chrono::Utc.with_ymd_and_hms(2018, 1, 1, 0, 0, 0).unwrap().timestamp_millis(),
|
||
);
|
||
let end = aura_ingest::unix_ms_to_epoch_ns(
|
||
chrono::Utc.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap().timestamp_millis(),
|
||
);
|
||
let roller = WindowRoller::new(
|
||
(origin, end),
|
||
12 * month_ns,
|
||
3 * month_ns,
|
||
3 * month_ns,
|
||
RollMode::Rolling,
|
||
)
|
||
.expect("span fits >= 1 window");
|
||
|
||
// The non-empty space the per-window in-sample sweep optimizes over: the two
|
||
// genuine tuning knobs (the #97 resolution — a real space to optimize).
|
||
let space = ger40_breakout_blueprint(
|
||
pip_size_of(SYMBOL),
|
||
BAR_MINUTES,
|
||
SESSION_HOUR,
|
||
SESSION_MINUTE,
|
||
Berlin,
|
||
)
|
||
.0
|
||
.param_space();
|
||
println!(
|
||
"optimizing param_space() per window: {:?} (in-sample grid {}×{})",
|
||
space.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
||
ENTRY_BARS.len(),
|
||
EXIT_BARS.len(),
|
||
);
|
||
println!();
|
||
|
||
let server_for_closure = Arc::clone(&server);
|
||
let space_for_closure = space.clone();
|
||
let result = walk_forward(roller, space, move |w: WindowBounds| -> WindowRun {
|
||
// In-sample optimize: sweep the entry×exit grid over the IS window, choose
|
||
// the point with the best in-sample total_pips. The blueprint is consumed
|
||
// directly via its param_space() — no re-authoring.
|
||
let is_grid = GridSpace::new(
|
||
&space_for_closure,
|
||
space_for_closure
|
||
.iter()
|
||
.map(|ps| match ps.name.as_str() {
|
||
"entry_bar.target" => ENTRY_BARS.iter().map(|&v| Scalar::i64(v)).collect(),
|
||
"exit_bar.target" => EXIT_BARS.iter().map(|&v| Scalar::i64(v)).collect(),
|
||
other => panic!("unexpected param slot {other}"),
|
||
})
|
||
.collect(),
|
||
)
|
||
.expect("IS grid well-formed");
|
||
let is_family = sweep(&is_grid, |pt: &[Cell]| {
|
||
run_point(&server_for_closure, pt, w.is.0, w.is.1).0
|
||
});
|
||
let best = is_family
|
||
.points
|
||
.iter()
|
||
.max_by(|a, b| {
|
||
a.report
|
||
.metrics
|
||
.total_pips
|
||
.partial_cmp(&b.report.metrics.total_pips)
|
||
.unwrap_or(std::cmp::Ordering::Equal)
|
||
})
|
||
.expect("the IS grid is non-empty");
|
||
let chosen = best.params.clone();
|
||
|
||
// Apply the chosen params on the OOS window — the honest out-of-sample run.
|
||
let (oos_report, oos_equity) =
|
||
run_point(&server_for_closure, &chosen, w.oos.0, w.oos.1);
|
||
WindowRun { chosen_params: chosen, oos_equity, oos_report }
|
||
});
|
||
|
||
println!("walk-forward produced {} windows\n", result.windows.len());
|
||
println!(
|
||
"{:>3} {:<23} {:<10} {:<9} {:>10} {:>9}",
|
||
"win", "oos-window (UTC date)", "entry_bar", "exit_bar", "oos_pips", "oos_dd"
|
||
);
|
||
println!("{}", "-".repeat(72));
|
||
for (k, w) in result.windows.iter().enumerate() {
|
||
let named = result.named_params(k);
|
||
let entry = named.iter().find(|(n, _)| n == "entry_bar.target").map(|(_, v)| v.as_i64()).unwrap_or(-1);
|
||
let exit = named.iter().find(|(n, _)| n == "exit_bar.target").map(|(_, v)| v.as_i64()).unwrap_or(-1);
|
||
let m = &w.run.oos_report.metrics;
|
||
let win = format!(
|
||
"{}..{}",
|
||
fmt_date(w.bounds.oos.0),
|
||
fmt_date(w.bounds.oos.1),
|
||
);
|
||
println!(
|
||
"{:>3} {:<23} {:<10} {:<9} {:>10.1} {:>9.1}",
|
||
k,
|
||
win,
|
||
entry,
|
||
exit,
|
||
m.total_pips,
|
||
m.max_drawdown,
|
||
);
|
||
}
|
||
|
||
let stitched = &result.stitched_oos_equity;
|
||
println!(
|
||
"\nstitched OOS curve: {} points, final cumulative = {:.1} pips",
|
||
stitched.len(),
|
||
stitched.last().map(|(_, v)| *v).unwrap_or(0.0),
|
||
);
|
||
println!(
|
||
"\nOK: NON-DEGENERATE walk-forward — every window optimized the breakout's\n\
|
||
{}-param space in-sample (chosen_params populated). The #97 empty-space\n\
|
||
degenerate roll is resolved: the strategy ships with a real space to tune.",
|
||
space_param_count(&result.space),
|
||
);
|
||
}
|
||
|
||
fn space_param_count(space: &[aura_engine::ParamSpec]) -> usize {
|
||
space.len()
|
||
}
|
||
|
||
/// Render an epoch-ns timestamp as a UTC `YYYY-MM-DD` for the window column.
|
||
/// Display only — never feeds the graph.
|
||
fn fmt_date(ts: Timestamp) -> String {
|
||
let secs = ts.0.div_euclid(1_000_000_000);
|
||
match chrono::Utc.timestamp_opt(secs, 0).single() {
|
||
Some(dt) => dt.format("%Y-%m-%d").to_string(),
|
||
None => format!("ts={}", ts.0),
|
||
}
|
||
}
|
||
|
||
fn as_f64(s: &Scalar) -> f64 {
|
||
match s {
|
||
Scalar::F64(v) => *v,
|
||
other => panic!("expected f64, got {other:?}"),
|
||
}
|
||
}
|