f5e00a9c72
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". A carry/funding cost is charged for every cycle a position is held, so the net_r_equity equity curve bleeds continuously over the hold (approach B, "ja, mach B") rather than stepping at close. Mechanism (logged on #148): ChargeMode { AtClose, PerHeldCycle } is a property of the cost factor, read by the one shared CostRunner (no second runner type). The AtClose arm is the pre-cycle-5 eval tokens VERBATIM (IEEE-754 byte-identity). The PerHeldCycle arm accrues `per` into `acc` each 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 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 UNCHANGED, and the cycle-0083/0084 net_expectancy_r goldens (-614.3134020253314 flat, -615.0304388396047 composed) + the C18 no-cost golden stay byte-identical. CarryCost is a ConstantCost twin differing only in charge_mode() -> PerHeldCycle (a labelled stress parameter, the flat base of the accrual family). Wired through the existing cost_graph/CostSum aggregation; a per-trade ConstantCost and a per-held-cycle CarryCost compose in one run (the costs sum per-field — the composed golden is exactly the sum of the two single-cost charges). New run-path --carry-per-cycle flag (sweep/walkforward/mc pass None, as the existing cost flags do). Tests: 2 RED-first unit B-proofs (open_cost grows over the hold, dumps the total at close, acc resets between trades — the discriminator a scalar net_expectancy_r cannot see); the CarryCost twin's 5 unit tests; 2 captured net_expectancy_r goldens (-768.2095026600887 carry, -1383.7939051991182 composed); a net_r_equity-bleeds-over- the-hold integration test (the terminal open hold's r_equity-net_r_equity gap strictly grows); plus negative-rate-refused-exit-2, monotone-in-rate, and zero-rate-exactly-free guards. Verified: cargo test --workspace (0 failures), cargo build --workspace, cargo clippy --workspace --all-targets -- -D warnings clean. summarize_r / aura-analysis untouched. Deferred to later #148 cycles (logged there): notional-based carry, the calendar-aware overnight swap, sweep-path cost. refs #148
122 lines
4.4 KiB
Rust
122 lines
4.4 KiB
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 flat base case of the accrual
|
|
//! family. 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, ChargeMode, 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<CarryCost> {
|
|
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 charge_mode(&self) -> ChargeMode {
|
|
ChargeMode::PerHeldCycle
|
|
}
|
|
fn cost_numerator(&mut self, _ctx: &Ctx<'_>) -> f64 {
|
|
self.carry_per_cycle
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use aura_core::{AnyColumn, Cell, Node, Scalar, Timestamp};
|
|
|
|
fn cols() -> Vec<AnyColumn> {
|
|
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 builder = CarryCost::builder();
|
|
let schema = 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);
|
|
}
|
|
}
|