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>
144 lines
4.8 KiB
Python
144 lines
4.8 KiB
Python
"""
|
|
scoring/volatility_regime.py — Volatility Regime Classification.
|
|
|
|
4 regimes from OHLCV data:
|
|
LOW_VOL: ATR/Close < 2% → compression, breakout imminent
|
|
NORMAL_VOL: ATR/Close 2-5% → normal trading
|
|
HIGH_VOL: ATR/Close 5-10% → trend acceleration, wider stops
|
|
EXPLOSIVE_VOL: ATR/Close > 10% → extreme, reduce or wait
|
|
|
|
Uses: ATR(14)/Close, HV(20)/HV(60) ratio, BB width ratio.
|
|
OHLCV-only — never goes offline.
|
|
"""
|
|
|
|
from datetime import date as Date
|
|
import sqlite3
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from .base import BaseScorer
|
|
from .constants import (
|
|
VOL_LOW, VOL_HIGH, VOL_REGIME_SCORES, HV_RATIO_LOW, HV_RATIO_HIGH,
|
|
)
|
|
from models import FactorScore, VolatilityRegimeScore, VolRegime, MacroDirection
|
|
from config import config
|
|
|
|
|
|
class VolatilityRegimeScorer(BaseScorer):
|
|
"""Classifies volatility regime from OHLCV data."""
|
|
|
|
def compute(self, target_date: Date) -> VolatilityRegimeScore:
|
|
conn = self.get_connection()
|
|
try:
|
|
df = pd.read_sql_query(
|
|
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT 120",
|
|
conn, params=(str(target_date),)
|
|
)
|
|
if df.empty:
|
|
return VolatilityRegimeScore(
|
|
name="Volatility Regime",
|
|
score=50.0,
|
|
label="No Data",
|
|
)
|
|
|
|
df = df.sort_values("date").reset_index(drop=True)
|
|
|
|
# 1. ATR/Close %
|
|
latest = df.iloc[-1]
|
|
atr = latest.get("atr_14")
|
|
close = float(latest["close"])
|
|
atr_pct = (atr / close * 100) if atr and not pd.isna(atr) and close > 0 else 3.0
|
|
|
|
# 2. HV(20) / HV(60) ratio
|
|
hv_ratio = self._compute_hv_ratio(df)
|
|
|
|
# 3. BB width ratio
|
|
bb_ratio = self._compute_bb_ratio(df)
|
|
|
|
# Classify regime
|
|
regime = self._classify(atr_pct, hv_ratio, bb_ratio)
|
|
|
|
# Score
|
|
score = VOL_REGIME_SCORES.get(regime.value, 50)
|
|
|
|
# Narrative
|
|
narrative = self._build_narrative(regime, atr_pct, hv_ratio, bb_ratio)
|
|
|
|
return VolatilityRegimeScore(
|
|
name="Volatility Regime",
|
|
score=float(score),
|
|
label=regime.value,
|
|
direction=MacroDirection.NEUTRAL,
|
|
vol_regime=regime,
|
|
atr_pct=round(atr_pct, 2),
|
|
hv_ratio=round(hv_ratio, 2),
|
|
bb_width_ratio=round(bb_ratio, 2),
|
|
sub_scores={
|
|
"atr_pct": round(atr_pct, 2),
|
|
"hv_ratio": round(hv_ratio, 2),
|
|
"bb_width_ratio": round(bb_ratio, 2),
|
|
},
|
|
narrative=narrative,
|
|
)
|
|
finally:
|
|
conn.close()
|
|
|
|
def _compute_hv_ratio(self, df: pd.DataFrame) -> float:
|
|
"""Compute HV(20) / HV(60) ratio."""
|
|
closes = df["close"].astype(float)
|
|
returns = closes.pct_change().dropna()
|
|
|
|
if len(returns) < 60:
|
|
return 1.0
|
|
|
|
hv20 = returns.tail(20).std() * np.sqrt(365) * 100
|
|
hv60 = returns.tail(60).std() * np.sqrt(365) * 100
|
|
|
|
if hv60 == 0:
|
|
return 1.0
|
|
|
|
return hv20 / hv60
|
|
|
|
def _compute_bb_ratio(self, df: pd.DataFrame) -> float:
|
|
"""Compute current BB width / 20d average BB width."""
|
|
bb_widths = df["bb_width"].dropna().tail(40)
|
|
if len(bb_widths) < 20:
|
|
return 1.0
|
|
|
|
current = bb_widths.iloc[-1]
|
|
avg = bb_widths.tail(20).mean()
|
|
if avg == 0:
|
|
return 1.0
|
|
|
|
return current / avg
|
|
|
|
@staticmethod
|
|
def _classify(atr_pct: float, hv_ratio: float, bb_ratio: float) -> VolRegime:
|
|
"""Classify volatility regime from multiple indicators."""
|
|
# Primary: ATR/Close %
|
|
if atr_pct > 10.0:
|
|
return VolRegime.EXPLOSIVE_VOL
|
|
elif atr_pct > VOL_HIGH:
|
|
return VolRegime.HIGH_VOL
|
|
elif atr_pct < VOL_LOW:
|
|
return VolRegime.LOW_VOL
|
|
|
|
# Secondary: HV ratio and BB ratio for edge cases
|
|
if hv_ratio > HV_RATIO_HIGH and bb_ratio > 1.3:
|
|
return VolRegime.HIGH_VOL
|
|
elif hv_ratio < HV_RATIO_LOW and bb_ratio < 0.8:
|
|
return VolRegime.LOW_VOL
|
|
|
|
return VolRegime.NORMAL_VOL
|
|
|
|
@staticmethod
|
|
def _build_narrative(regime: VolRegime, atr_pct: float,
|
|
hv_ratio: float, bb_ratio: float) -> str:
|
|
mapping = {
|
|
VolRegime.LOW_VOL: f"低波动(ATR={atr_pct:.1f}%), 布林带收窄, 突破前兆",
|
|
VolRegime.NORMAL_VOL: f"正常波动(ATR={atr_pct:.1f}%), 正常交易环境",
|
|
VolRegime.HIGH_VOL: f"高波动(ATR={atr_pct:.1f}%), 趋势加速, 放宽止损",
|
|
VolRegime.EXPLOSIVE_VOL: f"极端波动(ATR={atr_pct:.1f}%), 减仓或等待",
|
|
}
|
|
return mapping.get(regime, "Unknown")
|