Skip to content

Commit c167a40

Browse files
Pigbibicodex
andcommitted
feat: require explicit benchmarks for strict monitoring
Co-Authored-By: Codex <noreply@openai.com>
1 parent a508ce7 commit c167a40

7 files changed

Lines changed: 359 additions & 10 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Lifecycle monitoring benchmark catalog
2+
3+
Promotion-grade monitoring must name the passive or unleveraged instrument used
4+
to judge each strategy. A generic US-equity default such as SPY is not an
5+
acceptable substitute for a leveraged-sector strategy.
6+
7+
Use a JSON file with this shape:
8+
9+
```json
10+
{
11+
"schema_version": "qsl.strategy-benchmark-catalog.v1",
12+
"authority": {"monitoring_only": true, "no_order": true},
13+
"bindings": [
14+
{
15+
"strategy_profile": "soxl_soxx_trend_income",
16+
"benchmark_symbol": "buy_hold_SOXX",
17+
"benchmark_kind": "unleveraged_underlying",
18+
"relative_drawdown_required": true
19+
}
20+
]
21+
}
22+
```
23+
24+
Run strict monitoring with:
25+
26+
```text
27+
quant-lifecycle monitor --domain us_equity --benchmark-catalog catalog.json --require-explicit-benchmark
28+
```
29+
30+
Strict mode refuses to publish a snapshot when either the strategy binding or
31+
its benchmark return series is absent. The catalog is monitoring-only and
32+
never grants strategy, broker, or promotion authority.

