diff --git a/crates/ml/src/trainers/dqn/financials.rs b/crates/ml/src/trainers/dqn/financials.rs index 2e3c6f1d3..0b75687b4 100644 --- a/crates/ml/src/trainers/dqn/financials.rs +++ b/crates/ml/src/trainers/dqn/financials.rs @@ -80,13 +80,15 @@ pub(crate) fn compute_epoch_financials( // bars): annualized = exp(45.93 × 19_656 / 4_096_000) − 1 ≈ 24.7% — // a reasonable, interpretable number. // - // History (do not regress to either earlier form): + // History (do not regress to any earlier form): // v1 (pre-2026-04): mean_per_bar × bars_per_year (arithmetic // annualization) — produced -51,234% on negative per-bar means. // v2 (2026-04..2026-05-11): log_growth.exp() − 1 (total compounded, // no annualization) — produced +e19% on long rollouts. - // v3 (this commit, 2026-05-11): annualized compounded — bounded by - // construction and operationally meaningful. + // v3 (2026-05-11): annualized compounded for n_returns >= bars_per_year, + // fallback to v2 below it — but the fallback subsumed all production + // runs on volume bars (bars_per_year ≈ 8.7M > n_returns ≈ 4.1M). + // v3.1 (2026-05-11): unconditional CAGR — the clamp handles edge cases. // // Lower bound: -100% (log_growth.exp() − 1 ≥ -1 when log_growth ≥ // log(1e-10) ≈ -23 per the per-bar clamp). Upper bound: practically @@ -112,20 +114,25 @@ pub(crate) fn compute_epoch_financials( // annualization factor exceeds 1, which would extrapolate a // brief positive run to absurd annualized magnitudes (a 5-bar // test with 1.5% gain would annualize to e8% on 1-min bars). - // For sub-year rollouts, report TOTAL compounded growth - // instead — same `log_growth.exp() − 1` as the v2 formula, - // bounded by the per-bar floor to ±100% per the inflation - // audit's WR=46% honest meter discipline. + // **v3.1 (2026-05-11)**: dropped the `n_returns >= bars_per_year` + // short-rollout guard. The guard fell back to raw `log_growth` + // (= v2 formula) whenever the rollout was shorter than a trading + // year. For Foxhunt's volume bars the data extracts + // `bars_per_day ≈ 34_496` → `bars_per_year ≈ 8.7M`, but a single + // training epoch produces `n_returns ≈ 4.1M` step returns. Result: + // every production epoch took the v2 branch and the v3 fix was a + // no-op — smoke v4 epoch 1 (commit 62b5a50e8) reproduced v1's + // `Return=+2.963e2%` exactly. + // + // Unconditional CAGR is the correct invariant: "what annualized + // return is this policy producing?" works for any rollout length. + // Tests (very small n_returns) get extrapolated (a 5-bar +1.5% + // test extrapolates to e8% nominally) — the log-space clamp + // `[-23, +20]` bounds the display magnitude, and the test cases + // below assert ordering / sign, not exact magnitudes. let n_returns_f = n_returns as f64; if log_growth.is_finite() && n_returns_f > 0.0 && bars_per_year > 0.0 { - let log_scaled = if n_returns_f >= bars_per_year { - // Production rollout (typically ~4M bars vs ~20K bars/year) - // — annualize for CAGR semantics. - log_growth * bars_per_year / n_returns_f - } else { - // Short rollout (tests, warmup) — report total compounded. - log_growth - }; + let log_scaled = log_growth * bars_per_year / n_returns_f; // Sanity bounds: lower at -23 (≈ -100% by construction // from per-bar `(1+r).max(1e-10)` floor), upper at +20 // (≈ exp(20) − 1 ≈ +4.85e8% — beyond this magnitude the diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index cf37e28b0..e3b5a14f5 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -15349,3 +15349,84 @@ validate the full SP21 cascade end-to-end. Watch: (Phase 8 ISV-driven gains) - evaluate_baseline reproduces val_Sharpe / val_PF within 5% tolerance on the same checkpoint+window. + +## 2026-05-11 — Return v3.1: drop short-rollout guard, unconditional CAGR (atomic) + +### Scope (atomic single commit) + +One-file fix to `compute_epoch_financials`: drop the +`if n_returns_f >= bars_per_year` short-rollout fallback. v3 +left the v2 formula in place for sub-year rollouts, but for +Foxhunt's **volume bars** the data extracts +`bars_per_day ≈ 34_496` → `bars_per_year ≈ 8.7M`, while a +single training epoch produces `n_returns ≈ 4.1M`. Result: the +v3 guard fired for *every* production epoch, so the v3 fix was +a no-op. Smoke v4 (commit 62b5a50e8, workflow train-frv8x) +epoch 1 reproduced v1's `Return=+2.963e2%` bit-identically. + +### Diagnosis + +- v1 epoch 1 stats: Sharpe=70.40, log_growth ≈ ln(3.963) ≈ 1.377 +- v3 expectation (assuming 5-min bars, bars_per_year=98_280): + `exp(1.377 × 98280/4.1M) - 1 ≈ +3.36% (= +3.359e0%)` +- v4 actual: `Return=+2.963e2%` (same as v1) +- Volume bar diagnostic in log: + `Bars per day (from data): 34496 (17835714 total bars)` → + `bars_per_year = 34_496 × 252 = 8.69M > n_returns = 4.1M` → + guard fires → v2 fallback path + +### Fix + +Replace: +```rust +let log_scaled = if n_returns_f >= bars_per_year { + log_growth * bars_per_year / n_returns_f +} else { + log_growth +}; +``` +with unconditional CAGR: +```rust +let log_scaled = log_growth * bars_per_year / n_returns_f; +``` + +The log-space clamp `[-23, +20]` still bounds the display. +Tests (very small `n_returns`) extrapolate aggressively (a 5-bar +test produces clamp-bounded ~e8% display) but all test +assertions are sign-only (`> 0.0`), so no test regresses. + +### Expected v5 epoch 1 Return + +- log_growth ≈ 1.377 (same training-side stats as v1) +- log_scaled = 1.377 × 8.69M / 4.1M = 2.92 +- exp(2.92) - 1 ≈ 17.7 → `Return=+1.770e3%` (≈ 1770% annualized) + +Higher baseline than 296% because the metric now actually +extrapolates a 0.47-year rollout to a per-year CAGR. The clamp +caps overfit overflow at ~`+4.852e8%` (was `+8.73e19%` under v2). + +### Files changed + +- `crates/ml/src/trainers/dqn/financials.rs`: drop guard, update + comment block (v3 → v3.1 history line) +- `docs/dqn-wire-up-audit.md`: this entry + +### Pearls + invariants honoured + +- **feedback_no_quickfixes**: minimal scope, restores v3's original + CAGR intent without changing other semantics +- **feedback_no_legacy_aliases**: removes the now-misnamed + "short-rollout guard" rather than renaming or wrapping it +- **pearl_audit_first_caught_5_of_7_hypotheses_were_wrong**: + hypothesis-driven diagnosis (cache poisoning suspected → + ruled out by `git show` + workflow commit-sha check → + actual root cause: volume-bar `bars_per_year` mismatch) + +### Verification + +``` +cargo test -p ml --lib financials # 7/7 +``` + +No CUDA build needed for this fix (financials.rs is CPU-only). +Production validation deferred to v5 smoke dispatch.