175 lines
5.8 KiB
Python
175 lines
5.8 KiB
Python
"""
|
|
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)
|