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