Files
Aura/docs/design/contracts/c10-bias-r-cost.md
T
claude 8688a60ded docs(ledger): split the design ledger into an INDEX map, per-contract live files, and history sidecars
The single-file ledger had grown to 2968 lines / ~42k tokens, mixing
current design law with accreted history: 59 cycle-stamped realization
blocks, 18 [HISTORY] passages, 22 supersession markers, and the C10 /
C22 / C24 reframe sagas layered several supersessions deep. A
code-grounding audit (31 agents, adversarially verified) confirmed 11
defects stated as current truth: stale crate homes from the C28 #288
roster split (cost nodes, PositionManagement, PositionEvent, Session),
the renamed InputSpec->PortSpec, the pre-#241 project model in C16 and
the open-threads section, a stale HarnessKind retirement deferral in
C24, and three C28-internal inconsistencies.

New shape, per the ailang precedent:

- INDEX.md stays the sole addressable entry point: foundation, external
  components, a C-id-keyed contract map (one line per contract), and
  only the genuinely open architectural threads.
- contracts/cNN-<slug>.md carries each contract's current truth only:
  Guarantee / Forbids / Why with ratified refinements integrated, plus
  a code-anchored Current state. All confirmed defects are fixed here;
  crate anchors were re-verified against the tree.
- contracts/cNN-<slug>.history.md (18 sidecars) and INDEX.history.md
  preserve every superseded block verbatim, stamps and issue refs
  intact, under a frozen-record banner. Nothing was deleted: superseded
  design intent remains an addressable working-tree artifact, off the
  per-cycle audit walk.
- Ledger discipline is now stated in INDEX.md: live files are edited in
  place at cycle close, superseded text moves verbatim to the sidecar,
  and a supersession marker in a live file is itself an audit finding.

Every contract file was verified against its old text by an independent
zero-loss pass (statement-by-statement) plus a code-accuracy spot check;
C-ids and contract titles are unchanged, so existing C-id citations in
code, tests, and issues resolve as before.
2026-07-21 16:40:36 +02:00

26 KiB
Raw Blame History

C10 — Strategy output is a bias stream; signal quality is measured in R; cost is a composable downstream graph (gross R → net R); money is decoupled to the live deploy edge

Guarantee. A strategy's primary, backtestable output is not an equity curve, nor a position-event table, nor a position size, but a bias stream: the DAG expresses exactly one state at time t (C8 — a node emits at most one record per eval), so a strategy emits one signed, bounded bias f64 ∈ [-1, +1] per cycle (per instrument) — the sign is direction, the magnitude is conviction, and conviction is optional (a bare ±1 / 0 is the modal case). Bias is unsized: position sizing and the protective stop do not live in the strategy. The chain is signals (scores) → decision node → bias stream.

Risk-based execution is a decoupled downstream layer; in research it is Stop + position-management in R, no Sizer. Turning a bias into a tracked trade is the job of a downstream execution chain, never the strategy's. The stop-rule sets a protective stop, which defines the risk unit R (1R = the loss taken if stopped). In the research loop the executor is stop-rule → position-management, operating directly in R, with the Veto an optional documented pre-trade-gate seam (a pass-through identity DCE'd away under C19/C23 when absent): there is no Sizer. Sizing in currency (size / volume) is a deploy concept. C8's wiring totality (no optional-input concept — every declared port is covered by exactly one wiring act) forbids a dangling size port, so the resolution is concrete: research position-management either drops its size input port, or has that port driven by a constant unit node (the flat-1R degenerate) — never an unwired "vestigial" port; the record's size field is held at unit and carries no research information, and the position-event table's volume column likewise. Consequently the position-event table is demoted to a deploy / reconciliation artifact (real volume lives there), no longer a research artifact and no longer "fed to a broker". The three-way decoupling of direction (bias) / sizing / fill stands as a structure; sizing and fill are simply pushed entirely to the deploy edge, out of the research loop.

