d8c6938027
C29 compile/unit seam, tasks 4+5 of the self-description plan. aura-engine (task 4): derive_signature stamps doc: "" with the stance recorded in-code -- a derived composite signature is graph wiring, not a vocabulary entry; no seam walks its doc, the described surface is the composite's own doc at the register seam. All in-crate test literals thread doc: "test-only schema". Domain crates (task 5): the 12 production builder sites in aura-market / aura-strategy / aura-backtest carry authored meaning lines; test sites in aura-backtest / aura-composites / aura-ingest thread the test-only doc. Three texts were corrected against the actual node semantics after quality review rather than kept from the first authoring pass: SimBroker is the frictionless integrator of held exposure times price return into cumulative pip equity (no fills/stops/lifecycle -- that is PositionManagement's domain), the shared cost-node line names the PM-geometry inputs and both charge modes (AtClose / PerHeldCycle) instead of a "per-trade gross R" mapping, and LongOnly's line conditions on its enabled param and speaks port-term "exposure". Gates: cargo test green for all six touched crates (engine, market, strategy, backtest, composites, ingest); the workspace-wide gate follows task 6 (aura-runner is the one remaining unthreaded site, E0063 by design until then). refs #316
134 lines
5.1 KiB
Rust
134 lines
5.1 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 }],
|
|
doc: "size = risk_budget / stop_distance at the deploy edge; bias only gates firing, research runs flat-1R",
|
|
},
|
|
|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"]);
|
|
}
|
|
}
|