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
21 KiB
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_equitytap +summarize_rfolding 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, sosummarize_rand thenet_r_equitytap 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:
- adding
VolSlippageCost— a cost node whose per-trade charge scales with a measured volatility input (state-dependent), distinct fromConstantCost's flat charge; - 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; - wiring both into the stage1-r run path so
--cost-per-tradeand--slip-vol-multcan be set together, their costs summing into the net-R curve — withsummarize_randnet_r_equityreading 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:
$ 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.
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
/// 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:
/// 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
}
// 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:
// 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):
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:
// 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 forn = 1.- aura-std
lib.rs:mod+pub usefor both new nodes. stage1_r_graph(crates/aura-cli/src/main.rs): the cost block above;CostConfigstruct +SLIP_VOL_LENGTHconst; widenedcostcarrier.run_stage1_r/RunArgs/parse_run_args/run_dispatch(same file): the--slip-vol-multthread-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::newpanics (constructor invariant, matchingConstantCost);parse_run_argsalso rejects--slip-vol-mult < 0. n_costs < 1→CostSum::newpanics (never reached: the cost block buildsCostSumonly insideif let Some(cost), wheren >= 1).- Zero latched distance (
entry == stop) → no cost (the shared 1R-denominator guard), matchingConstantCostandsummarize_r. - A negative vol range (impossible from
max − min, but defended) is clamped to0.0. - Warm-up: before the vol window fills,
vrange(henceVolSlippageCost) withholds, so the earliest trades withinSLIP_VOL_LENGTHbars carry no slippage; documented, not an error.
Testing strategy
RED-first per task.
VolSlippageCostunit (in the node module, vol fed directly as an input column — no graph warm-up): closed chargesk·vol/latched; open emits the would-be cost, uncharged tocum;cumaccumulates across closes; zero latched → no cost; withholds until all five inputs present; negative-mult panic;labelcarries the mult. Mirrors theConstantCosttest set.CostSumunit: two 3-field inputs sum per-field;n = 1is identity; withholds until every leg present;n < 1panic.- Composition E2E (
crates/aura-engine/tests/oraura-cli/tests/): a stage1-r run with both--cost-per-tradeand--slip-vol-multset 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; andnet_r_equity's final sample == that net total (in-graph == post-run agreement, as cycle 1). - Single-node-via-aggregate: a
--slip-vol-mult-only run folds correctly throughCostSum(1); and a--cost-per-trade-only run is numerically unchanged from cycle 1 (theCostSum(1)identity does not move the net). - No-cost baseline byte-identical: the C18 golden (no cost flags → no
net_r_equitytap, no cost nodes) is unchanged — the regression floor. - 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).
CostSummakes the cost graph composable without touchingsummarize_ror thenet_r_equityLinComb(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
CostNodetrait and the cost-graph composite-builder remain deferred (designed against two shipped nodes in a later cycle); sweep-path cost remains deferred.