feat: wire FDR into the gauntlet/discovery — multiple-testing complement over the whole search

FINDING (third this session): fxhnt's existing gauntlet DSR already uses the CANONICAL Bailey-LdP SE
((kurt-1)/4), while the Rust ml-validation SE uses (excess_kurt/4) — slightly non-canonical. So we do NOT
swap the DSR (the gauntlet's is already correct). The genuine add from the ported suite is PBO + FDR.
Wired FDR in: Verdict gains a pvalue (1−DSR, non-breaking default); DiscoveryService now FDR-corrects
(Benjamini-Hochberg at alpha=1−dsr_min) over ALL candidate p-values, and a candidate survives only if it
passes its own gauntlet AND survives FDR across the search. DiscoveryReport reports n_passed_gauntlet vs
n_survivors (post-FDR). PBO kept available for fold-based CV (future wire). 48/48 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-06-13 09:35:18 +02:00
parent b4275fba30
commit b7ed0d0b1f
5 changed files with 27 additions and 8 deletions

View File

@@ -23,6 +23,7 @@ from fxhnt.domain.backtest import run_backtest
from fxhnt.domain.gauntlet import effective_trials, evaluate, per_period_sharpe
from fxhnt.domain.models import Market, ResearchRun, StrategySpec, run_id
from fxhnt.domain.strategies import get_strategy
from fxhnt.domain.validation import fdr_correct
from fxhnt.ports.data import DataProvider
from fxhnt.ports.repository import AnalyticalStore, OperationalRepository
@@ -55,7 +56,8 @@ class DiscoveryReport(BaseModel):
n_candidates: int
n_effective_trials: float # correlation-corrected independent trial count (the deflation uses this)
sr_variance: float
n_survivors: int
n_passed_gauntlet: int = 0 # passed the per-candidate gauntlet (before across-search FDR)
n_survivors: int = 0 # ALSO survived FDR over the whole search — the final set
survivors: list[ResearchRun] = Field(default_factory=list)
@@ -100,7 +102,7 @@ class DiscoveryService:
# phase 2 — judge each candidate against the effective-search bar
g = self._s.gauntlet
now = dt.datetime.now(dt.timezone.utc)
survivors: list[ResearchRun] = []
judged: list[tuple[ResearchRun, object, np.ndarray]] = []
for market, spec, prices, bt, r, split in rows:
strategy = get_strategy(spec.kind)
verdict = evaluate(
@@ -110,12 +112,24 @@ class DiscoveryService:
)
run = ResearchRun(run_id=run_id(market, spec), market=market, spec=spec, stats=bt.stats,
verdict=verdict, n_trials=n_trials_eff, created_at=now)
judged.append((run, prices, r))
# phase 3 — FDR over the WHOLE search: Benjamini-Hochberg on every candidate's p-value at
# alpha = 1 dsr_min. A candidate survives only if it passes its own gauntlet AND survives FDR —
# the multiple-testing complement to the per-candidate deflated Sharpe.
pvalues = [run.verdict.pvalue for run, _, _ in judged]
fdr = fdr_correct(pvalues, method="bh", alpha=1.0 - g.dsr_min) if len(pvalues) > 1 else None
n_passed = sum(run.verdict.passed for run, _, _ in judged)
survivors: list[ResearchRun] = []
for i, (run, prices, r) in enumerate(judged):
fdr_ok = fdr.rejected[i] if fdr is not None else True
is_survivor = run.verdict.passed and fdr_ok
if persist:
self._op.save_run(run) # the verdict (cheap, SQLite) is kept for every candidate — audit
if verdict.passed:
if is_survivor:
self._an.save_returns(run.run_id, prices.dates, r) # only survivors keep their timeseries
if verdict.passed:
if is_survivor:
survivors.append(run)
return DiscoveryReport(n_candidates=len(candidates), n_effective_trials=n_eff,
sr_variance=sr_variance, n_survivors=len(survivors), survivors=survivors)
return DiscoveryReport(n_candidates=len(candidates), n_effective_trials=n_eff, sr_variance=sr_variance,
n_passed_gauntlet=n_passed, n_survivors=len(survivors), survivors=survivors)

View File

@@ -234,7 +234,7 @@ def discover(
report = svc.search(GridGenerator(markets, grids))
typer.echo(f"searched {report.n_candidates} candidates (effective independent trials "
f"{report.n_effective_trials:.1f}, sr_var={report.sr_variance:.5f})")
typer.echo(f"survivors (deflated over the effective search): {report.n_survivors}")
typer.echo(f"passed the gauntlet: {report.n_passed_gauntlet}survivors after FDR over the search: {report.n_survivors}")
for run in sorted(report.survivors, key=lambda r: -r.verdict.dsr):
typer.echo(f" [PASS] {run.market} {run.spec.key():28} | Sharpe {run.stats.sharpe:+.2f} DSR {run.verdict.dsr:.3f} OOS {run.verdict.oos_sharpe:+.2f}")

View File

@@ -128,4 +128,6 @@ def evaluate(
reasons.append("no structural rationale — refusing a purely statistical fit")
elif has_economic_rationale is None:
reasons.append("⚠ economic rationale UNVERIFIED — justify before deploying")
return Verdict(passed=ok, dsr=dsr, is_sharpe=is_sr, oos_sharpe=oos_sr, n_trials=n_trials, reasons=reasons)
pvalue = max(0.0, min(1.0, 1.0 - dsr)) # for across-search FDR correction
return Verdict(passed=ok, dsr=dsr, is_sharpe=is_sr, oos_sharpe=oos_sr, n_trials=n_trials,
reasons=reasons, pvalue=pvalue)

View File

@@ -95,6 +95,7 @@ class Verdict(BaseModel):
oos_sharpe: float
n_trials: int
reasons: list[str] = Field(default_factory=list)
pvalue: float = 1.0 # one-sided p-value (1 DSR) — lets a search FDR-correct across candidates
def summary(self) -> str:
flag = "PASS" if self.passed else "REJECT"

View File

@@ -40,7 +40,9 @@ def test_search_counts_full_matrix_as_trials(tmp_path) -> None:
# every run was deflated by the same effective trial count
assert len({r.n_trials for r in runs}) == 1
assert 0 <= report.n_survivors <= 6
assert report.n_survivors <= report.n_passed_gauntlet # FDR over the search can only tighten
assert all(r.verdict.passed for r in report.survivors)
assert all(0.0 <= r.verdict.pvalue <= 1.0 for r in svc._op.list_runs()) # noqa: SLF001
def test_effective_trials_below_raw_for_correlated() -> None: