From 19ba1ec978f086d851eed42d3e0060637571cb0d Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 29 Jun 2026 00:02:26 +0200 Subject: [PATCH] spec: 0085 per-cycle-held accrual + CarryCost (boss-signed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../0085-per-cycle-accrual-carry-cost.md | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/specs/0085-per-cycle-accrual-carry-cost.md diff --git a/docs/specs/0085-per-cycle-accrual-carry-cost.md b/docs/specs/0085-per-cycle-accrual-carry-cost.md new file mode 100644 index 0000000..34f3916 --- /dev/null +++ b/docs/specs/0085-per-cycle-accrual-carry-cost.md @@ -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 0081–0084 (`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 { + 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 { 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 { 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, // --cost-per-trade + slip_vol_mult: Option, // --slip-vol-mult + carry_per_cycle: Option, // --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 ` (pin the f64), and a composed + `--cost-per-trade 2 --carry-per-cycle ` 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 ` 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).