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>
145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
"""
|
|
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)
|