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
279 lines
11 KiB
Rust
279 lines
11 KiB
Rust
//! Gated integration test: the GER40 session-breakout `Composite` blueprint is
|
||
//! driven by the World *orchestration families* — `sweep` and
|
||
//! `walk_forward` — on REAL GER40 M1 bars, with NO re-authoring of the strategy.
|
||
//! This is the milestone-completing executable proof that the #94 friction is
|
||
//! gone: the same `ger40_breakout_blueprint` the single backtest bootstraps is
|
||
//! consumed verbatim by the World machinery.
|
||
//!
|
||
//! Mirrors `tests/ger40_breakout_real.rs` / `real_bars.rs`: skips with a note
|
||
//! where the local Pepperstone archive is absent, so `cargo test --workspace`
|
||
//! stays green anywhere; exercises the real path where the files exist.
|
||
//!
|
||
//! Two properties, both over the shipped blueprint:
|
||
//! - `sweep` returns a ranked grid over the two named params
|
||
//! (`entry_bar.target` × `exit_bar.target`), each point's `total_pips` finite —
|
||
//! the `param_space()` is driven directly.
|
||
//! - `walk_forward` returns a NON-EMPTY roll, every window carrying a NON-EMPTY
|
||
//! `chosen_params` (the executable #97 non-degenerate proof: a real, non-empty
|
||
//! space is optimized per window — contrast the fieldtest's empty-space
|
||
//! degenerate roll over the raw FlatGraph).
|
||
|
||
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 = "../examples/shared/breakout_real.rs"]
|
||
mod breakout_real;
|
||
use breakout_real::*;
|
||
|
||
/// Bootstrap the shipped blueprint at one `{entry_bar, exit_bar}` point, run it
|
||
/// over the epoch-ns [`Timestamp`] window `[from, to]` of real GER40 OHLC, and
|
||
/// fold the recorded equity/held taps into a `RunReport` (`summarize` over the
|
||
/// drained channels — exactly as the SMA-cross sweep fixture and the fieldtest's
|
||
/// `run_point` do). A fresh blueprint per call (fresh nodes + channels) keeps the
|
||
/// disjoint sweep points independent (C1).
|
||
fn run_point(server: &Arc<DataServer>, point: &[Cell], from: Timestamp, to: Timestamp) -> RunReport {
|
||
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("sweep 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().collect::<Vec<_>>()
|
||
.iter()
|
||
.map(|(t, r)| (*t, scalar_f64(&r[0])))
|
||
.collect();
|
||
let exposure: Vec<(Timestamp, f64)> = taps.held.try_iter().collect::<Vec<_>>()
|
||
.iter()
|
||
.map(|(t, r)| (*t, scalar_f64(&r[0])))
|
||
.collect();
|
||
RunReport {
|
||
manifest: RunManifest {
|
||
commit: "ger40-breakout-world".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),
|
||
}
|
||
}
|
||
|
||
fn scalar_f64(s: &Scalar) -> f64 {
|
||
match s {
|
||
Scalar::F64(v) => *v,
|
||
other => panic!("expected f64, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Property: the World `sweep` family CONSUMES the shipped breakout blueprint
|
||
/// over its two named params on real GER40 — it returns a full ranked grid (no
|
||
/// re-authoring), and every grid point's `total_pips` is finite (a backtest
|
||
/// actually ran at each point). The grid is built from the blueprint's
|
||
/// `param_space()` directly, so the two `EqConst` targets are the swept axes.
|
||
#[test]
|
||
fn sweep_consumes_blueprint_over_named_params() {
|
||
let server = default_data_server();
|
||
if !server.has_symbol(SYMBOL) {
|
||
eprintln!("skip: no local data at {DEFAULT_DATA_PATH} (symbol {SYMBOL} absent)");
|
||
return;
|
||
}
|
||
|
||
let (from, to) = utc_month_window(2024, 9);
|
||
let space = ger40_breakout_blueprint(
|
||
pip_size_of(SYMBOL),
|
||
BAR_MINUTES,
|
||
SESSION_HOUR,
|
||
SESSION_MINUTE,
|
||
Berlin,
|
||
)
|
||
.0
|
||
.param_space();
|
||
// The grid is exactly over `param_space()`: entry ∈ {2,3,4}, exit ∈ {4,5,6}.
|
||
let grid = GridSpace::new(
|
||
&space,
|
||
space
|
||
.iter()
|
||
.map(|ps| match ps.name.as_str() {
|
||
"entry_bar.target" => vec![Scalar::i64(2), Scalar::i64(3), Scalar::i64(4)],
|
||
"exit_bar.target" => vec![Scalar::i64(4), Scalar::i64(5), Scalar::i64(6)],
|
||
other => panic!("unexpected param slot {other}"),
|
||
})
|
||
.collect(),
|
||
)
|
||
.expect("grid well-formed for the breakout param_space");
|
||
|
||
let server_for_closure = Arc::clone(&server);
|
||
let family = sweep(&grid, |pt: &[Cell]| {
|
||
run_point(&server_for_closure, pt, from, to)
|
||
});
|
||
|
||
// A full 3×3 grid was enumerated over the two named params (no re-authoring).
|
||
assert_eq!(family.points.len(), 9, "the 3×3 entry×exit grid must enumerate 9 points");
|
||
assert_eq!(
|
||
family.space.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
|
||
vec!["entry_bar.target", "exit_bar.target"],
|
||
"the swept space is exactly the two genuine tuning knobs",
|
||
);
|
||
// Every point's metrics are finite — a real backtest ran at each grid point.
|
||
for (i, p) in family.points.iter().enumerate() {
|
||
assert!(
|
||
p.report.metrics.total_pips.is_finite(),
|
||
"point {i} {:?} produced a non-finite total_pips",
|
||
family.named_params(i),
|
||
);
|
||
}
|
||
// The best point (max total_pips) is finite — the ranked grid has a real head.
|
||
let best = family
|
||
.points
|
||
.iter()
|
||
.map(|p| p.report.metrics.total_pips)
|
||
.fold(f64::NEG_INFINITY, f64::max);
|
||
assert!(best.is_finite(), "the best grid point's total_pips must be finite");
|
||
}
|
||
|
||
/// Property: the World `walk_forward` family CONSUMES the shipped breakout
|
||
/// blueprint NON-DEGENERATELY (the executable #97 resolution). The roll over
|
||
/// multi-year GER40 yields a NON-EMPTY set of windows, and EACH window carries a
|
||
/// NON-EMPTY `chosen_params` — a real `{entry_bar, exit_bar}` point was selected
|
||
/// by an in-sample sweep per window (contrast the fieldtest's empty-space roll
|
||
/// over the raw FlatGraph, where `chosen_params == vec![]` everywhere).
|
||
#[test]
|
||
fn walk_forward_consumes_blueprint_non_degenerate() {
|
||
let server = default_data_server();
|
||
if !server.has_symbol(SYMBOL) {
|
||
eprintln!("skip: no local data at {DEFAULT_DATA_PATH} (symbol {SYMBOL} absent)");
|
||
return;
|
||
}
|
||
|
||
// A short 2-window roll over 2023..2024 keeps the gated test cheap while still
|
||
// exercising the per-window in-sample optimize. is=6mo, 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(2023, 1, 1, 0, 0, 0).unwrap().timestamp_millis(),
|
||
);
|
||
let end = aura_ingest::unix_ms_to_epoch_ns(
|
||
chrono::Utc.with_ymd_and_hms(2024, 6, 30, 23, 59, 59).unwrap().timestamp_millis(),
|
||
);
|
||
let roller = WindowRoller::new(
|
||
(origin, end),
|
||
6 * month_ns,
|
||
3 * month_ns,
|
||
3 * month_ns,
|
||
RollMode::Rolling,
|
||
)
|
||
.expect("span fits >= 1 window");
|
||
|
||
// The non-empty space the in-sample sweep optimizes over per window: the two
|
||
// genuine tuning knobs. This is the #97 resolution — a real space, so the
|
||
// chosen params are populated, never an empty degenerate vec.
|
||
let space = ger40_breakout_blueprint(
|
||
pip_size_of(SYMBOL),
|
||
BAR_MINUTES,
|
||
SESSION_HOUR,
|
||
SESSION_MINUTE,
|
||
Berlin,
|
||
)
|
||
.0
|
||
.param_space();
|
||
let server_for_closure = Arc::clone(&server);
|
||
|
||
let result = walk_forward(roller, space.clone(), move |w: WindowBounds| -> WindowRun {
|
||
// In-sample optimize: a tiny 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,
|
||
space
|
||
.iter()
|
||
.map(|ps| match ps.name.as_str() {
|
||
"entry_bar.target" => vec![Scalar::i64(2), Scalar::i64(3)],
|
||
"exit_bar.target" => vec![Scalar::i64(5), Scalar::i64(6)],
|
||
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)
|
||
});
|
||
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.
|
||
let oos_report = run_point(&server_for_closure, &chosen, w.oos.0, w.oos.1);
|
||
// Re-run once on OOS to capture the recorded equity segment for stitching.
|
||
let (bp, taps) = ger40_breakout_blueprint(
|
||
pip_size_of(SYMBOL),
|
||
BAR_MINUTES,
|
||
SESSION_HOUR,
|
||
SESSION_MINUTE,
|
||
Berlin,
|
||
);
|
||
let mut h = bp.bootstrap_with_cells(&chosen).expect("chosen point kind-checked");
|
||
let sources = open_ohlc(&server_for_closure, SYMBOL, w.oos.0, w.oos.1)
|
||
.expect("OOS window overlaps data");
|
||
h.run(sources);
|
||
drop(h);
|
||
let oos_equity: Vec<(Timestamp, f64)> = taps
|
||
.equity
|
||
.try_iter()
|
||
.collect::<Vec<_>>()
|
||
.iter()
|
||
.map(|(t, r)| (*t, scalar_f64(&r[0])))
|
||
.collect();
|
||
|
||
WindowRun { chosen_params: chosen, oos_equity, oos_report }
|
||
});
|
||
|
||
// Non-degenerate: a non-empty roll, EACH window carrying a non-empty chosen
|
||
// point (the #97 proof — a real space was optimized per window).
|
||
assert!(!result.windows.is_empty(), "walk_forward must yield >= 1 window");
|
||
for (k, w) in result.windows.iter().enumerate() {
|
||
assert!(
|
||
!w.run.chosen_params.is_empty(),
|
||
"window {k}: chosen_params must be NON-empty (the #97 non-degenerate proof)",
|
||
);
|
||
assert_eq!(
|
||
w.run.chosen_params.len(),
|
||
2,
|
||
"window {k}: chosen_params spans the two-param space",
|
||
);
|
||
assert!(
|
||
w.run.oos_report.metrics.total_pips.is_finite(),
|
||
"window {k}: OOS total_pips must be finite",
|
||
);
|
||
}
|
||
}
|