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