spec: 0085 per-cycle-held accrual + CarryCost (boss-signed)

Cycle 5 of milestone #148 (cost-model graph in R): the per-cycle-held accrual
mechanism — C10's "per-cycle-held factors accrue over the hold" — exercised by a
constant per-held-cycle CarryCost node and a --carry-per-cycle CLI flag.

Approach B (user-decided, "ja, mach B"): the headline net_r_equity curve bleeds
continuously over each hold, not stepping at close. Mechanism (derived, logged on
#148): the bleed lives in open_cost_in_r (grown each held cycle = accrued-so-far
mark-to-market); cost_in_r dumps the accrued total into cum at close. The
net_r_equity tap already subtracts both cum_cost_in_r and open_cost_in_r, so the
curve bleeds with NO change to summarize_r or the CLI net_eq wiring — and the
AtClose runner branch is kept verbatim, so every cycle-0083/0084 net_expectancy_r
golden stays byte-identical. ChargeMode is a property of the factor read by the one
shared CostRunner (no second runner type); CarryCost is a ConstantCost twin
differing only in charge_mode() -> PerHeldCycle.

Auto-signed under /boss on a grounding-check PASS (all 6 current-behaviour
assumptions ratified against named green tests). Reference issue #148 carries the
A/B fork (user decision), the minimal-scope decision (derived), and the mechanism
refinement (derived).

