214 lines
8.0 KiB
Python
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
|