""" validation/regime_validator.py — Validates factors as regime separators. Tests: Mutual Information, KL Divergence, ANOVA F-score. Answers: "Does this factor distinguish different market regimes?" Key insight: a factor may have low IC (poor return predictor) but high regime separation (good regime classifier). Breadth is the prime example. """ from datetime import date as Date from typing import Optional import sqlite3 import logging import numpy as np import pandas as pd from config import config from .metrics import ( mutual_information, kl_divergence, anova_f_score, ) logger = logging.getLogger(__name__) class RegimeReport: """Structured report for regime separation validation.""" def __init__(self, factor_name: str): self.factor_name = factor_name self.mutual_info: float = 0.0 self.anova_f: float = 0.0 self.kl_pairs: dict = {} # (regime_a, regime_b) → KL divergence self.best_separates: list[str] = [] self.separation_score: float = 0.0 self.is_regime_factor: bool = False self.conclusion: str = "" def summary(self) -> str: lines = [ f"Factor: {self.factor_name}", f" Mutual Information: {self.mutual_info:.4f}", f" ANOVA F: {self.anova_f:.1f}", f" Best separates: {', '.join(self.best_separates) if self.best_separates else 'none'}", f" Regime Factor: {'YES' if self.is_regime_factor else 'No'}", f" → {self.conclusion}", ] return "\n".join(lines) class RegimeValidator: """ Validates a factor's ability to separate different market regimes. A good regime factor has: - Mutual Information > 0.1 - KL Divergence between regimes > 0.5 - ANOVA F-score high """ def __init__(self, db_path: Optional[str] = None): self.db_path = db_path or config.db_path def validate(self, factor_name: str, factor_scores: pd.Series, regime_labels: pd.Series) -> RegimeReport: """ Args: factor_name: Human-readable name factor_scores: Series indexed by date, values 0-100 regime_labels: Series indexed by date, values = 'TREND'/'RANGE'/'PANIC' """ report = RegimeReport(factor_name) # Align common_idx = factor_scores.index.intersection(regime_labels.index) if len(common_idx) < 30: report.conclusion = "INSUFFICIENT DATA" return report f = factor_scores[common_idx] labels = regime_labels[common_idx] # Mutual Information report.mutual_info = round(mutual_information(f, labels), 4) # ANOVA report.anova_f = round(anova_f_score(f, labels), 1) # KL Divergence between each pair of regimes unique_regimes = sorted(labels.unique()) for i, ra in enumerate(unique_regimes): for rb in unique_regimes[i + 1:]: kl = kl_divergence(f, labels, ra, rb) report.kl_pairs[f"{ra}↔{rb}"] = round(kl, 4) # Best separation if report.kl_pairs: sorted_pairs = sorted(report.kl_pairs, key=report.kl_pairs.get, reverse=True) report.best_separates = sorted_pairs[:2] # Separation score (0-1 composite) mi_norm = min(report.mutual_info / 0.5, 1.0) kl_avg = np.mean(list(report.kl_pairs.values())) if report.kl_pairs else 0 kl_norm = min(kl_avg / 1.0, 1.0) report.separation_score = round(0.5 * mi_norm + 0.5 * kl_norm, 2) # Is this a good regime factor? report.is_regime_factor = ( report.mutual_info > 0.1 and kl_avg > 0.5 ) if report.separation_score > 0.8: report.conclusion = "EXCELLENT regime separator" elif report.separation_score > 0.5: report.conclusion = "GOOD regime separator" elif report.separation_score > 0.3: report.conclusion = "MODERATE — some regime separation" else: report.conclusion = "WEAK regime separator" return report def validate_from_db(self, factor_name: str, score_query: str) -> RegimeReport: """Load scores and regime labels from DB, then validate.""" conn = sqlite3.connect(self.db_path) scores_df = pd.read_sql_query(score_query, conn) regimes_df = pd.read_sql_query( "SELECT date, regime FROM regime_history", conn ) conn.close() if scores_df.empty or regimes_df.empty: r = RegimeReport(factor_name) r.conclusion = "NO DATA" return r scores = scores_df.set_index("date")["score"] regimes = regimes_df.set_index("date")["regime"] return self.validate(factor_name, scores, regimes)