chore: 移除不再使用的 ChanMacro、system、tests。

这些目录已废弃,从仓库中清理。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 18:11:29 +08:00
co-authored by Cursor
parent f2e77e1bdb
commit e2e45bc1bc
51 changed files with 0 additions and 6172 deletions
-6
View File
@@ -1,6 +0,0 @@
"""Scoring engine — L1 factor computation."""
from .base import BaseScorer
from .price_structure import PriceStructureScorer
from .breadth_scorer import BreadthScorer
from .oi_matrix import OIMatrixScorer
from .volatility_regime import VolatilityRegimeScorer
-28
View File
@@ -1,28 +0,0 @@
"""
scoring/base.py — Abstract base class for all scoring modules.
"""
from abc import ABC, abstractmethod
from datetime import date as Date
from typing import Optional
import sqlite3
from models import FactorScore
from config import config
class BaseScorer(ABC):
"""Abstract base for all factor scorers."""
def __init__(self, db_path: Optional[str] = None):
self.db_path = db_path or config.db_path
def get_connection(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
@abstractmethod
def compute(self, target_date: Date) -> FactorScore:
"""Compute factor score for a given date from database records."""
...
-218
View File
@@ -1,218 +0,0 @@
"""
scoring/breadth_scorer.py — Market Breadth Score.
The first citizen of the system. Diffusion always leads price.
Multi-tier: Top20 / Top30 / Top50.
Quantile-based bucketing: EXTREME / STRONG / NORMAL / WEAK / PANIC.
4 sub-indicators (equal weight):
1. Advance/Decline ratio (30%)
2. % above EMA20 (35%)
3. New 20d highs (20%)
4. BTC Dominance change (15%, inverted)
"""
from datetime import date as Date
import sqlite3
import numpy as np
import pandas as pd
from .base import BaseScorer
from .constants import (
BREADTH_W_ADVANCE, BREADTH_W_EMA20, BREADTH_W_NEW_HIGHS, BREADTH_W_BTC_DOM,
)
from models import FactorScore, BreadthScore, BreadthBucket, MacroDirection
from config import config
class BreadthScorer(BaseScorer):
"""Scores market breadth with quantile-based bucketing."""
def compute(self, target_date: Date) -> BreadthScore:
conn = self.get_connection()
try:
row = conn.execute(
"SELECT * FROM breadth_daily WHERE date = ?", (str(target_date),)
).fetchone()
if row is None:
return BreadthScore(
name="Breadth",
score=50.0,
label="No Data",
breadth_bucket=BreadthBucket.NORMAL,
)
row = dict(row)
total = row.get("total_tracked", 50) or 50
# 1. Advance/Decline ratio
advance = row.get("advance_top50", 0) or 0
decline = row.get("decline_top50", 0) or 0
if advance + decline > 0:
ad_ratio = advance / (advance + decline)
else:
ad_ratio = 0.5
ad_score = ad_ratio * 100
# 2. % above EMA20
above_ema = row.get("above_ema20_top50", 0) or 0
ema_pct = above_ema / total if total > 0 else 0.5
ema_score = ema_pct * 100
# 3. New highs
new_highs = row.get("new_highs_20d_top50", 0) or 0
highs_pct = new_highs / total if total > 0 else 0
highs_score = highs_pct * 100
# 4. BTC Dominance (inverted: BTC.D up = bearish for alts)
btc_dom = row.get("btc_dominance")
btc_dom_score = 50.0 # neutral default
if btc_dom is not None:
# Placeholder — needs historical comparison
btc_dom_score = 50.0
# Weighted aggregate
score = (
ad_score * BREADTH_W_ADVANCE +
ema_score * BREADTH_W_EMA20 +
highs_score * BREADTH_W_NEW_HIGHS +
btc_dom_score * BREADTH_W_BTC_DOM
)
# Multi-tier breadth
b20 = self._compute_tier_breadth(row, 20, total)
b30 = self._compute_tier_breadth(row, 30, total)
b50 = score # Top50 = full score
# Quantile bucket
bucket = self._assign_bucket(score)
# Divergence
divergence = b20 - b50
# Direction
if score >= 60:
direction = MacroDirection.BULLISH
elif score <= 40:
direction = MacroDirection.BEARISH
else:
direction = MacroDirection.NEUTRAL
# Narrative
narrative = self._build_narrative(bucket, divergence, ema_pct, ad_ratio)
return BreadthScore(
name="Breadth",
score=round(score, 1),
label=bucket.value,
direction=direction,
breadth_top20=round(b20, 1),
breadth_top30=round(b30, 1),
breadth_top50=round(b50, 1),
breadth_bucket=bucket,
breadth_divergence=round(divergence, 1),
advance_pct_top50=round(ad_ratio * 100, 1),
above_ema20_pct_top50=round(ema_pct * 100, 1),
new_highs_top50=new_highs,
sub_scores={
"advance_decline": round(ad_score, 1),
"above_ema20": round(ema_score, 1),
"new_highs": round(highs_score, 1),
"btc_dominance": round(btc_dom_score, 1),
},
narrative=narrative,
)
finally:
conn.close()
def _compute_tier_breadth(self, row: dict, tier: int, total: int) -> float:
"""Compute breadth score for a specific tier (Top20 or Top30)."""
advance = row.get(f"advance_top{tier}", 0) or 0
above_ema = row.get(f"above_ema20_top{tier}", 0) or 0
new_highs = row.get(f"new_highs_20d_top{tier}", 0) or 0
tier_actual = min(tier, total)
if tier_actual == 0:
return 50.0
ad_ratio = advance / tier_actual if tier_actual > 0 else 0.5
ema_ratio = above_ema / tier_actual if tier_actual > 0 else 0.5
highs_ratio = new_highs / tier_actual if tier_actual > 0 else 0
return (
ad_ratio * 100 * BREADTH_W_ADVANCE +
ema_ratio * 100 * BREADTH_W_EMA20 +
highs_ratio * 100 * BREADTH_W_NEW_HIGHS +
50 * BREADTH_W_BTC_DOM # neutral for BTC.D
)
def _assign_bucket(self, score: float) -> BreadthBucket:
"""Assign quantile-based bucket. V1 uses fixed thresholds until history accumulated."""
# V1: fixed thresholds (will switch to quantile when enough history)
if score >= 80:
return BreadthBucket.EXTREME
elif score >= 60:
return BreadthBucket.STRONG
elif score >= 40:
return BreadthBucket.NORMAL
elif score >= 20:
return BreadthBucket.WEAK
else:
return BreadthBucket.PANIC
@staticmethod
def compute_quantile_boundaries(db_path: str) -> dict:
"""Compute quantile boundaries from historical breadth data.
This should be called after accumulating enough history (> 1 year).
Returns boundaries for pd.qcut.
"""
conn = sqlite3.connect(db_path)
df = pd.read_sql_query(
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily",
conn
)
conn.close()
if len(df) < 100:
return {"boundaries": [0, 20, 40, 60, 80, 100], "is_quantile": False}
df["ad_ratio"] = df["advance_top50"] / (df["advance_top50"] + df["decline_top50"])
df["ema_ratio"] = df["above_ema20_top50"] / 50
df["breadth_raw"] = (
df["ad_ratio"] * BREADTH_W_ADVANCE * 100 +
df["ema_ratio"] * BREADTH_W_EMA20 * 100 +
40 * BREADTH_W_NEW_HIGHS +
50 * BREADTH_W_BTC_DOM
)
boundaries = list(np.percentile(df["breadth_raw"].dropna(), [10, 30, 70, 90]))
return {
"boundaries": [0] + boundaries + [100],
"is_quantile": True,
"n_samples": len(df),
}
@staticmethod
def _build_narrative(bucket: BreadthBucket, divergence: float,
ema_pct: float, ad_ratio: float) -> str:
parts = []
if bucket == BreadthBucket.EXTREME:
parts.append(f"全市场极度扩散({ema_pct:.0%}站上EMA20)")
elif bucket == BreadthBucket.STRONG:
parts.append("市场广度强势")
elif bucket == BreadthBucket.NORMAL:
parts.append("市场广度中性")
elif bucket == BreadthBucket.WEAK:
parts.append("市场广度疲弱")
else:
parts.append("市场广度恐慌")
if divergence > 10:
parts.append("资金集中于大市值(Top20>>Top50)")
elif divergence < -10:
parts.append("垃圾币狂欢(Top50>>Top20)")
return ", ".join(parts)
-98
View File
@@ -1,98 +0,0 @@
"""
scoring/constants.py — Scoring thresholds, scale factors, and reference values.
All magic numbers in one place. Tune these via Phase 0 validation.
"""
# ── Price Structure ──────────────────────────────────────────
# ADX thresholds
ADX_TREND_THRESHOLD = 25 # ADX > 25 = trending
ADX_STRONG_THRESHOLD = 40 # ADX > 40 = strong trend
# EMA alignment
EMA_ALIGNMENT_BULLISH = 1.0 # EMA20 > EMA60 > EMA120
EMA_ALIGNMENT_NEUTRAL = 0.5 # mixed
EMA_ALIGNMENT_BEARISH = 0.0 # EMA20 < EMA60 < EMA120
# Volatility compression (BB width relative to 20d average)
BB_COMPRESSION_LOW = 0.7 # < 70% of avg = compressing
BB_COMPRESSION_HIGH = 1.5 # > 150% of avg = expanding
# Momentum (ROC annualized)
ROC_STRONG_BULLISH = 10.0 # % over period
ROC_STRONG_BEARISH = -10.0
# Consecutive candle threshold
CONSECUTIVE_CANDLES_SIGNAL = 4
# ── Breadth ──────────────────────────────────────────────────
# Quantile boundaries for breadth buckets
BREADTH_QUANTILES = [0, 0.1, 0.3, 0.7, 0.9, 1.0] # PANIC/WEAK/NORMAL/STRONG/EXTREME
# Breadth score computation weights
BREADTH_W_ADVANCE = 0.30 # advance/decline ratio
BREADTH_W_EMA20 = 0.35 # % above EMA20
BREADTH_W_NEW_HIGHS = 0.20 # new highs count
BREADTH_W_BTC_DOM = 0.15 # BTC dominance change (inverted)
# ── OI Matrix ────────────────────────────────────────────────
OI_PRICE_THRESHOLD = 0.5 # min |price_change%| to classify
OI_OI_THRESHOLD = 0.5 # min |OI_change%| to classify
# Score mapping for OI states
OI_STATE_SCORES = {
"New Longs": 85,
"Short Covering": 60,
"New Shorts": 20,
"Long Exit": 35,
"Neutral": 50,
}
# ── Volatility Regime ────────────────────────────────────────
VOL_LOW = 2.0 # ATR/Close % below this = LOW_VOL
VOL_HIGH = 5.0 # ATR/Close % below this = HIGH_VOL (above = EXPLOSIVE)
HV_RATIO_LOW = 0.7 # HV(20)/HV(60) below this = compressing
HV_RATIO_HIGH = 1.5 # HV(20)/HV(60) above this = expanding
# Score mapping
VOL_REGIME_SCORES = {
"LOW_VOL": 40, # Low vol → neutral with breakout potential
"NORMAL_VOL": 55,
"HIGH_VOL": 75,
"EXPLOSIVE_VOL": 90,
}
# ── Regime ───────────────────────────────────────────────────
REGIME_W_PRICE = 0.35
REGIME_W_BREADTH = 0.50
REGIME_W_VOL = 0.15
# PANIC: anti-trend + extreme vol (NO Fear/Liquidation)
PANIC_W_ANTI_TREND = 0.60
PANIC_W_VOL_EXTREME = 0.40
# ── Trend (L2) ───────────────────────────────────────────────
TREND_W_PRICE = 0.30
TREND_W_BREADTH = 0.70
# ── Maturity ─────────────────────────────────────────────────
MATURITY_W_TREND = 0.50
MATURITY_W_BREADTH = 0.30
MATURITY_W_VOL = 0.20
# ── Expectancy ───────────────────────────────────────────────
HALF_LIFE_DAYS = 180
SUFFICIENCY_MIN = 30
SUFFICIENCY_LOW = 50
SUFFICIENCY_MEDIUM = 100
LEVEL_MIN_SAMPLES = 50
KNN_MAX_DISTANCE = 0.35
KNN_K = 200
# ── Validation ───────────────────────────────────────────────
MIN_AVG_DURATION = 5
MAX_FLIP_RATE = 0.15
MIN_IC_THRESHOLD = 0.03
MIN_ICIR_THRESHOLD = 0.5
MIN_IG_THRESHOLD = 0.1 # Information Gain for regime factors
MIN_KL_THRESHOLD = 0.5 # KL Divergence for regime separation
-137
View File
@@ -1,137 +0,0 @@
"""
scoring/oi_matrix.py — OI × Price 2×2 state machine.
Discrete states, NOT a continuous score:
NEW_LONGS: Price↑ OI↑ → new money entering, trend continuation
SHORT_COVERING: Price↑ OI↓ → shorts covering, rally fragile
NEW_SHORTS: Price↓ OI↑ → new shorts entering, trend continuation
LONG_EXIT: Price↓ OI↓ → longs stopping out, panic (possible bottom)
NEUTRAL: flat → noise, don't force classification
"""
from datetime import date as Date
import sqlite3
from .base import BaseScorer
from .constants import OI_PRICE_THRESHOLD, OI_OI_THRESHOLD, OI_STATE_SCORES
from models import FactorScore, OIMatrixScore, OIState, MacroDirection
from config import config
class OIMatrixScorer(BaseScorer):
"""Classifies OI × Price state and assigns score."""
def compute(self, target_date: Date) -> OIMatrixScore:
conn = self.get_connection()
try:
row = conn.execute(
"SELECT * FROM derivatives WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
(str(target_date),)
).fetchone()
if row is None:
return OIMatrixScore(
name="OI Matrix",
score=50.0,
label="No Data",
oi_state=OIState.NEUTRAL,
)
row = dict(row)
oi_change = row.get("oi_24h_change_pct") or 0
# Get price change from OHLCV
price_change = self._get_price_change(conn, str(target_date))
# Classify state
oi_state = self._classify(price_change, oi_change)
# Score from state
score = OI_STATE_SCORES.get(oi_state.value, 50)
# Direction
if oi_state == OIState.NEW_LONGS:
direction = MacroDirection.BULLISH
elif oi_state == OIState.SHORT_COVERING:
direction = MacroDirection.BULLISH # bullish but fragile
elif oi_state == OIState.NEW_SHORTS:
direction = MacroDirection.BEARISH
elif oi_state == OIState.LONG_EXIT:
direction = MacroDirection.BEARISH # bearish but possible bottom
else:
direction = MacroDirection.NEUTRAL
# Narrative
narrative = self._build_narrative(oi_state, price_change, oi_change)
return OIMatrixScore(
name="OI Matrix",
score=float(score),
label=oi_state.value,
direction=direction,
oi_state=oi_state,
price_change_pct=round(price_change, 2),
oi_change_pct=round(oi_change, 2),
sub_scores={
"price_change_pct": round(price_change, 2),
"oi_change_pct": round(oi_change, 2),
},
narrative=narrative,
)
finally:
conn.close()
def _get_price_change(self, conn: sqlite3.Connection, date_str: str) -> float:
"""Get BTC 24h price change % for a given date."""
row = conn.execute(
"SELECT close FROM ohlcv_daily WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
(date_str,)
).fetchone()
if row is None:
return 0.0
# Get previous day close
prev = conn.execute(
"SELECT close FROM ohlcv_daily WHERE date < ? AND symbol = 'BTC/USDT:USDT' ORDER BY date DESC LIMIT 1",
(date_str,)
).fetchone()
if prev is None:
return 0.0
current_close = float(row["close"])
prev_close = float(prev["close"])
if prev_close == 0:
return 0.0
return (current_close - prev_close) / prev_close * 100
@staticmethod
def _classify(price_change_pct: float, oi_change_pct: float) -> OIState:
"""Classify OI × Price into discrete state."""
price_up = price_change_pct > OI_PRICE_THRESHOLD
price_down = price_change_pct < -OI_PRICE_THRESHOLD
oi_up = oi_change_pct > OI_OI_THRESHOLD
oi_down = oi_change_pct < -OI_OI_THRESHOLD
if price_up and oi_up:
return OIState.NEW_LONGS
elif price_up and oi_down:
return OIState.SHORT_COVERING
elif price_down and oi_up:
return OIState.NEW_SHORTS
elif price_down and oi_down:
return OIState.LONG_EXIT
else:
return OIState.NEUTRAL
@staticmethod
def _build_narrative(state: OIState, price_chg: float, oi_chg: float) -> str:
mapping = {
OIState.NEW_LONGS: f"新多进场: 价格+{price_chg:.1f}%, OI+{oi_chg:.1f}%, 真金白银推动",
OIState.SHORT_COVERING: f"空头回补: 价格+{price_chg:.1f}%, OI{oi_chg:.1f}%, 上涨脆弱",
OIState.NEW_SHORTS: f"新空进场: 价格{price_chg:.1f}%, OI+{oi_chg:.1f}%, 趋势延续",
OIState.LONG_EXIT: f"多头止损: 价格{price_chg:.1f}%, OI{oi_chg:.1f}%, 恐慌(可能见底)",
OIState.NEUTRAL: "OI/价格变化不显著, 噪音区",
}
return mapping.get(state, "Unknown")
-248
View File
@@ -1,248 +0,0 @@
"""
scoring/price_structure.py — Price Structure Score (OHLCV-only).
Three sub-dimensions:
1. Trend Strength (40%): EMA alignment + ADX
2. Volatility Compression (30%): ATR + BB width
3. Momentum (30%): ROC + consecutive candles
This module works with zero external dependencies — just OHLCV data.
"""
from datetime import date as Date
import sqlite3
import math
import numpy as np
import pandas as pd
from .base import BaseScorer
from .constants import (
ADX_TREND_THRESHOLD, ADX_STRONG_THRESHOLD,
BB_COMPRESSION_LOW, BB_COMPRESSION_HIGH,
ROC_STRONG_BULLISH, ROC_STRONG_BEARISH,
CONSECUTIVE_CANDLES_SIGNAL,
)
from models import FactorScore, PriceStructureScore, MacroDirection
from config import config
class PriceStructureScorer(BaseScorer):
"""Scores market structure from OHLCV data alone."""
def compute(self, target_date: Date) -> PriceStructureScore:
conn = self.get_connection()
try:
df = self._load_ohlcv(conn, str(target_date), lookback=120)
if df.empty:
return PriceStructureScore(
name="Price Structure",
score=50.0,
label="No Data",
)
trend = self._score_trend_strength(df)
vol_comp = self._score_volatility_compression(df)
momentum = self._score_momentum(df)
# Weighted aggregate
score = trend * 0.40 + vol_comp * 0.30 + momentum * 0.30
# Determine direction
if trend > 60:
direction = MacroDirection.BULLISH
elif trend < 40:
direction = MacroDirection.BEARISH
else:
direction = MacroDirection.NEUTRAL
# Build narrative
latest = df.iloc[-1]
narrative = self._build_narrative(trend, vol_comp, momentum, latest)
return PriceStructureScore(
name="Price Structure",
score=round(score, 1),
label=self._label(score),
direction=direction,
trend_strength=round(trend, 1),
volatility_compression=round(vol_comp, 1),
momentum=round(momentum, 1),
sub_scores={
"trend_strength": round(trend, 1),
"volatility_compression": round(vol_comp, 1),
"momentum": round(momentum, 1),
},
narrative=narrative,
)
finally:
conn.close()
def _load_ohlcv(self, conn: sqlite3.Connection, date_str: str,
lookback: int = 120) -> pd.DataFrame:
"""Load OHLCV data up to target_date."""
df = pd.read_sql_query(
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT ?",
conn, params=(date_str, lookback)
)
if df.empty:
return df
return df.sort_values("date").reset_index(drop=True)
def _score_trend_strength(self, df: pd.DataFrame) -> float:
"""Score trend based on EMA alignment and ADX."""
latest = df.iloc[-1]
# EMA alignment
ema20 = latest.get("ema20")
ema60 = latest.get("ema60")
ema120 = latest.get("ema120")
ema_score = 50.0
if ema20 and ema60 and ema120 and not pd.isna(ema20) and not pd.isna(ema60) and not pd.isna(ema120):
alignments = 0
if ema20 > ema60: alignments += 1
if ema60 > ema120: alignments += 1
if ema20 > ema120: alignments += 1
# Distance from EMAs
close = float(latest["close"])
ema20_dist = abs(close - ema20) / ema20 * 100 if ema20 else 0
if alignments == 3:
ema_score = 80 + min(ema20_dist, 15) # strong bullish alignment
elif alignments == 0:
ema_score = 20 - min(ema20_dist, 15) # strong bearish alignment
elif alignments == 2:
ema_score = 65
else:
ema_score = 35
# ADX
adx = latest.get("adx_14")
adx_score = 50.0
if adx and not pd.isna(adx):
if adx > ADX_STRONG_THRESHOLD:
adx_score = 85
elif adx > ADX_TREND_THRESHOLD:
adx_score = 65 + (adx - ADX_TREND_THRESHOLD) / (ADX_STRONG_THRESHOLD - ADX_TREND_THRESHOLD) * 20
else:
adx_score = 50 - (ADX_TREND_THRESHOLD - adx) / ADX_TREND_THRESHOLD * 30
return ema_score * 0.55 + adx_score * 0.45
def _score_volatility_compression(self, df: pd.DataFrame) -> float:
"""Score volatility compression — expansion = high, compression = low-mid."""
latest = df.iloc[-1]
bb_width = latest.get("bb_width")
if not bb_width or pd.isna(bb_width) or len(df) < 20:
return 50.0
# BB width relative to 20d average
recent_bb = df["bb_width"].dropna().tail(20)
if len(recent_bb) < 10:
return 50.0
bb_avg = recent_bb.mean()
bb_ratio = bb_width / bb_avg if bb_avg > 0 else 1.0
if bb_ratio < BB_COMPRESSION_LOW:
# Compression → potential breakout, neutral-bullish
return 45 + (BB_COMPRESSION_LOW - bb_ratio) * 30
elif bb_ratio > BB_COMPRESSION_HIGH:
# Expansion → trending or chaotic
return 75 + min((bb_ratio - BB_COMPRESSION_HIGH) * 20, 20)
else:
# Normal
return 55
def _score_momentum(self, df: pd.DataFrame) -> float:
"""Score momentum using ROC and consecutive candles."""
if len(df) < 10:
return 50.0
closes = df["close"].astype(float)
latest = float(closes.iloc[-1])
# ROC (5-bar)
if len(closes) >= 6:
roc5 = (closes.iloc[-1] - closes.iloc[-6]) / closes.iloc[-6] * 100
else:
roc5 = 0
# ROC (10-bar)
if len(closes) >= 11:
roc10 = (closes.iloc[-1] - closes.iloc[-11]) / closes.iloc[-11] * 100
else:
roc10 = 0
# ROC (20-bar)
if len(closes) >= 21:
roc20 = (closes.iloc[-1] - closes.iloc[-21]) / closes.iloc[-21] * 100
else:
roc20 = 0
# Score ROC: map to 0-100
def roc_to_score(roc, scale=15):
return 50 + np.clip(roc / scale * 50, -50, 50)
roc_score = roc_to_score(roc5, 10) * 0.4 + roc_to_score(roc10, 15) * 0.35 + roc_to_score(roc20, 20) * 0.25
# Consecutive candle direction
consec_score = 50.0
consec_up = 0
consec_down = 0
for i in range(len(closes) - 1, max(0, len(closes) - 10), -1):
if closes.iloc[i] > closes.iloc[i - 1]:
consec_up += 1
consec_down = 0
elif closes.iloc[i] < closes.iloc[i - 1]:
consec_down += 1
consec_up = 0
else:
break
if consec_up >= CONSECUTIVE_CANDLES_SIGNAL:
consec_score = 70 + min(consec_up * 5, 25)
elif consec_down >= CONSECUTIVE_CANDLES_SIGNAL:
consec_score = 30 - min(consec_down * 5, 25)
return roc_score * 0.70 + consec_score * 0.30
def _build_narrative(self, trend: float, vol: float, momentum: float,
latest: pd.Series) -> str:
parts = []
if trend > 65:
parts.append("EMA多头排列+ADX趋势明确")
elif trend > 50:
parts.append("趋势温和偏多")
elif trend < 35:
parts.append("EMA空头排列+ADX趋势明确")
elif trend < 50:
parts.append("趋势温和偏空")
else:
parts.append("趋势中性")
if vol > 70:
parts.append("波动率扩张")
elif vol < 45:
parts.append("波动率压缩(突破前兆)")
if momentum > 65:
parts.append("动量强劲")
elif momentum < 35:
parts.append("动量疲弱")
return ", ".join(parts) if parts else "中性"
@staticmethod
def _label(score: float) -> str:
if score >= 75:
return "Strong Bullish Structure"
elif score >= 60:
return "Bullish Structure"
elif score >= 40:
return "Neutral Structure"
elif score >= 25:
return "Bearish Structure"
return "Weak Bearish Structure"
-143
View File
@@ -1,143 +0,0 @@
"""
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")