diff --git a/docs/specs/0081-cost-model-constant-cost.md b/docs/specs/0081-cost-model-constant-cost.md new file mode 100644 index 0000000..b5b57f1 --- /dev/null +++ b/docs/specs/0081-cost-model-constant-cost.md @@ -0,0 +1,278 @@ +# Cost-model graph (in R) — cycle 1: ConstantCost node + net-R seam — Design Spec + +**Date:** 2026-06-28 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +Land the first cycle of the "Cost-model graph (in R)" milestone (#148): a single +concrete cost node plus the integration seam, so a backtest can produce a **net-R** +reading under an authored cost while a run with no cost stays an unchanged gross-R +baseline. After this cycle a researcher can attach a flat per-trade cost to the +`stage1-r` harness and get (a) a `net_r_equity` trace — the cost-drag drawn onto the +R equity curve, a sibling of the existing `r_equity` tap — and (b) a cost-adjusted +`net_expectancy_r` in the run metrics. A run with no cost requested is byte-identical +to today. + +This is C10's contract realised at its "default simple" floor. It deliberately ships +**one** cost node and the seam, and **defers** the general `CostNode` trait, the +multi-node cost-graph composite-builder, data-grounded factors (vol→slippage, +recorded-rate→swap), per-cycle-held accrual (carry/funding), the conviction-weighting +R-aggregation axis, percentage-of-notional cost, the Veto seam, and the Sizer +port-drop cleanup. Rationale for the cut, and the fork decisions below, are recorded +on #148 (decisions A–F and the Step-1.5 derived decisions G–I). + +## Architecture + +Cost is an **ordinary downstream node** (C9), not a post-run scalar. The new +`ConstantCost` node sits downstream of the per-symbol `PositionManagement` (PM) inside +the `stage1-r` harness, reads the trade-geometry fields PM already exposes, and emits a +**cost-in-R** record per cycle. Two consumers read that one stream — the single home +for cost, no double-count: + +1. **In-graph:** a `net_r_equity` recorder sink (built with the same `LinComb`+ + `Recorder` idiom as `r_equity`) draws `net R = gross R − cost`. +2. **Post-run:** `summarize_r` folds the same cost stream into `net_expectancy_r`, + replacing its scalar `round_trip_cost` parameter. + +The load-bearing mechanic that shapes the node (decision G): the **window-end trade is +synthesised post-run by `summarize_r`** (it force-closes a still-open position from the +last row's `unrealized_r`), it is *not* an in-graph close PM emits. So the cost node +mirrors PM's R-triple — it emits `{cost_in_r, cum_cost_in_r, open_cost_in_r}` +isomorphic to PM's `{realized_r, cum_realized_r, unrealized_r}` — which lets the +post-run fold charge the window-end trade and makes the node **subsume the scalar +`round_trip_cost` exactly** (charges every trade, including window-end), not merely at +the cost=0 floor. + +The cost layer is **opt-in**: with no cost requested the harness graph is exactly +today's (no `ConstantCost`, no `net_r_equity` tap, `summarize_r` folds no cost), so the +baseline is trivially byte-identical. The layer is additively composed-on when a cost +is requested. + +R-purity (C10, decision E): `cost_in_r = cost_per_trade / latched_dist` with +`latched_dist = |entry_price − stop_price|` recovered from the dense record; size is +flat-1R (unit) this cycle so the `size` factor in `cost_in_currency/(size·stop_dist)` +is 1 and notional cancels — the model is R-pure without ever holding equity. + +Invariants held: C1 (pure feed-forward subtraction, no equity feedback — the cost node +is feed-forward, deterministic), C2 (no look-ahead — cost reads only the current +record), C8 (one record per `eval`; `net_r_equity` is a sink), C9 (cost is ordinary +nodes), C10 (gross − cost = net, R-pure, one home), C18 (golden wire shape unchanged at +the no-cost baseline). + +## Concrete code shapes + +### User-facing program (the Step-2 acceptance evidence) + +A researcher runs the `stage1-r` harness with an authored flat cost and reads the +net-R curve and net expectancy: + +```console +# net-R run: a 2.0-price-unit round-trip cost per trade +$ aura run stage1-r --strategy ./my_signal --cost-per-trade 2.0 +run: my_signal trades: 37 E[R]: 0.42 net E[R]: 0.31 ... +$ aura chart my_signal --tap net_r_equity > net_r.html # cost-drag curve, sibling of r_equity + +# gross-R baseline: no cost flag → today's graph, byte-identical +$ aura run stage1-r --strategy ./my_signal +run: my_signal trades: 37 E[R]: 0.42 net E[R]: 0.42 ... # net == gross, no net_r_equity tap +``` + +The harness author wires the cost node beside the executor, mirroring the existing +`r_equity` tap (the Rust the CLI's `stage1_r_graph` adds when `cost_per_trade` is +requested): + +```rust +// in stage1_r_graph, after the RiskExecutor `exec` and its r_equity tap are built, +// gated on a requested cost (None → today's graph unchanged): +if let Some(cost_per_trade) = cost { + let cost_node = g.add( + ConstantCost::builder().bind("cost_per_trade", Scalar::f64(cost_per_trade)), + ); + // cost reads the trade-geometry PM already exposes + g.connect(exec.output("closed_this_cycle"), cost_node.input("closed")); + g.connect(exec.output("open"), cost_node.input("open")); + g.connect(exec.output("entry_price"), cost_node.input("entry_price")); + g.connect(exec.output("stop_price"), cost_node.input("stop_price")); + + // 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(cost_node.output("cum_cost_in_r"), net_eq.input("term[2]")); + g.connect(cost_node.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]")); + // record the cost the post-run fold needs: cost_in_r (closed-trade charge) and + // open_cost_in_r (window-end charge) — a 2-column cost stream, co-temporal with the PM record + let cost_rec = g.add(Recorder::builder(vec![ScalarKind::F64, ScalarKind::F64], Firing::Any, tx_cost)); + g.connect(cost_node.output("cost_in_r"), cost_rec.input("col[0]")); + g.connect(cost_node.output("open_cost_in_r"), cost_rec.input("col[1]")); +} +``` + +### Implementation shapes (secondary — before → after) + +**(1) New node `ConstantCost`** (`crates/aura-std/src/constant_cost.rs`), authored with +the standard `PrimitiveBuilder` idiom (cf. `Sma`, `LinComb`). Inputs are the four +PM-exposed fields it needs; output is the 3-field cost record; one F64 param. + +```rust +pub struct ConstantCost { cost_per_trade: f64, cum: f64, out: [Cell; 3] } + +impl ConstantCost { + pub fn builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "ConstantCost", + 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() }, + ], + 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: "cost_per_trade".into(), kind: ScalarKind::F64 }], + }, + |p| Box::new(ConstantCost::new(p[0].f64())), + ) + } +} + +impl Node for ConstantCost { + fn lookbacks(&self) -> Vec { vec![1, 1, 1, 1] } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> { + // wait until PM has produced a record this cycle (price present) + let entry = ctx.f64_in(2).get(0)?; + let stop = ctx.f64_in(3).get(0)?; + let closed = ctx.bool_in(0).get(0).unwrap_or(false); + let open = ctx.bool_in(1).get(0).unwrap_or(false); + let latched = (entry - stop).abs(); + let per = if latched > 0.0 { self.cost_per_trade / latched } else { 0.0 }; + let cost_in_r = if closed { per } else { 0.0 }; // charged on a real close + let open_cost_in_r = if open { per } else { 0.0 }; // open trade's would-be cost + 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 { "ConstantCost".into() } +} +``` + +**(2) `summarize_r` — scalar cost → consumed cost stream** (`crates/aura-analysis/src/lib.rs`). +The trade-collection and the net fold change so cost comes from the node's recorded +stream, joined to the trade ledger; the no-cost call yields the byte-identical net. + +```rust +// before: +pub fn summarize_r(record: &[(Timestamp, Vec)], round_trip_cost: f64) -> RMetrics +// ... net_sum = Σ trades.map(|t| t.r - if t.latched>0 { round_trip_cost/t.latched } else {0.0}) + +// after: cost is read from the co-temporal ConstantCost stream (empty ⇒ no cost ⇒ net == gross). +pub fn summarize_r(record: &[(Timestamp, Vec)], cost: &[(Timestamp, Vec)]) -> RMetrics +// Build a per-trade cost aligned to the trade ledger: +// - for each CLOSED row at ts, take the cost row at the same ts, field cost_in_r; +// - for the window-end open last row, take its cost row's open_cost_in_r. +// net_sum = Σ over trades of (t.r - t.cost); with an empty `cost` slice every t.cost = 0.0, +// so net_sum == Σ t.r and net_expectancy_r == expectancy_r — byte-identical to today's cost=0. +``` + +`r_metrics_from_rs` (the deliberate byte-identical OOS-pooling copy) is **unchanged**: +it carries no cost concept and stays `net_expectancy_r = expectancy_r` under the cost=0 +invariant; the cross-reducer equality test runs at cost=0 and still holds. + +## Components + +- **`ConstantCost`** (new, `aura-std`): the cost node above. State: `cost_per_trade` + (param), `cum` (running realised cost). Pure, feed-forward, lookback 1 per input. +- **`net_r_equity` tap** (harness wiring in `aura-cli`'s `stage1_r_graph`): a 4-term + `LinComb` + `Recorder`, persisted as `ColumnarTrace` named `"net_r_equity"` via + `persist_traces_r` / `TraceStore::write` — exactly the `r_equity` pattern. +- **`summarize_r`** (changed, `aura-analysis`): signature and net fold per shape (2). +- **CLI surface** (`aura-cli`): a `--cost-per-trade ` option (default: absent = + gross-R baseline) on the `stage1-r` run / sweep / chart paths, threaded as + `Option` into `stage1_r_graph` and as the cost stream into the `summarize_r` + call. The 10 existing `summarize_r(record, 0.0)` call sites become + `summarize_r(record, &[])` (or the recorded cost stream where a cost was requested). + +## Data flow + +``` +price ┐ ┌─ r_equity = LinComb[cum_realized_r, unrealized_r] → Recorder("r_equity") +bias ─┼─→ RiskExecutor(PM) ─────►┤ + │ exposes └─ net_r_equity = LinComb[cum_realized_r, unrealized_r, + │ closed/open/ −cum_cost_in_r, −open_cost_in_r] → Recorder("net_r_equity") + │ entry/stop ──→ ConstantCost ─→ {cost_in_r, cum_cost_in_r, open_cost_in_r} + │ └─ cost_in_r / open_cost_in_r → Recorder (cost stream) + ▼ + PM dense record ──(post-run)──► summarize_r(record, cost_stream) → RMetrics{expectancy_r, net_expectancy_r, …} +``` + +Per-cycle, feed-forward, deterministic. The cost stream and PM record are co-temporal +(same clock, one record each per cycle); the post-run fold joins them on timestamp. + +## Error handling + +- **No cost requested:** `stage1_r_graph` adds no cost node / tap; `summarize_r` is + called with an empty cost slice. No new failure surface; baseline unchanged. +- **Zero / invalid latched distance:** `latched ≤ 0` ⇒ the trade contributes no cost + (`per = 0.0`), matching the existing scalar guard — never a divide-by-zero. +- **C8 wiring totality:** the four `ConstantCost` inputs are each fed by exactly one + edge from the executor's exposed fields; an unwired port is a build-time + `CompileError::UnconnectedPort` (not user-facing). +- **Cost/trade-ledger join:** the fold matches cost rows to trade rows by timestamp; a + defensive mismatch (no cost row at a closed trade's ts) charges 0 for that trade + rather than panicking — flagged for the plan as the join's degenerate case. + +## Testing strategy + +- **Exact-subsumption (the headline):** a unit test over a synthetic PM record + + ConstantCost stream at `cost_per_trade = C` asserts `summarize_r`'s `net_expectancy_r` + equals the hand-computed `mean(rᵢ − C/latchedᵢ)` over **all** trades including the + window-end one — proving the node subsumes the old scalar exactly (not just at C=0). +- **Gross-R baseline byte-identity:** the existing C18 goldens stay byte-for-byte + unchanged for a no-cost run (no cost node, empty cost slice ⇒ `net == gross`); this is + the regression floor. +- **In-graph ↔ post-run agreement:** an E2E test (extending + `crates/aura-engine/tests/stage1_r_e2e.rs`) asserts the final `net_r_equity` sample + equals `cum_realized_r + unrealized_r − total_cost`, i.e. the post-run net total — + the two readings of cost agree by construction. +- **net < gross under cost:** the E2E asserts `net_expectancy_r < expectancy_r` for a + profitable signal under `cost_per_trade > 0`, and `expectancy_r` (gross) is untouched + by cost. +- **Adapt the existing cost test:** `stage1_r_e2e.rs` ~204–241 (today a hardcoded `2.0` + scalar through `summarize_r`) is rewritten to drive the same cost via the + `ConstantCost` node, asserting the same net result. + +## Acceptance criteria + +Applying aura's default feature-acceptance criterion (the feature solves a real +user-facing problem and contradicts no design commitment), evidenced by the worked +program above: + +1. **A researcher reaches for it naturally:** `aura run stage1-r --cost-per-trade C` + yields a `net_r_equity` trace and a cost-adjusted `net_expectancy_r`; `aura chart + --tap net_r_equity` renders the cost-drag curve. This is the C10 headline + artifact, reachable from the existing CLI surface. +2. **It measurably improves the model and removes redundancy:** cost becomes a + composable in-graph node with one home (the node), subsuming the dead hardcoded-`0.0` + scalar `round_trip_cost` exactly; the net-R reading is honest (gross vs net stated + explicitly), no unit invented. +3. **It reintroduces no class of failure the core constraints forbid:** the cost model + is a pure feed-forward subtraction (C1 — no equity feedback, no Sizer), reads only + the present (C2), is an ordinary node (C9), is R-pure without equity (C10), and the + no-cost baseline keeps the golden wire shape byte-identical (C18). +4. **Scope is one plan:** one new node, one tap, one signature change, one CLI option — + no sub-cycle decomposition; the deferred factors are recorded on #148, not built.