feat(0082): cost-graph composition — VolSlippageCost + CostSum net-R aggregate

Cycle 2 of the "Cost-model graph (in R)" milestone (#148): the milestone's real
architectural claim — the cost graph composes. Two cost nodes now sum into one
net-R curve while summarize_r and the net_r_equity tap stay structurally
unchanged.

- VolSlippageCost (aura-std): a second, STATE-DEPENDENT cost node; per-trade
  charge = slip_vol_mult * volatility / |entry-stop|, in R. The vol is an
  independent short-horizon realized range (SLIP_VOL_LENGTH=5, distinct from the
  stop's EWMA-3) — scaling slippage by the stop's own vol 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. summarize_r and net_r_equity
  read the aggregate (one home for cost; n=1 is the identity, so the cost path is
  uniform). A future CostNode trait (deferred) will unify the cost-triple the two
  producer nodes currently restate by-convention.
- Run path: --cost-per-trade and --slip-vol-mult combine, their costs summing into
  the net-R curve; a hoisted vol proxy (RollingMax-RollingMin) keeps the single
  feed. Run-path-scoped; sweep/walkforward/mc pass None.

Co-temporality contract (the load-bearing design decision; corrected from the
signed spec after the implement-loop correctly BLOCKED Task 3 on it). summarize_r
positional-joins cost[i] <-> record[i], so the cost stream must be co-temporal
1:1 with the PM record. A cost node is therefore gated ONLY by the PM geometry
(closed/open/entry/stop); a not-yet-warm state input (the vol proxy warms later
than PM) contributes 0 cost that cycle rather than withholding and desyncing the
stream. This makes co-temporality structural + warmup-independent, preserves the
C18 golden, and generalizes to any future cost factor. The rejected alternative
(a key-join in summarize_r) would have moved the golden and pushed cost-graph
logic into the post-run fold. Recorded on #148.

Tests: VolSlippageCost + CostSum unit sets (incl. the co-temporal-zero-during-
warmup case); the CLI composition run (both flags -> net_both < net_flat,
net_r_equity persisted); node-level EXACT additive composition (net_both ==
net_flat + net_vol - gross over the real nodes), the aggregate net_r_equity ==
post-run net total, and CostSum(1) identity. Full workspace suite green; clippy
-D warnings clean; the no-cost C18 golden byte-identical (the regression floor).

Deferred (later cycles of this milestone): the general CostNode trait + cost-graph
composite-builder (now justified by two concrete nodes), the conviction-weighting
R-aggregation axis, and cost on the reduce-mode sweep path. Spec + plan amended to
the corrected contract; both are cycle ephemera (git rm at cycle close).

refs #148
This commit is contained in:
2026-06-28 16:51:48 +02:00
parent 31ef46314a
commit 7f3756a395
8 changed files with 725 additions and 59 deletions
+22 -14
View File
@@ -68,8 +68,9 @@ use aura_core::{
/// A volatility-scaled per-trade slippage, emitted in R. Inputs are the four
/// executor-exposed geometry fields `closed`/`open`/`entry_price`/`stop_price`
/// plus a `volatility` stream (price units). Emits `None` until all five inputs
/// are present this cycle.
/// plus a `volatility` stream (price units). Withholds (`None`) only until the
/// four geometry inputs are present; a not-yet-warm `volatility` contributes 0
/// cost, so the stream stays co-temporal 1:1 with the PM record.
pub struct VolSlippageCost {
slip_vol_mult: f64,
cum: f64,
@@ -117,15 +118,15 @@ impl Node for VolSlippageCost {
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()
{
// Gate only on the PM geometry (co-temporality contract); a not-yet-warm
// volatility proxy contributes 0 cost rather than withholding the row.
if closed_w.is_empty() || open_w.is_empty() || entry_w.is_empty() || stop_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 realized range is non-negative; clamp defensively
let vol = if vol_w.is_empty() { 0.0 } else { vol_w[0] }; // 0 during proxy warm-up
// 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 };
@@ -167,15 +168,21 @@ mod tests {
}
#[test]
fn withholds_until_volatility_present() {
fn vol_not_yet_warm_emits_zero_cost_co_temporally() {
// The realized-range proxy warms after the PM geometry; during warm-up the
// node must still EMIT (a 0-cost row) so the cost stream stays 1:1 with the
// PM record — withholding here would desync the positional join.
let mut c = VolSlippageCost::new(0.5);
let mut inputs = cols();
inputs[0].push(Scalar::bool(true)).unwrap();
inputs[0].push(Scalar::bool(true)).unwrap(); // closed
inputs[1].push(Scalar::bool(false)).unwrap();
inputs[2].push(Scalar::f64(100.0)).unwrap();
inputs[3].push(Scalar::f64(96.0)).unwrap();
// volatility column still empty -> withhold
assert_eq!(c.eval(Ctx::new(&inputs, Timestamp(0))), None);
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
// volatility column empty (proxy not warm) -> vol = 0 -> 0 cost, but a row IS emitted
assert_eq!(
c.eval(Ctx::new(&inputs, Timestamp(0))),
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.0)].as_slice())
);
}
#[test]
@@ -534,9 +541,10 @@ and beside the `COL_PORTS` static, add:
```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 (spec 0082).
const SLIP_VOL_LENGTH: i64 = 20;
/// distinct from `STAGE1_R_STOP_LENGTH` (3): scaling slippage by the stop's own
/// vol would collapse cost-in-R to a constant (spec 0082). Short enough to warm
/// within the synthetic smoke fixture so the run path exercises non-zero slippage.
const SLIP_VOL_LENGTH: i64 = 5;
/// The maximum number of cost nodes the run-path cost graph wires (flat cost +
/// vol slippage). Sizes the interned `CostSum` input-port names below.
+25 -10
View File
@@ -65,6 +65,19 @@ from `ConstantCost`, defeating the point of a second, state-dependent node. So
`cost_in_R = k·vol_short / stop_dist` then varies trade-to-trade with the
short/long vol ratio — genuinely state-dependent.
**Co-temporality contract (load-bearing — the cost stream stays 1:1 with PM).**
`summarize_r` positional-joins `cost[i] ↔ record[i]`, so the cost stream MUST be
co-temporal 1:1 with the PM record. A cost node is therefore gated **only by the
PM trade-geometry** (`closed`/`open`/`entry`/`stop`); any not-yet-warm state input
(here the realized-range proxy, which warms later than PM) contributes **0 cost**
that cycle rather than withholding — the node still emits its row. This makes
co-temporality structural and warmup-independent, preserves the C18 golden, and
generalizes to any future state-dependent cost factor (a recorded swap rate, etc.).
`ConstantCost` satisfies it trivially (no warming factor); only `VolSlippageCost`
needs the missing-factor→0 rule. `SLIP_VOL_LENGTH` is kept short (5) so the vol
proxy warms within the synthetic smoke fixture and the run path exercises non-zero
slippage.
## Concrete code shapes
### User-facing program (the acceptance evidence)
@@ -137,15 +150,15 @@ impl Node for VolSlippageCost {
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()
{
// Gate only on the PM geometry (co-temporality contract); a not-yet-warm
// volatility proxy contributes 0 cost rather than withholding the row.
if closed_w.is_empty() || open_w.is_empty() || entry_w.is_empty() || stop_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
let vol = if vol_w.is_empty() { 0.0 } else { vol_w[0] }; // 0 during proxy warm-up
// 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 };
@@ -246,9 +259,10 @@ 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;
/// distinct from STAGE1_R_STOP_LENGTH (3): scaling slippage by the stop's own vol
/// would collapse cost-in-R to a constant (see spec 0082 Architecture). Short
/// enough to warm within the synthetic smoke fixture so slippage actually bites.
const SLIP_VOL_LENGTH: i64 = 5;
/// 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).
@@ -434,9 +448,10 @@ aggregate feeds the 3-wide cost `Recorder` (→ `summarize_r`) and the
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.
- Warm-up: before the vol window fills the proxy emits nothing, so
`VolSlippageCost` reads a missing vol as 0 and charges 0 slippage that cycle — it
still EMITS its row (co-temporality contract), so the cost stream stays 1:1 with
the PM record. `SLIP_VOL_LENGTH` is short enough to warm within the fixture.
## Testing strategy