Files
Chan/ChanMacro/regime_detector.py
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

214 lines
8.0 KiB
Python

"""
regime_detector.py — Market regime detection (V1: 3 states).
★ FACTOR-LOCKED: Regime = f(Price Structure, Breadth, Volatility) — forever.
Fear, Liquidation, ETF, Funding are Context, NOT regime inputs.
Adding new factors MUST NOT change regime definition.
★ VERSIONED: regime_version = 'v1_price_breadth_vol'.
Weight changes → new version. Multiple versions coexist.
Query: WHERE regime_version = 'v1_price_breadth_vol'.
★ CONFIDENCE-BASED: Each regime gets a continuous score. Highest wins.
No hard thresholds (prevents boundary oscillation).
"""
from datetime import date as Date
from typing import Optional
from collections import deque
from models import MarketRegime, RegimeResult
from config import config
class RegimeDetector:
"""
Detects market regime from Price + Breadth + Vol.
V1: 3 regimes (TREND / RANGE / PANIC)
V2+: Can split TREND→TREND_UP/TREND_DOWN/EUPHORIA when samples > 500/regime.
"""
def __init__(self, regime_version: Optional[str] = None):
self.version = regime_version or config.regime_version
self.w_price = config.regime_w_price
self.w_breadth = config.regime_w_breadth
self.w_vol = config.regime_w_vol
self.panic_w_anti_trend = config.regime_panic_w_anti_trend
self.panic_w_vol_extreme = config.regime_panic_w_vol_extreme
# State persistence
self._current_regime: Optional[MarketRegime] = None
self._pending_regime: Optional[MarketRegime] = None
self._confirmation_count: int = 0
self._consecutive_days: int = 0
self._regime_history: deque = deque(maxlen=100)
# Confirmation: 2 days minimum
self.MIN_CONFIRMATION = 2
def load_state(self, db_path: str):
"""Restore regime state from the most recent regime_history record."""
import sqlite3
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT regime, confidence, confirmation_days, maturity_score "
"FROM regime_history ORDER BY date DESC LIMIT 1"
).fetchone()
conn.close()
if row:
regime_str = row["regime"]
if regime_str in ("TREND", "RANGE", "PANIC"):
self._current_regime = MarketRegime(regime_str)
self._consecutive_days = row["confirmation_days"] or 1
except Exception:
pass # DB not initialized yet, use defaults
def detect(self, price_structure_score: float, breadth_score: float,
volatility_regime: str, date: Date) -> RegimeResult:
"""
Detect regime from the 3 locked factors.
Args:
price_structure_score: 0-100 from PriceStructureScorer
breadth_score: 0-100 from BreadthScorer
volatility_regime: 'LOW_VOL'/'NORMAL_VOL'/'HIGH_VOL'/'EXPLOSIVE_VOL'
date: Target date
"""
# ── Compute regime scores ────────────────────────
# TREND: strong price + strong breadth + non-extreme vol
trend_score = (
price_structure_score * self.w_price +
breadth_score * self.w_breadth +
self._vol_to_trend(volatility_regime) * self.w_vol
)
# RANGE: neutral price + neutral breadth + low vol
# Score how "range-like" each dimension is
price_neutral = 60 - abs(price_structure_score - 50)
breadth_neutral = 60 - abs(breadth_score - 50)
vol_neutral = 80 if volatility_regime in ("LOW_VOL", "NORMAL_VOL") else 30
range_score = (
price_neutral * 0.40 +
breadth_neutral * 0.40 +
vol_neutral * 0.20
)
# PANIC: very weak trend + extreme vol (NO Fear/Liquidation!)
anti_trend = 100 - trend_score
vol_extreme = 100 if volatility_regime == "EXPLOSIVE_VOL" else (
60 if volatility_regime == "HIGH_VOL" else 20
)
panic_score = (
anti_trend * self.panic_w_anti_trend +
vol_extreme * self.panic_w_vol_extreme
)
scores = {
MarketRegime.TREND: round(trend_score, 1),
MarketRegime.RANGE: round(range_score, 1),
MarketRegime.PANIC: round(panic_score, 1),
}
best_regime = max(scores, key=scores.get)
# ── Persistence check ────────────────────────────
prior_regime = self._current_regime
if best_regime == self._current_regime:
self._consecutive_days += 1
self._pending_regime = None
self._confirmation_count = 0
elif best_regime == self._pending_regime:
self._confirmation_count += 1
if self._confirmation_count >= self.MIN_CONFIRMATION:
# Transition confirmed
prior_regime = self._current_regime
self._current_regime = best_regime
self._consecutive_days = self.MIN_CONFIRMATION
self._pending_regime = None
self._confirmation_count = 0
else:
self._pending_regime = best_regime
self._confirmation_count = 1
# Fallback: if no current regime yet (first run)
if self._current_regime is None:
self._current_regime = best_regime
self._consecutive_days = 1
# ── Confidence: for the CONFIRMED regime, not the raw best ──
confirmed_regime = self._current_regime
confidence = scores[confirmed_regime] / 100.0
# ── Maturity ─────────────────────────────────────
maturity = self._compute_maturity(
trend_score, breadth_score, volatility_regime
)
# Track history
self._regime_history.append({
"date": date,
"regime": confirmed_regime.value,
"confidence": round(confidence, 3),
})
return RegimeResult(
date=date,
regime=confirmed_regime,
confidence=round(confidence, 3),
prior_regime=prior_regime,
regime_version=self.version,
maturity_score=round(maturity, 1),
all_scores={k.value: v for k, v in scores.items()},
confirmation_days=self._consecutive_days,
)
@property
def current_regime(self) -> Optional[MarketRegime]:
return self._current_regime
@property
def pending_regime(self) -> Optional[MarketRegime]:
return self._pending_regime
@property
def confirmation_progress(self) -> tuple[int, int]:
"""(confirmed_days, required_days) for pending transition."""
return (self._confirmation_count, self.MIN_CONFIRMATION)
@staticmethod
def _vol_to_trend(vol_regime: str) -> float:
"""Convert volatility regime to trend-contributing score."""
mapping = {
"LOW_VOL": 50, # Low vol: neutral for trend
"NORMAL_VOL": 70, # Normal vol: good for trend
"HIGH_VOL": 60, # High vol: trending but risky
"EXPLOSIVE_VOL": 30, # Explosive: anti-trend
}
return mapping.get(vol_regime, 50)
@staticmethod
def _compute_maturity(trend_score: float, breadth_score: float,
vol_regime: str) -> float:
"""
Compute regime maturity: 0-100 continuous.
0-30: EMERGING (trend accelerating, breadth expanding)
30-70: CONFIRMED (stable)
70-100: EXHAUSTING (decelerating, vol abnormal)
"""
# Trend strength contribution
trend_contrib = trend_score * 0.50
# Breadth contribution
breadth_contrib = breadth_score * 0.30
# Vol contribution (inverted: low vol = early, explosive = late)
vol_contrib = {"LOW_VOL": 20, "NORMAL_VOL": 40, "HIGH_VOL": 60, "EXPLOSIVE_VOL": 85}
vol_val = vol_contrib.get(vol_regime, 50) * 0.20
return trend_contrib + breadth_contrib + vol_val