Files
Aura/crates/aura-engine/tests/streaming_reduction_equivalence.rs
T
Brummel 3fe4684190 feat(0081): cost-model graph cycle 1 — ConstantCost node + net-R seam
Lands the first cycle of the "Cost-model graph (in R)" milestone: cost is
now a composable in-graph node, not a post-run scalar (C10).

- ConstantCost (aura-std): a flat round-trip cost per trade, emitted in R
  as a 3-field record {cost_in_r, cum_cost_in_r, open_cost_in_r} isomorphic
  to PositionManagement's R-triple. R-pure: cost_in_r = cost_per_trade /
  |entry-stop|; notional cancels, no equity held.
- summarize_r folds the cost stream into net_expectancy_r, replacing its
  scalar round_trip_cost (one home for cost, no double-count). Positional
  co-temporal join; an empty stream is the gross-R baseline.
- net_r_equity tap (stage1_r_graph): LinComb(4) over [cum_realized_r,
  unrealized_r, -cum_cost_in_r, -open_cost_in_r] -> Recorder, a sibling of
  r_equity; --cost-per-trade on the run path wires the node + tap + fold.
- A no-cost run is byte-identical: no net_r_equity tap, net == gross, the
  C18 golden unchanged.

Tests: exact-subsumption (the node-stream net == mean(r_i - cost_i) over
ALL trades incl. the window-end open one), the in-graph net_r_equity tap +
net < gross E2E, the no-cost golden held. Full suite green; clippy clean.

Implement-time decisions ratified (recorded on #148): the cost stream is
recorded 3-wide (the node's full output; summarize_r reads col 0/2, col 1
feeds the in-graph net curve) — resolving a 2-wide/3-wide inconsistency in
the plan's Step 3; Trade drops its `latched` field (cost now arrives in R),
so r_col ENTRY_PRICE/STOP_PRICE become #[allow(dead_code)] layout contract.

Scoped to the run path (non-reduce); sweep/walkforward/mc pass None — cost
on the reduce-mode sweep path and OOS-pooling cost are a deferred follow-up.

Known pre-existing concern (not this cycle): aura-std lib.rs module doc
still names "realistic broker profiles", retired by the C10 rework.

refs #148
2026-06-28 14:41:45 +02:00

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 `stage1_r_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_engine::{summarize, summarize_r};
use aura_std::{GatedRecorder, SeriesReducer, PM_RECORD_KINDS, PM_WIDTH};
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");
}