Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/research-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Research gates

`quant_lab.research_gates` separates native engineering evidence (A), an immutable
future research objective (B), and promotion (C). An objective's point estimate
never grants any of these gates. C always requires external authorization and the
existing model-promotion and real-quote cost requirements.

Recipes may include `research_plan` with schema `quant.research-gate-plan/v1`:
`sample_kind` (`retrospective` for exploratory recipes, or `prospective`), the exact
`sessions`, `review_date`, historical paired `calibration` rows, `confidence`,
`power`, `planning_effect`, `block_lengths`, `repetitions`, and `seed`. Each paired
row has `date`, `strategy`, and `benchmark` net simple returns. The original trial
registry timestamp controls future eligibility; reopening a registry cannot
backdate registration. Future sessions must be inside the recipe's holdout.

Supported primary metrics are mean daily excess return, volatility ratio,
zero-target downside deviation ratio, and worst-5%-tail mean-loss ratio. The
benchmark definition and cost assumptions belong to the frozen objective/recipe.
The declared planning alternative must improve beyond the objective boundary.
Required observations use paired circular-block bootstrap standard errors and
square-root-n extrapolation for that margin, confidence and power. This is a
stationarity-dependent planning approximation, not guaranteed power. Degenerate
calibration is rejected. All declared block lengths must support the conclusion.

`evaluate_gates` requires a validated evidence label, every frozen session, the
scheduled review date, sufficient detectability, matching recomputed point
estimates, confidence intervals beyond the objective boundary, and the existing
objective's measured budgets/data/stop conditions. Missing, provisional or unknown
evidence produces `insufficient_evidence`. Retrospective observations produce
`retrospective_only`. No universal observation count or repeated-trial threshold
is introduced. A development-window result from a prospective recipe still cannot
pass B: the actual evidence dates must match its future holdout sessions.

The equity adapter supplies pairs only for a measured matching benchmark identity;
it does not manufacture capacity, turnover or stop-condition evidence. Callers
must measure those conditions before a complete objective can pass. Findings keep
the gate result alongside the independently verified result artifact.
1 change: 1 addition & 0 deletions src/quant_lab/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def research_findings(path: Path) -> dict:
"metrics": result.get("metrics"),
"comparison": result.get("comparison"),
"objective_evaluation": result.get("objective_evaluation"),
"research_gates": result.get("research_gates"),
}
except (KeyError, ValueError, OSError, TypeError) as exc:
row["evidence"] = {"verified": False, "reason": str(exc)}
Expand Down
16 changes: 14 additions & 2 deletions src/quant_lab/objectives.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"drawdown_magnitude",
"net_cashflow_yield",
"capital_efficiency",
"mean_daily_excess_return",
"volatility_ratio",
"downside_ratio",
"cvar_ratio",
}


Expand Down Expand Up @@ -105,7 +109,14 @@ def check(category, metric, threshold, direction, actual):
primary = spec["primary"]
check(
"risk"
if primary["metric"] in {"tracking_error", "drawdown_magnitude"}
if primary["metric"]
in {
"tracking_error",
"drawdown_magnitude",
"volatility_ratio",
"downside_ratio",
"cvar_ratio",
}
else "execution"
if primary["metric"] == "capital_efficiency"
else "return",
Expand Down Expand Up @@ -157,5 +168,6 @@ def check(category, metric, threshold, direction, actual):
return {
"status": status,
"checks": checks,
"scope": "research acceptance only; does not authorize investment or relaxing risk controls",
"evidence_kind": "point_estimate_only",
"scope": "point estimate review only; does not pass engineering, future research or promotion gates",
}
34 changes: 33 additions & 1 deletion src/quant_lab/research.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"risk_model",
"family_id",
"objective",
"research_plan",
"measurement_basis",
}
STRATEGY_FIELDS = {"family", "frequency", "top_n", "max_weight", "cash_buffer", "trend_window"}
Expand Down Expand Up @@ -102,6 +103,15 @@ def validate_recipe(value: dict) -> dict:
from quant_lab.objectives import validate_objective

validate_objective(recipe["objective"])
if "research_plan" in recipe:
from quant_lab.research_gates import validate_plan

if "objective" not in recipe:
raise ValueError("research plan requires an objective")
validate_plan(recipe["research_plan"], recipe["objective"])
expected = "retrospective" if recipe.get("mode") == "exploratory" else "prospective"
if recipe["research_plan"]["sample_kind"] != expected:
raise ValueError("research plan sample use must match recipe mode")
if "measurement_basis" in recipe:
import pandas as pd

Expand Down Expand Up @@ -279,6 +289,11 @@ def validate_recipe(value: dict) -> dict:
raise ValueError("Holdout must follow development and have positive duration")
elif recipe.get("holdout"):
raise ValueError("Exploratory studies cannot claim an untouched holdout")
if "research_plan" in recipe:
window = recipe["holdout"] if recipe["mode"] == "prospective" else interval
sessions = recipe["research_plan"]["sessions"]
if sessions[0] < window["start"] or sessions[-1] > window["end"]:
raise ValueError("research sessions must lie inside the declared evaluation window")
histories = recipe.get("required_history", {})
if not isinstance(histories, dict) or any(
v not in {"universe", "status", "fundamentals", "classification"}
Expand Down Expand Up @@ -438,7 +453,7 @@ def execute_study(
"recipe": recipe,
"data_identity": data_identity,
}
for field in ("family_id", "objective"):
for field in ("family_id", "objective", "research_plan"):
if field in recipe:
definition[field] = deepcopy(recipe[field])
if recipe["mode"] == "prospective":
Expand Down Expand Up @@ -505,6 +520,15 @@ def execute_study(
"reason": "executor supplied no investment evidence",
}
)
from quant_lab.research_gates import evaluate_gates

payload["research_gates"] = evaluate_gates(
definition["objective"],
payload["objective_evaluation"],
plan=definition.get("research_plan"),
registered_at=registry.definition(study)["registered_at"],
evidence=objective_evidence,
)
payload.update(
schema_version="quant.research-result/v1",
candidate=candidate,
Expand Down Expand Up @@ -541,6 +565,14 @@ def execute_study(
"error_type": type(exc).__name__,
"error": str(exc),
}
diagnostics = getattr(exc, "diagnostics", None)
if isinstance(diagnostics, dict):
failure["diagnostics"] = diagnostics
if "failure_assessment" in diagnostics:
failure["failure_assessment"] = diagnostics["failure_assessment"]
assessment = getattr(exc, "failure_assessment", None)
if isinstance(assessment, dict):
failure["failure_assessment"] = assessment
(out / "failure.json").write_text(canonical(failure), encoding="utf-8")
registry.finish(attempt, "failed", failure)
results.append(failure)
Expand Down
Loading
Loading