spec: 0082 vol-slippage cost + cost-graph composition (boss-signed)

Cycle 2 of the "Cost-model graph (in R)" milestone (#148): the milestone's
real architectural claim — the cost graph composes. Two pieces:

- VolSlippageCost (aura-std): a second, state-dependent cost node whose
  per-trade charge scales with a measured volatility input (k * vol /
  |entry - stop|), distinct from ConstantCost's flat charge. The vol is an
  independent short-horizon realized range, deliberately NOT the stop's own
  vol — scaling slippage by the vol the stop already normalizes would collapse
  cost-in-R to a constant (indistinguishable from ConstantCost).
- CostSum (aura-std): the cost-graph output node — sums N cost nodes' 3-field
  cost-in-R records per-field into one aggregate record. So summarize_r and the
  net_r_equity LinComb(4) read the AGGREGATE and stay structurally unchanged
  (one home for cost; n=1 is the identity, keeping the cost path uniform).

Run-path wiring: --cost-per-trade and --slip-vol-mult combine, their costs
summing into the net-R curve; a hoisted vol proxy keeps the single-feed pattern.
A no-cost run stays byte-identical (C18 golden floor). Scope is the run path;
sweep/walkforward/mc still pass None (sweep-path cost remains deferred), and the
general CostNode trait stays deferred (designed against two shipped nodes later).

Auto-signed under /boss on the grounding-check PASS: all 8 load-bearing
existing-behaviour assumptions tie to named green tests. Fork decisions recorded
on #148.

refs #148
This commit is contained in:
2026-06-28 15:47:40 +02:00
parent e82725f2d7
commit 372eae9cac
+480
View File
@@ -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<usize> { 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<usize> { 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<f64>, // --cost-per-trade
slip_vol_mult: Option<f64>, // --slip-vol-mult
}
```
```rust
// signature: the cost tuple now carries the config, not a bare f64.
cost: Option<(CostConfig, mpsc::Sender<(Timestamp, Vec<Scalar>)>, mpsc::Sender<(Timestamp, Vec<Scalar>)>)>,
```
**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 26982731) 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<f64>) -> RunReport
fn run_stage1_r(
data: RunData,
trace: Option<&str>,
const_cost: Option<f64>,
slip_vol_mult: Option<f64>,
) -> 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<f64>`; `parse_run_args` parses
`--slip-vol-mult <f64>` (at most once, `>= 0`), mirroring `--cost-per-trade`;
`run_dispatch` threads both into the stage1-r arm. The usage strings gain
`[--slip-vol-mult <f64>]`.
### `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.