Signal quality is measured in R — gross R and net R. A downstream R-evaluator consumes the executor run and integrates the per-trade R-outcomes into an R-expectancy / R-curve: the account- and instrument-agnostic yardstick for "how much R out per 1R risked?". R, not pips, is the unit (pips are not risk-normalized). Two readings of the same unit: gross R (signal only) vs net R (after the cost model), with net R = gross R cost-in-R. The headline artifact is the net-R equity curve — the cost-drag drawn onto the R curve, recorded through a named net_r_equity tap/sink (sibling of the existing r_equity tap; a sink is the only thing the registry can display, C8/C18/C22). No new unit is invented: it is R, gross and net, continuous with the existing net_expectancy_r. A bare gross-R run with no cost model attached is valid: the cost layer is optional and additively composed-on (the zero-cost baseline is the "default simple" floor).

Cost is a COST MODEL — a composable C9 graph of cost nodes, in R, that approximates (never claims) realism. The realistic broker is retired: real friction — slippage (live liquidity / order-size / volatility at fill), swaps (broker-set, time-varying), even recorded feed spread (often a fake constant) — is not historically knowable, so an authored-friction historical broker is "horseshoe-throwing". Its replacement is a cost model: an ordinary downstream C9 graph of cost nodes that approximates the cost side a broker would produce, explicitly as an approximation. The cost nodes live in aura-strategy, the cost-graph composite-builder (cost_graph) in aura-composites (C16), never in the domain-free aura-engine (C14/C16). They are not "additive on the R stream" in isolation: a cost node reads the state it depends on — the price stream, a realized-volatility tap, a C11-recorded interest-rate source, and the executor's per-cycle R-record / trade events — and emits a cost-in-R stream that is subtracted from gross R to yield net R. Cost attaches at the structurally correct grain: per-trade factors (commission, a flat cost-per-trade) deduct from realized_r at close; per-cycle-held factors (carry / funding / swap) accrue over the holding duration. The model generalizes the scalar round_trip_cost / net_expectancy_r into a possibly state-dependent graph: the scalar round_trip_cost is the degenerate constant-per-trade special case, subsumed by the cost graph — the post-run summarize_r fold no longer recomputes cost independently but folds the cost-model's net-R stream into net_expectancy_r (one home for cost, no double-count). Discipline: every cost factor is either a clearly-labelled stress-parameter (e.g. a flat cost-per-trade is a breakeven-threshold probe) or data-grounded / falsifiable (e.g. realized-volatility → slippage; recorded interest-rate data via C11 → funding / swap). Default simple; complexity is earned per grounded factor. Stacking unfalsifiable guesses (over-modelling) is the anti-pattern.

Co-temporality contract (load-bearing, generalizes to all cost factors). Because summarize_r positionally joins cost[i] ↔ record[i], a cost node is gated only by the position-management trade-geometry; any not-yet-warm state input (a vol proxy warms later than the executor) 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 executor's R-record. Co-temporality is therefore structural and warm-up-independent, preserves the C18 golden, and is honest (no slippage estimate yet → no charge). A constant-per-trade factor satisfies it trivially; only state-dependent factors need the missing-factor → 0 rule.

The research loop is pure feed-forward — compounding is removed. Flat-1R-style R accounting needs no equity, and with the Sizer gone there is no equity → size edge at all in research: the loop is feed-forward, maximally parallel and deterministic (C1), the cost model a feed-forward subtraction on the R stream. Compounding is removed from research: it is a post-strategy money-management transform — a pure function of the per-trade net-R sequence and a bet-fraction f, multiplicative and path / order-dependent, the sole source of feedback — so compounding, Kelly-f, and drawdown-under-compounding are derived post-hoc, analytically, at the deploy / account layer from the net-R distribution. Consequently there is no z⁻¹ fill-edge register and no flat-1R-vs-compounding structural axis in the research loop (with no in-loop feedback there is nothing for a register to cut; this is strictly C9 / C23-cleaner).

