audit(0082): cycle close — drift-clean; C10 cycle-0082 note + cost-flag validation symmetry
Cycle 0082 (cost-graph composition, cycle 2) closes drift-clean against C10. The
architect review confirms the cycle is fundamentally sound:
What holds: VolSlippageCost + CostSum are ordinary aura-std downstream nodes
(C9/C8), cost stays out of aura-engine; the vol proxy is a trailing realized range
(C2, no look-ahead); feed-forward, no equity feedback (C1); the co-temporality
contract (gate cost nodes on PM geometry only, 0-cost on a cold state input) is
consistent with C1/C2/C9 and the cycle-1 positional join, and charging 0 during
warm-up is honest (no estimate yet, not a fabricated guess). The no-cost C18 golden
is byte-identical.
Drift resolved at close:
- [medium -> fixed] docs/design/INDEX.md (C10): added the cycle-0082 realization
note. The load-bearing co-temporality contract (generalizes to all future cost
factors) lived only in code comments + spec 0082; the ledger is now its durable
home once the spec is removed.
- [low -> fixed] crates/aura-cli/src/main.rs: --cost-per-trade now rejects negatives
at parse, symmetric with --slip-vol-mult (it previously deferred to the node
assert's panic). Run-path tests + the no-cost golden stay green.
Carry-on (documented deferrals, not this cycle's debt to fix):
- The 3-field cost triple {cost_in_r, cum_cost_in_r, open_cost_in_r} is restated
by-convention across ConstantCost, VolSlippageCost, and CostSum (compiler-unlinked
lockstep) — to be unified by the general CostNode trait, now justified by two
concrete nodes (#148).
- SLIP_VOL_LENGTH=5 is sized to warm within the synthetic smoke fixture; documented
in the const doc (on real M1 data any reasonable window warms).
- A future per-cycle-held accrual (carry/funding) accrues over holding duration,
which will force a summarize_r fold change and stress the positional seam — flagged
for that deferred cycle.
Regression gate green and unchanged: full workspace suite 0-fail, clippy --workspace
--all-targets -D warnings clean. No baseline moved -> no ratify.
Spec + plan ephemera removed (git rm docs/specs/0082, docs/plans/0082).
refs #148
This commit is contained in:
@@ -3206,7 +3206,11 @@ fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
}
|
||||
"--cost-per-trade" if cost.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
cost = Some(value.parse().map_err(|_| usage())?);
|
||||
let v: f64 = value.parse().map_err(|_| usage())?;
|
||||
if v < 0.0 {
|
||||
return Err(usage());
|
||||
}
|
||||
cost = Some(v);
|
||||
tail = t;
|
||||
}
|
||||
"--slip-vol-mult" if slip_vol_mult.is_none() => {
|
||||
|
||||
@@ -733,6 +733,30 @@ composite-builder, data-grounded factors (realized-vol → slippage, recorded-ra
|
||||
swap), per-cycle-held accrual (carry / funding), and the conviction-weighting
|
||||
R-aggregation axis. Decision log: #148.
|
||||
|
||||
**Realization (cycle 0082 — cost-graph composition, cycle 2, #148).** The cost graph
|
||||
**composes**: a second, *state-dependent* cost node plus an aggregator prove that two
|
||||
cost nodes sum into one net-R curve with `summarize_r` and the `net_r_equity` tap
|
||||
structurally unchanged. `VolSlippageCost` (`aura-std`) charges `slip_vol_mult · vol /
|
||||
|entry − stop|` in R, reading an **independent short-horizon realized-range** vol
|
||||
(`RollingMax − RollingMin`, window distinct from the stop's own vol — scaling slippage
|
||||
by the stop's vol would collapse cost-in-R to a constant, indistinguishable from
|
||||
`ConstantCost`). `CostSum` (`aura-std`) is the cost-graph **output node**: it sums `N`
|
||||
cost nodes' 3-field cost-in-R records **per-field** into one aggregate (`n = 1` is the
|
||||
identity), so the seam consumes a single cost stream regardless of node count — one
|
||||
home for cost, the positional join unchanged. **Co-temporality contract (load-bearing,
|
||||
generalizes to all future factors):** since `summarize_r` positionally joins `cost[i]
|
||||
↔ record[i]`, a cost node is gated **only by the PM trade-geometry**; any not-yet-warm
|
||||
state input (the vol proxy warms later than PM) contributes **0 cost** that cycle
|
||||
rather than withholding — the node still emits its row, so the cost stream stays
|
||||
co-temporal 1:1 with the PM record. This makes co-temporality structural and
|
||||
warm-up-independent, preserves the C18 golden, and is honest (no slippage estimate yet
|
||||
→ no charge). `ConstantCost` satisfies it trivially; only state-dependent nodes need
|
||||
the missing-factor → 0 rule. Wired on the **run path** via `--slip-vol-mult`,
|
||||
composable with `--cost-per-trade` (their costs sum); sweep / walk-forward / mc pass
|
||||
`None`. The 3-field cost triple is now restated by-convention across the two producers
|
||||
+ `CostSum` (a compiler-unlinked lockstep) — to be unified by the still-deferred
|
||||
general `CostNode` trait (now justified by two concrete nodes). Decision log: #148.
|
||||
|
||||
**Reframe (2026-06-23, #117 — exposure → bias, R as the signal-quality unit).**
|
||||
[HISTORY — its R spine survives into the 2026-06-28 contract; its Stage-2 currency /
|
||||
realistic-broker / register / flat-1R-vs-compounding portions are SUPERSEDED by that
|
||||
|
||||
@@ -1,973 +0,0 @@
|
||||
# Vol-slippage cost node + cost-graph composition — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0082-vol-slippage-cost.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Ship a second, state-dependent cost node (`VolSlippageCost`) and a
|
||||
cost-graph aggregator (`CostSum`), wired into the stage1-r run path so two cost
|
||||
nodes compose into one net-R curve while `summarize_r` and the `net_r_equity` tap
|
||||
stay structurally unchanged.
|
||||
|
||||
**Architecture:** Both new nodes live in `aura-std` and emit the cycle-1 3-field
|
||||
cost-in-R record `{cost_in_r, cum_cost_in_r, open_cost_in_r}`. `CostSum` is the
|
||||
cost-graph output: it sums N cost nodes' records per-field, so the single 3-wide
|
||||
cost stream the seam already consumes is now the aggregate (`n=1` is the
|
||||
identity). The run path inserts the cost nodes → `CostSum(n)` → the existing
|
||||
net/cost recorders; a hoisted short-horizon vol proxy (`RollingMax−RollingMin` of
|
||||
price) feeds `VolSlippageCost.volatility` via the single `price` feed.
|
||||
|
||||
**Tech Stack:** `aura-core` (`Node`/`PrimitiveBuilder`/`Cell`), `aura-std`
|
||||
(node library), `aura-cli` (the stage1-r harness graph + CLI), `aura-engine`
|
||||
(graph builder, E2E tests), `aura-analysis` (`summarize_r`, unchanged).
|
||||
|
||||
---
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Create: `crates/aura-std/src/vol_slippage_cost.rs` — the `VolSlippageCost` node + unit tests
|
||||
- Create: `crates/aura-std/src/cost_sum.rs` — the `CostSum` aggregator + unit tests
|
||||
- Modify: `crates/aura-std/src/lib.rs:18-72` — `mod` + `pub use` for both nodes
|
||||
- Modify: `crates/aura-cli/src/main.rs:31-34` — aura-std imports
|
||||
- Modify: `crates/aura-cli/src/main.rs:2562-2566` — `SLIP_VOL_LENGTH`/`CostConfig`/`COST_SUM_PORTS`
|
||||
- Modify: `crates/aura-cli/src/main.rs:2615-2735` — `stage1_r_graph` signature + vol hoist + cost block
|
||||
- Modify: `crates/aura-cli/src/main.rs:2956-2970` — `run_stage1_r` signature + cost bundle
|
||||
- Modify: `crates/aura-cli/src/main.rs:3059-3156` — `RunArgs`/`parse_run_args`/`run_dispatch`/`USAGE`
|
||||
- Modify: `crates/aura-cli/src/main.rs:4373` — the test call site of `run_stage1_r`
|
||||
- Test: `crates/aura-cli/tests/cli_run.rs` — CLI composition test (both flags); golden stays green
|
||||
- Test: `crates/aura-engine/tests/stage1_r_e2e.rs` — node-level exact-sum composition tests
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `VolSlippageCost` node (aura-std)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-std/src/vol_slippage_cost.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs:18-72`
|
||||
|
||||
- [ ] **Step 1: Write the node module with its tests**
|
||||
|
||||
Create `crates/aura-std/src/vol_slippage_cost.rs` with exactly:
|
||||
|
||||
```rust
|
||||
//! `VolSlippageCost` — a slippage cost that scales with a measured volatility
|
||||
//! input, charged once per closed trade, in R. The second cost node of the C10
|
||||
//! cost-model graph and the first *state-dependent* one: identical in shape to
|
||||
//! [`crate::ConstantCost`] but its per-trade charge numerator is
|
||||
//! `slip_vol_mult · volatility` instead of a flat constant, so the cost-in-R
|
||||
//! varies trade-to-trade. R-pure: `slip_vol_mult · vol / |entry - stop|`;
|
||||
//! notional cancels (C10). The vol is supplied as an input (an upstream
|
||||
//! realized-range estimator), kept independent of the stop's own vol — scaling
|
||||
//! by the stop's vol would collapse cost-in-R to a constant (spec 0082).
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// 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). 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,
|
||||
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] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe: one `slip_vol_mult` F64 knob; five inputs.
|
||||
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);
|
||||
// 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 = 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 };
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
fn cols() -> Vec<AnyColumn> {
|
||||
vec![
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // entry
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // stop
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // volatility
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_geometry_yet_withholds() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let inputs = cols(); // all columns empty
|
||||
assert_eq!(c.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(); // closed
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
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]
|
||||
fn closed_charges_mult_times_vol_over_latched() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut inputs = cols();
|
||||
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(); // latched 4.0
|
||||
inputs[4].push(Scalar::f64(3.0)).unwrap(); // vol 3.0
|
||||
// per = 0.5 * 3.0 / 4.0 = 0.375; cum = 0.375; open = 0.0
|
||||
assert_eq!(
|
||||
c.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.375), Cell::from_f64(0.375), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_emits_would_be_cost_not_charged_to_cum() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut inputs = cols();
|
||||
inputs[0].push(Scalar::bool(false)).unwrap();
|
||||
inputs[1].push(Scalar::bool(true)).unwrap(); // open
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(96.0)).unwrap(); // latched 4.0
|
||||
inputs[4].push(Scalar::f64(3.0)).unwrap(); // vol 3.0
|
||||
// cost_in_r = 0; cum 0; open_cost_in_r = 0.375
|
||||
assert_eq!(
|
||||
c.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.375)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_latched_contributes_no_cost() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut inputs = cols();
|
||||
inputs[0].push(Scalar::bool(true)).unwrap();
|
||||
inputs[1].push(Scalar::bool(false)).unwrap();
|
||||
inputs[2].push(Scalar::f64(100.0)).unwrap();
|
||||
inputs[3].push(Scalar::f64(100.0)).unwrap(); // latched 0 -> no divide
|
||||
inputs[4].push(Scalar::f64(3.0)).unwrap();
|
||||
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]
|
||||
fn cum_accumulates_across_closes() {
|
||||
let mut c = VolSlippageCost::new(0.5);
|
||||
let mut a = cols();
|
||||
a[0].push(Scalar::bool(true)).unwrap();
|
||||
a[1].push(Scalar::bool(false)).unwrap();
|
||||
a[2].push(Scalar::f64(100.0)).unwrap();
|
||||
a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4
|
||||
a[4].push(Scalar::f64(3.0)).unwrap(); // 0.5*3/4 = 0.375
|
||||
let _ = c.eval(Ctx::new(&a, Timestamp(0)));
|
||||
let mut b = cols();
|
||||
b[0].push(Scalar::bool(true)).unwrap();
|
||||
b[1].push(Scalar::bool(false)).unwrap();
|
||||
b[2].push(Scalar::f64(100.0)).unwrap();
|
||||
b[3].push(Scalar::f64(98.0)).unwrap(); // latched 2
|
||||
b[4].push(Scalar::f64(4.0)).unwrap(); // 0.5*4/2 = 1.0; cum 1.375
|
||||
assert_eq!(
|
||||
c.eval(Ctx::new(&b, Timestamp(1))),
|
||||
Some([Cell::from_f64(1.0), Cell::from_f64(1.375), Cell::from_f64(0.0)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_carries_the_mult() {
|
||||
assert_eq!(VolSlippageCost::new(0.5).label(), "VolSlippageCost(0.5)");
|
||||
assert_eq!(VolSlippageCost::new(2.0).label(), "VolSlippageCost(2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "slip_vol_mult must be >= 0")]
|
||||
fn new_panics_on_negative_mult() {
|
||||
let _ = VolSlippageCost::new(-1.0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the module in `lib.rs`**
|
||||
|
||||
In `crates/aura-std/src/lib.rs`, add to the `mod` block (after `mod sub;`,
|
||||
line 43, keeping it the last entry alphabetically):
|
||||
|
||||
```rust
|
||||
mod vol_slippage_cost;
|
||||
```
|
||||
|
||||
And to the `pub use` block (after `pub use sub::Sub;`, line 72):
|
||||
|
||||
```rust
|
||||
pub use vol_slippage_cost::VolSlippageCost;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the node's tests**
|
||||
|
||||
Run: `cargo test -p aura-std vol_slippage`
|
||||
Expected: PASS — 8 tests in `vol_slippage_cost::tests` pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `CostSum` aggregator node (aura-std)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/aura-std/src/cost_sum.rs`
|
||||
- Modify: `crates/aura-std/src/lib.rs:18-72`
|
||||
|
||||
- [ ] **Step 1: Write the aggregator module with its tests**
|
||||
|
||||
Create `crates/aura-std/src/cost_sum.rs` with exactly:
|
||||
|
||||
```rust
|
||||
//! `CostSum` — the output node of a C10 cost-model graph: it sums `n_costs`
|
||||
//! cost-in-R records per-field into one aggregate record, so any number of cost
|
||||
//! nodes collapses to the single 3-field cost stream the net-R seam already
|
||||
//! consumes (`summarize_r` + the `net_r_equity` tap stay unchanged). 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, so
|
||||
//! the cost path is uniform whether one or several cost nodes are wired.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder, ScalarKind,
|
||||
};
|
||||
|
||||
/// Per-field sum of `n_costs` cost-in-R records. Inputs are
|
||||
/// `cost[k].{cost_in_r,cum_cost_in_r,open_cost_in_r}` for `k in 0..n_costs`, in
|
||||
/// slot order (3 per cost node); the 3-field output mirrors a single cost record.
|
||||
/// Emits `None` 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] }
|
||||
}
|
||||
|
||||
/// The param-generic recipe. `n_costs` is topology (fixed per blueprint, C19),
|
||||
/// captured by the build closure (no per-build params). The input names are a
|
||||
/// lockstep contract with the connect side (`cost[k].<field>`).
|
||||
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![],
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{AnyColumn, Scalar, Timestamp};
|
||||
|
||||
fn f64_cols(n: usize) -> Vec<AnyColumn> {
|
||||
(0..n).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_records_sum_per_field() {
|
||||
let mut s = CostSum::new(2);
|
||||
let mut inputs = f64_cols(6);
|
||||
// cost[0] = [0.5, 0.5, 0.0]; cost[1] = [0.375, 1.0, 0.2]
|
||||
for (i, v) in [0.5, 0.5, 0.0, 0.375, 1.0, 0.2].into_iter().enumerate() {
|
||||
inputs[i].push(Scalar::f64(v)).unwrap();
|
||||
}
|
||||
// per-field sum: [0.875, 1.5, 0.2]
|
||||
assert_eq!(
|
||||
s.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.875), Cell::from_f64(1.5), Cell::from_f64(0.2)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_one_is_identity() {
|
||||
let mut s = CostSum::new(1);
|
||||
let mut inputs = f64_cols(3);
|
||||
for (i, v) in [0.5, 1.25, 0.3].into_iter().enumerate() {
|
||||
inputs[i].push(Scalar::f64(v)).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval(Ctx::new(&inputs, Timestamp(0))),
|
||||
Some([Cell::from_f64(0.5), Cell::from_f64(1.25), Cell::from_f64(0.3)].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withholds_until_every_leg_present() {
|
||||
let mut s = CostSum::new(2);
|
||||
let mut inputs = f64_cols(6);
|
||||
// only the first cost node's three fields present -> withhold
|
||||
for i in 0..3 {
|
||||
inputs[i].push(Scalar::f64(1.0)).unwrap();
|
||||
}
|
||||
assert_eq!(s.eval(Ctx::new(&inputs, Timestamp(0))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_slots_are_named_cost_index_field() {
|
||||
let s = CostSum::builder(2);
|
||||
let names: Vec<String> = s.schema().inputs.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"cost[0].cost_in_r", "cost[0].cum_cost_in_r", "cost[0].open_cost_in_r",
|
||||
"cost[1].cost_in_r", "cost[1].cum_cost_in_r", "cost[1].open_cost_in_r",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_carries_the_arity() {
|
||||
assert_eq!(CostSum::new(2).label(), "CostSum(2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "CostSum needs at least one cost input")]
|
||||
fn new_panics_on_zero() {
|
||||
let _ = CostSum::new(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the module in `lib.rs`**
|
||||
|
||||
In `crates/aura-std/src/lib.rs`, add to the `mod` block between `mod constant_cost;`
|
||||
(line 21) and `mod delay;` (line 22):
|
||||
|
||||
```rust
|
||||
mod cost_sum;
|
||||
```
|
||||
|
||||
And to the `pub use` block after `pub use constant_cost::ConstantCost;` (line 47):
|
||||
|
||||
```rust
|
||||
pub use cost_sum::CostSum;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the aggregator's tests**
|
||||
|
||||
Run: `cargo test -p aura-std cost_sum`
|
||||
Expected: PASS — 6 tests in `cost_sum::tests` pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Run-path wiring + CLI flag (aura-cli)
|
||||
|
||||
This task changes two signatures (`stage1_r_graph`'s `cost:` param type and
|
||||
`run_stage1_r`'s arity), so every call site is threaded inside this one task and
|
||||
the task ends on a clean `cargo build -p aura-cli` (the compile gate).
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-cli/src/main.rs` (imports, consts, `stage1_r_graph`, `run_stage1_r`, `RunArgs`, `parse_run_args`, `run_dispatch`, `USAGE`, the test call site at 4373)
|
||||
- Test: `crates/aura-cli/tests/cli_run.rs`
|
||||
|
||||
- [ ] **Step 1: Write the failing CLI composition test**
|
||||
|
||||
Append to `crates/aura-cli/tests/cli_run.rs` (beside
|
||||
`stage1_r_cost_run_persists_net_r_equity_and_charges_cost`):
|
||||
|
||||
```rust
|
||||
/// Property (spec 0082, the cost-graph composition headline): `--cost-per-trade`
|
||||
/// and `--slip-vol-mult` set together compose — both cost nodes sum into one
|
||||
/// net-R curve. The combined net is strictly below the flat-cost-only net (the
|
||||
/// vol-slippage node bites on top), and the `net_r_equity` trace persists.
|
||||
#[test]
|
||||
fn stage1_r_both_costs_compose_net_below_each_alone() {
|
||||
let dir = temp_cwd("stage1-r-compose");
|
||||
let run_net = |args: &[&str], trace: &str| -> f64 {
|
||||
let mut full = vec!["run", "--harness", "stage1-r"];
|
||||
full.extend_from_slice(args);
|
||||
full.extend_from_slice(&["--trace", trace]);
|
||||
let run = Command::new(BIN).current_dir(&dir).args(&full).output().unwrap();
|
||||
assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status,
|
||||
String::from_utf8_lossy(&run.stderr));
|
||||
let s = String::from_utf8(run.stdout).expect("utf-8 stdout");
|
||||
let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
|
||||
v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap()
|
||||
};
|
||||
let net_flat = run_net(&["--cost-per-trade", "2"], "flat");
|
||||
let net_both = run_net(&["--cost-per-trade", "2", "--slip-vol-mult", "0.5"], "both");
|
||||
assert!(dir.join("runs/traces/both/net_r_equity.json").exists(), "net_r_equity persisted");
|
||||
assert!(net_both < net_flat, "composed cost bites more: net_both {net_both} < net_flat {net_flat}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new test, verify it fails**
|
||||
|
||||
Run: `cargo test -p aura-cli --test cli_run stage1_r_both_costs_compose`
|
||||
Expected: FAIL — the binary rejects `--slip-vol-mult` (usage error, non-zero
|
||||
exit), so the `run.status.success()` assert fails.
|
||||
|
||||
- [ ] **Step 3: Add the aura-std imports**
|
||||
|
||||
In `crates/aura-cli/src/main.rs:31-34`, the `use aura_std::{...}` list: add
|
||||
`CostSum` after `ConstantCost` and `VolSlippageCost` after `Sub` (alphabetical
|
||||
within the existing list). Resulting additions only — `RollingMax`, `RollingMin`,
|
||||
`Sub`, `LinComb`, `Recorder`, `ConstantCost` are already imported.
|
||||
|
||||
- [ ] **Step 4: Add the consts, `CostConfig`, and interned port names**
|
||||
|
||||
In `crates/aura-cli/src/main.rs`, after the `STAGE1_R_STOP_K` const (line 2566)
|
||||
and beside the `COL_PORTS` static, add:
|
||||
|
||||
```rust
|
||||
/// Short-horizon realized-range window for vol-scaled slippage. Deliberately
|
||||
/// 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.
|
||||
const MAX_RUN_COST_NODES: usize = 2;
|
||||
|
||||
/// The 3-field cost-in-R record order — a lockstep contract with each cost node's
|
||||
/// output schema and `CostSum`'s inputs (and `summarize_r`'s `cost_col`).
|
||||
const COST_FIELDS: [&str; 3] = ["cost_in_r", "cum_cost_in_r", "open_cost_in_r"];
|
||||
|
||||
/// Interned `cost[k].<field>` `CostSum` input-port names, built once. Same
|
||||
/// `&'static str`-from-a-static rationale as `COL_PORTS`: `GraphBuilder::input`
|
||||
/// wants `&'static str`, so the names live in a `static` rather than being
|
||||
/// `format!(...).leak()`ed per build.
|
||||
static COST_SUM_PORTS: LazyLock<Vec<String>> = LazyLock::new(|| {
|
||||
let mut v = Vec::with_capacity(MAX_RUN_COST_NODES * 3);
|
||||
for k in 0..MAX_RUN_COST_NODES {
|
||||
for field in COST_FIELDS {
|
||||
v.push(format!("cost[{k}].{field}"));
|
||||
}
|
||||
}
|
||||
v
|
||||
});
|
||||
|
||||
/// Which cost nodes the run-path cost graph builds. At least one field is `Some`
|
||||
/// (the carrier `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
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Widen the `stage1_r_graph` `cost:` param type**
|
||||
|
||||
In `crates/aura-cli/src/main.rs:2624`, change the `cost` parameter type:
|
||||
|
||||
```rust
|
||||
cost: Option<(CostConfig, mpsc::Sender<(Timestamp, Vec<Scalar>)>, mpsc::Sender<(Timestamp, Vec<Scalar>)>)>,
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Hoist the vol proxy into the single `price` feed**
|
||||
|
||||
In `crates/aura-cli/src/main.rs`, replace the current `let price` + `g.feed`
|
||||
(lines 2672-2676) with the hoisted vol proxy and a single multi-target feed:
|
||||
|
||||
```rust
|
||||
// Hoisted above the single main feed: the short-horizon vol proxy iff a
|
||||
// vol-slippage cost is actually wired (run path, non-reduce), so its `price`
|
||||
// inputs join the one `price_targets` array (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);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Replace the cost block with the `CostSum`-aggregated block**
|
||||
|
||||
In `crates/aura-cli/src/main.rs`, replace the entire current
|
||||
`if let Some((cost_per_trade, tx_net, tx_cost)) = cost { ... }` block (lines
|
||||
2698-2732) with:
|
||||
|
||||
```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 (f, field) in COST_FIELDS.iter().copied().enumerate() {
|
||||
g.connect(cc.output(field), agg.input(COST_SUM_PORTS[slot * 3 + f].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 (f, field) in COST_FIELDS.iter().copied().enumerate() {
|
||||
g.connect(vs.output(field), agg.input(COST_SUM_PORTS[slot * 3 + f].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]"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Thread `run_stage1_r` (signature + cost bundle)**
|
||||
|
||||
In `crates/aura-cli/src/main.rs:2956`, change the signature and the bundle:
|
||||
|
||||
```rust
|
||||
fn run_stage1_r(
|
||||
data: RunData,
|
||||
trace: Option<&str>,
|
||||
const_cost: Option<f64>,
|
||||
slip_vol_mult: Option<f64>,
|
||||
) -> RunReport {
|
||||
```
|
||||
|
||||
Then replace line 2969 (`let cost_bundle = cost.map(|c| (c, tx_net, tx_cost));`):
|
||||
|
||||
```rust
|
||||
let cost_bundle = if const_cost.is_some() || slip_vol_mult.is_some() {
|
||||
Some((CostConfig { const_cost, slip_vol_mult }, tx_net, tx_cost))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
```
|
||||
|
||||
(The doc comment at 2952-2955 may keep its prose; the senders `tx_net`/`tx_cost`
|
||||
and the `stage1_r_graph(...)` call at 2970 are otherwise unchanged.)
|
||||
|
||||
- [ ] **Step 9: Thread the production call site (`run_dispatch`)**
|
||||
|
||||
In `crates/aura-cli/src/main.rs:3145`, change:
|
||||
|
||||
```rust
|
||||
(HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost, args.slip_vol_mult),
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Thread the test call site**
|
||||
|
||||
In `crates/aura-cli/src/main.rs:4373`, the unit test
|
||||
`run_stage1_r_synthetic_folds_an_r_block` calls `run_stage1_r(RunData::Synthetic,
|
||||
None, None)`; add the fourth arg:
|
||||
|
||||
```rust
|
||||
let report = run_stage1_r(RunData::Synthetic, None, None, None);
|
||||
```
|
||||
|
||||
(Confirm the exact current call by reading the line; add one trailing `None`.)
|
||||
|
||||
- [ ] **Step 11: Add the `RunArgs` field and parse the flag**
|
||||
|
||||
In `crates/aura-cli/src/main.rs:3059-3064`, add the field to `RunArgs`:
|
||||
|
||||
```rust
|
||||
slip_vol_mult: Option<f64>,
|
||||
```
|
||||
|
||||
In `parse_run_args`, add a local beside `let mut cost: Option<f64> = None;` (3081):
|
||||
|
||||
```rust
|
||||
let mut slip_vol_mult: Option<f64> = None;
|
||||
```
|
||||
|
||||
Add a parse arm after the `--cost-per-trade` arm (after line 3122):
|
||||
|
||||
```rust
|
||||
"--slip-vol-mult" if slip_vol_mult.is_none() => {
|
||||
let (value, t) = t.split_first().ok_or_else(usage)?;
|
||||
let v: f64 = value.parse().map_err(|_| usage())?;
|
||||
if v < 0.0 {
|
||||
return Err(usage());
|
||||
}
|
||||
slip_vol_mult = Some(v);
|
||||
tail = t;
|
||||
}
|
||||
```
|
||||
|
||||
Add the field to the `Ok(RunArgs { ... })` literal at 3135:
|
||||
|
||||
```rust
|
||||
Ok(RunArgs { harness, data, trace, cost, slip_vol_mult })
|
||||
```
|
||||
|
||||
Extend the `parse_run_args` usage string (3073) and the doc (3067) to include
|
||||
`[--slip-vol-mult <f64>]` after `[--cost-per-trade <f64>]`.
|
||||
|
||||
- [ ] **Step 12: Extend the `USAGE` const**
|
||||
|
||||
In `crates/aura-cli/src/main.rs:3156`, in the `aura run` clause, add
|
||||
`[--slip-vol-mult <f64>]` after `[--cost-per-trade <f64>]`.
|
||||
|
||||
- [ ] **Step 13: Compile gate**
|
||||
|
||||
Run: `cargo build -p aura-cli`
|
||||
Expected: 0 errors (all call sites threaded; the two signature changes resolve).
|
||||
|
||||
- [ ] **Step 14: Run the new composition test, verify it passes**
|
||||
|
||||
Run: `cargo test -p aura-cli --test cli_run stage1_r_both_costs_compose`
|
||||
Expected: PASS — both flags accepted, net_both < net_flat, net_r_equity persisted.
|
||||
|
||||
- [ ] **Step 15: Verify the no-cost golden floor is unchanged**
|
||||
|
||||
Run: `cargo test -p aura-cli --test cli_run stage1_r_single_run_output_golden`
|
||||
Expected: PASS — the C18 no-cost golden is byte-identical (no cost nodes wired,
|
||||
no `net_r_equity` tap).
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Node-level exact-sum composition tests (aura-engine)
|
||||
|
||||
Pins the cycle's correctness property precisely: `CostSum` over the real
|
||||
`ConstantCost` + `VolSlippageCost` nodes folds to the exact per-trade cost sum,
|
||||
and the aggregate agrees with the in-graph net seam. Test-only (the nodes exist
|
||||
from Tasks 1-2); it drives the real nodes, not algebraic stand-ins.
|
||||
|
||||
**Files:**
|
||||
- Test: `crates/aura-engine/tests/stage1_r_e2e.rs`
|
||||
|
||||
- [ ] **Step 1: Add the node-driver helpers and the composition tests**
|
||||
|
||||
In `crates/aura-engine/tests/stage1_r_e2e.rs`, extend the `aura_std` import
|
||||
(line 28-30) to include `CostSum` and `VolSlippageCost`. Then append, beside
|
||||
`const_cost_node_stream` (273-295):
|
||||
|
||||
```rust
|
||||
/// Drive the REAL `aura_std::VolSlippageCost(k)` node over a recorded PM `ledger`
|
||||
/// with a constant `vol` per cycle — the run-path's vol-slippage producer. Returns
|
||||
/// the node's 3-wide `[cost_in_r, cum_cost_in_r, open_cost_in_r]` rows, co-temporal
|
||||
/// 1:1 with `ledger`.
|
||||
fn vol_slippage_node_stream(
|
||||
ledger: &[(Timestamp, Vec<Scalar>)],
|
||||
k: f64,
|
||||
vol: f64,
|
||||
) -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let mut node = VolSlippageCost::new(k);
|
||||
ledger
|
||||
.iter()
|
||||
.map(|(ts, row)| {
|
||||
let mut cols = vec![
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // open
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // entry
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // stop
|
||||
AnyColumn::with_capacity(ScalarKind::F64, 1), // volatility
|
||||
];
|
||||
cols[0].push(Scalar::bool(row[CLOSED].as_bool())).unwrap();
|
||||
cols[1].push(Scalar::bool(row[OPEN].as_bool())).unwrap();
|
||||
cols[2].push(Scalar::f64(row[ENTRY_PRICE].as_f64())).unwrap();
|
||||
cols[3].push(Scalar::f64(row[STOP_PRICE].as_f64())).unwrap();
|
||||
cols[4].push(Scalar::f64(vol)).unwrap();
|
||||
let out = node.eval(Ctx::new(&cols, *ts)).expect("cost row co-temporal with PM record");
|
||||
(*ts, out.iter().map(|cell| Scalar::f64(cell.f64())).collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drive the REAL `aura_std::CostSum(n)` aggregator over `n` co-temporal cost
|
||||
/// streams, summing them per-field — the run-path's cost-graph output node.
|
||||
fn cost_sum_node_stream(streams: &[&[(Timestamp, Vec<Scalar>)]]) -> Vec<(Timestamp, Vec<Scalar>)> {
|
||||
let n = streams.len();
|
||||
let len = streams[0].len();
|
||||
let mut node = CostSum::new(n);
|
||||
(0..len)
|
||||
.map(|i| {
|
||||
let ts = streams[0][i].0;
|
||||
let mut cols: Vec<AnyColumn> =
|
||||
(0..n * 3).map(|_| AnyColumn::with_capacity(ScalarKind::F64, 1)).collect();
|
||||
for (k, s) in streams.iter().enumerate() {
|
||||
let (_, row) = &s[i];
|
||||
for f in 0..3 {
|
||||
cols[k * 3 + f].push(Scalar::f64(row[f].as_f64())).unwrap();
|
||||
}
|
||||
}
|
||||
let out = node.eval(Ctx::new(&cols, ts)).expect("aggregate co-temporal with cost streams");
|
||||
(ts, out.iter().map(|cell| Scalar::f64(cell.f64())).collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Property (spec 0082, exact composition): the `CostSum` of the real
|
||||
/// `ConstantCost` + `VolSlippageCost` node streams folds through `summarize_r` to
|
||||
/// the exact additive net — `net_both == net_flat + net_vol − gross` (since each
|
||||
/// single net is `gross − its_mean_cost`, the composed net subtracts BOTH mean
|
||||
/// costs). The path carries a clean stopped loser AND a window-end open trade, so
|
||||
/// both the cumulative-close-cost and the window-end open-cost terms bite.
|
||||
#[test]
|
||||
fn cost_sum_composes_constant_and_vol_slippage_exactly() {
|
||||
let mut stop = FixedStop::new(10.0);
|
||||
let ledger = run_chain_ledger(
|
||||
&mut stop,
|
||||
&[(1.0, 100.0), (1.0, 100.0), (0.0, 90.0), (1.0, 100.0), (1.0, 102.0), (1.0, 105.0)],
|
||||
);
|
||||
let cc = const_cost_node_stream(&ledger, 2.0);
|
||||
let vs = vol_slippage_node_stream(&ledger, 0.5, 3.0);
|
||||
let summed = cost_sum_node_stream(&[&cc, &vs]);
|
||||
|
||||
let gross = summarize_r(&ledger, &[]).expectancy_r;
|
||||
let net_flat = summarize_r(&ledger, &cc).net_expectancy_r;
|
||||
let net_vol = summarize_r(&ledger, &vs).net_expectancy_r;
|
||||
let net_both = summarize_r(&ledger, &summed).net_expectancy_r;
|
||||
|
||||
// additive identity: mean(r − cc − vs) == mean(r − cc) + mean(r − vs) − mean(r)
|
||||
assert!(
|
||||
(net_both - (net_flat + net_vol - gross)).abs() < 1e-9,
|
||||
"composition is exact + additive: net_both {net_both} == net_flat {net_flat} + net_vol {net_vol} − gross {gross}",
|
||||
);
|
||||
// both costs bite: the composed net is strictly below either single-cost net.
|
||||
assert!(net_both < net_flat && net_both < net_vol, "both costs bite");
|
||||
}
|
||||
|
||||
/// Property (spec 0082): the aggregate `CostSum` stream agrees with the in-graph
|
||||
/// net seam — the in-graph final `net_r_equity` sample (LinComb over the executor
|
||||
/// + the AGGREGATE cost) equals the post-run `summarize_r` net total. The aggregate
|
||||
/// is the single cost stream the run-path's net tap and `summarize_r` both read.
|
||||
#[test]
|
||||
fn aggregate_net_r_equity_final_sample_agrees_with_summarize_r_net_total() {
|
||||
let mut stop = FixedStop::new(10.0);
|
||||
let ledger = run_chain_ledger(
|
||||
&mut stop,
|
||||
&[(1.0, 100.0), (1.0, 100.0), (0.0, 90.0), (1.0, 100.0), (1.0, 102.0), (1.0, 105.0)],
|
||||
);
|
||||
let cc = const_cost_node_stream(&ledger, 2.0);
|
||||
let vs = vol_slippage_node_stream(&ledger, 0.5, 3.0);
|
||||
let summed = cost_sum_node_stream(&[&cc, &vs]);
|
||||
|
||||
let m = summarize_r(&ledger, &summed);
|
||||
let post_run_net_total = m.net_expectancy_r * m.n_trades as f64;
|
||||
let (_, last_pm) = ledger.last().unwrap();
|
||||
let (_, last_cost) = summed.last().unwrap();
|
||||
let net_eq_final = last_pm[CUM_REALIZED_R].as_f64() + last_pm[UNREALIZED_R].as_f64()
|
||||
- last_cost[CUM_COST_IN_R].as_f64()
|
||||
- last_cost[OPEN_COST_IN_R].as_f64();
|
||||
assert!(
|
||||
(net_eq_final - post_run_net_total).abs() < 1e-9,
|
||||
"in-graph aggregate net_r_equity {net_eq_final} must equal post-run net total {post_run_net_total}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (spec 0082): `CostSum(1)` is the identity — a lone vol-slippage stream
|
||||
/// folds through the aggregator to exactly the un-aggregated net (the run path is
|
||||
/// uniform whether one or several cost nodes are wired).
|
||||
#[test]
|
||||
fn cost_sum_of_one_is_identity_for_vol_slippage() {
|
||||
let mut stop = FixedStop::new(10.0);
|
||||
let ledger = run_chain_ledger(&mut stop, &long_path(&[100.0, 102.0, 105.0]));
|
||||
let vs = vol_slippage_node_stream(&ledger, 0.5, 3.0);
|
||||
let single = cost_sum_node_stream(&[&vs]);
|
||||
assert_eq!(
|
||||
summarize_r(&ledger, &single).net_expectancy_r,
|
||||
summarize_r(&ledger, &vs).net_expectancy_r,
|
||||
"CostSum(1) does not move the net",
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the composition tests**
|
||||
|
||||
Run: `cargo test -p aura-engine --test stage1_r_e2e cost_sum`
|
||||
Expected: PASS — `cost_sum_composes_constant_and_vol_slippage_exactly` and
|
||||
`cost_sum_of_one_is_identity_for_vol_slippage` pass.
|
||||
|
||||
Run: `cargo test -p aura-engine --test stage1_r_e2e aggregate_net_r_equity`
|
||||
Expected: PASS — the in-graph-vs-post-run aggregate agreement holds.
|
||||
|
||||
---
|
||||
|
||||
### Self-review (orchestrator, pre-handoff)
|
||||
|
||||
1. **Spec coverage:** VolSlippageCost (Task 1), CostSum (Task 2), run-path wiring
|
||||
+ CLI flag + composition E2E + golden floor (Task 3), exact-sum + aggregate
|
||||
agreement + identity (Task 4). Every spec section has a task.
|
||||
2. **Placeholder scan:** no TBD/TODO/"similar to"/"add appropriate".
|
||||
3. **Type consistency:** `cost_in_r`/`cum_cost_in_r`/`open_cost_in_r` field order
|
||||
matches across both nodes, `CostSum`, `COST_FIELDS`, `COST_SUM_PORTS`, and the
|
||||
cost `Recorder` cols; `CostConfig`/`slip_vol_mult`/`SLIP_VOL_LENGTH` consistent
|
||||
across `stage1_r_graph`/`run_stage1_r`/`RunArgs`/`parse_run_args`.
|
||||
4. **Step granularity:** each step is a single file edit or one command.
|
||||
5. **No commit steps:** none present.
|
||||
6. **Pin/replacement contiguity:** the CLI test asserts on JSON keys
|
||||
(`net_expectancy_r`), not a verbatim substring of an edited body — no split-pin
|
||||
risk. The golden test (Task 3 Step 15) is an existing pin, unchanged.
|
||||
7. **Compile-gate vs deferred-caller:** the two signature changes
|
||||
(`stage1_r_graph` cost type, `run_stage1_r` arity) and ALL their call sites
|
||||
(`run_dispatch` 3145, the 6 `None`-passing graph sites which type-unify, and
|
||||
the test site 4373) are threaded inside Task 3 before its Step-13 build gate —
|
||||
no caller deferred past the gate.
|
||||
8. **Verification-command filters resolve:** `vol_slippage`/`cost_sum`/
|
||||
`stage1_r_both_costs_compose`/`stage1_r_single_run_output_golden`/
|
||||
`aggregate_net_r_equity` each name a real test added or existing in this plan.
|
||||
@@ -1,495 +0,0 @@
|
||||
# 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.
|
||||
|
||||
**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)
|
||||
|
||||
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);
|
||||
// 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 = 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 };
|
||||
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 (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).
|
||||
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 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
|
||||
|
||||
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