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:
@@ -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 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<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.
|
||||||
Reference in New Issue
Block a user