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