Files
Aura/docs/specs/0081-cost-model-constant-cost.md
T
Brummel 2e63ed9e26 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
2026-06-28 13:04:41 +02:00

15 KiB
Raw Blame History

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 AF and the Step-1.5 derived decisions GI).

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:

# 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):

// 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.

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.

// 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 ~204241 (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.