Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """Closed-loop validation registry and promotion gate. | |
| The gate is intentionally small and importable: production publishability can | |
| check the promoted feature version without running market-data backtests. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| from dataclasses import asdict, dataclass | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| ROOT = Path(__file__).resolve().parent.parent | |
| sys.path.insert(0, str(ROOT)) | |
| from models.predictor import FEATURE_VERSION | |
| REGISTRY_PATH = ROOT / os.getenv("VALIDATION_REGISTRY_PATH", "docs/validation_registry.json") | |
| VALIDATION_RUNS_DIR = ROOT / "docs" / "validation_runs" | |
| class GateThresholds: | |
| min_accuracy_delta_pp: float = 5.0 | |
| min_direction_accuracy_delta_pp: float = -2.0 | |
| min_signal_count_ratio: float = 0.70 | |
| min_buy_signal_win_rate_delta_pp: float = 0.0 | |
| max_severe_data_warnings: int = 0 | |
| class GateConfig: | |
| min_accuracy_delta_pp: float = 5.0 | |
| min_direction_delta_pp: float = -2.0 | |
| min_signal_count_ratio: float = 0.70 | |
| min_buy_signal_win_rate_delta_pp: float = 0.0 | |
| max_severe_data_warnings: int = 0 | |
| def to_thresholds(self) -> GateThresholds: | |
| return GateThresholds( | |
| min_accuracy_delta_pp=self.min_accuracy_delta_pp, | |
| min_direction_accuracy_delta_pp=self.min_direction_delta_pp, | |
| min_signal_count_ratio=self.min_signal_count_ratio, | |
| min_buy_signal_win_rate_delta_pp=self.min_buy_signal_win_rate_delta_pp, | |
| max_severe_data_warnings=self.max_severe_data_warnings, | |
| ) | |
| class ValidationMetrics: | |
| accuracy: float = 0.0 | |
| direction_accuracy: float = 0.0 | |
| signal_count: float = 0.0 | |
| buy_signal_win_rate: float = 0.0 | |
| severe_data_warnings: int = 0 | |
| class PromotionDecision: | |
| passed: bool | |
| failed_checks: list[str] | |
| deltas: dict[str, float] | |
| checks: dict[str, bool] | |
| DEFAULT_GATE = GateThresholds() | |
| NO_PROMOTED_FEATURE_VERSION = "" | |
| def _now_iso() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def _registry_path(path: Path | str | None = None) -> Path: | |
| return Path(path) if path is not None else REGISTRY_PATH | |
| def _display_path(path: Path) -> str: | |
| try: | |
| return str(path.relative_to(ROOT)) | |
| except ValueError: | |
| return str(path) | |
| def _metric(metrics: dict[str, Any], *names: str, default: float = 0.0) -> float: | |
| for name in names: | |
| value = metrics.get(name) | |
| if value is not None: | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return default | |
| return default | |
| def _percent(value: float) -> float: | |
| """Normalize likely ratios to percentages while preserving percentage inputs.""" | |
| if -1.0 <= value <= 1.0: | |
| return value * 100.0 | |
| return value | |
| def normalize_metrics(metrics: dict[str, Any] | None) -> dict[str, float]: | |
| """Extract the small metric set used by the promotion gate.""" | |
| if isinstance(metrics, ValidationMetrics): | |
| metrics = asdict(metrics) | |
| metrics = metrics or {} | |
| stats = metrics.get("stats") if isinstance(metrics.get("stats"), dict) else {} | |
| merged = {**stats, **metrics} | |
| return { | |
| "accuracy": _percent(_metric(merged, "accuracy", "signal_accuracy_overall")), | |
| "direction_accuracy": _percent( | |
| _metric(merged, "direction_accuracy", "directional_accuracy", "signal_accuracy_overall") | |
| ), | |
| "buy_signal_win_rate": _percent( | |
| _metric(merged, "buy_signal_win_rate", "buy_win_rate", "up_precision") | |
| ), | |
| "signal_count": _metric(merged, "signal_count", "total_signals", "total_days"), | |
| "severe_data_warnings": _metric(merged, "severe_data_warnings", default=0.0), | |
| } | |
| def evaluate_gate( | |
| baseline_metrics: dict[str, Any] | None, | |
| candidate_metrics: dict[str, Any] | None, | |
| *, | |
| thresholds: GateThresholds = DEFAULT_GATE, | |
| ) -> dict[str, Any]: | |
| """Compare candidate metrics against baseline and return pass/fail details.""" | |
| baseline = normalize_metrics(baseline_metrics) | |
| candidate = normalize_metrics(candidate_metrics) | |
| accuracy_delta_pp = candidate["accuracy"] - baseline["accuracy"] | |
| direction_accuracy_delta_pp = candidate["direction_accuracy"] - baseline["direction_accuracy"] | |
| buy_signal_win_rate_delta_pp = candidate["buy_signal_win_rate"] - baseline["buy_signal_win_rate"] | |
| min_signal_count = baseline["signal_count"] * thresholds.min_signal_count_ratio | |
| checks = { | |
| "accuracy_delta": accuracy_delta_pp >= thresholds.min_accuracy_delta_pp, | |
| "direction_accuracy_delta": ( | |
| direction_accuracy_delta_pp >= thresholds.min_direction_accuracy_delta_pp | |
| ), | |
| "signal_count": candidate["signal_count"] >= min_signal_count, | |
| "buy_signal_win_rate_delta": ( | |
| buy_signal_win_rate_delta_pp >= thresholds.min_buy_signal_win_rate_delta_pp | |
| ), | |
| "severe_data_warnings": ( | |
| candidate["severe_data_warnings"] <= thresholds.max_severe_data_warnings | |
| ), | |
| } | |
| return { | |
| "passed": all(checks.values()), | |
| "checks": checks, | |
| "baseline": baseline, | |
| "candidate": candidate, | |
| "deltas": { | |
| "accuracy_delta_pp": round(accuracy_delta_pp, 4), | |
| "direction_accuracy_delta_pp": round(direction_accuracy_delta_pp, 4), | |
| "buy_signal_win_rate_delta_pp": round(buy_signal_win_rate_delta_pp, 4), | |
| "signal_count_delta": candidate["signal_count"] - baseline["signal_count"], | |
| }, | |
| "thresholds": asdict(thresholds), | |
| } | |
| def evaluate_promotion_gate( | |
| baseline: ValidationMetrics | dict[str, Any], | |
| candidate: ValidationMetrics | dict[str, Any], | |
| config: GateConfig | None = None, | |
| ) -> PromotionDecision: | |
| """Compatibility wrapper used by unit tests and future callers.""" | |
| gate_result = evaluate_gate( | |
| asdict(baseline) if isinstance(baseline, ValidationMetrics) else baseline, | |
| asdict(candidate) if isinstance(candidate, ValidationMetrics) else candidate, | |
| thresholds=(config or GateConfig()).to_thresholds(), | |
| ) | |
| failed_checks = [name for name, ok in gate_result["checks"].items() if not ok] | |
| check_name_map = { | |
| "accuracy_delta": "accuracy_delta_pp", | |
| "direction_accuracy_delta": "direction_accuracy_delta_pp", | |
| "signal_count": "signal_count_ratio", | |
| "buy_signal_win_rate_delta": "buy_signal_win_rate_delta_pp", | |
| "severe_data_warnings": "severe_data_warnings", | |
| } | |
| failed_checks = [check_name_map.get(name, name) for name in failed_checks] | |
| return PromotionDecision( | |
| passed=bool(gate_result["passed"]), | |
| failed_checks=failed_checks, | |
| deltas=gate_result["deltas"], | |
| checks=gate_result["checks"], | |
| ) | |
| def default_registry(*, feature_version: str = FEATURE_VERSION) -> dict[str, Any]: | |
| now = _now_iso() | |
| return { | |
| "schema_version": 1, | |
| "updated_at": now, | |
| "promotion_gate": asdict(DEFAULT_GATE), | |
| "promoted": { | |
| "feature_version": feature_version, | |
| "experiment_id": "current_production", | |
| "status": "pass", | |
| "promoted_at": now, | |
| "validation_run_path": None, | |
| "notes": "Initial registry entry for the current production feature version.", | |
| }, | |
| "latest_candidate": None, | |
| } | |
| def load_validation_registry( | |
| path: Path | str | None = None, | |
| *, | |
| allow_default: bool = True, | |
| ) -> dict[str, Any] | None: | |
| path = _registry_path(path) | |
| if not path.exists(): | |
| return default_registry() if allow_default else None | |
| try: | |
| data = json.loads(path.read_text()) | |
| except Exception: | |
| return default_registry() if allow_default else None | |
| if not isinstance(data, dict): | |
| return default_registry() if allow_default else None | |
| data.setdefault("schema_version", 1) | |
| data.setdefault("promotion_gate", asdict(DEFAULT_GATE)) | |
| data.setdefault("promoted", default_registry()["promoted"]) | |
| data.setdefault("latest_candidate", None) | |
| return data | |
| class ValidationRegistry: | |
| """Small registry facade for promoted feature-version checks.""" | |
| def __init__(self, path: Path | str | None = None) -> None: | |
| self.path = _registry_path(path) | |
| def load(self) -> dict[str, Any]: | |
| return load_validation_registry(self.path, allow_default=False) or {} | |
| def get_promoted_feature_version(self) -> str: | |
| registry = self.load() | |
| legacy = registry.get("promoted_feature_version") | |
| if legacy: | |
| return str(legacy) | |
| promoted = registry.get("promoted") if isinstance(registry.get("promoted"), dict) else {} | |
| if promoted.get("status") != "pass": | |
| return NO_PROMOTED_FEATURE_VERSION | |
| return str(promoted.get("feature_version") or NO_PROMOTED_FEATURE_VERSION) | |
| def is_promoted_feature_version(self, feature_version: str | None) -> bool: | |
| return bool(feature_version) and str(feature_version) == self.get_promoted_feature_version() | |
| def assert_promoted_feature_version(self, feature_version: str | None) -> None: | |
| if not self.is_promoted_feature_version(feature_version): | |
| raise ValueError( | |
| f"feature_version {feature_version!r} is not promoted " | |
| f"(promoted={self.get_promoted_feature_version()!r})" | |
| ) | |
| def save_validation_registry(registry: dict[str, Any], path: Path | str | None = None) -> None: | |
| path = _registry_path(path) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| registry["updated_at"] = _now_iso() | |
| path.write_text(json.dumps(registry, indent=2, ensure_ascii=False)) | |
| def get_promoted_feature_version(path: Path | str | None = None) -> str: | |
| return ValidationRegistry(path).get_promoted_feature_version() | |
| def is_feature_version_promoted(feature_version: str | None, path: Path | str | None = None) -> bool: | |
| return ValidationRegistry(path).is_promoted_feature_version(feature_version) | |
| def _load_metrics_file(path: str) -> dict[str, Any]: | |
| data = json.loads(Path(path).read_text()) | |
| if isinstance(data, dict) and isinstance(data.get("stats"), dict): | |
| return data["stats"] | |
| if isinstance(data, dict): | |
| return data | |
| raise ValueError(f"metrics file must contain a JSON object: {path}") | |
| def _gate_thresholds_from_registry(registry: dict[str, Any] | None) -> GateThresholds: | |
| gate = registry.get("promotion_gate") if isinstance(registry, dict) else {} | |
| if not isinstance(gate, dict): | |
| gate = {} | |
| return GateThresholds( | |
| min_accuracy_delta_pp=float( | |
| gate.get("min_accuracy_delta_pp", DEFAULT_GATE.min_accuracy_delta_pp) | |
| ), | |
| min_direction_accuracy_delta_pp=float( | |
| gate.get( | |
| "min_direction_accuracy_delta_pp", | |
| DEFAULT_GATE.min_direction_accuracy_delta_pp, | |
| ) | |
| ), | |
| min_signal_count_ratio=float(gate.get("min_signal_count_ratio", DEFAULT_GATE.min_signal_count_ratio)), | |
| min_buy_signal_win_rate_delta_pp=float( | |
| gate.get( | |
| "min_buy_signal_win_rate_delta_pp", | |
| DEFAULT_GATE.min_buy_signal_win_rate_delta_pp, | |
| ) | |
| ), | |
| max_severe_data_warnings=int( | |
| gate.get("max_severe_data_warnings", DEFAULT_GATE.max_severe_data_warnings) | |
| ), | |
| ) | |
| def write_validation_run( | |
| *, | |
| experiment_id: str, | |
| feature_version: str, | |
| baseline_metrics: dict[str, Any], | |
| candidate_metrics: dict[str, Any], | |
| result: dict[str, Any], | |
| output_dir: Path | str = VALIDATION_RUNS_DIR, | |
| ) -> Path: | |
| output_dir = Path(output_dir) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") | |
| path = output_dir / f"{timestamp}_{experiment_id}.json" | |
| payload = { | |
| "experiment_id": experiment_id, | |
| "feature_version": feature_version, | |
| "created_at": _now_iso(), | |
| "baseline_metrics": baseline_metrics, | |
| "candidate_metrics": candidate_metrics, | |
| "gate_result": result, | |
| } | |
| path.write_text(json.dumps(payload, indent=2, ensure_ascii=False)) | |
| return path | |
| def update_registry_with_validation( | |
| *, | |
| experiment_id: str, | |
| feature_version: str, | |
| gate_result: dict[str, Any], | |
| validation_run_path: Path | str | None = None, | |
| registry_path: Path | str = REGISTRY_PATH, | |
| promote_on_pass: bool = True, | |
| baseline_metrics: dict[str, Any] | None = None, | |
| candidate_metrics: dict[str, Any] | None = None, | |
| ) -> dict[str, Any]: | |
| registry = load_validation_registry(registry_path, allow_default=True) or default_registry() | |
| status = "pass" if gate_result.get("passed") else "fail" | |
| candidate = { | |
| "feature_version": feature_version, | |
| "experiment_id": experiment_id, | |
| "status": status, | |
| "validated_at": _now_iso(), | |
| "validation_run_path": str(validation_run_path) if validation_run_path else None, | |
| "deltas": gate_result.get("deltas", {}), | |
| "checks": gate_result.get("checks", {}), | |
| } | |
| if candidate_metrics is not None: | |
| candidate["metrics"] = candidate_metrics | |
| if baseline_metrics is not None: | |
| candidate["baseline_metrics"] = baseline_metrics | |
| registry["latest_candidate"] = candidate | |
| if promote_on_pass and status == "pass": | |
| registry["promoted"] = { | |
| **candidate, | |
| "promoted_at": _now_iso(), | |
| } | |
| save_validation_registry(registry, registry_path) | |
| return registry | |
| def main() -> int: | |
| parser = argparse.ArgumentParser(description="Evaluate candidate metrics against the promotion gate.") | |
| parser.add_argument("--experiment", required=True, help="Experiment ID, for example C22_label_gate") | |
| parser.add_argument("--feature-version", default=FEATURE_VERSION) | |
| parser.add_argument("--baseline-json", required=True, help="Baseline metrics JSON") | |
| parser.add_argument("--candidate-json", required=True, help="Candidate metrics JSON") | |
| parser.add_argument("--registry", default=str(REGISTRY_PATH)) | |
| parser.add_argument("--runs-dir", default=str(VALIDATION_RUNS_DIR), help="Directory for validation run JSON") | |
| parser.add_argument("--no-promote", action="store_true", help="Write result but do not promote on pass") | |
| parser.add_argument("--min-accuracy-delta-pp", type=float, default=None) | |
| parser.add_argument( | |
| "--min-direction-accuracy-delta-pp", | |
| type=float, | |
| default=None, | |
| ) | |
| parser.add_argument("--min-signal-count-ratio", type=float, default=None) | |
| parser.add_argument( | |
| "--min-buy-signal-win-rate-delta-pp", | |
| type=float, | |
| default=None, | |
| ) | |
| parser.add_argument("--max-severe-data-warnings", type=int, default=None) | |
| args = parser.parse_args() | |
| registry = load_validation_registry(args.registry, allow_default=True) | |
| registry_thresholds = _gate_thresholds_from_registry(registry) | |
| thresholds = GateThresholds( | |
| min_accuracy_delta_pp=( | |
| args.min_accuracy_delta_pp | |
| if args.min_accuracy_delta_pp is not None | |
| else registry_thresholds.min_accuracy_delta_pp | |
| ), | |
| min_direction_accuracy_delta_pp=( | |
| args.min_direction_accuracy_delta_pp | |
| if args.min_direction_accuracy_delta_pp is not None | |
| else registry_thresholds.min_direction_accuracy_delta_pp | |
| ), | |
| min_signal_count_ratio=( | |
| args.min_signal_count_ratio | |
| if args.min_signal_count_ratio is not None | |
| else registry_thresholds.min_signal_count_ratio | |
| ), | |
| min_buy_signal_win_rate_delta_pp=( | |
| args.min_buy_signal_win_rate_delta_pp | |
| if args.min_buy_signal_win_rate_delta_pp is not None | |
| else registry_thresholds.min_buy_signal_win_rate_delta_pp | |
| ), | |
| max_severe_data_warnings=( | |
| args.max_severe_data_warnings | |
| if args.max_severe_data_warnings is not None | |
| else registry_thresholds.max_severe_data_warnings | |
| ), | |
| ) | |
| baseline = _load_metrics_file(args.baseline_json) | |
| candidate = _load_metrics_file(args.candidate_json) | |
| gate_result = evaluate_gate(baseline, candidate, thresholds=thresholds) | |
| run_path = write_validation_run( | |
| experiment_id=args.experiment, | |
| feature_version=args.feature_version, | |
| baseline_metrics=baseline, | |
| candidate_metrics=candidate, | |
| result=gate_result, | |
| output_dir=args.runs_dir, | |
| ) | |
| update_registry_with_validation( | |
| experiment_id=args.experiment, | |
| feature_version=args.feature_version, | |
| gate_result=gate_result, | |
| validation_run_path=_display_path(run_path), | |
| registry_path=args.registry, | |
| promote_on_pass=not args.no_promote, | |
| ) | |
| print(json.dumps({"validation_run": str(run_path), **gate_result}, indent=2, ensure_ascii=False)) | |
| return 0 if gate_result["passed"] else 2 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |