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:
@@ -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<f64>, // --cost-per-trade
|
||||
slip_vol_mult: Option<f64>, // --slip-vol-mult
|
||||
carry_per_cycle: Option<f64>, // --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<f64>,
|
||||
slip_vol_mult: Option<f64>,
|
||||
carry_per_cycle: Option<f64>,
|
||||
) -> 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<String>,
|
||||
cost: Option<f64>,
|
||||
slip_vol_mult: Option<f64>,
|
||||
carry_per_cycle: Option<f64>,
|
||||
}
|
||||
|
||||
/// Parse the `run` tail: `[--harness <name>] [--real <SYM> [--from <ms>] [--to <ms>]]
|
||||
@@ -3129,7 +3135,7 @@ struct RunArgs {
|
||||
/// grammar is unit-testable; `main` does the side effects.
|
||||
fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
let usage = || {
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>]"
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>] [--carry-per-cycle <f64>]"
|
||||
.to_string()
|
||||
};
|
||||
let mut harness: Option<HarnessKind> = None;
|
||||
@@ -3139,6 +3145,7 @@ fn parse_run_args(rest: &[&str]) -> Result<RunArgs, String> {
|
||||
let mut trace: Option<String> = None;
|
||||
let mut cost: Option<f64> = None;
|
||||
let mut slip_vol_mult: Option<f64> = None;
|
||||
let mut carry_per_cycle: Option<f64> = 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<RunArgs, String> {
|
||||
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<RunArgs, String> {
|
||||
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<RunReport, String> {
|
||||
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<RunReport, String> {
|
||||
}
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura mc --strategy stage1-r [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--block-len <n>] [--resamples <n>] [--seed <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>] | aura generalize [--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n> --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] [--cost-per-trade <f64>] [--slip-vol-mult <f64>] [--carry-per-cycle <f64>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura mc --strategy stage1-r [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--block-len <n>] [--resamples <n>] [--seed <n>] | aura walkforward [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--select <argmax|plateau:mean|plateau:worst>] | aura generalize [--strategy stage1-r] --real <SYM1,SYM2,...> --fast <n> --slow <n> --stop-length <n> --stop-k <f> [--from <ms>] [--to <ms>] [--metric <r-metric>] [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<f64> {
|
||||
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::<f64>().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
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
+126
-1
@@ -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, 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user