Phase A-C complete: 4 core factors, regime detection, signal tracking, Bayesian expectancy. chanmacro/ (32 files, ~4000 lines): - models: 12 enums + 15 Pydantic v2 models (DateAwareModel, MarketStateVector, etc.) - fetchers: OHLCV + Breadth (from data_provider) + Derivatives (new endpoint) - scoring: Price Structure / Breadth (quantile buckets) / OI Matrix (5 discrete states) / Volatility Regime - regime_detector: 3-state (TREND/RANGE/PANIC), factor-locked (Price+Breadth+Vol), versioned, 2-day confirmation - expectancy: SignalTracker (record+outcomes), TimeDecay (half-life=180d), BayesianExpectancyEngine (Empirical Bayes, Leveled, SufficiencyGuard) - validation: FactorValidator (IC/ICIR/Hit Ratio), RegimeValidator (MI/KL/ANOVA), TransitionValidator (stability) - CLI: fetch|score|regime|track|backfill|expectancy|validate|serve - tests: 52 passing (models, scoring, regime, expectancy) data_provider: - /api/derivatives endpoint: funding rate, OI, OI change, basis - _derivatives storage: same persist pattern as K-line (merge→lock→snapshot→atomic write) - background refresh every 60s Co-Authored-By: Claude <noreply@anthropic.com>
193 lines
6.6 KiB
Python
193 lines
6.6 KiB
Python
"""
|
|
validation/metrics.py — Shared statistical metrics for factor and regime validation.
|
|
"""
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from scipy import stats
|
|
from typing import Optional
|
|
|
|
|
|
def information_coefficient(factor: pd.Series, forward_returns: pd.Series) -> float:
|
|
"""Spearman rank IC between factor values and forward returns."""
|
|
mask = factor.notna() & forward_returns.notna()
|
|
if mask.sum() < 10:
|
|
return 0.0
|
|
ic, _ = stats.spearmanr(factor[mask], forward_returns[mask])
|
|
return float(ic) if not np.isnan(ic) else 0.0
|
|
|
|
|
|
def icir(ic_series: pd.Series) -> float:
|
|
"""Information Coefficient IR = mean(IC) / std(IC)."""
|
|
if len(ic_series) < 5 or ic_series.std() == 0:
|
|
return 0.0
|
|
return float(ic_series.mean() / ic_series.std())
|
|
|
|
|
|
def hit_ratio(factor: pd.Series, forward_returns: pd.Series) -> float:
|
|
"""Fraction of times factor direction matches return direction."""
|
|
mask = factor.notna() & forward_returns.notna()
|
|
if mask.sum() < 10:
|
|
return 0.5
|
|
# Compare sign of factor deviation from median vs sign of returns
|
|
factor_median = factor[mask].median()
|
|
factor_sign = np.sign(factor[mask] - factor_median)
|
|
return_sign = np.sign(forward_returns[mask])
|
|
return float((factor_sign == return_sign).mean())
|
|
|
|
|
|
def quantile_spread(factor: pd.Series, forward_returns: pd.Series,
|
|
n_quantiles: int = 5) -> float:
|
|
"""Top vs bottom quantile return spread (分层回测)."""
|
|
mask = factor.notna() & forward_returns.notna()
|
|
if mask.sum() < n_quantiles * 3:
|
|
return 0.0
|
|
f = factor[mask]
|
|
r = forward_returns[mask]
|
|
labels = pd.qcut(f, n_quantiles, labels=False, duplicates="drop")
|
|
top_ret = r[labels == labels.max()].mean()
|
|
bot_ret = r[labels == labels.min()].mean()
|
|
return float(top_ret - bot_ret)
|
|
|
|
|
|
def lead_lag_ic(factor: pd.Series, returns: pd.Series,
|
|
max_lag: int = 14) -> dict:
|
|
"""Find the best leading/trailing relationship by computing IC at each lag."""
|
|
results = {}
|
|
for lag in range(-max_lag, max_lag + 1):
|
|
if lag < 0:
|
|
shifted = factor.shift(abs(lag))
|
|
ic = information_coefficient(shifted, returns)
|
|
results[f"lead_{abs(lag)}d"] = ic
|
|
elif lag > 0:
|
|
shifted = returns.shift(lag)
|
|
ic = information_coefficient(factor, shifted)
|
|
results[f"lag_{lag}d"] = ic
|
|
else:
|
|
ic = information_coefficient(factor, returns)
|
|
results["sync"] = ic
|
|
|
|
# Find best lead period
|
|
lead_ics = {k: v for k, v in results.items() if k.startswith("lead_")}
|
|
best_lead = max(lead_ics, key=lead_ics.get) if lead_ics else "sync"
|
|
best_ic = lead_ics.get(best_lead, results.get("sync", 0))
|
|
|
|
return {
|
|
"best_lead": best_lead,
|
|
"best_ic": best_ic,
|
|
"ic_curve": results,
|
|
"is_leading": best_lead.startswith("lead_") and abs(best_ic) > 0.03,
|
|
"lead_days": int(best_lead.split("_")[1].rstrip("d")) if best_lead.startswith("lead_") else 0,
|
|
}
|
|
|
|
|
|
def mutual_information(factor: pd.Series, labels: pd.Series,
|
|
n_bins: int = 10) -> float:
|
|
"""Mutual information between factor (binned) and discrete regime labels."""
|
|
mask = factor.notna() & labels.notna()
|
|
if mask.sum() < 20:
|
|
return 0.0
|
|
f = factor[mask]
|
|
l = labels[mask]
|
|
try:
|
|
f_binned = pd.qcut(f, n_bins, labels=False, duplicates="drop")
|
|
except ValueError:
|
|
f_binned = pd.cut(f, n_bins, labels=False)
|
|
mi = 0.0
|
|
for fi in range(n_bins):
|
|
p_f = (f_binned == fi).mean()
|
|
if p_f == 0:
|
|
continue
|
|
for li in l.unique():
|
|
p_l = (l == li).mean()
|
|
p_joint = ((f_binned == fi) & (l == li)).mean()
|
|
if p_joint > 0:
|
|
mi += p_joint * np.log(p_joint / (p_f * p_l))
|
|
return float(mi)
|
|
|
|
|
|
def kl_divergence(factor: pd.Series, labels: pd.Series,
|
|
regime_a: str, regime_b: str, n_bins: int = 10) -> float:
|
|
"""KL divergence between factor distributions in two regimes."""
|
|
mask_a = (labels == regime_a) & factor.notna()
|
|
mask_b = (labels == regime_b) & factor.notna()
|
|
if mask_a.sum() < 10 or mask_b.sum() < 10:
|
|
return 0.0
|
|
try:
|
|
hist_a, edges = np.histogram(factor[mask_a], bins=n_bins, density=True)
|
|
hist_b, _ = np.histogram(factor[mask_b], bins=edges, density=True)
|
|
except ValueError:
|
|
return 0.0
|
|
hist_a = np.clip(hist_a, 1e-10, None)
|
|
hist_b = np.clip(hist_b, 1e-10, None)
|
|
return float((hist_a * np.log(hist_a / hist_b)).sum())
|
|
|
|
|
|
def anova_f_score(factor: pd.Series, labels: pd.Series) -> float:
|
|
"""ANOVA F-statistic: how well factor separates different regimes."""
|
|
mask = factor.notna() & labels.notna()
|
|
if mask.sum() < 20:
|
|
return 0.0
|
|
groups = [factor[mask][labels[mask] == lbl] for lbl in labels[mask].unique()]
|
|
groups = [g for g in groups if len(g) > 1]
|
|
if len(groups) < 2:
|
|
return 0.0
|
|
f_stat, _ = stats.f_oneway(*groups)
|
|
return float(f_stat) if not np.isnan(f_stat) else 0.0
|
|
|
|
|
|
def transition_matrix(labels: pd.Series) -> pd.DataFrame:
|
|
"""Compute Markov transition matrix from regime sequence."""
|
|
unique = sorted(labels.dropna().unique())
|
|
n = len(unique)
|
|
matrix = np.zeros((n, n))
|
|
seq = labels.dropna().values
|
|
for i in range(len(seq) - 1):
|
|
from_idx = unique.index(seq[i])
|
|
to_idx = unique.index(seq[i + 1])
|
|
matrix[from_idx][to_idx] += 1
|
|
|
|
# Row-normalize
|
|
row_sums = matrix.sum(axis=1, keepdims=True)
|
|
row_sums[row_sums == 0] = 1
|
|
matrix = matrix / row_sums
|
|
|
|
return pd.DataFrame(matrix, index=unique, columns=unique)
|
|
|
|
|
|
def regime_duration_stats(labels: pd.Series) -> dict:
|
|
"""Compute average duration, flip rate, state entropy for regime sequence."""
|
|
seq = labels.dropna().values
|
|
if len(seq) < 2:
|
|
return {"avg_duration": 0, "flip_rate": 0, "state_entropy": 0, "n_days": len(seq)}
|
|
|
|
# Count durations
|
|
durations = []
|
|
current = seq[0]
|
|
count = 1
|
|
flips = 0
|
|
for i in range(1, len(seq)):
|
|
if seq[i] == current:
|
|
count += 1
|
|
else:
|
|
durations.append(count)
|
|
current = seq[i]
|
|
count = 1
|
|
flips += 1
|
|
durations.append(count)
|
|
|
|
avg_dur = float(np.mean(durations)) if durations else 0
|
|
flip_rate = flips / len(seq)
|
|
|
|
# State entropy
|
|
_, counts = np.unique(seq, return_counts=True)
|
|
probs = counts / counts.sum()
|
|
entropy = float(-(probs * np.log2(probs + 1e-10)).sum())
|
|
|
|
return {
|
|
"avg_duration": round(avg_dur, 1),
|
|
"flip_rate": round(flip_rate, 3),
|
|
"state_entropy": round(entropy, 3),
|
|
"n_days": len(seq),
|
|
}
|