chore: 移除不再使用的 ChanMacro、system、tests。
这些目录已废弃,从仓库中清理。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user