src/quant_platform_kit/strategy_lifecycle/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414
UpdateStage,
1515
WindowPerformance,
1616
)
17+
from quant_platform_kit.strategy_lifecycle.benchmark_catalog import (
18+
STRATEGY_BENCHMARK_CATALOG_SCHEMA,
19+
StrategyBenchmarkBinding,
20+
StrategyBenchmarkCatalogError,
21+
build_strategy_benchmark_catalog,
22+
load_strategy_benchmark_catalog,
23+
)
1724
from quant_platform_kit.strategy_spec import (
1825
OPTIMIZATION_SPEC_SCHEMA_VERSION,
1926
RESEARCH_SPEC_SCHEMA_VERSION,
@@ -80,6 +87,9 @@
8087

8188
__all__ = [
8289
"BacktestResult",
90+
"STRATEGY_BENCHMARK_CATALOG_SCHEMA",
91+
"StrategyBenchmarkBinding",
92+
"StrategyBenchmarkCatalogError",
8393
"DriftDimension",
8494
"DriftResult",
8595
"DriftStatus",
@@ -110,9 +120,11 @@
110120
"RESEARCH_DRIVER_SCHEMA_VERSION",
111121
"RESEARCH_DRIVER_TERMINAL_STATUSES",
112122
"load_evidence_package",
123+
"load_strategy_benchmark_catalog",
113124
"canonical_evidence_package_v2_bytes",
114125
"read_evidence_package_v2_json",
115126
"build_live_candidate_notification",
127+
"build_strategy_benchmark_catalog",
116128
"assess_strategy_release_readiness",
117129
"build_forward_risk_terminal_artifact",
118130
"build_nonready_forward_risk_stage",
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Validated, read-only benchmark bindings for lifecycle monitoring.
2+
3+
The catalog deliberately maps a *strategy profile* to the passive or
4+
unleveraged instrument used to judge it. It supplies monitoring context only:
5+
it cannot change a strategy, rebalance an account, or grant execution rights.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from collections.abc import Mapping, Sequence
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
15+
16+
STRATEGY_BENCHMARK_CATALOG_SCHEMA = "qsl.strategy-benchmark-catalog.v1"
17+
_BENCHMARK_KINDS = frozenset({"passive", "unleveraged_underlying"})
18+
19+
20+
class StrategyBenchmarkCatalogError(ValueError):
21+
"""Raised when a benchmark catalog cannot be safely used for monitoring."""
22+
23+
24+
def _nonblank(value: object, label: str) -> str:
25+
if not isinstance(value, str) or not value or value != value.strip():
26+
raise StrategyBenchmarkCatalogError(f"{label} must be a non-empty canonical string")
27+
return value
28+
29+
30+
@dataclass(frozen=True)
31+
class StrategyBenchmarkBinding:
32+
"""One explicit, no-authority performance benchmark binding."""
33+
34+
strategy_profile: str
35+
benchmark_symbol: str
36+
benchmark_kind: str = "passive"
37+
relative_drawdown_required: bool = True
38+
39+
def __post_init__(self) -> None:
40+
_nonblank(self.strategy_profile, "strategy profile")
41+
_nonblank(self.benchmark_symbol, "benchmark symbol")
42+
if self.benchmark_kind not in _BENCHMARK_KINDS:
43+
raise StrategyBenchmarkCatalogError("benchmark kind is not supported")
44+
if type(self.relative_drawdown_required) is not bool:
45+
raise StrategyBenchmarkCatalogError("relative drawdown requirement must be boolean")
46+
47+
48+
def build_strategy_benchmark_catalog(
49+
bindings: Sequence[StrategyBenchmarkBinding],
50+
) -> dict[str, object]:
51+
"""Return a validated, JSON-ready catalog without execution authority."""
52+
entries = tuple(bindings)
53+
if not entries or any(type(entry) is not StrategyBenchmarkBinding for entry in entries):
54+
raise StrategyBenchmarkCatalogError("catalog must contain immutable benchmark bindings")
55+
profiles = [entry.strategy_profile for entry in entries]
56+
if len(set(profiles)) != len(profiles):
57+
raise StrategyBenchmarkCatalogError("strategy profiles must be unique")
58+
return {
59+
"schema_version": STRATEGY_BENCHMARK_CATALOG_SCHEMA,
60+
"authority": {"monitoring_only": True, "no_order": True},
61+
"bindings": [
62+
{
63+
"strategy_profile": entry.strategy_profile,
64+
"benchmark_symbol": entry.benchmark_symbol,
65+
"benchmark_kind": entry.benchmark_kind,
66+
"relative_drawdown_required": entry.relative_drawdown_required,
67+
}
68+
for entry in entries
69+
],
70+
}
71+
72+
73+
def load_strategy_benchmark_catalog(path: str | Path) -> dict[str, str]:
74+
"""Load explicit profile-to-benchmark bindings from a validated JSON catalog."""
75+
try:
76+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
77+
except (OSError, json.JSONDecodeError) as exc:
78+
raise StrategyBenchmarkCatalogError("benchmark catalog could not be read as JSON") from exc
79+
if not isinstance(payload, Mapping):
80+
raise StrategyBenchmarkCatalogError("benchmark catalog must be an object")
81+
if payload.get("schema_version") != STRATEGY_BENCHMARK_CATALOG_SCHEMA:
82+
raise StrategyBenchmarkCatalogError("benchmark catalog schema version is not supported")
83+
authority = payload.get("authority")
84+
if authority != {"monitoring_only": True, "no_order": True}:
85+
raise StrategyBenchmarkCatalogError("benchmark catalog must declare monitoring-only no-order authority")
86+
raw_bindings = payload.get("bindings")
87+
if not isinstance(raw_bindings, list):
88+
raise StrategyBenchmarkCatalogError("benchmark catalog bindings must be a list")
89+
bindings: list[StrategyBenchmarkBinding] = []
90+
for raw in raw_bindings:
91+
if not isinstance(raw, Mapping):
92+
raise StrategyBenchmarkCatalogError("benchmark catalog binding must be an object")
93+
bindings.append(
94+
StrategyBenchmarkBinding(
95+
strategy_profile=raw.get("strategy_profile"),
96+
benchmark_symbol=raw.get("benchmark_symbol"),
97+
benchmark_kind=raw.get("benchmark_kind", "passive"),
98+
relative_drawdown_required=raw.get("relative_drawdown_required", True),
99+
)
100+
)
101+
catalog = build_strategy_benchmark_catalog(bindings)
102+
return {
103+
str(item["strategy_profile"]): str(item["benchmark_symbol"])
104+
for item in catalog["bindings"]
105+
if isinstance(item, Mapping)
106+
}
107+
108+
109+
__all__ = [
110+
"STRATEGY_BENCHMARK_CATALOG_SCHEMA",
111+
"StrategyBenchmarkBinding",
112+
"StrategyBenchmarkCatalogError",
113+
"build_strategy_benchmark_catalog",
114+
"load_strategy_benchmark_catalog",
115+
]

src/quant_platform_kit/strategy_lifecycle/cli.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,21 @@ def _run_monitor(args: argparse.Namespace) -> int:
2525
"quant_platform_kit.strategy_lifecycle.performance_monitor",
2626
"run_monitor",
2727
)
28-
snapshots = run_monitor(
29-
domain=args.domain,
30-
strategy_profile=args.strategy,
31-
output_dir=args.output_dir,
32-
)
28+
kwargs: dict[str, Any] = {
29+
"domain": args.domain,
30+
"strategy_profile": args.strategy,
31+
"output_dir": args.output_dir,
32+
}
33+
benchmark_catalog = getattr(args, "benchmark_catalog", None)
34+
if benchmark_catalog:
35+
load_strategy_benchmark_catalog = _load_callable(
36+
"quant_platform_kit.strategy_lifecycle.benchmark_catalog",
37+
"load_strategy_benchmark_catalog",
38+
)
39+
kwargs["strategy_benchmarks"] = load_strategy_benchmark_catalog(benchmark_catalog)
40+
if getattr(args, "require_explicit_benchmark", False):
41+
kwargs["require_explicit_benchmark"] = True
42+
snapshots = run_monitor(**kwargs)
3343
_print(f"[monitor] Generated {len(snapshots)} performance snapshots")
3444
return 0
3545

@@ -189,7 +199,15 @@ def _run_autopilot(args: argparse.Namespace) -> int:
189199
def _run_lifecycle(args: argparse.Namespace) -> int:
190200
_print(f"[lifecycle] Running full lifecycle for domain={args.domain}")
191201
_print("[lifecycle] Step: monitor")
192-
monitor_status = _run_monitor(argparse.Namespace(domain=args.domain, strategy=None, output_dir=None))
202+
monitor_status = _run_monitor(
203+
argparse.Namespace(
204+
domain=args.domain,
205+
strategy=None,
206+
output_dir=None,
207+
benchmark_catalog=getattr(args, "benchmark_catalog", None),
208+
require_explicit_benchmark=getattr(args, "require_explicit_benchmark", False),
209+
)
210+
)
193211
if monitor_status != 0:
194212
return monitor_status
195213

@@ -295,6 +313,16 @@ def build_parser() -> argparse.ArgumentParser:
295313
monitor.add_argument("--domain", default="us_equity")
296314
monitor.add_argument("--strategy", default=None)
297315
monitor.add_argument("--output-dir", default=None)
316+
monitor.add_argument(
317+
"--benchmark-catalog",
318+
default=None,
319+
help="Validated JSON mapping strategy profiles to their monitoring benchmarks.",
320+
)
321+
monitor.add_argument(
322+
"--require-explicit-benchmark",
323+
action="store_true",
324+
help="Fail closed if a strategy binding or its benchmark data is unavailable.",
325+
)
298326
monitor.set_defaults(func=_run_monitor)
299327

300328
drift = subparsers.add_parser("drift", help="Run drift detection and publish drift alerts.")
@@ -354,6 +382,8 @@ def build_parser() -> argparse.ArgumentParser:
354382
lifecycle.add_argument("--skip-optimization", action="store_true")
355383
lifecycle.add_argument("--no-alerts", action="store_true")
356384
lifecycle.add_argument("--dry-run-alerts", action="store_true")
385+
lifecycle.add_argument("--benchmark-catalog", default=None)
386+
lifecycle.add_argument("--require-explicit-benchmark", action="store_true")
357387
_add_baseline_options(lifecycle)
358388
lifecycle.set_defaults(func=_run_lifecycle)
359389

src/quant_platform_kit/strategy_lifecycle/performance_monitor.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ def run_monitor(
4949
fail_on_empty: bool = True,
5050
store: PerformanceStore | None = None,
5151
collector: ReturnCollector | None = None,
52+
strategy_benchmarks: Mapping[str, str] | None = None,
53+
require_explicit_benchmark: bool = False,
5254
) -> list[StrategyPerformanceSnapshot]:
5355
"""Run the performance monitor for the given domain.
5456
@@ -61,6 +63,10 @@ def run_monitor(
6163
fail_on_empty: Raise when no usable return series can be monitored.
6264
store: PerformanceStore instance; auto-created from env if None.
6365
collector: ReturnCollector instance; auto-created if None.
66+
strategy_benchmarks: Explicit strategy-profile to benchmark bindings.
67+
require_explicit_benchmark: Refuse to monitor profiles without a binding
68+
or without the declared benchmark return series. This is the
69+
promotion-grade setting for leveraged strategies.
6470
6571
Returns:
6672
List of StrategyPerformanceSnapshot objects generated.
@@ -89,8 +95,20 @@ def run_monitor(
8995
series = normalize_return_series(returns)
9096

9197
# Resolve benchmark
92-
benchmark_symbol = resolve_strategy_benchmark(profile, domain)
98+
benchmark_symbol = resolve_strategy_benchmark(
99+
profile,
100+
domain,
101+
catalog_benchmarks=strategy_benchmarks,
102+
require_explicit=require_explicit_benchmark,
103+
)
93104
benchmark_series = collector.collect_benchmark(domain, benchmark_symbol)
105+
if require_explicit_benchmark and not _is_valid_series(
106+
benchmark_series, min_observations=min_observations
107+
):
108+
raise RuntimeError(
109+
f"explicit benchmark data is unavailable or insufficient for "
110+
f"strategy_profile={profile!r}, benchmark={benchmark_symbol!r}"
111+
)
94112
benchmark_returns = normalize_return_series(benchmark_series) if benchmark_series is not None else None
95113

96114
# Load backtest reference for comparison

src/quant_platform_kit/strategy_lifecycle/return_collector.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
_RETURN_MATRIX_FILENAME = "portfolio_and_tracker_returns.csv"
2626

2727

28+
class MissingStrategyBenchmarkError(ValueError):
29+
"""Raised when strict monitoring has no explicit benchmark binding."""
30+
31+
2832
class ReturnCollector:
2933
"""Discover and read return matrices from market pipeline artifact directories.
3034
@@ -188,13 +192,28 @@ def resolve_strategy_benchmark(
188192
domain: str,
189193
*,
190194
catalog_benchmarks: Mapping[str, str] | None = None,
195+
require_explicit: bool = False,
191196
) -> str:
192197
"""Resolve the benchmark symbol for a strategy.
193198
194-
Falls back through: catalog metadata → domain defaults.
199+
In normal compatibility mode this falls back through catalog metadata then
200+
domain defaults. Strict mode is intended for promotion-grade or leveraged
201+
monitoring: every profile must have a catalog binding, preventing a silent
202+
and potentially inappropriate fallback to SPY.
195203
"""
196-
if catalog_benchmarks and strategy_profile in catalog_benchmarks:
197-
return catalog_benchmarks[strategy_profile]
204+
profile = str(strategy_profile or "").strip()
205+
if catalog_benchmarks and profile in catalog_benchmarks:
206+
benchmark = str(catalog_benchmarks[profile] or "").strip()
207+
if benchmark:
208+
return benchmark
209+
raise MissingStrategyBenchmarkError(
210+
f"explicit benchmark for strategy_profile={profile!r} is blank"
211+
)
212+
if require_explicit:
213+
raise MissingStrategyBenchmarkError(
214+
f"no explicit benchmark binding for strategy_profile={profile!r}; "
215+
"provide a validated strategy benchmark catalog"
216+
)
198217

199218
# Domain defaults
200219
defaults = {

0 commit comments

Comments
 (0)