DockerSpace / scripts /close_loop_validate.py
DennisChan0909's picture
feat: integrate local architecture with HF Space
e610a2f
Raw
History Blame Contribute Delete
17.2 kB
#!/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"
@dataclass(frozen=True)
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
@dataclass(frozen=True)
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,
)
@dataclass(frozen=True)
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
@dataclass(frozen=True)
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())