Conviction-based risk allocation survives — as an R-aggregation axis, not a Sizer. Scaling risk by bias strength is signal-side and R-denominated. Because per-trade R is size-invariant, conviction cannot be expressed by scaling position size (that is invisible to R); it is expressed by weighting the per-trade R-contribution in the R-equity: flat (sum of realized_r, sign only) vs conviction-weighted (sum of |bias| · realized_r, sign + magnitude). This is a feed-forward, additive, order-independent research axis, distinct from the removed money-Sizer; it is tested via the conviction_at_entry record field and conviction_terciles_r metric, and may sit in-graph or as a post-hoc fold.

Money / real broker / cTrader Open API = a separate, later live / deploy-edge concern — the only reliable (belastbare) ground truth, measured never modelled. Reliable friction statistics require forward-trading against a real broker (e.g. cTrader Open API), and are non-stationary even then. This fits C11 (record-then-replay: real fills are recorded live, then replayable) and the frozen-deploy invariant (C13: deploy = frozen bot + broker connection); reconciliation with the real account is an external I/O adapter at the recording / deploy edge, not an in-graph node. This is the only place account money appears. Currency-denominated reference geometry — pip value, stop-distance-in-currency from the C15 instrument_geometry sidecar — is still read at the ingestion edge to normalize a currency / pip cost factor into R; notional size cancels in cost_in_R = cost_in_currency / (size · stop_dist), so the cost model is R-pure without ever holding equity. It is equity / account money, not reference geometry, that lives only at the deploy edge. The broker-independent position-event table (event_ts, action[buy/sell/close], position_id, instrument_id, volume) is the deploy / reconciliation record at this edge (real volume), not a research artifact.

Honesty principle. The net-R curve under the cost model is a research / ranking tool and a hypothesis; the forward / live run against a real broker is the ground truth. The cost model approximates; it never claims realism. SimBroker (the legacy pip-equity, unsized-exposure node) is redundant as a quality measure with the net-R cost model in place (R displaces pips). It is retained as a legacy / simple optional pip yardstick (still wired alongside the R executor in the r-family member for an honest dual readout), not part of the new model and not to be expanded.

Forbids. Putting sizing or the stop in the strategy (bias is unsized; the stop-rule owns the stop, R is the unit); treating an equity curve (R or currency) as the strategy's direct output; putting a Sizer / currency size / volume into the research loop (size is a deploy concept; research is in R); leaving a dangling size input port on the research executor (C8 wiring totality forbids it — drop the port or drive it with a constant unit); any equity → size / equity → anything feedback in research (the research loop is pure feed-forward; compounding is a post-hoc money-management transform, not an in-loop edge); modelling an authored-friction "realistic broker" over historical data (real friction is not historically knowable — use the approximating cost model, and treat the real broker as the live-edge ground truth only); claiming the cost model is realism (it is an explicit approximation); stacking unfalsifiable cost guesses (each cost factor is a labelled stress-parameter or data-grounded — over-modelling is the anti-pattern); computing cost in two homes (the cost graph owns cost; the post-run fold subsumes the old scalar round_trip_cost, never double-counts it); expressing conviction by scaling position size (size-invisible to R; conviction is an R-aggregation weight); making the position-event table the strategy's direct DAG output (it is derived, not emitted per eval — a decision instant may need >1 event, which C8 forbids) or a research measure of signal quality (it is a deploy / reconciliation artifact); measuring signal quality in currency / account money in the research loop at all (account money lives only at the live deploy edge; reference geometry at ingestion is not account money); baking a broker into the strategy or an in-graph broker subsystem (the in-graph realistic broker is retired; the only broker is the live-edge I/O adapter, C11 / C13); a signed-volume direction trick in the event table (use action); storing open_ts (derive it from the opening event).

