Files
Chan/ChanMacro/validation/regime_validator.py
T
jackyu66gitandClaude 71951019fb chanmacro: Signal Expectancy Engine V1 — Market Memory System
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>
2026-06-24 17:44:55 +08:00

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)