feat(0085): per-cycle-held accrual — CarryCost + ChargeMode (approach B)
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
This commit is contained in:
+128
-3
@@ -43,6 +43,15 @@ fn cost_output_fields() -> Vec<FieldSpec> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// A cost factor: the per-round-trip cost in *price units* (the numerator the
|
||||
/// runner divides by the latched 1R distance). The only thing a cost node differs
|
||||
/// in; the co-temporality skeleton is [`CostRunner`]'s, shared.
|
||||
@@ -83,6 +92,11 @@ pub trait CostNode: 'static {
|
||||
fn extra_inputs(&self) -> Vec<PortSpec> {
|
||||
Vec::new()
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
/// The round-trip cost in price units this cycle, BEFORE R-normalization and
|
||||
/// BEFORE the closed/open gate. Reads its extra inputs from `ctx` at
|
||||
/// `GEOMETRY_WIDTH + i`; an empty window during warm-up means 0 (the runner
|
||||
@@ -96,12 +110,13 @@ pub trait CostNode: 'static {
|
||||
pub struct CostRunner<F: CostNode> {
|
||||
factor: F,
|
||||
cum: f64,
|
||||
acc: f64,
|
||||
out: [Cell; COST_WIDTH],
|
||||
}
|
||||
|
||||
impl<F: CostNode> CostRunner<F> {
|
||||
pub fn new(factor: F) -> Self {
|
||||
Self { factor, cum: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
||||
Self { factor, cum: 0.0, acc: 0.0, out: [Cell::from_f64(0.0); COST_WIDTH] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +144,30 @@ impl<F: CostNode> Node for CostRunner<F> {
|
||||
// `numerator / latched` token form is preserved verbatim from the
|
||||
// pre-migration nodes for byte-identity (IEEE-754).
|
||||
let per = if latched > 0.0 { numerator / latched } else { 0.0 };
|
||||
let cost_in_r = if closed { per } else { 0.0 };
|
||||
let open_cost_in_r = if open { per } else { 0.0 };
|
||||
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)
|
||||
}
|
||||
};
|
||||
self.cum += cost_in_r;
|
||||
self.out = [
|
||||
Cell::from_f64(cost_in_r),
|
||||
@@ -192,6 +229,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
fn geom_cols() -> Vec<AnyColumn> {
|
||||
vec![
|
||||
AnyColumn::with_capacity(ScalarKind::Bool, 1), // closed
|
||||
@@ -270,6 +321,80 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_input_cold_contributes_zero_but_row_emits() {
|
||||
// Co-temporality: a not-yet-warm factor input -> 0 cost, but a row IS emitted.
|
||||
|
||||
Reference in New Issue
Block a user