diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 37730ff..ab1958d 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -798,6 +798,38 @@ non-load-bearing). **Carried debt (#152, for the deferred sweep-cost cycle):** t construction, but to be interned (the `COL_PORTS` production pattern, not the test-only `.leak()`) before cost reaches the per-member sweep path. Decision log: #148. +**Realization (cycle 0085 — per-cycle-held accrual, cycle 5, #148).** C10's +"per-trade factors deduct at close; **per-cycle-held factors accrue over the hold**" +clause is first realized. A cost factor now declares *when* it charges via +`ChargeMode { AtClose, PerHeldCycle }` (a defaulted `CostNode::charge_mode()`, default +`AtClose`), read by the **one shared `CostRunner`** — not a second runner type (a +commission is intrinsically at-close, a carry intrinsically per-held-cycle; the timing +belongs to the factor, preserving the cycle-0083 "skeleton written once" win). The +`PerHeldCycle` arm accrues `per` into a per-position `acc` every held cycle, dumps the +accrued total into `cum` at close (resetting `acc`), and marks the open position via a +**growing `open_cost_in_r`**. The first accrual node, **`CarryCost`** (`aura-std`, +a `ConstantCost` twin differing only in `charge_mode()`), is a labelled stress +parameter — the flat base of the accrual family; a run-path `--carry-per-cycle` flag +pushes it into the existing `cost_graph`/`CostSum` aggregation (no new wiring; a +per-trade and a per-held-cycle node compose, the costs summing per-field). **Approach B +(honest bleed), achieved without a `summarize_r` fold change:** the headline +`net_r_equity` curve bleeds continuously over the hold because the bleed lives in +`open_cost_in_r`, which the `net_r_equity` tap already subtracts — so `summarize_r` and +the CLI `net_eq` wiring are **untouched**, and the cycle-0083/0084 `net_expectancy_r` +goldens stay byte-identical (the `AtClose` arm is the pre-cycle-5 eval tokens verbatim). +The 3-field cost record's semantics generalize cleanly: `cost_in_r` = cost realized this +cycle (into `cum`); `open_cost_in_r` = the open position's cost marked-to-market +as-of-now (would-be-close for `AtClose`, accrued-so-far for `PerHeldCycle`) — singly +counted, no `cum`/`open` double-count (the two are mutually exclusive per cycle). Honours +C9 (a `CostRunner` is an ordinary downstream `Node`), C11 (byte-identity), C16 +(factor in `aura-std`, `aura-engine` domain-free), C23 (`label()` carries the rate). The +B-vs-A discriminator (a growing intra-hold `open_cost_in_r`, invisible to the scalar +`net_expectancy_r`) is pinned by unit B-proofs + a `net_r_equity`-bleeds-over-the-hold +integration test. **Deferred (each its own #148 cycle):** a notional-based carry +(`price × rate`, reads the price tap), the calendar-aware **overnight swap** proper +(rollover-boundary timing, 3× Wednesday, long/short asymmetry — deploy-edge realism), and +sweep-path cost. Decision log: #148. + **Reframe (2026-06-23, #117 — exposure → bias, R as the signal-quality unit).** [HISTORY — its R spine survives into the 2026-06-28 contract; its Stage-2 currency / realistic-broker / register / flat-1R-vs-compounding portions are SUPERSEDED by that diff --git a/docs/plans/0085-per-cycle-accrual-carry-cost.md b/docs/plans/0085-per-cycle-accrual-carry-cost.md deleted file mode 100644 index 173d422..0000000 --- a/docs/plans/0085-per-cycle-accrual-carry-cost.md +++ /dev/null @@ -1,744 +0,0 @@ -# Per-cycle-held accrual + CarryCost — Implementation Plan - -> **Parent spec:** `docs/specs/0085-per-cycle-accrual-carry-cost.md` -> -> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run -> this plan. Steps use `- [ ]` checkboxes for tracking. - -**Goal:** Add a per-cycle-held accrual charge mode to the cost-node skeleton plus a -constant `CarryCost` node and a `--carry-per-cycle` CLI flag, so a carry/funding cost -bleeds the `net_r_equity` curve over the hold — while `summarize_r` and the CLI -`net_eq` wiring stay untouched and every cycle-0083/0084 golden stays byte-identical. - -**Architecture:** `ChargeMode` becomes a property of the cost factor read by the one -shared `CostRunner` (Task 1); the `AtClose` arm keeps the current eval tokens verbatim -(byte-identity), the new `PerHeldCycle` arm accrues into `acc`, dumps the total into -`cum` at close, and marks the open position (grows each held cycle) so the curve bleeds. -`CarryCost` is a `ConstantCost` twin differing only in `charge_mode()` (Task 2). The CLI -flag threads through the existing `cost_graph`/`CostSum`/`net_eq` wiring with no change to -that wiring (Task 3). Tests pin the B-proof (open_cost grows, dumps at close) at the unit -level and the bleed end-to-end (Task 4). - -**Tech Stack:** `crates/aura-std/src/cost.rs` (ChargeMode + runner branch), -`crates/aura-std/src/carry_cost.rs` (new node), `crates/aura-std/src/lib.rs` (module + -re-exports), `crates/aura-cli/src/main.rs` (flag wiring), `crates/aura-cli/tests/cli_run.rs` -(goldens + bleed test). `summarize_r` and `crates/aura-analysis` are UNCHANGED. - ---- - -**Files this plan creates or modifies:** - -- Modify: `crates/aura-std/src/cost.rs` — `ChargeMode` enum, `CostNode::charge_mode()` - default, `CostRunner::acc` + the `eval` match branch; new `#[cfg(test)]` PerHeldCycle - stub + 2 B-proof tests. -- Create: `crates/aura-std/src/carry_cost.rs` — `CarryCost` node (ConstantCost twin, - `charge_mode() -> PerHeldCycle`) + its `#[cfg(test)]` tests. -- Modify: `crates/aura-std/src/lib.rs:22-24,51-53` — `mod carry_cost;`, `pub use - carry_cost::CarryCost;`, add `ChargeMode` to the `pub use cost::{…}` re-export. -- Modify: `crates/aura-cli/src/main.rs` — `CarryCost` import (32), `CostConfig` - field (2577), the `CarryCost` push (~2743), the arg parser + `RunArgs` + - `run_stage1_r` signature + its 2 call sites (3218, 4446) + usage strings. -- Test: `crates/aura-cli/tests/cli_run.rs` — two `--carry-per-cycle` net_expectancy_r - goldens (capture-then-pin) + the `net_r_equity`-bleeds-over-the-hold test. - ---- - -## Task 1: ChargeMode + PerHeldCycle accrual branch (cost.rs) - -**Files:** -- Modify: `crates/aura-std/src/cost.rs:78-91` (trait), `:96-106` (runner struct + new), - `:124-134` (eval charge body), `:162+` (test mod) - -The `CostNode` trait gains a defaulted `charge_mode()`; `CostRunner` gains `acc` state -and one `eval` branch. The AtClose arm is the current code verbatim (byte-identity); the -PerHeldCycle arm is new. RED-first: the scaffolding (enum + defaulted method) is added so -the new tests COMPILE, then the tests fail by assertion against the still-AtClose eval, -then the branch makes them pass. - -- [ ] **Step 1: Add the `ChargeMode` enum + the defaulted `charge_mode()` (scaffolding, inert)** - -Insert the enum just above the `pub trait CostNode` line (cost.rs:78): - -```rust -/// When a cost factor charges its cost. `AtClose` — once per closed trade (commission, -/// flat cost, slippage): the per-trade default, charged on the close cycle. `PerHeldCycle` -/// — every cycle the position is held (carry, funding): the cost accrues over the hold. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ChargeMode { - AtClose, - PerHeldCycle, -} -``` - -Add the defaulted method inside the `CostNode` trait, after `extra_inputs` (cost.rs:83-85), -before `cost_numerator` (cost.rs:90): - -```rust - /// When this factor charges. Default `AtClose` (the per-trade cost shape); a - /// per-held-cycle (accrual) factor overrides this to `PerHeldCycle`. - fn charge_mode(&self) -> ChargeMode { - ChargeMode::AtClose - } -``` - -- [ ] **Step 2: Add the PerHeldCycle test stub + the two RED B-proof tests** - -In the `#[cfg(test)] mod tests` (cost.rs:162), add a stub mirroring `StubCost` (cost.rs:169-177) -but overriding `charge_mode`: - -```rust - /// A test-only factor charging per held cycle (accrual), constant numerator. - struct StubPerHeld(f64); - impl CostNode for StubPerHeld { - fn label(&self) -> String { - format!("StubPerHeld({})", self.0) - } - fn charge_mode(&self) -> ChargeMode { - ChargeMode::PerHeldCycle - } - fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 { - self.0 - } - } -``` - -Add the two tests (use the existing `geom_cols()` helper at cost.rs:195): - -```rust - #[test] - fn per_held_cycle_accrues_open_cost_and_dumps_at_close() { - // per = 2/4 = 0.5 each cycle. A hold of open, open, close. - let mut r = CostRunner::new(StubPerHeld(2.0)); - // Cycle 1: held open -> accrue 0.5 into open_cost; nothing into cum yet. - let mut a = geom_cols(); - a[0].push(Scalar::bool(false)).unwrap(); // not closed - a[1].push(Scalar::bool(true)).unwrap(); // open - a[2].push(Scalar::f64(100.0)).unwrap(); - a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4 -> per 0.5 - assert_eq!( - r.eval(Ctx::new(&a, Timestamp(0))), - Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice()) - ); - // Cycle 2: still held open -> open_cost GROWS to 1.0 (the accrual bleed), cum still 0. - let mut b = geom_cols(); - b[0].push(Scalar::bool(false)).unwrap(); - b[1].push(Scalar::bool(true)).unwrap(); - b[2].push(Scalar::f64(100.0)).unwrap(); - b[3].push(Scalar::f64(96.0)).unwrap(); - assert_eq!( - r.eval(Ctx::new(&b, Timestamp(1))), - Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(1.0)].as_slice()) - ); - // Cycle 3: close -> accrue the closing cycle, DUMP the total 1.5 into cost_in_r, - // cum steps to 1.5, open_cost back to 0. - let mut c = geom_cols(); - c[0].push(Scalar::bool(true)).unwrap(); // closed - c[1].push(Scalar::bool(false)).unwrap(); // not open - c[2].push(Scalar::f64(100.0)).unwrap(); - c[3].push(Scalar::f64(96.0)).unwrap(); - assert_eq!( - r.eval(Ctx::new(&c, Timestamp(2))), - Some([Cell::from_f64(1.5), Cell::from_f64(1.5), Cell::from_f64(0.0)].as_slice()) - ); - } - - #[test] - fn per_held_cycle_resets_accrual_between_trades() { - // Two trades (open, close each). acc must reset at the first close so the - // second trade's dumped total is its OWN accrual, not cumulative. - let mut r = CostRunner::new(StubPerHeld(2.0)); // per 0.5 - let mut o1 = geom_cols(); - o1[0].push(Scalar::bool(false)).unwrap(); - o1[1].push(Scalar::bool(true)).unwrap(); - o1[2].push(Scalar::f64(100.0)).unwrap(); - o1[3].push(Scalar::f64(96.0)).unwrap(); - let _ = r.eval(Ctx::new(&o1, Timestamp(0))); // open_cost 0.5 - let mut c1 = geom_cols(); - c1[0].push(Scalar::bool(true)).unwrap(); - c1[1].push(Scalar::bool(false)).unwrap(); - c1[2].push(Scalar::f64(100.0)).unwrap(); - c1[3].push(Scalar::f64(96.0)).unwrap(); - assert_eq!( - r.eval(Ctx::new(&c1, Timestamp(1))), - Some([Cell::from_f64(1.0), Cell::from_f64(1.0), Cell::from_f64(0.0)].as_slice()) - ); // trade 1 total 1.0, cum 1.0, acc reset - let mut o2 = geom_cols(); - o2[0].push(Scalar::bool(false)).unwrap(); - o2[1].push(Scalar::bool(true)).unwrap(); - o2[2].push(Scalar::f64(100.0)).unwrap(); - o2[3].push(Scalar::f64(96.0)).unwrap(); - let _ = r.eval(Ctx::new(&o2, Timestamp(2))); // fresh open_cost 0.5 - let mut c2 = geom_cols(); - c2[0].push(Scalar::bool(true)).unwrap(); - c2[1].push(Scalar::bool(false)).unwrap(); - c2[2].push(Scalar::f64(100.0)).unwrap(); - c2[3].push(Scalar::f64(96.0)).unwrap(); - assert_eq!( - r.eval(Ctx::new(&c2, Timestamp(3))), - Some([Cell::from_f64(1.0), Cell::from_f64(2.0), Cell::from_f64(0.0)].as_slice()) - ); // trade 2's OWN total 1.0 (acc reset proved), cum running 2.0 - } -``` - -- [ ] **Step 3: Run the new tests to verify they FAIL (RED)** - -Run: `cargo test -p aura-std per_held_cycle` -Expected: FAIL — both `per_held_cycle_accrues_open_cost_and_dumps_at_close` and -`per_held_cycle_resets_accrual_between_trades` fail by assertion (the eval still runs the -unconditional AtClose path, so cycle 2's `open_cost_in_r` is `0.5` not `1.0`, and the close -dumps `0.5` not the accrued total). `left == right` mismatch on the cells. - -- [ ] **Step 4: Add `CostRunner::acc` + the `eval` match branch** - -Add the `acc` field to the `CostRunner` struct (cost.rs:96-100), after `cum`: - -```rust -pub struct CostRunner { - factor: F, - cum: f64, - acc: f64, - out: [Cell; COST_WIDTH], -} -``` - -Init it in `CostRunner::new` (cost.rs:104): - -```rust - pub fn new(factor: F) -> Self { - Self { factor, cum: 0.0, acc: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] } - } -``` - -In `eval`, replace the two lines (cost.rs:132-133) - -```rust - let cost_in_r = if closed { per } else { 0.0 }; - let open_cost_in_r = if open { per } else { 0.0 }; -``` - -with the match (keep cost.rs:131 `let per = …` above it and cost.rs:134 -`self.cum += cost_in_r;` below it unchanged): - -```rust - let (cost_in_r, open_cost_in_r) = match self.factor.charge_mode() { - // At-close (per-trade): charged once on the close cycle. VERBATIM the - // pre-cycle-5 tokens — IEEE-754 byte-identity with the existing goldens. - ChargeMode::AtClose => { - 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) - } - // Per-held-cycle (accrual): the carry accrues every cycle the position is - // held; the accrued total realizes into `cum` at close, and marks the open - // position (grows each held cycle) so the net_r_equity curve bleeds over - // the hold rather than stepping at close. - ChargeMode::PerHeldCycle => { - if open || closed { - self.acc += per; - } - 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; - } - (cost_in_r, open_cost_in_r) - } - }; -``` - -- [ ] **Step 5: Run all aura-std tests to verify GREEN (new + verbatim AtClose regression)** - -Run: `cargo test -p aura-std` -(Unfiltered on purpose: the AtClose regression tests `charges_numerator_over_latched_on_close` -and `cum_accumulates` carry no "cost" substring, so a `cost` name-filter would silently skip -them — run the whole crate so "nothing ran" cannot masquerade as green.) -Expected: PASS — the two new `per_held_cycle_*` tests pass, AND the existing AtClose -tests stay verbatim-green: `charges_numerator_over_latched_on_close`, -`open_emits_would_be_cost_not_in_cum`, `zero_latched_no_cost`, `cum_accumulates`, -`extra_input_cold_contributes_zero_but_row_emits`, `extra_input_warm_scales_numerator`, -`producer_and_aggregator_share_the_triple`, etc. - ---- - -## Task 2: CarryCost node (carry_cost.rs + lib.rs registration) - -**Files:** -- Create: `crates/aura-std/src/carry_cost.rs` -- Modify: `crates/aura-std/src/lib.rs:22-24` (mod), `:51-53` (re-exports) - -A `ConstantCost` twin differing only in `charge_mode() -> PerHeldCycle`. RED-first: the -file is created WITHOUT the `charge_mode` override (so it defaults AtClose), the tests are -written, they fail by assertion, then the override makes them pass. Depends on Task 1 -(`ChargeMode` + the PerHeldCycle branch must exist). - -- [ ] **Step 1: Register the module + re-exports in lib.rs** - -In `crates/aura-std/src/lib.rs` add `mod carry_cost;` at the alphabetical slot between -`mod bias;` (line 22) and `mod constant_cost;` (line 23): - -```rust -mod bias; -mod carry_cost; -mod constant_cost; -mod cost; -``` - -Add the re-export between `pub use bias::Bias;` (line 51) and -`pub use constant_cost::ConstantCost;` (line 52), and add `ChargeMode` to the existing -`pub use cost::{…}` line (53): - -```rust -pub use bias::Bias; -pub use carry_cost::CarryCost; -pub use constant_cost::ConstantCost; -pub use cost::{ - cost_node_builder, ChargeMode, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, - GEOMETRY_WIDTH, -}; -``` - -(`ChargeMode` is re-exported for parity with the line-53 convention of surfacing every -public cost symbol — it is part of the public `CostNode::charge_mode` return type, so a -downstream crate authoring a per-held-cycle node can name it; the CLI does not need it.) - -- [ ] **Step 2: Create `carry_cost.rs` as the twin WITHOUT the charge_mode override (RED scaffolding)** - -```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. A `ConstantCost` twin differing -//! only in `charge_mode() -> PerHeldCycle`; the shared `CostRunner` accrues, dumps at -//! close, and marks the open position so the net_r_equity curve bleeds over the hold. - -use aura_core::{Ctx, ParamSpec, PrimitiveBuilder, ScalarKind}; - -use crate::cost::{cost_node_builder, CostNode, CostRunner}; - -/// A flat per-held-cycle carry in price units (`carry_per_cycle`), emitted in R via the -/// shared [`CostRunner`] in its per-held-cycle (accrual) charge mode. -pub struct CarryCost { - carry_per_cycle: f64, -} - -impl CarryCost { - /// A flat per-held-cycle carry node: the factor wrapped in the shared [`CostRunner`]. - 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 }) - } - - /// The param-generic recipe: one `carry_per_cycle` F64 knob, no extra inputs. - 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 cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 { - self.carry_per_cycle - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cost::ChargeMode; - use aura_core::{AnyColumn, Cell, Node, Scalar, Timestamp}; - - fn cols() -> Vec { - vec![ - AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed - AnyColumn::with_capacity(ScalarKind::Bool, 1), // open - AnyColumn::with_capacity(ScalarKind::F64, 1), // entry - AnyColumn::with_capacity(ScalarKind::F64, 1), // stop - ] - } - - #[test] - fn charge_mode_is_per_held_cycle() { - assert_eq!(CarryCost { carry_per_cycle: 1.0 }.charge_mode(), ChargeMode::PerHeldCycle); - } - - #[test] - fn accrues_each_held_cycle_then_dumps_at_close() { - // per = 1/4 = 0.25. open, open, close -> open_cost 0.25, 0.5, then dump 0.75. - let mut c = CarryCost::new(1.0); - let mut a = cols(); - a[0].push(Scalar::bool(false)).unwrap(); - a[1].push(Scalar::bool(true)).unwrap(); - a[2].push(Scalar::f64(100.0)).unwrap(); - a[3].push(Scalar::f64(96.0)).unwrap(); // latched 4 - assert_eq!( - c.eval(Ctx::new(&a, Timestamp(0))), - Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.25)].as_slice()) - ); - let mut b = cols(); - b[0].push(Scalar::bool(false)).unwrap(); - b[1].push(Scalar::bool(true)).unwrap(); - b[2].push(Scalar::f64(100.0)).unwrap(); - b[3].push(Scalar::f64(96.0)).unwrap(); - assert_eq!( - c.eval(Ctx::new(&b, Timestamp(1))), - Some([Cell::from_f64(0.0), Cell::from_f64(0.0), Cell::from_f64(0.5)].as_slice()) - ); - let mut d = cols(); - d[0].push(Scalar::bool(true)).unwrap(); - d[1].push(Scalar::bool(false)).unwrap(); - d[2].push(Scalar::f64(100.0)).unwrap(); - d[3].push(Scalar::f64(96.0)).unwrap(); - assert_eq!( - c.eval(Ctx::new(&d, Timestamp(2))), - Some([Cell::from_f64(0.75), Cell::from_f64(0.75), Cell::from_f64(0.0)].as_slice()) - ); - } - - #[test] - fn label_carries_the_carry() { - assert_eq!(CarryCost::new(0.5).label(), "CarryCost(0.5)"); - } - - #[test] - fn builder_exposes_one_param_no_extra() { - let schema = CarryCost::builder().schema(); - assert_eq!(schema.params.len(), 1); - assert_eq!(schema.params[0].name, "carry_per_cycle"); - // geometry-only inputs (4), no extras. - assert_eq!(schema.inputs.len(), crate::cost::GEOMETRY_WIDTH); - } - - #[test] - #[should_panic(expected = "carry_per_cycle must be >= 0")] - fn new_panics_on_negative_carry() { - let _ = CarryCost::new(-1.0); - } -} -``` - -- [ ] **Step 3: Run carry_cost tests to verify they FAIL (RED)** - -Run: `cargo test -p aura-std --lib carry_cost` -Expected: FAIL — `charge_mode_is_per_held_cycle` fails (`AtClose != PerHeldCycle`) and -`accrues_each_held_cycle_then_dumps_at_close` fails by assertion (defaulting AtClose, the -second open cycle's `open_cost_in_r` is `0.25` not `0.5`, and the close charges `0.25` -not the dumped `0.75`). The other three (`label_carries_the_carry`, -`builder_exposes_one_param_no_extra`, `new_panics_on_negative_carry`) already pass. - -- [ ] **Step 4: Add the `charge_mode` override** - -Add the override + the `ChargeMode` import in `carry_cost.rs`. Change the import line to - -```rust -use crate::cost::{cost_node_builder, ChargeMode, CostNode, CostRunner}; -``` - -and add the method inside `impl CostNode for CarryCost`, after `label`: - -```rust - fn charge_mode(&self) -> ChargeMode { - ChargeMode::PerHeldCycle - } -``` - -- [ ] **Step 5: Run carry_cost tests to verify GREEN** - -Run: `cargo test -p aura-std --lib carry_cost` -Expected: PASS — all five `carry_cost` tests pass. - ---- - -## Task 3: CLI `--carry-per-cycle` flag wiring (main.rs) - -**Files:** -- Modify: `crates/aura-cli/src/main.rs:32` (import), `:2576-2579` (CostConfig), - `:2732-2746` (CarryCost push), `:3005-3010` (run_stage1_r sig), `:3023-3024` (carrier - guard + construction), `:3117-3123` (RunArgs), `:3132` (usage), `:3140-3141` - (accumulators), `:3178-3208` (parser + RunArgs construction), `:3218` (dispatch call), - `:3229` (USAGE), `:4446` (in-crate unit-test call site) - -Behaviour-preserving when the flag is unset (the goldens stay green); adds the carry node -when set. ALL `run_stage1_r` call sites are threaded in this task (the signature change + -both callers), so the build gate is satisfiable. No new test here — its correctness is -build-clean + the existing goldens byte-identical + the in-crate unit test green; the new -behaviour is exercised by Task 4. - -- [ ] **Step 1: Thread the flag through every site** - -`crates/aura-cli/src/main.rs:32` — add `CarryCost` to the `aura_std` import (it lists -`ConstantCost` on line 32): - -```rust - Add, Bias, CarryCost, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LongOnly, Mul, -``` - -(keep the rest of the multi-line `use aura_std::{ … }` block as-is; only insert -`CarryCost` in alphabetical position before `ConstantCost`.) - -`:2576-2579` — add the field to `CostConfig`: - -```rust -struct CostConfig { - const_cost: Option, // --cost-per-trade - slip_vol_mult: Option, // --slip-vol-mult - carry_per_cycle: Option, // --carry-per-cycle -} -``` - -`:2743-2746` — in the cost block, push a `CarryCost` after the `VolSlippageCost` push and -before `let cg = g.add(cost_graph(cost_nodes));` (no `vol_slot` bookkeeping — CarryCost has -no extra inputs): - -```rust - if let Some(cpc) = cfg.carry_per_cycle { - cost_nodes.push(CarryCost::builder().bind("carry_per_cycle", Scalar::f64(cpc))); - } - // A single cost_graph composite fans the shared PM-geometry into the active -``` - -`:3005-3010` — add the param to `run_stage1_r`'s signature (after `slip_vol_mult`): - -```rust - const_cost: Option, - slip_vol_mult: Option, - carry_per_cycle: Option, -``` - -`:3023-3024` — extend the carrier guard and the `CostConfig` construction: - -```rust - let cost_bundle = if const_cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some() { - Some((CostConfig { const_cost, slip_vol_mult, carry_per_cycle }, tx_net, tx_cost)) -``` - -`:3117-3123` — add the field to `RunArgs`: - -```rust - cost: Option, - slip_vol_mult: Option, - carry_per_cycle: Option, -``` - -`:3132` — add `[--carry-per-cycle ]` to the `parse_run_args` usage string (append -after `[--slip-vol-mult ]`). - -`:3140-3141` — add the accumulator next to the existing two: - -```rust - let mut carry_per_cycle: Option = None; -``` - -`:3195` — add a parser arm verbatim-mirroring the `--slip-vol-mult` arm (main.rs:3187-3194, -the `tail`/`t` split-first cursor + `usage` closure + `< 0.0` reject), inserted after that -arm (before the `_ => return Err(usage()),` catch-all at 3196): - -```rust - "--carry-per-cycle" if carry_per_cycle.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - let v: f64 = value.parse().map_err(|_| usage())?; - if v < 0.0 { - return Err(usage()); - } - carry_per_cycle = Some(v); - tail = t; - } -``` - -`:3208` — add the field to the `RunArgs` construction: - -```rust - Ok(RunArgs { harness, data, trace, cost, slip_vol_mult, carry_per_cycle }) -``` - -`:3218` — pass the new arg in the dispatch call: - -```rust - (HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost, args.slip_vol_mult, args.carry_per_cycle), -``` - -`:3229` — add `[--carry-per-cycle ]` to the top-level `USAGE` const's `run` form -(after `[--slip-vol-mult ]`). - -`:4446` — the in-crate unit test `run_stage1_r_synthetic_folds_an_r_block` call gains the -5th `None`: - -```rust - run_stage1_r(RunData::Synthetic, None, None, None, None) -``` - -(verify the exact current arg count at main.rs:4446 — it is `run_stage1_r(RunData::Synthetic, -None, None, None)`, four args; add one more `None`.) - -- [ ] **Step 2: Build the CLI crate clean** - -Run: `cargo build -p aura-cli` -Expected: `Finished` — 0 errors. (If `run_stage1_r` has any other call site the build will -name it; thread the new `None`/arg there too — all callers live in this task.) - -- [ ] **Step 3: Run the existing CLI goldens + the in-crate unit test (byte-identity)** - -Run: `cargo test -p aura-cli --test cli_run` -Expected: PASS — every existing test green, in particular -`stage1_r_flat_cost_net_expectancy_r_golden` (-614.3134020253314), -`stage1_r_composed_cost_net_expectancy_r_golden` (-615.0304388396047), and -`stage1_r_single_run_output_golden` byte-identical. - -Run: `cargo test -p aura-cli run_stage1_r_synthetic_folds_an_r_block` -(No `--lib`: this test lives in `main.rs` (a bin-target unit test), not a lib target, so a -name-only filter across all `aura-cli` targets is what resolves it.) -Expected: PASS — the threaded unit-test call site compiles and the R-block fold is unchanged. - ---- - -## Task 4: CLI carry goldens + the net_r_equity bleed test (cli_run.rs) - -**Files:** -- Test: `crates/aura-cli/tests/cli_run.rs` — append three tests near the existing cost - goldens (~after line 1729) - -Two capture-then-pin net_expectancy_r goldens (the carry value is whatever the -deterministic binary emits — RUN it, read the float, pin it; do NOT invent it) and the -bleed test (the integration B-proof: `net_r_equity` falls DURING the terminal multi-cycle -hold, ts 14-18, where the position stays open and never closes). Depends on Task 3. - -- [ ] **Step 1: Add the carry-only net_expectancy_r golden (capture-then-pin)** - -```rust -/// Golden characterization (cycle 0085, the per-held-cycle accrual path): pins the EXACT -/// `net_expectancy_r` of `aura run --harness stage1-r --carry-per-cycle 0.5`, so the -/// CarryCost accrual (open_cost grows over the hold, dumps into cum at close) is VERIFIED -/// at the observable boundary. Deterministic over the fixed synthetic stream (C1). -#[test] -fn stage1_r_carry_cost_net_expectancy_r_golden() { - let out = Command::new(BIN) - .args(["run", "--harness", "stage1-r", "--carry-per-cycle", "0.5"]) - .output() - .unwrap(); - assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, - String::from_utf8_lossy(&out.stderr)); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); - assert_eq!( - net, /* CAPTURE: the exact f64 this run prints */ 0.0, - "carry net_expectancy_r drifted from the golden: {s}" - ); -} -``` - -- [ ] **Step 2: Capture and pin the carry-only golden value** - -Run: `cargo test -p aura-cli --test cli_run stage1_r_carry_cost_net_expectancy_r_golden` -Expected: FAIL first (placeholder `0.0` mismatches) — the panic message prints the actual -`net_expectancy_r` (and the full report `s`). Replace the `0.0` with the EXACT printed -f64, re-run. -Expected (after pin): PASS. - -- [ ] **Step 3: Add the composed cost+carry net_expectancy_r golden (capture-then-pin)** - -```rust -/// Golden characterization (cycle 0085, composed at-close + accrual): pins the EXACT -/// `net_expectancy_r` of `aura run --harness stage1-r --cost-per-trade 2 --carry-per-cycle -/// 0.5` — the per-trade ConstantCost and the per-held-cycle CarryCost sum through -/// cost_graph/CostSum into one net-R curve. Deterministic (C1). -#[test] -fn stage1_r_cost_and_carry_composed_net_expectancy_r_golden() { - let out = Command::new(BIN) - .args(["run", "--harness", "stage1-r", "--cost-per-trade", "2", "--carry-per-cycle", "0.5"]) - .output() - .unwrap(); - assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, - String::from_utf8_lossy(&out.stderr)); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); - assert_eq!( - net, /* CAPTURE: the exact f64 this run prints */ 0.0, - "composed cost+carry net_expectancy_r drifted from the golden: {s}" - ); -} -``` - -- [ ] **Step 4: Capture and pin the composed golden value** - -Run: `cargo test -p aura-cli --test cli_run stage1_r_cost_and_carry_composed_net_expectancy_r_golden` -Expected: FAIL first (placeholder), read the printed f64, pin it, re-run → PASS. - -- [ ] **Step 5: Add the net_r_equity bleed test (the integration B-proof)** - -The terminal position is held open over the last consecutive cycles (the synthetic -fixture's ts 14-18, never closing). Under accrual the gap `r_equity − net_r_equity` -(= `cum_cost_in_r + open_cost_in_r`) STRICTLY GROWS across that terminal hold (open_cost -accrues each cycle), whereas a non-accruing (A / would-be-constant) cost would leave it -FLAT. Read both taps' first column off disk via the existing `json_array_body` helper -(cli_run.rs:478) and assert strict growth over the last 3 recorded cycles. - -```rust -/// Property (cycle 0085, the approach-B discriminator): with `--carry-per-cycle`, the -/// `net_r_equity` tap BLEEDS continuously during a multi-cycle hold — the gap to the -/// gross `r_equity` strictly grows each held cycle as the carry accrues, rather than -/// stepping only at close (approach A, which net_expectancy_r alone could not tell -/// apart). Read over the synthetic fixture's terminal open hold (the position held to the -/// end, never closing), where cum_cost_in_r is constant so the growth is the open-side -/// accrual alone. -#[test] -fn stage1_r_carry_net_r_equity_bleeds_over_the_hold() { - let dir = temp_cwd("stage1-r-carry-bleed"); - let run = Command::new(BIN) - .current_dir(&dir) - .args(["run", "--harness", "stage1-r", "--carry-per-cycle", "0.5", "--trace", "bleed"]) - .output() - .unwrap(); - assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status, - String::from_utf8_lossy(&run.stderr)); - - let read_col = |tap: &str| -> Vec { - let s = std::fs::read_to_string(dir.join(format!("runs/traces/bleed/{tap}.json"))) - .unwrap_or_else(|e| panic!("read {tap}.json: {e}")); - json_array_body(&s, "\"columns\":[[") - .split(',') - .map(|x| x.trim().parse::().unwrap_or_else(|_| panic!("parse {tap} value {x:?}"))) - .collect() - }; - let r_eq = read_col("r_equity"); - let net_eq = read_col("net_r_equity"); - assert_eq!(r_eq.len(), net_eq.len(), "taps are co-temporal (parallel arrays)"); - let n = r_eq.len(); - assert!(n >= 3, "need a multi-cycle terminal hold to observe the bleed; len {n}"); - // gap = r_equity − net_r_equity = cum_cost_in_r + open_cost_in_r. Over the terminal - // open hold cum is constant, so a strictly growing gap proves the open-side accrual. - let gap = |i: usize| r_eq[i] - net_eq[i]; - assert!( - gap(n - 3) < gap(n - 2) && gap(n - 2) < gap(n - 1), - "net_r_equity must bleed during the terminal hold (gap strictly grows): \ - gaps {}, {}, {}", - gap(n - 3), gap(n - 2), gap(n - 1) - ); - assert!(gap(n - 1) > 0.0, "carry is charged: terminal gap {} > 0", gap(n - 1)); - let _ = std::fs::remove_dir_all(&dir); -} -``` - -- [ ] **Step 6: Run the bleed test to verify GREEN** - -Run: `cargo test -p aura-cli --test cli_run stage1_r_carry_net_r_equity_bleeds_over_the_hold` -Expected: PASS — the terminal-hold gaps strictly increase (the accrual bleed), proving B -was built, not A. - -- [ ] **Step 7: Full workspace regression + clippy** - -Run: `cargo test --workspace` -Expected: PASS — 0 failures across all crates (the new cost.rs/carry_cost.rs/cli_run.rs -tests + every existing test, including the three byte-identity goldens). - -Run: `cargo clippy --workspace --all-targets -- -D warnings` -Expected: clean — no warnings (the new `acc` field is read on the PerHeldCycle branch; no -dead code). diff --git a/docs/specs/0085-per-cycle-accrual-carry-cost.md b/docs/specs/0085-per-cycle-accrual-carry-cost.md deleted file mode 100644 index 34f3916..0000000 --- a/docs/specs/0085-per-cycle-accrual-carry-cost.md +++ /dev/null @@ -1,312 +0,0 @@ -# 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).