Why. A strategy's edge is a procedure, not a currency outcome ("focus on the procedure, not the money"): the right primary question is "how much R out per 1R risked?", and R — defined by the stop — is the only account- and instrument-agnostic, risk-normalized unit (pips are not). Separating direction (bias) from sizing from fill is the decomposition every mature system converges on (LEAN's Alpha → Portfolio-Construction → Execution is a near isomorphism; backtrader, QSTrader, zipline all emit an unsized directional signal sized downstream) — and aura pushes sizing and fill off the research loop for two distinct reasons: sizing is off because per-trade R is size-invariant (size carries no information in R — flat-1R is perfectly knowable, it just does not matter), and fill / friction is off because real friction is not historically knowable and therefore not honest over history. Keeping research pure feed-forward leaves the signal-quality layer parallel and deterministic (C1); the only real feedback (equity → bet-fraction) is compounding, a closed-form, path-dependent transform of the net-R sequence, and therefore belongs after the strategy, at the deploy / account layer, not as an in-loop register. The cost model as a C9 graph keeps cost within the one Node / graph abstraction (C9) and generalizes the scalar net_expectancy_r continuously, while the gross-R / net-R split states the cost-drag honestly without inventing a unit. Refusing the historical realistic broker is an honesty stance: the only reliable friction is measured forward against a real broker (cTrader Open API) — the live deploy edge, the sole place account money and ground truth appear. The DAG holds exactly one state at t and a node emits ≤1 record per eval (C8), so the faithful per-cycle output is the bias (one value); position events are a derived, deploy-side consequence. Industry grounding for the bias/R spine: LEAN / nautilus_trader / backtrader / QSTrader / vectorbt / zipline (#117 decision log).

Current state

Bias and executor. The unsized strategy output is the Bias node (aura-strategy/src/bias.rs). The per-symbol RiskExecutor is the risk_executor(StopRule, risk_budget) composite-builder (aura-composites/src/lib.rs) with a StopRule{Fixed, Vol, VolTf} structural axis (C20); risk_executor_vol_open is the gridding sibling with the two vol-stop knobs left open as sweep axes. Stop rules: FixedStop (a triggered constant) and the vol_stop(length, k) composition k·√EMA(Δ²) built from Mul/Sqrt primitives; VolTfStop for a resampled-timeframe vol stop (FixedStop in aura-strategy/src/stop_rule.rs, vol_stop composite in aura-composites). The Veto is a documented seam, not a runtime node.

The Sizer is a vestige, not yet removed. The ratified design (#116) has no Sizer in research, but the composite still physically wires stop → Sizer → PositionManagement, with the Sizer bound to a constant risk_budget (risk_budget is a formal argument; the runner calls risk_executor(stop, 1.0)aura-runner/src/member.rs). This is the constant-unit-driven-size-port resolution of C8 wiring totality (not the drop-the-port resolution): risk_budget is a constant, never equity-fed, so no equity → size edge exists and the pure-feed-forward invariant holds; under R size-invariance the Sizer's size = 1.0 / stop_distance carries no research information. Physically deleting the Sizer node and its risk_budget argument is outstanding cleanup toward the ratified shape.

Position management and R metrics. PositionManagement (aura-backtest/src/position_management.rs) is the stateful heart: it latches the entry-cycle stop distance as the immutable R-denominator, marks against the one-cycle-lagged fill (no look-ahead, C2), and emits a dense per-cycle R-record (one row per eval, C8; the trade ledger is the closed_this_cycle subset, the R-equity is cum_realized_r + unrealized_r). summarize_r (aura-backtest/src/metrics.rs) is a post-run fold (sibling of summarize, not an in-graph node) → RMetrics (E[R], SQN, sqn_normalized, win-rate, profit-factor, max-R-drawdown, conviction_terciles_r, net_expectancy_r). It folds a co-temporal cost stream (positional 1:1 join) into net_expectancy_r — one home for cost, byte-identical to the cost = 0 baseline on an empty stream. sqn_normalized = (mean_R / stdev_R)·√(min(n, 100)) (SQN_CAP = 100) is an opt-in turnover-robust rank key; below the cap it equals raw sqn exactly. SQN is the operational single-number ranking objective; a sweep's default grid varies only the signal, holding the stop fixed — the stop defines 1R, so varying it across members would change what R means per member and break cross-member SQN comparability.

Cost model. aura-strategy/src/cost.rs owns the abstraction: the 3-field cost triple {cost_in_r, cum_cost_in_r, open_cost_in_r} is one source of truth (COST_FIELD_NAMES / COST_WIDTH = 3, mirroring the position-management record), prefixed by the 4-wide GEOMETRY_WIDTH geometry inputs. The CostNode factor trait carries a node's only per-node difference — the price-unit cost numerator (cost_numerator), plus extra_inputs, label, and a defaulted charge_mode(); everything else is the generic CostRunner<F: CostNode> adapter (a plain downstream Node, C9; no runtime sub-object), which writes the co-temporality skeleton (geometry-only gating, numerator / latched R-normalization, the closed/open charge, the running cum, the 3-field emit) once. ChargeMode {AtClose, PerHeldCycle} selects timing per factor (a commission is intrinsically at-close, a carry per-held-cycle); the single CostRunner handles both arms, the PerHeldCycle arm accruing per into a per-position acc each held cycle and dumping it into cum at close. Three shipped factors, all aura-strategy: ConstantCost (a labelled stress-parameter, cost_per_trade / |entry stop|), VolSlippageCost (slip_vol_mult · vol / |entry stop|, reading an independent short-horizon realized-range vol proxy distinct from the stop's own vol), and CarryCost (a ConstantCost twin with charge_mode() = PerHeldCycle, the flat base of the accrual family). CostSum (aura-strategy/src/cost_sum.rs) is the cost-graph output node, summing N nodes' 3-field records per-field into one aggregate (n = 1 is the identity), so the seam consumes a single cost stream regardless of node count. cost_port / intern_port (aura-strategy/src/cost.rs) intern runtime port names process-globally (the COL_PORTS production pattern, #152), reused across per-member rebuilds. cost_graph(Vec<PrimitiveBuilder>) (aura-composites/src/lib.rs) is the authoring primitive: it fans the 4 geometry inputs to N cost nodes, surfaces each node's extras (schema().inputs[GEOMETRY_WIDTH..]) as cost[k].<port> roles, sums through CostSum, and exposes the 3-field aggregate at arbitrary arity. The headline sink is net_r_equity = LinComb(4)[cum_realized_r, unrealized_r, cum_cost_in_r, open_cost_in_r] → Recorder (C8/C18), a sibling of r_equity, emitted only when a cost is authored; a per-held-cycle factor bleeds continuously over the hold because the bleed lives in open_cost_in_r, which this tap already subtracts (Approach B, no summarize_r fold change).

Cost on the campaign / sweep path (net is the default). Cost is authored as the campaign document's additive cost: Vec<CostSpec> block (aura-research/src/lib.rs, CampaignDoc) — a closed, externally-tagged vocabulary over the three shipped nodes (constant / vol_slippage / carry, field names = the builders' ParamSpec names), mirroring the risk block (serde default + skip-if-empty: cost-less docs hash byte-identically, C18). An absent block is the explicit zero-cost model and summarize_r's net family equals gross under it — every result is net, no second gross-labelled result kind. cost_nodes_for (beside stop_rule_for_regime, aura-runner/src/translate.rs) is the one doc→builder binding; wrap_r (aura-runner/src/member.rs) carries the optional cost leg (cost_graph off the executor's four geometry outputs, the vol proxy in production, a gated cost recorder in reduce mode as the summarize_r join input, the LinComb(4) net_r_equity curve in trace mode). Both re-run sides re-derive the model (cost_specs_from_params), so the C1 drift alarm compares like with like; costed families reproduce bit-identically (incl. Carry). TapChannel::Net (aura-runner/src/runner.rs) routes net_r_equity to persisted curves; a cost-less doc requesting it keeps a remedy-naming skip notice. There are no --cost-* run-path flags (removed #221/#234); cost travels in the document.

Risk regime as a structural campaign axis. The StopRule{Fixed, Vol} axis is realized at the campaign-document level as CampaignDoc.risk: [RiskRegime] (aura-research, variants Vol{length, k} and VolTf{period_minutes, length, k} (#262), the fixed-stop rule additive when needed) — a kept-separate matrix axis, peer of instruments and windows. The executor keys the nominee map by (strategy, window, regime), so generalize aggregates within a regime, never across. Regimes are compared at presentation, never argmax-selected across (a cross-regime E[R] argmax would compare R-multiples in different R units). Each member manifest stamps its resolved stop (default included). Two default representations coexist by design (#217): a dissolved sweep binds no regime (risk: [], late-resolved per member by stop_rule_for_regime), while walkforward/mc/generalize bind the default regime eagerly (risk: [Vol{length:3, k:2.0}]) — same R behaviour, deliberately different document content-ids. Deferred: regime-aware trace persistence — the trace re-run and cell-key dir naming still assume the default stop, since CellRealization carries no regime (#212).

Legacy pip yardstick. SimBroker (aura-backtest/src/sim_broker.rs, node kind simbroker) is a legacy pip-equity node, retained as an optional pip yardstick and still wired alongside the R executor in the r-family member for an honest dual pip/R readout; not part of the cost model, not to be expanded.

Position-event table (deploy / reconciliation). A closed PositionAction {Buy, Sell, Close} enum + the PositionEvent row (event_ts, action, position_id, instrument_id, unsigned volume; no open_ts; direction is the action) and derive_position_events(record, instrument_id) -> Vec<PositionEvent> live in aura-backtest/src/metrics.rs as post-run value types (not per-eval nodes, C8). action serde-encodes as a bare i64 (Buy=0, Sell=1, Close=2), the C7 scalar column form, out-of-range rejected on read. derive_position_events is the first difference of the executed book — a pure reduction over the position-management dense record (read positionally as type-erased Scalars, C7 SoA; no in-graph node, so the hot path stays domain-free, C14) — emitting a Buy/Sell at each open and a Close at each exit; a reversal (or stop-then-same-cycle reopen) emits Close then the opposite open at one event_ts (the C8 ">1 event per instant" case that forces a derived table). The close sizes the actual book (the closed position's stored volume), never an exposure delta. A position open at window end emits its open with no synthetic Close (the table records actual executed events; summarize_r's force-close is for the R metric only). The table stays broker-independent.

CLI. The run/sweep surface is blueprint-driven — aura <verb> <blueprint.json> over examples/r_*.json / crates/aura-cli/tests/fixtures/; the built-in --harness / --strategy selectors were retired with the demos → blueprint-data cut (#159). The strategy identifier family is the r-familyr-sma / r-breakout / r-meanrev (renamed from the Stage-1 family in #174). metric_cmp (aura-registry) ranks the higher-is-better R metrics sqn, expectancy_r, net_expectancy_r, sqn_normalized; a member with no r block sorts last.

Deferred work. Physical removal of the vestigial Sizer and its risk_budget argument (ratified shape, #116). Data-grounded cost factors beyond the shipped stress parameters: a notional-based carry (price × rate, reads the price tap) and the calendar-aware overnight swap proper (rollover-boundary timing, 3× Wednesday, long/short asymmetry — deploy-edge realism) (#148). The conviction-weighting R-aggregation axis (flat vs |bias|·realized_r), tested via conviction_at_entry / conviction_terciles_r (#148). Regime-aware trace persistence (#212). Money, a real broker, and cTrader Open API remain the separate live / deploy-edge concern (C11 record-then-replay, C13 frozen deploy).

See also

  • C1 — determinism / bit-identity (costed families reproduce byte-for-byte)
  • C2 — no look-ahead (one-cycle-lagged fill; the co-temporality 0-cost warm-up rule)
  • C8 — ≤1 record per eval; sinks are the only displayable surface
  • C9 — cost nodes are ordinary downstream nodes; the cost model is a C9 graph
  • C11 — record-then-replay; the live broker as recorded I/O; bit-identity of re-runs
  • C13 — the frozen-deploy invariant (deploy = frozen bot + broker connection)
  • C14 — the domain-free engine (no cost / broker logic in aura-engine)
  • C15 — the instrument_geometry sidecar (pip value / stop-distance-in-currency)
  • C16 — the composites layer (cost_graph, risk_executor)
  • C18 — the registry / golden (byte-identity, content-addressed docs)
  • C20 — structural axes (StopRule, the risk regime)
  • C23 — names non-load-bearing; DCE of the absent Veto
  • C24 / C25 — experiment intent in the campaign document
  • C28 — the crate-layer ladder

History: c10-bias-r-cost.history.md