"""Strategy improvement tracker: SQS computation, journal I/O, leaderboard.""" from __future__ import annotations import contextlib import datetime as dt import functools import fcntl import json from pathlib import Path from typing import Any, Callable, Iterator from libs.backtest.domain import ( CommonWindowSummary, ConfigDelta, DeploymentScoreWeights, ExperimentRegistry, JournalEntry, MetricsBundle, RobustnessMatrixSummary, WalkForwardScoreWeights, WFQSv2Weights, ReturnScoreWeights, PromotionScoreWeights, RegistryEntry, OverlayWindowSummary, SplitResult, SQSWeights, SQSv2Weights, UnifiedScoreWeights, WalkForwardAggregate, WalkForwardSummary, ) from libs.common.logging import get_logger from libs.common.time_utils import utc_now logger = get_logger(__name__) _DEFAULT_WEIGHTS = SQSWeights() _DEFAULT_V2_WEIGHTS = SQSv2Weights() _DEFAULT_PROMOTION_WEIGHTS = PromotionScoreWeights() _DEFAULT_UNIFIED_WEIGHTS = UnifiedScoreWeights() _DEFAULT_RQS_WEIGHTS = ReturnScoreWeights() _DEFAULT_WFQS_WEIGHTS = WalkForwardScoreWeights() _DEFAULT_WFQS_V2_WEIGHTS = WFQSv2Weights() _DEFAULT_DEPLOYMENT_WEIGHTS = DeploymentScoreWeights() _DEFAULT_PUBLIC_COMMON_WINDOW_WEIGHT = 0.20 _RETIRED_STRATEGY_FAMILIES = { "short_core", "legacy_pead", "leveraged_return_max_long", "exact_pocket_return_max_long", "named_micro_return_max_long", } _EXPERIMENTS_DIR = Path("configs/experiments") _NAMED_MICRO_ENGINE_TOKENS = ( "epam_micro", "nrg_np12", "glw_np11", "pl_micro", "exas_micro", "aap_micro", "apld_micro", "czr_hot_micro", ) # --------------------------------------------------------------------------- # SQS computation (pure functions, no I/O) # --------------------------------------------------------------------------- def _normalize(value: float | None, low: float, high: float) -> float: """Linear normalise *value* to 0-100 between *low* (0 pts) and *high* (100 pts).""" if value is None: return 0.0 if high == low: return 50.0 score = (value - low) / (high - low) * 100.0 return max(0.0, min(100.0, score)) def _normalize_inverse(value: float | None, low: float, high: float) -> float: """Like _normalize but lower values are better (e.g. drawdown).""" if value is None: return 0.0 if high == low: return 50.0 # low = worst (0 pts), high = best (100 pts) — but for inverse metrics # low value = good, high value = bad. Swap the interpretation. score = (low - value) / (low - high) * 100.0 return max(0.0, min(100.0, score)) def _normalize_band( value: float | None, low_bad: float, low_good: float, high_good: float, high_bad: float, ) -> float: """Score 0-100 with an optimal middle band.""" if value is None: return 0.0 if value <= low_bad or value >= high_bad: return 0.0 if low_good <= value <= high_good: return 100.0 if value < low_good: return (value - low_bad) / (low_good - low_bad) * 100.0 return (high_bad - value) / (high_bad - high_good) * 100.0 def _calibrate_sqs(value: float | None) -> float | None: """Compress raw integrated scores into a harsher absolute-looking range.""" if value is None: return None return round(max(0.0, value * 0.70 - 7.5), 1) def _safe_ratio(numerator: float | None, denominator: float | None) -> float | None: if numerator is None or denominator is None or denominator <= 0: return None return numerator / denominator def _apply_single_split_penalty(value: float | None) -> float | None: """Discount scores that have no valid/test confirmation pair.""" if value is None: return None return round(value * 0.80, 1) def compute_sqs( metrics: MetricsBundle, weights: SQSWeights | None = None, ) -> tuple[float, dict[str, float]]: """Compute Strategy Quality Score from test-split metrics. Returns (sqs_score, breakdown_dict). """ w = weights or _DEFAULT_WEIGHTS # --- Profitability (40%) --- pf_score = _normalize(metrics.profit_factor, low=0.8, high=2.0) ret_score = _normalize(metrics.total_return_pct, low=-5.0, high=5.0) profitability = pf_score * 0.6 + ret_score * 0.4 # --- Risk (25%) --- dd_score = _normalize_inverse(metrics.max_drawdown_pct, low=10.0, high=1.0) sharpe_score = _normalize(metrics.sharpe_ratio, low=-1.0, high=2.0) risk = dd_score * 0.5 + sharpe_score * 0.5 # --- Consistency (20%) --- wr_score = _normalize(metrics.win_rate, low=0.35, high=0.65) mwr_score = _normalize(metrics.monthly_win_rate, low=0.30, high=0.70) consistency = wr_score * 0.5 + mwr_score * 0.5 # --- Robustness (15%) --- r2_score = _normalize(metrics.equity_curve_r_squared, low=0.0, high=0.80) tc_score = _normalize(float(metrics.trade_count), low=10.0, high=100.0) robustness = r2_score * 0.5 + tc_score * 0.5 # Weighted total sqs = ( profitability * w.profitability + risk * w.risk + consistency * w.consistency + robustness * w.robustness ) # Low-trade penalty if metrics.trade_count < w.low_trade_penalty_threshold: sqs *= w.low_trade_penalty_factor sqs = round(sqs, 1) breakdown = { "profitability": round(profitability, 1), "risk": round(risk, 1), "consistency": round(consistency, 1), "robustness": round(robustness, 1), } return sqs, breakdown def compute_sqs_v2( metrics: MetricsBundle, weights: SQSv2Weights | None = None, ) -> tuple[float | None, dict[str, float]]: """Compute SQS v2, adding a capital-efficiency sleeve to the score.""" if metrics.avg_gross_exposure_pct is None or metrics.days_in_market_pct is None: return None, {} w = weights or _DEFAULT_V2_WEIGHTS pf_score = _normalize(metrics.profit_factor, low=0.8, high=2.0) ret_score = _normalize(metrics.total_return_pct, low=-5.0, high=5.0) profitability = pf_score * 0.6 + ret_score * 0.4 dd_score = _normalize_inverse(metrics.max_drawdown_pct, low=10.0, high=1.0) sharpe_score = _normalize(metrics.sharpe_ratio, low=-1.0, high=2.0) risk = dd_score * 0.5 + sharpe_score * 0.5 wr_score = _normalize(metrics.win_rate, low=0.35, high=0.65) mwr_score = _normalize(metrics.monthly_win_rate, low=0.30, high=0.70) consistency = wr_score * 0.5 + mwr_score * 0.5 r2_score = _normalize(metrics.equity_curve_r_squared, low=0.0, high=0.80) tc_score = _normalize(float(metrics.trade_count), low=10.0, high=100.0) robustness = r2_score * 0.5 + tc_score * 0.5 return_on_gross_exposure = None if metrics.total_return_pct is not None and metrics.avg_gross_exposure_pct > 0: return_on_gross_exposure = metrics.total_return_pct / metrics.avg_gross_exposure_pct roe_score = _normalize(return_on_gross_exposure, low=0.0, high=0.50) dim_score = _normalize_band( metrics.days_in_market_pct, low_bad=10.0, low_good=40.0, high_good=80.0, high_bad=100.0, ) capital_efficiency = roe_score * 0.7 + dim_score * 0.3 sqs_v2 = ( profitability * w.profitability + risk * w.risk + consistency * w.consistency + robustness * w.robustness + capital_efficiency * w.capital_efficiency ) if metrics.trade_count < w.low_trade_penalty_threshold: sqs_v2 *= w.low_trade_penalty_factor sqs_v2 = round(sqs_v2, 1) breakdown = { "profitability": round(profitability, 1), "risk": round(risk, 1), "consistency": round(consistency, 1), "robustness": round(robustness, 1), "capital_efficiency": round(capital_efficiency, 1), } return sqs_v2, breakdown # --------------------------------------------------------------------------- # Helper builders # --------------------------------------------------------------------------- def build_split_result(split_name: str, run_id: str, metrics: MetricsBundle) -> SplitResult: """Create a SplitResult from MetricsBundle.""" return SplitResult( run_id=run_id, trade_count=metrics.trade_count, profit_factor=metrics.profit_factor, total_return_pct=metrics.total_return_pct, annualized_return_pct=metrics.annualized_return_pct, win_rate=metrics.win_rate, max_drawdown_pct=metrics.max_drawdown_pct, sharpe_ratio=metrics.sharpe_ratio, monthly_win_rate=metrics.monthly_win_rate, equity_curve_r_squared=metrics.equity_curve_r_squared, avg_gross_exposure_pct=metrics.avg_gross_exposure_pct, avg_net_exposure_pct=metrics.avg_net_exposure_pct, days_in_market_pct=metrics.days_in_market_pct, ) def _metrics_from_split_result(result: SplitResult | None) -> MetricsBundle | None: if result is None: return None payload = result.model_dump(exclude={"run_id"}) return MetricsBundle.model_validate(payload) _metrics_path_index: dict[str, Path] | None = None def _get_metrics_path_index() -> dict[str, Path]: """Build run_id -> metrics_summary.json path index (single rglob).""" global _metrics_path_index if _metrics_path_index is not None: return _metrics_path_index _metrics_path_index = {} runs_root = Path("runs") for metrics_path in runs_root.rglob("metrics/metrics_summary.json"): # run_dir is metrics_path.parent.parent (e.g. runs/.../run_id/) run_dir = metrics_path.parent.parent run_dir_name = run_dir.name # Index by directory name as run_id if run_dir_name not in _metrics_path_index or metrics_path.stat().st_mtime > _metrics_path_index[run_dir_name].stat().st_mtime: _metrics_path_index[run_dir_name] = metrics_path return _metrics_path_index @functools.lru_cache(maxsize=2048) def _load_run_metrics_summary(run_id: str) -> dict[str, Any] | None: runs_root = Path("runs") metrics_path = runs_root / run_id / "metrics" / "metrics_summary.json" if not metrics_path.exists(): idx = _get_metrics_path_index() found = idx.get(run_id) if not found: return None metrics_path = found summary = json.loads(metrics_path.read_text()) needed_fields = ( "annualized_return_pct", "avg_gross_exposure_pct", "avg_net_exposure_pct", "days_in_market_pct", ) if all(summary.get(field) is not None for field in needed_fields): return summary equity_curve_path = metrics_path.parent.parent / "artifacts" / "daily_equity_curve.parquet" if not equity_curve_path.exists(): return summary try: import pyarrow.parquet as pq rows = pq.read_table( equity_curve_path, columns=["equity", "gross_exposure", "net_exposure"], ).to_pylist() except Exception: return summary exposure_rows = [row for row in rows if float(row["equity"]) > 0] if not exposure_rows: return summary if summary.get("avg_gross_exposure_pct") is None: summary["avg_gross_exposure_pct"] = sum( float(row["gross_exposure"]) / float(row["equity"]) * 100.0 for row in exposure_rows ) / len(exposure_rows) if summary.get("avg_net_exposure_pct") is None: summary["avg_net_exposure_pct"] = sum( float(row["net_exposure"]) / float(row["equity"]) * 100.0 for row in exposure_rows ) / len(exposure_rows) if summary.get("days_in_market_pct") is None: summary["days_in_market_pct"] = ( sum(1 for row in rows if float(row["gross_exposure"]) > 0) / len(rows) * 100.0 ) return summary def _hydrate_split_result(result: SplitResult | None) -> SplitResult | None: if result is None: return None needed_fields = ( "annualized_return_pct", "avg_gross_exposure_pct", "avg_net_exposure_pct", "days_in_market_pct", ) if all(getattr(result, field) is not None for field in needed_fields): return result summary = _load_run_metrics_summary(result.run_id) if summary is None: return result updates = { field: summary.get(field) for field in needed_fields if getattr(result, field) is None and summary.get(field) is not None } if not updates: return result return result.model_copy(update=updates) def _compute_split_quality_score( result: SplitResult | None, ) -> tuple[float | None, dict[str, float], str | None]: metrics = _metrics_from_split_result(_hydrate_split_result(result)) if metrics is None: return None, {}, None sqs_v2_score, sqs_v2_breakdown = compute_sqs_v2(metrics) if sqs_v2_score is not None: return sqs_v2_score, sqs_v2_breakdown, "sqs_v2" sqs_score, sqs_breakdown = compute_sqs(metrics) return sqs_score, sqs_breakdown, "sqs" def compute_unified_split_quality( metrics: MetricsBundle, weights: UnifiedScoreWeights | None = None, ) -> tuple[float | None, dict[str, float]]: """Compute a stricter split-level quality score.""" if metrics.avg_gross_exposure_pct is None or metrics.days_in_market_pct is None: return None, {} w = weights or _DEFAULT_UNIFIED_WEIGHTS pf_score = _normalize(metrics.profit_factor, low=1.0, high=3.0) ret_score = _normalize(metrics.total_return_pct, low=0.0, high=3.0) profitability = pf_score * 0.5 + ret_score * 0.5 dd_score = _normalize_inverse(metrics.max_drawdown_pct, low=5.0, high=0.5) sharpe_score = _normalize(metrics.sharpe_ratio, low=0.0, high=3.5) risk = dd_score * 0.5 + sharpe_score * 0.5 wr_score = _normalize(metrics.win_rate, low=0.50, high=0.65) mwr_score = _normalize(metrics.monthly_win_rate, low=0.45, high=0.75) consistency = wr_score * 0.5 + mwr_score * 0.5 r2_score = _normalize(metrics.equity_curve_r_squared, low=0.10, high=0.90) tc_score = _normalize(float(metrics.trade_count), low=20.0, high=80.0) robustness = r2_score * 0.5 + tc_score * 0.5 return_on_gross_exposure = None if metrics.total_return_pct is not None and metrics.avg_gross_exposure_pct > 0: return_on_gross_exposure = metrics.total_return_pct / metrics.avg_gross_exposure_pct roe_score = _normalize(return_on_gross_exposure, low=0.05, high=0.40) dim_score = _normalize_band( metrics.days_in_market_pct, low_bad=20.0, low_good=45.0, high_good=75.0, high_bad=90.0, ) capital_efficiency = roe_score * 0.7 + dim_score * 0.3 split_quality = ( profitability * w.split_profitability + risk * w.split_risk + consistency * w.split_consistency + robustness * w.split_robustness + capital_efficiency * w.split_capital_efficiency ) if metrics.trade_count < 20: split_quality *= 0.75 split_quality = round(split_quality, 1) breakdown = { "profitability": round(profitability, 1), "risk": round(risk, 1), "consistency": round(consistency, 1), "robustness": round(robustness, 1), "capital_efficiency": round(capital_efficiency, 1), } return split_quality, breakdown def compute_promotion_score( test_result: SplitResult | None, valid_result: SplitResult | None, weights: PromotionScoreWeights | None = None, ) -> tuple[float | None, dict[str, float]]: """Compute a promotion score using valid/test quality and a floor term.""" test_score, _, _ = _compute_split_quality_score(test_result) valid_score, _, _ = _compute_split_quality_score(valid_result) if test_score is None or valid_score is None: return None, {} w = weights or _DEFAULT_PROMOTION_WEIGHTS floor_score = min(test_score, valid_score) promotion_score = ( valid_score * w.valid_quality + test_score * w.test_quality + floor_score * w.floor_quality ) breakdown = { "valid_quality": round(valid_score, 1), "test_quality": round(test_score, 1), "floor_quality": round(floor_score, 1), } return round(promotion_score, 1), breakdown def compute_unified_score( test_result: SplitResult | None, valid_result: SplitResult | None, weights: UnifiedScoreWeights | None = None, ) -> tuple[float | None, dict[str, float]]: """Compute one integrated score for real promotion decisions.""" test_result = _hydrate_split_result(test_result) valid_result = _hydrate_split_result(valid_result) test_metrics = _metrics_from_split_result(test_result) valid_metrics = _metrics_from_split_result(valid_result) if test_metrics is None or valid_metrics is None: return None, {} w = weights or _DEFAULT_UNIFIED_WEIGHTS test_quality, _ = compute_unified_split_quality(test_metrics, w) valid_quality, _ = compute_unified_split_quality(valid_metrics, w) if test_quality is None or valid_quality is None: return None, {} floor_quality = min(test_quality, valid_quality) gap_quality = _normalize_inverse(abs(valid_quality - test_quality), low=35.0, high=5.0) unified_score = ( valid_quality * w.valid_quality + test_quality * w.test_quality + floor_quality * w.floor_quality + gap_quality * w.gap_quality ) breakdown = { "valid_quality": round(valid_quality, 1), "test_quality": round(test_quality, 1), "floor_quality": round(floor_quality, 1), "gap_quality": round(gap_quality, 1), } return _calibrate_sqs(unified_score), breakdown def compute_public_sqs( train_result: SplitResult | None, valid_result: SplitResult | None, test_result: SplitResult | None, walk_forward_summary: WalkForwardSummary | None = None, robustness_matrix_summary: RobustnessMatrixSummary | None = None, out_of_time_robustness_summary: RobustnessMatrixSummary | None = None, common_window_summary: CommonWindowSummary | None = None, rqs_score: float | None = None, wfqs_score: float | None = None, deployment_score: float | None = None, ) -> tuple[float | None, dict[str, float], str | None]: """Return the current public-facing SQS. v4 keeps the v3 stress/robustness gates intact, and adds a modest full-cycle capital-growth term when a comparable common-window run is available. Missing common-window summaries fall back to v3. """ return compute_public_sqs_v4( train_result, valid_result, test_result, walk_forward_summary=walk_forward_summary, robustness_matrix_summary=robustness_matrix_summary, out_of_time_robustness_summary=out_of_time_robustness_summary, common_window_summary=common_window_summary, rqs_score=rqs_score, wfqs_v2_score=wfqs_score, ) def _compute_return_split_score( metrics: MetricsBundle, split_name: str, weights: ReturnScoreWeights | None = None, ) -> tuple[float, dict[str, float]]: """Compute a return-max score for a single split.""" w = weights or _DEFAULT_RQS_WEIGHTS return_bands = { "train": (0.0, 50.0), "valid": (0.0, 30.0), "test": (0.0, 35.0), } annualized_return_bands = { "train": (0.0, 25.0), "valid": (0.0, 120.0), "test": (0.0, 120.0), } dd_bands = { "train": (20.0, 2.0), "valid": (12.0, 2.0), "test": (12.0, 2.0), } ret_low, ret_high = return_bands.get(split_name, (0.0, 35.0)) ann_low, ann_high = annualized_return_bands.get(split_name, (0.0, 100.0)) dd_low, dd_high = dd_bands.get(split_name, (12.0, 2.0)) total_return_score = _normalize(metrics.total_return_pct, low=ret_low, high=ret_high) annualized_return_score = _normalize(metrics.annualized_return_pct, low=ann_low, high=ann_high) effective_profit_factor = metrics.profit_factor if ( effective_profit_factor is None and metrics.trade_count > 0 and metrics.win_rate is not None and metrics.win_rate >= 0.999 ): # Backtest summaries emit ``None`` when there are no losing trades. # For return-max ranking that should be treated as capped-best, not zero. effective_profit_factor = 3.0 pf_score = _normalize(effective_profit_factor, low=1.0, high=3.0) sharpe_score = _normalize(metrics.sharpe_ratio, low=0.0, high=3.5) drawdown_score = _normalize_inverse(metrics.max_drawdown_pct, low=dd_low, high=dd_high) gross_score = _normalize_band( metrics.avg_gross_exposure_pct, low_bad=2.0, low_good=8.0, high_good=40.0, high_bad=75.0, ) dim_score = _normalize_band( metrics.days_in_market_pct, low_bad=5.0, low_good=15.0, high_good=60.0, high_bad=90.0, ) return_on_gross_exposure = None if metrics.total_return_pct is not None and metrics.avg_gross_exposure_pct and metrics.avg_gross_exposure_pct > 0: return_on_gross_exposure = metrics.total_return_pct / metrics.avg_gross_exposure_pct return_on_gross_score = _normalize(return_on_gross_exposure, low=0.20, high=2.50) split_score = ( total_return_score * w.split_total_return + annualized_return_score * w.split_annualized_return + pf_score * w.split_profitability + sharpe_score * w.split_sharpe + drawdown_score * w.split_drawdown + return_on_gross_score * w.split_return_on_gross + gross_score * w.split_gross_exposure + dim_score * w.split_days_in_market ) if metrics.trade_count < w.low_trade_penalty_threshold: split_score *= w.low_trade_penalty_factor split_score = round(split_score, 1) breakdown = { "total_return": round(total_return_score, 1), "annualized_return": round(annualized_return_score, 1), "profitability": round(pf_score, 1), "sharpe": round(sharpe_score, 1), "drawdown": round(drawdown_score, 1), "return_on_gross": round(return_on_gross_score, 1), "gross_exposure": round(gross_score, 1), "days_in_market": round(dim_score, 1), } return split_score, breakdown def compute_rqs( train_result: SplitResult | None, valid_result: SplitResult | None, test_result: SplitResult | None, weights: ReturnScoreWeights | None = None, ) -> tuple[float | None, dict[str, float]]: """Compute Return Quality Score for return-max strategy ranking.""" w = weights or _DEFAULT_RQS_WEIGHTS hydrated = { "train": _hydrate_split_result(train_result), "valid": _hydrate_split_result(valid_result), "test": _hydrate_split_result(test_result), } split_scores: dict[str, float] = {} for split_name, result in hydrated.items(): metrics = _metrics_from_split_result(result) if metrics is None: continue split_score, _ = _compute_return_split_score(metrics, split_name, w) split_scores[split_name] = split_score if "valid" not in split_scores or "test" not in split_scores: return None, {} weighted_sum = 0.0 weighted_den = 0.0 if "train" in split_scores: weighted_sum += split_scores["train"] * w.train_quality weighted_den += w.train_quality weighted_sum += split_scores["valid"] * w.valid_quality weighted_den += w.valid_quality weighted_sum += split_scores["test"] * w.test_quality weighted_den += w.test_quality split_quality = weighted_sum / weighted_den if weighted_den > 0 else 0.0 floor_quality = min(split_scores.values()) gap_quality = _normalize_inverse( max(split_scores.values()) - min(split_scores.values()), low=60.0, high=10.0, ) rqs = split_quality * (1.0 - w.floor_quality - w.gap_quality) + floor_quality * w.floor_quality + gap_quality * w.gap_quality if "train" not in split_scores: rqs *= w.missing_train_penalty breakdown = { "train_quality": round(split_scores.get("train", 0.0), 1), "valid_quality": round(split_scores["valid"], 1), "test_quality": round(split_scores["test"], 1), "floor_quality": round(floor_quality, 1), "gap_quality": round(gap_quality, 1), } return round(rqs, 1), breakdown def compute_wfqs( walk_forward_summary: WalkForwardSummary | None, weights: WalkForwardScoreWeights | None = None, ) -> tuple[float | None, dict[str, float]]: """Compute a walk-forward robustness score from fold-level aggregates.""" if walk_forward_summary is None or walk_forward_summary.fold_count <= 0: return None, {} w = weights or _DEFAULT_WFQS_WEIGHTS test = walk_forward_summary.test_aggregate gap = walk_forward_summary.gap_stats median_return_score = _normalize(test.median_return_pct, low=0.0, high=25.0) mean_return_score = _normalize(test.mean_return_pct, low=0.0, high=25.0) worst_return_score = _normalize(test.worst_return_pct, low=-5.0, high=10.0) positive_fold_rate_score = _normalize(test.positive_fold_rate_pct, low=50.0, high=100.0) profit_factor_score = _normalize(test.mean_profit_factor, low=1.0, high=4.0) drawdown_score = _normalize_inverse(test.mean_max_drawdown_pct, low=15.0, high=2.0) gap_score = _normalize_inverse(gap.mean_train_test_return_gap_pct, low=70.0, high=15.0) fold_count_score = _normalize(float(walk_forward_summary.fold_count), low=4.0, high=10.0) wfqs = ( median_return_score * w.median_return + mean_return_score * w.mean_return + worst_return_score * w.worst_return + positive_fold_rate_score * w.positive_fold_rate + profit_factor_score * w.profit_factor + drawdown_score * w.drawdown + gap_score * w.train_test_gap + fold_count_score * w.fold_count ) if walk_forward_summary.fold_count < w.low_fold_penalty_threshold: wfqs *= w.low_fold_penalty_factor breakdown = { "median_return": round(median_return_score, 1), "mean_return": round(mean_return_score, 1), "worst_return": round(worst_return_score, 1), "positive_fold_rate": round(positive_fold_rate_score, 1), "profit_factor": round(profit_factor_score, 1), "drawdown": round(drawdown_score, 1), "train_test_gap": round(gap_score, 1), "fold_count": round(fold_count_score, 1), } return round(wfqs, 1), breakdown # --------------------------------------------------------------------------- # WFQS v2: multiplicative penalty model # --------------------------------------------------------------------------- def _gap_penalty(gap_pct: float | None) -> float: """Multiplicative penalty based on mean train-test gap percentage.""" if gap_pct is None: return 1.0 if gap_pct <= 30.0: return 1.0 if gap_pct <= 100.0: return 1.0 - (gap_pct - 30.0) / (100.0 - 30.0) * 0.4 # 1.0 → 0.6 if gap_pct <= 300.0: return 0.6 - (gap_pct - 100.0) / (300.0 - 100.0) * 0.3 # 0.6 → 0.3 return 0.2 def _fold_variance_penalty(fold_return_cv: float | None) -> float: """Multiplicative penalty based on CV of fold test returns.""" if fold_return_cv is None: return 1.0 if fold_return_cv <= 0.5: return 1.0 if fold_return_cv <= 1.5: return 1.0 - (fold_return_cv - 0.5) / (1.5 - 0.5) * 0.3 # 1.0 → 0.7 return 0.6 def _trade_credibility(mean_trades: float | None, mean_win_rate: float | None) -> float: """Multiplicative penalty for low trade counts or suspiciously high win rates.""" if mean_trades is None: return 0.5 if mean_trades < 5: factor = 0.5 elif mean_trades < 10: factor = 0.7 elif mean_trades < 20: factor = 0.85 else: factor = 1.0 if (mean_win_rate or 0.0) > 0.95 and mean_trades < 15: factor *= 0.8 return factor def _engine_reliability_penalty(ratio: float | None) -> float: """Multiplicative penalty based on engine reliability ratio.""" if ratio is None: return 1.0 if ratio >= 0.7: return 1.0 if ratio >= 0.3: return 0.5 + (ratio - 0.3) / (0.7 - 0.3) * 0.5 # 0.5 → 1.0 return 0.4 def _derive_fold_stats( walk_forward_summary: WalkForwardSummary, ) -> tuple[float | None, float | None, float | None]: """Derive mean_trade_count, mean_win_rate, fold_return_cv from folds when aggregate fields are missing.""" import statistics as _stats folds = walk_forward_summary.folds if not folds: return None, None, None trade_counts = [float(f.test_metrics.trade_count) for f in folds] win_rates = [f.test_metrics.win_rate for f in folds if f.test_metrics.win_rate is not None] returns = [f.test_metrics.total_return_pct for f in folds if f.test_metrics.total_return_pct is not None] mean_tc = round(_stats.mean(trade_counts), 1) if trade_counts else None mean_wr = round(_stats.mean(win_rates), 4) if win_rates else None cv = None if len(returns) >= 2: mean_ret = _stats.mean(returns) if abs(mean_ret) > 1e-9: cv = round(_stats.stdev(returns) / abs(mean_ret), 3) return mean_tc, mean_wr, cv def _wfqs_v2_quality_from_aggregate( aggregate: WalkForwardAggregate, fold_count: int, weights: WFQSv2Weights, ) -> float: """Compute additive WFQS v2 quality score before multiplicative penalties.""" median_return_score = _normalize(aggregate.median_return_pct, low=0.0, high=25.0) mean_return_score = _normalize(aggregate.mean_return_pct, low=0.0, high=25.0) worst_return_score = _normalize(aggregate.worst_return_pct, low=-5.0, high=10.0) positive_fold_rate_score = _normalize(aggregate.positive_fold_rate_pct, low=50.0, high=100.0) profit_factor_score = _normalize(aggregate.mean_profit_factor, low=1.0, high=4.0) drawdown_score = _normalize_inverse(aggregate.mean_max_drawdown_pct, low=15.0, high=2.0) fold_count_score = _normalize(float(fold_count), low=4.0, high=10.0) quality = ( median_return_score * weights.median_return + mean_return_score * weights.mean_return + worst_return_score * weights.worst_return + positive_fold_rate_score * weights.positive_fold_rate + profit_factor_score * weights.profit_factor + drawdown_score * weights.drawdown + fold_count_score * weights.fold_count ) if fold_count < weights.low_fold_penalty_threshold: quality *= weights.low_fold_penalty_factor return quality def _build_recent_fold_aggregate( walk_forward_summary: WalkForwardSummary, lookback_days: int, min_folds: int, ) -> tuple[WalkForwardAggregate | None, int]: """Aggregate test-fold quality for folds ending within the recent lookback window.""" import statistics as _stats folds = walk_forward_summary.folds if not folds: return None, 0 anchor = max(f.test_end for f in folds) cutoff = anchor - dt.timedelta(days=max(lookback_days - 1, 0)) recent_folds = [f for f in folds if f.test_end >= cutoff] if len(recent_folds) < min_folds: return None, len(recent_folds) returns = [f.test_metrics.total_return_pct for f in recent_folds if f.test_metrics.total_return_pct is not None] pfs = [f.test_metrics.profit_factor for f in recent_folds if f.test_metrics.profit_factor is not None] dds = [f.test_metrics.max_drawdown_pct for f in recent_folds if f.test_metrics.max_drawdown_pct is not None] trade_counts = [float(f.test_metrics.trade_count) for f in recent_folds] win_rates = [f.test_metrics.win_rate for f in recent_folds if f.test_metrics.win_rate is not None] positive = [r for r in returns if r > 0] aggregate = WalkForwardAggregate( mean_return_pct=round(_stats.mean(returns), 2) if returns else None, median_return_pct=round(_stats.median(returns), 2) if returns else None, worst_return_pct=round(min(returns), 2) if returns else None, positive_fold_rate_pct=round(len(positive) / len(recent_folds) * 100.0, 1) if recent_folds else None, mean_profit_factor=round(_stats.mean(pfs), 2) if pfs else None, mean_max_drawdown_pct=round(_stats.mean(dds), 2) if dds else None, mean_trade_count=round(_stats.mean(trade_counts), 1) if trade_counts else None, mean_win_rate=round(_stats.mean(win_rates), 4) if win_rates else None, ) return aggregate, len(recent_folds) def _public_activity_factor( test_result: SplitResult | None, walk_forward_summary: WalkForwardSummary | None, ) -> tuple[float, dict[str, float]]: """Return a light multiplicative penalty for strategies that barely trade.""" if test_result is None or walk_forward_summary is None: return 1.0, {} mean_tc = walk_forward_summary.test_aggregate.mean_trade_count if mean_tc is None: derived_tc, _, _ = _derive_fold_stats(walk_forward_summary) mean_tc = derived_tc test_trade_score = _normalize(float(test_result.trade_count), low=8.0, high=24.0) wf_trade_score = _normalize(mean_tc, low=3.0, high=10.0) dim_score = _normalize(test_result.days_in_market_pct, low=20.0, high=60.0) activity_quality = ( test_trade_score * 0.45 + wf_trade_score * 0.35 + dim_score * 0.20 ) if test_result.trade_count < 10: activity_quality *= 0.85 if (mean_tc or 0.0) < 4.0: activity_quality *= 0.85 factor = 0.50 + 0.50 * (activity_quality / 100.0) breakdown = { "activity_factor": round(factor, 2), "activity_quality": round(activity_quality, 1), "activity_test_trades": round(test_trade_score, 1), "activity_wf_mean_trades": round(wf_trade_score, 1), "activity_days_in_market": round(dim_score, 1), } return factor, breakdown def compute_wfqs_v2( walk_forward_summary: WalkForwardSummary | None, weights: WFQSv2Weights | None = None, ) -> tuple[float | None, dict[str, float]]: """Compute WFQS v2 with multiplicative overfitting penalties.""" if walk_forward_summary is None or walk_forward_summary.fold_count <= 0: return None, {} w = weights or _DEFAULT_WFQS_V2_WEIGHTS test = walk_forward_summary.test_aggregate gap = walk_forward_summary.gap_stats # Derive missing stats from folds when aggregate fields are absent mean_tc = test.mean_trade_count mean_wr = test.mean_win_rate fold_cv = gap.fold_return_cv if mean_tc is None or mean_wr is None or fold_cv is None: derived_tc, derived_wr, derived_cv = _derive_fold_stats(walk_forward_summary) if mean_tc is None: mean_tc = derived_tc if mean_wr is None: mean_wr = derived_wr if fold_cv is None: fold_cv = derived_cv overall_quality = _wfqs_v2_quality_from_aggregate( test, walk_forward_summary.fold_count, w, ) median_return_score = _normalize(test.median_return_pct, low=0.0, high=25.0) mean_return_score = _normalize(test.mean_return_pct, low=0.0, high=25.0) worst_return_score = _normalize(test.worst_return_pct, low=-5.0, high=10.0) positive_fold_rate_score = _normalize(test.positive_fold_rate_pct, low=50.0, high=100.0) profit_factor_score = _normalize(test.mean_profit_factor, low=1.0, high=4.0) drawdown_score = _normalize_inverse(test.mean_max_drawdown_pct, low=15.0, high=2.0) fold_count_score = _normalize(float(walk_forward_summary.fold_count), low=4.0, high=10.0) recent_aggregate, recent_fold_count = _build_recent_fold_aggregate( walk_forward_summary, lookback_days=w.recent_lookback_days, min_folds=w.recent_min_folds, ) recent_quality = None if recent_aggregate is not None: recent_quality = _wfqs_v2_quality_from_aggregate( recent_aggregate, recent_fold_count, w, ) if recent_quality is not None: base_score = overall_quality * (1.0 - w.recent_fold_quality) + recent_quality * w.recent_fold_quality else: base_score = overall_quality # Multiplicative penalties gp = _gap_penalty(gap.mean_train_test_return_gap_pct) fvp = _fold_variance_penalty(fold_cv) tc = _trade_credibility(mean_tc, mean_wr) er = _engine_reliability_penalty(walk_forward_summary.engine_reliability_ratio) wfqs_v2 = base_score * gp * fvp * tc * er breakdown = { "base_score": round(base_score, 1), "median_return": round(median_return_score, 1), "mean_return": round(mean_return_score, 1), "worst_return": round(worst_return_score, 1), "positive_fold_rate": round(positive_fold_rate_score, 1), "profit_factor": round(profit_factor_score, 1), "drawdown": round(drawdown_score, 1), "fold_count": round(fold_count_score, 1), "overall_quality": round(overall_quality, 1), "recent_quality": round(recent_quality, 1) if recent_quality is not None else 0.0, "recent_fold_count": float(recent_fold_count), "recent_fold_weight": round(w.recent_fold_quality if recent_quality is not None else 0.0, 2), "gap_penalty": round(gp, 3), "fold_variance_penalty": round(fvp, 3), "trade_credibility": round(tc, 3), "engine_reliability": round(er, 3), } return round(wfqs_v2, 1), breakdown def _compute_public_sqs_components( train_result: SplitResult | None, valid_result: SplitResult | None, test_result: SplitResult | None, walk_forward_summary: WalkForwardSummary | None = None, robustness_matrix_summary: RobustnessMatrixSummary | None = None, out_of_time_robustness_summary: RobustnessMatrixSummary | None = None, rqs_score: float | None = None, wfqs_v2_score: float | None = None, ) -> tuple[dict[str, Any] | None, dict[str, float], str | None]: """Resolve shared components for public SQS variants.""" resolved_rqs = rqs_score if resolved_rqs is None: resolved_rqs, _ = compute_rqs(train_result, valid_result, test_result) if resolved_rqs is None: return None, {}, None missing_requirements: dict[str, float] = {} if walk_forward_summary is None: missing_requirements["requires_walk_forward"] = 1.0 if robustness_matrix_summary is None: missing_requirements["requires_robustness"] = 1.0 if out_of_time_robustness_summary is None: missing_requirements["requires_out_of_time_robustness"] = 1.0 if missing_requirements: return None, missing_requirements, "pending_validation" resolved_wfqs_v2 = wfqs_v2_score if resolved_wfqs_v2 is None: resolved_wfqs_v2, _ = compute_wfqs_v2(walk_forward_summary) if resolved_wfqs_v2 is None: return None, {}, None test = walk_forward_summary.test_aggregate gap = walk_forward_summary.gap_stats pass_positive = (test.positive_fold_rate_pct or 0.0) >= 70.0 pass_median = (test.median_return_pct or 0.0) >= 5.0 pass_worst = (test.worst_return_pct or 0.0) >= -5.0 pass_gap = (gap.mean_train_test_return_gap_pct or 999.0) <= 35.0 pass_count = sum([pass_positive, pass_median, pass_worst, pass_gap]) deployment_gate_factor = {4: 1.00, 3: 0.85, 2: 0.65, 1: 0.40, 0: 0.20}[pass_count] base_score = (resolved_rqs * 0.45 + resolved_wfqs_v2 * 0.55) * deployment_gate_factor gate_factor_rb, gate_breakdown = compute_robustness_gate(robustness_matrix_summary) oot_gate_factor, oot_gate_breakdown = compute_oot_robustness_gate(out_of_time_robustness_summary) oot_quality, oot_quality_breakdown = compute_oot_robustness_quality(out_of_time_robustness_summary) activity_factor, activity_breakdown = _public_activity_factor(test_result, walk_forward_summary) return ( { "base_score": base_score, "deployment_gate_factor": deployment_gate_factor, "rb_gate_factor": gate_factor_rb, "rb_gate_breakdown": gate_breakdown, "oot_gate_factor": oot_gate_factor, "oot_gate_breakdown": oot_gate_breakdown, "oot_quality": oot_quality, "oot_quality_breakdown": oot_quality_breakdown, "activity_factor": activity_factor, "activity_breakdown": activity_breakdown, }, {}, "v3_deployment", ) def compute_public_sqs_v2( train_result: SplitResult | None, valid_result: SplitResult | None, test_result: SplitResult | None, walk_forward_summary: WalkForwardSummary | None = None, robustness_matrix_summary: RobustnessMatrixSummary | None = None, out_of_time_robustness_summary: RobustnessMatrixSummary | None = None, rqs_score: float | None = None, wfqs_v2_score: float | None = None, ) -> tuple[float | None, dict[str, float], str | None]: """Return legacy stress-adjusted public SQS. This keeps the historical multiplicative OOT quality factor that compresses strong recent strategies when stress-period quality is weaker. """ components, breakdown, source = _compute_public_sqs_components( train_result, valid_result, test_result, walk_forward_summary=walk_forward_summary, robustness_matrix_summary=robustness_matrix_summary, out_of_time_robustness_summary=out_of_time_robustness_summary, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) if components is None: return None, breakdown, source oot_quality_factor = 1.0 if components["oot_quality"] is not None: oot_quality_factor = 0.30 + 0.70 * (components["oot_quality"] / 100.0) final_score = ( components["base_score"] * components["rb_gate_factor"] * components["oot_gate_factor"] * oot_quality_factor * components["activity_factor"] ) resolved_breakdown = { "base_score": round(components["base_score"], 1), "gate_factor": round(components["rb_gate_factor"], 2), **components["rb_gate_breakdown"], "oot_gate_factor": round(components["oot_gate_factor"], 2), "oot_quality_factor": round(oot_quality_factor, 2), **{ f"oot_{key}": value for key, value in components["oot_gate_breakdown"].items() }, **{ f"oot_{key}": value for key, value in components["oot_quality_breakdown"].items() }, **components["activity_breakdown"], } if components["oot_quality"] is not None: resolved_breakdown["oot_quality"] = round(components["oot_quality"], 1) return round(final_score, 1), resolved_breakdown, "v2_deployment+robustness+oot" def compute_public_sqs_v3( train_result: SplitResult | None, valid_result: SplitResult | None, test_result: SplitResult | None, walk_forward_summary: WalkForwardSummary | None = None, robustness_matrix_summary: RobustnessMatrixSummary | None = None, out_of_time_robustness_summary: RobustnessMatrixSummary | None = None, rqs_score: float | None = None, wfqs_v2_score: float | None = None, ) -> tuple[float | None, dict[str, float], str | None]: """Return public SQS v3. v3 treats stress OOT as a hard gate for eligibility, but keeps OOT quality as a diagnostic instead of a multiplicative ranking penalty. """ components, breakdown, source = _compute_public_sqs_components( train_result, valid_result, test_result, walk_forward_summary=walk_forward_summary, robustness_matrix_summary=robustness_matrix_summary, out_of_time_robustness_summary=out_of_time_robustness_summary, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) if components is None: return None, breakdown, source final_score = ( components["base_score"] * components["rb_gate_factor"] * components["oot_gate_factor"] * components["activity_factor"] ) resolved_breakdown = { "base_score": round(components["base_score"], 1), "gate_factor": round(components["rb_gate_factor"], 2), **components["rb_gate_breakdown"], "oot_gate_factor": round(components["oot_gate_factor"], 2), **{ f"oot_{key}": value for key, value in components["oot_gate_breakdown"].items() }, **{ f"oot_{key}": value for key, value in components["oot_quality_breakdown"].items() }, **components["activity_breakdown"], } if components["oot_quality"] is not None: resolved_breakdown["oot_quality"] = round(components["oot_quality"], 1) return round(final_score, 1), resolved_breakdown, "v3_deployment+robustness+oot_gate" def compute_public_sqs_v4( train_result: SplitResult | None, valid_result: SplitResult | None, test_result: SplitResult | None, walk_forward_summary: WalkForwardSummary | None = None, robustness_matrix_summary: RobustnessMatrixSummary | None = None, out_of_time_robustness_summary: RobustnessMatrixSummary | None = None, common_window_summary: CommonWindowSummary | None = None, rqs_score: float | None = None, wfqs_v2_score: float | None = None, ) -> tuple[float | None, dict[str, float], str | None]: """Return public SQS v4. v4 keeps v3 intact as the deployment/stress backbone, then blends in a modest full-cycle capital-growth score when a comparable common-window run is available. If no common-window summary exists, v4 falls back to v3. """ v3_score, v3_breakdown, source = compute_public_sqs_v3( train_result, valid_result, test_result, walk_forward_summary=walk_forward_summary, robustness_matrix_summary=robustness_matrix_summary, out_of_time_robustness_summary=out_of_time_robustness_summary, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) if v3_score is None: return None, v3_breakdown, source common_score, common_breakdown = compute_common_window_score(common_window_summary) if common_score is None: return v3_score, v3_breakdown, "v4_fallback_v3_missing_common_window" weight = _DEFAULT_PUBLIC_COMMON_WINDOW_WEIGHT final_score = v3_score * (1.0 - weight) + common_score * weight breakdown = { "v3_score": round(v3_score, 1), "common_window_score": round(common_score, 1), "common_window_weight": round(weight, 2), **v3_breakdown, **common_breakdown, } return round(final_score, 1), breakdown, "v4_deployment+common_window" def compute_deployment_score( train_result: SplitResult | None, valid_result: SplitResult | None, test_result: SplitResult | None, walk_forward_summary: WalkForwardSummary | None, rqs_score: float | None = None, wfqs_score: float | None = None, weights: DeploymentScoreWeights | None = None, ) -> tuple[float | None, dict[str, float]]: """Compute a deployment-oriented score that requires walk-forward confirmation.""" if walk_forward_summary is None: return None, {} rqs = rqs_score if rqs is None: rqs, _ = compute_rqs(train_result, valid_result, test_result) wfqs = wfqs_score if wfqs is None: wfqs, _ = compute_wfqs(walk_forward_summary) if rqs is None or wfqs is None: return None, {} w = weights or _DEFAULT_DEPLOYMENT_WEIGHTS test = walk_forward_summary.test_aggregate gap = walk_forward_summary.gap_stats pass_positive = (test.positive_fold_rate_pct or 0.0) >= 70.0 pass_median = (test.median_return_pct or 0.0) >= 5.0 pass_worst = (test.worst_return_pct or 0.0) >= -5.0 pass_gap = (gap.mean_train_test_return_gap_pct or 999.0) <= 35.0 pass_count = sum([pass_positive, pass_median, pass_worst, pass_gap]) gate_factor = { 4: 1.00, 3: 0.85, 2: 0.65, 1: 0.40, 0: 0.20, }[pass_count] deployment = (rqs * w.rqs_quality + wfqs * w.wfqs_quality) * gate_factor breakdown = { "rqs_quality": round(rqs, 1), "wfqs_quality": round(wfqs, 1), "gate_factor": round(gate_factor, 2), "gate_positive_fold_rate": 1.0 if pass_positive else 0.0, "gate_median_return": 1.0 if pass_median else 0.0, "gate_worst_return": 1.0 if pass_worst else 0.0, "gate_train_test_gap": 1.0 if pass_gap else 0.0, } return round(deployment, 1), breakdown def compute_common_window_score( common_window_summary: CommonWindowSummary | None, ) -> tuple[float | None, dict[str, float]]: """Score a continuous full-cycle run for capital-growth / recycling quality.""" if common_window_summary is None: return None, {} metrics = common_window_summary.metrics total_return_score = _normalize(metrics.total_return_pct, low=0.0, high=250.0) profit_factor_score = _normalize(metrics.profit_factor, low=1.0, high=6.0) sharpe_score = _normalize(metrics.sharpe_ratio, low=0.0, high=3.0) drawdown_score = _normalize_inverse(metrics.max_drawdown_pct, low=12.0, high=2.5) return_on_gross = _safe_ratio(metrics.total_return_pct, metrics.avg_gross_exposure_pct) return_on_gross_score = _normalize(return_on_gross, low=1.0, high=8.0) capital_velocity = _safe_ratio(metrics.total_return_pct, metrics.days_in_market_pct) capital_velocity_score = _normalize(capital_velocity, low=0.4, high=3.0) score = ( total_return_score * 0.35 + profit_factor_score * 0.10 + sharpe_score * 0.15 + drawdown_score * 0.15 + return_on_gross_score * 0.20 + capital_velocity_score * 0.05 ) if metrics.trade_count < 25: score *= 0.85 breakdown = { "cw_total_return": round(total_return_score, 1), "cw_profit_factor": round(profit_factor_score, 1), "cw_sharpe": round(sharpe_score, 1), "cw_drawdown": round(drawdown_score, 1), "cw_return_on_gross": round(return_on_gross_score, 1), "cw_capital_velocity": round(capital_velocity_score, 1), "cw_trade_count": float(metrics.trade_count), } return round(score, 1), breakdown def compute_overlay_window_score( overlay_summary: OverlayWindowSummary | None, ) -> tuple[float | None, dict[str, float]]: """Score an overlay on full-window capital growth and risk efficiency.""" if overlay_summary is None: return None, {} return_score = _normalize(overlay_summary.return_pct, low=0.0, high=180.0) ann_score = _normalize(overlay_summary.annualized_return_pct, low=0.0, high=35.0) sharpe_score = _normalize(overlay_summary.sharpe_ratio, low=0.0, high=3.0) drawdown_score = _normalize_inverse(overlay_summary.max_drawdown_pct, low=12.0, high=2.5) activity_score = _normalize(float(overlay_summary.day_count), low=250.0, high=1000.0) score = ( return_score * 0.40 + ann_score * 0.15 + sharpe_score * 0.25 + drawdown_score * 0.15 + activity_score * 0.05 ) breakdown = { "overlay_return": round(return_score, 1), "overlay_annualized_return": round(ann_score, 1), "overlay_sharpe": round(sharpe_score, 1), "overlay_drawdown": round(drawdown_score, 1), "overlay_activity": round(activity_score, 1), "overlay_day_count": float(overlay_summary.day_count), } return round(score, 1), breakdown def compute_overlay_stress_gate( overlay_stress_summary: OverlayWindowSummary | None, ) -> tuple[float, dict[str, float]]: """Gate overlay scores using a stress-period overlay run.""" if overlay_stress_summary is None: return 0.0, {"requires_overlay_stress_window": 1.0} pass_return = (overlay_stress_summary.return_pct or 0.0) >= 5.0 pass_sharpe = (overlay_stress_summary.sharpe_ratio or 0.0) >= 0.50 pass_drawdown = (overlay_stress_summary.max_drawdown_pct or 999.0) <= 10.0 pass_count = sum([pass_return, pass_sharpe, pass_drawdown]) gate_factor = { 3: 1.00, 2: 0.85, 1: 0.65, 0: 0.40, }[pass_count] return gate_factor, { "overlay_gate_return": 1.0 if pass_return else 0.0, "overlay_gate_sharpe": 1.0 if pass_sharpe else 0.0, "overlay_gate_drawdown": 1.0 if pass_drawdown else 0.0, } def compute_overlay_stress_quality( overlay_stress_summary: OverlayWindowSummary | None, ) -> tuple[float | None, dict[str, float]]: """Diagnostic stress quality for overlay entries.""" if overlay_stress_summary is None: return None, {} return_score = _normalize(overlay_stress_summary.return_pct, low=0.0, high=20.0) sharpe_score = _normalize(overlay_stress_summary.sharpe_ratio, low=0.0, high=1.5) drawdown_score = _normalize_inverse(overlay_stress_summary.max_drawdown_pct, low=12.0, high=3.0) quality = return_score * 0.45 + sharpe_score * 0.35 + drawdown_score * 0.20 breakdown = { "overlay_stress_return": round(return_score, 1), "overlay_stress_sharpe": round(sharpe_score, 1), "overlay_stress_drawdown": round(drawdown_score, 1), } return round(quality, 1), breakdown def compute_overlay_public_sqs( overlay_common_window_summary: OverlayWindowSummary | None, overlay_stress_window_summary: OverlayWindowSummary | None, ) -> tuple[float | None, dict[str, float], str | None]: """Return the official overlay score.""" base_score, base_breakdown = compute_overlay_window_score(overlay_common_window_summary) if base_score is None: return None, {"requires_overlay_common_window": 1.0}, "pending_overlay_common_window" gate_factor, gate_breakdown = compute_overlay_stress_gate(overlay_stress_window_summary) if overlay_stress_window_summary is None: return None, gate_breakdown, "pending_overlay_stress_window" stress_quality, stress_breakdown = compute_overlay_stress_quality(overlay_stress_window_summary) final_score = base_score * gate_factor breakdown = { "overlay_base_score": round(base_score, 1), "overlay_gate_factor": round(gate_factor, 2), **base_breakdown, **gate_breakdown, **stress_breakdown, } if stress_quality is not None: breakdown["overlay_stress_quality"] = round(stress_quality, 1) return round(final_score, 1), breakdown, "overlay_v1_common_window+stress_gate" def compute_overlay_stress_sqs( overlay_common_window_summary: OverlayWindowSummary | None, overlay_stress_window_summary: OverlayWindowSummary | None, ) -> tuple[float | None, dict[str, float], str | None]: """Legacy overlay score with an extra stress-quality penalty.""" base_score, base_breakdown = compute_overlay_window_score(overlay_common_window_summary) if base_score is None: return None, {"requires_overlay_common_window": 1.0}, "pending_overlay_common_window" gate_factor, gate_breakdown = compute_overlay_stress_gate(overlay_stress_window_summary) if overlay_stress_window_summary is None: return None, gate_breakdown, "pending_overlay_stress_window" stress_quality, stress_breakdown = compute_overlay_stress_quality(overlay_stress_window_summary) quality_factor = 1.0 if stress_quality is not None: quality_factor = 0.30 + 0.70 * (stress_quality / 100.0) final_score = base_score * gate_factor * quality_factor breakdown = { "overlay_base_score": round(base_score, 1), "overlay_gate_factor": round(gate_factor, 2), "overlay_quality_factor": round(quality_factor, 2), **base_breakdown, **gate_breakdown, **stress_breakdown, } if stress_quality is not None: breakdown["overlay_stress_quality"] = round(stress_quality, 1) return round(final_score, 1), breakdown, "overlay_v1_common_window+stress_quality" def _get_robustness_horizon_summary( summary: RobustnessMatrixSummary | None, horizon_days: int, ) -> dict[str, float | int | None] | None: if summary is None: return None for item in summary.horizon_summaries: if item.horizon_days == horizon_days: return item.model_dump() return None def compute_robustness_gate( robustness_matrix_summary: RobustnessMatrixSummary | None, ) -> tuple[float, dict[str, float]]: """Return multiplicative gate from the robustness matrix summary.""" if robustness_matrix_summary is None: return 1.0, {} h63 = _get_robustness_horizon_summary(robustness_matrix_summary, 63) h252 = _get_robustness_horizon_summary(robustness_matrix_summary, 252) pass_positive = (robustness_matrix_summary.overall_positive_window_rate_pct or 0.0) >= 65.0 pass_63 = ((h63 or {}).get("median_return_pct") or 0.0) >= 3.0 pass_252 = ((h252 or {}).get("median_return_pct") or 0.0) >= 8.0 pass_worst = (robustness_matrix_summary.overall_worst_return_pct or -999.0) >= -12.0 pass_count = sum([pass_positive, pass_63, pass_252, pass_worst]) gate_factor = { 4: 1.00, 3: 0.85, 2: 0.65, 1: 0.40, 0: 0.20, }[pass_count] breakdown = { "rb_gate_positive_rate": 1.0 if pass_positive else 0.0, "rb_gate_63d_median": 1.0 if pass_63 else 0.0, "rb_gate_252d_median": 1.0 if pass_252 else 0.0, "rb_gate_worst_return": 1.0 if pass_worst else 0.0, } return gate_factor, breakdown def _compute_oot_positive_rate_63plus( summary: RobustnessMatrixSummary, ) -> float | None: """63d+ horizon weighted-average positive window rate. 21d windows penalise catalyst strategies unfairly (0-trade windows count as non-positive). 63d+ windows give a fairer survival signal. """ qualifying = [h for h in summary.horizon_summaries if h.horizon_days >= 63] if not qualifying: return None total_windows = sum(h.window_count for h in qualifying) if total_windows == 0: return None weighted_positive = sum( (h.positive_window_rate_pct or 0.0) * h.window_count for h in qualifying ) return round(weighted_positive / total_windows, 1) def compute_oot_robustness_gate( robustness_matrix_summary: RobustnessMatrixSummary | None, ) -> tuple[float, dict[str, float]]: """OOT stress-test robustness gate with relaxed thresholds. Stress-test thresholds (vs main-period): - 63d+ positive rate >= 50% (main: overall >= 65%) - 63d median >= 0.5% (main: >= 3.0%) - 252d median >= 3.0% (main: >= 8.0%) - worst return >= -15.0% (main: >= -12.0%) """ if robustness_matrix_summary is None: return 1.0, {} h63 = _get_robustness_horizon_summary(robustness_matrix_summary, 63) h252 = _get_robustness_horizon_summary(robustness_matrix_summary, 252) positive_rate_63plus = _compute_oot_positive_rate_63plus(robustness_matrix_summary) pass_positive = (positive_rate_63plus or 0.0) >= 50.0 pass_63 = ((h63 or {}).get("median_return_pct") or 0.0) >= 0.5 pass_252 = ((h252 or {}).get("median_return_pct") or 0.0) >= 3.0 pass_worst = (robustness_matrix_summary.overall_worst_return_pct or -999.0) >= -15.0 pass_count = sum([pass_positive, pass_63, pass_252, pass_worst]) gate_factor = { 4: 1.00, 3: 0.85, 2: 0.65, 1: 0.40, 0: 0.20, }[pass_count] breakdown = { "rb_gate_positive_rate": 1.0 if pass_positive else 0.0, "rb_gate_63d_median": 1.0 if pass_63 else 0.0, "rb_gate_252d_median": 1.0 if pass_252 else 0.0, "rb_gate_worst_return": 1.0 if pass_worst else 0.0, } return gate_factor, breakdown def compute_robustness_quality( robustness_matrix_summary: RobustnessMatrixSummary | None, ) -> tuple[float | None, dict[str, float]]: """Return a continuous robustness quality score in [0, 100]. This complements the coarse pass/fail gate so repaired OOT runs can meaningfully reorder strategies instead of collapsing into the same gate. """ if robustness_matrix_summary is None: return None, {} h63 = _get_robustness_horizon_summary(robustness_matrix_summary, 63) h252 = _get_robustness_horizon_summary(robustness_matrix_summary, 252) positive_rate_score = _normalize( robustness_matrix_summary.overall_positive_window_rate_pct, low=30.0, high=80.0, ) worst_return_score = _normalize( robustness_matrix_summary.overall_worst_return_pct, low=-25.0, high=0.0, ) h63_median_score = _normalize( (h63 or {}).get("median_return_pct"), low=-5.0, high=5.0, ) h252_median_score = _normalize( (h252 or {}).get("median_return_pct"), low=-10.0, high=15.0, ) quality = ( positive_rate_score * 0.30 + h63_median_score * 0.25 + h252_median_score * 0.30 + worst_return_score * 0.15 ) breakdown = { "rb_quality_positive_rate": round(positive_rate_score, 1), "rb_quality_63d_median": round(h63_median_score, 1), "rb_quality_252d_median": round(h252_median_score, 1), "rb_quality_worst_return": round(worst_return_score, 1), } return round(quality, 1), breakdown def compute_oot_robustness_quality( robustness_matrix_summary: RobustnessMatrixSummary | None, ) -> tuple[float | None, dict[str, float]]: """OOT stress-test continuous quality score [0, 100]. Normalisation ranges adjusted for stress-test periods (e.g. COVID). Uses 63d+ positive rate instead of overall (excludes 21d noise). """ if robustness_matrix_summary is None: return None, {} h63 = _get_robustness_horizon_summary(robustness_matrix_summary, 63) h252 = _get_robustness_horizon_summary(robustness_matrix_summary, 252) positive_rate_63plus = _compute_oot_positive_rate_63plus(robustness_matrix_summary) positive_rate_score = _normalize( positive_rate_63plus, low=20.0, high=70.0, ) worst_return_score = _normalize( robustness_matrix_summary.overall_worst_return_pct, low=-30.0, high=-5.0, ) h63_median_score = _normalize( (h63 or {}).get("median_return_pct"), low=-8.0, high=3.0, ) h252_median_score = _normalize( (h252 or {}).get("median_return_pct"), low=-15.0, high=10.0, ) quality = ( positive_rate_score * 0.30 + h63_median_score * 0.25 + h252_median_score * 0.30 + worst_return_score * 0.15 ) breakdown = { "rb_quality_positive_rate": round(positive_rate_score, 1), "rb_quality_63d_median": round(h63_median_score, 1), "rb_quality_252d_median": round(h252_median_score, 1), "rb_quality_worst_return": round(worst_return_score, 1), } return round(quality, 1), breakdown def compute_config_delta( current: dict[str, Any], baseline: dict[str, Any], baseline_name: str, ) -> ConfigDelta: """Compute a flat diff between two config dicts (one level deep).""" changes: dict[str, str] = {} _diff_recursive(baseline, current, prefix="", changes=changes) return ConfigDelta(base_experiment=baseline_name, changes=changes) def _diff_recursive( old: dict[str, Any], new: dict[str, Any], prefix: str, changes: dict[str, str], ) -> None: all_keys = set(old.keys()) | set(new.keys()) for key in sorted(all_keys): full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" old_val = old.get(key) new_val = new.get(key) if isinstance(old_val, dict) and isinstance(new_val, dict): _diff_recursive(old_val, new_val, full_key, changes) elif old_val != new_val: changes[full_key] = f"{old_val} \u2192 {new_val}" # --------------------------------------------------------------------------- # Journal I/O # --------------------------------------------------------------------------- def get_next_entry_id(journal_path: Path) -> str: """Return next entry ID like 'IMP-0001'.""" entries = load_journal(journal_path) return f"IMP-{len(entries) + 1:04d}" @contextlib.contextmanager def journal_lock(journal_path: Path) -> Iterator[None]: """Serialize journal mutations across concurrent record commands.""" journal_path.parent.mkdir(parents=True, exist_ok=True) lock_path = journal_path.with_suffix(f"{journal_path.suffix}.lock") with lock_path.open("a+") as lock_file: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) try: yield finally: fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) def append_journal_entry(journal_path: Path, entry: JournalEntry) -> None: """Append a single JournalEntry as one JSON line.""" journal_path.parent.mkdir(parents=True, exist_ok=True) with open(journal_path, "a") as f: f.write(entry.model_dump_json() + "\n") logger.info("journal_entry_appended", entry_id=entry.entry_id, experiment=entry.experiment_name) def replace_journal_entry(journal_path: Path, entry: JournalEntry) -> None: """Replace an existing journal entry by entry_id.""" entries = load_journal(journal_path) replaced = False updated_entries: list[JournalEntry] = [] for current in entries: if current.entry_id == entry.entry_id: updated_entries.append(entry) replaced = True else: updated_entries.append(current) if not replaced: raise ValueError(f"Journal entry not found: {entry.entry_id}") journal_path.write_text("".join(e.model_dump_json() + "\n" for e in updated_entries)) logger.info("journal_entry_replaced", entry_id=entry.entry_id, experiment=entry.experiment_name) def load_journal(journal_path: Path) -> list[JournalEntry]: """Load all journal entries from JSONL file.""" if not journal_path.exists(): return [] entries: list[JournalEntry] = [] for line in journal_path.read_text().strip().splitlines(): line = line.strip() if line: entries.append(JournalEntry.model_validate_json(line)) return entries def refresh_public_scores( journal_path: Path, selector: Callable[[JournalEntry], bool] | None = None, ) -> int: """Recompute stored public scores and score diagnostics for journal entries.""" entries = load_journal(journal_path) updated_entries: list[JournalEntry] = [] updated_count = 0 for entry in entries: if selector is not None and not selector(entry): updated_entries.append(entry) continue if entry.overlay_common_window_summary is not None: overlay_sqs, overlay_breakdown, _ = compute_overlay_public_sqs( entry.overlay_common_window_summary, entry.overlay_stress_window_summary, ) overlay_stress_sqs, overlay_stress_breakdown, _ = compute_overlay_stress_sqs( entry.overlay_common_window_summary, entry.overlay_stress_window_summary, ) refreshed = entry.model_copy( update={ "sqs_score": overlay_sqs, "sqs_breakdown": overlay_breakdown, "sqs_v3_score": overlay_sqs, "sqs_v3_breakdown": overlay_breakdown, "stress_sqs_score": overlay_stress_sqs, "stress_sqs_breakdown": overlay_stress_breakdown, "common_window_score": None, "common_window_breakdown": {}, } ) if refreshed.model_dump() != entry.model_dump(): updated_count += 1 updated_entries.append(refreshed) continue test_result = _hydrate_split_result(entry.results.get("test")) valid_result = _hydrate_split_result(entry.results.get("valid")) train_result = _hydrate_split_result(entry.results.get("train")) test_metrics = _metrics_from_split_result(test_result) sqs_v2_score = None sqs_v2_breakdown: dict[str, float] = {} if test_metrics is not None: sqs_v2_score, sqs_v2_breakdown = compute_sqs_v2(test_metrics) rqs_score, rqs_breakdown = compute_rqs(train_result, valid_result, test_result) wfqs_score, wfqs_breakdown = compute_wfqs(entry.walk_forward_summary) wfqs_v2_score, wfqs_v2_breakdown = compute_wfqs_v2(entry.walk_forward_summary) deployment_score, deployment_breakdown = compute_deployment_score( train_result, valid_result, test_result, entry.walk_forward_summary, rqs_score=rqs_score, wfqs_score=wfqs_score, ) common_window_score, common_window_breakdown = compute_common_window_score( entry.common_window_summary ) sqs_v3_score, sqs_v3_breakdown, _ = compute_public_sqs_v3( train_result, valid_result, test_result, walk_forward_summary=entry.walk_forward_summary, robustness_matrix_summary=entry.robustness_matrix_summary, out_of_time_robustness_summary=entry.out_of_time_robustness_summary, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) sqs_score, sqs_breakdown, _ = compute_public_sqs( train_result, valid_result, test_result, walk_forward_summary=entry.walk_forward_summary, robustness_matrix_summary=entry.robustness_matrix_summary, out_of_time_robustness_summary=entry.out_of_time_robustness_summary, common_window_summary=entry.common_window_summary, rqs_score=rqs_score, wfqs_score=wfqs_v2_score, ) stress_sqs_score, stress_sqs_breakdown, _ = compute_public_sqs_v2( train_result, valid_result, test_result, walk_forward_summary=entry.walk_forward_summary, robustness_matrix_summary=entry.robustness_matrix_summary, out_of_time_robustness_summary=entry.out_of_time_robustness_summary, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) refreshed = entry.model_copy( update={ "sqs_score": sqs_score, "sqs_breakdown": sqs_breakdown, "sqs_v3_score": sqs_v3_score, "sqs_v3_breakdown": sqs_v3_breakdown, "stress_sqs_score": stress_sqs_score, "stress_sqs_breakdown": stress_sqs_breakdown, "sqs_v2_score": sqs_v2_score, "sqs_v2_breakdown": sqs_v2_breakdown, "rqs_score": rqs_score, "rqs_breakdown": rqs_breakdown, "wfqs_score": wfqs_score, "wfqs_breakdown": wfqs_breakdown, "wfqs_v2_score": wfqs_v2_score, "wfqs_v2_breakdown": wfqs_v2_breakdown, "deployment_score": deployment_score, "deployment_breakdown": deployment_breakdown, "common_window_score": common_window_score, "common_window_breakdown": common_window_breakdown, } ) if refreshed.model_dump() != entry.model_dump(): updated_count += 1 updated_entries.append(refreshed) if updated_count: journal_path.write_text("".join(item.model_dump_json() + "\n" for item in updated_entries)) logger.info("journal_public_scores_refreshed", updated_count=updated_count) return updated_count def _resolve_journal_target(entries: list[JournalEntry], target: str) -> JournalEntry: target_upper = target.upper() by_id = [entry for entry in entries if entry.entry_id.upper() == target_upper] if by_id: return by_id[0] exact_name = [entry for entry in entries if entry.experiment_name == target] if len(exact_name) == 1: return exact_name[0] if len(exact_name) > 1: return sorted(exact_name, key=lambda e: e.timestamp)[-1] partial = [entry for entry in entries if target.lower() in entry.experiment_name.lower()] if len(partial) != 1: raise ValueError(f"Unable to uniquely match journal entry: {target}") return partial[0] def attach_walk_forward_summary( journal_path: Path, target: str, summary: WalkForwardSummary, ) -> JournalEntry: """Attach walk-forward validation summary to an existing journal entry.""" entries = load_journal(journal_path) selected = _resolve_journal_target(entries, target) wfqs_score, wfqs_breakdown = compute_wfqs(summary) wfqs_v2_score, wfqs_v2_breakdown = compute_wfqs_v2(summary) deployment_score, deployment_breakdown = compute_deployment_score( selected.results.get("train"), selected.results.get("valid"), selected.results.get("test"), summary, ) updated = selected.model_copy( update={ "walk_forward_summary": summary, "wfqs_score": wfqs_score, "wfqs_breakdown": wfqs_breakdown, "wfqs_v2_score": wfqs_v2_score, "wfqs_v2_breakdown": wfqs_v2_breakdown, "deployment_score": deployment_score, "deployment_breakdown": deployment_breakdown, } ) replace_journal_entry(journal_path, updated) return updated def attach_robustness_summary( journal_path: Path, target: str, summary: RobustnessMatrixSummary, ) -> JournalEntry: """Attach robustness matrix summary to an existing journal entry.""" entries = load_journal(journal_path) selected = _resolve_journal_target(entries, target) updated = selected.model_copy( update={ "robustness_matrix_summary": summary, } ) replace_journal_entry(journal_path, updated) return updated def attach_out_of_time_robustness_summary( journal_path: Path, target: str, summary: RobustnessMatrixSummary, ) -> JournalEntry: """Attach out-of-time robustness summary to an existing journal entry.""" entries = load_journal(journal_path) selected = _resolve_journal_target(entries, target) updated = selected.model_copy( update={ "out_of_time_robustness_summary": summary, } ) replace_journal_entry(journal_path, updated) return updated def attach_common_window_summary( journal_path: Path, target: str, summary: CommonWindowSummary, ) -> JournalEntry: """Attach common-window continuous-run summary to an existing journal entry.""" entries = load_journal(journal_path) selected = _resolve_journal_target(entries, target) common_window_score, common_window_breakdown = compute_common_window_score(summary) updated = selected.model_copy( update={ "common_window_summary": summary, "common_window_score": common_window_score, "common_window_breakdown": common_window_breakdown, } ) replace_journal_entry(journal_path, updated) return updated @functools.lru_cache(maxsize=1024) def _load_manifest_json(experiment_name: str) -> dict[str, Any] | None: path = _EXPERIMENTS_DIR / f"{experiment_name}.json" if not path.exists(): return None with path.open("r", encoding="utf-8") as fh: return json.load(fh) def _manifest_has_exact_pocket_structure(experiment_name: str) -> bool: manifest = _load_manifest_json(experiment_name) if manifest is None: return False for engine in manifest.get("strategy_engines", []): engine_id = str(engine.get("engine_id", "")).lower() if engine.get("enabled", True) and "exact" in engine_id: return True return False def _manifest_has_named_micro_structure(experiment_name: str) -> bool: manifest = _load_manifest_json(experiment_name) if manifest is None: return False for engine in manifest.get("strategy_engines", []): engine_id = str(engine.get("engine_id", "")).lower() if any(token in engine_id for token in _NAMED_MICRO_ENGINE_TOKENS): return True return False def classify_strategy_family(experiment_name: str, tags: list[str] | None = None) -> str: """Classify an experiment into a coarse strategy family for leaderboard filtering.""" lowered_name = experiment_name.lower() lowered_tags = {tag.lower() for tag in tags or []} if lowered_name.startswith("return_book_overlay") or "overlay" in lowered_tags: return "overlay" if _manifest_has_exact_pocket_structure(experiment_name): return "exact_pocket_return_max_long" if _manifest_has_named_micro_structure(experiment_name): return "named_micro_return_max_long" if ( "exact" in lowered_name or "combo" in lowered_name or "exact" in lowered_tags or "combo" in lowered_tags ): return "exact_pocket_return_max_long" if lowered_name.startswith("return_max_long") and "_bp" in lowered_name: return "leveraged_return_max_long" if lowered_name.startswith("return_max_long"): return "return_max_long" if "short_core" in lowered_name or "short_core" in lowered_tags: return "short_core" if lowered_name.startswith("pead_") or "pead" in lowered_tags: return "legacy_pead" return "other" def is_retired_strategy_family(strategy_family: str) -> bool: """Return True when the strategy family is retired from the default workflow.""" return strategy_family in _RETIRED_STRATEGY_FAMILIES def filter_registry_entries( entries: list[RegistryEntry], *, include_retired: bool = False, include_overlays: bool = False, ) -> list[RegistryEntry]: """Filter registry entries for the default surfaced leaderboard.""" filtered = list(entries) if include_retired else [entry for entry in entries if not entry.is_retired] return [ entry for entry in filtered if ( entry.sqs_score is not None and ( (include_overlays and entry.strategy_family == "overlay") or ( entry.strategy_family != "overlay" and entry.trade_count > 0 and entry.valid_trade_count > 0 ) ) ) ] def filter_overlay_registry_entries( entries: list[RegistryEntry], *, include_retired: bool = False, ) -> list[RegistryEntry]: """Filter registry entries for the dedicated overlay leaderboard.""" filtered = list(entries) if include_retired else [entry for entry in entries if not entry.is_retired] return [ entry for entry in filtered if ( entry.sqs_score is not None and entry.strategy_family == "overlay" and entry.overlay_common_window_summary is not None ) ] def _is_retired_journal_entry(entry: JournalEntry) -> bool: return is_retired_strategy_family(classify_strategy_family(entry.experiment_name, entry.tags)) def _is_complete_journal_entry(entry: JournalEntry) -> bool: if entry.overlay_common_window_summary is not None: return entry.sqs_score is not None test_result = entry.results.get("test") valid_result = entry.results.get("valid") return ( test_result is not None and valid_result is not None and test_result.trade_count > 0 and valid_result.trade_count > 0 ) def _load_optional_walk_forward_summary(experiment_name: str) -> WalkForwardSummary | None: summary_path = Path("runs") / f"{experiment_name}_wfv" / "walk_forward" / "walk_forward_summary.json" if not summary_path.exists(): return None return WalkForwardSummary.model_validate_json(summary_path.read_text()) def _load_optional_robustness_summary(experiment_name: str) -> RobustnessMatrixSummary | None: summary_path = ( Path("runs") / f"{experiment_name}_rm" / "robustness_matrix" / "robustness_matrix_summary.json" ) if not summary_path.exists(): return None return RobustnessMatrixSummary.model_validate_json(summary_path.read_text()) def _load_optional_out_of_time_robustness_summary(experiment_name: str) -> RobustnessMatrixSummary | None: summary_path = ( Path("runs") / f"{experiment_name}_oot_rm" / "robustness_matrix" / "robustness_matrix_summary.json" ) if not summary_path.exists(): return None return RobustnessMatrixSummary.model_validate_json(summary_path.read_text()) def _build_manifest_index( runs_dir: Path, ) -> dict[str, list[tuple[Path, dict, dict]]]: """Build experiment_name -> [(run_dir, manifest_data, metadata)] index in one rglob pass.""" index: dict[str, list[tuple[Path, dict, dict]]] = {} for manifest_file in sorted(runs_dir.rglob("manifest.json")): run_path = manifest_file.parent try: manifest_data = json.loads(manifest_file.read_text()) except Exception: continue exp_name = manifest_data.get("experiment_name") if not exp_name: continue metadata_file = run_path / "metadata.json" try: metadata = json.loads(metadata_file.read_text()) if metadata_file.exists() else {} except Exception: metadata = {} index.setdefault(exp_name, []).append((run_path, manifest_data, metadata)) return index def _scan_runs_from_index( run_entries: list[tuple[Path, dict, dict]], ) -> dict[str, tuple[str, "MetricsBundle"]]: """Resolve split runs from pre-indexed manifest entries (no rglob).""" from libs.backtest.domain import MetricsBundle matches: list[tuple[Path, str, str, str, str | None]] = [] for run_path, _manifest_data, metadata in run_entries: run_id = metadata.get("run_id", run_path.name) started_at = str(metadata.get("started_at") or metadata.get("finished_at") or "") split_name = metadata.get("split_name") parts = run_path.name.split("_") config_hash = parts[-1] if len(parts) >= 2 else "" matches.append((run_path, run_id, config_hash, started_at, split_name)) if not matches: return {} results: dict[str, tuple[str, MetricsBundle]] = {} has_split_names = any(sn is not None for _, _, _, _, sn in matches) canonical_splits = {"train", "valid", "test"} if has_split_names: canonical_matches = [m for m in matches if m[4] in canonical_splits] if not canonical_matches: return {} latest_by_split: dict[str, tuple[str, Path, str]] = {} for run_path, run_id, _, started_at, split_name in canonical_matches: split = split_name or "unknown" current = latest_by_split.get(split) if current is None or started_at >= current[0]: latest_by_split[split] = (started_at, run_path, run_id) for split, (_, run_path, run_id) in latest_by_split.items(): metrics_file = run_path / "metrics" / "metrics_summary.json" if metrics_file.exists(): metrics = MetricsBundle.model_validate_json(metrics_file.read_text()) results[split] = (run_id, metrics) else: from collections import defaultdict groups: dict[str, list[tuple[str, Path, str]]] = defaultdict(list) for run_path, run_id, config_hash, started_at, _ in matches: groups[config_hash].append((started_at, run_path, run_id)) best_hash = max(groups, key=lambda h: max(t[0] for t in groups[h])) sorted_runs = sorted(groups[best_hash], key=lambda t: t[0]) split_order = ["train", "valid", "test"] for i, (_, run_path, run_id) in enumerate(sorted_runs[-3:]): split = split_order[i] if i < 3 else f"extra_{i}" metrics_file = run_path / "metrics" / "metrics_summary.json" if metrics_file.exists(): metrics = MetricsBundle.model_validate_json(metrics_file.read_text()) results[split] = (run_id, metrics) return results def sync_official_manifests( journal_path: Path, runs_dir: Path, configs_dir: Path | None = None, ) -> list[JournalEntry]: """Append official manifests with complete runs that are missing from the journal.""" configs_root = configs_dir or _EXPERIMENTS_DIR existing_entries = load_journal(journal_path) existing_names = {entry.experiment_name for entry in existing_entries} next_index = len(existing_entries) + 1 synced_entries: list[JournalEntry] = [] # Build manifest index once (single rglob) instead of per-experiment manifest_index = _build_manifest_index(runs_dir) for manifest_path in sorted(configs_root.glob("*.json")): manifest_payload = json.loads(manifest_path.read_text()) experiment_name = str(manifest_payload.get("experiment_name") or manifest_path.stem) if experiment_name in existing_names: continue run_entries = manifest_index.get(experiment_name) if not run_entries: continue split_runs = _scan_runs_from_index(run_entries) if not {"train", "valid", "test"}.issubset(split_runs): continue results: dict[str, SplitResult] = {} for split_name in ("train", "valid", "test"): run_id, metrics = split_runs[split_name] results[split_name] = build_split_result(split_name, run_id, metrics) test_metrics = _metrics_from_split_result(results.get("test")) sqs_score = None sqs_breakdown: dict[str, float] = {} sqs_v2_score = None sqs_v2_breakdown: dict[str, float] = {} if test_metrics is not None: sqs_score, sqs_breakdown = compute_sqs(test_metrics) sqs_v2_score, sqs_v2_breakdown = compute_sqs_v2(test_metrics) promotion_score, promotion_breakdown = compute_promotion_score( results.get("test"), results.get("valid"), ) unified_score, unified_breakdown = compute_unified_score( results.get("test"), results.get("valid"), ) rqs_score, rqs_breakdown = compute_rqs( results.get("train"), results.get("valid"), results.get("test"), ) walk_forward_summary = _load_optional_walk_forward_summary(experiment_name) wfqs_score, wfqs_breakdown = compute_wfqs(walk_forward_summary) wfqs_v2_score, wfqs_v2_breakdown = compute_wfqs_v2(walk_forward_summary) deployment_score, deployment_breakdown = compute_deployment_score( results.get("train"), results.get("valid"), results.get("test"), walk_forward_summary, rqs_score=rqs_score, wfqs_score=wfqs_score, ) robustness_matrix_summary = _load_optional_robustness_summary(experiment_name) out_of_time_robustness_summary = _load_optional_out_of_time_robustness_summary(experiment_name) public_sqs, public_breakdown, _ = compute_public_sqs( results.get("train"), results.get("valid"), results.get("test"), walk_forward_summary=walk_forward_summary, robustness_matrix_summary=robustness_matrix_summary, out_of_time_robustness_summary=out_of_time_robustness_summary, rqs_score=rqs_score, wfqs_score=wfqs_v2_score, ) public_sqs_v3, public_breakdown_v3, _ = compute_public_sqs_v3( results.get("train"), results.get("valid"), results.get("test"), walk_forward_summary=walk_forward_summary, robustness_matrix_summary=robustness_matrix_summary, out_of_time_robustness_summary=out_of_time_robustness_summary, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) stress_sqs, stress_breakdown, _ = compute_public_sqs_v2( results.get("train"), results.get("valid"), results.get("test"), walk_forward_summary=walk_forward_summary, robustness_matrix_summary=robustness_matrix_summary, out_of_time_robustness_summary=out_of_time_robustness_summary, rqs_score=rqs_score, wfqs_v2_score=wfqs_v2_score, ) if public_sqs is not None: sqs_score = public_sqs sqs_breakdown = public_breakdown timestamp = utc_now().isoformat() entry = JournalEntry( entry_id=f"IMP-{next_index:04d}", timestamp=timestamp, experiment_name=experiment_name, hypothesis=str(manifest_payload.get("description") or manifest_payload.get("notes") or ""), results=results, walk_forward_summary=walk_forward_summary, robustness_matrix_summary=robustness_matrix_summary, out_of_time_robustness_summary=out_of_time_robustness_summary, sqs_score=sqs_score, sqs_breakdown=sqs_breakdown, sqs_v3_score=public_sqs_v3, sqs_v3_breakdown=public_breakdown_v3, stress_sqs_score=stress_sqs, stress_sqs_breakdown=stress_breakdown, sqs_v2_score=sqs_v2_score, sqs_v2_breakdown=sqs_v2_breakdown, promotion_score=promotion_score, promotion_breakdown=promotion_breakdown, unified_score=unified_score, unified_breakdown=unified_breakdown, rqs_score=rqs_score, rqs_breakdown=rqs_breakdown, wfqs_score=wfqs_score, wfqs_breakdown=wfqs_breakdown, wfqs_v2_score=wfqs_v2_score, wfqs_v2_breakdown=wfqs_v2_breakdown, deployment_score=deployment_score, deployment_breakdown=deployment_breakdown, verdict="unknown", verdict_reasoning="Auto-synced from official manifest.", next_direction="", tags=list(manifest_payload.get("tags") or []), ) append_journal_entry(journal_path, entry) synced_entries.append(entry) existing_names.add(experiment_name) next_index += 1 return synced_entries # --------------------------------------------------------------------------- # Registry / Leaderboard # --------------------------------------------------------------------------- def rebuild_registry( journal_path: Path, registry_path: Path, leaderboard_path: Path, ) -> ExperimentRegistry: """Rebuild experiment_registry.json and LEADERBOARD.md from journal.""" entries = load_journal(journal_path) registry_entries: list[RegistryEntry] = [] for je in entries: strategy_family = classify_strategy_family(je.experiment_name, je.tags) is_retired = is_retired_strategy_family(strategy_family) if je.overlay_common_window_summary is not None: computed_overlay_sqs, _, _ = compute_overlay_public_sqs( je.overlay_common_window_summary, je.overlay_stress_window_summary, ) computed_overlay_stress_sqs, _, _ = compute_overlay_stress_sqs( je.overlay_common_window_summary, je.overlay_stress_window_summary, ) overlay = je.overlay_common_window_summary registry_entries.append( RegistryEntry( entry_id=je.entry_id, experiment_name=je.experiment_name, strategy_family=strategy_family, is_retired=is_retired, sqs_score=computed_overlay_sqs, sqs_v3_score=computed_overlay_sqs, stress_sqs_score=computed_overlay_stress_sqs, common_window_score=None, overlay_common_window_summary=je.overlay_common_window_summary, overlay_stress_window_summary=je.overlay_stress_window_summary, total_return_pct=overlay.return_pct, annualized_return_pct=overlay.annualized_return_pct, sharpe_ratio=overlay.sharpe_ratio, max_drawdown_pct=overlay.max_drawdown_pct, trade_count=0, timestamp=je.timestamp, ) ) continue test_result = _hydrate_split_result(je.results.get("test")) valid_result = _hydrate_split_result(je.results.get("valid")) train_result = _hydrate_split_result(je.results.get("train")) computed_sqs_v2 = None computed_rqs = None computed_rqs_breakdown: dict[str, float] = {} computed_wfqs = None computed_wfqs_breakdown: dict[str, float] = {} computed_deployment = None computed_deployment_breakdown: dict[str, float] = {} test_metrics = _metrics_from_split_result(test_result) if test_metrics is not None: computed_sqs_v2, _ = compute_sqs_v2(test_metrics) computed_promotion_score, _ = compute_promotion_score(test_result, valid_result) computed_unified_score, _ = compute_unified_score(test_result, valid_result) computed_rqs, computed_rqs_breakdown = compute_rqs( train_result, valid_result, test_result, ) computed_wfqs, computed_wfqs_breakdown = compute_wfqs(je.walk_forward_summary) computed_wfqs_v2, _ = compute_wfqs_v2(je.walk_forward_summary) computed_deployment, computed_deployment_breakdown = compute_deployment_score( train_result, valid_result, test_result, je.walk_forward_summary, rqs_score=computed_rqs, wfqs_score=computed_wfqs, ) computed_common_window, computed_common_window_breakdown = compute_common_window_score( je.common_window_summary ) computed_public_sqs_v3, _, _ = compute_public_sqs_v3( train_result, valid_result, test_result, walk_forward_summary=je.walk_forward_summary, robustness_matrix_summary=je.robustness_matrix_summary, out_of_time_robustness_summary=je.out_of_time_robustness_summary, rqs_score=computed_rqs, wfqs_v2_score=computed_wfqs_v2, ) computed_public_sqs, computed_public_breakdown, _ = compute_public_sqs( train_result, valid_result, test_result, walk_forward_summary=je.walk_forward_summary, robustness_matrix_summary=je.robustness_matrix_summary, out_of_time_robustness_summary=je.out_of_time_robustness_summary, common_window_summary=je.common_window_summary, rqs_score=computed_rqs, wfqs_score=computed_wfqs_v2, ) computed_stress_sqs, _, _ = compute_public_sqs_v2( train_result, valid_result, test_result, walk_forward_summary=je.walk_forward_summary, robustness_matrix_summary=je.robustness_matrix_summary, out_of_time_robustness_summary=je.out_of_time_robustness_summary, rqs_score=computed_rqs, wfqs_v2_score=computed_wfqs_v2, ) canonical_sqs = computed_public_sqs registry_entries.append( RegistryEntry( entry_id=je.entry_id, experiment_name=je.experiment_name, strategy_family=strategy_family, is_retired=is_retired, sqs_score=canonical_sqs, sqs_v3_score=computed_public_sqs_v3, stress_sqs_score=computed_stress_sqs, sqs_v2_score=je.sqs_v2_score if je.sqs_v2_score is not None else computed_sqs_v2, promotion_score=( je.promotion_score if je.promotion_score is not None else computed_promotion_score ), unified_score=( je.unified_score if je.unified_score is not None else computed_unified_score ), rqs_score=computed_rqs if computed_rqs is not None else je.rqs_score, wfqs_score=computed_wfqs if computed_wfqs is not None else je.wfqs_score, wfqs_v2_score=computed_wfqs_v2 if computed_wfqs_v2 is not None else je.wfqs_v2_score, deployment_score=computed_deployment if computed_deployment is not None else je.deployment_score, common_window_score=( computed_common_window if computed_common_window is not None else je.common_window_score ), common_window_summary=je.common_window_summary, walk_forward_summary=je.walk_forward_summary, robustness_matrix_summary=je.robustness_matrix_summary, out_of_time_robustness_summary=je.out_of_time_robustness_summary, train_total_return_pct=train_result.total_return_pct if train_result else None, train_annualized_return_pct=train_result.annualized_return_pct if train_result else None, profit_factor=test_result.profit_factor if test_result else None, total_return_pct=test_result.total_return_pct if test_result else None, annualized_return_pct=test_result.annualized_return_pct if test_result else None, win_rate=test_result.win_rate if test_result else None, sharpe_ratio=test_result.sharpe_ratio if test_result else None, max_drawdown_pct=test_result.max_drawdown_pct if test_result else None, trade_count=test_result.trade_count if test_result else 0, avg_gross_exposure_pct=test_result.avg_gross_exposure_pct if test_result else None, avg_net_exposure_pct=test_result.avg_net_exposure_pct if test_result else None, days_in_market_pct=test_result.days_in_market_pct if test_result else None, valid_profit_factor=valid_result.profit_factor if valid_result else None, valid_total_return_pct=valid_result.total_return_pct if valid_result else None, valid_annualized_return_pct=valid_result.annualized_return_pct if valid_result else None, valid_win_rate=valid_result.win_rate if valid_result else None, valid_sharpe_ratio=valid_result.sharpe_ratio if valid_result else None, valid_max_drawdown_pct=valid_result.max_drawdown_pct if valid_result else None, valid_trade_count=valid_result.trade_count if valid_result else 0, valid_avg_gross_exposure_pct=valid_result.avg_gross_exposure_pct if valid_result else None, valid_avg_net_exposure_pct=valid_result.avg_net_exposure_pct if valid_result else None, valid_days_in_market_pct=valid_result.days_in_market_pct if valid_result else None, timestamp=je.timestamp, ) ) # Default registry / markdown ordering is public SQS v3 descending. registry_entries.sort( key=lambda e: ( -(e.sqs_score or 0.0), e.stress_sqs_score is None, -(e.stress_sqs_score or 0.0), e.deployment_score is None, -(e.deployment_score or 0.0), e.rqs_score is None, -(e.rqs_score or 0.0), e.promotion_score is None, -(e.promotion_score or 0.0), ) ) registry = ExperimentRegistry( entries=registry_entries, updated_at=utc_now().isoformat(), ) # Write registry JSON registry_path.parent.mkdir(parents=True, exist_ok=True) registry_path.write_text(registry.model_dump_json(indent=2)) # Write LEADERBOARD.md and OVERLAY_LEADERBOARD.md _write_leaderboard_md(leaderboard_path, registry, entries) _write_overlay_leaderboard_md(leaderboard_path.parent / "OVERLAY_LEADERBOARD.md", registry, entries) logger.info("registry_rebuilt", count=len(registry_entries)) return registry def _write_leaderboard_md( path: Path, registry: ExperimentRegistry, journal_entries: list[JournalEntry], ) -> None: visible_entries = filter_registry_entries( registry.entries, include_retired=False, include_overlays=False, ) visible_recent = [ entry for entry in reversed(journal_entries) if ( not _is_retired_journal_entry(entry) and _is_complete_journal_entry(entry) and classify_strategy_family(entry.experiment_name, entry.tags) != "overlay" ) ][:5] lines: list[str] = [] lines.append("# Strategy Improvement Leaderboard") lines.append(f"_Updated: {registry.updated_at}_\n") lines.append("_Default view excludes overlay/book-of-books rows, retired legacy PEAD / short-core / exact-pocket families, and incomplete train-only scans. Use `fithia2 lb --overlay-only` for overlays or `fithia2 lb --include-retired` to inspect archived research._\n") lines.append("_`SQS` below is public SQS v4: v3 deployment/WFV-first ranking plus a modest common-window capital-growth term when available. Stress OOT remains an eligibility gate. Prior v3 values remain in `experiment_registry.json` as `sqs_v3_score`._\n") lines.append("| # | Experiment | SQS | [Tr]Ret% | [V]Ret% | [T]Ret% | [T]Ann% | [T]DD% | [T]Gross% | [T]DIM% | [T]R/G | Date |") lines.append("|---|-----------|-----|----------|----------|----------|----------|--------|------------|----------|---------|------|") for rank, e in enumerate(visible_entries, 1): trret = f"{e.train_total_return_pct:+.1f}" if e.train_total_return_pct is not None else "-" vret = f"{e.valid_total_return_pct:+.1f}" if e.valid_total_return_pct is not None else "-" ret = f"{e.total_return_pct:+.1f}" if e.total_return_pct is not None else "-" ann = f"{e.annualized_return_pct:+.1f}" if e.annualized_return_pct is not None else "-" dd = f"{e.max_drawdown_pct:.1f}" if e.max_drawdown_pct is not None else "-" gross = f"{e.avg_gross_exposure_pct:.1f}" if e.avg_gross_exposure_pct is not None else "-" dim = f"{e.days_in_market_pct:.1f}" if e.days_in_market_pct is not None else "-" ret_on_gross = "-" if ( e.total_return_pct is not None and e.avg_gross_exposure_pct is not None and e.avg_gross_exposure_pct > 0 ): ret_on_gross = f"{e.total_return_pct / e.avg_gross_exposure_pct:.2f}" ts = e.timestamp[:10] if e.timestamp else "-" lines.append( f"| {rank} | {e.experiment_name} | {e.sqs_score:.1f}" f" | {trret} | {vret} | {ret} | {ann} | {dd} | {gross} | {dim} | {ret_on_gross} | {ts} |" ) # Recent entries (last 5) if visible_recent: registry_by_id = {entry.entry_id: entry for entry in registry.entries} lines.append("\n## Recent Entries") for je in visible_recent: canonical_sqs = registry_by_id.get(je.entry_id).sqs_score if je.entry_id in registry_by_id else je.sqs_score stress_sqs = ( registry_by_id.get(je.entry_id).stress_sqs_score if je.entry_id in registry_by_id else je.stress_sqs_score ) lines.append(f"### {je.entry_id} ({je.timestamp[:10]}) \u2014 {je.experiment_name}") lines.append(f"Hypothesis: {je.hypothesis}") stress_suffix = f", Stress SQS {stress_sqs:.1f}" if stress_sqs is not None else "" lines.append(f"Verdict: **{je.verdict.upper()}** (SQS {canonical_sqs}{stress_suffix})") if je.verdict_reasoning: lines.append(f"Reasoning: {je.verdict_reasoning}") if je.next_direction: lines.append(f"Next: {je.next_direction}") lines.append("") path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(lines) + "\n") def _write_overlay_leaderboard_md( path: Path, registry: ExperimentRegistry, journal_entries: list[JournalEntry], ) -> None: visible_entries = filter_overlay_registry_entries(registry.entries, include_retired=False) visible_recent = [ entry for entry in reversed(journal_entries) if ( not _is_retired_journal_entry(entry) and _is_complete_journal_entry(entry) and classify_strategy_family(entry.experiment_name, entry.tags) == "overlay" ) ][:5] lines: list[str] = [] lines.append("# Overlay Strategy Leaderboard") lines.append(f"_Updated: {registry.updated_at}_\n") lines.append("_This board is separate from the default single-book leaderboard. Overlay rows are book-of-books evaluations and are not directly comparable to single-book `SQS` rows._\n") lines.append("_`SQS` here is overlay official SQS: common-window overlay score with a stress OOT gate. `T.*` columns are common-window overlay metrics._\n") lines.append("| # | Overlay | SQS | [T]Ret% | [T]Ann% | [T]DD% | Stress Ret% | Stress DD% | Stress Sharpe | Date |") lines.append("|---|---------|-----|----------|----------|--------|-------------|------------|---------------|------|") for rank, e in enumerate(visible_entries, 1): stress = e.overlay_stress_window_summary ts = e.timestamp[:10] if e.timestamp else "-" lines.append( f"| {rank} | {e.experiment_name} | {e.sqs_score:.1f}" f" | {e.total_return_pct:+.1f} | {e.annualized_return_pct:+.1f} | {e.max_drawdown_pct:.1f}" f" | {stress.return_pct:+.1f if stress and stress.return_pct is not None else '-'}" f" | {stress.max_drawdown_pct:.1f if stress and stress.max_drawdown_pct is not None else '-'}" f" | {stress.sharpe_ratio:+.2f if stress and stress.sharpe_ratio is not None else '-'}" f" | {ts} |" ) if visible_recent: lines.append("\n## Recent Overlay Entries") registry_by_id = {entry.entry_id: entry for entry in registry.entries} for je in visible_recent: canonical_sqs = registry_by_id.get(je.entry_id).sqs_score if je.entry_id in registry_by_id else je.sqs_score lines.append(f"### {je.entry_id} ({je.timestamp[:10]}) — {je.experiment_name}") lines.append(f"Hypothesis: {je.hypothesis}") lines.append(f"Verdict: **{je.verdict.upper()}** (SQS {canonical_sqs})") if je.verdict_reasoning: lines.append(f"Reasoning: {je.verdict_reasoning}") if je.next_direction: lines.append(f"Next: {je.next_direction}") lines.append("") path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(lines) + "\n") # --------------------------------------------------------------------------- # Run scanning helpers (for CLI record command) # --------------------------------------------------------------------------- def scan_runs_for_experiment( runs_dir: Path, experiment_name: str, ) -> dict[str, tuple[str, MetricsBundle]]: """Scan runs_dir for runs matching experiment_name. Returns dict of split_name -> (run_id, MetricsBundle). Uses manifest.json for experiment matching, metadata.json for split_name, falls back to time-ordered grouping by config hash. """ from libs.backtest.domain import MetricsBundle matches: list[tuple[Path, str, str, str, str | None]] = [] # (run_dir, run_id, config_hash, started_at, split) for manifest_file in sorted(runs_dir.rglob("manifest.json")): run_path = manifest_file.parent manifest_data = json.loads(manifest_file.read_text()) if manifest_data.get("experiment_name") != experiment_name: continue metadata_file = run_path / "metadata.json" metadata = json.loads(metadata_file.read_text()) if metadata_file.exists() else {} run_id = metadata.get("run_id", run_path.name) started_at = str(metadata.get("started_at") or metadata.get("finished_at") or "") split_name = metadata.get("split_name") # Extract config hash from run_id (last segment after underscore) parts = run_path.name.split("_") config_hash = parts[-1] if len(parts) >= 2 else "" matches.append((run_path, run_id, config_hash, started_at, split_name)) if not matches: return {} results: dict[str, tuple[str, MetricsBundle]] = {} # Check for split_name in metadata first has_split_names = False for _, _, _, _, split_name in matches: if split_name is not None: has_split_names = True break canonical_splits = {"train", "valid", "test"} if has_split_names: canonical_matches = [ (run_path, run_id, config_hash, started_at, split_name) for run_path, run_id, config_hash, started_at, split_name in matches if split_name in canonical_splits ] if not canonical_matches: return {} latest_by_split: dict[str, tuple[str, Path, str]] = {} for run_path, run_id, _, started_at, split_name in canonical_matches: split = split_name or "unknown" current = latest_by_split.get(split) if current is None or started_at >= current[0]: latest_by_split[split] = (started_at, run_path, run_id) for split, (_, run_path, run_id) in latest_by_split.items(): metrics_file = run_path / "metrics" / "metrics_summary.json" if metrics_file.exists(): metrics = MetricsBundle.model_validate_json(metrics_file.read_text()) results[split] = (run_id, metrics) else: # Fallback: group by config_hash, then assign train/valid/test by time order from collections import defaultdict groups: dict[str, list[tuple[str, Path, str]]] = defaultdict(list) for run_path, run_id, config_hash, started_at, _ in matches: groups[config_hash].append((started_at, run_path, run_id)) # Use the largest group (most likely the 3-split set) biggest_group = max(groups.values(), key=len) if groups else [] split_names = ["train", "valid", "test"] for i, (_, run_path, run_id) in enumerate(sorted(biggest_group)): split = split_names[i] if i < len(split_names) else f"extra_{i}" metrics_file = run_path / "metrics" / "metrics_summary.json" if metrics_file.exists(): metrics = MetricsBundle.model_validate_json(metrics_file.read_text()) results[split] = (run_id, metrics) return results def check_duplicate(journal_path: Path, experiment_name: str) -> list[JournalEntry]: """Check if experiment_name already exists in journal.""" entries = load_journal(journal_path) return [e for e in entries if e.experiment_name == experiment_name]