From f5e00a9c725491a8b11f0332361cff387631efdf Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 29 Jun 2026 01:00:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(0085):=20per-cycle-held=20accrual=20?= =?UTF-8?q?=E2=80=94=20CarryCost=20+=20ChargeMode=20(approach=20B)?= 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". 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 --- crates/aura-cli/src/main.rs | 32 ++++-- crates/aura-cli/tests/cli_run.rs | 161 ++++++++++++++++++++++++++++++ crates/aura-std/src/carry_cost.rs | 121 ++++++++++++++++++++++ crates/aura-std/src/cost.rs | 131 +++++++++++++++++++++++- crates/aura-std/src/lib.rs | 7 +- 5 files changed, 440 insertions(+), 12 deletions(-) create mode 100644 crates/aura-std/src/carry_cost.rs diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 0666fd1..24cfb36 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -29,7 +29,7 @@ use aura_registry::{ FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, TraceStore, WriteKind, }; use aura_std::{ - Add, Bias, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, + Add, Bias, CarryCost, ConstantCost, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, Recorder, RollingMax, RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, VolSlippageCost, PM_FIELD_NAMES, PM_RECORD_KINDS, }; @@ -2576,6 +2576,7 @@ const SLIP_VOL_LENGTH: i64 = 5; struct CostConfig { const_cost: Option, // --cost-per-trade slip_vol_mult: Option, // --slip-vol-mult + carry_per_cycle: Option, // --carry-per-cycle } /// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1 @@ -2741,6 +2742,9 @@ fn stage1_r_graph( vol_slot = Some(cost_nodes.len()); cost_nodes.push(VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(svm))); } + 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 // cost nodes and sums their per-field charges into the run's cost streams. let cg = g.add(cost_graph(cost_nodes)); @@ -3007,6 +3011,7 @@ fn run_stage1_r( trace: Option<&str>, const_cost: Option, slip_vol_mult: Option, + carry_per_cycle: Option, ) -> RunReport { if let Some(n) = trace && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) @@ -3020,8 +3025,8 @@ fn run_stage1_r( let (tx_req, rx_req) = mpsc::channel(); let (tx_net, rx_net) = mpsc::channel(); let (tx_cost, rx_cost) = mpsc::channel(); - let cost_bundle = if const_cost.is_some() || slip_vol_mult.is_some() { - Some((CostConfig { const_cost, slip_vol_mult }, tx_net, tx_cost)) + 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)) } else { None }; @@ -3120,6 +3125,7 @@ struct RunArgs { trace: Option, cost: Option, slip_vol_mult: Option, + carry_per_cycle: Option, } /// Parse the `run` tail: `[--harness ] [--real [--from ] [--to ]] @@ -3129,7 +3135,7 @@ struct RunArgs { /// grammar is unit-testable; `main` does the side effects. fn parse_run_args(rest: &[&str]) -> Result { let usage = || { - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ]" + "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ]" .to_string() }; let mut harness: Option = None; @@ -3139,6 +3145,7 @@ fn parse_run_args(rest: &[&str]) -> Result { let mut trace: Option = None; let mut cost: Option = None; let mut slip_vol_mult: Option = None; + let mut carry_per_cycle: Option = None; let mut tail = rest; while let Some((flag, t)) = tail.split_first() { match *flag { @@ -3193,6 +3200,15 @@ fn parse_run_args(rest: &[&str]) -> Result { slip_vol_mult = Some(v); tail = t; } + "--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; + } _ => return Err(usage()), } } @@ -3205,7 +3221,7 @@ fn parse_run_args(rest: &[&str]) -> Result { Some(s) => RunData::Real { symbol: s, from, to }, None => RunData::Synthetic, }; - Ok(RunArgs { harness, data, trace, cost, slip_vol_mult }) + Ok(RunArgs { harness, data, trace, cost, slip_vol_mult, carry_per_cycle }) } /// Route a parsed `run` invocation to its harness handler. The compile-time `match` IS @@ -3215,7 +3231,7 @@ fn run_dispatch(args: RunArgs) -> Result { Ok(match (args.harness, args.data) { (HarnessKind::Sma, RunData::Synthetic) => run_sample(trace), (HarnessKind::Macd, RunData::Synthetic) => run_macd(trace), - (HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost, args.slip_vol_mult), + (HarnessKind::Stage1R, data) => run_stage1_r(data, trace, args.cost, args.slip_vol_mult, args.carry_per_cycle), (HarnessKind::Sma, RunData::Real { symbol, from, to }) => { run_sample_real(&symbol, from, to, trace) } @@ -3226,7 +3242,7 @@ fn run_dispatch(args: RunArgs) -> Result { } const USAGE: &str = - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura mc --strategy stage1-r [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ] | aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ] | aura generalize [--strategy stage1-r] --real --fast --slow --stop-length --stop-k [--from ] [--to ] [--metric ] [--name ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura mc --strategy stage1-r [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ] | aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ] | aura generalize [--strategy stage1-r] --real --fast --slow --stop-length --stop-k [--from ] [--to ] [--metric ] [--name ] | aura runs families | aura runs family [rank ]"; fn main() { // Restore the default SIGPIPE disposition. Rust's runtime sets SIGPIPE to SIG_IGN @@ -4443,7 +4459,7 @@ mod tests { fn run_stage1_r_synthetic_folds_an_r_block() { // the stage1-r harness scores the SMA-cross signal in R: one shell-callable run // yields a RunReport whose metrics.r is Some with a finite SQN and >= 1 trade. - let report = run_stage1_r(RunData::Synthetic, None, None, None); + let report = run_stage1_r(RunData::Synthetic, None, None, None, None); let r = report.metrics.r.as_ref().expect("stage1-r run must populate metrics.r"); assert!(r.n_trades >= 1, "expected >= 1 trade, got {}", r.n_trades); assert!(r.sqn.is_finite(), "SQN must be finite, got {}", r.sqn); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 2171f12..a03cb93 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1728,6 +1728,167 @@ fn stage1_r_composed_cost_net_expectancy_r_golden() { ); } +/// 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, -768.2095026600887, + "carry net_expectancy_r drifted from the golden: {s}" + ); +} + +/// 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, -1383.7939051991182, + "composed cost+carry net_expectancy_r drifted from the golden: {s}" + ); +} + +/// 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); +} + +/// Property (cycle 0085, cost-flag validation symmetry): a NEGATIVE `--carry-per-cycle` +/// is refused at the binary boundary — exit 2, usage on stderr, nothing on stdout, no +/// `runs/` write — the same shape the sibling `--cost-per-trade`/`--slip-vol-mult` flags +/// enforce. The `v < 0.0` parse guard is the load-bearing line: without it a negative +/// carry would reach `CarryCost::new` and PANIC (assert), crashing the binary instead of +/// a clean usage refusal, and a negative price-unit carry would CREDIT R — inverting the +/// sign of the whole accrual mechanism. None of the cycle-0085 goldens/bleed tests +/// exercise the rejection branch. Deterministic; the refusal precedes any data access. +#[test] +fn stage1_r_negative_carry_per_cycle_refused_with_usage_exit_2() { + let dir = temp_cwd("stage1-r-carry-neg"); + let out = Command::new(BIN) + .current_dir(&dir) + .args(["run", "--harness", "stage1-r", "--carry-per-cycle", "-1"]) + .output() + .expect("spawn aura run --carry-per-cycle -1"); + assert_eq!(out.status.code(), Some(2), + "negative carry must exit 2, not panic; status: {:?}; stderr: {}", + out.status, String::from_utf8_lossy(&out.stderr)); + assert!(out.stdout.is_empty(), "a refused run must not emit a report on stdout: {:?}", out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("usage"), "negative carry stderr must carry usage: {stderr:?}"); + assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Property (cycle 0085, carry is a monotone debit in the rate): a larger +/// `--carry-per-cycle` makes the run's `net_expectancy_r` STRICTLY more negative. The +/// carry is purely downstream — bias/stop/signal (hence trade count and gross R) are +/// identical across rates — so doubling the per-held-cycle carry can only deepen the +/// charge. The point-goldens pin two exact values but not the DIRECTION: a sign flip that +/// made carry a CREDIT, or a rate that were ignored/clamped, would re-baseline to some +/// other pair of numbers and slip past a golden; this relation survives any future +/// fixture re-baseline. Deterministic over the fixed synthetic stream (C1). +#[test] +fn stage1_r_larger_carry_per_cycle_lowers_net_expectancy_r() { + let net = |rate: &str| -> f64 { + let out = Command::new(BIN) + .args(["run", "--harness", "stage1-r", "--carry-per-cycle", rate]) + .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(); + v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap() + }; + let lo = net("0.5"); + let hi = net("1.0"); + assert!(hi < lo, "a larger carry must charge more (lower net): carry-1.0 {hi} < carry-0.5 {lo}"); +} + +/// Property (cycle 0085, the zero-rate boundary is exactly free): `--carry-per-cycle 0` +/// activates the cost path (a CarryCost node IS wired) yet charges NOTHING — the run's +/// `net_expectancy_r` is bit-identical to the gross `expectancy_r`. Guards against an +/// accrual branch that leaks a spurious charge even at rate 0 (e.g. an off-by-one in the +/// open-side mark): such a bug would shift net away from gross while the positive-rate +/// goldens stayed green. Pins the zero ACCRUAL is zero COST invariant relationally, so it +/// survives any future re-baseline of the gross value. Deterministic (C1). +#[test] +fn stage1_r_zero_carry_per_cycle_charges_nothing() { + let out = Command::new(BIN) + .args(["run", "--harness", "stage1-r", "--carry-per-cycle", "0"]) + .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 r = &v["metrics"]["r"]; + let net = r["net_expectancy_r"].as_f64().unwrap(); + let gross = r["expectancy_r"].as_f64().unwrap(); + assert_eq!(net, gross, "zero carry must be exactly free: net {net} == gross {gross}; {s}"); +} + /// Property (the spec's dual-yardstick headline): `aura run --harness stage1-r` scores /// ONE signal in BOTH yardsticks at once — the single emitted `RunReport` carries the pip /// `metrics.total_pips` (off the SimBroker branch) AND the nested `r` block (off the diff --git a/crates/aura-std/src/carry_cost.rs b/crates/aura-std/src/carry_cost.rs new file mode 100644 index 0000000..47bd237 --- /dev/null +++ b/crates/aura-std/src/carry_cost.rs @@ -0,0 +1,121 @@ +//! `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 { + 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 { + 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); + } +} diff --git a/crates/aura-std/src/cost.rs b/crates/aura-std/src/cost.rs index b859737..93d6ccb 100644 --- a/crates/aura-std/src/cost.rs +++ b/crates/aura-std/src/cost.rs @@ -43,6 +43,15 @@ fn cost_output_fields() -> Vec { .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 { 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 { factor: F, cum: f64, + acc: f64, out: [Cell; COST_WIDTH], } impl CostRunner { 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 Node for CostRunner { // `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 { 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. diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 44ef9da..ece6038 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -20,6 +20,7 @@ mod add; mod and; mod bias; +mod carry_cost; mod constant_cost; mod cost; mod cost_sum; @@ -49,8 +50,12 @@ mod vol_slippage_cost; pub use add::Add; pub use and::And; pub use bias::Bias; +pub use carry_cost::CarryCost; pub use constant_cost::ConstantCost; -pub use cost::{cost_node_builder, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, GEOMETRY_WIDTH}; +pub use cost::{ + cost_node_builder, ChargeMode, CostNode, CostRunner, COST_FIELD_NAMES, COST_WIDTH, + GEOMETRY_WIDTH, +}; pub use cost_sum::CostSum; pub use delay::Delay; pub use ema::Ema;