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:
@@ -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<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).
|
||||||
Reference in New Issue
Block a user