feat(stage1-r): Sizer + RiskExecutor + R-metric enrichment (iter 2)
Iteration 2 of cycle 0065 (Stage-1 R signal quality) — the node + metric layer. Builds on iter-1's PositionManagement dense R-record + core summarize_r. What ships: - PositionManagement gains a 4th `size` input (slot 3 -> col 10), fed by the Sizer. R is computed size-INVARIANTLY (pure stop-distance ratio), so size never touches realized_r — pinned by a RED test (scaling size leaves every realized_r unchanged) at the node and again end-to-end through the executor. - Sizer (aura-std): `size = risk_budget / stop_distance` — flat-1R (risk_budget 1.0 => one risk unit per trade, size inversely proportional to the stop, not a constant). Reads bias (firing/presence) + stop_distance; the Stage-2 fixed-fractional sizer slots in unchanged (swap risk_budget for risk_fraction*equity, same node shape). - summarize_r enrichment: SQN (sqrt(n)*mean_R/sample-stdev_R; n<2 or zero-variance -> 0), conviction_terciles_r (E[R] by |bias_at_entry| tercile), net_expectancy_r (gross minus one round-trip cost per trade, charged in R via the latched_dist recovered from the entry_price/stop_price columns). summarize_r gains a `round_trip_cost` param (price units). The net-of-cost recovery is tested through the real producer->consumer seam, not just hand-built rows. - RunMetrics.r: Option<RMetrics> with #[serde(default, skip_serializing_if = "Option::is_none")] — legacy runs.jsonl (no `r` key) deserialise to None and a pip-only run's on-disk shape stays byte-unchanged (C14/C18 back-compat). Every RunMetrics literal threaded (report.rs, aura-registry, aura-engine/mc). - RiskExecutor (aura-engine integration-test fixture, sibling of vol_stop_composite): FixedStop -> Sizer -> PositionManagement behind open bias+price input roles, price fanned to both the stop and PM, the Sizer's size into PM. Bias is produced in-graph from the single price source (a second bias *source* would k-way-merge into separate cycles and mark stale prices). The Veto is a DOCUMENTED SEAM, not a runtime node (a pass-through identity is what C19/C23 DCE deletes). Tests: the composite bootstraps + runs + folds to the documented hand value; R invariant under risk_budget while the size column scales; a live-folded RMetrics survives the RunMetrics serde round-trip. The dense-record size column (10) is brought under the cross-crate layout guard (stage1_r_e2e r_col_indices_match_producer_field_layout) so the executor fixture's size-invariance read is drift-protected like the others. Scope: the CLI/recording surface (#129) is sub-split into a separate iteration 3 and is NOT in this commit. Verified: cargo build --workspace clean; cargo test --workspace 500 passed, 0 failed; cargo clippy --workspace --all-targets -D warnings clean. refs #117 #127 #128 #129
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
//! `Sizer` — the flat-1R sizing seam (C10 Stage-1). Turns a protective-stop distance
|
||||
//! into a position `size = risk_budget / stop_distance`: with `risk_budget = 1.0` this is
|
||||
//! true flat-1R (one risk unit per trade) and `size` is inversely proportional to the
|
||||
//! stop — NOT a constant. The `bias` input gates firing (a size is only meaningful when a
|
||||
//! strategy is taking a position) and is the Stage-2 seam: fixed-fractional sizing swaps
|
||||
//! `risk_budget` for `risk_fraction · equity` (an added equity input), same node shape.
|
||||
//! R is computed SIZE-INVARIANTLY downstream, so the Sizer never contaminates signal
|
||||
//! quality — it only scales the (Stage-2) currency exposure.
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// Flat-1R sizer: `size = risk_budget / stop_distance` (`risk_budget` > 0). Emits `None`
|
||||
/// until both inputs are present (warm-up filter, C8).
|
||||
pub struct Sizer {
|
||||
risk_budget: f64,
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Sizer {
|
||||
/// Build a sizer with risk budget `risk_budget` (must be > 0; `1.0` = flat-1R).
|
||||
pub fn new(risk_budget: f64) -> Self {
|
||||
assert!(risk_budget > 0.0, "Sizer risk_budget must be > 0");
|
||||
Self { risk_budget, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe: declares `risk_budget` and builds through `Sizer::new`.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"Sizer",
|
||||
NodeSchema {
|
||||
inputs: vec![
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "bias".into() },
|
||||
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_distance".into() },
|
||||
],
|
||||
output: vec![FieldSpec { name: "size".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "risk_budget".into(), kind: ScalarKind::F64 }],
|
||||
},
|
||||
|p| Box::new(Sizer::new(p[0].f64())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Sizer {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1, 1]
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let bias = ctx.f64_in(0);
|
||||
let dist = ctx.f64_in(1);
|
||||
if bias.is_empty() || dist.is_empty() {
|
||||
return None; // a size needs a (present) bias and a stop distance
|
||||
}
|
||||
let d = dist[0];
|
||||
// a zero/degenerate stop distance yields zero size (no division blow-up); a real
|
||||
// stop rule emits a strictly positive distance, so this guards only warm-up edges.
|
||||
let size = if d > 0.0 { self.risk_budget / d } else { 0.0 };
|
||||
self.out[0] = Cell::from_f64(size);
|
||||
Some(&self.out)
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
format!("Sizer({})", self.risk_budget)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
fn eval_once(s: &mut Sizer, bias: f64, dist: f64) -> Option<f64> {
|
||||
let mut c = vec![
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
];
|
||||
c[0].push(Scalar::f64(bias)).unwrap();
|
||||
c[1].push(Scalar::f64(dist)).unwrap();
|
||||
s.eval(Ctx::new(&c, Timestamp(0))).map(|o| o[0].f64())
|
||||
}
|
||||
|
||||
// (4) flat-1R invariant: size * stop_distance == risk_budget for every stop distance.
|
||||
#[test]
|
||||
fn sizer_is_flat_1r_invariant() {
|
||||
for budget in [1.0_f64, 2.5] {
|
||||
let mut s = Sizer::new(budget);
|
||||
for dist in [0.5_f64, 2.0, 10.0, 37.5] {
|
||||
let size = eval_once(&mut s, 1.0, dist).expect("both inputs present");
|
||||
assert!(
|
||||
(size * dist - budget).abs() < 1e-9,
|
||||
"size*dist must equal risk_budget; budget={budget} dist={dist} size={size}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "risk_budget must be > 0")]
|
||||
fn sizer_panics_on_zero_risk_budget() {
|
||||
let _ = Sizer::new(0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "risk_budget must be > 0")]
|
||||
fn sizer_panics_on_negative_risk_budget() {
|
||||
let _ = Sizer::new(-1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizer_is_none_until_both_inputs_present() {
|
||||
let mut s = Sizer::new(1.0);
|
||||
let mut c = vec![
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1),
|
||||
];
|
||||
// only bias present -> None
|
||||
c[0].push(Scalar::f64(1.0)).unwrap();
|
||||
assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), None);
|
||||
// both present -> Some(risk_budget / dist) = 1.0/10.0 = 0.1
|
||||
c[1].push(Scalar::f64(10.0)).unwrap();
|
||||
assert_eq!(s.eval(Ctx::new(&c, Timestamp(0))), Some([Cell::from_f64(0.1)].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_slots_are_named_bias_and_stop_distance() {
|
||||
let b = Sizer::builder();
|
||||
let names: Vec<&str> = b.schema().inputs.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["bias", "stop_distance"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user