Files
Aura/crates/aura-std/src/sizer.rs
T
Brummel 4de6d5cbad rename: retire the stage1-* family for the r-family (r-sma / r-breakout / r-meanrev)
The stage1 ordinal has been dead vocabulary since the C10 reframe dropped
the two-stage research model: a 1 structurally implies a 2 that no longer
exists. The family is renamed by its live discriminator - the R yardstick -
with members named by their signal, uniform with their signal-named
siblings (sma, macd, momentum):

- selectors: stage1-r -> r-sma, stage1-breakout -> r-breakout,
  stage1-meanrev -> r-meanrev (old tokens are usage errors, exit 2 - no
  silent alias)
- identifiers: Strategy::RSma/RBreakout/RMeanRev, HarnessKind::RSma,
  r_sma_*/r_breakout_*/r_meanrev_*, wrap_r, run_signal_r, RGrid, R_SMA_*
- persisted identity: the sma_signal composite (param prefix
  sma_signal.fast.length / .slow.length; fixtures regenerated; content-ids
  shift - no test pins a literal hash, the registry parses no record names)
- e2e test files git-mv'd to r_sma_e2e / r_breakout_e2e / r_meanrev_e2e
- dead Stage-1/Stage-2 prose reworded to post-C10 vocabulary across
  rustdoc, Cargo.tomls, ledger live lines, and the glossary (historical
  entries stay; fieldtests corpus untouched)
- CLAUDE.md invariant 7 rewritten to record the ratified C10 reframe
  faithfully (the token-swap alone would have laundered the retired
  gated-currency/realistic-broker design into unmarked live prose); the
  unbacked account-mode clause dropped

Verification: cargo build/test --workspace green (51 targets), clippy
-D warnings clean, doc build clean, acceptance grep gate leaves exactly
the one resampling-stage false positive (harness.rs), smoke: --harness
r-sma runs, --harness stage1-r exits 2 with the new usage line.

Decision log: forks and rationale recorded on the issue (reconciliation
+ implementation-phase comments).

closes #174
2026-07-02 12:03:09 +02:00

133 lines
5.0 KiB
Rust

//! `Sizer` — the flat-1R sizing seam (C10). 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 live/deploy-edge 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 (deploy-edge) 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"]);
}
}