spec: 0081 cost-model constant-cost (boss-signed)
Cycle 1 of the "Cost-model graph (in R)" milestone (#148): a single ConstantCost node (flat round-trip cost per trade) plus the net-R seam — an in-graph cost_in_r stream, a net_r_equity sink (sibling of r_equity), and summarize_r folding that one stream into net_expectancy_r (replacing the scalar round_trip_cost). A run with no cost stays a byte-identical gross-R baseline. Defers the general CostNode trait, the cost-graph composite-builder, data-grounded factors, and the conviction axis. Auto-signed under /boss on the grounding-check PASS: all load-bearing existing-behaviour assumptions are ratified by green tests. Fork decisions recorded on #148. refs #148
This commit is contained in:
@@ -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<usize> { 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<Scalar>)], 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<Scalar>)], cost: &[(Timestamp, Vec<Scalar>)]) -> 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 <f64>` option (default: absent =
|
||||
gross-R baseline) on the `stage1-r` run / sweep / chart paths, threaded as
|
||||
`Option<f64>` 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
|
||||
<run> --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.
|
||||
Reference in New Issue
Block a user