refs #148
This commit is contained in:
2026-06-29 00:02:26 +02:00
parent 39f9387c57
commit 19ba1ec978
@@ -0,0 +1,312 @@
# Per-cycle-held accrual + CarryCost — Design Spec
**Date:** 2026-06-28
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
Cycle 5 of milestone #148 ("Cost-model graph (in R)"). Reference issue: #148
(fork decisions + the mechanism refinement logged there). Builds directly on the
cost-model graph shipped in cycles 00810084 (`CostNode`/`CostRunner`,
`ConstantCost`, `VolSlippageCost`, `CostSum`, the `cost_graph` composite, the
`net_r_equity` tap, and the `summarize_r` cost-stream fold).
## Goal
Ship the **per-cycle-held accrual mechanism** — the unbuilt half of C10's "per-trade
factors deduct at close; **per-cycle-held factors accrue over the hold**" — exercised
by the simplest concrete accrual node, a constant per-held-cycle carry. A carry/funding
cost is incurred for *every cycle a position is held*, not once at close; the headline
artifact, the `net_r_equity` equity curve, must **bleed continuously over the hold**
(approach B, the user's choice — "ja, mach B"), not step at close (approach A).
Scope is deliberately minimal: the charge-mode mechanism + one constant carry node + a
CLI flag + tests. Explicitly **out** (each a later #148 cycle): a notional-based carry
(`price × rate`), the calendar-aware *overnight swap* proper (rollover-boundary timing,
the 3× Wednesday rule, long/short rate asymmetry — deploy-edge realism), sweep-path cost,
and the conviction axis.
## Architecture
A cost node's charge timing becomes a property of the **factor**, read by the one shared
`CostRunner`. Today every cost node charges *at close* (a per-trade cost). A new
`ChargeMode` lets a factor declare it charges *per held cycle* instead. The shared runner
gains one branch; the at-close branch is kept byte-for-byte. This honours cycle 0083's
"write the co-temporality skeleton once" — a single runner, not a second `AccrualRunner`
type (a commission is intrinsically at-close, a carry intrinsically per-held-cycle; the
timing belongs to the factor).
The honest bleeding curve is produced **without touching `summarize_r` or the CLI
`net_eq` wiring**. The 3-field cost record's existing semantics generalize:
- `cost_in_r` — the cost **realized this cycle** (added to `cum`). AtClose: `per` at close.
PerHeldCycle: the accrued total, **dumped at close**.
- `cum_cost_in_r` — running cumulative of `cost_in_r` (realized/closed cost).
- `open_cost_in_r` — the open position's cost **marked-to-market as-of-now**. AtClose: the
would-be close cost. PerHeldCycle: the **accrued-so-far** (grows each held cycle).
The `net_r_equity` tap is `cum_realized_r + unrealized_r cum_cost_in_r open_cost_in_r`.
During a hold the PerHeldCycle node's `open_cost_in_r` grows each cycle, so the curve
bleeds; at close the accrued total moves into `cum_cost_in_r` and `open_cost_in_r` drops to
0 — a clean hand-off, no double-count. `summarize_r` reads the closed-trade cost from the
close row's `cost_in_r` (= the dumped accrued total) and the window-end open-trade cost
from `open_cost_in_r` (= accrued-so-far); both already correct, so the fold is unchanged.
For the AtClose model nothing about the record or the fold changes, so every cycle-0083/0084
golden stays byte-identical trivially.
The new node, `CarryCost`, is a `ConstantCost` twin: a constant price-unit numerator,
R-normalized by the latched 1R distance through the shared runner, differing only in
`charge_mode() -> PerHeldCycle`. A labelled stress parameter (C10: each factor is a
stress-parameter OR data-grounded; this is the former). It drops into the existing
`cost_graph`/`CostSum` aggregation with no new wiring — it has no extra inputs (like
`ConstantCost`), so it needs only the 4 geometry roles `cost_graph` already fans, and
`CostSum` already sums its 3 fields into the aggregate the run consumes.
## Concrete code shapes
### User-facing surface (the acceptance-criterion evidence)
A researcher asks "what if holding a position costs 0.5 price-units of carry per cycle?"
and reaches for a flag, exactly as for the existing per-trade and slippage costs:
```
# carry alone — the net-R curve bleeds each held cycle:
aura run --harness stage1-r --carry-per-cycle 0.5
# composed with the existing per-trade cost (the costs sum, per cost_graph/CostSum):
aura run --harness stage1-r --cost-per-trade 2 --carry-per-cycle 0.5
# trace the bleed (net_r_equity falls during the hold, not only at close):
aura run --harness stage1-r --carry-per-cycle 0.5 --trace carry
aura chart carry --tap net_r_equity
```
Observable effect: `net_expectancy_r` drops by each trade's total accrued carry (carry per
held cycle × cycles held), and the `net_r_equity` tap bleeds continuously over each hold
(the property that distinguishes B from A — under A the same scalar would step at close).
### `CarryCost` node (new — `crates/aura-std/src/carry_cost.rs`)
A `ConstantCost` twin; the only difference is the `charge_mode` override:
```rust
//! `CarryCost` — a flat per-held-cycle carry/financing cost, in R. The simplest
//! ACCRUAL cost node (C10): a cost incurred for every cycle a position is held,
//! accruing over the hold rather than charged once at close. A labelled stress
//! parameter (a constant price-unit carry); the notional-based and calendar-aware
//! (overnight-swap) variants are later #148 cycles.
use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind};
use crate::cost::{cost_node_builder, ChargeMode, CostNode, CostRunner};
/// A flat per-held-cycle carry in price units, emitted in R via the shared
/// [`CostRunner`] in its per-held-cycle (accrual) charge mode.
pub struct CarryCost {
carry_per_cycle: f64,
}
impl CarryCost {
pub fn new(carry_per_cycle: f64) -> CostRunner<CarryCost> {
assert!(carry_per_cycle >= 0.0, "CarryCost carry_per_cycle must be >= 0");
CostRunner::new(CarryCost { carry_per_cycle })
}
pub fn builder() -> PrimitiveBuilder {
cost_node_builder(
"CarryCost",
Vec::new(),
vec![ParamSpec { name: "carry_per_cycle".into(), kind: ScalarKind::F64 }],
|p| Box::new(CarryCost::new(p[0].f64())),
)
}
}
impl CostNode for CarryCost {
fn label(&self) -> String {
format!("CarryCost({})", self.carry_per_cycle)
}
fn charge_mode(&self) -> ChargeMode {
ChargeMode::PerHeldCycle
}
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
self.carry_per_cycle
}
}
```
### `CostNode` / `CostRunner` (modified — `crates/aura-std/src/cost.rs`)
`ChargeMode` is new; `CostNode` gains a defaulted `charge_mode()`; `CostRunner` gains
`acc` state and one branch in `eval`. The **AtClose branch keeps the current tokens
verbatim** (IEEE-754 byte-identity).
before (the trait + the charge body of `eval`):
```rust
pub trait CostNode: 'static {
fn label(&self) -> String;
fn extra_inputs(&self) -> Vec<PortSpec> { Vec::new() }
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64;
}
// ... in eval, after computing `per`:
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;
```
after:
```rust
/// When a cost factor charges its cost. `AtClose` — once per closed trade (commission,
/// flat cost, slippage): the per-trade default. `PerHeldCycle` — every cycle the
/// position is held (carry, funding): accrues over the hold.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChargeMode {
AtClose,
PerHeldCycle,
}
pub trait CostNode: 'static {
fn label(&self) -> String;
fn extra_inputs(&self) -> Vec<PortSpec> { Vec::new() }
/// When this factor charges. Default `AtClose` (the per-trade cost shape).
fn charge_mode(&self) -> ChargeMode { ChargeMode::AtClose }
fn cost_numerator(&mut self, ctx: &Ctx<'_>) -> f64;
}
// CostRunner gains `acc: f64` (the open position's accrued carry, for PerHeldCycle),
// initialised 0.0 alongside `cum`. In eval, after computing `per`:
let (cost_in_r, open_cost_in_r) = match self.factor.charge_mode() {
ChargeMode::AtClose => {
// verbatim the pre-cycle-5 charge (byte-identity)
let cost_in_r = if closed { per } else { 0.0 };
let open_cost_in_r = if open { per } else { 0.0 };
(cost_in_r, open_cost_in_r)
}
ChargeMode::PerHeldCycle => {
// Accrue this cycle's carry while the position exists (open or closing).
if open || closed {
self.acc += per;
}
// Realize the accrued total into cum at close; mark it open-MtM while held.
let cost_in_r = if closed { self.acc } else { 0.0 };
let open_cost_in_r = if open { self.acc } else { 0.0 };
if closed {
self.acc = 0.0; // reset for the next position
}
(cost_in_r, open_cost_in_r)
}
};
self.cum += cost_in_r;
```
### `summarize_r` and the CLI `net_eq` wiring — UNCHANGED
`crates/aura-analysis/src/lib.rs::summarize_r` is **not modified**: the closed-trade
read (`cost[i].cost_in_r` at the close row) already sees the dumped accrued total, and
the window-end open-trade read (`cost[last].open_cost_in_r`) already sees the
accrued-so-far. The CLI `net_eq` `LinComb(4)` and the cost recorder in
`stage1_r_graph` (`crates/aura-cli/src/main.rs`) are **not modified** — they already
subtract both `cum_cost_in_r` and `open_cost_in_r`, which is exactly the bleed.
### CLI flag (modified — `crates/aura-cli/src/main.rs`)
`CostConfig` gains a third optional field; the arg parser gains `--carry-per-cycle`
(at most once, mirroring `--cost-per-trade`); the cost-node build pushes a `CarryCost`:
```rust
struct CostConfig {
const_cost: Option<f64>, // --cost-per-trade
slip_vol_mult: Option<f64>, // --slip-vol-mult
carry_per_cycle: Option<f64>, // --carry-per-cycle
}
// in stage1_r_graph's cost block, in the same conditional order, after the existing pushes:
if let Some(cpc) = cfg.carry_per_cycle {
cost_nodes.push(CarryCost::builder().bind("carry_per_cycle", Scalar::f64(cpc)));
}
// (no vol_slot bookkeeping — CarryCost has no extra inputs; cost_graph fans only geometry.)
```
The flag is run-path-scoped only (the sweep / walk-forward / Monte-Carlo paths pass
`None`, exactly as the existing cost flags do — sweep-path cost is a later #148 cycle).
The `cost` carrier `Option` is `Some` when *any* of the three cost flags is set; usage
strings and `RunArgs` thread the new flag alongside the existing two.
## Components
- **`ChargeMode`** (`cost.rs`, new enum) — `AtClose` (default) | `PerHeldCycle`.
- **`CostNode::charge_mode()`** (`cost.rs`, new defaulted method) — `AtClose` default keeps
every existing factor (`ConstantCost`, `VolSlippageCost`, test stubs) unchanged.
- **`CostRunner::acc`** (`cost.rs`, new state) — the open position's accrued carry; only
read/written on the `PerHeldCycle` branch, so AtClose runners are unaffected.
- **`CarryCost`** (`carry_cost.rs`, new node) — the constant per-held-cycle carry factor.
- **`CostConfig::carry_per_cycle` + `--carry-per-cycle`** (`main.rs`) — the run-path knob.
- **`summarize_r`, the `net_eq` wiring, `cost_graph`, `CostSum`** — unchanged consumers.
## Data flow
Per cycle, the executor emits its PM record; `cost_graph` fans the 4 geometry inputs
(`closed_this_cycle`, `open`, `entry_price`, `stop_price`) to each active cost node.
`CarryCost`'s runner, in `PerHeldCycle` mode, accrues `carry_per_cycle / |entry stop|`
into `acc` whenever the position is held, exposes `acc` as `open_cost_in_r` while open,
and dumps `acc` into `cost_in_r` (and `cum`) at close. `CostSum` sums the per-field
records across all active nodes; the aggregate feeds the `net_r_equity` tap (bleeds via
the growing `open_cost_in_r`) and the cost recorder (folded by `summarize_r` into
`net_expectancy_r`). Determinism (C1), causality (C2 — the factor reads only the
current/past geometry), co-temporality (the cost stream stays 1:1 with the PM record:
the runner still emits a row every cycle the geometry is present) are all preserved.
## Error handling
- `CarryCost::new` panics on a negative `carry_per_cycle` (mirrors `ConstantCost::new`;
a stress parameter is a non-negative cost).
- Zero latched 1R distance ⇒ no valid denominator ⇒ `per = 0.0` (the shared runner's
existing guard; unchanged, applies to both modes).
- `--carry-per-cycle` given twice, or a non-f64 value, is a usage error (mirrors the
existing `--cost-per-trade` arg handling).
- Co-temporality: a held cycle with no close still emits its row (charge 0 into
`cost_in_r`, the accrued into `open_cost_in_r`) — the stream never desyncs from PM.
## Testing strategy
RED-first where test-specifiable. The load-bearing proof is that B (bleed) was built,
not A (step) — and since `net_expectancy_r` is **scalar-identical** under A and B, the
discriminator is the per-cycle shape of `open_cost_in_r` / `net_r_equity`, not the scalar.
- **`cost.rs` unit (the B-proof):** a `CostRunner` over a `PerHeldCycle` stub fed a
multi-cycle hold (open, open, close) emits a **growing** `open_cost_in_r`
(`per`, `2·per`, then 0 at close) and dumps the accrued total into `cost_in_r` at close
(`3·per`), with `cum` stepping only at close. A second case: two consecutive trades —
`acc` resets after the first close so the second trade's accrued total is its own.
- **`cost.rs` unchanged-AtClose regression:** the existing AtClose tests
(`charges_numerator_over_latched_on_close`, `open_emits_would_be_cost_not_in_cum`,
`cum_accumulates`, …) stay green verbatim (they pin the verbatim branch).
- **`carry_cost.rs` unit:** `CarryCost` charges per held cycle (not only at close),
`charge_mode() == PerHeldCycle`, `label()` carries the param, `builder()` exposes one
`carry_per_cycle` F64 param and no extra inputs, `new(-1.0)` panics.
- **`cli_run.rs` golden:** the EXACT `net_expectancy_r` of
`aura run --harness stage1-r --carry-per-cycle <c>` (pin the f64), and a composed
`--cost-per-trade 2 --carry-per-cycle <c>` golden (the costs sum).
- **`cli_run.rs` bleed test (the integration B-proof):** with `--carry-per-cycle` and
`--trace`, the `net_r_equity` tap falls **during** a multi-cycle hold (strictly below
`r_equity` by a growing amount before the trade closes), proving the bleed is
continuous, not a single close-step.
- **Byte-identity regression:** `stage1_r_flat_cost_net_expectancy_r_golden`
(-614.3134020253314), `stage1_r_composed_cost_net_expectancy_r_golden`
(-615.0304388396047), and the C18 no-cost `stage1_r_single_run_output_golden` stay
byte-identical (summarize_r + the AtClose branch are untouched). Full
`cargo test --workspace` green; `clippy --workspace --all-targets -- -D warnings` clean.
## Acceptance criteria
1. `aura run --harness stage1-r --carry-per-cycle <c>` runs; `net_expectancy_r` drops by
each trade's total accrued carry; the `net_r_equity` tap bleeds over the hold.
2. `--carry-per-cycle` composes additively with `--cost-per-trade` / `--slip-vol-mult`
(costs sum through `cost_graph`/`CostSum`).
3. The `net_r_equity` curve bleeds continuously during a hold (B), not only at close (A)
— pinned by the unit B-proof and the CLI bleed test.
4. Every cycle-0083/0084 golden is byte-identical; the full suite is green; clippy clean.
5. The mechanism is a single shared runner with one new branch (no second runner type);
`summarize_r` and the CLI `net_eq` wiring are unmodified.
6. Scope holds: no notional carry, no calendar-aware overnight swap, no sweep-path cost,
no conviction work (all deferred on #148).