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
111 lines
4.5 KiB
Rust
111 lines
4.5 KiB
Rust
//! Folded sink reductions are byte-for-byte equal to the raw-recorder path.
|
|
//! Drives the folding sinks node-by-node (the `r_sma_e2e.rs` direct-node
|
|
//! style) over a synthetic dense R-record + equity/exposure series, and asserts
|
|
//! `summarize_r` over `GatedRecorder`'s emitted rows equals `summarize_r` over
|
|
//! the full record, and a `SeriesReducer` summary equals `summarize`'s fields.
|
|
|
|
use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp};
|
|
use aura_backtest::{summarize, summarize_r, PM_RECORD_KINDS, PM_WIDTH};
|
|
use aura_std::{GatedRecorder, SeriesReducer};
|
|
use std::sync::mpsc;
|
|
|
|
const CLOSED: usize = 0;
|
|
const REALIZED_R: usize = 1;
|
|
const OPEN: usize = 11;
|
|
const UNREALIZED_R: usize = 12;
|
|
const ENTRY_PRICE: usize = 6;
|
|
const STOP_PRICE: usize = 7;
|
|
|
|
/// The kind-correct zero for a dense-record column. The PM record is
|
|
/// heterogeneous (`PM_RECORD_KINDS`: Bool/I64/Timestamp slots beside the f64s),
|
|
/// so each unset slot must default to a scalar of its column's kind — a blanket
|
|
/// f64 zero would be rejected by the kind-typed `AnyColumn::push`.
|
|
fn zero_of(kind: ScalarKind) -> Scalar {
|
|
match kind {
|
|
ScalarKind::F64 => Scalar::f64(0.0),
|
|
ScalarKind::I64 => Scalar::i64(0),
|
|
ScalarKind::Bool => Scalar::bool(false),
|
|
ScalarKind::Timestamp => Scalar::ts(Timestamp(0)),
|
|
}
|
|
}
|
|
|
|
/// One dense R-record row. `closed`/`open` set the gate + the open-at-end flag;
|
|
/// the priced fields make `summarize_r`'s R arithmetic well-defined.
|
|
fn pm_row(closed: bool, realized: f64, open: bool, entry: f64, stop: f64) -> Vec<Scalar> {
|
|
let mut v: Vec<Scalar> = PM_RECORD_KINDS.iter().map(|&k| zero_of(k)).collect();
|
|
debug_assert_eq!(v.len(), PM_WIDTH);
|
|
v[CLOSED] = Scalar::bool(closed);
|
|
v[REALIZED_R] = Scalar::f64(realized);
|
|
v[OPEN] = Scalar::bool(open);
|
|
v[UNREALIZED_R] = Scalar::f64(if open { realized } else { 0.0 });
|
|
v[ENTRY_PRICE] = Scalar::f64(entry);
|
|
v[STOP_PRICE] = Scalar::f64(stop);
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn gated_recorder_then_summarize_r_equals_raw_summarize_r() {
|
|
// a realistic mix: holds, closed wins/losses, a hold tail, then an open-at-end row.
|
|
let full: Vec<(Timestamp, Vec<Scalar>)> = [
|
|
pm_row(false, 0.0, false, 0.0, 0.0),
|
|
pm_row(true, 1.5, false, 100.0, 95.0),
|
|
pm_row(false, 0.0, false, 0.0, 0.0),
|
|
pm_row(true, -1.0, false, 100.0, 95.0),
|
|
pm_row(false, 0.0, false, 0.0, 0.0),
|
|
pm_row(false, 0.7, true, 100.0, 95.0), // open at end
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(i, r)| (Timestamp(i as i64 + 1), r))
|
|
.collect();
|
|
|
|
// RAW: summarize_r over the whole dense record.
|
|
let raw = summarize_r(&full, &[]);
|
|
|
|
// FOLDED: drive GatedRecorder (gate = CLOSED), collect its emitted rows, fold.
|
|
let (tx, rx) = mpsc::channel();
|
|
let mut g = GatedRecorder::new(&PM_RECORD_KINDS, CLOSED, tx);
|
|
let mut cols: Vec<AnyColumn> =
|
|
PM_RECORD_KINDS.iter().map(|&k| AnyColumn::with_capacity(k, 1)).collect();
|
|
for (ts, row) in &full {
|
|
for (i, s) in row.iter().enumerate() {
|
|
cols[i].push(*s).unwrap();
|
|
}
|
|
assert_eq!(g.eval(Ctx::new(&cols, *ts)), None);
|
|
}
|
|
g.finalize();
|
|
let folded_rows: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
|
let folded = summarize_r(&folded_rows, &[]);
|
|
|
|
assert_eq!(folded, raw, "GatedRecorder+summarize_r must equal raw summarize_r");
|
|
}
|
|
|
|
#[test]
|
|
fn series_reducer_equals_summarize_pip_fields() {
|
|
let equity: Vec<(Timestamp, f64)> =
|
|
[10.0, 12.0, 8.0, 9.0, 7.0].iter().enumerate().map(|(i, &v)| (Timestamp(i as i64), v)).collect();
|
|
let exposure: Vec<(Timestamp, f64)> =
|
|
[1.0, 1.0, -1.0, 0.0, 1.0].iter().enumerate().map(|(i, &v)| (Timestamp(i as i64), v)).collect();
|
|
|
|
let raw = summarize(&equity, &exposure);
|
|
|
|
// FOLDED: one SeriesReducer per series; read pip fields from their summaries.
|
|
let fold_series = |series: &[(Timestamp, f64)]| -> Vec<Scalar> {
|
|
let (tx, rx) = mpsc::channel();
|
|
let mut r = SeriesReducer::new(tx);
|
|
let mut col = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
|
|
for &(ts, v) in series {
|
|
col[0].push(Scalar::f64(v)).unwrap();
|
|
r.eval(Ctx::new(&col, ts));
|
|
}
|
|
r.finalize();
|
|
rx.try_iter().next().expect("one summary row").1
|
|
};
|
|
let eqs = fold_series(&equity);
|
|
let exs = fold_series(&exposure);
|
|
|
|
assert_eq!(eqs[0].as_f64(), raw.total_pips, "total_pips");
|
|
assert_eq!(eqs[1].as_f64(), raw.max_drawdown, "max_drawdown");
|
|
assert_eq!(exs[2].as_i64() as u64, raw.bias_sign_flips, "bias_sign_flips");
|
|
}
|