""" validation/factor_validator.py — Validates a factor's predictive power. Tests: IC, ICIR, Hit Ratio, Quantile Spread, Lead-Lag analysis. Answers: "Does this factor predict future returns?" """ 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 ( information_coefficient, icir, hit_ratio, quantile_spread, lead_lag_ic, ) logger = logging.getLogger(__name__) class FactorReport: """Structured report for a single factor's validation results.""" def __init__(self, factor_name: str): self.factor_name = factor_name self.ic_mean: float = 0.0 self.ic_std: float = 0.0 self.icir: float = 0.0 self.hit_ratio: float = 0.0 self.quantile_spread: float = 0.0 self.is_leading: bool = False self.lead_days: int = 0 self.lead_ic: float = 0.0 self.n_observations: int = 0 self.conclusion: str = "" def summary(self) -> str: lines = [ f"Factor: {self.factor_name}", f" N={self.n_observations}", f" IC mean={self.ic_mean:.4f} std={self.ic_std:.4f} ICIR={self.icir:.2f}", f" Hit Ratio={self.hit_ratio:.1%} Top-Bot Spread={self.quantile_spread:.4f}", f" Best Lead: {self.lead_days}d (IC={self.lead_ic:.4f})" if self.is_leading else " Leading: No (synchronous/lagging)", f" → {self.conclusion}", ] return "\n".join(lines) class FactorValidator: """ Validates a factor's predictive power using standard quant metrics. For each forward horizon (1d, 3d, 5d, 7d, 14d), computes: - IC (Spearman rank correlation) - ICIR (IC stability) - Hit Ratio (direction accuracy) - Quantile spread (top vs bottom bucket) - Lead-lag profile A factor is valid if IC > 0.03 and ICIR > 0.5. For regime factors, also check regime_validator. """ 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, forward_returns: dict[str, pd.Series]) -> FactorReport: """ Args: factor_name: Human-readable name factor_scores: Series indexed by date, values 0-100 forward_returns: Dict of horizon → Series indexed by date (e.g. "1d" → returns) """ report = FactorReport(factor_name) # Align series to common dates common_idx = factor_scores.index for ret in forward_returns.values(): common_idx = common_idx.intersection(ret.index) if len(common_idx) < 30: report.conclusion = "INSUFFICIENT DATA (< 30 observations)" return report f = factor_scores[common_idx] report.n_observations = len(common_idx) # Test against 7d forward returns (primary horizon) primary_ret = forward_returns.get("7d") if primary_ret is None: # Use first available primary_ret = list(forward_returns.values())[0] r = primary_ret[common_idx] # IC ic = information_coefficient(f, r) report.ic_mean = round(ic, 4) # Rolling IC for ICIR rolling_ics = [] for i in range(30, len(f)): ic_i = information_coefficient(f.iloc[:i], r.iloc[:i]) rolling_ics.append(ic_i) ic_series = pd.Series(rolling_ics) report.ic_std = round(ic_series.std(), 4) report.icir = round(icir(ic_series), 2) # Hit ratio report.hit_ratio = round(hit_ratio(f, r), 4) # Quantile spread report.quantile_spread = round(quantile_spread(f, r), 4) # Lead-lag lead = lead_lag_ic(f, r, max_lag=14) report.is_leading = lead["is_leading"] report.lead_days = lead["lead_days"] report.lead_ic = round(lead["best_ic"], 4) # Conclusion if abs(report.ic_mean) > 0.05 and report.icir > 1.0: report.conclusion = "STRONG: significant predictive power" elif abs(report.ic_mean) > 0.03 and report.icir > 0.5: report.conclusion = "VALID: moderate predictive power" elif abs(report.ic_mean) < 0.02: report.conclusion = "CONFIRMING: describes current state, not predictive" else: report.conclusion = "WEAK: borderline, monitor or downweight" return report def validate_from_db(self, factor_name: str, score_query: str, horizon_days: int = 7) -> FactorReport: """ Convenience: load scores from DB and OHLCV returns, then validate. score_query: SQL that returns (date, score) pairs. """ conn = sqlite3.connect(self.db_path) scores_df = pd.read_sql_query(score_query, conn) if scores_df.empty: conn.close() r = FactorReport(factor_name) r.conclusion = "NO DATA" return r scores_df["date"] = pd.to_datetime(scores_df["date"]) scores = scores_df.set_index("date")["score"] # Load forward returns from OHLCV ohlcv = pd.read_sql_query( "SELECT date, close FROM ohlcv_daily WHERE symbol='BTC/USDT:USDT' ORDER BY date", conn ) conn.close() ohlcv["date"] = pd.to_datetime(ohlcv["date"]) ohlcv = ohlcv.set_index("date") ohlcv["ret"] = ohlcv["close"].pct_change().shift(-1) # forward 1d # Build forward returns for multiple horizons forward = {} for h in [1, 3, 5, 7, 14]: forward[str(h) + "d"] = ohlcv["close"].pct_change(periods=h).shift(-h) return self.validate(factor_name, scores, forward)