diff --git a/docs/specs/0082-vol-slippage-cost.md b/docs/specs/0082-vol-slippage-cost.md new file mode 100644 index 0000000..9aa701a --- /dev/null +++ b/docs/specs/0082-vol-slippage-cost.md @@ -0,0 +1,480 @@ +# Vol-slippage cost node + cost-graph composition — Design Spec + +**Date:** 2026-06-28 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +> Cycle 2 of the "Cost-model graph (in R)" milestone (#148). Cycle 1 shipped the +> first cost node (`ConstantCost`) and the net-R seam (`net_r_equity` tap + +> `summarize_r` folding a single co-temporal cost stream). This cycle ships the +> milestone's real architectural claim — **the cost graph composes**: a second, +> structurally-different cost node (`VolSlippageCost`, state-dependent) plus an +> aggregator (`CostSum`) that sums any number of cost nodes per-field into one +> cost record, so `summarize_r` and the `net_r_equity` tap stay unchanged. All +> fork decisions are recorded on #148. + +## Goal + +Demonstrate that the C10 cost model is a *composable graph of cost nodes*, not a +single node, by: + +1. adding `VolSlippageCost` — a cost node whose per-trade charge scales with a + measured volatility input (state-dependent), distinct from `ConstantCost`'s + flat charge; +2. adding `CostSum` — the cost-graph **output node**: it sums N cost nodes' + 3-field cost-in-R records per-field into one aggregate cost record; +3. wiring both into the stage1-r run path so `--cost-per-trade` and + `--slip-vol-mult` can be set together, their costs summing into the net-R + curve — with `summarize_r` and `net_r_equity` reading the **aggregate**, + structurally unchanged from cycle 1. + +A run with no cost flag stays a byte-identical gross-R baseline (the C18 golden +floor). Scope is the run path only; sweep / walkforward / mc still pass `None` +(the reduce-mode sweep-path cost remains the named deferral). + +## Architecture + +The cycle-1 seam already consumes a **single 3-field cost stream** +`{cost_in_r, cum_cost_in_r, open_cost_in_r}` (`summarize_r` reads col 0 per +close + col 2 for the window-end open row; `net_r_equity` is `LinComb(4)` over +`[cum_realized_r, unrealized_r, −cum_cost_in_r, −open_cost_in_r]`). The +composition design keeps that single-stream contract intact by inserting **one +aggregator node** between the cost nodes and the seam: + +``` +exec (PositionManagement geometry) ─┬─> ConstantCost ─┐ + │ ├─> CostSum(n) ─┬─> cost Recorder (3-wide) ─> summarize_r +price ─> RollingMax/Min/Sub (vol) ──┴─> VolSlippageCost ─┘ └─> net_r_equity LinComb(4) +``` + +`CostSum` is the cost graph's output: every cost node emits the same 3-field +cost-in-R record, `CostSum` sums them field-by-field, and the rest of the graph +sees exactly the cycle-1 single cost record. Adding an Nth cost node later is one +more `CostSum` input — `summarize_r`'s signature never changes again. For `n = 1` +`CostSum` is the per-field identity, so the cost path is **uniform** (always +cost-nodes → `CostSum` → seam); the single-`ConstantCost` case stays numerically +identical to cycle 1 (identity sum). + +**Why a state-dependent vol input distinct from the stop's vol (load-bearing).** +The stop is vol-based (`StopRule::Vol`, so `|entry − stop| = stop_dist ∝ vol`). A +slippage scaled by *that same* vol would give +`cost_in_R = k·vol / (k_stop·vol) = k/k_stop`, a **constant** — indistinguishable +from `ConstantCost`, defeating the point of a second, state-dependent node. So +`VolSlippageCost` reads an **independent, short-horizon** realized-range vol +(window `SLIP_VOL_LENGTH`, deliberately distinct from `STAGE1_R_STOP_LENGTH`); +`cost_in_R = k·vol_short / stop_dist` then varies trade-to-trade with the +short/long vol ratio — genuinely state-dependent. + +## Concrete code shapes + +### User-facing program (the acceptance evidence) + +A trader who already charges a flat per-trade cost now composes a vol-scaled +slippage on top and reads the *combined* drag on net R: + +```console +$ aura run --harness stage1-r --real GER40 \ + --cost-per-trade 1.0 --slip-vol-mult 0.5 --trace demo +... gross E[R] = -0.007 net E[R] = -0.241 ... + +$ aura chart demo --panels # gross r_equity vs net_r_equity, the cost drag of BOTH +``` + +`--cost-per-trade` alone, `--slip-vol-mult` alone, and both together are all +valid; both-together is the composition the cycle proves. With neither flag, the +output is the byte-identical gross-R baseline (no `net_r_equity` tap, no cost +nodes). + +### `VolSlippageCost` (new node, aura-std) — mirrors `ConstantCost` + +A cost node identical to `ConstantCost` except: one extra `volatility` f64 input, +the param is `slip_vol_mult` (`k`), and the per-trade charge numerator is +`k · volatility` instead of a constant. Same 3-field cost-in-R output, same +withhold/zero-latched discipline. + +```rust +pub struct VolSlippageCost { + slip_vol_mult: f64, + cum: f64, + out: [Cell; 3], +} + +impl VolSlippageCost { + pub fn new(slip_vol_mult: f64) -> Self { + assert!(slip_vol_mult >= 0.0, "VolSlippageCost slip_vol_mult must be >= 0"); + Self { slip_vol_mult, cum: 0.0, out: [Cell::from_f64(0.0); 3] } + } + + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "VolSlippageCost", + NodeSchema { + inputs: vec![ + PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "closed".into() }, + PortSpec { kind: ScalarKind::Bool, firing: Firing::Any, name: "open".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "entry_price".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "stop_price".into() }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "volatility".into() }, + ], + output: vec![ + FieldSpec { name: "cost_in_r".into(), kind: ScalarKind::F64 }, + FieldSpec { name: "cum_cost_in_r".into(), kind: ScalarKind::F64 }, + FieldSpec { name: "open_cost_in_r".into(), kind: ScalarKind::F64 }, + ], + params: vec![ParamSpec { name: "slip_vol_mult".into(), kind: ScalarKind::F64 }], + }, + |p| Box::new(VolSlippageCost::new(p[0].f64())), + ) + } +} + +impl Node for VolSlippageCost { + fn lookbacks(&self) -> Vec { vec![1, 1, 1, 1, 1] } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let closed_w = ctx.bool_in(0); + let open_w = ctx.bool_in(1); + let entry_w = ctx.f64_in(2); + let stop_w = ctx.f64_in(3); + let vol_w = ctx.f64_in(4); + if closed_w.is_empty() || open_w.is_empty() || entry_w.is_empty() + || stop_w.is_empty() || vol_w.is_empty() + { + return None; + } + let closed = closed_w[0]; + let open = open_w[0]; + let latched = (entry_w[0] - stop_w[0]).abs(); + let vol = vol_w[0].max(0.0); // a negative range is impossible; clamp defensively + // Same zero-latched guard as ConstantCost: no valid 1R denominator -> no cost. + let per = if latched > 0.0 { self.slip_vol_mult * vol / latched } else { 0.0 }; + let cost_in_r = if closed { per } else { 0.0 }; + let open_cost_in_r = if open { per } else { 0.0 }; + self.cum += cost_in_r; + self.out = [ + Cell::from_f64(cost_in_r), + Cell::from_f64(self.cum), + Cell::from_f64(open_cost_in_r), + ]; + Some(&self.out) + } + + fn label(&self) -> String { + format!("VolSlippageCost({})", self.slip_vol_mult) + } +} +``` + +### `CostSum` (new node, aura-std) — the cost-graph output + +```rust +/// Sums `n_costs` cost-in-R records per-field into one aggregate cost record — +/// the output node of a C10 cost-model graph. Each cost node contributes the +/// 3-field {cost_in_r, cum_cost_in_r, open_cost_in_r} record; the aggregate is +/// the per-field sum. `n_costs = 1` is the identity. Withholds until every input +/// leg is present (mode-A as-of join, like LinComb). +pub struct CostSum { + n_costs: usize, + out: [Cell; 3], +} + +impl CostSum { + pub fn new(n_costs: usize) -> Self { + assert!(n_costs >= 1, "CostSum needs at least one cost input"); + Self { n_costs, out: [Cell::from_f64(0.0); 3] } + } + + /// `arity` (n_costs) is topology (fixed per blueprint, C19). Inputs are + /// `cost[k].{cost_in_r,cum_cost_in_r,open_cost_in_r}` for k in 0..n_costs, + /// in slot order; the 3-field output mirrors a single cost record. + pub fn builder(n_costs: usize) -> PrimitiveBuilder { + let mut inputs = Vec::with_capacity(n_costs * 3); + for k in 0..n_costs { + for field in ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"] { + inputs.push(PortSpec { + kind: ScalarKind::F64, + firing: Firing::Any, + name: format!("cost[{k}].{field}"), + }); + } + } + PrimitiveBuilder::new( + "CostSum", + NodeSchema { + inputs, + output: vec![ + FieldSpec { name: "cost_in_r".into(), kind: ScalarKind::F64 }, + FieldSpec { name: "cum_cost_in_r".into(), kind: ScalarKind::F64 }, + FieldSpec { name: "open_cost_in_r".into(), kind: ScalarKind::F64 }, + ], + params: vec![], + }, + // arity is captured topology; no per-build params. + move |_| Box::new(CostSum::new(n_costs)), + ) + } +} + +impl Node for CostSum { + fn lookbacks(&self) -> Vec { vec![1; self.n_costs * 3] } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + let mut acc = [0.0_f64; 3]; // [cost_in_r, cum_cost_in_r, open_cost_in_r] + for k in 0..self.n_costs { + for f in 0..3 { + let w = ctx.f64_in(k * 3 + f); + if w.is_empty() { + return None; // withhold until every cost leg is present + } + acc[f] += w[0]; + } + } + self.out = [Cell::from_f64(acc[0]), Cell::from_f64(acc[1]), Cell::from_f64(acc[2])]; + Some(&self.out) + } + + fn label(&self) -> String { + format!("CostSum({})", self.n_costs) + } +} +``` + +### `stage1_r_graph` cost block — before → after + +The signature's `cost` carrier widens from one `f64` to a small two-knob config. +A new module const sits beside `STAGE1_R_STOP_LENGTH`: + +```rust +/// Short-horizon realized-range window for vol-scaled slippage. Deliberately +/// distinct from STAGE1_R_STOP_LENGTH: scaling slippage by the stop's own vol +/// would collapse cost-in-R to a constant (see spec 0082 Architecture). +const SLIP_VOL_LENGTH: i64 = 20; + +/// Which cost nodes the run-path cost graph builds. At least one field is `Some` +/// (the outer `Option` is `None` when no cost flag was given). +struct CostConfig { + const_cost: Option, // --cost-per-trade + slip_vol_mult: Option, // --slip-vol-mult +} +``` + +```rust +// signature: the cost tuple now carries the config, not a bare f64. +cost: Option<(CostConfig, mpsc::Sender<(Timestamp, Vec)>, mpsc::Sender<(Timestamp, Vec)>)>, +``` + +**Single-feed discipline (load-bearing).** Every existing graph feeds the `price` +source-role exactly once, with a multi-target array (`stage1_r_graph` at +`crates/aura-cli/src/main.rs:2673`; the breakout/meanrev E2E graphs likewise). +The vol proxy's `RollingMax`/`RollingMin` also read `price`, so rather than a +second `g.feed` call the proxy is **hoisted above the single main feed** and its +two input ports join the one `price_targets` array — staying on the exact +single-feed pattern the suite already exercises: + +```rust +// Hoisted above the main feed: build the short-horizon vol proxy iff a +// vol-slippage cost is actually wired (run path, non-reduce), so its `price` +// inputs join the SINGLE main feed (no second feed call). +let vol_proxy = match &cost { + Some((cfg, _, _)) if !reduce && cfg.slip_vol_mult.is_some() => { + let vhi = g.add(RollingMax::builder().named("slip_vol_hi").bind("length", Scalar::i64(SLIP_VOL_LENGTH))); + let vlo = g.add(RollingMin::builder().named("slip_vol_lo").bind("length", Scalar::i64(SLIP_VOL_LENGTH))); + let vrange = g.add(Sub::builder().named("slip_vol_range")); + g.connect(vhi.output("value"), vrange.input("lhs")); + g.connect(vlo.output("value"), vrange.input("rhs")); + Some((vhi, vlo, vrange)) + } + _ => None, +}; + +let price = g.source_role("price", ScalarKind::F64); +let mut price_targets = vec![ + fast.input("series"), slow.input("series"), + broker.input("price"), exec.input("price"), +]; +if let Some((vhi, vlo, _)) = vol_proxy { + price_targets.push(vhi.input("series")); + price_targets.push(vlo.input("series")); +} +g.feed(price, price_targets); +``` + +The cost block (replacing lines 2698–2731) builds each named node, wires it to a +`CostSum` slot, then points the existing `net_eq` (`LinComb(4)`) and the 3-wide +cost `Recorder` at the **aggregate** instead of a single node. The vol-slippage +arm consumes the hoisted `vol_proxy` rather than building its own (handles are +`Copy` graph indices — `RollingMax`/`Sub` are used by handle multiple times in +`stage1_breakout_graph` already): + +```rust +if let Some((cfg, tx_net, tx_cost)) = cost { + let n = cfg.const_cost.is_some() as usize + cfg.slip_vol_mult.is_some() as usize; + let agg = g.add(CostSum::builder(n)); + let mut slot = 0usize; + + if let Some(cpt) = cfg.const_cost { + let cc = g.add(ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cpt))); + g.connect(exec.output("closed_this_cycle"), cc.input("closed")); + g.connect(exec.output("open"), cc.input("open")); + g.connect(exec.output("entry_price"), cc.input("entry_price")); + g.connect(exec.output("stop_price"), cc.input("stop_price")); + for field in ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"] { + g.connect(cc.output(field), agg.input(format!("cost[{slot}].{field}").as_str())); + } + slot += 1; + } + + if let Some(svm) = cfg.slip_vol_mult { + let (_, _, vrange) = vol_proxy.expect("vol proxy is built whenever slip_vol_mult is set"); + let vs = g.add(VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(svm))); + g.connect(exec.output("closed_this_cycle"), vs.input("closed")); + g.connect(exec.output("open"), vs.input("open")); + g.connect(exec.output("entry_price"), vs.input("entry_price")); + g.connect(exec.output("stop_price"), vs.input("stop_price")); + g.connect(vrange.output("value"), vs.input("volatility")); + for field in ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"] { + g.connect(vs.output(field), agg.input(format!("cost[{slot}].{field}").as_str())); + } + slot += 1; + } + debug_assert_eq!(slot, n); + + // net_r_equity = cum_realized_r + unrealized_r - Σcum_cost_in_r - Σopen_cost_in_r + let net_eq = g.add( + LinComb::builder(4) + .bind("weights[0]", Scalar::f64(1.0)) + .bind("weights[1]", Scalar::f64(1.0)) + .bind("weights[2]", Scalar::f64(-1.0)) + .bind("weights[3]", Scalar::f64(-1.0)), + ); + g.connect(exec.output("cum_realized_r"), net_eq.input("term[0]")); + g.connect(exec.output("unrealized_r"), net_eq.input("term[1]")); + g.connect(agg.output("cum_cost_in_r"), net_eq.input("term[2]")); + g.connect(agg.output("open_cost_in_r"), net_eq.input("term[3]")); + let net_rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_net)); + g.connect(net_eq.output("value"), net_rec.input("col[0]")); + + // The aggregate cost record summarize_r folds (col 0 per-close, col 2 window-end). + let cost_rec = g.add(Recorder::builder( + vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64], + Firing::Any, + tx_cost, + )); + g.connect(agg.output("cost_in_r"), cost_rec.input("col[0]")); + g.connect(agg.output("cum_cost_in_r"), cost_rec.input("col[1]")); + g.connect(agg.output("open_cost_in_r"), cost_rec.input("col[2]")); +} +``` + +### `run_stage1_r`, `RunArgs`, `parse_run_args` — before → after + +`run_stage1_r` takes both knobs and builds the `CostConfig` when either is set: + +```rust +// before: fn run_stage1_r(data: RunData, trace: Option<&str>, cost: Option) -> RunReport +fn run_stage1_r( + data: RunData, + trace: Option<&str>, + const_cost: Option, + slip_vol_mult: Option, +) -> RunReport { + // ... + let cost = if const_cost.is_some() || slip_vol_mult.is_some() { + Some((CostConfig { const_cost, slip_vol_mult }, tx_net, tx_cost)) + } else { + None + }; + // ... stage1_r_graph(..., cost) +} +``` + +`RunArgs` gains `slip_vol_mult: Option`; `parse_run_args` parses +`--slip-vol-mult ` (at most once, `>= 0`), mirroring `--cost-per-trade`; +`run_dispatch` threads both into the stage1-r arm. The usage strings gain +`[--slip-vol-mult ]`. + +### `summarize_r` — unchanged + +No signature change. It still folds **one** co-temporal 3-field cost stream +(`crates/aura-analysis/src/lib.rs:171`); that stream is now `CostSum`'s recorded +aggregate. This is the whole point of the aggregator: the analysis fold is +agnostic to how many cost nodes the graph ran. + +## Components + +- **`VolSlippageCost`** (`crates/aura-std/src/vol_slippage_cost.rs`, new): + state-dependent cost node; `cost_in_r = slip_vol_mult · volatility / |entry − + stop|`, charged on close, would-be on the open window-end row. +- **`CostSum`** (`crates/aura-std/src/cost_sum.rs`, new): per-field sum of N cost + records; the cost-graph output node; identity for `n = 1`. +- **aura-std `lib.rs`**: `mod` + `pub use` for both new nodes. +- **`stage1_r_graph`** (`crates/aura-cli/src/main.rs`): the cost block above; + `CostConfig` struct + `SLIP_VOL_LENGTH` const; widened `cost` carrier. +- **`run_stage1_r` / `RunArgs` / `parse_run_args` / `run_dispatch`** (same file): + the `--slip-vol-mult` thread-through. + +## Data flow + +Per cycle, `PositionManagement` (`exec`) emits its dense geometry record and the +price feeds the short-horizon `RollingMax/RollingMin/Sub` vol proxy. Each present +cost node reads `{closed, open, entry_price, stop_price[, volatility]}`, emits its +3-field cost-in-R record. `CostSum` sums them per-field into one aggregate record +(co-temporal 1:1 with PM — every cost node and the aggregate emit exactly when PM +emits, preserving the positional-join contract `summarize_r` relies on). The +aggregate feeds the 3-wide cost `Recorder` (→ `summarize_r`) and the +`net_r_equity` `LinComb(4)` tap (→ the net curve). Gross `r_equity` is untouched. + +## Error handling + +- Negative `slip_vol_mult` → `VolSlippageCost::new` panics (constructor invariant, + matching `ConstantCost`); `parse_run_args` also rejects `--slip-vol-mult < 0`. +- `n_costs < 1` → `CostSum::new` panics (never reached: the cost block builds + `CostSum` only inside `if let Some(cost)`, where `n >= 1`). +- Zero latched distance (`entry == stop`) → no cost (the shared 1R-denominator + guard), matching `ConstantCost` and `summarize_r`. +- A negative vol range (impossible from `max − min`, but defended) is clamped to + `0.0`. +- Warm-up: before the vol window fills, `vrange` (hence `VolSlippageCost`) + withholds, so the earliest trades within `SLIP_VOL_LENGTH` bars carry no + slippage; documented, not an error. + +## Testing strategy + +RED-first per task. + +1. **`VolSlippageCost` unit** (in the node module, vol fed directly as an input + column — no graph warm-up): closed charges `k·vol/latched`; open emits the + would-be cost, uncharged to `cum`; `cum` accumulates across closes; zero + latched → no cost; withholds until all five inputs present; negative-mult + panic; `label` carries the mult. Mirrors the `ConstantCost` test set. +2. **`CostSum` unit**: two 3-field inputs sum per-field; `n = 1` is identity; + withholds until every leg present; `n < 1` panic. +3. **Composition E2E** (`crates/aura-engine/tests/` or `aura-cli/tests/`): a + stage1-r run with both `--cost-per-trade` and `--slip-vol-mult` set produces a + net total equal to the **exact per-trade sum** of the two costs — i.e. net == + `mean(rᵢ − cc_i − vs_i)` hand-computed over all trades incl. the window-end + one; and `net_r_equity`'s final sample == that net total (in-graph == + post-run agreement, as cycle 1). +4. **Single-node-via-aggregate**: a `--slip-vol-mult`-only run folds correctly + through `CostSum(1)`; and a `--cost-per-trade`-only run is **numerically + unchanged** from cycle 1 (the `CostSum(1)` identity does not move the net). +5. **No-cost baseline byte-identical**: the C18 golden (no cost flags → no + `net_r_equity` tap, no cost nodes) is unchanged — the regression floor. +6. **Cross-reducer equality** stays green at no-cost (existing test). + +## Acceptance criteria + +- A trader composes a flat cost and a vol-scaled slippage in one invocation and + reads the combined net-R drag (the worked example runs and charts). +- `CostSum` makes the cost graph composable without touching `summarize_r` or the + `net_r_equity` `LinComb(4)` arity (only their *source* moves to the aggregate). +- Composition is exact: net == the per-trade sum of both costs over all trades + incl. the window-end one. +- The no-cost run is byte-identical (C18 golden held); the `--cost-per-trade`-only + run is numerically unchanged from cycle 1. +- Determinism / causality / feed-forward (C1/C2) preserved: the vol proxy is a + trailing realized range (no look-ahead), the cost path is pure feed-forward, no + equity feedback. +- The general `CostNode` trait and the cost-graph composite-builder remain + deferred (designed against two shipped nodes in a later cycle); sweep-path cost + remains deferred.