Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542adad583 | ||
|
|
29cff47f98 | ||
|
|
340676bfbd | ||
|
|
9cf625c413 | ||
|
|
18a7f485e6 | ||
|
|
0f6eb92a1f | ||
|
|
9880e236a5 | ||
|
|
ec08de098e | ||
|
|
6c627f009a | ||
|
|
dbb6202325 |
@@ -44,3 +44,6 @@ data_provider/._config.json
|
||||
# ESS gate / engineering-loop working dirs(归档进 docs/runs/)
|
||||
.gates/
|
||||
loop/
|
||||
|
||||
# Crypto Wyckoff Screener local cache
|
||||
data/crypto_wyckoff/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Data Provider URL (existing chan data_provider service)
|
||||
PROVIDER_URL=http://127.0.0.1:80
|
||||
|
||||
# Database path
|
||||
DB_PATH=data/macro.db
|
||||
|
||||
# Telegram (reuse bsp_monitor config)
|
||||
# TELEGRAM_BOT_TOKEN=your_bot_token
|
||||
# TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# AI API (for daily report, Phase 5+)
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
@@ -0,0 +1 @@
|
||||
data/
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
ChanMacro — Crypto Market Memory System (Signal Expectancy Engine).
|
||||
|
||||
V1: 4 factors (Price Structure, Breadth, OI State, Volatility Regime)
|
||||
3 regimes (TREND / RANGE / PANIC)
|
||||
Factor-locked: Regime = f(Price, Breadth, Vol) — forever.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
chan_integration.py — 缠论引擎集成:检测 BSP 信号并写入 signal_features。
|
||||
|
||||
复用 bsp_monitor/engine.py 的 ChanEngine 管线,对历史日线数据批量跑缠论,
|
||||
提取 B1/B2/B3/S1/S2/S3 信号,通过 SignalTracker 记录到 signal_features。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import date as Date, timedelta
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
# 确保 Chan 引擎在路径上(与 bsp_monitor/engine.py 相同的路径设置)
|
||||
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _PARENT not in sys.path:
|
||||
sys.path.insert(0, _PARENT)
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR
|
||||
from ChanBSP import ChanBSP
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChanSignalDetector:
|
||||
"""
|
||||
对历史日线数据运行缠论管线,提取所有 BSP 信号。
|
||||
|
||||
Usage:
|
||||
detector = ChanSignalDetector()
|
||||
signals = detector.detect_from_db("2026-01-01", "2026-06-24")
|
||||
# → [{"date": Date, "signal_type": "B3", "entry_price": 96500, ...}, ...]
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
from TF_DF import TF_DF as _TF_DF_Class
|
||||
self._TF_DF_Class = _TF_DF_Class
|
||||
|
||||
def detect_from_db(self, start_date: str, end_date: str) -> list[dict]:
|
||||
"""从数据库加载日线数据,跑缠论管线,提取信号。"""
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
df = pd.read_sql_query(
|
||||
"SELECT date, open, high, low, close, volume "
|
||||
"FROM ohlcv_daily WHERE symbol='BTC/USDT:USDT' "
|
||||
"AND date BETWEEN ? AND ? ORDER BY date",
|
||||
conn, params=(start_date, end_date)
|
||||
)
|
||||
conn.close()
|
||||
|
||||
if df.empty or len(df) < 50:
|
||||
logger.warning(f"日线数据不足: {len(df)} 根")
|
||||
return []
|
||||
|
||||
return self.detect_from_df(df)
|
||||
|
||||
def detect_from_df(self, df: pd.DataFrame) -> list[dict]:
|
||||
"""从 DataFrame 运行缠论管线,提取 BSP 信号。"""
|
||||
# 需要 datetime 列才能跑 TF_DF
|
||||
df = df.copy()
|
||||
df["timestamp"] = pd.to_datetime(df["date"])
|
||||
df["date"] = df["timestamp"]
|
||||
|
||||
try:
|
||||
engine = self._build_engine(df)
|
||||
except Exception as e:
|
||||
logger.error(f"缠论管线失败: {e}")
|
||||
return []
|
||||
|
||||
return self._extract_signals(engine)
|
||||
|
||||
def _build_engine(self, df: pd.DataFrame):
|
||||
"""构建缠论管线(对齐 bsp_monitor/engine.py 的 ChanEngine)。"""
|
||||
from TF_DF import TF_DF as _TF_DF_Class
|
||||
|
||||
if df.empty or len(df) < 50:
|
||||
raise ValueError(f"数据不足: {len(df)} 根 K 线")
|
||||
|
||||
if "date" not in df.columns and "timestamp" in df.columns:
|
||||
df["date"] = df["timestamp"]
|
||||
|
||||
# 使用 __new__ 避免触发 TF_DF.__init__
|
||||
engine = type('ChanEngine', (), {})() # 简单容器
|
||||
tf = _TF_DF_Class.__new__(_TF_DF_Class)
|
||||
|
||||
df_with_indicators = tf.add_indicators(df.copy())
|
||||
engine.klu_list = tf.get_klu_list(df_with_indicators)
|
||||
engine.klc_list = tf.get_klc_list(engine.klu_list)
|
||||
engine.bi_list = tf.cal_bi_list(engine.klc_list)
|
||||
engine.seg_list = tf.get_seg_list(engine.bi_list)
|
||||
engine.bi_zs_list = tf.cal_bi_zs(engine.seg_list)
|
||||
engine.bsp_list = tf.find_all_bsp(engine.bi_list, engine.bi_zs_list)
|
||||
|
||||
return engine
|
||||
|
||||
def _extract_signals(self, engine) -> list[dict]:
|
||||
"""从 ChanEngine 输出中提取所有 BSP 信号。"""
|
||||
signals = []
|
||||
for bsp in engine.bsp_list:
|
||||
if bsp.type == Chan_BSP_TYPE.NONE:
|
||||
continue
|
||||
if bsp.klc is None:
|
||||
continue
|
||||
|
||||
signal_type = self._bsp_type_str(bsp.type)
|
||||
entry_price = bsp.klc.close
|
||||
signal_date = self._klc_date(bsp.klc)
|
||||
|
||||
if signal_date is None:
|
||||
continue
|
||||
|
||||
# 信号质量:根据分型强度判断
|
||||
strength = self._calc_strength(bsp)
|
||||
grade = "A" if strength >= 70 else "B" if strength >= 50 else "C"
|
||||
|
||||
signals.append({
|
||||
"date": signal_date,
|
||||
"signal_type": signal_type,
|
||||
"entry_price": float(entry_price),
|
||||
"signal_grade": grade,
|
||||
"signal_strength": float(strength),
|
||||
})
|
||||
|
||||
details = ", ".join(f"{s['signal_type']}({s['date']})" for s in signals)
|
||||
logger.info(f"检测到 {len(signals)} 个信号: {details}")
|
||||
return signals
|
||||
|
||||
def populate_signal_features(self, start_date: str = "2024-01-01",
|
||||
end_date: Optional[str] = None) -> int:
|
||||
"""
|
||||
完整流程:检测信号 → 计算市场状态 → 写入 signal_features。
|
||||
|
||||
Returns: 写入的信号数量。
|
||||
"""
|
||||
if end_date is None:
|
||||
end_date = Date.today().isoformat()
|
||||
|
||||
logger.info(f"开始信号检测: {start_date} → {end_date}")
|
||||
|
||||
# Step 1: 检测缠论信号
|
||||
signals = self.detect_from_db(start_date, end_date)
|
||||
if not signals:
|
||||
logger.warning("未检测到任何 BSP 信号")
|
||||
return 0
|
||||
|
||||
# Step 2: 去重 — 跳过已存在的信号
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
existing = set()
|
||||
for row in conn.execute(
|
||||
"SELECT date, signal_type FROM signal_features"
|
||||
).fetchall():
|
||||
existing.add((row[0], row[1]))
|
||||
conn.close()
|
||||
|
||||
new_signals = [s for s in signals
|
||||
if (str(s["date"]), s["signal_type"]) not in existing]
|
||||
if not new_signals:
|
||||
logger.info("所有信号已存在,跳过")
|
||||
return 0
|
||||
|
||||
# Step 3: 写入 signal_features
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
count = tracker.backfill_signals(new_signals)
|
||||
|
||||
logger.info(f"信号入库完成: {count}/{len(signals)}")
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def _bsp_type_str(t: Chan_BSP_TYPE) -> str:
|
||||
mapping = {
|
||||
Chan_BSP_TYPE.B1: "B1", Chan_BSP_TYPE.B2: "B2", Chan_BSP_TYPE.B3: "B3",
|
||||
Chan_BSP_TYPE.S1: "S1", Chan_BSP_TYPE.S2: "S2", Chan_BSP_TYPE.S3: "S3",
|
||||
}
|
||||
return mapping.get(t, "UNKNOWN")
|
||||
|
||||
@staticmethod
|
||||
def _klc_date(klc) -> Optional[Date]:
|
||||
"""从 KLC 提取信号确认日期。"""
|
||||
end_time = getattr(klc, "end_time", None)
|
||||
if end_time is None:
|
||||
start_time = getattr(klc, "start_time", None)
|
||||
if start_time is None:
|
||||
return None
|
||||
end_time = start_time
|
||||
if hasattr(end_time, "date"):
|
||||
return end_time.date()
|
||||
if isinstance(end_time, str):
|
||||
return Date.fromisoformat(end_time[:10])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _calc_strength(bsp: ChanBSP) -> float:
|
||||
"""根据 BSP 特征计算信号强度 0-100。"""
|
||||
score = 50.0
|
||||
klc = bsp.klc
|
||||
if klc is None:
|
||||
return score
|
||||
|
||||
# 分型强度
|
||||
from ChanEnum import Chan_KLC_FX
|
||||
fx = getattr(klc, "klc_fx_type", None)
|
||||
if fx is not None:
|
||||
strong_fxs = {Chan_KLC_FX.TOP2, Chan_KLC_FX.TOP3, Chan_KLC_FX.BOTTOM2, Chan_KLC_FX.BOTTOM3}
|
||||
medium_fxs = {Chan_KLC_FX.TOP1, Chan_KLC_FX.BOTTOM1, Chan_KLC_FX.TOP4, Chan_KLC_FX.BOTTOM4}
|
||||
if fx in strong_fxs:
|
||||
score += 25
|
||||
elif fx in medium_fxs:
|
||||
score += 10
|
||||
|
||||
# BSP 类型
|
||||
if bsp.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1):
|
||||
score += 10 # 一类买卖点: 背驰确认, 额外加分
|
||||
|
||||
# 笔特征
|
||||
bi = getattr(bsp, "bi", None)
|
||||
if bi and hasattr(bi, "height") and hasattr(bi, "width"):
|
||||
if bi.width > 3 and abs(bi.height) > 100:
|
||||
score += 10
|
||||
|
||||
return min(score, 100.0)
|
||||
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
cli.py — Command-line interface for ChanMacro.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import date as Date, datetime, timedelta
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("chanmacro")
|
||||
|
||||
|
||||
def parse_date(date_str: str) -> Date:
|
||||
"""Parse YYYY-MM-DD string to Date."""
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def _build_market_state(target: Date) -> tuple:
|
||||
"""Shared helper: compute all scores → (MarketStateVector, RegimeResult)."""
|
||||
from config import config
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketStateVector
|
||||
|
||||
ps = PriceStructureScorer().compute(target)
|
||||
br = BreadthScorer().compute(target)
|
||||
oi = OIMatrixScorer().compute(target)
|
||||
vol = VolatilityRegimeScorer().compute(target)
|
||||
|
||||
detector = RegimeDetector()
|
||||
detector.load_state(config.db_path)
|
||||
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
|
||||
|
||||
state = MarketStateVector(
|
||||
date=target, regime=r.regime, regime_confidence=r.confidence,
|
||||
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
|
||||
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
|
||||
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
|
||||
breadth_divergence=br.breadth_divergence,
|
||||
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
|
||||
price_structure_score=ps, breadth_score=br,
|
||||
oi_matrix_score=oi, volatility_regime_score=vol,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
|
||||
# Persist regime to DB so subsequent calls have correct state
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO regime_history
|
||||
(date, regime, confidence, regime_version, maturity_score, all_scores_json,
|
||||
prior_regime, confirmation_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
str(target), r.regime.value, r.confidence, r.regime_version,
|
||||
r.maturity_score, json.dumps(r.all_scores),
|
||||
r.prior_regime.value if r.prior_regime else None,
|
||||
r.confirmation_days,
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return state, r
|
||||
|
||||
|
||||
def cmd_fetch(args):
|
||||
"""Fetch raw data and store to DB."""
|
||||
from database import init_db
|
||||
from fetchers.ohlcv import OHLCVFetcher
|
||||
from fetchers.breadth import BreadthFetcher
|
||||
|
||||
target = parse_date(args.date) if args.date else Date.today()
|
||||
init_db()
|
||||
|
||||
module = args.module or "all"
|
||||
|
||||
if module in ("ohlcv", "all"):
|
||||
logger.info(f"Fetching OHLCV for {target}...")
|
||||
fetcher = OHLCVFetcher()
|
||||
df = fetcher.fetch(target)
|
||||
if not df.empty:
|
||||
n = fetcher.store_df(df)
|
||||
logger.info(f"OHLCV: stored {n} rows")
|
||||
|
||||
if module in ("breadth", "all"):
|
||||
logger.info(f"Fetching Breadth for {target}...")
|
||||
fetcher = BreadthFetcher()
|
||||
record = fetcher.fetch(target)
|
||||
if record:
|
||||
fetcher.store(record=record)
|
||||
logger.info(f"Breadth: stored (adv={record.get('advance_top50')}, "
|
||||
f"dec={record.get('decline_top50')}, "
|
||||
f"ema20={record.get('above_ema20_top50')})")
|
||||
|
||||
if module in ("derivatives", "all"):
|
||||
logger.info(f"Fetching Derivatives for {target}...")
|
||||
from fetchers.derivatives import DerivativesFetcher
|
||||
fetcher = DerivativesFetcher()
|
||||
records = fetcher.fetch(target)
|
||||
if records:
|
||||
n = fetcher.store(records=records)
|
||||
logger.info(f"Derivatives: stored {n} records")
|
||||
|
||||
|
||||
def cmd_score(args):
|
||||
"""Compute all factor scores and regime for a date."""
|
||||
from database import init_db
|
||||
|
||||
target = parse_date(args.date) if args.date else Date.today()
|
||||
init_db()
|
||||
logger.info(f"Computing scores for {target}...")
|
||||
|
||||
state, _ = _build_market_state(target)
|
||||
|
||||
# Output
|
||||
ps = state.price_structure_score
|
||||
br = state.breadth_score
|
||||
oi = state.oi_matrix_score
|
||||
vol = state.volatility_regime_score
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {target} Market State")
|
||||
print(f"{'='*60}")
|
||||
print(f" Regime: {state.regime.value} (conf={state.regime_confidence:.2f}, "
|
||||
f"v={state.regime_version})")
|
||||
print(f" Maturity: {state.regime_maturity_score:.0f}/100")
|
||||
print(f" Breadth: {state.breadth_bucket.value} "
|
||||
f"(T20={state.breadth_top20:.0f} T30={state.breadth_top30:.0f} "
|
||||
f"T50={state.breadth_top50:.0f} div={state.breadth_divergence:+.0f})")
|
||||
print(f" OI State: {state.oi_state.value}")
|
||||
print(f" Volatility: {state.volatility_regime.value}")
|
||||
print(f"{'='*60}")
|
||||
print(f" Scores:")
|
||||
print(f" Price Structure: {ps.score:.0f} {ps.label}")
|
||||
print(f" Breadth: {br.score:.0f} {br.breadth_bucket.value}")
|
||||
print(f" OI Matrix: {oi.score:.0f} {oi.oi_state.value}")
|
||||
print(f" Volatility: {vol.score:.0f} {vol.vol_regime.value}")
|
||||
print(f"{'='*60}")
|
||||
print(f" Market State Hash: {state.market_state_hash}")
|
||||
print()
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def cmd_regime(args):
|
||||
"""Show regime history."""
|
||||
from database import get_connection
|
||||
days = args.days or 30
|
||||
conn = get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT date, regime, confidence, maturity_score, confirmation_days "
|
||||
"FROM regime_history ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f" Regime History (last {days} days)")
|
||||
print(f"{'='*50}")
|
||||
for r in rows:
|
||||
print(f" {r['date']} {r['regime']:7s} conf={r['confidence']:.2f} "
|
||||
f"mat={r['maturity_score']:.0f} days={r['confirmation_days']}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_track(args):
|
||||
"""Record a trading signal with current market state."""
|
||||
from database import init_db
|
||||
from expectancy.tracker import SignalTracker
|
||||
|
||||
target = parse_date(args.date) if args.date else Date.today()
|
||||
init_db()
|
||||
|
||||
logger.info(f"Recording {args.signal} on {target} @ {args.price}")
|
||||
|
||||
state, _ = _build_market_state(target)
|
||||
|
||||
tracker = SignalTracker()
|
||||
rid = tracker.record(
|
||||
date=target, signal_type=args.signal, entry_price=args.price,
|
||||
state=state, signal_grade=args.grade, signal_strength=args.strength,
|
||||
)
|
||||
logger.info(f"Signal recorded: id={rid}")
|
||||
|
||||
|
||||
def cmd_backfill(args):
|
||||
"""Backfill historical breadth + regime scores."""
|
||||
from datetime import date as Date, timedelta
|
||||
from database import init_db, get_connection
|
||||
from fetchers.ohlcv import OHLCVFetcher
|
||||
from fetchers.breadth import BreadthFetcher
|
||||
from config import config
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
start = parse_date(args.from_date)
|
||||
end = parse_date(args.to_date) if args.to_date else Date.today()
|
||||
init_db()
|
||||
|
||||
# Step 1: Ensure OHLCV data exists for the range
|
||||
logger.info(f"Step 1/3: Fetching BTC OHLCV...")
|
||||
OHLCVFetcher().store_df(OHLCVFetcher().fetch())
|
||||
|
||||
# Step 2: Backfill breadth — fetch TOP50 daily data and compute per date
|
||||
logger.info(f"Step 2/3: Backfilling breadth {start} → {end}...")
|
||||
provider_url = config.provider_url
|
||||
all_symbol_data = {}
|
||||
|
||||
for sym in config.top50_symbols:
|
||||
try:
|
||||
df = pd.DataFrame(requests.get(
|
||||
f"{provider_url}/api/candles",
|
||||
params={"symbol": sym, "tf": "1d", "limit": 400},
|
||||
timeout=30
|
||||
).json())
|
||||
if not df.empty and "timestamp" in df.columns:
|
||||
df["date"] = pd.to_datetime(df["timestamp"], unit="ms").dt.date
|
||||
df["close"] = df["close"].astype(float)
|
||||
df["high"] = df["high"].astype(float)
|
||||
df["ema20"] = df["close"].ewm(20).mean()
|
||||
all_symbol_data[sym] = df
|
||||
except Exception as e:
|
||||
logger.debug(f" Skip {sym}: {e}")
|
||||
|
||||
logger.info(f" Fetched {len(all_symbol_data)}/{len(config.top50_symbols)} symbols")
|
||||
|
||||
# Compute breadth for each date
|
||||
conn = get_connection()
|
||||
current = start
|
||||
breadth_count = 0
|
||||
while current <= end:
|
||||
target_str = str(current)
|
||||
try:
|
||||
advances_50 = declines_50 = above_ema20_50 = new_highs_50 = 0
|
||||
advances_30 = advances_20 = above_ema20_30 = above_ema20_20 = 0
|
||||
new_highs_30 = new_highs_20 = 0
|
||||
|
||||
for rank, (sym, df) in enumerate(all_symbol_data.items()):
|
||||
rows = df[df["date"] == current]
|
||||
if rows.empty:
|
||||
continue
|
||||
row = rows.iloc[0]
|
||||
prev_rows = df[df["date"] < current]
|
||||
if prev_rows.empty:
|
||||
continue
|
||||
prev = prev_rows.iloc[-1]
|
||||
|
||||
if row["close"] > prev["close"]:
|
||||
if rank < 50: advances_50 += 1
|
||||
if rank < 30: advances_30 += 1
|
||||
if rank < 20: advances_20 += 1
|
||||
elif row["close"] < prev["close"]:
|
||||
if rank < 50: declines_50 += 1
|
||||
|
||||
if not pd.isna(row.get("ema20")) and row["close"] > row["ema20"]:
|
||||
if rank < 50: above_ema20_50 += 1
|
||||
if rank < 30: above_ema20_30 += 1
|
||||
if rank < 20: above_ema20_20 += 1
|
||||
|
||||
recent_highs = df[(df["date"] < current) & (df["date"] >= current - timedelta(days=20))]
|
||||
if not recent_highs.empty and row["high"] > recent_highs["high"].max():
|
||||
if rank < 50: new_highs_50 += 1
|
||||
if rank < 30: new_highs_30 += 1
|
||||
if rank < 20: new_highs_20 += 1
|
||||
|
||||
conn.execute("""INSERT OR REPLACE INTO breadth_daily
|
||||
(date, total_tracked, advance_top50, decline_top50, above_ema20_top50,
|
||||
new_highs_20d_top50, advance_top30, advance_top20,
|
||||
above_ema20_top30, above_ema20_top20, new_highs_20d_top30, new_highs_20d_top20)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(target_str, len(all_symbol_data),
|
||||
advances_50, declines_50, above_ema20_50, new_highs_50,
|
||||
advances_30, advances_20, above_ema20_30, above_ema20_20,
|
||||
new_highs_30, new_highs_20))
|
||||
breadth_count += 1
|
||||
except Exception as e:
|
||||
logger.debug(f" Breadth skip {current}: {e}")
|
||||
current += timedelta(days=1)
|
||||
|
||||
conn.commit()
|
||||
logger.info(f" Breadth backfill: {breadth_count} days")
|
||||
|
||||
# Step 3: Compute regime scores for each date
|
||||
logger.info(f"Step 3/3: Computing regime scores {start} → {end}...")
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
|
||||
detector = RegimeDetector()
|
||||
current = start
|
||||
score_count = 0
|
||||
while current <= end:
|
||||
try:
|
||||
ps = PriceStructureScorer().compute(current)
|
||||
br = BreadthScorer().compute(current)
|
||||
if br.score == 50.0 and br.label == "No Data":
|
||||
current += timedelta(days=1)
|
||||
continue
|
||||
oi = OIMatrixScorer().compute(current)
|
||||
vol = VolatilityRegimeScorer().compute(current)
|
||||
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, current)
|
||||
|
||||
conn.execute("""INSERT OR REPLACE INTO regime_history
|
||||
(date, regime, confidence, regime_version, maturity_score,
|
||||
all_scores_json, confirmation_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(str(current), r.regime.value, r.confidence, r.regime_version,
|
||||
r.maturity_score, json.dumps(r.all_scores), r.confirmation_days))
|
||||
score_count += 1
|
||||
if score_count % 30 == 0:
|
||||
conn.commit()
|
||||
logger.info(f" Scored {score_count} days... ({current})")
|
||||
except Exception as e:
|
||||
logger.debug(f" Score skip {current}: {e}")
|
||||
current += timedelta(days=1)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Backfill complete: {breadth_count} breadth + {score_count} regime days")
|
||||
|
||||
|
||||
def cmd_expectancy(args):
|
||||
"""Query signal expectancy for current market state."""
|
||||
from database import init_db
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
target = parse_date(args.date) if args.date else Date.today()
|
||||
init_db()
|
||||
|
||||
state, _ = _build_market_state(target)
|
||||
|
||||
engine = BayesianExpectancyEngine()
|
||||
signal = args.signal or "B3"
|
||||
report = engine.estimate(state, signal_type=signal, target_date=target)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {target} Signal Expectancy: {signal}")
|
||||
print(f"{'='*60}")
|
||||
print(f" Regime: {state.regime.value} (conf={state.regime_confidence:.2f})")
|
||||
print(f" Breadth: {state.breadth_bucket.value} (T50={state.breadth_top50:.0f})")
|
||||
print(f" OI State: {state.oi_state.value}")
|
||||
print(f" Volatility: {state.volatility_regime.value}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
for layer in report.layers:
|
||||
print(f" {layer.name:15s} N={layer.samples:4d} eff={layer.effective_samples:.0f} "
|
||||
f"raw={layer.raw_winrate or 0:.1%} post={layer.posterior_winrate:.1%} "
|
||||
f"ret={layer.avg_return or 0:+.1f}%")
|
||||
|
||||
print(f"{'='*60}")
|
||||
print(f" Final: {report.final_estimate:.1%} "
|
||||
f"(sufficiency={report.sufficiency.value}, source={report.source})")
|
||||
if report.profit_factor:
|
||||
print(f" PF={report.profit_factor} MAE={report.max_adverse_excursion}%")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ChanMacro — Crypto Market Memory System"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", help="Commands")
|
||||
|
||||
# fetch
|
||||
p_fetch = sub.add_parser("fetch", help="Fetch raw data")
|
||||
p_fetch.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||
p_fetch.add_argument("--module", choices=["ohlcv", "breadth", "derivatives", "all"])
|
||||
|
||||
# score
|
||||
p_score = sub.add_parser("score", help="Compute scores and regime")
|
||||
p_score.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||
|
||||
# regime
|
||||
p_regime = sub.add_parser("regime", help="Show regime history")
|
||||
p_regime.add_argument("--days", type=int, default=30)
|
||||
|
||||
# track
|
||||
p_track = sub.add_parser("track", help="Record a trading signal")
|
||||
p_track.add_argument("--date", help="Signal date (YYYY-MM-DD)")
|
||||
p_track.add_argument("--signal", required=True, help="Signal type (B1/B2/B3/S1/S2/S3)")
|
||||
p_track.add_argument("--price", type=float, required=True, help="Entry price")
|
||||
p_track.add_argument("--grade", choices=["A", "B", "C"], help="Signal quality grade")
|
||||
p_track.add_argument("--strength", type=float, help="Signal strength 0-100")
|
||||
|
||||
# backfill
|
||||
p_backfill = sub.add_parser("backfill", help="Backfill historical scores")
|
||||
p_backfill.add_argument("--from", dest="from_date", required=True)
|
||||
p_backfill.add_argument("--to", dest="to_date")
|
||||
|
||||
# expectancy
|
||||
p_expectancy = sub.add_parser("expectancy", help="Query signal expectancy")
|
||||
p_expectancy.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||
p_expectancy.add_argument("--signal", default="B3", help="Signal type")
|
||||
|
||||
# validate
|
||||
p_validate = sub.add_parser("validate", help="Run validation framework")
|
||||
|
||||
# cron
|
||||
p_cron = sub.add_parser("cron", help="Run scheduled fetch+score loop")
|
||||
# detect (Chan BSP signals)
|
||||
p_detect = sub.add_parser("detect", help="Detect Chan BSP signals and populate signal_features")
|
||||
p_detect.add_argument("--from", dest="from_date", default="2024-01-01")
|
||||
p_detect.add_argument("--to", dest="to_date")
|
||||
# serve
|
||||
p_serve = sub.add_parser("serve", help="Start web dashboard")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "fetch":
|
||||
cmd_fetch(args)
|
||||
elif args.command == "score":
|
||||
cmd_score(args)
|
||||
elif args.command == "regime":
|
||||
cmd_regime(args)
|
||||
elif args.command == "track":
|
||||
cmd_track(args)
|
||||
elif args.command == "backfill":
|
||||
cmd_backfill(args)
|
||||
elif args.command == "expectancy":
|
||||
cmd_expectancy(args)
|
||||
elif args.command == "validate":
|
||||
from validation.reporter import ValidationReporter
|
||||
report = ValidationReporter().run_all()
|
||||
print(report)
|
||||
elif args.command == "detect":
|
||||
from chan_integration import ChanSignalDetector
|
||||
start = args.from_date
|
||||
end = args.to_date or Date.today().isoformat()
|
||||
detector = ChanSignalDetector()
|
||||
count = detector.populate_signal_features(start, end)
|
||||
logger.info(f"写入 {count} 条信号记录")
|
||||
elif args.command == "serve":
|
||||
from scheduler import get_scheduler
|
||||
get_scheduler().start()
|
||||
logger.info("启动 Web Dashboard: http://127.0.0.1:8124")
|
||||
from web.app import app
|
||||
app.run(host="0.0.0.0", port=8124, debug=False)
|
||||
elif args.command == "cron":
|
||||
from scheduler import get_scheduler
|
||||
logger.info("启动后台调度器 (Ctrl+C 停止)")
|
||||
s = get_scheduler()
|
||||
s.start()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(60)
|
||||
except KeyboardInterrupt:
|
||||
s.stop()
|
||||
logger.info("调度器已停止")
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"provider_url": "https://provider.jackyu66.com",
|
||||
"db_path": "data/macro.db",
|
||||
"btc_symbol": "BTC/USDT:USDT",
|
||||
"regime_version": "v1_price_breadth_vol",
|
||||
"half_life_days": 180,
|
||||
"sufficiency_min_effective": 30,
|
||||
"sufficiency_low": 50,
|
||||
"sufficiency_medium": 100,
|
||||
"level_min_samples": 50,
|
||||
"knn_max_distance": 0.35,
|
||||
"knn_k": 200,
|
||||
"oi_price_threshold_pct": 0.5,
|
||||
"oi_oi_threshold_pct": 0.5,
|
||||
"vol_low_threshold": 2.0,
|
||||
"vol_high_threshold": 5.0,
|
||||
"vol_explosive_threshold": 10.0,
|
||||
"regime_w_price": 0.35,
|
||||
"regime_w_breadth": 0.50,
|
||||
"regime_w_vol": 0.15,
|
||||
"trend_w_price": 0.30,
|
||||
"trend_w_breadth": 0.70
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
config.py — Global configuration for ChanMacro.
|
||||
|
||||
All weights, thresholds, and paths are configurable.
|
||||
V1 weights are deliberately simple; they will be tuned via Phase 0 validation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Global configuration. Override via config.json or env vars."""
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────
|
||||
db_path: str = "data/macro.db"
|
||||
data_dir: str = "data"
|
||||
|
||||
# ── Data Provider ──────────────────────────────────────
|
||||
provider_url: str = "https://provider.jackyu66.com"
|
||||
btc_symbol: str = "BTC/USDT:USDT"
|
||||
top50_symbols: list[str] = field(default_factory=lambda: [
|
||||
"BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT",
|
||||
"BNB/USDT:USDT", "XRP/USDT:USDT", "DOGE/USDT:USDT",
|
||||
"SUI/USDT:USDT", "TON/USDT:USDT", "ZEC/USDT:USDT",
|
||||
"1000PEPE/USDT:USDT", "SAGA/USDT:USDT",
|
||||
"XAU/USDT:USDT", "XAG/USDT:USDT",
|
||||
"CL/USDT:USDT", "BILL/USDT:USDT", "BZ/USDT:USDT",
|
||||
"LAB/USDT:USDT", "CRCL/USDT:USDT", "SNDK/USDT:USDT",
|
||||
"CHIP/USDT:USDT",
|
||||
])
|
||||
|
||||
# ── Breadth ────────────────────────────────────────────
|
||||
breadth_top_n: list[int] = field(default_factory=lambda: [20, 30, 50])
|
||||
breadth_ema_period: int = 20
|
||||
breadth_new_high_window: int = 20
|
||||
|
||||
# ── Regime (factor-locked: Price + Breadth + Vol) ─────
|
||||
regime_version: str = "v1_price_breadth_vol"
|
||||
# Weights for trend_score within regime detection
|
||||
regime_w_price: float = 0.35
|
||||
regime_w_breadth: float = 0.50
|
||||
regime_w_vol: float = 0.15
|
||||
# Weights for panic_score
|
||||
regime_panic_w_anti_trend: float = 0.60
|
||||
regime_panic_w_vol_extreme: float = 0.40
|
||||
|
||||
# ── Price Structure ────────────────────────────────────
|
||||
ps_ema_fast: int = 20
|
||||
ps_ema_mid: int = 60
|
||||
ps_ema_slow: int = 120
|
||||
ps_adx_period: int = 14
|
||||
ps_adx_threshold: int = 25
|
||||
ps_atr_period: int = 14
|
||||
ps_bb_period: int = 20
|
||||
ps_roc_periods: list[int] = field(default_factory=lambda: [5, 10, 20])
|
||||
|
||||
# ── OI Matrix ──────────────────────────────────────────
|
||||
oi_price_threshold_pct: float = 0.5 # min price change% to classify
|
||||
oi_oi_threshold_pct: float = 0.5 # min OI change% to classify
|
||||
|
||||
# ── Volatility Regime ──────────────────────────────────
|
||||
vol_atr_period: int = 14
|
||||
vol_hv_short: int = 20
|
||||
vol_hv_long: int = 60
|
||||
# Thresholds (ATR/Close %)
|
||||
vol_low_threshold: float = 2.0
|
||||
vol_high_threshold: float = 5.0
|
||||
vol_explosive_threshold: float = 10.0
|
||||
|
||||
# ── Trend (L2 aggregation) ─────────────────────────────
|
||||
trend_w_price: float = 0.30
|
||||
trend_w_breadth: float = 0.70
|
||||
|
||||
# ── Maturity Score ─────────────────────────────────────
|
||||
maturity_w_trend: float = 0.50
|
||||
maturity_w_breadth: float = 0.30
|
||||
maturity_w_vol: float = 0.20
|
||||
|
||||
# ── Expectancy ─────────────────────────────────────────
|
||||
half_life_days: int = 180
|
||||
sufficiency_min_effective: int = 30
|
||||
sufficiency_low: int = 50
|
||||
sufficiency_medium: int = 100
|
||||
level_min_samples: int = 50
|
||||
knn_max_distance: float = 0.35
|
||||
knn_k: int = 200
|
||||
|
||||
# ── Validation ─────────────────────────────────────────
|
||||
min_history_days: int = 365
|
||||
regime_min_avg_duration: int = 5
|
||||
regime_max_flip_rate: float = 0.15
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path: str = "config.json") -> "Config":
|
||||
"""Load config from JSON file, overriding defaults."""
|
||||
import json
|
||||
config = cls()
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
for key, value in data.items():
|
||||
if hasattr(config, key):
|
||||
setattr(config, key, value)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return config
|
||||
|
||||
|
||||
# Global singleton
|
||||
config = Config()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
database.py — SQLite schema initialization and connection management.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = """
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- L0: Raw data tables
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ohlcv_daily (
|
||||
date TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL DEFAULT 'BTC/USDT:USDT',
|
||||
open REAL,
|
||||
high REAL,
|
||||
low REAL,
|
||||
close REAL,
|
||||
volume REAL,
|
||||
ema20 REAL,
|
||||
ema60 REAL,
|
||||
ema120 REAL,
|
||||
atr_14 REAL,
|
||||
bb_width REAL,
|
||||
adx_14 REAL,
|
||||
PRIMARY KEY (date, symbol)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS breadth_daily (
|
||||
date TEXT PRIMARY KEY,
|
||||
total_tracked INTEGER DEFAULT 50,
|
||||
advance_top50 INTEGER DEFAULT 0,
|
||||
decline_top50 INTEGER DEFAULT 0,
|
||||
above_ema20_top50 INTEGER DEFAULT 0,
|
||||
new_highs_20d_top50 INTEGER DEFAULT 0,
|
||||
btc_dominance REAL,
|
||||
advance_top20 INTEGER DEFAULT 0,
|
||||
advance_top30 INTEGER DEFAULT 0,
|
||||
above_ema20_top20 INTEGER DEFAULT 0,
|
||||
above_ema20_top30 INTEGER DEFAULT 0,
|
||||
new_highs_20d_top20 INTEGER DEFAULT 0,
|
||||
new_highs_20d_top30 INTEGER DEFAULT 0,
|
||||
fetched_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS derivatives (
|
||||
date TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL DEFAULT 'BTC/USDT:USDT',
|
||||
funding_rate REAL,
|
||||
open_interest REAL,
|
||||
oi_24h_change_pct REAL,
|
||||
long_liquidations REAL,
|
||||
short_liquidations REAL,
|
||||
basis_annualised_pct REAL,
|
||||
source TEXT DEFAULT 'binance',
|
||||
fetched_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (date, symbol)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS etf_flow (
|
||||
date TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
net_flow_million REAL NOT NULL,
|
||||
price REAL,
|
||||
source TEXT DEFAULT 'farside',
|
||||
fetched_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (date, product)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stablecoin_supply (
|
||||
date TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
chain TEXT NOT NULL DEFAULT 'all',
|
||||
supply REAL NOT NULL,
|
||||
source TEXT DEFAULT 'defillama',
|
||||
fetched_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (date, token, chain)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- L3: Regime history
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS regime_history (
|
||||
date TEXT PRIMARY KEY,
|
||||
regime TEXT NOT NULL,
|
||||
confidence REAL,
|
||||
regime_version TEXT NOT NULL DEFAULT 'v1_price_breadth_vol',
|
||||
maturity_score REAL DEFAULT 50.0,
|
||||
all_scores_json TEXT DEFAULT '{}',
|
||||
prior_regime TEXT,
|
||||
confirmation_days INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- ★ signal_features — THE moat
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signal_features (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
signal_type TEXT NOT NULL,
|
||||
signal_version TEXT NOT NULL DEFAULT 'b3_v1',
|
||||
symbol TEXT DEFAULT 'BTC/USDT:USDT',
|
||||
|
||||
-- ★★ Version control (most important fields)
|
||||
regime_version TEXT NOT NULL DEFAULT 'v1_price_breadth_vol',
|
||||
signal_grade TEXT,
|
||||
signal_strength REAL,
|
||||
|
||||
-- Market State Vector snapshot
|
||||
regime TEXT NOT NULL,
|
||||
regime_confidence REAL,
|
||||
regime_maturity_score REAL DEFAULT 50.0,
|
||||
market_state_hash TEXT,
|
||||
state_embedding TEXT DEFAULT '[]',
|
||||
breadth_top20 REAL,
|
||||
breadth_top30 REAL,
|
||||
breadth_top50 REAL,
|
||||
breadth_bucket TEXT,
|
||||
breadth_divergence REAL,
|
||||
oi_state TEXT,
|
||||
volatility_regime TEXT,
|
||||
price_structure_score REAL,
|
||||
|
||||
-- Chan context (V5+)
|
||||
chan_trend_direction TEXT,
|
||||
chan_pivot_count INTEGER,
|
||||
chan_divergence_type TEXT,
|
||||
|
||||
-- Outcomes
|
||||
entry_price REAL,
|
||||
result_1d REAL,
|
||||
result_3d REAL,
|
||||
result_5d REAL,
|
||||
result_7d REAL,
|
||||
result_14d REAL,
|
||||
max_favorable_excursion REAL,
|
||||
max_adverse_excursion REAL,
|
||||
is_win_7d INTEGER,
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_regime ON signal_features(regime);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_signal ON signal_features(signal_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_oi_state ON signal_features(oi_state);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_date ON signal_features(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_state_hash ON signal_features(market_state_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_regime_version ON signal_features(regime_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_signal_version ON signal_features(signal_version);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- Expectancy cache (raw counts, NOT posteriors)
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS expectancy_cache (
|
||||
state_hash TEXT NOT NULL,
|
||||
signal_type TEXT NOT NULL,
|
||||
wins_weighted REAL DEFAULT 0,
|
||||
losses_weighted REAL DEFAULT 0,
|
||||
sum_return_7d REAL DEFAULT 0,
|
||||
sum_return_sq_7d REAL DEFAULT 0,
|
||||
effective_samples REAL DEFAULT 0,
|
||||
sufficiency TEXT DEFAULT 'INSUFFICIENT',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (state_hash, signal_type)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- Similarity outcome (KNN weight learning, Phase D)
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS similarity_outcome (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
state_a_hash TEXT,
|
||||
state_b_hash TEXT,
|
||||
distance REAL,
|
||||
actual_return_gap REAL,
|
||||
dimension_weights_json TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- chan_context — Chan theory integration (V1 empty)
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chan_context (
|
||||
date TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL DEFAULT '1d',
|
||||
trend_direction TEXT,
|
||||
trend_strength REAL,
|
||||
pivot_count INTEGER,
|
||||
pivot_level TEXT,
|
||||
signal_type TEXT,
|
||||
signal_strength REAL,
|
||||
divergence_type TEXT,
|
||||
chan_structure_score REAL,
|
||||
alignment_score REAL,
|
||||
raw_context_json TEXT DEFAULT '{}',
|
||||
PRIMARY KEY (date, timeframe)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def init_db(db_path: str = "data/macro.db") -> sqlite3.Connection:
|
||||
"""Initialize database: create directory and all tables."""
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def get_connection(db_path: str = "data/macro.db") -> sqlite3.Connection:
|
||||
"""Get a database connection. Creates tables if first run."""
|
||||
if not os.path.exists(db_path):
|
||||
return init_db(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Expectancy Engine — Signal tracking, Bayesian inference, time decay."""
|
||||
from .tracker import SignalTracker
|
||||
from .decay import TimeDecay
|
||||
from .engine import BayesianExpectancyEngine, SufficiencyGuard
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
expectancy/decay.py — Time-weighted sample decay.
|
||||
|
||||
2024 market structure ≠ 2026 market structure.
|
||||
Recent samples get higher weight via exponential decay.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TimeDecay:
|
||||
"""Exponential time decay for sample weighting."""
|
||||
|
||||
def __init__(self, half_life_days: int = 180):
|
||||
self.half_life = half_life_days
|
||||
self._decay_rate = np.log(2) / half_life_days
|
||||
|
||||
def weight(self, sample_date: Date, reference_date: Optional[Date] = None) -> float:
|
||||
"""
|
||||
Compute decay weight for a sample.
|
||||
weight = exp(-days_ago * decay_rate)
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = Date.today()
|
||||
days = (reference_date - sample_date).days
|
||||
return np.exp(-days * self._decay_rate)
|
||||
|
||||
def weights(self, dates: list[Date], reference_date: Optional[Date] = None) -> np.ndarray:
|
||||
"""Compute decay weights for a list of dates."""
|
||||
return np.array([self.weight(d, reference_date) for d in dates])
|
||||
|
||||
def weighted_win_rate(self, wins: np.ndarray, weights: np.ndarray) -> float:
|
||||
"""Weighted win rate: sum(wins * weights) / sum(weights)."""
|
||||
total_weight = weights.sum()
|
||||
if total_weight == 0:
|
||||
return 0.0
|
||||
return float((wins * weights).sum() / total_weight)
|
||||
|
||||
def weighted_mean(self, values: np.ndarray, weights: np.ndarray) -> float:
|
||||
"""Weighted mean."""
|
||||
total_weight = weights.sum()
|
||||
if total_weight == 0:
|
||||
return 0.0
|
||||
return float((values * weights).sum() / total_weight)
|
||||
|
||||
def effective_samples(self, weights: np.ndarray) -> float:
|
||||
"""Effective number of samples after decay weighting."""
|
||||
return float(weights.sum())
|
||||
|
||||
@staticmethod
|
||||
def weight_at_age(days_ago: int, half_life_days: int = 180) -> float:
|
||||
"""Quick weight lookup for a given age in days."""
|
||||
return np.exp(-days_ago * np.log(2) / half_life_days)
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
expectancy/engine.py — Bayesian Expectancy Engine.
|
||||
|
||||
Core algorithm:
|
||||
1. LeveledExpectancy: filter layer-by-layer, stop at highest valid level
|
||||
2. Empirical Bayes prior: prior = signal's global historical winrate
|
||||
3. Dynamic Beta strength: adaptive to sample size
|
||||
4. Time decay: recent samples weighted higher (half_life=180d)
|
||||
5. SufficiencyGuard: refuse output if effective_samples < 30
|
||||
6. KNN Fallback: similarity search when strict filtering fails (Phase D)
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from models import (
|
||||
MarketStateVector, ExpectancyReport, ExpectancyLayer,
|
||||
SufficiencyLevel, MarketRegime,
|
||||
)
|
||||
from config import config
|
||||
from .decay import TimeDecay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SufficiencyGuard:
|
||||
"""Prevents trading advice from insufficient samples."""
|
||||
|
||||
def __init__(self, min_effective: int = 30, low: int = 50, medium: int = 100):
|
||||
self.MIN = min_effective
|
||||
self.LOW = low
|
||||
self.MEDIUM = medium
|
||||
|
||||
def evaluate(self, effective_samples: float) -> SufficiencyLevel:
|
||||
if effective_samples < self.MIN:
|
||||
return SufficiencyLevel.INSUFFICIENT
|
||||
elif effective_samples < self.LOW:
|
||||
return SufficiencyLevel.LOW
|
||||
elif effective_samples < self.MEDIUM:
|
||||
return SufficiencyLevel.MEDIUM
|
||||
return SufficiencyLevel.HIGH
|
||||
|
||||
|
||||
class BayesianExpectancyEngine:
|
||||
"""
|
||||
Leveled Bayesian Expectancy Engine.
|
||||
|
||||
Query layers from coarse to fine. Stop when effective_samples drops below threshold.
|
||||
Uses Empirical Bayes prior (signal's global winrate, not fixed 50%).
|
||||
"""
|
||||
|
||||
# Expectancy query levels: name → WHERE clause template
|
||||
LEVELS = [
|
||||
("Base", "signal_type = '{signal}'"),
|
||||
("+ Regime", "signal_type = '{signal}' AND regime = '{regime}'"),
|
||||
("+ Breadth", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}'"),
|
||||
("+ OI State", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}' AND oi_state = '{oi}'"),
|
||||
("+ Volatility", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}' AND oi_state = '{oi}' AND volatility_regime = '{vol}'"),
|
||||
]
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None,
|
||||
half_life_days: int = 180,
|
||||
level_min_samples: int = 50):
|
||||
self.db_path = db_path or config.db_path
|
||||
self.decay = TimeDecay(half_life_days)
|
||||
self.guard = SufficiencyGuard(
|
||||
min_effective=config.sufficiency_min_effective,
|
||||
low=config.sufficiency_low,
|
||||
medium=config.sufficiency_medium,
|
||||
)
|
||||
self.level_min = level_min_samples
|
||||
|
||||
def estimate(self, state: MarketStateVector,
|
||||
signal_type: str = "B3",
|
||||
target_date: Optional[Date] = None) -> ExpectancyReport:
|
||||
"""
|
||||
Compute layered Bayesian expectancy for a signal in current market state.
|
||||
|
||||
Returns the estimate at the deepest level with >= level_min effective samples.
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = Date.today()
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
# Get global signal winrate for Empirical Bayes prior
|
||||
global_rate = self._global_winrate(conn, signal_type)
|
||||
|
||||
layers = []
|
||||
best_result = None
|
||||
|
||||
for level_name, template in self.LEVELS:
|
||||
where = template.format(
|
||||
signal=signal_type,
|
||||
regime=state.regime.value,
|
||||
breadth=state.breadth_bucket.value,
|
||||
oi=state.oi_state.value,
|
||||
vol=state.volatility_regime.value,
|
||||
)
|
||||
query = f"SELECT * FROM signal_features WHERE {where}"
|
||||
df = pd.read_sql_query(query, conn)
|
||||
|
||||
if df.empty:
|
||||
layers.append(ExpectancyLayer(
|
||||
name=level_name, posterior_winrate=0.0,
|
||||
samples=0, effective_samples=0.0,
|
||||
))
|
||||
continue
|
||||
|
||||
# Time-weighted stats
|
||||
dates_list = [Date.fromisoformat(d) for d in df["date"]]
|
||||
weights = self.decay.weights(dates_list, target_date)
|
||||
eff_n = self.decay.effective_samples(weights)
|
||||
|
||||
wins = pd.to_numeric(df["is_win_7d"].fillna(0), errors="coerce").fillna(0).values
|
||||
returns = pd.to_numeric(df["result_7d"].fillna(0), errors="coerce").fillna(0).values
|
||||
|
||||
raw_wr = float(wins.mean()) if len(wins) > 0 else 0.0
|
||||
weighted_wr = self.decay.weighted_win_rate(wins, weights)
|
||||
weighted_ret = self.decay.weighted_mean(returns, weights)
|
||||
|
||||
# Empirical Bayes posterior
|
||||
posterior = self._bayesian_posterior(
|
||||
global_rate=global_rate,
|
||||
wins=wins.sum(),
|
||||
samples=len(df),
|
||||
)
|
||||
|
||||
layer = ExpectancyLayer(
|
||||
name=level_name,
|
||||
posterior_winrate=round(posterior, 4),
|
||||
raw_winrate=round(raw_wr, 4),
|
||||
samples=len(df),
|
||||
effective_samples=round(eff_n, 1),
|
||||
avg_return=round(weighted_ret, 2),
|
||||
)
|
||||
layers.append(layer)
|
||||
|
||||
# Level-based fallback: keep going while samples sufficient
|
||||
if eff_n >= self.level_min:
|
||||
best_result = layer
|
||||
|
||||
conn.close()
|
||||
|
||||
if best_result is None and layers:
|
||||
# Fallback to the deepest layer that had any samples
|
||||
for layer in reversed(layers):
|
||||
if layer.samples > 0:
|
||||
best_result = layer
|
||||
break
|
||||
|
||||
if best_result is None:
|
||||
return ExpectancyReport(
|
||||
signal_type=signal_type,
|
||||
date=target_date,
|
||||
layers=layers,
|
||||
final_estimate=0.0,
|
||||
sufficiency=SufficiencyLevel.INSUFFICIENT,
|
||||
source="insufficient",
|
||||
)
|
||||
|
||||
sufficiency = self.guard.evaluate(
|
||||
best_result.effective_samples
|
||||
)
|
||||
|
||||
# Compute profit factor and MAE from the SAME level as best_result
|
||||
profit_factor = None
|
||||
avg_mae = None
|
||||
if best_result and best_result.samples > 0:
|
||||
# Re-query the level that produced best_result
|
||||
best_level_idx = next(
|
||||
i for i, l in enumerate(layers) if l.name == best_result.name
|
||||
)
|
||||
where = self.LEVELS[best_level_idx][1].format(
|
||||
signal=signal_type, regime=state.regime.value,
|
||||
breadth=state.breadth_bucket.value, oi=state.oi_state.value,
|
||||
vol=state.volatility_regime.value,
|
||||
)
|
||||
query = f"SELECT result_7d, max_adverse_excursion FROM signal_features WHERE {where}"
|
||||
conn2 = sqlite3.connect(self.db_path)
|
||||
df_detail = pd.read_sql_query(query, conn2)
|
||||
conn2.close()
|
||||
if not df_detail.empty:
|
||||
returns_7d = df_detail["result_7d"].dropna()
|
||||
if len(returns_7d) > 0:
|
||||
gains = returns_7d[returns_7d > 0].sum()
|
||||
losses = abs(returns_7d[returns_7d < 0].sum())
|
||||
profit_factor = round(gains / losses, 2) if losses > 0 else None
|
||||
maes = df_detail["max_adverse_excursion"].dropna()
|
||||
if len(maes) > 0:
|
||||
avg_mae = round(float(maes.mean()), 2)
|
||||
|
||||
return ExpectancyReport(
|
||||
signal_type=signal_type,
|
||||
date=target_date,
|
||||
layers=layers,
|
||||
final_estimate=round(best_result.posterior_winrate, 4),
|
||||
sufficiency=sufficiency,
|
||||
prior_strength=self._prior_strength(best_result.samples),
|
||||
half_life_days=self.decay.half_life,
|
||||
source="bayesian",
|
||||
avg_return_7d=best_result.avg_return,
|
||||
profit_factor=profit_factor,
|
||||
max_adverse_excursion=avg_mae,
|
||||
)
|
||||
|
||||
def _global_winrate(self, conn: sqlite3.Connection,
|
||||
signal_type: str) -> float:
|
||||
"""Get global historical winrate for a signal type (Empirical Bayes prior)."""
|
||||
row = conn.execute(
|
||||
"SELECT AVG(is_win_7d) as wr, COUNT(*) as cnt "
|
||||
"FROM signal_features WHERE signal_type = ? AND is_win_7d IS NOT NULL",
|
||||
(signal_type,)
|
||||
).fetchone()
|
||||
if row and row[1] and row[1] > 0:
|
||||
return float(row[0])
|
||||
return 0.50 # default: neutral
|
||||
|
||||
def _prior_strength(self, samples: int) -> int:
|
||||
"""Dynamic prior strength based on sample count."""
|
||||
if samples < 100:
|
||||
return 20 # Beta(10,10)
|
||||
elif samples < 500:
|
||||
return 40 # Beta(20,20)
|
||||
else:
|
||||
return 100 # Beta(50,50) — data dominates
|
||||
|
||||
def _bayesian_posterior(self, global_rate: float, wins: float,
|
||||
samples: int) -> float:
|
||||
"""
|
||||
Empirical Bayes posterior: prior = global signal winrate.
|
||||
|
||||
posterior = (alpha + wins) / (alpha + beta + samples)
|
||||
where alpha/(alpha+beta) = global_rate
|
||||
"""
|
||||
prior_strength = self._prior_strength(samples)
|
||||
alpha = max(global_rate * prior_strength, 1.0) # floor at 1 to ensure shrinkage
|
||||
beta = max((1 - global_rate) * prior_strength, 1.0)
|
||||
return (alpha + wins) / (alpha + beta + samples)
|
||||
|
||||
def precompute_cache(self):
|
||||
"""
|
||||
Precompute expectancy for all state_hashes in signal_features.
|
||||
Populates expectancy_cache table with raw weighted counts (not posteriors).
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
hashes = conn.execute(
|
||||
"SELECT DISTINCT market_state_hash, signal_type FROM signal_features"
|
||||
).fetchall()
|
||||
|
||||
today = Date.today()
|
||||
count = 0
|
||||
|
||||
for row in hashes:
|
||||
h = row["market_state_hash"]
|
||||
sig = row["signal_type"]
|
||||
|
||||
df = pd.read_sql_query(
|
||||
"SELECT date, is_win_7d, result_7d "
|
||||
"FROM signal_features WHERE market_state_hash = ? AND signal_type = ?",
|
||||
conn, params=(h, sig)
|
||||
)
|
||||
|
||||
if df.empty:
|
||||
continue
|
||||
|
||||
dates_list = [Date.fromisoformat(d) for d in df["date"]]
|
||||
weights = self.decay.weights(dates_list, today)
|
||||
wins_w = (df["is_win_7d"].fillna(0).values * weights).sum()
|
||||
losses_w = ((1 - df["is_win_7d"].fillna(0)).values * weights).sum()
|
||||
ret_sum = (df["result_7d"].fillna(0).values * weights).sum()
|
||||
ret_sq = ((df["result_7d"].fillna(0).values ** 2) * weights).sum()
|
||||
eff_n = weights.sum()
|
||||
|
||||
sufficiency = self.guard.evaluate(eff_n).value
|
||||
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO expectancy_cache
|
||||
(state_hash, signal_type, wins_weighted, losses_weighted,
|
||||
sum_return_7d, sum_return_sq_7d, effective_samples, sufficiency)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (h, sig, wins_w, losses_w, ret_sum, ret_sq, eff_n, sufficiency))
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Precomputed expectancy cache: {count} state×signal combos")
|
||||
return count
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
expectancy/tracker.py — SignalTracker: records signals with full market state
|
||||
and computes forward outcomes.
|
||||
|
||||
This is the entry point for populating signal_features — THE moat table.
|
||||
"""
|
||||
|
||||
from datetime import date as Date, timedelta
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from models import (
|
||||
MarketStateVector, SignalFeatureRecord, MarketRegime,
|
||||
OIState, BreadthBucket, VolRegime, SignalGrade,
|
||||
)
|
||||
from config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SignalTracker:
|
||||
"""
|
||||
Records trading signals with full market state context.
|
||||
|
||||
Usage:
|
||||
tracker = SignalTracker()
|
||||
tracker.record(
|
||||
date=Date(2026, 6, 24),
|
||||
signal_type="B3",
|
||||
entry_price=96500.0,
|
||||
state=market_state_vector, # from scoring pipeline
|
||||
signal_grade="A",
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def record(self, date: Date, signal_type: str, entry_price: float,
|
||||
state: MarketStateVector,
|
||||
signal_version: str = "b3_v1",
|
||||
signal_grade: Optional[str] = None,
|
||||
signal_strength: Optional[float] = None) -> int:
|
||||
"""
|
||||
Record a signal with market state snapshot and compute forward outcomes.
|
||||
|
||||
Returns the record ID in signal_features.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
# Compute forward outcomes
|
||||
outcomes = self._compute_outcomes(conn, date, entry_price)
|
||||
|
||||
# Build embedding
|
||||
embedding = json.dumps(state.state_embedding())
|
||||
|
||||
record_id = conn.execute("""
|
||||
INSERT INTO signal_features
|
||||
(date, signal_type, signal_version, symbol,
|
||||
regime_version, signal_grade, signal_strength,
|
||||
regime, regime_confidence, regime_maturity_score,
|
||||
market_state_hash, state_embedding,
|
||||
breadth_top20, breadth_top30, breadth_top50,
|
||||
breadth_bucket, breadth_divergence,
|
||||
oi_state, volatility_regime, price_structure_score,
|
||||
entry_price,
|
||||
result_1d, result_3d, result_5d, result_7d, result_14d,
|
||||
max_favorable_excursion, max_adverse_excursion,
|
||||
is_win_7d)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?,
|
||||
?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
?)
|
||||
""", (
|
||||
str(date), signal_type, signal_version, state.symbol,
|
||||
state.regime_version, signal_grade, signal_strength,
|
||||
state.regime.value, state.regime_confidence, state.regime_maturity_score,
|
||||
state.market_state_hash, embedding,
|
||||
state.breadth_top20, state.breadth_top30, state.breadth_top50,
|
||||
state.breadth_bucket.value, state.breadth_divergence,
|
||||
state.oi_state.value, state.volatility_regime.value,
|
||||
state.price_structure_score.score,
|
||||
entry_price,
|
||||
outcomes.get("result_1d"), outcomes.get("result_3d"),
|
||||
outcomes.get("result_5d"), outcomes.get("result_7d"),
|
||||
outcomes.get("result_14d"),
|
||||
outcomes.get("mfe"), outcomes.get("mae"),
|
||||
outcomes.get("is_win_7d"),
|
||||
)).lastrowid
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
is_win = outcomes.get("is_win_7d", 0)
|
||||
ret_7d = outcomes.get("result_7d", 0) or 0
|
||||
logger.info(
|
||||
f"Recorded {signal_type} on {date} @ {entry_price:.0f} "
|
||||
f"(regime={state.regime.value}, breadth={state.breadth_bucket.value}, "
|
||||
f"oi={state.oi_state.value}) → 7d={ret_7d:+.1f}%"
|
||||
)
|
||||
return record_id
|
||||
|
||||
def _compute_outcomes(self, conn: sqlite3.Connection, date: Date,
|
||||
entry_price: float) -> dict:
|
||||
"""
|
||||
Compute forward returns, MFE, MAE from OHLCV data.
|
||||
|
||||
Queries future daily bars relative to the signal date.
|
||||
"""
|
||||
# Get future OHLCV data
|
||||
df = pd.read_sql_query(
|
||||
"SELECT date, high, low, close FROM ohlcv_daily "
|
||||
"WHERE date > ? AND symbol = 'BTC/USDT:USDT' "
|
||||
"ORDER BY date ASC LIMIT 20",
|
||||
conn, params=(str(date),)
|
||||
)
|
||||
|
||||
if df.empty:
|
||||
return {}
|
||||
|
||||
outcomes = {}
|
||||
entry = entry_price
|
||||
|
||||
# Forward returns
|
||||
for horizon_days, col in [(1, "result_1d"), (3, "result_3d"),
|
||||
(5, "result_5d"), (7, "result_7d"),
|
||||
(14, "result_14d")]:
|
||||
if len(df) >= horizon_days:
|
||||
exit_price = float(df.iloc[horizon_days - 1]["close"])
|
||||
outcomes[col] = round((exit_price - entry) / entry * 100, 2)
|
||||
|
||||
# MFE / MAE
|
||||
if len(df) > 0:
|
||||
highs = df["high"].astype(float).values[:14]
|
||||
lows = df["low"].astype(float).values[:14]
|
||||
outcomes["mfe"] = round((max(highs) - entry) / entry * 100, 2)
|
||||
outcomes["mae"] = round((min(lows) - entry) / entry * 100, 2)
|
||||
|
||||
# is_win_7d
|
||||
outcomes["is_win_7d"] = 1 if outcomes.get("result_7d", 0) > 0 else 0
|
||||
|
||||
return outcomes
|
||||
|
||||
def backfill_signals(self, signals: list[dict]) -> int:
|
||||
"""
|
||||
Backfill multiple signals from historical data.
|
||||
|
||||
Each signal dict:
|
||||
{"date": Date, "signal_type": str, "entry_price": float,
|
||||
"signal_grade": str (optional), "signal_strength": float (optional)}
|
||||
|
||||
This requires the scoring pipeline to have been run for those dates
|
||||
(breadth_daily, ohlcv_daily, derivatives all populated).
|
||||
"""
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
|
||||
detector = RegimeDetector()
|
||||
count = 0
|
||||
|
||||
for sig in signals:
|
||||
target = sig["date"]
|
||||
try:
|
||||
# Compute market state for this date
|
||||
ps = PriceStructureScorer(self.db_path).compute(target)
|
||||
br = BreadthScorer(self.db_path).compute(target)
|
||||
oi = OIMatrixScorer(self.db_path).compute(target)
|
||||
vol = VolatilityRegimeScorer(self.db_path).compute(target)
|
||||
|
||||
regime_result = detector.detect(
|
||||
price_structure_score=ps.score,
|
||||
breadth_score=br.breadth_top50,
|
||||
volatility_regime=vol.vol_regime.value,
|
||||
date=target,
|
||||
)
|
||||
|
||||
state = MarketStateVector(
|
||||
date=target,
|
||||
regime=regime_result.regime,
|
||||
regime_confidence=regime_result.confidence,
|
||||
regime_version=regime_result.regime_version,
|
||||
regime_maturity_score=regime_result.maturity_score,
|
||||
breadth_top20=br.breadth_top20,
|
||||
breadth_top30=br.breadth_top30,
|
||||
breadth_top50=br.breadth_top50,
|
||||
breadth_bucket=br.breadth_bucket,
|
||||
breadth_divergence=br.breadth_divergence,
|
||||
oi_state=oi.oi_state,
|
||||
volatility_regime=vol.vol_regime,
|
||||
price_structure_score=ps,
|
||||
breadth_score=br,
|
||||
oi_matrix_score=oi,
|
||||
volatility_regime_score=vol,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
|
||||
self.record(
|
||||
date=target,
|
||||
signal_type=sig["signal_type"],
|
||||
entry_price=sig["entry_price"],
|
||||
state=state,
|
||||
signal_grade=sig.get("signal_grade"),
|
||||
signal_strength=sig.get("signal_strength"),
|
||||
)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to backfill {sig['signal_type']} on {target}: {e}")
|
||||
|
||||
return count
|
||||
|
||||
def get_samples(self, signal_type: Optional[str] = None,
|
||||
regime: Optional[str] = None,
|
||||
breadth_bucket: Optional[str] = None,
|
||||
oi_state: Optional[str] = None,
|
||||
volatility_regime: Optional[str] = None,
|
||||
limit: int = 5000) -> list[dict]:
|
||||
"""Query signal_features with optional filters."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
query = "SELECT * FROM signal_features WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if signal_type:
|
||||
query += " AND signal_type = ?"
|
||||
params.append(signal_type)
|
||||
if regime:
|
||||
query += " AND regime = ?"
|
||||
params.append(regime)
|
||||
if breadth_bucket:
|
||||
query += " AND breadth_bucket = ?"
|
||||
params.append(breadth_bucket)
|
||||
if oi_state:
|
||||
query += " AND oi_state = ?"
|
||||
params.append(oi_state)
|
||||
if volatility_regime:
|
||||
query += " AND volatility_regime = ?"
|
||||
params.append(volatility_regime)
|
||||
|
||||
query += " ORDER BY date DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def count_samples(self) -> dict:
|
||||
"""Count signal_features by signal_type and regime."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
rows = conn.execute("""
|
||||
SELECT signal_type, regime, COUNT(*) as cnt
|
||||
FROM signal_features
|
||||
GROUP BY signal_type, regime
|
||||
ORDER BY signal_type, regime
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return {f"{r[0]}/{r[1]}": r[2] for r in rows}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Data fetchers — L0 raw data acquisition."""
|
||||
from .base import BaseFetcher
|
||||
from .ohlcv import OHLCVFetcher
|
||||
from .breadth import BreadthFetcher
|
||||
from .derivatives import DerivativesFetcher
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
fetchers/base.py — Abstract base class for all macro data fetchers.
|
||||
|
||||
Provides retry logic, rate limiting, and a common interface.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
|
||||
|
||||
class BaseFetcher(ABC):
|
||||
"""Abstract base for all macro data fetchers."""
|
||||
|
||||
def __init__(self, timeout: int = 30, max_retries: int = 3):
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
def _get(self, url: str, params: Optional[dict] = None,
|
||||
headers: Optional[dict] = None) -> dict:
|
||||
"""GET with retry and exponential backoff."""
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, params=params, headers=headers, timeout=self.timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.RequestException as e:
|
||||
wait = 2 ** attempt
|
||||
self.logger.warning(
|
||||
f"Request failed (attempt {attempt+1}/{self.max_retries}): {e}. "
|
||||
f"Retrying in {wait}s"
|
||||
)
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(wait)
|
||||
else:
|
||||
raise
|
||||
|
||||
def _get_raw(self, url: str, params: Optional[dict] = None,
|
||||
headers: Optional[dict] = None) -> bytes:
|
||||
"""GET raw bytes with retry (for non-JSON endpoints)."""
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, params=params, headers=headers, timeout=self.timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except requests.RequestException as e:
|
||||
wait = 2 ** attempt
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(wait)
|
||||
else:
|
||||
raise
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self, target_date: Optional[Date] = None) -> list[dict]:
|
||||
"""Fetch raw data. Returns list of record dicts."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def store(self, db_path: str, records: list[dict]) -> int:
|
||||
"""Store raw records into SQLite. Returns count of new rows."""
|
||||
...
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
fetchers/breadth.py — Fetches TOP50 OHLCV and computes market breadth metrics.
|
||||
|
||||
Multi-tier: Top20 / Top30 / Top50 for advance/decline, EMA20%, new highs, BTC.D.
|
||||
"""
|
||||
|
||||
from datetime import date as Date, datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from .base import BaseFetcher
|
||||
from config import config
|
||||
|
||||
|
||||
class BreadthFetcher(BaseFetcher):
|
||||
"""Fetches TOP50 coin OHLCV data and computes breadth metrics."""
|
||||
|
||||
def __init__(self, provider_url: Optional[str] = None):
|
||||
super().__init__(timeout=60, max_retries=3)
|
||||
self.provider_url = provider_url or config.provider_url
|
||||
self.symbols = config.top50_symbols
|
||||
self.ema_period = config.breadth_ema_period
|
||||
self.new_high_window = config.breadth_new_high_window
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def fetch(self, target_date: Optional[Date] = None) -> dict:
|
||||
"""
|
||||
Fetch daily OHLCV for all TOP50 symbols and compute breadth.
|
||||
|
||||
Returns a dict suitable for storing in breadth_daily table.
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = Date.today()
|
||||
|
||||
# Fetch last 60 days of daily data for each symbol to compute EMAs and new highs
|
||||
all_data = {}
|
||||
for symbol in self.symbols:
|
||||
try:
|
||||
df = self._fetch_symbol(symbol)
|
||||
if df is not None and not df.empty:
|
||||
all_data[symbol] = df
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to fetch {symbol}: {e}")
|
||||
|
||||
if not all_data:
|
||||
self.logger.error("No symbol data fetched for breadth")
|
||||
return {}
|
||||
|
||||
# Compute breadth metrics for the target date
|
||||
breadth = self._compute_breadth(all_data, target_date)
|
||||
return breadth
|
||||
|
||||
def _fetch_symbol(self, symbol: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch daily OHLCV for a single symbol."""
|
||||
url = f"{self.provider_url}/api/candles"
|
||||
params = {
|
||||
"symbol": symbol,
|
||||
"tf": "1d",
|
||||
"limit": 100,
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not data:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df["date"] = df["timestamp"].dt.date
|
||||
df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True)
|
||||
df["close"] = df["close"].astype(float)
|
||||
df["ema20"] = df["close"].ewm(span=self.ema_period, adjust=False).mean()
|
||||
return df
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _compute_breadth(self, all_data: dict, target_date: Date) -> dict:
|
||||
"""Compute breadth metrics for a specific date across all symbols."""
|
||||
total = len(all_data)
|
||||
|
||||
advances_50 = declines_50 = 0
|
||||
above_ema20_50 = 0
|
||||
new_highs_50 = 0
|
||||
advances_30 = declines_30 = 0
|
||||
above_ema20_30 = 0
|
||||
new_highs_30 = 0
|
||||
advances_20 = declines_20 = 0
|
||||
above_ema20_20 = 0
|
||||
new_highs_20 = 0
|
||||
|
||||
for i, (symbol, df) in enumerate(all_data.items()):
|
||||
# Get data for target date
|
||||
df["date_str"] = df["date"].astype(str)
|
||||
target_str = str(target_date)
|
||||
idx = df[df["date_str"] == target_str].index
|
||||
|
||||
if len(idx) == 0:
|
||||
continue
|
||||
|
||||
row_idx = idx[0]
|
||||
if row_idx < 1:
|
||||
continue
|
||||
|
||||
current_close = df.loc[row_idx, "close"]
|
||||
prev_close = df.loc[row_idx - 1, "close"]
|
||||
|
||||
# Advance/Decline
|
||||
if current_close > prev_close:
|
||||
if i < 50: advances_50 += 1
|
||||
if i < 30: advances_30 += 1
|
||||
if i < 20: advances_20 += 1
|
||||
elif current_close < prev_close:
|
||||
if i < 50: declines_50 += 1
|
||||
if i < 30: declines_30 += 1
|
||||
if i < 20: declines_20 += 1
|
||||
|
||||
# Above EMA20
|
||||
ema20_val = df.loc[row_idx, "ema20"]
|
||||
if not pd.isna(ema20_val) and current_close > ema20_val:
|
||||
if i < 50: above_ema20_50 += 1
|
||||
if i < 30: above_ema20_30 += 1
|
||||
if i < 20: above_ema20_20 += 1
|
||||
|
||||
# New 20-day highs
|
||||
lookback_start = max(0, row_idx - self.new_high_window)
|
||||
recent_highs = df.loc[lookback_start:row_idx - 1, "high"].astype(float)
|
||||
current_high = df.loc[row_idx, "high"]
|
||||
if len(recent_highs) > 0 and float(current_high) > recent_highs.max():
|
||||
if i < 50: new_highs_50 += 1
|
||||
if i < 30: new_highs_30 += 1
|
||||
if i < 20: new_highs_20 += 1
|
||||
|
||||
return {
|
||||
"date": str(target_date),
|
||||
"total_tracked": total,
|
||||
"advance_top50": advances_50,
|
||||
"decline_top50": declines_50,
|
||||
"above_ema20_top50": above_ema20_50,
|
||||
"new_highs_20d_top50": new_highs_50,
|
||||
"advance_top30": advances_30,
|
||||
"advance_top20": advances_20,
|
||||
"above_ema20_top30": above_ema20_30,
|
||||
"above_ema20_top20": above_ema20_20,
|
||||
"new_highs_20d_top30": new_highs_30,
|
||||
"new_highs_20d_top20": new_highs_20,
|
||||
"btc_dominance": None, # Reserved for Coinglass API integration
|
||||
}
|
||||
|
||||
def store(self, db_path: Optional[str] = None, record: Optional[dict] = None) -> int:
|
||||
"""Store a breadth record into SQLite. Returns 1 if inserted/updated."""
|
||||
import sqlite3
|
||||
db_path = db_path or config.db_path
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
if record is None:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO breadth_daily
|
||||
(date, total_tracked,
|
||||
advance_top50, decline_top50, above_ema20_top50, new_highs_20d_top50,
|
||||
advance_top30, advance_top20,
|
||||
above_ema20_top30, above_ema20_top20,
|
||||
new_highs_20d_top30, new_highs_20d_top20,
|
||||
btc_dominance)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
record["date"], record.get("total_tracked", 50),
|
||||
record.get("advance_top50", 0), record.get("decline_top50", 0),
|
||||
record.get("above_ema20_top50", 0), record.get("new_highs_20d_top50", 0),
|
||||
record.get("advance_top30", 0), record.get("advance_top20", 0),
|
||||
record.get("above_ema20_top30", 0), record.get("above_ema20_top20", 0),
|
||||
record.get("new_highs_20d_top30", 0), record.get("new_highs_20d_top20", 0),
|
||||
record.get("btc_dominance"),
|
||||
))
|
||||
conn.commit()
|
||||
return 1
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to store breadth: {e}")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
fetchers/derivatives.py — Fetches derivatives data from data_provider API.
|
||||
|
||||
Clean consumer: no direct ccxt dependency. Just HTTP GET /api/derivatives.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .base import BaseFetcher
|
||||
from config import config
|
||||
|
||||
|
||||
class DerivativesFetcher(BaseFetcher):
|
||||
"""Fetches derivatives snapshot from data_provider /api/derivatives."""
|
||||
|
||||
def __init__(self, provider_url: Optional[str] = None):
|
||||
super().__init__(timeout=15, max_retries=3)
|
||||
self.provider_url = provider_url or config.provider_url
|
||||
|
||||
def fetch(self, target_date: Optional[Date] = None) -> list[dict]:
|
||||
"""Fetch derivatives data. Returns list with one record dict."""
|
||||
url = f"{self.provider_url}/api/derivatives"
|
||||
params = {"symbol": config.btc_symbol}
|
||||
try:
|
||||
data = self._get(url, params=params)
|
||||
record = {
|
||||
"date": str(target_date or Date.today()),
|
||||
"symbol": config.btc_symbol,
|
||||
"funding_rate": data.get("funding_rate"),
|
||||
"open_interest": data.get("open_interest"),
|
||||
"oi_24h_change_pct": data.get("oi_change_pct"),
|
||||
"basis_annualised_pct": data.get("basis"),
|
||||
"source": "data_provider",
|
||||
}
|
||||
return [record]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def store(self, db_path: Optional[str] = None, records: Optional[list[dict]] = None) -> int:
|
||||
"""Store derivatives records into SQLite."""
|
||||
import sqlite3
|
||||
db_path = db_path or config.db_path
|
||||
records = records or []
|
||||
conn = sqlite3.connect(db_path)
|
||||
count = 0
|
||||
for r in records:
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO derivatives
|
||||
(date, symbol, funding_rate, open_interest, oi_24h_change_pct,
|
||||
long_liquidations, short_liquidations, basis_annualised_pct)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?)
|
||||
""", (
|
||||
r["date"], r.get("symbol", config.btc_symbol),
|
||||
r.get("funding_rate"), r.get("open_interest"),
|
||||
r.get("oi_24h_change_pct"), r.get("basis_annualised_pct"),
|
||||
))
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return count
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
fetchers/ohlcv.py — Fetches BTC daily OHLCV from the existing data_provider service.
|
||||
|
||||
Also pre-computes EMA20/60/120, ATR(14), BB width, ADX(14).
|
||||
"""
|
||||
|
||||
from datetime import date as Date, datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from .base import BaseFetcher
|
||||
from config import config
|
||||
|
||||
|
||||
class OHLCVFetcher(BaseFetcher):
|
||||
"""Fetches BTC daily K-line data from data_provider API."""
|
||||
|
||||
def __init__(self, provider_url: Optional[str] = None):
|
||||
super().__init__(timeout=30, max_retries=3)
|
||||
self.provider_url = provider_url or config.provider_url
|
||||
self.symbol = config.btc_symbol
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def fetch(self, target_date: Optional[Date] = None) -> pd.DataFrame:
|
||||
"""
|
||||
Fetch daily OHLCV for BTC. Returns DataFrame with computed indicators.
|
||||
|
||||
Fetches enough history (200 bars) to compute EMAs/ATR/BB/ADX accurately.
|
||||
"""
|
||||
url = f"{self.provider_url}/api/candles"
|
||||
params = {
|
||||
"symbol": self.symbol,
|
||||
"tf": "1d",
|
||||
"limit": 200,
|
||||
}
|
||||
resp = requests.get(url, params=params, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if not data:
|
||||
self.logger.warning("OHLCV API returned empty data")
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df["date"] = df["timestamp"].dt.date
|
||||
df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True)
|
||||
|
||||
# Rename columns to match expected format
|
||||
df = df.rename(columns={
|
||||
"open": "open", "high": "high", "low": "low", "close": "close",
|
||||
"volume": "volume",
|
||||
})
|
||||
|
||||
# Compute indicators
|
||||
df = self._add_indicators(df)
|
||||
|
||||
return df
|
||||
|
||||
def _add_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Add EMA, ATR, BB, ADX indicators."""
|
||||
close = df["close"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
|
||||
# EMAs
|
||||
df["ema20"] = close.ewm(span=20, adjust=False).mean()
|
||||
df["ema60"] = close.ewm(span=60, adjust=False).mean()
|
||||
df["ema120"] = close.ewm(span=120, adjust=False).mean()
|
||||
|
||||
# ATR(14)
|
||||
tr1 = high - low
|
||||
tr2 = (high - close.shift(1)).abs()
|
||||
tr3 = (low - close.shift(1)).abs()
|
||||
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
||||
df["atr_14"] = tr.rolling(14).mean()
|
||||
|
||||
# Bollinger Bands width
|
||||
sma20 = close.rolling(20).mean()
|
||||
std20 = close.rolling(20).std()
|
||||
df["bb_width"] = (2 * std20) / sma20 * 100 # as percentage
|
||||
|
||||
# ADX(14)
|
||||
df["adx_14"] = self._compute_adx(df, period=14)
|
||||
|
||||
return df
|
||||
|
||||
@staticmethod
|
||||
def _compute_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
"""Compute ADX from OHLC data."""
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
|
||||
plus_dm = high.diff()
|
||||
minus_dm = low.diff().abs() * -1
|
||||
plus_dm = plus_dm.where(plus_dm > 0, 0)
|
||||
minus_dm = minus_dm.where(minus_dm < 0, 0).abs()
|
||||
|
||||
tr1 = high - low
|
||||
tr2 = (high - close.shift(1)).abs()
|
||||
tr3 = (low - close.shift(1)).abs()
|
||||
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
||||
|
||||
atr = tr.rolling(period).mean()
|
||||
plus_di = 100 * (plus_dm.rolling(period).mean() / atr)
|
||||
minus_di = 100 * (minus_dm.rolling(period).mean() / atr)
|
||||
|
||||
dx = (abs(plus_di - minus_di) / (plus_di + minus_di)) * 100
|
||||
adx = dx.rolling(period).mean()
|
||||
return adx
|
||||
|
||||
def store(self, db_path: str, records: list[dict]) -> int:
|
||||
"""Store OHLCV records into SQLite. Not used directly — see store_df."""
|
||||
return 0
|
||||
|
||||
def store_df(self, df: pd.DataFrame, db_path: Optional[str] = None) -> int:
|
||||
"""Store the DataFrame into the ohlcv_daily table."""
|
||||
import sqlite3
|
||||
db_path = db_path or config.db_path
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
count = 0
|
||||
for _, row in df.iterrows():
|
||||
if pd.isna(row.get("date")):
|
||||
continue
|
||||
date_str = str(row["date"])
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO ohlcv_daily
|
||||
(date, symbol, open, high, low, close, volume,
|
||||
ema20, ema60, ema120, atr_14, bb_width, adx_14)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
date_str, self.symbol,
|
||||
float(row["open"]), float(row["high"]),
|
||||
float(row["low"]), float(row["close"]),
|
||||
float(row.get("volume", 0)),
|
||||
float(row["ema20"]) if not pd.isna(row.get("ema20")) else None,
|
||||
float(row["ema60"]) if not pd.isna(row.get("ema60")) else None,
|
||||
float(row["ema120"]) if not pd.isna(row.get("ema120")) else None,
|
||||
float(row["atr_14"]) if not pd.isna(row.get("atr_14")) else None,
|
||||
float(row["bb_width"]) if not pd.isna(row.get("bb_width")) else None,
|
||||
float(row["adx_14"]) if not pd.isna(row.get("adx_14")) else None,
|
||||
))
|
||||
count += 1
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Skip row {date_str}: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self.logger.info(f"Stored {count} OHLCV rows")
|
||||
return count
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
main.py — ChanMacro entry point.
|
||||
|
||||
CLI: python main.py fetch|score|regime|serve
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure package root is on path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
models.py — Pydantic v2 models and enums for ChanMacro.
|
||||
|
||||
All market state types, factor scores, and database record models.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Shared validators
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_date(v):
|
||||
"""Reusable date-string parser for field_validator."""
|
||||
if isinstance(v, str):
|
||||
return Date.fromisoformat(v)
|
||||
return v
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Enums
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class MarketRegime(str, Enum):
|
||||
"""V1: 3-state regime (factor-locked: Price + Breadth + Vol)."""
|
||||
TREND = "TREND"
|
||||
RANGE = "RANGE"
|
||||
PANIC = "PANIC"
|
||||
|
||||
|
||||
class OIState(str, Enum):
|
||||
"""Discrete OI × Price state machine. NOT compressed into a score."""
|
||||
NEW_LONGS = "New Longs"
|
||||
SHORT_COVERING = "Short Covering"
|
||||
NEW_SHORTS = "New Shorts"
|
||||
LONG_EXIT = "Long Exit"
|
||||
NEUTRAL = "Neutral"
|
||||
|
||||
|
||||
class BreadthBucket(str, Enum):
|
||||
"""Quantile-based breadth buckets — always have samples regardless of cycle."""
|
||||
EXTREME = "EXTREME"
|
||||
STRONG = "STRONG"
|
||||
NORMAL = "NORMAL"
|
||||
WEAK = "WEAK"
|
||||
PANIC = "PANIC"
|
||||
|
||||
|
||||
class VolRegime(str, Enum):
|
||||
"""Volatility regime classification."""
|
||||
LOW_VOL = "LOW_VOL"
|
||||
NORMAL_VOL = "NORMAL_VOL"
|
||||
HIGH_VOL = "HIGH_VOL"
|
||||
EXPLOSIVE_VOL = "EXPLOSIVE_VOL"
|
||||
|
||||
|
||||
class MacroDirection(str, Enum):
|
||||
BULLISH = "bullish"
|
||||
NEUTRAL = "neutral"
|
||||
BEARISH = "bearish"
|
||||
|
||||
|
||||
class MarketEmotion(str, Enum):
|
||||
EXTREME_FEAR = "Extreme Fear"
|
||||
FEAR = "Fear"
|
||||
NEUTRAL = "Neutral"
|
||||
GREED = "Greed"
|
||||
EXTREME_GREED = "Extreme Greed"
|
||||
|
||||
|
||||
class FlowState(str, Enum):
|
||||
STRONG_INFLOW = "Strong Inflow"
|
||||
INFLOW = "Inflow"
|
||||
NEUTRAL = "Neutral"
|
||||
OUTFLOW = "Outflow"
|
||||
STRONG_OUTFLOW = "Strong Outflow"
|
||||
|
||||
|
||||
class CapitalState(str, Enum):
|
||||
ENTERING = "Entering"
|
||||
STABLE = "Stable"
|
||||
EXITING = "Exiting"
|
||||
|
||||
|
||||
class SufficiencyLevel(str, Enum):
|
||||
HIGH = "HIGH"
|
||||
MEDIUM = "MEDIUM"
|
||||
LOW = "LOW"
|
||||
INSUFFICIENT = "INSUFFICIENT"
|
||||
|
||||
|
||||
class SignalGrade(str, Enum):
|
||||
A = "A"
|
||||
B = "B"
|
||||
C = "C"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# L0: Raw Data Models
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class OHLCVDaily(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
symbol: str
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
volume: float
|
||||
ema20: Optional[float] = None
|
||||
ema60: Optional[float] = None
|
||||
ema120: Optional[float] = None
|
||||
atr_14: Optional[float] = None
|
||||
bb_width: Optional[float] = None
|
||||
adx_14: Optional[float] = None
|
||||
|
||||
|
||||
class BreadthRecord(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
total_tracked: int = 50
|
||||
advance_top50: int = 0
|
||||
decline_top50: int = 0
|
||||
above_ema20_top50: int = 0
|
||||
new_highs_20d_top50: int = 0
|
||||
btc_dominance: Optional[float] = None
|
||||
advance_top20: int = 0
|
||||
advance_top30: int = 0
|
||||
above_ema20_top20: int = 0
|
||||
above_ema20_top30: int = 0
|
||||
new_highs_20d_top20: int = 0
|
||||
new_highs_20d_top30: int = 0
|
||||
|
||||
|
||||
class DerivativesRecord(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
symbol: str = "BTC/USDT:USDT"
|
||||
funding_rate: Optional[float] = None
|
||||
open_interest: Optional[float] = None
|
||||
oi_24h_change_pct: Optional[float] = None
|
||||
long_liquidations: Optional[float] = None
|
||||
short_liquidations: Optional[float] = None
|
||||
basis_annualised_pct: Optional[float] = None
|
||||
source: str = "binance"
|
||||
|
||||
|
||||
class ETFFlowRecord(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
product: str
|
||||
net_flow_million: float
|
||||
price: Optional[float] = None
|
||||
source: str = "farside"
|
||||
|
||||
|
||||
class StablecoinSupplyRecord(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
token: str
|
||||
chain: str = "all"
|
||||
supply: float
|
||||
source: str = "defillama"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# L1: Factor Score Models
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class FactorScore(BaseModel):
|
||||
"""Single factor scoring output."""
|
||||
name: str = ""
|
||||
score: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||
label: str = ""
|
||||
direction: MacroDirection = MacroDirection.NEUTRAL
|
||||
sub_scores: dict = Field(default_factory=dict)
|
||||
narrative: str = ""
|
||||
|
||||
|
||||
class PriceStructureScore(FactorScore):
|
||||
"""Price Structure — 3 sub-dimensions."""
|
||||
trend_strength: float = 0.0
|
||||
volatility_compression: float = 0.0
|
||||
momentum: float = 0.0
|
||||
|
||||
|
||||
class BreadthScore(FactorScore):
|
||||
"""Breadth — multi-tier market diffusion."""
|
||||
breadth_top20: float = 0.0
|
||||
breadth_top30: float = 0.0
|
||||
breadth_top50: float = 0.0
|
||||
breadth_bucket: BreadthBucket = BreadthBucket.NORMAL
|
||||
breadth_divergence: float = 0.0
|
||||
advance_pct_top50: float = 0.0
|
||||
above_ema20_pct_top50: float = 0.0
|
||||
new_highs_top50: int = 0
|
||||
btc_dominance_7d_chg: Optional[float] = None
|
||||
|
||||
|
||||
class OIMatrixScore(FactorScore):
|
||||
"""OI Matrix — discrete state + continuous score."""
|
||||
oi_state: OIState = OIState.NEUTRAL
|
||||
price_change_pct: float = 0.0
|
||||
oi_change_pct: float = 0.0
|
||||
|
||||
|
||||
class VolatilityRegimeScore(FactorScore):
|
||||
"""Volatility regime classification."""
|
||||
vol_regime: VolRegime = VolRegime.NORMAL_VOL
|
||||
atr_pct: float = 0.0
|
||||
hv_ratio: float = 1.0
|
||||
bb_width_ratio: float = 1.0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# L4: Market State Vector (the final product)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class MarketStateVector(BaseModel):
|
||||
"""L4: Complete market state description. NOT compressed into one number."""
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
|
||||
date: Date
|
||||
symbol: str = "BTC/USDT:USDT"
|
||||
|
||||
regime: MarketRegime
|
||||
regime_confidence: float = Field(ge=0.0, le=1.0)
|
||||
regime_version: str
|
||||
regime_maturity_score: float = Field(ge=0.0, le=100.0, default=50.0)
|
||||
|
||||
breadth_top20: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||
breadth_top30: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||
breadth_top50: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||
breadth_bucket: BreadthBucket = BreadthBucket.NORMAL
|
||||
breadth_divergence: float = 0.0
|
||||
|
||||
oi_state: OIState = OIState.NEUTRAL
|
||||
volatility_regime: VolRegime = VolRegime.NORMAL_VOL
|
||||
|
||||
price_structure_score: FactorScore = Field(default_factory=FactorScore)
|
||||
breadth_score: BreadthScore = Field(default_factory=BreadthScore)
|
||||
oi_matrix_score: OIMatrixScore = Field(default_factory=OIMatrixScore)
|
||||
volatility_regime_score: VolatilityRegimeScore = Field(default_factory=VolatilityRegimeScore)
|
||||
|
||||
market_state_hash: str = ""
|
||||
|
||||
def compute_hash(self) -> str:
|
||||
import hashlib
|
||||
key = f"{self.regime.value}|{self.breadth_bucket.value}|{self.oi_state.value}|{self.volatility_regime.value}"
|
||||
return hashlib.md5(key.encode()).hexdigest()[:12]
|
||||
|
||||
def state_embedding(self) -> list[float]:
|
||||
return [
|
||||
self.breadth_top20,
|
||||
self.breadth_top30,
|
||||
self.breadth_top50,
|
||||
self.regime_maturity_score,
|
||||
self.price_structure_score.score,
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Factor Contribution
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class FactorContribution(BaseModel):
|
||||
"""How much a factor contributed to the overall score."""
|
||||
factor: str
|
||||
raw_score: float
|
||||
weight: float
|
||||
impact: float
|
||||
direction: str # 'bullish' / 'bearish' / 'neutral'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Regime Result
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class RegimeResult(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
regime: MarketRegime
|
||||
confidence: float
|
||||
regime_version: str
|
||||
maturity_score: float
|
||||
all_scores: dict = Field(default_factory=dict)
|
||||
prior_regime: Optional[MarketRegime] = None
|
||||
confirmation_days: int = 0
|
||||
|
||||
|
||||
class SignalFeatureRecord(BaseModel):
|
||||
"""A single signal → market state → outcome record."""
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
signal_type: str
|
||||
signal_version: str = "b3_v1"
|
||||
symbol: str = "BTC/USDT:USDT"
|
||||
|
||||
regime_version: str
|
||||
signal_grade: Optional[SignalGrade] = None
|
||||
signal_strength: Optional[float] = None
|
||||
|
||||
regime: MarketRegime
|
||||
regime_confidence: float
|
||||
regime_maturity_score: float
|
||||
market_state_hash: str
|
||||
state_embedding: str = "[]"
|
||||
breadth_top20: float
|
||||
breadth_top30: float
|
||||
breadth_top50: float
|
||||
breadth_bucket: BreadthBucket
|
||||
breadth_divergence: float
|
||||
oi_state: OIState
|
||||
volatility_regime: VolRegime
|
||||
price_structure_score: float
|
||||
|
||||
chan_trend_direction: Optional[str] = None
|
||||
chan_pivot_count: Optional[int] = None
|
||||
chan_divergence_type: Optional[str] = None
|
||||
|
||||
entry_price: Optional[float] = None
|
||||
result_1d: Optional[float] = None
|
||||
result_3d: Optional[float] = None
|
||||
result_5d: Optional[float] = None
|
||||
result_7d: Optional[float] = None
|
||||
result_14d: Optional[float] = None
|
||||
max_favorable_excursion: Optional[float] = None
|
||||
max_adverse_excursion: Optional[float] = None
|
||||
is_win_7d: Optional[int] = None
|
||||
|
||||
|
||||
class ExpectancyLayer(BaseModel):
|
||||
name: str
|
||||
posterior_winrate: float
|
||||
raw_winrate: Optional[float] = None
|
||||
samples: int = 0
|
||||
effective_samples: float = 0.0
|
||||
avg_return: Optional[float] = None
|
||||
|
||||
|
||||
class ExpectancyReport(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
signal_type: str
|
||||
date: Date
|
||||
layers: list[ExpectancyLayer] = Field(default_factory=list)
|
||||
final_estimate: float
|
||||
sufficiency: SufficiencyLevel = SufficiencyLevel.INSUFFICIENT
|
||||
prior_strength: int = 40
|
||||
half_life_days: int = 180
|
||||
source: str = "bayesian"
|
||||
|
||||
avg_return_7d: Optional[float] = None
|
||||
profit_factor: Optional[float] = None
|
||||
max_adverse_excursion: Optional[float] = None
|
||||
|
||||
|
||||
class DailyOutput(BaseModel):
|
||||
"""Final daily output: Market State + Expectancy."""
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
market_state: MarketStateVector
|
||||
expectancy: dict[str, ExpectancyReport] = Field(default_factory=dict)
|
||||
ai_report_en: Optional[str] = None
|
||||
ai_report_zh: Optional[str] = None
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
ccxt>=4.0.0
|
||||
pandas>=2.0.0
|
||||
numpy>=1.21.2
|
||||
pydantic>=2.0.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
scipy>=1.10.0
|
||||
flask>=3.0.0
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
# run_tests.sh — Run the ChanMacro test suite.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_tests.sh # All tests
|
||||
# ./run_tests.sh -v # Verbose
|
||||
# ./run_tests.sh -k regime # Only regime tests
|
||||
# ./run_tests.sh --cov # With coverage (requires pytest-cov)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
python -m pytest tests/ "$@" --tb=short
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
scheduler.py — 后台自动调度:定时拉取数据 + 计算因子 + 制度判定。
|
||||
|
||||
Python main.py cron → 前台阻塞运行,每 N 分钟一个 tick
|
||||
Web app 启动时自动启动调度器 → 后台线程,不阻塞 Web 请求
|
||||
"""
|
||||
|
||||
import threading
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger("chanmacro.scheduler")
|
||||
|
||||
|
||||
class MacroScheduler:
|
||||
"""后台调度器:定时 fetch + score。"""
|
||||
|
||||
def __init__(self, interval_minutes: int = 60):
|
||||
self.interval = interval_minutes
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
self._last_run: Optional[datetime] = None
|
||||
self._running = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""启动后台线程。"""
|
||||
if self._running:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._loop, name="macro-scheduler", daemon=True)
|
||||
self._thread.start()
|
||||
self._running = True
|
||||
logger.info(f"调度器已启动, 每 {self.interval} 分钟执行一次")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止后台线程。"""
|
||||
self._stop.set()
|
||||
self._running = False
|
||||
logger.info("调度器已停止")
|
||||
|
||||
@property
|
||||
def last_run(self) -> Optional[datetime]:
|
||||
return self._last_run
|
||||
|
||||
def _loop(self) -> None:
|
||||
"""后台循环。"""
|
||||
# 首次启动立即跑一次
|
||||
self._tick()
|
||||
|
||||
while not self._stop.wait(self.interval * 60):
|
||||
self._tick()
|
||||
|
||||
def _tick(self) -> None:
|
||||
"""执行一次:fetch → score。"""
|
||||
try:
|
||||
from fetchers.ohlcv import OHLCVFetcher
|
||||
from fetchers.breadth import BreadthFetcher
|
||||
from fetchers.derivatives import DerivativesFetcher
|
||||
from database import init_db
|
||||
from datetime import date as Date
|
||||
|
||||
init_db()
|
||||
today = Date.today()
|
||||
|
||||
# Fetch
|
||||
ohlcv = OHLCVFetcher()
|
||||
df = ohlcv.fetch()
|
||||
if not df.empty:
|
||||
ohlcv.store_df(df)
|
||||
|
||||
breadth = BreadthFetcher()
|
||||
record = breadth.fetch()
|
||||
if record:
|
||||
breadth.store(record=record)
|
||||
|
||||
deriv = DerivativesFetcher()
|
||||
records = deriv.fetch(today)
|
||||
if records:
|
||||
deriv.store(records=records)
|
||||
|
||||
# Score + Regime (also persisted inside _build_state)
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketStateVector
|
||||
from config import config
|
||||
import json
|
||||
from database import get_connection
|
||||
|
||||
ps = PriceStructureScorer().compute(today)
|
||||
br = BreadthScorer().compute(today)
|
||||
oi = OIMatrixScorer().compute(today)
|
||||
vol = VolatilityRegimeScorer().compute(today)
|
||||
|
||||
detector = RegimeDetector()
|
||||
detector.load_state(config.db_path)
|
||||
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, today)
|
||||
|
||||
conn = get_connection()
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO regime_history
|
||||
(date, regime, confidence, regime_version, maturity_score,
|
||||
all_scores_json, prior_regime, confirmation_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
str(today), r.regime.value, r.confidence, r.regime_version,
|
||||
r.maturity_score, json.dumps(r.all_scores),
|
||||
r.prior_regime.value if r.prior_regime else None,
|
||||
r.confirmation_days,
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# 检测新信号(每天运行一次,UTC 0 点后首次触发)
|
||||
now = datetime.now(timezone.utc)
|
||||
if self._last_run is None or now.date() > self._last_run.date():
|
||||
try:
|
||||
from chan_integration import ChanSignalDetector
|
||||
detector = ChanSignalDetector()
|
||||
# 检测最近 90 天的 4h 信号
|
||||
count = detector.populate_signal_features(
|
||||
start_date=(today - __import__('datetime').timedelta(days=90)).isoformat(),
|
||||
end_date=today.isoformat(),
|
||||
)
|
||||
if count > 0:
|
||||
logger.info(f"新增 {count} 条信号记录")
|
||||
except Exception as e:
|
||||
logger.debug(f"信号检测跳过: {e}")
|
||||
|
||||
self._last_run = now
|
||||
logger.info(
|
||||
f"Tick 完成: regime={r.regime.value} conf={r.confidence:.2f} "
|
||||
f"breadth={br.score:.0f}({br.breadth_bucket.value}) "
|
||||
f"price={ps.score:.0f} oi={oi.oi_state.value} vol={vol.vol_regime.value}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tick 失败: {e}", exc_info=True)
|
||||
|
||||
|
||||
# 单例
|
||||
_scheduler: Optional[MacroScheduler] = None
|
||||
|
||||
|
||||
def get_scheduler() -> MacroScheduler:
|
||||
global _scheduler
|
||||
if _scheduler is None:
|
||||
_scheduler = MacroScheduler(interval_minutes=60)
|
||||
return _scheduler
|
||||
@@ -0,0 +1,6 @@
|
||||
"""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
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
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."""
|
||||
...
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
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")
|
||||
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
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"
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
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")
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
tests/conftest.py — Shared fixtures for ChanMacro tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
import sqlite3
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure package root on path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
"""Create a temporary SQLite database with full mock data."""
|
||||
db = str(tmp_path / "test_macro.db")
|
||||
from database import init_db
|
||||
conn = init_db(db)
|
||||
|
||||
np.random.seed(42)
|
||||
base = date(2025, 9, 1)
|
||||
n_days = 300
|
||||
|
||||
# Generate realistic price series with 3 regime periods
|
||||
prices = [90000]
|
||||
regimes = []
|
||||
for i in range(n_days):
|
||||
if i < 100:
|
||||
ret = np.random.normal(0.003, 0.015)
|
||||
regime = "TREND"
|
||||
elif i < 200:
|
||||
ret = np.random.normal(0.000, 0.012)
|
||||
regime = "RANGE"
|
||||
else:
|
||||
ret = np.random.normal(-0.003, 0.025)
|
||||
regime = "PANIC"
|
||||
prices.append(prices[-1] * (1 + ret))
|
||||
regimes.append(regime)
|
||||
|
||||
for i in range(n_days):
|
||||
d = base + timedelta(days=i)
|
||||
c = prices[i]
|
||||
r = regimes[i]
|
||||
|
||||
# OHLCV
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO ohlcv_daily
|
||||
(date,symbol,open,high,low,close,volume,ema20,ema60,ema120,atr_14,bb_width,adx_14)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
d.strftime("%Y-%m-%d"), "BTC/USDT:USDT",
|
||||
c * 0.99, c * 1.03, c * 0.97, c, 1000,
|
||||
c * (0.98 if r == "TREND" else 1.02 if r == "PANIC" else 1.0),
|
||||
c * (0.95 if r == "TREND" else 1.05 if r == "PANIC" else 1.0),
|
||||
c * (0.90 if r == "TREND" else 1.10 if r == "PANIC" else 1.0),
|
||||
c * (0.02 if r == "PANIC" else 0.015),
|
||||
4.5, 28.0 if r == "TREND" else 18.0,
|
||||
))
|
||||
|
||||
# Breadth
|
||||
adv = 42 if r == "TREND" else 25 if r == "RANGE" else 8
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO breadth_daily
|
||||
(date,total_tracked,advance_top50,decline_top50,above_ema20_top50,
|
||||
new_highs_20d_top50,advance_top30,advance_top20,
|
||||
above_ema20_top30,above_ema20_top20,new_highs_20d_top30,new_highs_20d_top20)
|
||||
VALUES (?,50,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
d.strftime("%Y-%m-%d"), adv, 50 - adv, adv, min(adv, 15),
|
||||
int(adv * 0.7), int(adv * 0.5), int(adv * 0.7), int(adv * 0.5),
|
||||
min(int(adv * 0.7), 12), min(int(adv * 0.5), 8),
|
||||
))
|
||||
|
||||
# Derivatives
|
||||
oi_chg = 3.5 if r == "TREND" else 0.5 if r == "RANGE" else -2.0
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO derivatives
|
||||
(date,symbol,funding_rate,open_interest,oi_24h_change_pct,
|
||||
long_liquidations,short_liquidations,basis_annualised_pct)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
d.strftime("%Y-%m-%d"), "BTC/USDT:USDT",
|
||||
0.0001 + np.random.normal(0, 0.0002),
|
||||
35e9, oi_chg + np.random.normal(0, 1.0),
|
||||
50e6 * np.random.random(), 30e6 * np.random.random(),
|
||||
8.5 if r == "TREND" else 3.0,
|
||||
))
|
||||
|
||||
# Regime history
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO regime_history
|
||||
(date,regime,confidence,regime_version,maturity_score,all_scores_json,confirmation_days)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
""", (d.strftime("%Y-%m-%d"), r, 0.75, "v1_price_breadth_vol", 50, "{}", 1))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Override config to use test DB
|
||||
from config import config
|
||||
old_db = config.db_path
|
||||
config.db_path = db
|
||||
yield db
|
||||
config.db_path = old_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_state(db_path):
|
||||
"""Build a MarketStateVector for a known test date."""
|
||||
from models import (
|
||||
MarketStateVector, MarketRegime, BreadthBucket,
|
||||
OIState, VolRegime,
|
||||
)
|
||||
state = MarketStateVector(
|
||||
date=date(2026, 3, 15),
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
regime_maturity_score=55.0,
|
||||
breadth_top20=82.0,
|
||||
breadth_top30=78.0,
|
||||
breadth_top50=74.0,
|
||||
breadth_bucket=BreadthBucket.STRONG,
|
||||
breadth_divergence=8.0,
|
||||
oi_state=OIState.NEW_LONGS,
|
||||
volatility_regime=VolRegime.NORMAL_VOL,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
return state
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Test SignalTracker, TimeDecay, and BayesianExpectancyEngine."""
|
||||
import pytest
|
||||
from datetime import date, timedelta
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TestTimeDecay:
|
||||
def test_recent_weight_near_one(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
d = TimeDecay(180)
|
||||
w = d.weight(date(2026, 6, 20), date(2026, 6, 24))
|
||||
assert 0.95 < w < 1.0
|
||||
|
||||
def test_old_weight_decays(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
d = TimeDecay(180)
|
||||
w = d.weight(date(2025, 6, 24), date(2026, 6, 24))
|
||||
assert 0.2 < w < 0.3 # ~365 days at half_life=180
|
||||
|
||||
def test_effective_samples(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
d = TimeDecay(180)
|
||||
dates = [date(2026, 6, 24)] * 10
|
||||
weights = d.weights(dates, date(2026, 6, 24))
|
||||
eff = d.effective_samples(weights)
|
||||
assert eff == pytest.approx(10.0, rel=0.01)
|
||||
|
||||
def test_weighted_win_rate(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
d = TimeDecay(180)
|
||||
wins = np.array([1, 0, 1, 0])
|
||||
weights = np.array([1.0, 1.0, 1.0, 1.0])
|
||||
wr = d.weighted_win_rate(wins, weights)
|
||||
assert wr == 0.5
|
||||
|
||||
def test_weight_at_age(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
w = TimeDecay.weight_at_age(180, 180)
|
||||
assert w == pytest.approx(0.5, rel=0.01)
|
||||
|
||||
|
||||
class TestSignalTracker:
|
||||
def test_record_signal(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
rid = tracker.record(
|
||||
date(2026, 3, 15), "B3", 98000.0, sample_state,
|
||||
signal_grade="A", signal_strength=75.0,
|
||||
)
|
||||
assert rid is not None
|
||||
assert rid > 0
|
||||
|
||||
def test_get_samples(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||
tracker.record(date(2026, 3, 16), "B2", 98500.0, sample_state)
|
||||
|
||||
samples = tracker.get_samples(signal_type="B3")
|
||||
assert len(samples) == 1
|
||||
assert samples[0]["signal_type"] == "B3"
|
||||
|
||||
def test_count_samples(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||
tracker.record(date(2026, 3, 16), "B3", 98500.0, sample_state)
|
||||
|
||||
counts = tracker.count_samples()
|
||||
assert "B3/TREND" in counts
|
||||
assert counts["B3/TREND"] == 2
|
||||
|
||||
def test_filter_by_regime(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||
|
||||
samples = tracker.get_samples(signal_type="B3", regime="TREND")
|
||||
assert len(samples) == 1
|
||||
|
||||
samples = tracker.get_samples(signal_type="B3", regime="PANIC")
|
||||
assert len(samples) == 0
|
||||
|
||||
def test_backfill_signals(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
signals = [
|
||||
{"date": date(2026, 3, 15), "signal_type": "B3", "entry_price": 98000},
|
||||
{"date": date(2026, 3, 20), "signal_type": "B2", "entry_price": 99000},
|
||||
]
|
||||
count = tracker.backfill_signals(signals)
|
||||
assert count == 2
|
||||
|
||||
|
||||
class TestBayesianExpectancyEngine:
|
||||
def test_estimate_returns_report(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
# Record some signals first
|
||||
tracker = SignalTracker()
|
||||
for i in range(10):
|
||||
tracker.record(
|
||||
date(2026, 3, 15) + timedelta(days=i),
|
||||
"B3", 98000.0, sample_state,
|
||||
)
|
||||
|
||||
engine = BayesianExpectancyEngine(level_min_samples=3)
|
||||
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||
assert report.signal_type == "B3"
|
||||
assert len(report.layers) > 0
|
||||
assert report.source in ("bayesian", "insufficient")
|
||||
|
||||
def test_insufficient_with_no_samples(self, db_path, sample_state):
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
engine = BayesianExpectancyEngine(level_min_samples=10)
|
||||
report = engine.estimate(sample_state, "B1", date(2026, 3, 25))
|
||||
assert report.sufficiency.value in ("INSUFFICIENT", "LOW", "MEDIUM", "HIGH")
|
||||
|
||||
def test_empirical_bayes_shrinks_small_samples(self, db_path, sample_state):
|
||||
"""With N=3, raw=100%, posterior should be pulled toward prior."""
|
||||
from expectancy.tracker import SignalTracker
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
tracker = SignalTracker()
|
||||
for i in range(3):
|
||||
tracker.record(
|
||||
date(2026, 3, 15) + timedelta(days=i),
|
||||
"B3", 98000.0, sample_state,
|
||||
)
|
||||
|
||||
engine = BayesianExpectancyEngine(level_min_samples=1)
|
||||
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||
|
||||
# With small N, posterior should differ from raw
|
||||
base_layer = report.layers[0]
|
||||
if base_layer.raw_winrate and base_layer.samples < 50:
|
||||
# Posterior should be pulled toward prior (50% or global rate)
|
||||
if base_layer.raw_winrate > 0.8:
|
||||
assert base_layer.posterior_winrate < base_layer.raw_winrate
|
||||
|
||||
def test_leveled_fallback_stops_at_min_samples(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
tracker = SignalTracker()
|
||||
for i in range(20):
|
||||
tracker.record(date(2026, 3, 15) + timedelta(days=i), "B3", 98000.0, sample_state)
|
||||
|
||||
engine = BayesianExpectancyEngine(level_min_samples=15)
|
||||
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||
# Should have stopped at a level with >= 15 effective samples
|
||||
assert report.final_estimate >= 0
|
||||
|
||||
|
||||
class TestSufficiencyGuard:
|
||||
def test_insufficient(self):
|
||||
from expectancy.engine import SufficiencyGuard
|
||||
from models import SufficiencyLevel
|
||||
g = SufficiencyGuard()
|
||||
assert g.evaluate(10) == SufficiencyLevel.INSUFFICIENT
|
||||
|
||||
def test_low(self):
|
||||
from expectancy.engine import SufficiencyGuard
|
||||
from models import SufficiencyLevel
|
||||
g = SufficiencyGuard()
|
||||
assert g.evaluate(40) == SufficiencyLevel.LOW
|
||||
|
||||
def test_high(self):
|
||||
from expectancy.engine import SufficiencyGuard
|
||||
from models import SufficiencyLevel
|
||||
g = SufficiencyGuard()
|
||||
assert g.evaluate(200) == SufficiencyLevel.HIGH
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Test all Pydantic models and enums."""
|
||||
import pytest
|
||||
from datetime import date
|
||||
from models import (
|
||||
MarketRegime, OIState, BreadthBucket, VolRegime,
|
||||
MarketStateVector, FactorScore, RegimeResult,
|
||||
SignalFeatureRecord, ExpectancyReport, DailyOutput,
|
||||
FactorContribution, SufficiencyLevel, SignalGrade,
|
||||
)
|
||||
|
||||
|
||||
class TestEnums:
|
||||
def test_regime_values(self):
|
||||
assert MarketRegime.TREND.value == "TREND"
|
||||
assert MarketRegime.RANGE.value == "RANGE"
|
||||
assert MarketRegime.PANIC.value == "PANIC"
|
||||
|
||||
def test_oi_state_has_neutral(self):
|
||||
assert OIState.NEUTRAL.value == "Neutral"
|
||||
assert len(OIState) == 5
|
||||
|
||||
def test_breadth_bucket_values(self):
|
||||
assert BreadthBucket.EXTREME.value == "EXTREME"
|
||||
assert len(BreadthBucket) == 5
|
||||
|
||||
def test_vol_regime_values(self):
|
||||
assert VolRegime.LOW_VOL.value == "LOW_VOL"
|
||||
assert VolRegime.EXPLOSIVE_VOL.value == "EXPLOSIVE_VOL"
|
||||
|
||||
|
||||
class TestMarketStateVector:
|
||||
def test_minimal_construction(self):
|
||||
sv = MarketStateVector(
|
||||
date="2026-06-24",
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
)
|
||||
assert sv.date == date(2026, 6, 24)
|
||||
assert sv.regime == MarketRegime.TREND
|
||||
assert sv.breadth_top50 == 50.0 # default
|
||||
|
||||
def test_date_string_parsing(self):
|
||||
sv = MarketStateVector(
|
||||
date="2026-01-15",
|
||||
regime=MarketRegime.RANGE,
|
||||
regime_confidence=0.55,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
)
|
||||
assert sv.date == date(2026, 1, 15)
|
||||
|
||||
def test_compute_hash(self):
|
||||
sv = MarketStateVector(
|
||||
date="2026-06-24",
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
breadth_bucket=BreadthBucket.EXTREME,
|
||||
oi_state=OIState.NEW_LONGS,
|
||||
volatility_regime=VolRegime.NORMAL_VOL,
|
||||
)
|
||||
h = sv.compute_hash()
|
||||
assert len(h) == 12
|
||||
# Same state = same hash
|
||||
sv2 = MarketStateVector(
|
||||
date="2026-06-25",
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.80,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
breadth_bucket=BreadthBucket.EXTREME,
|
||||
oi_state=OIState.NEW_LONGS,
|
||||
volatility_regime=VolRegime.NORMAL_VOL,
|
||||
)
|
||||
assert sv2.compute_hash() == h
|
||||
|
||||
def test_state_embedding(self):
|
||||
sv = MarketStateVector(
|
||||
date="2026-06-24",
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
breadth_top20=80.0,
|
||||
breadth_top30=75.0,
|
||||
breadth_top50=70.0,
|
||||
regime_maturity_score=60.0,
|
||||
)
|
||||
emb = sv.state_embedding()
|
||||
assert len(emb) == 5
|
||||
assert emb[0] == 80.0
|
||||
assert emb[3] == 60.0
|
||||
|
||||
|
||||
class TestRegimeResult:
|
||||
def test_construction(self):
|
||||
r = RegimeResult(
|
||||
date="2026-06-24",
|
||||
regime=MarketRegime.TREND,
|
||||
confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
maturity_score=55.0,
|
||||
all_scores={"TREND": 82.0, "RANGE": 45.0, "PANIC": 20.0},
|
||||
confirmation_days=5,
|
||||
)
|
||||
assert r.regime == MarketRegime.TREND
|
||||
assert r.confirmation_days == 5
|
||||
|
||||
|
||||
class TestExpectancyReport:
|
||||
def test_insufficient(self):
|
||||
r = ExpectancyReport(
|
||||
signal_type="B3",
|
||||
date="2026-06-24",
|
||||
final_estimate=0.0,
|
||||
sufficiency=SufficiencyLevel.INSUFFICIENT,
|
||||
source="insufficient",
|
||||
)
|
||||
assert r.final_estimate == 0.0
|
||||
assert r.sufficiency == SufficiencyLevel.INSUFFICIENT
|
||||
|
||||
|
||||
class TestFactorContribution:
|
||||
def test_construction(self):
|
||||
fc = FactorContribution(
|
||||
factor="ETF Flow",
|
||||
raw_score=85.0,
|
||||
weight=0.1925,
|
||||
impact=6.7,
|
||||
direction="bullish",
|
||||
)
|
||||
assert fc.impact > 0
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Test regime detector and validation."""
|
||||
import pytest
|
||||
from datetime import date
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TestRegimeDetector:
|
||||
def test_detects_trend(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
r = d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||
assert r.regime == MarketRegime.TREND
|
||||
assert r.confidence > 0.5
|
||||
|
||||
def test_detects_range(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
r = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 24))
|
||||
assert r.regime in (MarketRegime.RANGE, MarketRegime.TREND)
|
||||
|
||||
def test_detects_panic(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
r = d.detect(15.0, 10.0, "EXPLOSIVE_VOL", date(2026, 6, 24))
|
||||
assert r.regime == MarketRegime.PANIC
|
||||
|
||||
def test_2day_confirmation(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
# Day 1: RANGE
|
||||
r1 = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 24))
|
||||
assert r1.regime == MarketRegime.RANGE # first run, no confirmation needed
|
||||
# Day 2: still RANGE
|
||||
r2 = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 25))
|
||||
assert r2.regime == MarketRegime.RANGE
|
||||
assert r2.confirmation_days == 2
|
||||
|
||||
def test_transition_needs_confirmation(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
# Establish TREND
|
||||
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 25))
|
||||
# Day 3: weak scores → raw best = RANGE, but TREND should persist
|
||||
r3 = d.detect(35.0, 40.0, "NORMAL_VOL", date(2026, 6, 26))
|
||||
# First day of pending transition — should still be TREND
|
||||
assert r3.regime == MarketRegime.TREND
|
||||
assert d.pending_regime is not None
|
||||
|
||||
def test_version_is_stored(self):
|
||||
from regime_detector import RegimeDetector
|
||||
d = RegimeDetector(regime_version="v1_price_breadth_vol")
|
||||
r = d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||
assert r.regime_version == "v1_price_breadth_vol"
|
||||
|
||||
def test_load_state(self, db_path):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
d.load_state(db_path)
|
||||
# DB has TREND for first 100 days, so most recent should load
|
||||
assert d.current_regime is not None
|
||||
|
||||
def test_confidence_for_confirmed_regime(self):
|
||||
"""Confidence should be for the confirmed regime, not raw best."""
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
# Establish TREND
|
||||
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 25))
|
||||
# Now feed weak scores → raw best would be PANIC or RANGE
|
||||
r = d.detect(15.0, 10.0, "EXPLOSIVE_VOL", date(2026, 6, 26))
|
||||
# Should still report TREND (need 2 confirmations to switch)
|
||||
assert r.regime == MarketRegime.TREND
|
||||
|
||||
|
||||
class TestTransitionValidator:
|
||||
def test_stable_regime_passes(self):
|
||||
from validation.transition_validator import TransitionValidator
|
||||
# Create stable regime sequence: long periods
|
||||
seq = pd.Series(
|
||||
["TREND"] * 50 + ["RANGE"] * 50 + ["PANIC"] * 40,
|
||||
index=pd.date_range("2026-01-01", periods=140),
|
||||
)
|
||||
tv = TransitionValidator()
|
||||
report = tv.validate(seq)
|
||||
assert report.is_stable
|
||||
assert report.avg_duration > 20
|
||||
assert report.flip_rate < 0.05
|
||||
|
||||
def test_unstable_regime_fails(self):
|
||||
from validation.transition_validator import TransitionValidator
|
||||
# Create unstable sequence: flips every 2 days
|
||||
seq = pd.Series(
|
||||
["TREND", "TREND", "RANGE", "RANGE", "TREND", "TREND",
|
||||
"PANIC", "PANIC", "RANGE", "RANGE"] * 5,
|
||||
index=pd.date_range("2026-01-01", periods=50),
|
||||
)
|
||||
tv = TransitionValidator()
|
||||
report = tv.validate(seq)
|
||||
assert not report.is_stable
|
||||
assert report.flip_rate > 0.15
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Test all 4 core scorers."""
|
||||
import pytest
|
||||
from datetime import date
|
||||
|
||||
|
||||
class TestPriceStructureScorer:
|
||||
def test_computes_score(self, db_path):
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
scorer = PriceStructureScorer()
|
||||
result = scorer.compute(date(2026, 3, 15))
|
||||
assert result.name == "Price Structure"
|
||||
assert 0 <= result.score <= 100
|
||||
assert result.trend_strength >= 0
|
||||
assert result.volatility_compression >= 0
|
||||
assert result.momentum >= 0
|
||||
assert result.label
|
||||
|
||||
def test_bullish_in_trend(self, db_path):
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
scorer = PriceStructureScorer()
|
||||
result = scorer.compute(date(2025, 11, 15)) # TREND period
|
||||
assert result.score > 50 # Should be bullish in uptrend
|
||||
|
||||
def test_bearish_in_panic(self, db_path):
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
scorer = PriceStructureScorer()
|
||||
result = scorer.compute(date(2026, 5, 15)) # PANIC period
|
||||
# In panic period, EMA alignment should be bearish
|
||||
assert result.trend_strength < 60
|
||||
|
||||
def test_no_data_handling(self, db_path):
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
scorer = PriceStructureScorer()
|
||||
result = scorer.compute(date(2020, 1, 1))
|
||||
assert result.score == 50.0
|
||||
assert result.label == "No Data"
|
||||
|
||||
|
||||
class TestBreadthScorer:
|
||||
def test_computes_score(self, db_path):
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
scorer = BreadthScorer()
|
||||
result = scorer.compute(date(2026, 3, 15))
|
||||
assert result.name == "Breadth"
|
||||
assert 0 <= result.score <= 100
|
||||
assert result.breadth_bucket
|
||||
assert result.breadth_top20 >= 0
|
||||
assert result.breadth_top50 >= 0
|
||||
|
||||
def test_tier_values(self, db_path):
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
scorer = BreadthScorer()
|
||||
result = scorer.compute(date(2025, 11, 15)) # TREND period
|
||||
# Top20 should generally be higher than Top50 (large caps lead)
|
||||
assert result.breadth_top20 >= 0
|
||||
assert result.breadth_top50 >= 0
|
||||
|
||||
def test_bucket_assignment(self, db_path):
|
||||
from scoring.breadth_scorer import BreadthScorer, BreadthBucket
|
||||
scorer = BreadthScorer()
|
||||
result = scorer.compute(date(2025, 11, 15)) # TREND: adv=42/50
|
||||
assert result.breadth_bucket in (
|
||||
BreadthBucket.EXTREME, BreadthBucket.STRONG, BreadthBucket.NORMAL
|
||||
)
|
||||
|
||||
def test_no_data(self, db_path):
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
scorer = BreadthScorer()
|
||||
result = scorer.compute(date(2020, 1, 1))
|
||||
assert result.score == 50.0
|
||||
|
||||
|
||||
class TestOIMatrixScorer:
|
||||
def test_computes_state(self, db_path):
|
||||
from scoring.oi_matrix import OIMatrixScorer, OIState
|
||||
scorer = OIMatrixScorer()
|
||||
result = scorer.compute(date(2025, 11, 15)) # TREND period, oi_chg=+3.5
|
||||
assert result.oi_state in OIState
|
||||
assert 0 <= result.score <= 100
|
||||
|
||||
def test_new_longs_in_trend(self, db_path):
|
||||
from scoring.oi_matrix import OIMatrixScorer, OIState
|
||||
scorer = OIMatrixScorer()
|
||||
# Test multiple dates in TREND period — at least one should be NEW_LONGS or NEUTRAL
|
||||
found_bullish = False
|
||||
for d in ["2025-11-15", "2025-11-20", "2025-12-01", "2025-12-15"]:
|
||||
result = scorer.compute(date.fromisoformat(d))
|
||||
if result.oi_state in (OIState.NEW_LONGS, OIState.SHORT_COVERING, OIState.NEUTRAL):
|
||||
found_bullish = True
|
||||
break
|
||||
assert found_bullish, "No bullish OI state found in TREND period"
|
||||
|
||||
def test_no_data(self, db_path):
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
scorer = OIMatrixScorer()
|
||||
result = scorer.compute(date(2020, 1, 1))
|
||||
assert result.score == 50.0
|
||||
assert result.label == "No Data"
|
||||
|
||||
|
||||
class TestVolatilityRegimeScorer:
|
||||
def test_computes_regime(self, db_path):
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
|
||||
scorer = VolatilityRegimeScorer()
|
||||
result = scorer.compute(date(2026, 3, 15))
|
||||
assert result.vol_regime in VolRegime
|
||||
assert 0 <= result.score <= 100
|
||||
|
||||
def test_higher_vol_in_panic(self, db_path):
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
|
||||
scorer = VolatilityRegimeScorer()
|
||||
trend_result = scorer.compute(date(2025, 11, 15))
|
||||
panic_result = scorer.compute(date(2026, 5, 15))
|
||||
# PANIC period has higher ATR → higher vol regime or score
|
||||
assert panic_result.atr_pct >= trend_result.atr_pct * 0.5 # at least comparable
|
||||
|
||||
def test_no_data(self, db_path):
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
scorer = VolatilityRegimeScorer()
|
||||
result = scorer.compute(date(2020, 1, 1))
|
||||
assert result.score == 50.0
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
trend_detector.py — Trend strength and maturity helpers.
|
||||
|
||||
Utility functions for computing trend alignment, acceleration, persistence.
|
||||
Used by regime_detector and price_structure scorer.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def ema_alignment_score(close: float, ema20: float, ema60: float, ema120: float) -> float:
|
||||
"""Score EMA alignment: 0=bearish, 50=neutral, 100=bullish."""
|
||||
if any(pd.isna(x) for x in [ema20, ema60, ema120]):
|
||||
return 50.0
|
||||
|
||||
alignments = 0
|
||||
if ema20 > ema60:
|
||||
alignments += 1
|
||||
if ema60 > ema120:
|
||||
alignments += 1
|
||||
if ema20 > ema120:
|
||||
alignments += 1
|
||||
|
||||
if alignments == 3:
|
||||
return 85.0
|
||||
elif alignments == 2:
|
||||
return 65.0
|
||||
elif alignments == 1:
|
||||
return 35.0
|
||||
else:
|
||||
return 15.0
|
||||
|
||||
|
||||
def adx_trend_score(adx: float) -> float:
|
||||
"""Convert ADX value to trend score: 0-100."""
|
||||
if pd.isna(adx):
|
||||
return 50.0
|
||||
if adx > 40:
|
||||
return 90.0
|
||||
elif adx > 25:
|
||||
return 60.0 + (adx - 25) / 15 * 30
|
||||
elif adx > 15:
|
||||
return 40.0 + (adx - 15) / 10 * 20
|
||||
else:
|
||||
return max(10.0, adx / 15 * 40)
|
||||
|
||||
|
||||
def breadth_persistence(breadth_scores: list[float], window: int = 5) -> float:
|
||||
"""How consistently has breadth stayed at its current level? 0-100."""
|
||||
if len(breadth_scores) < window:
|
||||
return 50.0
|
||||
recent = breadth_scores[-window:]
|
||||
mean_val = np.mean(recent)
|
||||
std_val = np.std(recent) if len(recent) > 1 else 0
|
||||
# Low std = high persistence
|
||||
persistence = 100 - min(std_val * 5, 100)
|
||||
# Bias: higher breadth = higher persistence score
|
||||
return persistence * 0.5 + mean_val * 0.5
|
||||
|
||||
|
||||
def trend_strength_composite(ema_score: float, adx_score: float,
|
||||
breadth_score: float) -> float:
|
||||
"""Composite trend strength 0-100."""
|
||||
return ema_score * 0.25 + adx_score * 0.25 + breadth_score * 0.50
|
||||
|
||||
|
||||
def compute_maturity(trend_strength: float, breadth_persistence: float,
|
||||
vol_expansion: float) -> float:
|
||||
"""
|
||||
Compute regime maturity score 0-100.
|
||||
|
||||
EMERGING (0-30): trend accelerating, breadth expanding
|
||||
CONFIRMED (30-70): trend stable, breadth stable
|
||||
EXHAUSTING (70-100): trend decelerating, breadth contracting, vol abnormal
|
||||
"""
|
||||
return (
|
||||
trend_strength * 0.50 +
|
||||
breadth_persistence * 0.30 +
|
||||
(100 - vol_expansion) * 0.20 # inverted: low vol = early stage
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Validation Framework — Phase 0: verify every factor before trusting it."""
|
||||
from .factor_validator import FactorValidator
|
||||
from .regime_validator import RegimeValidator
|
||||
from .transition_validator import TransitionValidator
|
||||
from .reporter import ValidationReporter
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
validation/factor_validator.py — Validates a factor's predictive power.
|
||||
|
||||
Tests: IC, ICIR, Hit Ratio, Quantile Spread, Lead-Lag analysis.
|
||||
Answers: "Does this factor predict future returns?"
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import config
|
||||
from .metrics import (
|
||||
information_coefficient, icir, hit_ratio,
|
||||
quantile_spread, lead_lag_ic,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FactorReport:
|
||||
"""Structured report for a single factor's validation results."""
|
||||
|
||||
def __init__(self, factor_name: str):
|
||||
self.factor_name = factor_name
|
||||
self.ic_mean: float = 0.0
|
||||
self.ic_std: float = 0.0
|
||||
self.icir: float = 0.0
|
||||
self.hit_ratio: float = 0.0
|
||||
self.quantile_spread: float = 0.0
|
||||
self.is_leading: bool = False
|
||||
self.lead_days: int = 0
|
||||
self.lead_ic: float = 0.0
|
||||
self.n_observations: int = 0
|
||||
self.conclusion: str = ""
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
f"Factor: {self.factor_name}",
|
||||
f" N={self.n_observations}",
|
||||
f" IC mean={self.ic_mean:.4f} std={self.ic_std:.4f} ICIR={self.icir:.2f}",
|
||||
f" Hit Ratio={self.hit_ratio:.1%} Top-Bot Spread={self.quantile_spread:.4f}",
|
||||
f" Best Lead: {self.lead_days}d (IC={self.lead_ic:.4f})" if self.is_leading else " Leading: No (synchronous/lagging)",
|
||||
f" → {self.conclusion}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class FactorValidator:
|
||||
"""
|
||||
Validates a factor's predictive power using standard quant metrics.
|
||||
|
||||
For each forward horizon (1d, 3d, 5d, 7d, 14d), computes:
|
||||
- IC (Spearman rank correlation)
|
||||
- ICIR (IC stability)
|
||||
- Hit Ratio (direction accuracy)
|
||||
- Quantile spread (top vs bottom bucket)
|
||||
- Lead-lag profile
|
||||
|
||||
A factor is valid if IC > 0.03 and ICIR > 0.5.
|
||||
For regime factors, also check regime_validator.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def validate(self, factor_name: str, factor_scores: pd.Series,
|
||||
forward_returns: dict[str, pd.Series]) -> FactorReport:
|
||||
"""
|
||||
Args:
|
||||
factor_name: Human-readable name
|
||||
factor_scores: Series indexed by date, values 0-100
|
||||
forward_returns: Dict of horizon → Series indexed by date (e.g. "1d" → returns)
|
||||
"""
|
||||
report = FactorReport(factor_name)
|
||||
|
||||
# Align series to common dates
|
||||
common_idx = factor_scores.index
|
||||
for ret in forward_returns.values():
|
||||
common_idx = common_idx.intersection(ret.index)
|
||||
|
||||
if len(common_idx) < 30:
|
||||
report.conclusion = "INSUFFICIENT DATA (< 30 observations)"
|
||||
return report
|
||||
|
||||
f = factor_scores[common_idx]
|
||||
report.n_observations = len(common_idx)
|
||||
|
||||
# Test against 7d forward returns (primary horizon)
|
||||
primary_ret = forward_returns.get("7d")
|
||||
if primary_ret is None:
|
||||
# Use first available
|
||||
primary_ret = list(forward_returns.values())[0]
|
||||
|
||||
r = primary_ret[common_idx]
|
||||
|
||||
# IC
|
||||
ic = information_coefficient(f, r)
|
||||
report.ic_mean = round(ic, 4)
|
||||
|
||||
# Rolling IC for ICIR
|
||||
rolling_ics = []
|
||||
for i in range(30, len(f)):
|
||||
ic_i = information_coefficient(f.iloc[:i], r.iloc[:i])
|
||||
rolling_ics.append(ic_i)
|
||||
ic_series = pd.Series(rolling_ics)
|
||||
report.ic_std = round(ic_series.std(), 4)
|
||||
report.icir = round(icir(ic_series), 2)
|
||||
|
||||
# Hit ratio
|
||||
report.hit_ratio = round(hit_ratio(f, r), 4)
|
||||
|
||||
# Quantile spread
|
||||
report.quantile_spread = round(quantile_spread(f, r), 4)
|
||||
|
||||
# Lead-lag
|
||||
lead = lead_lag_ic(f, r, max_lag=14)
|
||||
report.is_leading = lead["is_leading"]
|
||||
report.lead_days = lead["lead_days"]
|
||||
report.lead_ic = round(lead["best_ic"], 4)
|
||||
|
||||
# Conclusion
|
||||
if abs(report.ic_mean) > 0.05 and report.icir > 1.0:
|
||||
report.conclusion = "STRONG: significant predictive power"
|
||||
elif abs(report.ic_mean) > 0.03 and report.icir > 0.5:
|
||||
report.conclusion = "VALID: moderate predictive power"
|
||||
elif abs(report.ic_mean) < 0.02:
|
||||
report.conclusion = "CONFIRMING: describes current state, not predictive"
|
||||
else:
|
||||
report.conclusion = "WEAK: borderline, monitor or downweight"
|
||||
|
||||
return report
|
||||
|
||||
def validate_from_db(self, factor_name: str,
|
||||
score_query: str,
|
||||
horizon_days: int = 7) -> FactorReport:
|
||||
"""
|
||||
Convenience: load scores from DB and OHLCV returns, then validate.
|
||||
|
||||
score_query: SQL that returns (date, score) pairs.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
scores_df = pd.read_sql_query(score_query, conn)
|
||||
if scores_df.empty:
|
||||
conn.close()
|
||||
r = FactorReport(factor_name)
|
||||
r.conclusion = "NO DATA"
|
||||
return r
|
||||
|
||||
scores_df["date"] = pd.to_datetime(scores_df["date"])
|
||||
scores = scores_df.set_index("date")["score"]
|
||||
|
||||
# Load forward returns from OHLCV
|
||||
ohlcv = pd.read_sql_query(
|
||||
"SELECT date, close FROM ohlcv_daily WHERE symbol='BTC/USDT:USDT' ORDER BY date",
|
||||
conn
|
||||
)
|
||||
conn.close()
|
||||
|
||||
ohlcv["date"] = pd.to_datetime(ohlcv["date"])
|
||||
ohlcv = ohlcv.set_index("date")
|
||||
ohlcv["ret"] = ohlcv["close"].pct_change().shift(-1) # forward 1d
|
||||
|
||||
# Build forward returns for multiple horizons
|
||||
forward = {}
|
||||
for h in [1, 3, 5, 7, 14]:
|
||||
forward[str(h) + "d"] = ohlcv["close"].pct_change(periods=h).shift(-h)
|
||||
|
||||
return self.validate(factor_name, scores, forward)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
validation/metrics.py — Shared statistical metrics for factor and regime validation.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy import stats
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def information_coefficient(factor: pd.Series, forward_returns: pd.Series) -> float:
|
||||
"""Spearman rank IC between factor values and forward returns."""
|
||||
mask = factor.notna() & forward_returns.notna()
|
||||
if mask.sum() < 10:
|
||||
return 0.0
|
||||
ic, _ = stats.spearmanr(factor[mask], forward_returns[mask])
|
||||
return float(ic) if not np.isnan(ic) else 0.0
|
||||
|
||||
|
||||
def icir(ic_series: pd.Series) -> float:
|
||||
"""Information Coefficient IR = mean(IC) / std(IC)."""
|
||||
if len(ic_series) < 5 or ic_series.std() == 0:
|
||||
return 0.0
|
||||
return float(ic_series.mean() / ic_series.std())
|
||||
|
||||
|
||||
def hit_ratio(factor: pd.Series, forward_returns: pd.Series) -> float:
|
||||
"""Fraction of times factor direction matches return direction."""
|
||||
mask = factor.notna() & forward_returns.notna()
|
||||
if mask.sum() < 10:
|
||||
return 0.5
|
||||
# Compare sign of factor deviation from median vs sign of returns
|
||||
factor_median = factor[mask].median()
|
||||
factor_sign = np.sign(factor[mask] - factor_median)
|
||||
return_sign = np.sign(forward_returns[mask])
|
||||
return float((factor_sign == return_sign).mean())
|
||||
|
||||
|
||||
def quantile_spread(factor: pd.Series, forward_returns: pd.Series,
|
||||
n_quantiles: int = 5) -> float:
|
||||
"""Top vs bottom quantile return spread (分层回测)."""
|
||||
mask = factor.notna() & forward_returns.notna()
|
||||
if mask.sum() < n_quantiles * 3:
|
||||
return 0.0
|
||||
f = factor[mask]
|
||||
r = forward_returns[mask]
|
||||
labels = pd.qcut(f, n_quantiles, labels=False, duplicates="drop")
|
||||
top_ret = r[labels == labels.max()].mean()
|
||||
bot_ret = r[labels == labels.min()].mean()
|
||||
return float(top_ret - bot_ret)
|
||||
|
||||
|
||||
def lead_lag_ic(factor: pd.Series, returns: pd.Series,
|
||||
max_lag: int = 14) -> dict:
|
||||
"""Find the best leading/trailing relationship by computing IC at each lag."""
|
||||
results = {}
|
||||
for lag in range(-max_lag, max_lag + 1):
|
||||
if lag < 0:
|
||||
shifted = factor.shift(abs(lag))
|
||||
ic = information_coefficient(shifted, returns)
|
||||
results[f"lead_{abs(lag)}d"] = ic
|
||||
elif lag > 0:
|
||||
shifted = returns.shift(lag)
|
||||
ic = information_coefficient(factor, shifted)
|
||||
results[f"lag_{lag}d"] = ic
|
||||
else:
|
||||
ic = information_coefficient(factor, returns)
|
||||
results["sync"] = ic
|
||||
|
||||
# Find best lead period
|
||||
lead_ics = {k: v for k, v in results.items() if k.startswith("lead_")}
|
||||
best_lead = max(lead_ics, key=lead_ics.get) if lead_ics else "sync"
|
||||
best_ic = lead_ics.get(best_lead, results.get("sync", 0))
|
||||
|
||||
return {
|
||||
"best_lead": best_lead,
|
||||
"best_ic": best_ic,
|
||||
"ic_curve": results,
|
||||
"is_leading": best_lead.startswith("lead_") and abs(best_ic) > 0.03,
|
||||
"lead_days": int(best_lead.split("_")[1].rstrip("d")) if best_lead.startswith("lead_") else 0,
|
||||
}
|
||||
|
||||
|
||||
def mutual_information(factor: pd.Series, labels: pd.Series,
|
||||
n_bins: int = 10) -> float:
|
||||
"""Mutual information between factor (binned) and discrete regime labels."""
|
||||
mask = factor.notna() & labels.notna()
|
||||
if mask.sum() < 20:
|
||||
return 0.0
|
||||
f = factor[mask]
|
||||
l = labels[mask]
|
||||
try:
|
||||
f_binned = pd.qcut(f, n_bins, labels=False, duplicates="drop")
|
||||
except ValueError:
|
||||
f_binned = pd.cut(f, n_bins, labels=False)
|
||||
mi = 0.0
|
||||
for fi in range(n_bins):
|
||||
p_f = (f_binned == fi).mean()
|
||||
if p_f == 0:
|
||||
continue
|
||||
for li in l.unique():
|
||||
p_l = (l == li).mean()
|
||||
p_joint = ((f_binned == fi) & (l == li)).mean()
|
||||
if p_joint > 0:
|
||||
mi += p_joint * np.log(p_joint / (p_f * p_l))
|
||||
return float(mi)
|
||||
|
||||
|
||||
def kl_divergence(factor: pd.Series, labels: pd.Series,
|
||||
regime_a: str, regime_b: str, n_bins: int = 10) -> float:
|
||||
"""KL divergence between factor distributions in two regimes."""
|
||||
mask_a = (labels == regime_a) & factor.notna()
|
||||
mask_b = (labels == regime_b) & factor.notna()
|
||||
if mask_a.sum() < 10 or mask_b.sum() < 10:
|
||||
return 0.0
|
||||
try:
|
||||
hist_a, edges = np.histogram(factor[mask_a], bins=n_bins, density=True)
|
||||
hist_b, _ = np.histogram(factor[mask_b], bins=edges, density=True)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
hist_a = np.clip(hist_a, 1e-10, None)
|
||||
hist_b = np.clip(hist_b, 1e-10, None)
|
||||
return float((hist_a * np.log(hist_a / hist_b)).sum())
|
||||
|
||||
|
||||
def anova_f_score(factor: pd.Series, labels: pd.Series) -> float:
|
||||
"""ANOVA F-statistic: how well factor separates different regimes."""
|
||||
mask = factor.notna() & labels.notna()
|
||||
if mask.sum() < 20:
|
||||
return 0.0
|
||||
groups = [factor[mask][labels[mask] == lbl] for lbl in labels[mask].unique()]
|
||||
groups = [g for g in groups if len(g) > 1]
|
||||
if len(groups) < 2:
|
||||
return 0.0
|
||||
f_stat, _ = stats.f_oneway(*groups)
|
||||
return float(f_stat) if not np.isnan(f_stat) else 0.0
|
||||
|
||||
|
||||
def transition_matrix(labels: pd.Series) -> pd.DataFrame:
|
||||
"""Compute Markov transition matrix from regime sequence."""
|
||||
unique = sorted(labels.dropna().unique())
|
||||
n = len(unique)
|
||||
matrix = np.zeros((n, n))
|
||||
seq = labels.dropna().values
|
||||
for i in range(len(seq) - 1):
|
||||
from_idx = unique.index(seq[i])
|
||||
to_idx = unique.index(seq[i + 1])
|
||||
matrix[from_idx][to_idx] += 1
|
||||
|
||||
# Row-normalize
|
||||
row_sums = matrix.sum(axis=1, keepdims=True)
|
||||
row_sums[row_sums == 0] = 1
|
||||
matrix = matrix / row_sums
|
||||
|
||||
return pd.DataFrame(matrix, index=unique, columns=unique)
|
||||
|
||||
|
||||
def regime_duration_stats(labels: pd.Series) -> dict:
|
||||
"""Compute average duration, flip rate, state entropy for regime sequence."""
|
||||
seq = labels.dropna().values
|
||||
if len(seq) < 2:
|
||||
return {"avg_duration": 0, "flip_rate": 0, "state_entropy": 0, "n_days": len(seq)}
|
||||
|
||||
# Count durations
|
||||
durations = []
|
||||
current = seq[0]
|
||||
count = 1
|
||||
flips = 0
|
||||
for i in range(1, len(seq)):
|
||||
if seq[i] == current:
|
||||
count += 1
|
||||
else:
|
||||
durations.append(count)
|
||||
current = seq[i]
|
||||
count = 1
|
||||
flips += 1
|
||||
durations.append(count)
|
||||
|
||||
avg_dur = float(np.mean(durations)) if durations else 0
|
||||
flip_rate = flips / len(seq)
|
||||
|
||||
# State entropy
|
||||
_, counts = np.unique(seq, return_counts=True)
|
||||
probs = counts / counts.sum()
|
||||
entropy = float(-(probs * np.log2(probs + 1e-10)).sum())
|
||||
|
||||
return {
|
||||
"avg_duration": round(avg_dur, 1),
|
||||
"flip_rate": round(flip_rate, 3),
|
||||
"state_entropy": round(entropy, 3),
|
||||
"n_days": len(seq),
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
validation/regime_validator.py — Validates factors as regime separators.
|
||||
|
||||
Tests: Mutual Information, KL Divergence, ANOVA F-score.
|
||||
Answers: "Does this factor distinguish different market regimes?"
|
||||
|
||||
Key insight: a factor may have low IC (poor return predictor) but high
|
||||
regime separation (good regime classifier). Breadth is the prime example.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import config
|
||||
from .metrics import (
|
||||
mutual_information, kl_divergence, anova_f_score,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RegimeReport:
|
||||
"""Structured report for regime separation validation."""
|
||||
|
||||
def __init__(self, factor_name: str):
|
||||
self.factor_name = factor_name
|
||||
self.mutual_info: float = 0.0
|
||||
self.anova_f: float = 0.0
|
||||
self.kl_pairs: dict = {} # (regime_a, regime_b) → KL divergence
|
||||
self.best_separates: list[str] = []
|
||||
self.separation_score: float = 0.0
|
||||
self.is_regime_factor: bool = False
|
||||
self.conclusion: str = ""
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
f"Factor: {self.factor_name}",
|
||||
f" Mutual Information: {self.mutual_info:.4f}",
|
||||
f" ANOVA F: {self.anova_f:.1f}",
|
||||
f" Best separates: {', '.join(self.best_separates) if self.best_separates else 'none'}",
|
||||
f" Regime Factor: {'YES' if self.is_regime_factor else 'No'}",
|
||||
f" → {self.conclusion}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class RegimeValidator:
|
||||
"""
|
||||
Validates a factor's ability to separate different market regimes.
|
||||
|
||||
A good regime factor has:
|
||||
- Mutual Information > 0.1
|
||||
- KL Divergence between regimes > 0.5
|
||||
- ANOVA F-score high
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def validate(self, factor_name: str, factor_scores: pd.Series,
|
||||
regime_labels: pd.Series) -> RegimeReport:
|
||||
"""
|
||||
Args:
|
||||
factor_name: Human-readable name
|
||||
factor_scores: Series indexed by date, values 0-100
|
||||
regime_labels: Series indexed by date, values = 'TREND'/'RANGE'/'PANIC'
|
||||
"""
|
||||
report = RegimeReport(factor_name)
|
||||
|
||||
# Align
|
||||
common_idx = factor_scores.index.intersection(regime_labels.index)
|
||||
if len(common_idx) < 30:
|
||||
report.conclusion = "INSUFFICIENT DATA"
|
||||
return report
|
||||
|
||||
f = factor_scores[common_idx]
|
||||
labels = regime_labels[common_idx]
|
||||
|
||||
# Mutual Information
|
||||
report.mutual_info = round(mutual_information(f, labels), 4)
|
||||
|
||||
# ANOVA
|
||||
report.anova_f = round(anova_f_score(f, labels), 1)
|
||||
|
||||
# KL Divergence between each pair of regimes
|
||||
unique_regimes = sorted(labels.unique())
|
||||
for i, ra in enumerate(unique_regimes):
|
||||
for rb in unique_regimes[i + 1:]:
|
||||
kl = kl_divergence(f, labels, ra, rb)
|
||||
report.kl_pairs[f"{ra}↔{rb}"] = round(kl, 4)
|
||||
|
||||
# Best separation
|
||||
if report.kl_pairs:
|
||||
sorted_pairs = sorted(report.kl_pairs, key=report.kl_pairs.get, reverse=True)
|
||||
report.best_separates = sorted_pairs[:2]
|
||||
|
||||
# Separation score (0-1 composite)
|
||||
mi_norm = min(report.mutual_info / 0.5, 1.0)
|
||||
kl_avg = np.mean(list(report.kl_pairs.values())) if report.kl_pairs else 0
|
||||
kl_norm = min(kl_avg / 1.0, 1.0)
|
||||
report.separation_score = round(0.5 * mi_norm + 0.5 * kl_norm, 2)
|
||||
|
||||
# Is this a good regime factor?
|
||||
report.is_regime_factor = (
|
||||
report.mutual_info > 0.1 and
|
||||
kl_avg > 0.5
|
||||
)
|
||||
|
||||
if report.separation_score > 0.8:
|
||||
report.conclusion = "EXCELLENT regime separator"
|
||||
elif report.separation_score > 0.5:
|
||||
report.conclusion = "GOOD regime separator"
|
||||
elif report.separation_score > 0.3:
|
||||
report.conclusion = "MODERATE — some regime separation"
|
||||
else:
|
||||
report.conclusion = "WEAK regime separator"
|
||||
|
||||
return report
|
||||
|
||||
def validate_from_db(self, factor_name: str,
|
||||
score_query: str) -> RegimeReport:
|
||||
"""Load scores and regime labels from DB, then validate."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
scores_df = pd.read_sql_query(score_query, conn)
|
||||
regimes_df = pd.read_sql_query(
|
||||
"SELECT date, regime FROM regime_history", conn
|
||||
)
|
||||
conn.close()
|
||||
|
||||
if scores_df.empty or regimes_df.empty:
|
||||
r = RegimeReport(factor_name)
|
||||
r.conclusion = "NO DATA"
|
||||
return r
|
||||
|
||||
scores = scores_df.set_index("date")["score"]
|
||||
regimes = regimes_df.set_index("date")["regime"]
|
||||
|
||||
return self.validate(factor_name, scores, regimes)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
validation/reporter.py — Aggregates all validation reports into a unified summary.
|
||||
|
||||
Used by: python main.py validate
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from .factor_validator import FactorValidator, FactorReport
|
||||
from .regime_validator import RegimeValidator, RegimeReport
|
||||
from .transition_validator import TransitionValidator, TransitionReport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValidationReporter:
|
||||
"""
|
||||
Orchestrates full validation pipeline:
|
||||
|
||||
1. Factor validation (IC, ICIR, Hit Ratio) for each factor
|
||||
2. Regime validation (MI, KL, ANOVA) for each factor
|
||||
3. Transition validation (stability, flip rate)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
from config import config
|
||||
self.db_path = db_path or config.db_path
|
||||
self.factor_validator = FactorValidator(self.db_path)
|
||||
self.regime_validator = RegimeValidator(self.db_path)
|
||||
self.transition_validator = TransitionValidator(self.db_path)
|
||||
|
||||
def run_all(self) -> str:
|
||||
"""Run all validations and return a formatted report string."""
|
||||
lines = []
|
||||
lines.append("=" * 70)
|
||||
lines.append(f" ChanMacro Validation Report — {Date.today()}")
|
||||
lines.append("=" * 70)
|
||||
|
||||
# ── Factor Validation ──────────────────────────
|
||||
lines.append("")
|
||||
lines.append("─" * 50)
|
||||
lines.append(" FACTOR VALIDATION (Predictive Power)")
|
||||
lines.append("─" * 50)
|
||||
|
||||
factor_queries = {
|
||||
"Price Structure": "SELECT date, score FROM ohlcv_daily WHERE ema20 IS NOT NULL",
|
||||
"Breadth": """
|
||||
SELECT bd.date,
|
||||
(bd.advance_top50*1.0/(bd.advance_top50+bd.decline_top50+1)*100*0.30
|
||||
+ bd.above_ema20_top50*1.0/50*100*0.35
|
||||
+ bd.new_highs_20d_top50*1.0/50*100*0.20
|
||||
+ 50*0.15) as score
|
||||
FROM breadth_daily bd
|
||||
""",
|
||||
}
|
||||
|
||||
factor_reports: list[FactorReport] = []
|
||||
for name, query in factor_queries.items():
|
||||
try:
|
||||
report = self.factor_validator.validate_from_db(name, query)
|
||||
factor_reports.append(report)
|
||||
lines.append(report.summary())
|
||||
lines.append("")
|
||||
except Exception as e:
|
||||
logger.warning(f"Factor validation failed for {name}: {e}")
|
||||
|
||||
# ── Regime Validation ──────────────────────────
|
||||
lines.append("─" * 50)
|
||||
lines.append(" REGIME VALIDATION (Regime Separation)")
|
||||
lines.append("─" * 50)
|
||||
|
||||
regime_reports: list[RegimeReport] = []
|
||||
for name, query in factor_queries.items():
|
||||
try:
|
||||
report = self.regime_validator.validate_from_db(name, query)
|
||||
regime_reports.append(report)
|
||||
lines.append(report.summary())
|
||||
lines.append("")
|
||||
except Exception as e:
|
||||
logger.warning(f"Regime validation failed for {name}: {e}")
|
||||
|
||||
# ── Transition Validation ──────────────────────
|
||||
lines.append("─" * 50)
|
||||
lines.append(" TRANSITION VALIDATION (Regime Stability)")
|
||||
lines.append("─" * 50)
|
||||
|
||||
try:
|
||||
t_report = self.transition_validator.validate_from_db()
|
||||
lines.append(t_report.summary())
|
||||
except Exception as e:
|
||||
logger.warning(f"Transition validation failed: {e}")
|
||||
|
||||
# ── Summary ────────────────────────────────────
|
||||
lines.append("")
|
||||
lines.append("=" * 70)
|
||||
lines.append(" SUMMARY")
|
||||
lines.append("=" * 70)
|
||||
|
||||
# Factor ranking by IC
|
||||
if factor_reports:
|
||||
ranked = sorted(factor_reports, key=lambda r: abs(r.ic_mean), reverse=True)
|
||||
lines.append(" Factor Ranking (by |IC|):")
|
||||
for i, r in enumerate(ranked):
|
||||
tag = "★★★" if abs(r.ic_mean) > 0.05 else "★★" if abs(r.ic_mean) > 0.03 else "★"
|
||||
lines.append(f" {i+1}. {r.factor_name:20s} IC={r.ic_mean:+.4f} {tag} {r.conclusion}")
|
||||
|
||||
# Regime factor ranking
|
||||
if regime_reports:
|
||||
ranked_r = sorted(regime_reports, key=lambda r: r.separation_score, reverse=True)
|
||||
lines.append("")
|
||||
lines.append(" Regime Factor Ranking (by Separation Score):")
|
||||
for i, r in enumerate(ranked_r):
|
||||
lines.append(f" {i+1}. {r.factor_name:20s} Score={r.separation_score:.2f} {r.conclusion}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("=" * 70)
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
validation/transition_validator.py — Validates regime stability.
|
||||
|
||||
Tests: Transition matrix, average duration, flip rate, state entropy.
|
||||
Answers: "Does the regime design produce stable, persistent states?"
|
||||
|
||||
Hard requirements:
|
||||
- avg_duration > 5 days
|
||||
- flip_rate < 15%
|
||||
- Fails → regime definition needs redesign.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import config
|
||||
from .metrics import transition_matrix, regime_duration_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransitionReport:
|
||||
"""Structured report for regime stability validation."""
|
||||
|
||||
def __init__(self):
|
||||
self.avg_duration: float = 0.0
|
||||
self.flip_rate: float = 0.0
|
||||
self.state_entropy: float = 0.0
|
||||
self.n_days: int = 0
|
||||
self.transition_matrix: Optional[pd.DataFrame] = None
|
||||
self.persistence_score: float = 0.0
|
||||
self.is_stable: bool = False
|
||||
self.conclusion: str = ""
|
||||
self.warnings: list[str] = []
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
f"Regime Stability (N={self.n_days} days)",
|
||||
f" Avg Duration: {self.avg_duration:.1f} days (need > {config.regime_min_avg_duration})",
|
||||
f" Flip Rate: {self.flip_rate:.1%} (need < {config.regime_max_flip_rate:.0%})",
|
||||
f" State Entropy: {self.state_entropy:.3f}",
|
||||
f" Persistence Score: {self.persistence_score:.2f}",
|
||||
f" Stable: {'YES' if self.is_stable else 'NO — redesign needed'}",
|
||||
]
|
||||
if self.warnings:
|
||||
lines.append(f" Warnings: {'; '.join(self.warnings)}")
|
||||
if self.transition_matrix is not None:
|
||||
lines.append(f" Transition Matrix:\n{self.transition_matrix.to_string()}")
|
||||
lines.append(f" → {self.conclusion}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TransitionValidator:
|
||||
"""
|
||||
Validates regime temporal stability.
|
||||
|
||||
Regime must persist — not flip daily.
|
||||
If flip_rate > 20% or avg_duration < 3 days → regime definition failed.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def validate(self, regime_labels: pd.Series) -> TransitionReport:
|
||||
"""Validate a regime sequence for stability."""
|
||||
report = TransitionReport()
|
||||
report.n_days = len(regime_labels)
|
||||
|
||||
if len(regime_labels) < 30:
|
||||
report.conclusion = "INSUFFICIENT DATA (< 30 days)"
|
||||
return report
|
||||
|
||||
# Duration stats
|
||||
stats = regime_duration_stats(regime_labels)
|
||||
report.avg_duration = stats["avg_duration"]
|
||||
report.flip_rate = stats["flip_rate"]
|
||||
report.state_entropy = stats["state_entropy"]
|
||||
|
||||
# Transition matrix
|
||||
report.transition_matrix = transition_matrix(regime_labels)
|
||||
|
||||
# Persistence: how often does regime stay the same?
|
||||
diag = np.diag(report.transition_matrix.values)
|
||||
report.persistence_score = round(float(np.mean(diag)), 2)
|
||||
|
||||
# Stability check
|
||||
report.is_stable = (
|
||||
report.avg_duration >= config.regime_min_avg_duration and
|
||||
report.flip_rate <= config.regime_max_flip_rate
|
||||
)
|
||||
|
||||
# Warnings
|
||||
if report.avg_duration < 3:
|
||||
report.warnings.append(f"CRITICAL: avg duration={report.avg_duration:.1f}d — regime flips too fast")
|
||||
elif report.avg_duration < config.regime_min_avg_duration:
|
||||
report.warnings.append(f"WARNING: avg duration={report.avg_duration:.1f}d < {config.regime_min_avg_duration}")
|
||||
|
||||
if report.flip_rate > 0.20:
|
||||
report.warnings.append(f"CRITICAL: flip rate={report.flip_rate:.1%} — regime unstable")
|
||||
elif report.flip_rate > config.regime_max_flip_rate:
|
||||
report.warnings.append(f"WARNING: flip rate={report.flip_rate:.1%} > {config.regime_max_flip_rate:.0%}")
|
||||
|
||||
if report.state_entropy > 2.0:
|
||||
report.warnings.append(f"NOTE: high state entropy={report.state_entropy:.2f}, regimes may be too fine-grained")
|
||||
|
||||
if report.is_stable:
|
||||
report.conclusion = "PASS: regime design is stable"
|
||||
else:
|
||||
report.conclusion = "FAIL: regime definition needs adjustment"
|
||||
|
||||
return report
|
||||
|
||||
def validate_from_db(self) -> TransitionReport:
|
||||
"""Load regime history from DB and validate stability."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
df = pd.read_sql_query(
|
||||
"SELECT date, regime FROM regime_history ORDER BY date", conn
|
||||
)
|
||||
conn.close()
|
||||
|
||||
if df.empty:
|
||||
r = TransitionReport()
|
||||
r.conclusion = "NO DATA"
|
||||
return r
|
||||
|
||||
regimes = df.set_index("date")["regime"]
|
||||
return self.validate(regimes)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
web/app.py — ChanMacro dashboard (Flask, port 8124).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import json
|
||||
from datetime import date as Date
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
|
||||
from database import get_connection
|
||||
from config import config
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketStateVector
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _build_state(target: Date):
|
||||
"""Build MarketStateVector and persist regime to DB."""
|
||||
ps = PriceStructureScorer().compute(target)
|
||||
br = BreadthScorer().compute(target)
|
||||
oi = OIMatrixScorer().compute(target)
|
||||
vol = VolatilityRegimeScorer().compute(target)
|
||||
|
||||
detector = RegimeDetector()
|
||||
detector.load_state(config.db_path)
|
||||
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
|
||||
|
||||
state = MarketStateVector(
|
||||
date=target, regime=r.regime, regime_confidence=r.confidence,
|
||||
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
|
||||
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
|
||||
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
|
||||
breadth_divergence=br.breadth_divergence,
|
||||
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
|
||||
price_structure_score=ps, breadth_score=br,
|
||||
oi_matrix_score=oi, volatility_regime_score=vol,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
|
||||
# Persist regime to DB so load_state() works across requests
|
||||
conn = get_connection()
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO regime_history
|
||||
(date, regime, confidence, regime_version, maturity_score, all_scores_json,
|
||||
prior_regime, confirmation_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
str(target), r.regime.value, r.confidence, r.regime_version,
|
||||
r.maturity_score, json.dumps(r.all_scores),
|
||||
r.prior_regime.value if r.prior_regime else None,
|
||||
r.confirmation_days,
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return state
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def dashboard():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/state")
|
||||
def api_state():
|
||||
"""Current market state with all factor scores."""
|
||||
try:
|
||||
target = Date.today()
|
||||
state = _build_state(target)
|
||||
return jsonify({
|
||||
"date": str(state.date),
|
||||
"regime": state.regime.value,
|
||||
"regime_confidence": state.regime_confidence,
|
||||
"regime_maturity": state.regime_maturity_score,
|
||||
"breadth": {
|
||||
"score": state.breadth_score.score,
|
||||
"bucket": state.breadth_bucket.value,
|
||||
"top20": state.breadth_top20,
|
||||
"top30": state.breadth_top30,
|
||||
"top50": state.breadth_top50,
|
||||
"divergence": state.breadth_divergence,
|
||||
"narrative": state.breadth_score.narrative,
|
||||
},
|
||||
"oi_state": state.oi_state.value,
|
||||
"oi_score": state.oi_matrix_score.score,
|
||||
"oi_narrative": state.oi_matrix_score.narrative,
|
||||
"volatility": state.volatility_regime.value,
|
||||
"price_structure": {
|
||||
"score": state.price_structure_score.score,
|
||||
"trend": state.price_structure_score.trend_strength,
|
||||
"vol_comp": state.price_structure_score.volatility_compression,
|
||||
"momentum": state.price_structure_score.momentum,
|
||||
"label": state.price_structure_score.label,
|
||||
"narrative": state.price_structure_score.narrative,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/history")
|
||||
def api_history():
|
||||
"""Regime and factor score history."""
|
||||
days = request.args.get("days", 60, type=int)
|
||||
conn = get_connection()
|
||||
|
||||
# Regime history
|
||||
regimes = conn.execute(
|
||||
"SELECT date, regime, confidence, maturity_score FROM regime_history ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
|
||||
# Breadth history
|
||||
breadth = conn.execute(
|
||||
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
"regimes": [{"date": r["date"], "regime": r["regime"],
|
||||
"confidence": r["confidence"], "maturity": r["maturity_score"]}
|
||||
for r in reversed(regimes)],
|
||||
"breadth": [{"date": b["date"], "advance": b["advance_top50"],
|
||||
"decline": b["decline_top50"], "above_ema20": b["above_ema20_top50"]}
|
||||
for b in reversed(breadth)],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/expectancy")
|
||||
def api_expectancy():
|
||||
"""Query signal expectancy."""
|
||||
signal = request.args.get("signal", "B3")
|
||||
try:
|
||||
target = Date.today()
|
||||
state = _build_state(target)
|
||||
engine = BayesianExpectancyEngine(level_min_samples=5)
|
||||
report = engine.estimate(state, signal_type=signal, target_date=target)
|
||||
|
||||
layers = []
|
||||
for l in report.layers:
|
||||
layers.append({
|
||||
"name": l.name,
|
||||
"samples": l.samples,
|
||||
"effective_samples": l.effective_samples,
|
||||
"raw_winrate": l.raw_winrate,
|
||||
"posterior_winrate": l.posterior_winrate,
|
||||
"avg_return": l.avg_return,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"signal": signal,
|
||||
"final_estimate": report.final_estimate,
|
||||
"sufficiency": report.sufficiency.value,
|
||||
"source": report.source,
|
||||
"avg_return_7d": report.avg_return_7d,
|
||||
"profit_factor": report.profit_factor,
|
||||
"max_adverse": report.max_adverse_excursion,
|
||||
"layers": layers,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from scheduler import get_scheduler
|
||||
get_scheduler().start()
|
||||
app.run(host="0.0.0.0", port=8124, debug=True)
|
||||
@@ -0,0 +1,160 @@
|
||||
// dashboard.js — ChanMacro
|
||||
|
||||
const C = { TREND: "#3fb950", RANGE: "#d29922", PANIC: "#f85149" };
|
||||
let regimeChart = null, breadthChart = null;
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const r = await fetch("/api/state");
|
||||
const d = await r.json();
|
||||
if (d.error) { document.getElementById("update-time").textContent = d.error; return; }
|
||||
|
||||
document.getElementById("update-time").textContent = d.date;
|
||||
|
||||
// Hero
|
||||
const regime = d.regime;
|
||||
const names = { TREND: "TREND", RANGE: "RANGE", PANIC: "PANIC" };
|
||||
document.getElementById("hero-regime").textContent = names[regime] || regime;
|
||||
document.getElementById("hero-regime").className = "regime-name " + regime.toLowerCase();
|
||||
document.getElementById("hero-badge").textContent = regime;
|
||||
document.getElementById("hero-badge").className = "regime-badge " + regime.toLowerCase();
|
||||
document.getElementById("hero-conf").textContent = (d.regime_confidence * 100).toFixed(0) + "%";
|
||||
document.getElementById("hero-maturity").textContent = d.regime_maturity.toFixed(0);
|
||||
document.getElementById("hero-ps").textContent = d.price_structure.score.toFixed(0);
|
||||
document.getElementById("hero-ps").style.color =
|
||||
d.price_structure.score >= 60 ? "#3fb950" : d.price_structure.score >= 40 ? "#d29922" : "#f85149";
|
||||
document.getElementById("hero-br").textContent = d.breadth.score.toFixed(0);
|
||||
document.getElementById("hero-br").style.color =
|
||||
d.breadth.bucket === "EXTREME" || d.breadth.bucket === "STRONG" ? "#3fb950" :
|
||||
d.breadth.bucket === "WEAK" || d.breadth.bucket === "PANIC" ? "#f85149" : "#d29922";
|
||||
|
||||
// Factor cards
|
||||
const ps = d.price_structure;
|
||||
document.getElementById("f-price").textContent = ps.score.toFixed(0);
|
||||
document.getElementById("f-price").style.color =
|
||||
ps.score >= 60 ? "#3fb950" : ps.score >= 40 ? "#d29922" : "#f85149";
|
||||
document.getElementById("f-price-sub").textContent =
|
||||
`趋势 ${ps.trend.toFixed(0)} · 波动 ${ps.vol_comp.toFixed(0)} · 动量 ${ps.momentum.toFixed(0)}`;
|
||||
document.getElementById("bar-price").style.width = ps.score + "%";
|
||||
document.getElementById("bar-price").style.background =
|
||||
ps.score >= 60 ? "#3fb950" : ps.score >= 40 ? "#d29922" : "#f85149";
|
||||
|
||||
const br = d.breadth;
|
||||
document.getElementById("f-breadth").textContent = br.score.toFixed(0);
|
||||
document.getElementById("f-breadth").style.color =
|
||||
br.bucket === "EXTREME" || br.bucket === "STRONG" ? "#3fb950" :
|
||||
br.bucket === "WEAK" || br.bucket === "PANIC" ? "#f85149" : "#d29922";
|
||||
document.getElementById("f-breadth-sub").textContent =
|
||||
`${br.bucket} · T20=${br.top20.toFixed(0)} T50=${br.top50.toFixed(0)}`;
|
||||
document.getElementById("bar-breadth").style.width = br.score + "%";
|
||||
document.getElementById("bar-breadth").style.background =
|
||||
br.bucket === "EXTREME" || br.bucket === "STRONG" ? "#3fb950" :
|
||||
br.bucket === "WEAK" || br.bucket === "PANIC" ? "#f85149" : "#d29922";
|
||||
|
||||
document.getElementById("f-oi").textContent = d.oi_state.toUpperCase().replace(" ", "\n");
|
||||
document.getElementById("f-oi").style.color =
|
||||
d.oi_state === "New Longs" ? "#3fb950" : d.oi_state.includes("Short") || d.oi_state === "Long Exit" ? "#f85149" : "#8b949e";
|
||||
document.getElementById("f-oi-sub").textContent = d.oi_narrative;
|
||||
|
||||
const vm = { LOW_VOL: "低波动", NORMAL_VOL: "正常", HIGH_VOL: "高波动", EXPLOSIVE_VOL: "极端" };
|
||||
document.getElementById("f-vol").textContent = vm[d.volatility] || d.volatility;
|
||||
document.getElementById("f-vol").style.color =
|
||||
d.volatility === "LOW_VOL" ? "#58a6ff" : d.volatility === "NORMAL_VOL" ? "#8b949e" :
|
||||
d.volatility === "HIGH_VOL" ? "#d29922" : "#f85149";
|
||||
document.getElementById("f-vol-sub").textContent = d.volatility;
|
||||
document.getElementById("bar-vol").style.width =
|
||||
(d.volatility === "EXPLOSIVE_VOL" ? 95 : d.volatility === "HIGH_VOL" ? 70 :
|
||||
d.volatility === "NORMAL_VOL" ? 40 : 20) + "%";
|
||||
document.getElementById("bar-vol").style.background =
|
||||
d.volatility === "EXPLOSIVE_VOL" ? "#f85149" : d.volatility === "HIGH_VOL" ? "#d29922" :
|
||||
d.volatility === "NORMAL_VOL" ? "#8b949e" : "#58a6ff";
|
||||
} catch (e) {
|
||||
document.getElementById("update-time").textContent = "连接失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const r = await fetch("/api/history?days=60");
|
||||
const d = await r.json();
|
||||
|
||||
const dates = d.regimes.map(x => x.date);
|
||||
const colors = d.regimes.map(x => C[x.regime] || "#5c6675");
|
||||
|
||||
if (regimeChart) regimeChart.destroy();
|
||||
regimeChart = new Chart(document.getElementById("chart-regime").getContext("2d"), {
|
||||
type: "bar",
|
||||
data: { labels: dates, datasets: [{ data: d.regimes.map(x => x.confidence * 100),
|
||||
backgroundColor: colors, borderWidth: 0, borderRadius: 2 }] },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: "#5c6675", maxTicksLimit: 15, maxRotation: 45, font: { size: 10 } },
|
||||
grid: { color: "#151a23" } },
|
||||
y: { max: 100, ticks: { color: "#5c6675", font: { size: 10 } }, grid: { color: "#151a23" } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (breadthChart) breadthChart.destroy();
|
||||
breadthChart = new Chart(document.getElementById("chart-breadth").getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: d.breadth.map(x => x.date),
|
||||
datasets: [
|
||||
{ label: "上涨", data: d.breadth.map(x => x.advance), borderColor: "#3fb950",
|
||||
backgroundColor: "rgba(63,185,80,0.08)", fill: true, tension: 0.3, pointRadius: 0 },
|
||||
{ label: "下跌", data: d.breadth.map(x => x.decline), borderColor: "#f85149",
|
||||
backgroundColor: "rgba(248,81,73,0.06)", fill: true, tension: 0.3, pointRadius: 0 },
|
||||
{ label: ">EMA20", data: d.breadth.map(x => x.above_ema20), borderColor: "#58a6ff",
|
||||
borderDash: [3, 3], tension: 0.3, pointRadius: 0 },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: "#5c6675", usePointStyle: true, boxWidth: 6, font: { size: 10 } } } },
|
||||
scales: {
|
||||
x: { ticks: { color: "#5c6675", maxTicksLimit: 15, maxRotation: 45, font: { size: 10 } },
|
||||
grid: { color: "#151a23" } },
|
||||
y: { ticks: { color: "#5c6675", font: { size: 10 } }, grid: { color: "#151a23" } }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function loadExpectancy() {
|
||||
const signal = document.getElementById("exp-signal").value;
|
||||
try {
|
||||
const r = await fetch(`/api/expectancy?signal=${signal}`);
|
||||
const d = await r.json();
|
||||
if (d.error) { document.getElementById("exp-layers").innerHTML =
|
||||
`<tr><td colspan="6" style="color:#f85149">${d.error}</td></tr>`; return; }
|
||||
|
||||
const el = document.getElementById("exp-sufficiency");
|
||||
el.textContent = d.sufficiency;
|
||||
el.className = "suff suff-" + d.sufficiency;
|
||||
|
||||
let html = "";
|
||||
for (const l of d.layers) {
|
||||
html += `<tr>
|
||||
<td>${l.name}</td><td>${l.samples}</td><td>${l.effective_samples.toFixed(0)}</td>
|
||||
<td>${l.raw_winrate ? (l.raw_winrate * 100).toFixed(1) + "%" : "—"}</td>
|
||||
<td><strong>${(l.posterior_winrate * 100).toFixed(1)}%</strong></td>
|
||||
<td style="color:${l.avg_return > 0 ? '#3fb950' : l.avg_return < 0 ? '#f85149' : '#8b949e'}">${l.avg_return ? (l.avg_return > 0 ? "+" : "") + l.avg_return.toFixed(2) + "%" : "—"}</td>
|
||||
</tr>`;
|
||||
}
|
||||
document.getElementById("exp-layers").innerHTML = html;
|
||||
|
||||
let s = `后验胜率 <strong style="color:#58a6ff">${(d.final_estimate * 100).toFixed(1)}%</strong>`;
|
||||
if (d.avg_return_7d) s += ` · 平均收益 <strong>${d.avg_return_7d > 0 ? "+" : ""}${d.avg_return_7d.toFixed(2)}%</strong>`;
|
||||
if (d.profit_factor) s += ` · 盈亏比 <strong>${d.profit_factor}</strong>`;
|
||||
if (d.max_adverse) s += ` · MAE <strong>${d.max_adverse.toFixed(1)}%</strong>`;
|
||||
document.getElementById("exp-summary").innerHTML = s;
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
loadState();
|
||||
loadHistory();
|
||||
loadExpectancy();
|
||||
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChanMacro — 市场状态</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #0a0e14; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, "SF Mono", monospace; }
|
||||
.app { max-width: 1200px; margin: 0 auto; padding: 20px 24px; }
|
||||
|
||||
/* Header */
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-end; padding: 20px 0 28px;
|
||||
border-bottom: 1px solid #1c2333; margin-bottom: 24px; }
|
||||
.header h1 { font-size: 22px; font-weight: 600; letter-spacing: 1px; }
|
||||
.header h1 span { color: #58a6ff; }
|
||||
.header .time { color: #5c6675; font-size: 13px; }
|
||||
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #3fb950;
|
||||
margin-right: 6px; animation: pulse 2s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
|
||||
/* Regime Hero */
|
||||
.hero { display: flex; gap: 16px; margin-bottom: 24px; }
|
||||
.hero-card { flex: 1; background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 20px 24px; }
|
||||
.hero-card.main { flex: 2; display: flex; align-items: center; gap: 28px; }
|
||||
.regime-badge { display: inline-block; padding: 5px 16px; border-radius: 4px; font-size: 13px;
|
||||
font-weight: 600; letter-spacing: 2px; }
|
||||
.regime-badge.trend { background: rgba(63,185,80,0.12); color: #3fb950; border: 1px solid rgba(63,185,80,0.3); }
|
||||
.regime-badge.range { background: rgba(210,153,34,0.12); color: #d29922; border: 1px solid rgba(210,153,34,0.3); }
|
||||
.regime-badge.panic { background: rgba(248,81,73,0.12); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
|
||||
.regime-name { font-size: 42px; font-weight: 700; letter-spacing: 2px; }
|
||||
.regime-name.trend { color: #3fb950; }
|
||||
.regime-name.range { color: #d29922; }
|
||||
.regime-name.panic { color: #f85149; }
|
||||
.hero-stat { text-align: center; }
|
||||
.hero-stat .val { font-size: 28px; font-weight: 600; color: #e6edf3; }
|
||||
.hero-stat .lbl { font-size: 11px; color: #5c6675; letter-spacing: 1px; margin-top: 4px; }
|
||||
|
||||
/* Factor Grid */
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
|
||||
.fcard { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
|
||||
.fcard .title { font-size: 11px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 10px; }
|
||||
.fcard .score { font-size: 38px; font-weight: 700; margin-bottom: 4px; }
|
||||
.fcard .sub { font-size: 12px; color: #5c6675; }
|
||||
.fcard .bar-wrap { height: 3px; background: #1c2333; border-radius: 2px; margin-top: 12px; }
|
||||
.fcard .bar { height: 100%; border-radius: 2px; transition: width 0.6s; }
|
||||
|
||||
/* Charts */
|
||||
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
|
||||
.chart-box { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
|
||||
.chart-box h3 { font-size: 12px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 14px; }
|
||||
.chart-box canvas { max-height: 260px; }
|
||||
|
||||
/* Expectancy */
|
||||
.exp { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
|
||||
.exp h3 { font-size: 12px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 14px; }
|
||||
.exp-row { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
|
||||
.exp select { background: #0a0e14; color: #c9d1d9; border: 1px solid #1c2333; padding: 6px 12px;
|
||||
border-radius: 4px; font-size: 13px; }
|
||||
.exp button { background: #1c3a5c; color: #58a6ff; border: 1px solid #2d4f7c; padding: 6px 18px;
|
||||
border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
.exp button:hover { background: #254d7a; }
|
||||
.exp .suff { font-size: 11px; padding: 3px 10px; border-radius: 3px; }
|
||||
.suff-HIGH { background: rgba(63,185,80,0.12); color: #3fb950; }
|
||||
.suff-MEDIUM { background: rgba(210,153,34,0.12); color: #d29922; }
|
||||
.suff-LOW { background: rgba(248,81,73,0.12); color: #f85149; }
|
||||
.suff-INSUFFICIENT { background: rgba(92,102,117,0.12); color: #5c6675; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; color: #5c6675; font-weight: 500; padding: 8px 10px; border-bottom: 1px solid #1c2333; }
|
||||
td { padding: 7px 10px; border-bottom: 1px solid #0e1219; color: #8b949e; }
|
||||
td strong { color: #e6edf3; }
|
||||
.exp-summary { margin-top: 14px; font-size: 13px; color: #8b949e; padding: 10px 14px;
|
||||
background: #0d1117; border-radius: 6px; border-left: 3px solid #58a6ff; }
|
||||
.exp-summary strong { color: #e6edf3; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1><span>Chan</span>Macro</h1>
|
||||
</div>
|
||||
<div class="time"><span class="dot"></span> <span id="update-time">加载中...</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Regime Hero -->
|
||||
<div class="hero">
|
||||
<div class="hero-card main">
|
||||
<div>
|
||||
<div class="regime-badge" id="hero-badge">—</div>
|
||||
<div class="regime-name" id="hero-regime">—</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:32px; margin-left:auto;">
|
||||
<div class="hero-stat"><div class="val" id="hero-conf">—</div><div class="lbl">置信度</div></div>
|
||||
<div class="hero-stat"><div class="val" id="hero-maturity">—</div><div class="lbl">成熟度</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-card" style="flex:1">
|
||||
<div class="hero-stat"><div class="val" id="hero-ps">—</div><div class="lbl">价格结构</div></div>
|
||||
</div>
|
||||
<div class="hero-card" style="flex:1">
|
||||
<div class="hero-stat"><div class="val" id="hero-br">—</div><div class="lbl">市场广度</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4 Factor Cards -->
|
||||
<div class="grid">
|
||||
<div class="fcard">
|
||||
<div class="title">价格结构 PRICE STRUCTURE</div>
|
||||
<div class="score" id="f-price">—</div>
|
||||
<div class="sub" id="f-price-sub"></div>
|
||||
<div class="bar-wrap"><div class="bar" id="bar-price"></div></div>
|
||||
</div>
|
||||
<div class="fcard">
|
||||
<div class="title">市场广度 BREADTH</div>
|
||||
<div class="score" id="f-breadth">—</div>
|
||||
<div class="sub" id="f-breadth-sub"></div>
|
||||
<div class="bar-wrap"><div class="bar" id="bar-breadth"></div></div>
|
||||
</div>
|
||||
<div class="fcard">
|
||||
<div class="title">持仓状态 OI MATRIX</div>
|
||||
<div class="score" id="f-oi" style="font-size:24px">—</div>
|
||||
<div class="sub" id="f-oi-sub"></div>
|
||||
</div>
|
||||
<div class="fcard">
|
||||
<div class="title">波动率 VOLATILITY</div>
|
||||
<div class="score" id="f-vol">—</div>
|
||||
<div class="sub" id="f-vol-sub"></div>
|
||||
<div class="bar-wrap"><div class="bar" id="bar-vol"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="charts">
|
||||
<div class="chart-box"><h3>制度历史 REGIME HISTORY</h3><canvas id="chart-regime"></canvas></div>
|
||||
<div class="chart-box"><h3>市场广度 BREADTH</h3><canvas id="chart-breadth"></canvas></div>
|
||||
</div>
|
||||
|
||||
<!-- Expectancy -->
|
||||
<div class="exp">
|
||||
<h3>信号期望 SIGNAL EXPECTANCY</h3>
|
||||
<div class="exp-row">
|
||||
<select id="exp-signal">
|
||||
<option value="B3">B3 · 三买</option><option value="B2">B2 · 二买</option><option value="B1">B1 · 一买</option>
|
||||
<option value="S3">S3 · 三卖</option><option value="S2">S2 · 二卖</option><option value="S1">S1 · 一卖</option>
|
||||
</select>
|
||||
<button onclick="loadExpectancy()">查询</button>
|
||||
<span class="suff" id="exp-sufficiency">—</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>层级</th><th>样本</th><th>有效样本</th><th>原始胜率</th><th>后验胜率</th><th>平均收益</th></tr></thead>
|
||||
<tbody id="exp-layers"></tbody>
|
||||
</table>
|
||||
<div class="exp-summary" id="exp-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -55,8 +55,8 @@ class IndicatorsBuilderMixin:
|
||||
return None
|
||||
|
||||
def add_indicators(self, df):
|
||||
fast = 26
|
||||
slow = 52
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""crypto_wyckoff — multi-TF screener for crypto (ported from A_Share_DP Architecture v1.0)."""
|
||||
|
||||
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
|
||||
|
||||
__all__ = ["WYCKOFF_ENGINE_VERSION", "ARCHITECTURE_VERSION"]
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Walk-forward Wyckoff phase/event annotations for chart overlay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from crypto_wyckoff.domain_models import OHLCVFrame, WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||
from crypto_wyckoff.cycle import CycleEngine
|
||||
from crypto_wyckoff.event import EventEngine
|
||||
from crypto_wyckoff.features import FeatureEngine
|
||||
from crypto_wyckoff.phase import PhaseEngine
|
||||
|
||||
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
|
||||
|
||||
_NOTABLE_EVENTS = {
|
||||
WyckoffEvent.PS.value,
|
||||
WyckoffEvent.SC.value,
|
||||
WyckoffEvent.AR.value,
|
||||
WyckoffEvent.ST.value,
|
||||
WyckoffEvent.SPRING.value,
|
||||
WyckoffEvent.TEST.value,
|
||||
WyckoffEvent.SOS.value,
|
||||
WyckoffEvent.LPS.value,
|
||||
WyckoffEvent.JUMP.value,
|
||||
WyckoffEvent.BACKUP.value,
|
||||
WyckoffEvent.BC.value,
|
||||
WyckoffEvent.UTAD.value,
|
||||
WyckoffEvent.SOW.value,
|
||||
WyckoffEvent.LPSY.value,
|
||||
}
|
||||
|
||||
|
||||
def _slice_frame(frame: OHLCVFrame, end_idx: int) -> OHLCVFrame:
|
||||
n = end_idx + 1
|
||||
return OHLCVFrame(
|
||||
ts_code=frame.ts_code,
|
||||
timeframe=frame.timeframe,
|
||||
trade_dates=frame.trade_dates[:n],
|
||||
open=frame.open[:n],
|
||||
high=frame.high[:n],
|
||||
low=frame.low[:n],
|
||||
close=frame.close[:n],
|
||||
volume=frame.volume[:n],
|
||||
amount=frame.amount[:n] if frame.amount else [],
|
||||
)
|
||||
|
||||
|
||||
def _compress_phases(points: list[tuple[str, str]]) -> list[dict]:
|
||||
"""points: [(date_iso, phase), ...] → segments."""
|
||||
if not points:
|
||||
return []
|
||||
segs: list[dict] = []
|
||||
start, phase = points[0]
|
||||
prev = start
|
||||
for d, p in points[1:]:
|
||||
if p != phase:
|
||||
segs.append({"start": start, "end": prev, "phase": phase})
|
||||
start, phase = d, p
|
||||
prev = d
|
||||
segs.append({"start": start, "end": prev, "phase": phase})
|
||||
return segs
|
||||
|
||||
|
||||
def annotate_frame(
|
||||
frame: OHLCVFrame,
|
||||
step: int | None = None,
|
||||
*,
|
||||
role: str | None = None,
|
||||
) -> dict:
|
||||
"""Pure annotation: phase bands + event markers + latest levels.
|
||||
|
||||
``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe.
|
||||
``step`` defaults by role to keep interactive charts snappy.
|
||||
"""
|
||||
tf = role or frame.timeframe
|
||||
min_bars = _MIN_BARS.get(tf, 30)
|
||||
if step is None:
|
||||
step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2)
|
||||
|
||||
empty = {
|
||||
"phases": [],
|
||||
"events": [],
|
||||
"levels": {},
|
||||
"bars": len(frame),
|
||||
"timeframe": tf,
|
||||
}
|
||||
if frame.empty or len(frame) < min_bars:
|
||||
return empty
|
||||
|
||||
feat_eng = FeatureEngine()
|
||||
cycle_eng = CycleEngine()
|
||||
phase_eng = PhaseEngine()
|
||||
event_eng = EventEngine()
|
||||
|
||||
phase_points: list[tuple[str, str]] = []
|
||||
events: list[dict] = []
|
||||
last_event: str | None = None
|
||||
levels: dict = {}
|
||||
|
||||
# Ensure last bar is always evaluated
|
||||
indices = list(range(min_bars - 1, len(frame), step))
|
||||
if indices[-1] != len(frame) - 1:
|
||||
indices.append(len(frame) - 1)
|
||||
|
||||
for i in indices:
|
||||
sub = _slice_frame(frame, i)
|
||||
f = feat_eng.run(sub, tf)
|
||||
c = cycle_eng.run(f, tf)
|
||||
p = phase_eng.run(c, f, tf)
|
||||
e = event_eng.run(c, p, f, tf)
|
||||
|
||||
d = str(frame.trade_dates[i])[:10]
|
||||
phase = p.payload.get("phase") or WyckoffPhase.NONE.value
|
||||
phase_points.append((d, phase))
|
||||
|
||||
cur = e.payload.get("current_event") or WyckoffEvent.NONE.value
|
||||
if cur in _NOTABLE_EVENTS and cur != last_event:
|
||||
events.append({
|
||||
"date": d,
|
||||
"event": cur,
|
||||
"price": float(frame.close[i]),
|
||||
"low": float(frame.low[i]),
|
||||
"high": float(frame.high[i]),
|
||||
})
|
||||
last_event = cur
|
||||
elif cur == WyckoffEvent.NONE.value:
|
||||
last_event = None
|
||||
|
||||
if i == len(frame) - 1 and not f.payload.get("insufficient"):
|
||||
levels = {
|
||||
k: f.payload.get(k)
|
||||
for k in (
|
||||
"range_high", "range_low", "ma20", "ma60",
|
||||
"swing_high", "swing_low", "close",
|
||||
)
|
||||
if f.payload.get(k) is not None
|
||||
}
|
||||
levels["phase"] = phase
|
||||
levels["cycle"] = c.payload.get("cycle")
|
||||
levels["current_event"] = cur
|
||||
|
||||
return {
|
||||
"phases": _compress_phases(phase_points),
|
||||
"events": events,
|
||||
"levels": levels,
|
||||
"bars": len(frame),
|
||||
"timeframe": tf,
|
||||
}
|
||||
|
||||
|
||||
_RANGE_CYCLES = {
|
||||
WyckoffCycle.ACCUMULATION.value,
|
||||
WyckoffCycle.RE_ACCUMULATION.value,
|
||||
WyckoffCycle.DISTRIBUTION.value,
|
||||
WyckoffCycle.RE_DISTRIBUTION.value,
|
||||
}
|
||||
|
||||
|
||||
def _build_range_zones(
|
||||
price_frame: OHLCVFrame,
|
||||
cycle_segs: list[dict],
|
||||
levels: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""Build price boxes (high/low × date span) for accum/distrib ranges."""
|
||||
if price_frame.empty:
|
||||
return []
|
||||
dates = [str(d)[:10] for d in price_frame.trade_dates]
|
||||
highs = price_frame.high
|
||||
lows = price_frame.low
|
||||
zones: list[dict] = []
|
||||
|
||||
for seg in cycle_segs or []:
|
||||
cy = seg.get("cycle")
|
||||
if cy not in _RANGE_CYCLES:
|
||||
continue
|
||||
start, end = seg["start"], seg["end"]
|
||||
idxs = [i for i, d in enumerate(dates) if start <= d <= end]
|
||||
if not idxs:
|
||||
# weekly bar date may sit between daily bars — take nearest window
|
||||
i0 = next((i for i, d in enumerate(dates) if d >= start), None)
|
||||
if i0 is None:
|
||||
continue
|
||||
i1 = next((i for i, d in enumerate(dates) if d > end), len(dates)) - 1
|
||||
idxs = list(range(i0, max(i0, i1) + 1))
|
||||
if not idxs:
|
||||
continue
|
||||
# pad short weekly hits to at least ~1 week of dailies for visibility
|
||||
if len(idxs) < 5 and idxs[-1] + 1 < len(dates):
|
||||
extra = min(5 - len(idxs), len(dates) - 1 - idxs[-1])
|
||||
idxs = list(range(idxs[0], idxs[-1] + 1 + max(0, extra)))
|
||||
hi = max(highs[i] for i in idxs)
|
||||
lo = min(lows[i] for i in idxs)
|
||||
if hi <= lo:
|
||||
continue
|
||||
zones.append({
|
||||
"kind": cy,
|
||||
"start": dates[idxs[0]],
|
||||
"end": dates[idxs[-1]],
|
||||
"high": float(hi),
|
||||
"low": float(lo),
|
||||
"current": False,
|
||||
})
|
||||
|
||||
# Always expose the latest trading-range box from feature snapshot
|
||||
levels = levels or {}
|
||||
rh, rl = levels.get("range_high"), levels.get("range_low")
|
||||
if rh is not None and rl is not None and float(rh) > float(rl):
|
||||
look = min(60, len(dates))
|
||||
cy = levels.get("cycle") or "Unknown"
|
||||
if cy not in _RANGE_CYCLES:
|
||||
# Phase B/C in a range → treat as accumulation-style TR for display
|
||||
ph = levels.get("phase") or ""
|
||||
if ph in ("A", "B", "C"):
|
||||
cy = WyckoffCycle.ACCUMULATION.value
|
||||
elif ph in ("D", "E") and float(levels.get("close") or 0) < float(rh):
|
||||
cy = WyckoffCycle.ACCUMULATION.value
|
||||
else:
|
||||
cy = "Range"
|
||||
zones.append({
|
||||
"kind": cy,
|
||||
"start": dates[-look],
|
||||
"end": dates[-1],
|
||||
"high": float(rh),
|
||||
"low": float(rl),
|
||||
"current": True,
|
||||
})
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
def annotate_symbol(
|
||||
ts_code: str,
|
||||
freq: str,
|
||||
end_date: date | None = None,
|
||||
lookback: int = 180,
|
||||
*,
|
||||
combo_id: str | None = None,
|
||||
) -> dict:
|
||||
"""IO + annotate for one symbol (used by API).
|
||||
|
||||
For the combo *low* chart, phase bands come from **mid** structure,
|
||||
while event markers / levels come from the low TF.
|
||||
"""
|
||||
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo
|
||||
from crypto_wyckoff.io import load_frame
|
||||
|
||||
combo = get_combo(combo_id)
|
||||
allowed = {combo["low"], combo["mid"], combo["high"]}
|
||||
if freq not in allowed:
|
||||
raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})")
|
||||
empty = {
|
||||
"ts_code": ts_code,
|
||||
"freq": freq,
|
||||
"phases": [],
|
||||
"events": [],
|
||||
"levels": {},
|
||||
"zones": [],
|
||||
"bars": 0,
|
||||
"phase_source": freq,
|
||||
"cycles": [],
|
||||
"combo_id": combo["id"],
|
||||
}
|
||||
_ = end_date
|
||||
|
||||
if freq == combo["low"]:
|
||||
low = load_frame(ts_code, combo["low"], lookback)
|
||||
mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3))
|
||||
if low is None:
|
||||
return empty
|
||||
d_ann = annotate_frame(low, role=ROLE_LOW)
|
||||
w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []}
|
||||
cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else []
|
||||
levels = d_ann.get("levels") or {}
|
||||
if cycles:
|
||||
levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")}
|
||||
for p in reversed(w_ann.get("phases") or []):
|
||||
if p.get("phase") not in (None, "None"):
|
||||
levels = {**levels, "phase": p["phase"]}
|
||||
break
|
||||
return {
|
||||
"ts_code": ts_code,
|
||||
"freq": freq,
|
||||
"end_date": low.trade_dates[-1].isoformat() if low.trade_dates else None,
|
||||
"phases": w_ann.get("phases") or [],
|
||||
"events": d_ann.get("events") or [],
|
||||
"levels": d_ann.get("levels") or {},
|
||||
"zones": _build_range_zones(low, cycles, levels),
|
||||
"bars": d_ann.get("bars", 0),
|
||||
"phase_source": combo["mid"],
|
||||
"cycles": cycles,
|
||||
"combo_id": combo["id"],
|
||||
}
|
||||
|
||||
role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH
|
||||
frame = load_frame(ts_code, freq, lookback)
|
||||
if frame is None:
|
||||
return empty
|
||||
out = annotate_frame(frame, role=role)
|
||||
out["ts_code"] = ts_code
|
||||
out["freq"] = freq
|
||||
out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None
|
||||
out["phase_source"] = freq
|
||||
out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID)
|
||||
out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {})
|
||||
out["combo_id"] = combo["id"]
|
||||
if role == ROLE_HIGH:
|
||||
if not any(p.get("phase") not in (None, "None") for p in out["phases"]):
|
||||
out["phases"] = [
|
||||
{"start": c["start"], "end": c["end"], "phase": c["cycle"]}
|
||||
for c in out["cycles"]
|
||||
if c.get("cycle") and c["cycle"] != "Unknown"
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def _cycle_segments(
|
||||
frame: OHLCVFrame,
|
||||
step: int | None = None,
|
||||
*,
|
||||
role: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Walk-forward cycle labels compressed to segments."""
|
||||
tf = role or frame.timeframe
|
||||
min_bars = _MIN_BARS.get(tf, 30)
|
||||
if step is None:
|
||||
step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2)
|
||||
if frame.empty or len(frame) < min_bars:
|
||||
return []
|
||||
|
||||
feat_eng = FeatureEngine()
|
||||
cycle_eng = CycleEngine()
|
||||
points: list[tuple[str, str]] = []
|
||||
indices = list(range(min_bars - 1, len(frame), step))
|
||||
if indices[-1] != len(frame) - 1:
|
||||
indices.append(len(frame) - 1)
|
||||
for i in indices:
|
||||
sub = _slice_frame(frame, i)
|
||||
f = feat_eng.run(sub, tf)
|
||||
c = cycle_eng.run(f, tf)
|
||||
points.append((str(frame.trade_dates[i])[:10], c.payload.get("cycle") or "Unknown"))
|
||||
segs = _compress_phases(points)
|
||||
return [{"start": s["start"], "end": s["end"], "cycle": s["phase"]} for s in segs]
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Multi-timeframe combo presets for Crypto Wyckoff Screener.
|
||||
|
||||
Roles (engine rule aliases stay D/W/M):
|
||||
high → Cycle (rules as 1M)
|
||||
mid → Phase (rules as 1w)
|
||||
low → Event (rules as 1d)
|
||||
|
||||
Actual bar TFs come from the combo (e.g. 8h/4h/1h).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.io import DATA_DIR, ensure_dirs
|
||||
|
||||
ROLE_LOW = "1d"
|
||||
ROLE_MID = "1w"
|
||||
ROLE_HIGH = "1M"
|
||||
|
||||
# Minutes for ordering / validation (provider labels)
|
||||
_TF_MINUTES: dict[str, int] = {
|
||||
"1m": 1, "2m": 2, "3m": 3, "4m": 4, "5m": 5,
|
||||
"10m": 10, "15m": 15, "20m": 20, "25m": 25, "30m": 30, "45m": 45,
|
||||
"1h": 60, "2h": 120, "3h": 180, "4h": 240, "5h": 300,
|
||||
"6h": 360, "7h": 420, "8h": 480, "9h": 540, "10h": 600,
|
||||
"11h": 660, "12h": 720, "16h": 960, "20h": 1200,
|
||||
"1d": 1440, "2d": 2880, "3d": 4320, "4d": 5760, "5d": 7200, "6d": 8640,
|
||||
"1w": 10080, "2w": 20160, "3w": 30240,
|
||||
"1M": 43200,
|
||||
}
|
||||
|
||||
# TFs we allow in custom combos (provider-backed + local 1M)
|
||||
ALLOWED_TFS: tuple[str, ...] = (
|
||||
"1h", "2h", "3h", "4h", "6h", "8h", "12h",
|
||||
"1d", "2d", "3d", "1w", "1M",
|
||||
)
|
||||
|
||||
BUILTIN: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "h8_4_1",
|
||||
"label": "8h / 4h / 1h",
|
||||
"high": "8h",
|
||||
"mid": "4h",
|
||||
"low": "1h",
|
||||
"builtin": True,
|
||||
},
|
||||
{
|
||||
"id": "d_w_m",
|
||||
"label": "1d / 1w / 1M",
|
||||
"high": "1M",
|
||||
"mid": "1w",
|
||||
"low": "1d",
|
||||
"builtin": True,
|
||||
},
|
||||
]
|
||||
|
||||
_COMBOS_FILE = DATA_DIR / "combos.json"
|
||||
_lock = threading.Lock()
|
||||
_cache: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
def tf_minutes(tf: str) -> int | None:
|
||||
if tf in _TF_MINUTES:
|
||||
return _TF_MINUTES[tf]
|
||||
# tolerate provider typo "10" → skip
|
||||
m = re.fullmatch(r"(\d+)([mhdwM])", tf)
|
||||
if not m:
|
||||
return None
|
||||
n, u = int(m.group(1)), m.group(2)
|
||||
mult = {"m": 1, "h": 60, "d": 1440, "w": 10080, "M": 43200}[u]
|
||||
return n * mult
|
||||
|
||||
|
||||
def combo_id_for(high: str, mid: str, low: str) -> str:
|
||||
def _tok(t: str) -> str:
|
||||
return t.replace("/", "_")
|
||||
|
||||
return f"{_tok(high)}_{_tok(mid)}_{_tok(low)}"
|
||||
|
||||
|
||||
def validate_combo(high: str, mid: str, low: str) -> str | None:
|
||||
"""Return error message or None if ok."""
|
||||
for tf in (high, mid, low):
|
||||
if tf not in ALLOWED_TFS:
|
||||
return f"不支持的周期: {tf}"
|
||||
if len({high, mid, low}) < 3:
|
||||
return "高/中/低周期必须互不相同"
|
||||
hm, mm, lm = tf_minutes(high), tf_minutes(mid), tf_minutes(low)
|
||||
if hm is None or mm is None or lm is None:
|
||||
return "无法解析周期长度"
|
||||
if not (hm > mm > lm):
|
||||
return "须满足 高 > 中 > 低(例如 8h > 4h > 1h)"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
high, mid, low = row.get("high"), row.get("mid"), row.get("low")
|
||||
if not high or not mid or not low:
|
||||
return None
|
||||
err = validate_combo(str(high), str(mid), str(low))
|
||||
if err:
|
||||
return None
|
||||
cid = str(row.get("id") or combo_id_for(high, mid, low))
|
||||
label = str(row.get("label") or f"{high} / {mid} / {low}")
|
||||
return {
|
||||
"id": cid,
|
||||
"label": label,
|
||||
"high": str(high),
|
||||
"mid": str(mid),
|
||||
"low": str(low),
|
||||
"builtin": bool(row.get("builtin", False)),
|
||||
}
|
||||
|
||||
|
||||
def _load_raw() -> list[dict[str, Any]]:
|
||||
ensure_dirs()
|
||||
if not _COMBOS_FILE.exists():
|
||||
return deepcopy(BUILTIN)
|
||||
try:
|
||||
data = json.loads(_COMBOS_FILE.read_text(encoding="utf-8"))
|
||||
items = data.get("combos") if isinstance(data, dict) else data
|
||||
if not isinstance(items, list):
|
||||
return deepcopy(BUILTIN)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return deepcopy(BUILTIN)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for b in BUILTIN:
|
||||
out.append(deepcopy(b))
|
||||
seen.add(b["id"])
|
||||
for row in items:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
norm = _normalize(row)
|
||||
if not norm or norm["id"] in seen:
|
||||
continue
|
||||
if norm["id"] in {b["id"] for b in BUILTIN}:
|
||||
continue
|
||||
norm["builtin"] = False
|
||||
out.append(norm)
|
||||
seen.add(norm["id"])
|
||||
return out
|
||||
|
||||
|
||||
def _save(combos: list[dict[str, Any]]) -> None:
|
||||
ensure_dirs()
|
||||
custom = [c for c in combos if not c.get("builtin")]
|
||||
payload = {"combos": custom}
|
||||
tmp = _COMBOS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(_COMBOS_FILE)
|
||||
|
||||
|
||||
def list_combos() -> list[dict[str, Any]]:
|
||||
global _cache
|
||||
with _lock:
|
||||
if _cache is None:
|
||||
_cache = _load_raw()
|
||||
return deepcopy(_cache)
|
||||
|
||||
|
||||
def get_combo(combo_id: str | None) -> dict[str, Any]:
|
||||
combos = list_combos()
|
||||
if combo_id:
|
||||
for c in combos:
|
||||
if c["id"] == combo_id:
|
||||
return deepcopy(c)
|
||||
return deepcopy(combos[0])
|
||||
|
||||
|
||||
def add_combo(high: str, mid: str, low: str, label: str | None = None) -> dict[str, Any]:
|
||||
err = validate_combo(high, mid, low)
|
||||
if err:
|
||||
raise ValueError(err)
|
||||
cid = combo_id_for(high, mid, low)
|
||||
row = {
|
||||
"id": cid,
|
||||
"label": label or f"{high} / {mid} / {low}",
|
||||
"high": high,
|
||||
"mid": mid,
|
||||
"low": low,
|
||||
"builtin": False,
|
||||
}
|
||||
with _lock:
|
||||
combos = _load_raw()
|
||||
for c in combos:
|
||||
if c["id"] == cid or (c["high"], c["mid"], c["low"]) == (high, mid, low):
|
||||
_cache = combos
|
||||
return deepcopy(c)
|
||||
combos.append(row)
|
||||
_save(combos)
|
||||
_cache = combos
|
||||
return deepcopy(row)
|
||||
|
||||
|
||||
def delete_combo(combo_id: str) -> bool:
|
||||
with _lock:
|
||||
combos = _load_raw()
|
||||
kept: list[dict[str, Any]] = []
|
||||
removed = False
|
||||
for c in combos:
|
||||
if c["id"] == combo_id:
|
||||
if c.get("builtin"):
|
||||
raise ValueError("内置组合不可删除")
|
||||
removed = True
|
||||
continue
|
||||
kept.append(c)
|
||||
if removed:
|
||||
_save(kept)
|
||||
_cache = kept
|
||||
return removed
|
||||
|
||||
|
||||
def all_tfs_for_combos(combos: list[dict[str, Any]] | None = None) -> list[str]:
|
||||
"""Unique TFs needed by active combos (stable order)."""
|
||||
rows = combos if combos is not None else list_combos()
|
||||
seen: list[str] = []
|
||||
for c in rows:
|
||||
for k in ("low", "mid", "high"):
|
||||
tf = c[k]
|
||||
if tf not in seen:
|
||||
seen.append(tf)
|
||||
return seen
|
||||
|
||||
|
||||
def lookback_for(tf: str) -> int:
|
||||
defaults = {
|
||||
"1h": 500,
|
||||
"2h": 400,
|
||||
"3h": 350,
|
||||
"4h": 300,
|
||||
"6h": 280,
|
||||
"8h": 250,
|
||||
"12h": 220,
|
||||
"1d": 250,
|
||||
"2d": 200,
|
||||
"3d": 180,
|
||||
"1w": 104,
|
||||
"1M": 60,
|
||||
}
|
||||
return defaults.get(tf, 200)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Cycle Engine — monthly/weekly macro cycle via Rule Registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, WyckoffCycle
|
||||
from crypto_wyckoff.rules.base import RuleHit
|
||||
from crypto_wyckoff.rules.registry import rule_registry
|
||||
|
||||
|
||||
def _resolve_range_conflict(hits: list[RuleHit], features: dict) -> list[RuleHit]:
|
||||
"""Accumulation vs Distribution overlap → mutually exclusive by MA120 position."""
|
||||
accum = [h for h in hits if h.cycle == WyckoffCycle.ACCUMULATION.value]
|
||||
dist = [h for h in hits if h.cycle == WyckoffCycle.DISTRIBUTION.value]
|
||||
if not (accum and dist):
|
||||
return hits
|
||||
|
||||
close = float(features.get("close") or 0)
|
||||
ma120 = float(features.get("ma120") or close) or close
|
||||
others = [
|
||||
h for h in hits
|
||||
if h.cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value)
|
||||
]
|
||||
# Below MA120 → accumulation; above → distribution; equal band uses relative position
|
||||
if close < ma120 * 0.995:
|
||||
return others + accum
|
||||
if close > ma120 * 1.005:
|
||||
return others + dist
|
||||
# Tight band: keep higher confidence only
|
||||
best_a = max(accum, key=lambda h: h.confidence)
|
||||
best_d = max(dist, key=lambda h: h.confidence)
|
||||
return others + ([best_a] if best_a.confidence >= best_d.confidence else [best_d])
|
||||
|
||||
|
||||
class CycleEngine:
|
||||
name = "Cycle"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, feature: EngineResult, timeframe: str) -> EngineResult:
|
||||
features = feature.payload
|
||||
if features.get("insufficient"):
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=15.0,
|
||||
score=40.0,
|
||||
reasons=[f"{timeframe} 数据不足,Cycle=Unknown"],
|
||||
warnings=["insufficient_features"],
|
||||
payload={
|
||||
"cycle": WyckoffCycle.UNKNOWN.value,
|
||||
"timeframe": timeframe,
|
||||
"trend_score": 40.0,
|
||||
},
|
||||
)
|
||||
|
||||
context = {"features": features, "timeframe": timeframe}
|
||||
hits: list[RuleHit] = []
|
||||
for rule in rule_registry.by_category("cycle", timeframe):
|
||||
hit = rule.evaluate(context)
|
||||
if hit and hit.cycle:
|
||||
hits.append(hit)
|
||||
|
||||
hits = _resolve_range_conflict(hits, features)
|
||||
|
||||
if not hits:
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=30.0,
|
||||
score=40.0,
|
||||
reasons=["无匹配周期规则,标记 Unknown"],
|
||||
payload={
|
||||
"cycle": WyckoffCycle.UNKNOWN.value,
|
||||
"timeframe": timeframe,
|
||||
"trend_score": 40.0,
|
||||
},
|
||||
)
|
||||
|
||||
best = max(hits, key=lambda h: h.confidence)
|
||||
trend_score = best.score
|
||||
if best.cycle == WyckoffCycle.MARKUP.value:
|
||||
trend_score = max(trend_score, 75.0)
|
||||
elif best.cycle == WyckoffCycle.ACCUMULATION.value:
|
||||
trend_score = max(60.0, trend_score * 0.9)
|
||||
elif best.cycle == WyckoffCycle.DISTRIBUTION.value:
|
||||
trend_score = min(45.0, 100 - trend_score * 0.5)
|
||||
elif best.cycle == WyckoffCycle.MARKDOWN.value:
|
||||
trend_score = min(30.0, 100 - trend_score)
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=best.confidence,
|
||||
score=trend_score,
|
||||
reasons=best.reasons,
|
||||
metrics=best.metrics,
|
||||
payload={
|
||||
"cycle": best.cycle,
|
||||
"timeframe": timeframe,
|
||||
"rule_id": best.rule_id,
|
||||
"trend_score": trend_score,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Decision Engine — multi-timeframe fusion and tradability (Architecture v1.0)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import (
|
||||
DecisionSignal,
|
||||
EngineResult,
|
||||
RiskLevel,
|
||||
WyckoffCycle,
|
||||
WyckoffEvent,
|
||||
WyckoffPhase,
|
||||
)
|
||||
|
||||
BULL_CYCLES = {
|
||||
WyckoffCycle.ACCUMULATION.value,
|
||||
WyckoffCycle.RE_ACCUMULATION.value,
|
||||
WyckoffCycle.MARKUP.value,
|
||||
}
|
||||
BEAR_CYCLES = {
|
||||
WyckoffCycle.DISTRIBUTION.value,
|
||||
WyckoffCycle.RE_DISTRIBUTION.value,
|
||||
WyckoffCycle.MARKDOWN.value,
|
||||
}
|
||||
|
||||
|
||||
class DecisionEngine:
|
||||
name = "Decision"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(
|
||||
self,
|
||||
monthly_cycle: EngineResult,
|
||||
weekly_cycle: EngineResult,
|
||||
weekly_phase: EngineResult,
|
||||
weekly_event: EngineResult,
|
||||
daily_event: EngineResult,
|
||||
daily_signal: EngineResult,
|
||||
) -> EngineResult:
|
||||
m_cycle = monthly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
|
||||
w_cycle = weekly_cycle.payload.get("cycle", WyckoffCycle.UNKNOWN.value)
|
||||
w_phase = weekly_phase.payload.get("phase", WyckoffPhase.NONE.value)
|
||||
w_event = weekly_event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||
d_event = daily_event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||
|
||||
trend_score = float(monthly_cycle.payload.get("trend_score", monthly_cycle.score))
|
||||
structure_score = float(weekly_phase.payload.get("structure_score", weekly_phase.score))
|
||||
entry_score = float(daily_event.payload.get("entry_score", daily_event.score))
|
||||
|
||||
overall_score = 0.30 * trend_score + 0.30 * structure_score + 0.40 * entry_score
|
||||
|
||||
reasons: list[str] = []
|
||||
warnings: list[str] = []
|
||||
alignment = 50.0
|
||||
|
||||
m_bull = m_cycle in BULL_CYCLES
|
||||
m_bear = m_cycle in BEAR_CYCLES
|
||||
w_bull = w_cycle in BULL_CYCLES
|
||||
d_bullish_event = d_event in {
|
||||
WyckoffEvent.SPRING.value,
|
||||
WyckoffEvent.TEST.value,
|
||||
WyckoffEvent.SOS.value,
|
||||
WyckoffEvent.LPS.value,
|
||||
WyckoffEvent.JUMP.value,
|
||||
WyckoffEvent.BACKUP.value,
|
||||
}
|
||||
d_bearish_event = d_event in {
|
||||
WyckoffEvent.UTAD.value,
|
||||
WyckoffEvent.SOW.value,
|
||||
WyckoffEvent.LPSY.value,
|
||||
}
|
||||
|
||||
# Alignment scoring
|
||||
if m_bull and w_bull and d_bullish_event:
|
||||
alignment = 92.0
|
||||
reasons.append("✓ 月/周多头结构与日线多头事件一致")
|
||||
elif m_bull and d_bullish_event:
|
||||
alignment = 78.0
|
||||
reasons.append("✓ 月线支持,日线有入场事件")
|
||||
if not w_bull:
|
||||
warnings.append("周线结构未完全确认")
|
||||
alignment -= 8
|
||||
elif m_bear and d_bullish_event:
|
||||
alignment = 35.0
|
||||
reasons.append("✗ 月线派发/下跌,日线弹簧可能只是反弹")
|
||||
elif m_bear and d_bearish_event:
|
||||
alignment = 85.0
|
||||
reasons.append("✓ 空头多周期一致")
|
||||
else:
|
||||
alignment = 55.0
|
||||
reasons.append("○ 多周期部分一致,需观察")
|
||||
|
||||
if w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value) and m_bull:
|
||||
alignment = min(98.0, alignment + 6)
|
||||
reasons.append(f"✓ 周线阶段 {w_phase} 结构成熟({w_event})")
|
||||
active = daily_event.payload.get("active_events") or daily_event.payload.get("recent_events") or []
|
||||
if d_event == WyckoffEvent.SPRING.value and len(active) >= 3:
|
||||
alignment = min(98.0, alignment + 4)
|
||||
reasons.append("✓ 日线多重事件同时确认")
|
||||
|
||||
# Decision signal — hard gate on monthly bear + daily spring
|
||||
decision = DecisionSignal.WATCH.value
|
||||
risk = RiskLevel.MEDIUM.value
|
||||
|
||||
if m_bear and d_event == WyckoffEvent.SPRING.value:
|
||||
decision = DecisionSignal.WATCH.value
|
||||
risk = RiskLevel.HIGH.value
|
||||
overall_score = min(overall_score, 55.0)
|
||||
reasons.append("→ 决策:观察(月线不支持,禁止追日线弹簧)")
|
||||
elif m_bear and d_bullish_event:
|
||||
decision = DecisionSignal.AVOID.value
|
||||
risk = RiskLevel.HIGH.value
|
||||
overall_score = min(overall_score, 48.0)
|
||||
reasons.append("→ 决策:回避(逆大周期多头事件)")
|
||||
elif (
|
||||
m_bull
|
||||
and w_phase in (WyckoffPhase.D.value, WyckoffPhase.E.value, WyckoffPhase.C.value)
|
||||
and d_event in (WyckoffEvent.SPRING.value, WyckoffEvent.LPS.value, WyckoffEvent.SOS.value)
|
||||
and alignment >= 85
|
||||
and overall_score >= 80
|
||||
):
|
||||
decision = DecisionSignal.STRONG_BUY.value
|
||||
risk = RiskLevel.LOW.value
|
||||
reasons.append("→ 决策:强烈买入(三级共振)")
|
||||
elif m_bull and d_bullish_event and overall_score >= 68 and alignment >= 70:
|
||||
decision = DecisionSignal.BUY.value
|
||||
risk = RiskLevel.LOW.value if alignment >= 80 else RiskLevel.MEDIUM.value
|
||||
reasons.append("→ 决策:买入")
|
||||
elif m_bear and d_bearish_event and overall_score >= 65:
|
||||
decision = DecisionSignal.SELL.value
|
||||
risk = RiskLevel.MEDIUM.value
|
||||
reasons.append("→ 决策:卖出")
|
||||
else:
|
||||
decision = DecisionSignal.WATCH.value
|
||||
reasons.append("→ 决策:观察")
|
||||
|
||||
# Stars from score + alignment
|
||||
combo = 0.6 * overall_score + 0.4 * alignment
|
||||
if combo >= 90:
|
||||
stars = 5
|
||||
elif combo >= 80:
|
||||
stars = 4
|
||||
elif combo >= 65:
|
||||
stars = 3
|
||||
elif combo >= 50:
|
||||
stars = 2
|
||||
else:
|
||||
stars = 1
|
||||
|
||||
overall_confidence = (
|
||||
0.25 * monthly_cycle.confidence
|
||||
+ 0.25 * weekly_phase.confidence
|
||||
+ 0.25 * daily_event.confidence
|
||||
+ 0.25 * daily_signal.confidence
|
||||
)
|
||||
# Weak event pulls overall down
|
||||
if daily_event.confidence < 60:
|
||||
overall_confidence = min(overall_confidence, daily_event.confidence + 15)
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=overall_confidence,
|
||||
score=overall_score,
|
||||
reasons=reasons,
|
||||
warnings=warnings,
|
||||
metrics={
|
||||
"trend_score": trend_score,
|
||||
"structure_score": structure_score,
|
||||
"entry_score": entry_score,
|
||||
"alignment": alignment,
|
||||
"stars": stars,
|
||||
},
|
||||
payload={
|
||||
"decision_signal": decision,
|
||||
"alignment": alignment,
|
||||
"stars": stars,
|
||||
"risk": risk,
|
||||
"overall_score": overall_score,
|
||||
"overall_confidence": overall_confidence,
|
||||
"trend_score": trend_score,
|
||||
"structure_score": structure_score,
|
||||
"entry_score": entry_score,
|
||||
"m_cycle": m_cycle,
|
||||
"w_cycle": w_cycle,
|
||||
"w_phase": w_phase,
|
||||
"w_event": w_event,
|
||||
"d_event": d_event,
|
||||
# Facts preserved — never overwritten
|
||||
"facts": {
|
||||
"monthly": {"cycle": m_cycle},
|
||||
"weekly": {"cycle": w_cycle, "phase": w_phase, "event": w_event},
|
||||
"daily": {"event": d_event},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Wyckoff Screener domain models — Architecture v1.0 frozen contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class WyckoffCycle(str, Enum):
|
||||
ACCUMULATION = "Accumulation"
|
||||
RE_ACCUMULATION = "ReAccumulation"
|
||||
MARKUP = "Markup"
|
||||
DISTRIBUTION = "Distribution"
|
||||
RE_DISTRIBUTION = "ReDistribution"
|
||||
MARKDOWN = "Markdown"
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
|
||||
class WyckoffPhase(str, Enum):
|
||||
A = "A"
|
||||
B = "B"
|
||||
C = "C"
|
||||
D = "D"
|
||||
E = "E"
|
||||
NONE = "None"
|
||||
|
||||
|
||||
class WyckoffEvent(str, Enum):
|
||||
PS = "PS"
|
||||
SC = "SC"
|
||||
AR = "AR"
|
||||
ST = "ST"
|
||||
SPRING = "Spring"
|
||||
TEST = "Test"
|
||||
SOS = "SOS"
|
||||
LPS = "LPS"
|
||||
JUMP = "Jump"
|
||||
BACKUP = "Backup"
|
||||
BC = "BC"
|
||||
UTAD = "UTAD"
|
||||
SOW = "SOW"
|
||||
LPSY = "LPSY"
|
||||
NONE = "None"
|
||||
|
||||
|
||||
class DecisionSignal(str, Enum):
|
||||
STRONG_BUY = "StrongBuy"
|
||||
BUY = "Buy"
|
||||
WATCH = "Watch"
|
||||
AVOID = "Avoid"
|
||||
SELL = "Sell"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
LOW = "Low"
|
||||
MEDIUM = "Medium"
|
||||
HIGH = "High"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineResult:
|
||||
"""Unified result envelope for every Wyckoff engine (v1.0 contract)."""
|
||||
|
||||
name: str
|
||||
version: str = "1.0.0"
|
||||
confidence: float = 0.0
|
||||
score: float = 0.0
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
metrics: dict[str, Any] = field(default_factory=dict)
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"version": self.version,
|
||||
"confidence": self.confidence,
|
||||
"score": self.score,
|
||||
"reasons": self.reasons,
|
||||
"warnings": self.warnings,
|
||||
"metrics": self.metrics,
|
||||
"payload": self.payload,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OHLCVFrame:
|
||||
"""In-memory OHLCV for one symbol one timeframe. Engines never touch DB."""
|
||||
|
||||
ts_code: str
|
||||
timeframe: str # "1d" | "1w" | "1M"
|
||||
trade_dates: list[date]
|
||||
open: list[float]
|
||||
high: list[float]
|
||||
low: list[float]
|
||||
close: list[float]
|
||||
volume: list[float]
|
||||
amount: list[float] = field(default_factory=list)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.close)
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return len(self.close) == 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class WyckoffScanRow:
|
||||
"""Persisted scan row for wyckoff_scan table."""
|
||||
|
||||
trade_date: date
|
||||
ts_code: str
|
||||
name: str = ""
|
||||
industry: str = ""
|
||||
engine_version: str = "v1.0.0"
|
||||
combo_id: str = "d_w_m"
|
||||
|
||||
m_cycle: str = WyckoffCycle.UNKNOWN.value
|
||||
cycle_confidence: float = 0.0
|
||||
trend_score: float = 0.0
|
||||
|
||||
w_cycle: str = WyckoffCycle.UNKNOWN.value
|
||||
w_phase: str = WyckoffPhase.NONE.value
|
||||
w_current_event: str = WyckoffEvent.NONE.value
|
||||
w_recent_events_json: str = "[]"
|
||||
phase_confidence: float = 0.0
|
||||
structure_score: float = 0.0
|
||||
|
||||
d_current_event: str = WyckoffEvent.NONE.value
|
||||
d_recent_events_json: str = "[]"
|
||||
event_confidence: float = 0.0
|
||||
entry_score: float = 0.0
|
||||
|
||||
entry: Optional[float] = None
|
||||
stop: Optional[float] = None
|
||||
target1: Optional[float] = None
|
||||
target2: Optional[float] = None
|
||||
rr: Optional[float] = None
|
||||
|
||||
alignment: float = 0.0
|
||||
stars: int = 1
|
||||
decision_signal: str = DecisionSignal.WATCH.value
|
||||
signal_confidence: float = 0.0
|
||||
overall_confidence: float = 0.0
|
||||
overall_score: float = 0.0
|
||||
risk: str = RiskLevel.MEDIUM.value
|
||||
reasons_json: str = "[]"
|
||||
|
||||
feature_snapshot_json: str = "{}"
|
||||
markers_json: str = "[]"
|
||||
scanned_at: datetime = field(default_factory=datetime.now)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Event Engine — active concurrent events via Rule Registry.
|
||||
|
||||
Note: `active_events` are rules that fire on the latest bar snapshot,
|
||||
NOT a historical SC→AR→ST timeline. Do not present as chronological chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
|
||||
from crypto_wyckoff.rules.registry import rule_registry
|
||||
|
||||
# Display order only (not temporal history)
|
||||
_DISPLAY_ORDER = [
|
||||
WyckoffEvent.PS.value,
|
||||
WyckoffEvent.SC.value,
|
||||
WyckoffEvent.AR.value,
|
||||
WyckoffEvent.ST.value,
|
||||
WyckoffEvent.SPRING.value,
|
||||
WyckoffEvent.TEST.value,
|
||||
WyckoffEvent.SOS.value,
|
||||
WyckoffEvent.LPS.value,
|
||||
WyckoffEvent.JUMP.value,
|
||||
WyckoffEvent.BACKUP.value,
|
||||
WyckoffEvent.BC.value,
|
||||
WyckoffEvent.UTAD.value,
|
||||
WyckoffEvent.SOW.value,
|
||||
WyckoffEvent.LPSY.value,
|
||||
]
|
||||
|
||||
# Dominant event: highest confidence wins; ties broken by this priority
|
||||
_DOMINANCE_PRIORITY = [
|
||||
WyckoffEvent.SOS.value,
|
||||
WyckoffEvent.LPS.value,
|
||||
WyckoffEvent.UTAD.value,
|
||||
WyckoffEvent.SPRING.value,
|
||||
WyckoffEvent.JUMP.value,
|
||||
WyckoffEvent.BACKUP.value,
|
||||
WyckoffEvent.TEST.value,
|
||||
WyckoffEvent.SC.value,
|
||||
WyckoffEvent.SOW.value,
|
||||
WyckoffEvent.AR.value,
|
||||
WyckoffEvent.ST.value,
|
||||
]
|
||||
|
||||
|
||||
class EventEngine:
|
||||
name = "Event"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(
|
||||
self,
|
||||
cycle: EngineResult,
|
||||
phase: EngineResult,
|
||||
feature: EngineResult,
|
||||
timeframe: str,
|
||||
) -> EngineResult:
|
||||
if feature.payload.get("insufficient"):
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=20.0,
|
||||
score=30.0,
|
||||
reasons=["特征不足,跳过事件识别"],
|
||||
warnings=["insufficient_features"],
|
||||
payload={
|
||||
"current_event": WyckoffEvent.NONE.value,
|
||||
"active_events": [],
|
||||
"recent_events": [], # alias for DB/API compat; same as active_events
|
||||
"timeframe": timeframe,
|
||||
"entry_score": 30.0,
|
||||
},
|
||||
)
|
||||
|
||||
context = {
|
||||
"features": feature.payload,
|
||||
"cycle": cycle.payload,
|
||||
"phase": phase.payload,
|
||||
"timeframe": timeframe,
|
||||
}
|
||||
hits = []
|
||||
for rule in rule_registry.by_category("event", timeframe):
|
||||
hit = rule.evaluate(context)
|
||||
if hit and hit.event:
|
||||
hits.append(hit)
|
||||
|
||||
if not hits:
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=35.0,
|
||||
score=40.0,
|
||||
reasons=["无显著事件"],
|
||||
payload={
|
||||
"current_event": WyckoffEvent.NONE.value,
|
||||
"active_events": [],
|
||||
"recent_events": [],
|
||||
"timeframe": timeframe,
|
||||
"entry_score": 40.0,
|
||||
},
|
||||
)
|
||||
|
||||
by_event: dict[str, float] = {}
|
||||
reasons: list[str] = []
|
||||
metrics: dict = {}
|
||||
for h in hits:
|
||||
prev = by_event.get(h.event, -1.0)
|
||||
if h.confidence >= prev:
|
||||
by_event[h.event] = h.confidence
|
||||
reasons.extend(h.reasons)
|
||||
metrics.update(h.metrics)
|
||||
|
||||
active = [e for e in _DISPLAY_ORDER if e in by_event]
|
||||
for e in by_event:
|
||||
if e not in active:
|
||||
active.append(e)
|
||||
|
||||
# Dominant = max confidence; tie-break by dominance priority index
|
||||
def _dom_key(ev: str) -> tuple:
|
||||
conf = by_event[ev]
|
||||
try:
|
||||
prio = _DOMINANCE_PRIORITY.index(ev)
|
||||
except ValueError:
|
||||
prio = 99
|
||||
return (conf, -prio)
|
||||
|
||||
current = max(by_event.keys(), key=_dom_key)
|
||||
event_conf = by_event[current]
|
||||
co_bonus = min(12.0, max(0, len(active) - 1) * 3)
|
||||
entry_score = min(98.0, event_conf + co_bonus)
|
||||
if current == WyckoffEvent.SPRING.value and WyckoffEvent.TEST.value in by_event:
|
||||
entry_score = min(98.0, entry_score + 5)
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=event_conf,
|
||||
score=entry_score,
|
||||
reasons=list(dict.fromkeys(reasons))[:8],
|
||||
warnings=["active_events_are_concurrent_not_timeline"],
|
||||
metrics=metrics,
|
||||
payload={
|
||||
"current_event": current,
|
||||
"active_events": active,
|
||||
"recent_events": active, # persisted column name; semantic = active
|
||||
"event_scores": by_event,
|
||||
"timeframe": timeframe,
|
||||
"entry_score": entry_score,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Feature Engine — pure function over OHLCVFrame → EngineResult(FeatureSnapshot)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, OHLCVFrame
|
||||
|
||||
|
||||
def _sma(arr: np.ndarray, n: int) -> float:
|
||||
if len(arr) < n:
|
||||
return float(arr[-1]) if len(arr) else 0.0
|
||||
return float(np.mean(arr[-n:]))
|
||||
|
||||
|
||||
def _atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
|
||||
if len(close) < 2:
|
||||
return 0.0
|
||||
prev_close = close[:-1]
|
||||
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - prev_close), np.abs(low[1:] - prev_close)))
|
||||
if len(tr) < n:
|
||||
return float(np.mean(tr)) if len(tr) else 0.0
|
||||
return float(np.mean(tr[-n:]))
|
||||
|
||||
|
||||
def _adx(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int = 14) -> float:
|
||||
"""Simplified ADX approximation."""
|
||||
if len(close) < n + 2:
|
||||
return 15.0
|
||||
up = high[1:] - high[:-1]
|
||||
down = low[:-1] - low[1:]
|
||||
plus_dm = np.where((up > down) & (up > 0), up, 0.0)
|
||||
minus_dm = np.where((down > up) & (down > 0), down, 0.0)
|
||||
tr = np.maximum(high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])))
|
||||
atr = np.mean(tr[-n:]) or 1e-9
|
||||
plus_di = 100 * np.mean(plus_dm[-n:]) / atr
|
||||
minus_di = 100 * np.mean(minus_dm[-n:]) / atr
|
||||
denom = plus_di + minus_di
|
||||
if denom < 1e-9:
|
||||
return 10.0
|
||||
dx = 100 * abs(plus_di - minus_di) / denom
|
||||
return float(min(60.0, dx))
|
||||
|
||||
|
||||
def compute_feature_snapshot(frame: OHLCVFrame) -> dict[str, Any]:
|
||||
"""Compute technical snapshot dict from OHLCV (no I/O)."""
|
||||
if frame.empty or len(frame) < 5:
|
||||
return {"ts_code": frame.ts_code, "timeframe": frame.timeframe, "bars": len(frame)}
|
||||
|
||||
close = np.asarray(frame.close, dtype=float)
|
||||
high = np.asarray(frame.high, dtype=float)
|
||||
low = np.asarray(frame.low, dtype=float)
|
||||
volume = np.asarray(frame.volume, dtype=float)
|
||||
open_ = np.asarray(frame.open, dtype=float)
|
||||
|
||||
ma20 = _sma(close, 20)
|
||||
ma60 = _sma(close, 60)
|
||||
ma120 = _sma(close, min(120, len(close)))
|
||||
atr = _atr(high, low, close, 14)
|
||||
vol_ma20 = _sma(volume, 20) or 1e-9
|
||||
volume_ratio = float(volume[-1] / vol_ma20)
|
||||
|
||||
look = min(60, len(close))
|
||||
window_h = high[-look:]
|
||||
window_l = low[-look:]
|
||||
range_high = float(np.max(window_h))
|
||||
range_low = float(np.min(window_l))
|
||||
rng = max(range_high - range_low, 1e-9)
|
||||
range_pct_60 = float(rng / close[-1]) if close[-1] else 0.0
|
||||
range_position = float((close[-1] - range_low) / rng)
|
||||
|
||||
# Spring / UTAD hints
|
||||
pierce_below = max(0.0, (range_low - low[-1]) / close[-1]) if close[-1] else 0.0
|
||||
# if previous bars broke below and last close back in range
|
||||
prior_low = float(np.min(low[-6:-1])) if len(low) >= 6 else float(low[-2])
|
||||
pierce_below = max(pierce_below, max(0.0, (range_low - prior_low) / close[-1]))
|
||||
close_back_in_range = 1.0 if close[-1] >= range_low else 0.0
|
||||
reclaim_speed = 0.0
|
||||
if pierce_below > 0 and close[-1] >= range_low:
|
||||
reclaim_speed = min(1.0, (close[-1] - low[-1]) / max(atr, 1e-9) / 2)
|
||||
|
||||
pierce_above = max(0.0, (high[-1] - range_high) / close[-1])
|
||||
fail_back = 1.0 if pierce_above > 0 and close[-1] <= range_high else 0.0
|
||||
breakout_above = 1.0 if close[-1] > range_high and volume_ratio >= 1.0 else -1.0
|
||||
|
||||
# pullback hold: close near ma20 from above after being higher
|
||||
pullback_hold = 0.0
|
||||
if len(close) >= 5 and close[-1] > ma20 and close[-3] > close[-1] and (close[-1] - ma20) / max(atr, 1e-9) < 1.5:
|
||||
pullback_hold = 0.8
|
||||
|
||||
ma60_prev = _sma(close[:-5], 60) if len(close) > 65 else ma60
|
||||
ma60_slope = (ma60 - ma60_prev) / max(abs(ma60_prev), 1e-9)
|
||||
|
||||
# volume trend: recent 10 vs prior 10
|
||||
if len(volume) >= 20:
|
||||
volume_trend = float(np.mean(volume[-10:]) / (np.mean(volume[-20:-10]) + 1e-9) - 1.0)
|
||||
else:
|
||||
volume_trend = 0.0
|
||||
|
||||
bar_range_atr = float((high[-1] - low[-1]) / max(atr, 1e-9))
|
||||
bounce_from_low = float((close[-1] - float(np.min(low[-10:]))) / close[-1]) if close[-1] else 0.0
|
||||
gap_up_pct = float((open_[-1] - close[-2]) / close[-2]) if len(close) >= 2 and close[-2] else 0.0
|
||||
after_strength = 0.0
|
||||
if len(close) >= 4 and close[-3] > close[-4]:
|
||||
after_strength = 0.7
|
||||
|
||||
spring_score_hint = 0.0
|
||||
if pierce_below >= 0.002 and close_back_in_range:
|
||||
spring_score_hint = min(90.0, 50 + pierce_below * 1500 + reclaim_speed * 20)
|
||||
utad_score_hint = min(90.0, 50 + pierce_above * 1500) if pierce_above >= 0.002 and fail_back else 0.0
|
||||
|
||||
# swing
|
||||
swing_high = float(np.max(high[-20:])) if len(high) >= 5 else float(high[-1])
|
||||
swing_low = float(np.min(low[-20:])) if len(low) >= 5 else float(low[-1])
|
||||
|
||||
return {
|
||||
"ts_code": frame.ts_code,
|
||||
"timeframe": frame.timeframe,
|
||||
"bars": len(frame),
|
||||
"close": float(close[-1]),
|
||||
"open": float(open_[-1]),
|
||||
"high": float(high[-1]),
|
||||
"low": float(low[-1]),
|
||||
"volume": float(volume[-1]),
|
||||
"ma20": ma20,
|
||||
"ma60": ma60,
|
||||
"ma120": ma120,
|
||||
"ma60_slope": float(ma60_slope),
|
||||
"atr": atr,
|
||||
"adx": _adx(high, low, close),
|
||||
"volume_ma20": float(vol_ma20),
|
||||
"volume_ratio": volume_ratio,
|
||||
"volume_trend": volume_trend,
|
||||
"range_high": range_high,
|
||||
"range_low": range_low,
|
||||
"range_pct_60": range_pct_60,
|
||||
"range_position": range_position,
|
||||
"pierce_below_range": pierce_below,
|
||||
"pierce_above_range": pierce_above,
|
||||
"close_back_in_range": close_back_in_range,
|
||||
"reclaim_speed": reclaim_speed,
|
||||
"fail_back_into_range": fail_back,
|
||||
"breakout_above_range": breakout_above,
|
||||
"pullback_hold": pullback_hold,
|
||||
"bar_range_atr": bar_range_atr,
|
||||
"bounce_from_low": bounce_from_low,
|
||||
"gap_up_pct": gap_up_pct,
|
||||
"after_strength": after_strength,
|
||||
"spring_score_hint": spring_score_hint,
|
||||
"utad_score_hint": utad_score_hint,
|
||||
"swing_high": swing_high,
|
||||
"swing_low": swing_low,
|
||||
"trade_date": str(frame.trade_dates[-1]) if frame.trade_dates else None,
|
||||
}
|
||||
|
||||
|
||||
# Minimum bars before a timeframe is considered usable (no cross-TF borrow)
|
||||
_MIN_BARS = {"1d": 40, "1w": 26, "1M": 18}
|
||||
|
||||
|
||||
class FeatureEngine:
|
||||
"""Pure Feature Engine — no database access."""
|
||||
|
||||
name = "Feature"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, frame: OHLCVFrame | None, timeframe: str | None = None) -> EngineResult:
|
||||
tf = timeframe or (frame.timeframe if frame else "1d")
|
||||
min_bars = _MIN_BARS.get(tf, 30)
|
||||
|
||||
if frame is None or frame.empty or len(frame) < min_bars:
|
||||
bars = 0 if frame is None or frame.empty else len(frame)
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=10.0,
|
||||
score=10.0,
|
||||
reasons=[f"{tf} bars={bars} < min={min_bars},标记 insufficient"],
|
||||
warnings=["insufficient_features"],
|
||||
metrics={"bars": bars, "min_bars": min_bars},
|
||||
payload={
|
||||
"ts_code": getattr(frame, "ts_code", ""),
|
||||
"timeframe": tf,
|
||||
"bars": bars,
|
||||
"insufficient": True,
|
||||
},
|
||||
)
|
||||
|
||||
snap = compute_feature_snapshot(frame)
|
||||
snap["insufficient"] = False
|
||||
conf = 90.0 if snap.get("bars", 0) >= 60 else 50.0 + min(40.0, snap.get("bars", 0) * 0.5)
|
||||
warnings = []
|
||||
if snap.get("bars", 0) < 60:
|
||||
warnings.append("bars偏少,特征可靠性中等")
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=conf,
|
||||
score=conf,
|
||||
reasons=[f"computed {snap.get('bars', 0)} bars {tf}"],
|
||||
warnings=warnings,
|
||||
metrics={"bars": snap.get("bars", 0)},
|
||||
payload=snap,
|
||||
)
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Paths + OHLCV cache + DATA_SERVICE fetch (crypto continuous calendar)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import requests
|
||||
|
||||
from crypto_wyckoff.domain_models import OHLCVFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA_DIR = Path(os.environ.get("CRYPTO_WYCKOFF_DATA", str(_REPO_ROOT / "data" / "crypto_wyckoff")))
|
||||
BARS_DB = DATA_DIR / "bars.sqlite"
|
||||
SCAN_DB = DATA_DIR / "scan.sqlite"
|
||||
|
||||
DATA_SERVICE_URL = os.environ.get(
|
||||
"DATA_SERVICE_URL",
|
||||
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
||||
).rstrip("/")
|
||||
|
||||
# Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers)
|
||||
# Provider has many TFs; 1M is resampled locally from daily UTC months.
|
||||
LOOKBACK = {
|
||||
"1h": 500,
|
||||
"2h": 400,
|
||||
"4h": 300,
|
||||
"6h": 280,
|
||||
"8h": 250,
|
||||
"12h": 220,
|
||||
"1d": 250,
|
||||
"1w": 104,
|
||||
"1M": 60,
|
||||
}
|
||||
# Default D/W/M stack (kept for compat); combos may request more TFs from provider.
|
||||
TF_PROVIDER = ("1h", "4h", "8h", "1d", "1w")
|
||||
TF_LIST = ("1d", "1w", "1M")
|
||||
LOCAL_ONLY_TFS = frozenset({"1M"})
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _symbol_key(symbol: str) -> str:
|
||||
return symbol.replace("/", "_").replace(":", "_")
|
||||
|
||||
|
||||
def _bars_conn() -> sqlite3.Connection:
|
||||
ensure_dirs()
|
||||
conn = sqlite3.connect(str(BARS_DB), timeout=60)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bars (
|
||||
symbol TEXT NOT NULL,
|
||||
tf TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
open REAL, high REAL, low REAL, close REAL, volume REAL,
|
||||
PRIMARY KEY (symbol, tf, ts)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bars_sym_tf ON bars(symbol, tf)")
|
||||
return conn
|
||||
|
||||
|
||||
def fetch_candles(
|
||||
symbol: str,
|
||||
tf: str,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
start_ms: int | None = None,
|
||||
end_ms: int | None = None,
|
||||
timeout: float = 15.0,
|
||||
) -> list[dict]:
|
||||
params: dict = {"symbol": symbol, "tf": tf}
|
||||
if limit is not None:
|
||||
params["limit"] = int(limit)
|
||||
if start_ms is not None:
|
||||
params["start"] = int(start_ms)
|
||||
if end_ms is not None:
|
||||
params["end"] = int(end_ms)
|
||||
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out = []
|
||||
for row in data:
|
||||
try:
|
||||
ts = int(float(row["timestamp"]))
|
||||
out.append(
|
||||
{
|
||||
"ts": ts,
|
||||
"open": float(row["open"]),
|
||||
"high": float(row["high"]),
|
||||
"low": float(row["low"]),
|
||||
"close": float(row["close"]),
|
||||
"volume": float(row.get("volume") or 0),
|
||||
}
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
out.sort(key=lambda r: r["ts"])
|
||||
return out
|
||||
|
||||
|
||||
def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO bars(symbol, tf, ts, open, high, low, close, volume)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(symbol, tf, ts) DO UPDATE SET
|
||||
open=excluded.open, high=excluded.high, low=excluded.low,
|
||||
close=excluded.close, volume=excluded.volume
|
||||
""",
|
||||
[
|
||||
(symbol, tf, r["ts"], r["open"], r["high"], r["low"], r["close"], r["volume"])
|
||||
for r in rows
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
return len(rows)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_intraday_tf(tf: str) -> bool:
|
||||
"""True for minute/hour TFs that need clock time on charts."""
|
||||
t = (tf or "").strip()
|
||||
return t.endswith("m") or t.endswith("h")
|
||||
|
||||
|
||||
def load_bars_with_ts(
|
||||
symbol: str, tf: str, lookback: int | None = None
|
||||
) -> list[dict]:
|
||||
"""Return OHLCV rows with UTC ms ts (for chart labels).
|
||||
|
||||
``datetime`` is wall-clock in Asia/Shanghai (UTC+8) for display.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
tz_cn = ZoneInfo("Asia/Shanghai")
|
||||
if lookback is None:
|
||||
try:
|
||||
from crypto_wyckoff.combos import lookback_for
|
||||
|
||||
lookback = lookback_for(tf)
|
||||
except Exception:
|
||||
lookback = LOOKBACK.get(tf, 100)
|
||||
lookback = lookback or LOOKBACK.get(tf, 100)
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT ts, open, high, low, close, volume FROM bars
|
||||
WHERE symbol=? AND tf=?
|
||||
ORDER BY ts DESC LIMIT ?
|
||||
""",
|
||||
(symbol, tf, lookback),
|
||||
)
|
||||
rows = list(reversed(cur.fetchall()))
|
||||
finally:
|
||||
conn.close()
|
||||
out = []
|
||||
for ts, o, h, l, c, v in rows:
|
||||
dt_utc = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
|
||||
dt_cn = dt_utc.astimezone(tz_cn)
|
||||
out.append(
|
||||
{
|
||||
"ts": int(ts),
|
||||
"datetime": dt_cn.strftime("%Y-%m-%dT%H:%M:%S+08:00"),
|
||||
"date": dt_cn.strftime("%Y-%m-%d"),
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": v,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None:
|
||||
rows = load_bars_with_ts(symbol, tf, lookback)
|
||||
if not rows:
|
||||
return None
|
||||
return OHLCVFrame(
|
||||
ts_code=symbol,
|
||||
timeframe=tf,
|
||||
trade_dates=[
|
||||
datetime.fromtimestamp(r["ts"] / 1000.0, tz=timezone.utc).date() for r in rows
|
||||
],
|
||||
open=[r["open"] for r in rows],
|
||||
high=[r["high"] for r in rows],
|
||||
low=[r["low"] for r in rows],
|
||||
close=[r["close"] for r in rows],
|
||||
volume=[r["volume"] for r in rows],
|
||||
)
|
||||
|
||||
|
||||
def bar_count(symbol: str, tf: str) -> int:
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"SELECT COUNT(*) FROM bars WHERE symbol=? AND tf=?", (symbol, tf)
|
||||
)
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def rebuild_monthly_from_daily(symbol: str) -> int:
|
||||
"""Aggregate UTC calendar-month OHLCV from local daily bars (provider has no 1M)."""
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT ts, open, high, low, close, volume FROM bars
|
||||
WHERE symbol=? AND tf='1d' ORDER BY ts ASC
|
||||
""",
|
||||
(symbol,),
|
||||
)
|
||||
daily = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
if not daily:
|
||||
return 0
|
||||
|
||||
months: dict[tuple[int, int], dict] = {}
|
||||
for ts, o, h, l, c, v in daily:
|
||||
dt = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc)
|
||||
key = (dt.year, dt.month)
|
||||
# month bar open timestamp = first day 00:00 UTC
|
||||
month_ts = int(datetime(dt.year, dt.month, 1, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
if key not in months:
|
||||
months[key] = {
|
||||
"ts": month_ts,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": v or 0.0,
|
||||
}
|
||||
else:
|
||||
m = months[key]
|
||||
m["high"] = max(m["high"], h)
|
||||
m["low"] = min(m["low"], l)
|
||||
m["close"] = c
|
||||
m["volume"] = (m["volume"] or 0) + (v or 0)
|
||||
|
||||
rows = sorted(months.values(), key=lambda r: r["ts"])
|
||||
# drop stale months then upsert
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
conn.execute("DELETE FROM bars WHERE symbol=? AND tf='1M'", (symbol,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return upsert_bars(symbol, "1M", rows)
|
||||
|
||||
|
||||
def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict:
|
||||
"""Pull history for requested TFs; monthly derived from daily when needed."""
|
||||
wanted = list(dict.fromkeys(tfs))
|
||||
stats: dict = {}
|
||||
need_monthly = "1M" in wanted
|
||||
if need_monthly and "1d" not in wanted:
|
||||
wanted = ["1d", *wanted]
|
||||
|
||||
for tf in wanted:
|
||||
if tf in LOCAL_ONLY_TFS:
|
||||
continue
|
||||
need = LOOKBACK.get(tf, 100)
|
||||
if tf == "1d" and need_monthly:
|
||||
need = max(need, LOOKBACK["1M"] * 31)
|
||||
try:
|
||||
rows = fetch_candles(symbol, tf, limit=need)
|
||||
n = upsert_bars(symbol, tf, rows)
|
||||
stats[tf] = n
|
||||
except Exception as e:
|
||||
logger.warning("backfill %s %s failed: %s", symbol, tf, e)
|
||||
stats[tf] = 0
|
||||
time.sleep(0.05)
|
||||
|
||||
if need_monthly:
|
||||
try:
|
||||
stats["1M"] = rebuild_monthly_from_daily(symbol)
|
||||
except Exception as e:
|
||||
logger.warning("monthly rebuild %s failed: %s", symbol, e)
|
||||
stats["1M"] = 0
|
||||
return stats
|
||||
|
||||
|
||||
def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool:
|
||||
"""Update forming tip bars (limit=3). Returns True if any bar changed."""
|
||||
wanted = list(dict.fromkeys(tfs))
|
||||
changed = False
|
||||
for tf in wanted:
|
||||
if tf in LOCAL_ONLY_TFS:
|
||||
continue
|
||||
try:
|
||||
rows = fetch_candles(symbol, tf, limit=3)
|
||||
if not rows:
|
||||
continue
|
||||
before = _tip_fingerprint(symbol, tf)
|
||||
upsert_bars(symbol, tf, rows)
|
||||
after = _tip_fingerprint(symbol, tf)
|
||||
if before != after:
|
||||
changed = True
|
||||
except Exception as e:
|
||||
logger.debug("tip %s %s: %s", symbol, tf, e)
|
||||
time.sleep(0.02)
|
||||
if "1M" in wanted:
|
||||
before_m = _tip_fingerprint(symbol, "1M")
|
||||
try:
|
||||
rebuild_monthly_from_daily(symbol)
|
||||
except Exception as e:
|
||||
logger.debug("monthly tip %s: %s", symbol, e)
|
||||
after_m = _tip_fingerprint(symbol, "1M")
|
||||
if before_m != after_m:
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _tip_fingerprint(symbol: str, tf: str) -> tuple | None:
|
||||
conn = _bars_conn()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT ts, open, high, low, close, volume FROM bars
|
||||
WHERE symbol=? AND tf=? ORDER BY ts DESC LIMIT 1
|
||||
""",
|
||||
(symbol, tf),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return tuple(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_symbols_from_provider() -> list[str]:
|
||||
try:
|
||||
resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=8)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
symbols = payload.get("symbols") or payload.get("symbol_list") or []
|
||||
return [s for s in symbols if isinstance(s, str)]
|
||||
except Exception as e:
|
||||
logger.warning("health symbols failed: %s", e)
|
||||
return []
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Phase Engine — Phase A–E via Rule Registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, WyckoffPhase
|
||||
from crypto_wyckoff.rules.registry import rule_registry
|
||||
|
||||
|
||||
class PhaseEngine:
|
||||
name = "Phase"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, cycle: EngineResult, feature: EngineResult, timeframe: str) -> EngineResult:
|
||||
if feature.payload.get("insufficient") or cycle.payload.get("cycle") == "Unknown":
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=20.0,
|
||||
score=30.0,
|
||||
reasons=["数据/周期不足,Phase=None"],
|
||||
warnings=["insufficient_features"],
|
||||
payload={
|
||||
"phase": WyckoffPhase.NONE.value,
|
||||
"timeframe": timeframe,
|
||||
"cycle": cycle.payload.get("cycle"),
|
||||
"structure_score": 30.0,
|
||||
},
|
||||
)
|
||||
|
||||
context = {
|
||||
"features": feature.payload,
|
||||
"cycle": cycle.payload,
|
||||
"timeframe": timeframe,
|
||||
}
|
||||
hits = []
|
||||
for rule in rule_registry.by_category("phase", timeframe):
|
||||
hit = rule.evaluate(context)
|
||||
if hit and hit.phase:
|
||||
hits.append(hit)
|
||||
|
||||
if not hits:
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=40.0,
|
||||
score=cycle.score * 0.5,
|
||||
reasons=["未识别明确 Phase"],
|
||||
payload={
|
||||
"phase": WyckoffPhase.NONE.value,
|
||||
"timeframe": timeframe,
|
||||
"cycle": cycle.payload.get("cycle"),
|
||||
"structure_score": cycle.score * 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
best = max(hits, key=lambda h: h.confidence)
|
||||
structure_score = best.score
|
||||
# Phase D/E stronger structure
|
||||
if best.phase in (WyckoffPhase.D.value, WyckoffPhase.E.value):
|
||||
structure_score = max(structure_score, 80.0)
|
||||
elif best.phase == WyckoffPhase.C.value:
|
||||
structure_score = max(structure_score, 72.0)
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=best.confidence,
|
||||
score=structure_score,
|
||||
reasons=best.reasons,
|
||||
metrics=best.metrics,
|
||||
payload={
|
||||
"phase": best.phase,
|
||||
"timeframe": timeframe,
|
||||
"cycle": cycle.payload.get("cycle"),
|
||||
"rule_id": best.rule_id,
|
||||
"structure_score": structure_score,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Scan pipeline: load local frames → engines → store (per TF combo)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo, lookback_for
|
||||
from crypto_wyckoff.cycle import CycleEngine
|
||||
from crypto_wyckoff.decision import DecisionEngine
|
||||
from crypto_wyckoff.domain_models import WyckoffScanRow
|
||||
from crypto_wyckoff.event import EventEngine
|
||||
from crypto_wyckoff.features import FeatureEngine
|
||||
from crypto_wyckoff.io import load_frame
|
||||
from crypto_wyckoff.phase import PhaseEngine
|
||||
from crypto_wyckoff.plan import PlanEngine
|
||||
from crypto_wyckoff.signal import SignalEngine
|
||||
from crypto_wyckoff.store import upsert_row
|
||||
from crypto_wyckoff.symbols_cn import display_name_cn
|
||||
from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def analyze_symbol(
|
||||
low_frame,
|
||||
mid_frame,
|
||||
high_frame,
|
||||
*,
|
||||
feature_eng: FeatureEngine,
|
||||
cycle_eng: CycleEngine,
|
||||
phase_eng: PhaseEngine,
|
||||
event_eng: EventEngine,
|
||||
signal_eng: SignalEngine,
|
||||
decision_eng: DecisionEngine,
|
||||
plan_eng: PlanEngine,
|
||||
) -> dict:
|
||||
"""Run engines with D/W/M *role* aliases so existing rules match.
|
||||
|
||||
Frames may be any TF combo (e.g. 1h/4h/8h); rules still see 1d/1w/1M roles.
|
||||
"""
|
||||
f_d = feature_eng.run(low_frame, ROLE_LOW)
|
||||
f_w = feature_eng.run(mid_frame, ROLE_MID)
|
||||
f_m = feature_eng.run(high_frame, ROLE_HIGH)
|
||||
|
||||
c_m = cycle_eng.run(f_m, ROLE_HIGH)
|
||||
c_w = cycle_eng.run(f_w, ROLE_MID)
|
||||
|
||||
p_w = phase_eng.run(c_w, f_w, ROLE_MID)
|
||||
p_d = phase_eng.run(c_w, f_d, ROLE_LOW)
|
||||
|
||||
e_w = event_eng.run(c_w, p_w, f_w, ROLE_MID)
|
||||
e_d = event_eng.run(c_w, p_d, f_d, ROLE_LOW)
|
||||
|
||||
s_d = signal_eng.run(e_d, p_d)
|
||||
decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d)
|
||||
plan = plan_eng.run(f_d, decision)
|
||||
|
||||
return {
|
||||
"f_d": f_d, "f_w": f_w, "f_m": f_m,
|
||||
"c_m": c_m, "c_w": c_w, "p_w": p_w,
|
||||
"e_w": e_w, "e_d": e_d, "s_d": s_d,
|
||||
"decision": decision, "plan": plan,
|
||||
}
|
||||
|
||||
|
||||
def _to_row(
|
||||
trade_date: date,
|
||||
symbol: str,
|
||||
result: dict,
|
||||
*,
|
||||
combo_id: str,
|
||||
combo_label: str,
|
||||
) -> WyckoffScanRow:
|
||||
d = result["decision"]
|
||||
p = result["plan"]
|
||||
c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"]
|
||||
e_w, e_d, s_d = result["e_w"], result["e_d"], result["s_d"]
|
||||
f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"]
|
||||
|
||||
snapshot = {
|
||||
"combo_id": combo_id,
|
||||
"combo_label": combo_label,
|
||||
"daily": {k: f_d.payload.get(k) for k in (
|
||||
"ma20", "ma60", "ma120", "atr", "adx", "volume_ratio",
|
||||
"range_high", "range_low", "swing_high", "swing_low", "close",
|
||||
)},
|
||||
"weekly": {k: f_w.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
|
||||
"monthly": {k: f_m.payload.get(k) for k in ("ma20", "ma60", "adx", "close")},
|
||||
}
|
||||
markers = []
|
||||
for key, typ in (("entry", "entry"), ("stop", "stop"), ("target1", "target1"), ("target2", "target2")):
|
||||
if p.payload.get(key) is not None:
|
||||
markers.append({"type": typ, "price": p.payload[key]})
|
||||
|
||||
return WyckoffScanRow(
|
||||
trade_date=trade_date,
|
||||
ts_code=symbol,
|
||||
name=display_name_cn(symbol),
|
||||
industry="crypto",
|
||||
engine_version=WYCKOFF_ENGINE_VERSION,
|
||||
m_cycle=c_m.payload.get("cycle", "Unknown"),
|
||||
cycle_confidence=c_m.confidence,
|
||||
trend_score=float(d.payload.get("trend_score", c_m.score)),
|
||||
w_cycle=c_w.payload.get("cycle", "Unknown"),
|
||||
w_phase=p_w.payload.get("phase", "None"),
|
||||
w_current_event=e_w.payload.get("current_event", "None"),
|
||||
w_recent_events_json=json.dumps(
|
||||
e_w.payload.get("active_events") or e_w.payload.get("recent_events") or [],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
phase_confidence=p_w.confidence,
|
||||
structure_score=float(d.payload.get("structure_score", p_w.score)),
|
||||
d_current_event=e_d.payload.get("current_event", "None"),
|
||||
d_recent_events_json=json.dumps(
|
||||
e_d.payload.get("active_events") or e_d.payload.get("recent_events") or [],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
event_confidence=e_d.confidence,
|
||||
entry_score=float(d.payload.get("entry_score", e_d.score)),
|
||||
entry=p.payload.get("entry"),
|
||||
stop=p.payload.get("stop"),
|
||||
target1=p.payload.get("target1"),
|
||||
target2=p.payload.get("target2"),
|
||||
rr=p.payload.get("rr"),
|
||||
alignment=float(d.payload.get("alignment", 0)),
|
||||
stars=int(d.payload.get("stars", 1)),
|
||||
decision_signal=d.payload.get("decision_signal", "Watch"),
|
||||
signal_confidence=s_d.confidence,
|
||||
overall_confidence=float(d.payload.get("overall_confidence", d.confidence)),
|
||||
overall_score=float(d.payload.get("overall_score", d.score)),
|
||||
risk=d.payload.get("risk", "Medium"),
|
||||
reasons_json=json.dumps(d.reasons + d.warnings, ensure_ascii=False),
|
||||
feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False),
|
||||
markers_json=json.dumps(markers, ensure_ascii=False),
|
||||
scanned_at=datetime.now(timezone.utc),
|
||||
combo_id=combo_id,
|
||||
)
|
||||
|
||||
|
||||
_ENGINES = None
|
||||
|
||||
|
||||
def _engines():
|
||||
global _ENGINES
|
||||
if _ENGINES is None:
|
||||
_ENGINES = {
|
||||
"feature_eng": FeatureEngine(),
|
||||
"cycle_eng": CycleEngine(),
|
||||
"phase_eng": PhaseEngine(),
|
||||
"event_eng": EventEngine(),
|
||||
"signal_eng": SignalEngine(),
|
||||
"decision_eng": DecisionEngine(),
|
||||
"plan_eng": PlanEngine(),
|
||||
}
|
||||
return _ENGINES
|
||||
|
||||
|
||||
def analyze_and_store(
|
||||
symbol: str,
|
||||
trade_date: date | None = None,
|
||||
*,
|
||||
combo_id: str | None = None,
|
||||
) -> WyckoffScanRow | None:
|
||||
eng = _engines()
|
||||
combo = get_combo(combo_id)
|
||||
low_tf, mid_tf, high_tf = combo["low"], combo["mid"], combo["high"]
|
||||
|
||||
low = load_frame(symbol, low_tf, lookback_for(low_tf))
|
||||
mid = load_frame(symbol, mid_tf, lookback_for(mid_tf))
|
||||
high = load_frame(symbol, high_tf, lookback_for(high_tf))
|
||||
if low is None or len(low) < 40:
|
||||
return None
|
||||
result = analyze_symbol(low, mid, high, **eng)
|
||||
td = trade_date or (
|
||||
low.trade_dates[-1] if low.trade_dates else datetime.now(timezone.utc).date()
|
||||
)
|
||||
row = _to_row(td, symbol, result, combo_id=combo["id"], combo_label=combo["label"])
|
||||
upsert_row(row)
|
||||
return row
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Plan Engine — Entry / Stop / Target / RR only when Decision is tradable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import DecisionSignal, EngineResult
|
||||
|
||||
|
||||
_TRADABLE = {
|
||||
DecisionSignal.STRONG_BUY.value,
|
||||
DecisionSignal.BUY.value,
|
||||
DecisionSignal.SELL.value,
|
||||
}
|
||||
|
||||
|
||||
class PlanEngine:
|
||||
name = "Plan"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, daily_feature: EngineResult, decision: EngineResult) -> EngineResult:
|
||||
f = daily_feature.payload
|
||||
close = float(f.get("close") or 0)
|
||||
atr = float(f.get("atr") or 0) or close * 0.02
|
||||
swing_low = float(f.get("swing_low") or close - 2 * atr)
|
||||
swing_high = float(f.get("swing_high") or close + 2 * atr)
|
||||
range_high = float(f.get("range_high") or swing_high)
|
||||
signal = decision.payload.get("decision_signal", DecisionSignal.WATCH.value)
|
||||
|
||||
entry = stop = t1 = t2 = rr = None
|
||||
reasons: list[str] = []
|
||||
|
||||
if signal not in _TRADABLE or close <= 0:
|
||||
reasons.append(f"无交易计划(信号={signal})")
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=decision.confidence,
|
||||
score=decision.score,
|
||||
reasons=reasons,
|
||||
payload={
|
||||
"entry": None,
|
||||
"stop": None,
|
||||
"target1": None,
|
||||
"target2": None,
|
||||
"rr": None,
|
||||
},
|
||||
)
|
||||
|
||||
if signal in (DecisionSignal.STRONG_BUY.value, DecisionSignal.BUY.value):
|
||||
entry = round(close, 4)
|
||||
stop = round(min(swing_low, close - 1.5 * atr), 4)
|
||||
risk = max(entry - stop, 1e-6)
|
||||
t1 = round(entry + 2.0 * risk, 4)
|
||||
t2 = round(max(range_high, entry + 3.0 * risk), 4)
|
||||
rr = round((t1 - entry) / risk, 2)
|
||||
reasons.append(f"入场={entry} 止损={stop} 目标一={t1} 盈亏比={rr}")
|
||||
else: # Sell
|
||||
entry = round(close, 4)
|
||||
stop = round(max(swing_high, close + 1.5 * atr), 4)
|
||||
risk = max(stop - entry, 1e-6)
|
||||
t1 = round(entry - 2.0 * risk, 4)
|
||||
t2 = round(entry - 3.0 * risk, 4)
|
||||
rr = round((entry - t1) / risk, 2)
|
||||
reasons.append(f"做空计划 入场={entry} 止损={stop} 目标一={t1}")
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=decision.confidence,
|
||||
score=decision.score,
|
||||
reasons=reasons,
|
||||
payload={
|
||||
"entry": entry,
|
||||
"stop": stop,
|
||||
"target1": t1,
|
||||
"target2": t2,
|
||||
"rr": rr,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from crypto_wyckoff.rules.registry import rule_registry
|
||||
|
||||
__all__ = ["rule_registry"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Rule protocol for Wyckoff Rule Registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuleHit:
|
||||
"""A single rule match."""
|
||||
|
||||
rule_id: str
|
||||
event: str | None = None
|
||||
phase: str | None = None
|
||||
cycle: str | None = None
|
||||
confidence: float = 0.0
|
||||
score: float = 0.0
|
||||
reasons: list[str] = field(default_factory=list)
|
||||
metrics: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class WyckoffRule(ABC):
|
||||
"""Pluggable rule. Engines iterate registry; never hardcode rule lists."""
|
||||
|
||||
rule_id: str
|
||||
category: str # cycle | phase | event
|
||||
timeframes: tuple[str, ...] = ("1d", "1w", "1M")
|
||||
|
||||
@abstractmethod
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
"""Return RuleHit if matched, else None. Pure — no I/O."""
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Cycle classification rules (monthly / weekly)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.domain_models import WyckoffCycle
|
||||
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||
|
||||
|
||||
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||
v = ctx.get("features", {}).get(key, default)
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
class MarkupCycleRule(WyckoffRule):
|
||||
rule_id = "cycle_markup"
|
||||
category = "cycle"
|
||||
timeframes = ("1M", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
close = _f(context, "close")
|
||||
ma20 = _f(context, "ma20")
|
||||
ma60 = _f(context, "ma60")
|
||||
ma120 = _f(context, "ma120")
|
||||
adx = _f(context, "adx")
|
||||
slope = _f(context, "ma60_slope")
|
||||
if close > ma20 > ma60 and (ma60 >= ma120 or slope > 0) and adx >= 18:
|
||||
conf = min(95.0, 55 + adx + (10 if close > ma120 else 0))
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
cycle=WyckoffCycle.MARKUP.value,
|
||||
confidence=conf,
|
||||
score=conf,
|
||||
reasons=["价格位于均线多头排列", f"ADX={adx:.1f}"],
|
||||
metrics={"adx": adx, "slope": slope},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class MarkdownCycleRule(WyckoffRule):
|
||||
rule_id = "cycle_markdown"
|
||||
category = "cycle"
|
||||
timeframes = ("1M", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
close = _f(context, "close")
|
||||
ma20 = _f(context, "ma20")
|
||||
ma60 = _f(context, "ma60")
|
||||
ma120 = _f(context, "ma120")
|
||||
adx = _f(context, "adx")
|
||||
slope = _f(context, "ma60_slope")
|
||||
if close < ma20 < ma60 and (ma60 <= ma120 or slope < 0) and adx >= 18:
|
||||
conf = min(95.0, 55 + adx + (10 if close < ma120 else 0))
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
cycle=WyckoffCycle.MARKDOWN.value,
|
||||
confidence=conf,
|
||||
score=conf,
|
||||
reasons=["价格位于均线空头排列", f"ADX={adx:.1f}"],
|
||||
metrics={"adx": adx},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class AccumulationCycleRule(WyckoffRule):
|
||||
rule_id = "cycle_accumulation"
|
||||
category = "cycle"
|
||||
timeframes = ("1M", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
adx = _f(context, "adx")
|
||||
range_pct = _f(context, "range_pct_60")
|
||||
close = _f(context, "close")
|
||||
ma120 = _f(context, "ma120")
|
||||
vol_trend = _f(context, "volume_trend")
|
||||
# Range-bound after decline: strictly at/below MA120 (mutually exclusive vs Distribution)
|
||||
if adx < 22 and range_pct < 0.28 and close <= ma120:
|
||||
conf = 60 + (10 if vol_trend > 0 else 0) + (10 if close < ma120 else 0)
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
cycle=WyckoffCycle.ACCUMULATION.value,
|
||||
confidence=min(90.0, conf),
|
||||
score=min(90.0, conf),
|
||||
reasons=["低趋势强度区间震荡", "疑似吸筹区间"],
|
||||
metrics={"adx": adx, "range_pct_60": range_pct},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class DistributionCycleRule(WyckoffRule):
|
||||
rule_id = "cycle_distribution"
|
||||
category = "cycle"
|
||||
timeframes = ("1M", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
adx = _f(context, "adx")
|
||||
range_pct = _f(context, "range_pct_60")
|
||||
close = _f(context, "close")
|
||||
ma120 = _f(context, "ma120")
|
||||
vol_trend = _f(context, "volume_trend")
|
||||
# Range-bound near highs: strictly above MA120 (mutually exclusive vs Accumulation)
|
||||
if adx < 22 and range_pct < 0.28 and close > ma120:
|
||||
conf = 60 + (10 if vol_trend < 0 else 0) + (10 if close > ma120 else 0)
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
cycle=WyckoffCycle.DISTRIBUTION.value,
|
||||
confidence=min(90.0, conf),
|
||||
score=min(90.0, conf),
|
||||
reasons=["高位低趋势震荡", "疑似派发区间"],
|
||||
metrics={"adx": adx, "range_pct_60": range_pct},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_rules() -> list[WyckoffRule]:
|
||||
# Order: trend cycles first (more decisive), then range cycles
|
||||
return [
|
||||
MarkupCycleRule(),
|
||||
MarkdownCycleRule(),
|
||||
AccumulationCycleRule(),
|
||||
DistributionCycleRule(),
|
||||
]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Event rules: Spring/SOS/LPS/UTAD/SC/AR/ST/..."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||
|
||||
|
||||
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||
v = ctx.get("features", {}).get(key, default)
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _cycle(ctx: dict[str, Any]) -> str:
|
||||
return (ctx.get("cycle") or {}).get("cycle") or ""
|
||||
|
||||
|
||||
def _phase(ctx: dict[str, Any]) -> str:
|
||||
return (ctx.get("phase") or {}).get("phase") or ""
|
||||
|
||||
|
||||
class SpringRule(WyckoffRule):
|
||||
rule_id = "event_spring"
|
||||
category = "event"
|
||||
timeframes = ("1d",)
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value,
|
||||
WyckoffCycle.MARKUP.value):
|
||||
# Allow spring only in accumulative contexts; Decision will filter MTF
|
||||
if cycle == WyckoffCycle.DISTRIBUTION.value:
|
||||
pass # still detect for facts but lower confidence
|
||||
pierce = _f(context, "pierce_below_range")
|
||||
reclaim = _f(context, "reclaim_speed")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
close_in_range = _f(context, "close_back_in_range")
|
||||
if pierce >= 0.002 and close_in_range >= 0.5 and reclaim >= 0.3:
|
||||
strength = min(98.0, 50 + pierce * 2000 + reclaim * 20 + (15 if vol_ratio < 1.2 else 5))
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.SPRING.value,
|
||||
confidence=strength,
|
||||
score=strength,
|
||||
reasons=[
|
||||
f"跌破区间后收回 (pierce={pierce:.3%})",
|
||||
f"回收速度={reclaim:.2f}",
|
||||
f"量比={vol_ratio:.2f}",
|
||||
],
|
||||
metrics={"pierce": pierce, "reclaim": reclaim, "volume_ratio": vol_ratio},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class TestRule(WyckoffRule):
|
||||
rule_id = "event_test"
|
||||
category = "event"
|
||||
timeframes = ("1d", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
pos = _f(context, "range_position")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
near_low = pos < 0.2
|
||||
if near_low and vol_ratio < 0.85:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.TEST.value,
|
||||
confidence=68.0,
|
||||
score=65.0,
|
||||
reasons=["低位缩量回测"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class SOSRule(WyckoffRule):
|
||||
rule_id = "event_sos"
|
||||
category = "event"
|
||||
timeframes = ("1d", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
breakout = _f(context, "breakout_above_range")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
close = _f(context, "close")
|
||||
ma20 = _f(context, "ma20")
|
||||
if breakout >= 0.0 and vol_ratio >= 1.2 and close > ma20:
|
||||
conf = min(95.0, 70 + vol_ratio * 8)
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.SOS.value,
|
||||
confidence=conf,
|
||||
score=conf,
|
||||
reasons=["放量突破区间上沿 (SOS)"],
|
||||
metrics={"vol_ratio": vol_ratio},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class LPSRule(WyckoffRule):
|
||||
rule_id = "event_lps"
|
||||
category = "event"
|
||||
timeframes = ("1d", "1w")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
# Pullback hold above broken range / MA20 after prior strength
|
||||
pullback = _f(context, "pullback_hold")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
above_ma = _f(context, "close") > _f(context, "ma20")
|
||||
if pullback >= 0.5 and above_ma and vol_ratio <= 1.1:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.LPS.value,
|
||||
confidence=74.0,
|
||||
score=76.0,
|
||||
reasons=["突破后缩量回踩支撑 (LPS)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class SCRule(WyckoffRule):
|
||||
rule_id = "event_sc"
|
||||
category = "event"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
bar_range = _f(context, "bar_range_atr")
|
||||
pos = _f(context, "range_position")
|
||||
if vol_ratio >= 1.8 and bar_range >= 1.5 and pos < 0.35:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.SC.value,
|
||||
confidence=72.0,
|
||||
score=70.0,
|
||||
reasons=["低位放量宽幅,疑似 Selling Climax"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class ARRule(WyckoffRule):
|
||||
rule_id = "event_ar"
|
||||
category = "event"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
# Automatic rally: bounce from lows
|
||||
bounce = _f(context, "bounce_from_low")
|
||||
if bounce >= 0.04:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.AR.value,
|
||||
confidence=65.0,
|
||||
score=62.0,
|
||||
reasons=["低点后自动反弹 (AR)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class STRule(WyckoffRule):
|
||||
rule_id = "event_st"
|
||||
category = "event"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
pos = _f(context, "range_position")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
if 0.15 < pos < 0.45 and vol_ratio < 1.0:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.ST.value,
|
||||
confidence=60.0,
|
||||
score=58.0,
|
||||
reasons=["次级测试 (ST)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class UTADRule(WyckoffRule):
|
||||
rule_id = "event_utad"
|
||||
category = "event"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
pierce_up = _f(context, "pierce_above_range")
|
||||
fail = _f(context, "fail_back_into_range")
|
||||
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value,
|
||||
WyckoffCycle.MARKUP.value):
|
||||
if pierce_up >= 0.002 and fail >= 0.5:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.UTAD.value,
|
||||
confidence=76.0,
|
||||
score=74.0,
|
||||
reasons=["冲高失败回到区间 (UTAD)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class JumpRule(WyckoffRule):
|
||||
rule_id = "event_jump"
|
||||
category = "event"
|
||||
timeframes = ("1d",)
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
gap = _f(context, "gap_up_pct")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
if gap >= 0.03 and vol_ratio >= 1.3:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.JUMP.value,
|
||||
confidence=70.0,
|
||||
score=72.0,
|
||||
reasons=["放量向上跳跃 (Jump)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class BackupRule(WyckoffRule):
|
||||
rule_id = "event_backup"
|
||||
category = "event"
|
||||
timeframes = ("1d",)
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
pullback = _f(context, "pullback_hold")
|
||||
after_jump = _f(context, "after_strength")
|
||||
if after_jump >= 0.5 and pullback >= 0.5:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
event=WyckoffEvent.BACKUP.value,
|
||||
confidence=68.0,
|
||||
score=70.0,
|
||||
reasons=["跳跃后回踩 (Backup)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_rules() -> list[WyckoffRule]:
|
||||
return [
|
||||
SpringRule(),
|
||||
UTADRule(),
|
||||
SOSRule(),
|
||||
LPSRule(),
|
||||
SCRule(),
|
||||
JumpRule(),
|
||||
BackupRule(),
|
||||
TestRule(),
|
||||
ARRule(),
|
||||
STRule(),
|
||||
]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Phase A–E rules (primarily weekly)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.domain_models import WyckoffCycle, WyckoffPhase
|
||||
from crypto_wyckoff.rules.base import RuleHit, WyckoffRule
|
||||
|
||||
|
||||
def _f(ctx: dict[str, Any], key: str, default: float = 0.0) -> float:
|
||||
v = ctx.get("features", {}).get(key, default)
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _cycle(ctx: dict[str, Any]) -> str:
|
||||
return (ctx.get("cycle") or {}).get("cycle") or WyckoffCycle.UNKNOWN.value
|
||||
|
||||
|
||||
class PhaseARule(WyckoffRule):
|
||||
rule_id = "phase_a"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value,
|
||||
WyckoffCycle.RE_ACCUMULATION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||
return None
|
||||
# Stopping action: high vol + large range recently, still range-bound
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
range_last = _f(context, "bar_range_atr")
|
||||
if vol_ratio >= 1.4 and range_last >= 1.2:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.A.value,
|
||||
confidence=70.0,
|
||||
score=65.0,
|
||||
reasons=["放量宽幅波动,疑似 Phase A 停止行为"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PhaseBRule(WyckoffRule):
|
||||
rule_id = "phase_b"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
if cycle not in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.DISTRIBUTION.value):
|
||||
return None
|
||||
adx = _f(context, "adx")
|
||||
range_pct = _f(context, "range_pct_60")
|
||||
pos = _f(context, "range_position") # 0=low 1=high of range
|
||||
if adx < 20 and 0.25 < pos < 0.75 and range_pct < 0.30:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.B.value,
|
||||
confidence=72.0,
|
||||
score=68.0,
|
||||
reasons=["区间中部震荡,疑似 Phase B 建仓/派发"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PhaseCRule(WyckoffRule):
|
||||
rule_id = "phase_c"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
pos = _f(context, "range_position")
|
||||
spring_like = _f(context, "spring_score_hint")
|
||||
utad_like = _f(context, "utad_score_hint")
|
||||
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
|
||||
if pos < 0.25 or spring_like >= 50:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.C.value,
|
||||
confidence=75.0 + min(15.0, spring_like * 0.15),
|
||||
score=78.0,
|
||||
reasons=["区间低位测试,疑似 Phase C (Spring/Test)"],
|
||||
)
|
||||
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||
if pos > 0.75 or utad_like >= 50:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.C.value,
|
||||
confidence=75.0,
|
||||
score=78.0,
|
||||
reasons=["区间高位测试,疑似 Phase C (UTAD)"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PhaseDRule(WyckoffRule):
|
||||
rule_id = "phase_d"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
close = _f(context, "close")
|
||||
ma20 = _f(context, "ma20")
|
||||
range_high = _f(context, "range_high")
|
||||
range_low = _f(context, "range_low")
|
||||
vol_ratio = _f(context, "volume_ratio")
|
||||
if cycle in (WyckoffCycle.ACCUMULATION.value, WyckoffCycle.RE_ACCUMULATION.value):
|
||||
if close > ma20 and range_high > 0 and close >= range_high * 0.98 and vol_ratio >= 1.1:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.D.value,
|
||||
confidence=80.0,
|
||||
score=82.0,
|
||||
reasons=["突破区间上沿放量,疑似 Phase D SOS"],
|
||||
)
|
||||
if cycle in (WyckoffCycle.DISTRIBUTION.value, WyckoffCycle.RE_DISTRIBUTION.value):
|
||||
if close < ma20 and range_low > 0 and close <= range_low * 1.02:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.D.value,
|
||||
confidence=80.0,
|
||||
score=82.0,
|
||||
reasons=["跌破区间下沿,疑似 Phase D SOW"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class PhaseERule(WyckoffRule):
|
||||
rule_id = "phase_e"
|
||||
category = "phase"
|
||||
timeframes = ("1w", "1d")
|
||||
|
||||
def evaluate(self, context: dict[str, Any]) -> RuleHit | None:
|
||||
cycle = _cycle(context)
|
||||
# Markup/Markdown already imply trend continuation (Phase E of prior structure)
|
||||
if cycle == WyckoffCycle.MARKUP.value:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.E.value,
|
||||
confidence=78.0,
|
||||
score=80.0,
|
||||
reasons=["趋势上行,对应 Phase E Markup"],
|
||||
)
|
||||
if cycle == WyckoffCycle.MARKDOWN.value:
|
||||
return RuleHit(
|
||||
rule_id=self.rule_id,
|
||||
phase=WyckoffPhase.E.value,
|
||||
confidence=78.0,
|
||||
score=80.0,
|
||||
reasons=["趋势下行,对应 Phase E Markdown"],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_rules() -> list[WyckoffRule]:
|
||||
# More specific phases first
|
||||
return [PhaseDRule(), PhaseCRule(), PhaseARule(), PhaseBRule(), PhaseERule()]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Rule Registry — register Wyckoff rules without modifying engines."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.rules.base import WyckoffRule
|
||||
|
||||
|
||||
class RuleRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._rules: dict[str, WyckoffRule] = {}
|
||||
|
||||
def register(self, rule: WyckoffRule) -> None:
|
||||
self._rules[rule.rule_id] = rule
|
||||
|
||||
def get(self, rule_id: str) -> WyckoffRule | None:
|
||||
return self._rules.get(rule_id)
|
||||
|
||||
def by_category(self, category: str, timeframe: str | None = None) -> list[WyckoffRule]:
|
||||
out = [r for r in self._rules.values() if r.category == category]
|
||||
if timeframe:
|
||||
out = [r for r in out if timeframe in r.timeframes]
|
||||
return out
|
||||
|
||||
def all(self) -> list[WyckoffRule]:
|
||||
return list(self._rules.values())
|
||||
|
||||
|
||||
rule_registry = RuleRegistry()
|
||||
|
||||
|
||||
def _register_defaults() -> None:
|
||||
from crypto_wyckoff.rules import cycle_rules, event_rules, phase_rules
|
||||
|
||||
for mod in (cycle_rules, phase_rules, event_rules):
|
||||
for rule in mod.build_rules():
|
||||
rule_registry.register(rule)
|
||||
|
||||
|
||||
_register_defaults()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Background tip + scan scheduler for crypto wyckoff (all enabled combos)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from crypto_wyckoff.combos import all_tfs_for_combos, list_combos
|
||||
from crypto_wyckoff.io import (
|
||||
backfill_symbol,
|
||||
bar_count,
|
||||
fetch_symbols_from_provider,
|
||||
tip_update_symbol,
|
||||
)
|
||||
from crypto_wyckoff.pipeline import analyze_and_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_thread: threading.Thread | None = None
|
||||
_stop = threading.Event()
|
||||
_status: dict = {
|
||||
"running": False,
|
||||
"last_tick_at": None,
|
||||
"last_error": None,
|
||||
"symbols_total": 0,
|
||||
"symbols_scanned": 0,
|
||||
"tick_interval_sec": 60,
|
||||
"backfill_done": False,
|
||||
}
|
||||
_status_lock = threading.Lock()
|
||||
|
||||
|
||||
def _set(**kwargs):
|
||||
with _status_lock:
|
||||
_status.update(kwargs)
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
with _status_lock:
|
||||
return dict(_status)
|
||||
|
||||
|
||||
def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict:
|
||||
"""One cycle: refresh symbols, tip-update, analyze each combo."""
|
||||
symbols = fetch_symbols_from_provider()
|
||||
if max_symbols:
|
||||
symbols = symbols[:max_symbols]
|
||||
combos = list_combos()
|
||||
tfs = all_tfs_for_combos(combos)
|
||||
_set(symbols_total=len(symbols), running=True, last_error=None)
|
||||
scanned = 0
|
||||
errors = 0
|
||||
changed_n = 0
|
||||
|
||||
for i, sym in enumerate(symbols):
|
||||
try:
|
||||
# Prefer low-TF of first combo for "enough history" gate
|
||||
low0 = combos[0]["low"] if combos else "1d"
|
||||
if bar_count(sym, low0) < 40:
|
||||
backfill_symbol(sym, tfs)
|
||||
tip_changed = tip_update_symbol(sym, tfs)
|
||||
if tip_changed:
|
||||
changed_n += 1
|
||||
if force_rescan or tip_changed:
|
||||
for combo in combos:
|
||||
row = analyze_and_store(sym, combo_id=combo["id"])
|
||||
if row:
|
||||
scanned += 1
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
if errors <= 5:
|
||||
logger.warning("tick %s: %s", sym, e)
|
||||
_set(last_error=str(e))
|
||||
if (i + 1) % 25 == 0:
|
||||
_set(symbols_scanned=scanned)
|
||||
logger.info("wyckoff tick progress %s/%s scanned=%s", i + 1, len(symbols), scanned)
|
||||
|
||||
_set(
|
||||
running=False,
|
||||
symbols_scanned=scanned,
|
||||
last_tick_at=datetime.now(timezone.utc).isoformat(),
|
||||
backfill_done=True,
|
||||
)
|
||||
return {
|
||||
"symbols": len(symbols),
|
||||
"scanned": scanned,
|
||||
"changed_tips": changed_n,
|
||||
"errors": errors,
|
||||
"combos": [c["id"] for c in combos],
|
||||
"tfs": tfs,
|
||||
}
|
||||
|
||||
|
||||
def _loop(interval: int, max_symbols: int | None):
|
||||
try:
|
||||
run_tick(max_symbols=max_symbols, force_rescan=True)
|
||||
except Exception as e:
|
||||
logger.exception("initial tick failed: %s", e)
|
||||
_set(last_error=str(e), running=False)
|
||||
while not _stop.wait(interval):
|
||||
try:
|
||||
# Tip-driven: only force full rescan when tips change is handled inside
|
||||
run_tick(max_symbols=max_symbols, force_rescan=False)
|
||||
except Exception as e:
|
||||
logger.exception("tick failed: %s", e)
|
||||
_set(last_error=str(e), running=False)
|
||||
|
||||
|
||||
def start_scheduler(interval_sec: int = 60, max_symbols: int | None = None) -> None:
|
||||
global _thread
|
||||
if _thread and _thread.is_alive():
|
||||
return
|
||||
_stop.clear()
|
||||
_set(tick_interval_sec=interval_sec)
|
||||
_thread = threading.Thread(
|
||||
target=_loop,
|
||||
args=(interval_sec, max_symbols),
|
||||
name="crypto-wyckoff-scheduler",
|
||||
daemon=True,
|
||||
)
|
||||
_thread.start()
|
||||
logger.info("crypto wyckoff scheduler started interval=%ss", interval_sec)
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
_stop.set()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Signal Engine — timeframe-local status labels only (not tradability)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crypto_wyckoff.domain_models import EngineResult, WyckoffEvent
|
||||
|
||||
|
||||
class SignalEngine:
|
||||
"""Maps local Event/Phase into a status label. Decision decides tradability."""
|
||||
|
||||
name = "Signal"
|
||||
version = "1.0.0"
|
||||
|
||||
def run(self, event: EngineResult, phase: EngineResult | None = None) -> EngineResult:
|
||||
current = event.payload.get("current_event", WyckoffEvent.NONE.value)
|
||||
conf = event.confidence
|
||||
label = current # status label mirrors event for V1
|
||||
reasons = [f"本地事件标签: {label}"]
|
||||
if phase and phase.payload.get("phase"):
|
||||
reasons.append(f"本地阶段: {phase.payload.get('phase')}")
|
||||
|
||||
return EngineResult(
|
||||
name=self.name,
|
||||
version=self.version,
|
||||
confidence=conf,
|
||||
score=event.score,
|
||||
reasons=reasons,
|
||||
payload={
|
||||
"signal_label": label,
|
||||
"current_event": current,
|
||||
"phase": (phase.payload.get("phase") if phase else None),
|
||||
"active_events": event.payload.get("active_events")
|
||||
or event.payload.get("recent_events", []),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""SQLite persistence for crypto wyckoff scan rows (per combo)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from crypto_wyckoff.domain_models import WyckoffScanRow
|
||||
from crypto_wyckoff.io import SCAN_DB, ensure_dirs
|
||||
|
||||
_COLS = [
|
||||
"trade_date", "combo_id", "ts_code", "name", "industry", "engine_version",
|
||||
"m_cycle", "cycle_confidence", "trend_score",
|
||||
"w_cycle", "w_phase", "w_current_event", "w_recent_events_json",
|
||||
"phase_confidence", "structure_score",
|
||||
"d_current_event", "d_recent_events_json", "event_confidence", "entry_score",
|
||||
"entry", "stop", "target1", "target2", "rr",
|
||||
"alignment", "stars", "decision_signal", "signal_confidence",
|
||||
"overall_confidence", "overall_score", "risk", "reasons_json",
|
||||
"feature_snapshot_json", "markers_json", "scanned_at",
|
||||
]
|
||||
|
||||
_CREATE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS wyckoff_scan (
|
||||
trade_date TEXT NOT NULL,
|
||||
combo_id TEXT NOT NULL DEFAULT 'd_w_m',
|
||||
ts_code TEXT NOT NULL,
|
||||
name TEXT DEFAULT '',
|
||||
industry TEXT DEFAULT '',
|
||||
engine_version TEXT,
|
||||
m_cycle TEXT, cycle_confidence REAL, trend_score REAL,
|
||||
w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT,
|
||||
phase_confidence REAL, structure_score REAL,
|
||||
d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL,
|
||||
entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL,
|
||||
alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL,
|
||||
overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT,
|
||||
feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT,
|
||||
PRIMARY KEY (trade_date, combo_id, ts_code)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def _migrate(c: sqlite3.Connection) -> None:
|
||||
cur = c.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='wyckoff_scan'"
|
||||
)
|
||||
if not cur.fetchone():
|
||||
c.execute(_CREATE_SQL)
|
||||
c.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||
)
|
||||
return
|
||||
|
||||
cols = {r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan)")}
|
||||
if "combo_id" in cols:
|
||||
c.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||
)
|
||||
return
|
||||
|
||||
# Legacy PK (trade_date, ts_code) → add combo_id via table rebuild
|
||||
c.execute("ALTER TABLE wyckoff_scan RENAME TO wyckoff_scan_old")
|
||||
c.execute(_CREATE_SQL)
|
||||
old_cols = [r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan_old)")]
|
||||
shared = [col for col in _COLS if col != "combo_id" and col in old_cols]
|
||||
col_sql = ",".join(shared)
|
||||
c.execute(
|
||||
f"""
|
||||
INSERT INTO wyckoff_scan (combo_id, {col_sql})
|
||||
SELECT 'd_w_m', {col_sql} FROM wyckoff_scan_old
|
||||
"""
|
||||
)
|
||||
c.execute("DROP TABLE wyckoff_scan_old")
|
||||
c.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_cw_score "
|
||||
"ON wyckoff_scan(trade_date, combo_id, overall_score DESC)"
|
||||
)
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
ensure_dirs()
|
||||
c = sqlite3.connect(str(SCAN_DB), timeout=60)
|
||||
c.row_factory = sqlite3.Row
|
||||
_migrate(c)
|
||||
c.commit()
|
||||
return c
|
||||
|
||||
|
||||
def upsert_row(row: WyckoffScanRow) -> None:
|
||||
combo_id = getattr(row, "combo_id", None) or "d_w_m"
|
||||
vals = (
|
||||
row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date),
|
||||
combo_id,
|
||||
row.ts_code, row.name, row.industry, row.engine_version,
|
||||
row.m_cycle, row.cycle_confidence, row.trend_score,
|
||||
row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json,
|
||||
row.phase_confidence, row.structure_score,
|
||||
row.d_current_event, row.d_recent_events_json, row.event_confidence, row.entry_score,
|
||||
row.entry, row.stop, row.target1, row.target2, row.rr,
|
||||
row.alignment, row.stars, row.decision_signal, row.signal_confidence,
|
||||
row.overall_confidence, row.overall_score, row.risk, row.reasons_json,
|
||||
row.feature_snapshot_json, row.markers_json,
|
||||
row.scanned_at.isoformat() if isinstance(row.scanned_at, datetime) else str(row.scanned_at),
|
||||
)
|
||||
c = _conn()
|
||||
try:
|
||||
placeholders = ",".join("?" * len(_COLS))
|
||||
col_sql = ",".join(_COLS)
|
||||
updates = ",".join(
|
||||
f"{col}=excluded.{col}"
|
||||
for col in _COLS
|
||||
if col not in ("trade_date", "combo_id", "ts_code")
|
||||
)
|
||||
c.execute(
|
||||
f"""
|
||||
INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders})
|
||||
ON CONFLICT(trade_date, combo_id, ts_code) DO UPDATE SET {updates}
|
||||
""",
|
||||
vals,
|
||||
)
|
||||
c.commit()
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def latest_trade_date(combo_id: str | None = None) -> str | None:
|
||||
c = _conn()
|
||||
try:
|
||||
if combo_id:
|
||||
cur = c.execute(
|
||||
"SELECT MAX(trade_date) FROM wyckoff_scan WHERE combo_id=?",
|
||||
(combo_id,),
|
||||
)
|
||||
else:
|
||||
cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan")
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] else None
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -> int:
|
||||
td = trade_date or latest_trade_date(combo_id)
|
||||
if not td:
|
||||
return 0
|
||||
c = _conn()
|
||||
try:
|
||||
if combo_id:
|
||||
cur = c.execute(
|
||||
"SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=? AND combo_id=?",
|
||||
(td, combo_id),
|
||||
)
|
||||
else:
|
||||
cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,))
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def query_scan(
|
||||
*,
|
||||
trade_date: str | None = None,
|
||||
combo_id: str | None = None,
|
||||
m_cycle: str | None = None,
|
||||
w_phase: str | None = None,
|
||||
d_event: str | None = None,
|
||||
decision_signal: str | None = None,
|
||||
min_overall_score: float | None = None,
|
||||
min_alignment: float | None = None,
|
||||
sort: str = "overall_score",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
cid = combo_id or "d_w_m"
|
||||
td = trade_date or latest_trade_date(cid)
|
||||
if not td:
|
||||
return []
|
||||
sort_col = sort if sort in {
|
||||
"overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars"
|
||||
} else "overall_score"
|
||||
clauses = ["trade_date=?", "combo_id=?"]
|
||||
args: list[Any] = [td, cid]
|
||||
if m_cycle:
|
||||
clauses.append("m_cycle=?")
|
||||
args.append(m_cycle)
|
||||
if w_phase:
|
||||
clauses.append("w_phase=?")
|
||||
args.append(w_phase)
|
||||
if d_event:
|
||||
clauses.append("d_current_event=?")
|
||||
args.append(d_event)
|
||||
if decision_signal:
|
||||
clauses.append("decision_signal=?")
|
||||
args.append(decision_signal)
|
||||
if min_overall_score is not None:
|
||||
clauses.append("overall_score>=?")
|
||||
args.append(min_overall_score)
|
||||
if min_alignment is not None:
|
||||
clauses.append("alignment>=?")
|
||||
args.append(min_alignment)
|
||||
where = " AND ".join(clauses)
|
||||
args.extend([limit, offset])
|
||||
c = _conn()
|
||||
try:
|
||||
cur = c.execute(
|
||||
f"SELECT * FROM wyckoff_scan WHERE {where} ORDER BY {sort_col} DESC LIMIT ? OFFSET ?",
|
||||
args,
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
|
||||
def get_symbol(
|
||||
ts_code: str,
|
||||
trade_date: str | None = None,
|
||||
combo_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
cid = combo_id or "d_w_m"
|
||||
td = trade_date or latest_trade_date(cid)
|
||||
if not td:
|
||||
return None
|
||||
c = _conn()
|
||||
try:
|
||||
cur = c.execute(
|
||||
"SELECT * FROM wyckoff_scan WHERE trade_date=? AND combo_id=? AND ts_code=?",
|
||||
(td, cid, ts_code),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
c.close()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Crypto symbol → Chinese display name for screener UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Base asset → 中文名(覆盖 provider 当前币对;未知则回退 base)
|
||||
_BASE_CN: dict[str, str] = {
|
||||
"BTC": "比特币",
|
||||
"ETH": "以太坊",
|
||||
"SOL": "索拉纳",
|
||||
"XAU": "黄金",
|
||||
"XAG": "白银",
|
||||
"SAGA": "Saga",
|
||||
"CL": "原油",
|
||||
"ZEC": "大零币",
|
||||
"XRP": "瑞波币",
|
||||
"DOGE": "狗狗币",
|
||||
"BNB": "币安币",
|
||||
"SUI": "Sui",
|
||||
"BILL": "Bill",
|
||||
"BZ": "BZ",
|
||||
"LAB": "Lab",
|
||||
"TON": "通联币",
|
||||
"CRCL": "Circle",
|
||||
"SNDK": "SNDK",
|
||||
"1000PEPE": "千倍佩佩",
|
||||
"PEPE": "佩佩",
|
||||
"CHIP": "CHIP",
|
||||
"WIF": "狗帽子",
|
||||
}
|
||||
|
||||
|
||||
def base_asset(symbol: str) -> str:
|
||||
"""BTC/USDT:USDT → BTC;1000PEPE/USDT:USDT → 1000PEPE."""
|
||||
s = (symbol or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
head = s.split(":")[0]
|
||||
return head.split("/")[0].upper() if "/" in head else head.upper()
|
||||
|
||||
|
||||
def display_name_cn(symbol: str) -> str:
|
||||
base = base_asset(symbol)
|
||||
if not base:
|
||||
return symbol or ""
|
||||
return _BASE_CN.get(base, base)
|
||||
|
||||
|
||||
def symbol_name_map(symbols: list[str] | None = None) -> dict[str, str]:
|
||||
if not symbols:
|
||||
return {f"{k}/USDT:USDT": v for k, v in _BASE_CN.items()}
|
||||
return {s: display_name_cn(s) for s in symbols}
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Wyckoff Screener engine version — bump when rules change."""
|
||||
|
||||
WYCKOFF_ENGINE_VERSION = "v1.0.0"
|
||||
ARCHITECTURE_VERSION = "1.0"
|
||||
@@ -23,6 +23,8 @@
|
||||
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
||||
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
||||
- ECR-007 Final Approval / `276481e`:Wyckoff Live Structure(`live.py`);Confirmed ≠ Live;execution 仅 confirmed
|
||||
- ECR-008 Reviewed:主站 `chart_tv.js` → `chart_tv_{lifecycle,shell,indicators,chan,overlays,finalize}.js` + 薄门面
|
||||
- ECR-009 Implementing:`/wyckoff_crypto` 独立选股页(`crypto_wyckoff/`);D/W + 本地月线;60s tip
|
||||
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
|
||||
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
|
||||
|
||||
@@ -30,14 +32,15 @@
|
||||
|
||||
- `/api/analyze` 字段可增不可删
|
||||
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||
- 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法
|
||||
- 威科夫为独立叠层(ECR-003/007);Crypto Screener 为独立页(ECR-009),勿混进缠论引擎
|
||||
- Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||
|
||||
## 已知债务
|
||||
|
||||
- `chart_tv.js` 单体巨大 → 后续可选 ECR
|
||||
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
|
||||
- 内存泄漏尚无自动化 heap/监听断言
|
||||
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
||||
- 威科夫启发式参数未做 UI 调参
|
||||
- ECR-007 待 PR 合入 `dev`
|
||||
- ECR-007 待 Human 在 Gitea 开 PR 合入 `dev`
|
||||
- `chart_tv_overlays.js` 仍偏大,可后续再拆
|
||||
- ECR-009:月线历史受日线深度限制;Cycle 规则在 crypto 上可能偏 Unknown,看效果再调参
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
## Unreleased — 2026-08-07
|
||||
|
||||
### ECR-009(L2,进行中)
|
||||
|
||||
- 独立页 `/wyckoff_crypto`:移植 A_Share_DP D/W/M 威科夫选股引擎至数字货币
|
||||
- 本地 `data/crypto_wyckoff/`;60s tip;月线由日线 UTC 自然月聚合(provider 无 1M)
|
||||
- API:`/api/wyckoff_crypto/*`;不碰主站 analyze / 缠论叠层
|
||||
|
||||
### ECR-008(L3,Reviewed)
|
||||
|
||||
- 主站 `chart_tv.js` 拆为 lifecycle / shell / indicators / chan / overlays / finalize + 薄门面
|
||||
- 行为冻结;`initTradingView` / `disposeTradingViewCharts` 对外不变;无 Vite/TS
|
||||
|
||||
### ECR-007(L2,LOOP-RUN-005)
|
||||
|
||||
- Wyckoff **Live Structure**:`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# CODE_REVIEW — ECR-008
|
||||
|
||||
**Role:** REVIEWER
|
||||
**Date:** 2026-08-07
|
||||
**Scope:** chart_tv 物理拆分
|
||||
**Decision:** Approve
|
||||
|
||||
## Checklist
|
||||
|
||||
| Item | Result | Notes |
|
||||
|------|--------|-------|
|
||||
| 行为冻结(仅搬移) | PASS | ctx 编排;无绘制算法改写意图 |
|
||||
| 对外 API | PASS | `initTradingView` / `disposeTradingViewCharts` 保留 |
|
||||
| Forbidden | PASS | 无 Vite/TS;无 strategies/config;无 analyze 契约改动 |
|
||||
| script 顺序 | PASS | lifecycle→shell→indicators→chan→overlays→finalize→门面→sync |
|
||||
| 测试证据 | PASS | `node --check` ALL_CHECK_OK |
|
||||
|
||||
## Findings
|
||||
|
||||
1. **Low:** 浏览器硬刷新冒烟仍建议 Human 点一次(自动刷新 + Cycle Summary)。不挡 Approve。
|
||||
2. **Low:** `chart_tv_overlays.js` 仍偏大(~2.3k 行);可后续再拆,非本 ECR 范围。
|
||||
|
||||
## Decision
|
||||
|
||||
**Approve**
|
||||
@@ -0,0 +1,61 @@
|
||||
# ECR-008
|
||||
|
||||
**Title:** 拆分主站巨型 `chart_tv.js`(行为冻结)
|
||||
**Status:** Done (Reviewed)
|
||||
**Date:** 2026-08-07
|
||||
**Change Level:** L3(结构重构;行为冻结)
|
||||
|
||||
## Change
|
||||
|
||||
将 `web/static/js/app/chart_tv.js`(≈4700 行)按职责拆为多个无打包 script;薄门面保留 `initTradingView` / `disposeTradingViewCharts` 供 `ui.js` 调用。
|
||||
|
||||
## Motivation
|
||||
|
||||
ECR-001/002 CODE_REVIEW 非阻断债务;威科夫与 Live 叠层继续堆入单体,审阅与回归成本上升。
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed
|
||||
|
||||
- 新增:`chart_tv_lifecycle.js` / `chart_tv_shell.js` / `chart_tv_indicators.js` / `chart_tv_chan.js` / `chart_tv_overlays.js` / `chart_tv_finalize.js`
|
||||
- `chart_tv.js` 改为编排门面;`index.html` 调整 script 顺序与 cache bust
|
||||
- `node --check`;主站手动冒烟
|
||||
|
||||
### Forbidden
|
||||
|
||||
- Vite / React / TS 构建流水线
|
||||
- 修改笔 / 线段 / 中枢 / 买卖点算法语义或绘制语义(仅搬移)
|
||||
- 破坏 `/api/analyze` JSON 字段
|
||||
- 修改 `config/` / `strategies/`
|
||||
- 为主站重新引入 WebSocket 实时
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| 拆分漏变量 / 作用域错误 | ctx 显式传参;冒烟 dispose + 三周期元素 + 威科夫 |
|
||||
| script 顺序错误 | index.html 固定 lifecycle→…→门面→sync |
|
||||
| 缓存旧单体 | bump `?v=` |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `initTradingView` / `disposeTradingViewCharts` 仍可被 `ui.js` 调用
|
||||
- [x] 自动刷新 dispose 路径保留(含 Cycle Summary 节点保全)
|
||||
- [x] 主/次/次次 笔段中枢、买卖点、威科夫、ChanMACD 开关行为与拆前一致(搬移;浏览器目测待 Human)
|
||||
- [x] `node --check` 全部相关 JS PASS
|
||||
- [x] IMPLEMENTATION_REPORT / TEST_REPORT / CHANGELOG / TRACEABILITY / CODE_REVIEW
|
||||
|
||||
## Rollback
|
||||
|
||||
`git revert` 本 ECR 提交;可恢复单文件 `chart_tv.js`。
|
||||
|
||||
## Risk Review
|
||||
|
||||
N/A(不改交易决策语义)
|
||||
|
||||
## Linked
|
||||
|
||||
- IDEA: `docs/IDEA/IDEA-006-chart-tv-split.md`
|
||||
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-008-chart-tv-split.md`
|
||||
- HANDOFF: `docs/HANDOFF/ECR-008-architect-to-engineer.md`
|
||||
- TRACEABILITY: Yes
|
||||
@@ -0,0 +1,25 @@
|
||||
# ECR-009
|
||||
|
||||
**Title:** Crypto Wyckoff Screener 独立页(D/W/M)
|
||||
**Status:** Implementing
|
||||
**Date:** 2026-08-07
|
||||
**Change Level:** L2
|
||||
|
||||
## Change
|
||||
|
||||
新增 `crypto_wyckoff/` 包(移植 A_Share_DP 引擎)+ `/wyckoff_crypto` 页 + `/api/wyckoff_crypto/*`;本地缓存 K 线;60s tip 更新。
|
||||
|
||||
周期组合:内置 `8h/4h/1h`(默认)与 `1d/1w/1M`;UI 下拉切换;可添加自定义高/中/低组合(规则引擎仍按 D/W/M 角色映射)。
|
||||
|
||||
## Forbidden
|
||||
|
||||
- 改缠论算法、主站叠层、`/api/analyze`、`config/`/`strategies/`
|
||||
- 自动下单
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [ ] 页面可列出扫描结果(decision/cycle/phase/event)
|
||||
- [ ] 本地 `data/crypto_wyckoff/` 有 K 线与 scan
|
||||
- [ ] 调度可跑 tip 更新
|
||||
- [ ] Decision 门闩单测通过
|
||||
- [ ] 下拉可选 `8h/4h/1h`,可添加新组合
|
||||
@@ -0,0 +1,38 @@
|
||||
# ENGINEERING_SPEC — ECR-008 chart_tv 拆分
|
||||
|
||||
**ECR:** ECR-008
|
||||
**Level:** L3 · 行为冻结
|
||||
**Date:** 2026-08-07
|
||||
|
||||
## Goal
|
||||
|
||||
物理拆分主站 Lightweight Charts 绘制单体,不改变可见行为。
|
||||
|
||||
## Module map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `chart_tv_lifecycle.js` | `disposeTradingViewCharts`;cleanup 数组与 chart.remove |
|
||||
| `chart_tv_shell.js` | `chartTvBuildShell(ctx)`:容器、createChart、K 线主系列 |
|
||||
| `chart_tv_indicators.js` | `chartTvRenderIndicators(ctx)`:成交量 / ATR / ChanMACD |
|
||||
| `chart_tv_chan.js` | `chartTvRenderChan(ctx)`:笔 / 线段 / 中枢(含未完成与 BI) |
|
||||
| `chart_tv_overlays.js` | `chartTvRenderOverlays(ctx)`:结构区、威科夫、BSP/分型、布林等 |
|
||||
| `chart_tv_finalize.js` | `chartTvFinalize(ctx)`:时间轴同步、bindSync、视图恢复、tooltip |
|
||||
| `chart_tv.js` | `initTradingView`:组 ctx → 顺序调用上述步骤 |
|
||||
|
||||
## Context object
|
||||
|
||||
`ctx` 至少携带:`symbol`、`timeframe`、`symbolConfig`、周期开关、`candles`、各 chart/container、`showMacd`。全局 `currentData` / `tvWidget` 仍按现网约定使用。
|
||||
|
||||
## HTML load order
|
||||
|
||||
`lifecycle → shell → indicators → chan → overlays → finalize → chart_tv.js → chart_sync.js → …`
|
||||
|
||||
## Tests
|
||||
|
||||
1. `node --check` 各新文件 + 门面
|
||||
2. 人工:首屏、自动刷新、威科夫开关、三周期笔段中枢、Cycle Summary
|
||||
|
||||
## Out of scope
|
||||
|
||||
Live 验证批跑、威科夫算法调参、analyze JSON 快照、`chart_sync` 大改。
|
||||
@@ -0,0 +1,31 @@
|
||||
# ENGINEERING_SPEC — ECR-009 Crypto Wyckoff Screener
|
||||
|
||||
**Level:** L2 · 独立页
|
||||
**Date:** 2026-08-07
|
||||
|
||||
## Goal
|
||||
|
||||
数字货币 D/W/M 威科夫选股观察页(A_Share_DP 引擎语义);24/7 tip 每分钟更新。
|
||||
|
||||
## Package
|
||||
|
||||
`crypto_wyckoff/`:features → cycle/phase/event/signal → decision → plan;本地 `data/crypto_wyckoff/`。
|
||||
|
||||
## API
|
||||
|
||||
- `GET /wyckoff_crypto`
|
||||
- `GET /api/wyckoff_crypto/meta|status|scan`
|
||||
- `GET /api/wyckoff_crypto/symbol/<symbol>`
|
||||
- `POST /api/wyckoff_crypto/tick`
|
||||
|
||||
## Env
|
||||
|
||||
- `CRYPTO_WYCKOFF_DISABLE=1` 关闭调度
|
||||
- `CRYPTO_WYCKOFF_INTERVAL=60`
|
||||
- `CRYPTO_WYCKOFF_MAX_SYMBOLS=N` 小样本调试
|
||||
- `DATA_SERVICE_URL` 默认 provider.jackyu66.com
|
||||
|
||||
## Crypto calendar
|
||||
|
||||
UTC 连续盘;回填不做 A 股周末放大。
|
||||
**月线**:provider 无 `1M`,由本地日线按 **UTC 自然月** OHLCV 聚合;日/周直接拉 `1d`/`1w`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# HANDOFF — Architect → Engineer(ECR-008)
|
||||
|
||||
**From:** Architect
|
||||
**To:** Engineer
|
||||
**ECR:** ECR-008
|
||||
**Date:** 2026-08-07
|
||||
|
||||
## Mission
|
||||
|
||||
按 ENG-008 拆分 `chart_tv.js`;剪切粘贴优先;禁止改绘制语义。
|
||||
|
||||
## Steps
|
||||
|
||||
1. 抽出 `disposeTradingViewCharts` → `chart_tv_lifecycle.js`
|
||||
2. 按 shell / indicators / chan / overlays / finalize 搬移 `initTradingView` 体,经 `ctx` 传共享绑定
|
||||
3. 门面 `initTradingView` 仅:dispose → build ctx → 顺序调用
|
||||
4. 更新 `index.html` script 顺序与 `?v=`
|
||||
5. `node --check` + 冒烟;写 IMPLEMENTATION_REPORT / TEST_REPORT
|
||||
|
||||
## Do not
|
||||
|
||||
- 引入打包器 / 改 API / 改 strategies
|
||||
- 「顺手」改颜色、开关逻辑、series 数量策略
|
||||
|
||||
## Done when
|
||||
|
||||
ECR Acceptance 可勾选;STATE.owner → reviewer。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Idea: 拆分主站巨型 chart_tv.js
|
||||
|
||||
## Problem
|
||||
|
||||
`web/static/js/app/chart_tv.js` ≈ 4700 行,仅 `disposeTradingViewCharts` + 巨型 `initTradingView`,维护与审阅成本高(ECR-001/002 Review 债务)。
|
||||
|
||||
## Observation
|
||||
|
||||
ECR-002 明确将 chart_tv 拆分列为可选且未做;后续威科夫/Live 改动都挤在同一文件。
|
||||
|
||||
## Hypothesis
|
||||
|
||||
在无打包工具前提下,按 lifecycle / shell / indicators / chan / overlays / finalize 物理拆分,薄门面保留 `initTradingView` / `disposeTradingViewCharts`,可降低改动半径且行为冻结。
|
||||
|
||||
## Expected Impact
|
||||
|
||||
主站前端可维护性提升;与 `chart_sync` / `chart_view` 边界更清晰。
|
||||
|
||||
## Change Level Guess
|
||||
|
||||
**L3**(结构重构;行为冻结)
|
||||
|
||||
## Next
|
||||
|
||||
- [x] ECR-008 Draft → Human Approve(计划执行即 Approve)
|
||||
- [ ] ENGINEERING_SPEC / HANDOFF
|
||||
- [ ] 实现与 CODE_REVIEW
|
||||
@@ -0,0 +1,13 @@
|
||||
# Idea: Crypto Wyckoff Screener(独立页)
|
||||
|
||||
## Problem
|
||||
|
||||
主站威科夫是图叠层;需要 A_Share_DP 式 D/W/M 多周期选股/决策观察,用于数字货币。
|
||||
|
||||
## Hypothesis
|
||||
|
||||
独立包 + 独立页,币对来自 DATA_SERVICE,本地缓存 1d/1w/1M,每分钟 tip 更新,不碰缠论主链路。
|
||||
|
||||
## Change Level Guess
|
||||
|
||||
**L2**(新行为面;不改 strategies)
|
||||
@@ -0,0 +1,29 @@
|
||||
# IMPLEMENTATION_REPORT — ECR-008
|
||||
|
||||
**Status:** Implemented
|
||||
**Date:** 2026-08-07
|
||||
**Branch:** `feature/ECR-008-chart-tv-split`
|
||||
|
||||
## Change summary
|
||||
|
||||
将 `chart_tv.js` 单体拆为:
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `chart_tv_lifecycle.js` | `disposeTradingViewCharts` |
|
||||
| `chart_tv_shell.js` | `chartTvBuildShell(ctx)` |
|
||||
| `chart_tv_indicators.js` | `chartTvRenderIndicators(ctx)` |
|
||||
| `chart_tv_chan.js` | `chartTvRenderChan(ctx)` |
|
||||
| `chart_tv_overlays.js` | `chartTvRenderOverlays(ctx)` |
|
||||
| `chart_tv_finalize.js` | `chartTvFinalize(ctx)` |
|
||||
| `chart_tv.js` | `initTradingView` 薄门面 |
|
||||
|
||||
`index.html` 按 ENG 顺序加载;cache `?v=20260807f`。
|
||||
|
||||
## Method
|
||||
|
||||
剪切粘贴原 `initTradingView` 体段;共享绑定经 `ctx`;绘制语义未改。
|
||||
|
||||
## Not changed
|
||||
|
||||
缠论算法、`/api/analyze`、`config/`、`strategies/`、主站 WS。
|
||||
@@ -34,7 +34,7 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
||||
|
||||
## Active anchors
|
||||
|
||||
- ECR: ECR-002/003/004 Reviewed;ECR-007 Final Approval(Live Structure,待合入 `dev`)
|
||||
- ECR: ECR-002/003/004 Reviewed;ECR-007 Final Approval(待合入 `dev`);ECR-008 Reviewed(chart_tv 拆分)
|
||||
- EXP: N/A
|
||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||
- Memory: `docs/AGENT_MEMORY.md`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# STATE
|
||||
|
||||
**owner:** idle
|
||||
**active_ecr:** none(ECR-007 Final Approval;待合入 `dev`)
|
||||
**phase:** post-approval
|
||||
**owner:** engineer
|
||||
**active_ecr:** ECR-009(crypto wyckoff screener)
|
||||
**phase:** implementing
|
||||
**system_version:** v1.0.0
|
||||
**strategy_version:** unchanged
|
||||
**updated:** 2026-08-07
|
||||
@@ -16,11 +16,12 @@
|
||||
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
|
||||
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
|
||||
| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
|
||||
| ECR-007 | L2 | Done (Final Approval) | Live Structure · `276481e` · LOOP-RUN-005 |
|
||||
| ECR-007 | L2 | Done (Final Approval) | Live Structure · 待合入 `dev` |
|
||||
| ECR-008 | L3 | Done (Reviewed) | chart_tv 拆分 |
|
||||
| ECR-009 | L2 | Implementing | `/wyckoff_crypto` · D/W/M |
|
||||
|
||||
## Notes
|
||||
|
||||
- ECR-007:**FINAL_APPROVAL** · gate PASS · Confirmed ≠ Live ≠ execution
|
||||
- 归档:`docs/runs/LOOP-RUN-005/`
|
||||
- ECR-009:打开 http://localhost:8128/wyckoff_crypto ;默认组合 `8h/4h/1h`,可下拉切 `1d/1w/1M` 或「添加组合」
|
||||
- 可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流;月线仍由日线 UTC 聚合
|
||||
- 未请求新 system tag
|
||||
- 分支 `feature/ECR-007-wyckoff-live-structure` 待 PR → `dev`
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ecr: ECR-008
|
||||
owner: idle
|
||||
phase: done
|
||||
updated: 2026-08-07
|
||||
change_level: L3
|
||||
decision: Approve
|
||||
notes: chart_tv split Reviewed · node --check PASS · browser smoke pending Human
|
||||
@@ -0,0 +1,5 @@
|
||||
ecr: ECR-009
|
||||
owner: engineer
|
||||
phase: implementing
|
||||
updated: 2026-08-07
|
||||
notes: crypto wyckoff screener · D/W/M · 24/7 tip
|
||||
@@ -0,0 +1,15 @@
|
||||
task_id: ECR-008
|
||||
title: 拆分主站 chart_tv.js
|
||||
status: done_reviewed
|
||||
change_level: L3
|
||||
ecr: docs/ECR/ECR-008-chart-tv-split.md
|
||||
engineering_spec: docs/ENGINEERING_SPEC/ECR-008-chart-tv-split.md
|
||||
handoff: docs/HANDOFF/ECR-008-architect-to-engineer.md
|
||||
code_review: docs/CODE_REVIEW/ECR-008.md
|
||||
decision: Approve
|
||||
gates:
|
||||
- node --check all chart_tv*.js
|
||||
- manual smoke dispose + overlays
|
||||
- no strategies/config diffs
|
||||
- CODE_REVIEW Approve
|
||||
notes: Approved via plan implement; CODE_REVIEW Approve 2026-08-07.
|
||||
@@ -0,0 +1,34 @@
|
||||
# TEST_REPORT — ECR-008
|
||||
|
||||
**Date:** 2026-08-07
|
||||
**ECR:** ECR-008
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
node --check web/static/js/app/chart_tv_lifecycle.js
|
||||
node --check web/static/js/app/chart_tv_shell.js
|
||||
node --check web/static/js/app/chart_tv_indicators.js
|
||||
node --check web/static/js/app/chart_tv_chan.js
|
||||
node --check web/static/js/app/chart_tv_overlays.js
|
||||
node --check web/static/js/app/chart_tv_finalize.js
|
||||
node --check web/static/js/app/chart_tv.js
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
```text
|
||||
ALL_CHECK_OK(2026-08-07)
|
||||
```
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
| Case | Result |
|
||||
|------|--------|
|
||||
| 符号导出:`disposeTradingViewCharts` / `initTradingView` / 各 `chartTv*` | PASS(全局函数存在于对应文件) |
|
||||
| 语法 | PASS |
|
||||
| 浏览器:首屏 / 自动刷新 dispose / 威科夫 / 三周期元素 | 待 Human 硬刷新 `?v=20260807f` 目测 |
|
||||
|
||||
## Design Compliance
|
||||
|
||||
PASS — 无打包器;行为冻结搬移;API/strategies 未改。
|
||||
@@ -0,0 +1,25 @@
|
||||
# TEST_REPORT — ECR-009
|
||||
|
||||
**Date:** 2026-08-07
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. python -m pytest tests/test_crypto_wyckoff_decision.py -q
|
||||
CRYPTO_WYCKOFF_DISABLE=1 PYTHONPATH=.:web python -m pytest web/tests/test_wyckoff_crypto_routes.py -q
|
||||
# Manual / live:
|
||||
# cd web && CRYPTO_WYCKOFF_MAX_SYMBOLS=5 PYTHONPATH=..:. python app.py
|
||||
# curl -I http://127.0.0.1:8128/wyckoff_crypto
|
||||
```
|
||||
|
||||
## Result
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| Decision gate unit | 2 passed |
|
||||
| Route page/meta/scan | 补测(本文件) |
|
||||
| Live HTTP 2026-08-07 | `GET /wyckoff_crypto` → 200(需先启动 web) |
|
||||
|
||||
## Note
|
||||
|
||||
此前冒烟只做了引擎 tick,**未**在交付前保持 Flask 常驻并给浏览器 URL——属 ESS 测试缺口,已补路由测试与本报告。
|
||||
@@ -53,3 +53,19 @@
|
||||
| ECR-007 | execution 仅 confirmed | BD-2026-007 | `execution_signal_from_wyckoff` | live-only → None | 276481e |
|
||||
| ECR-007 | Summary Confirmed/Live 分区 | PRODUCT | `ui.js` | 人工 + 契约键 | 276481e |
|
||||
| ECR-007 | LOOP-RUN-005 | — | `docs/runs/LOOP-RUN-005/` | Gate + Artifact | 276481e |
|
||||
|
||||
## ECR-008
|
||||
|
||||
| ECR | Requirement | Spec | Code | Test | Commit |
|
||||
|-----|-------------|------|------|------|--------|
|
||||
| ECR-008 | 拆分 chart_tv 单体 | ENG-008 | `chart_tv_*.js` + 薄门面 | `node --check` | dbb6202 |
|
||||
| ECR-008 | 对外 API 不变 | ENG-008 | `initTradingView` / `disposeTradingViewCharts` | ui.js 调用点 | dbb6202 |
|
||||
| ECR-008 | 无打包器 | PROFILE | `index.html` script 顺序 | 人工 | dbb6202 |
|
||||
|
||||
## ECR-009
|
||||
|
||||
| ECR | Requirement | Spec | Code | Test | Commit |
|
||||
|-----|-------------|------|------|------|--------|
|
||||
| ECR-009 | Crypto D/W/M screener 独立页 | ENG-009 | `crypto_wyckoff/` + `/wyckoff_crypto` | `test_crypto_wyckoff_decision` | ec08de0 |
|
||||
| ECR-009 | 月线本地聚合 | ENG-009 | `io.rebuild_monthly_from_daily` | smoke tip | ec08de0 |
|
||||
| ECR-009 | 不碰 analyze/缠论 | ECR-009 Forbidden | 新 API 前缀 | 人工 | ec08de0 |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Unit tests for TF combo validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from crypto_wyckoff.combos import (
|
||||
add_combo,
|
||||
delete_combo,
|
||||
get_combo,
|
||||
list_combos,
|
||||
validate_combo,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_default_is_h8_4_1():
|
||||
c = get_combo(None)
|
||||
assert c["id"] == "h8_4_1"
|
||||
assert (c["high"], c["mid"], c["low"]) == ("8h", "4h", "1h")
|
||||
|
||||
|
||||
def test_validate_order():
|
||||
assert validate_combo("8h", "4h", "1h") is None
|
||||
assert validate_combo("1h", "4h", "8h") is not None
|
||||
assert validate_combo("8h", "8h", "1h") is not None
|
||||
|
||||
|
||||
def test_list_includes_dwm():
|
||||
ids = {c["id"] for c in list_combos()}
|
||||
assert "h8_4_1" in ids
|
||||
assert "d_w_m" in ids
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Decision engine MTF gate tests (ported semantics)."""
|
||||
|
||||
from crypto_wyckoff.domain_models import (
|
||||
DecisionSignal,
|
||||
EngineResult,
|
||||
WyckoffCycle,
|
||||
WyckoffEvent,
|
||||
WyckoffPhase,
|
||||
)
|
||||
from crypto_wyckoff.decision import DecisionEngine
|
||||
|
||||
|
||||
def _er(name, payload, score=70, confidence=70):
|
||||
return EngineResult(name=name, score=score, confidence=confidence, payload=payload)
|
||||
|
||||
|
||||
def test_monthly_distribution_daily_spring_is_watch():
|
||||
eng = DecisionEngine()
|
||||
monthly = _er("Cycle", {"cycle": WyckoffCycle.DISTRIBUTION.value, "trend_score": 40}, score=40)
|
||||
weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 70}, score=70)
|
||||
weekly_p = _er(
|
||||
"Phase",
|
||||
{"phase": WyckoffPhase.B.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 65},
|
||||
score=65,
|
||||
)
|
||||
weekly_e = _er("Event", {"current_event": WyckoffEvent.ST.value, "recent_events": ["SC", "AR", "ST"]}, score=60)
|
||||
daily_e = _er(
|
||||
"Event",
|
||||
{"current_event": WyckoffEvent.SPRING.value, "recent_events": ["SC", "AR", "ST", "Spring"], "entry_score": 92},
|
||||
score=92,
|
||||
confidence=92,
|
||||
)
|
||||
daily_s = _er("Signal", {"signal_label": "Spring", "current_event": "Spring"}, confidence=92, score=92)
|
||||
out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s)
|
||||
assert out.payload["decision_signal"] == DecisionSignal.WATCH.value
|
||||
assert out.payload["d_event"] == WyckoffEvent.SPRING.value
|
||||
|
||||
|
||||
def test_bull_alignment_can_strong_buy():
|
||||
eng = DecisionEngine()
|
||||
monthly = _er("Cycle", {"cycle": WyckoffCycle.MARKUP.value, "trend_score": 90}, score=90, confidence=90)
|
||||
weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 85}, score=85, confidence=85)
|
||||
weekly_p = _er(
|
||||
"Phase",
|
||||
{"phase": WyckoffPhase.D.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 88},
|
||||
score=88,
|
||||
confidence=88,
|
||||
)
|
||||
weekly_e = _er("Event", {"current_event": WyckoffEvent.SOS.value, "recent_events": ["SOS"]}, score=85, confidence=85)
|
||||
daily_e = _er(
|
||||
"Event",
|
||||
{
|
||||
"current_event": WyckoffEvent.SPRING.value,
|
||||
"recent_events": ["SC", "AR", "ST", "Spring", "Test"],
|
||||
"active_events": ["SC", "AR", "ST", "Spring"],
|
||||
"entry_score": 92,
|
||||
},
|
||||
score=92,
|
||||
confidence=92,
|
||||
)
|
||||
daily_s = _er("Signal", {"signal_label": "Spring"}, confidence=92, score=92)
|
||||
out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s)
|
||||
assert out.payload["decision_signal"] in (
|
||||
DecisionSignal.STRONG_BUY.value,
|
||||
DecisionSignal.BUY.value,
|
||||
)
|
||||
@@ -2,6 +2,9 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services.runtime import * # noqa: F403
|
||||
from services import runtime as R
|
||||
# import * 不会带出下划线私有名;结构区缓存需显式导入
|
||||
from services.runtime.state import _zone_cache
|
||||
from services.runtime.timeframes import _zone_cache_ttl
|
||||
|
||||
bp = Blueprint("analyze", __name__)
|
||||
|
||||
@@ -785,3 +788,77 @@ def analyze():
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _serialize_kl_tail(df, limit: int):
|
||||
"""只序列化最近 limit 根,供自动刷新增量合并。"""
|
||||
if df is None or getattr(df, "empty", True):
|
||||
return []
|
||||
tail = df.tail(limit)
|
||||
clean = clean_dataframe_for_json(tail)
|
||||
records = clean.to_dict("records")
|
||||
for row in records:
|
||||
d = row.get("date")
|
||||
if hasattr(d, "isoformat"):
|
||||
try:
|
||||
row["date"] = d.isoformat()
|
||||
except Exception:
|
||||
row["date"] = str(d)
|
||||
# timestamp 统一成 int ms,便于前端按 key 合并
|
||||
ts = row.get("timestamp")
|
||||
if ts is not None:
|
||||
try:
|
||||
row["timestamp"] = int(ts)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif hasattr(d, "timestamp"):
|
||||
try:
|
||||
row["timestamp"] = int(d.timestamp() * 1000)
|
||||
except Exception:
|
||||
pass
|
||||
return records
|
||||
|
||||
|
||||
@bp.route("/api/klines/recent")
|
||||
def klines_recent():
|
||||
"""轻量拉取最近 N 根 K 线(不做缠论/威科夫),供主站自动刷新增量。"""
|
||||
symbol = (request.args.get("symbol") or "").strip()
|
||||
if not symbol:
|
||||
return jsonify({"error": "交易对不能为空"}), 400
|
||||
|
||||
timeframe = request.args.get("timeframe", "5m")
|
||||
try:
|
||||
limit = int(request.args.get("limit", 2))
|
||||
except (TypeError, ValueError):
|
||||
limit = 2
|
||||
limit = max(1, min(limit, 20))
|
||||
|
||||
element_timeframe = request.args.get("element_timeframe") or None
|
||||
sub_sub_timeframe = request.args.get("sub_sub_timeframe") or None
|
||||
|
||||
# 只取尾部:不传 start/end,避免全量窗口回拉
|
||||
df = get_kl_data(symbol, timeframe, limit=limit)
|
||||
if df is None:
|
||||
return jsonify({"error": "获取数据失败"}), 502
|
||||
if len(df) == 0:
|
||||
return jsonify({"error": "没有数据"}), 404
|
||||
|
||||
result = {
|
||||
"partial": True,
|
||||
"symbol": symbol,
|
||||
"timeframe": timeframe,
|
||||
"limit": limit,
|
||||
"kline_data": _serialize_kl_tail(df, limit),
|
||||
}
|
||||
|
||||
if element_timeframe:
|
||||
edf = get_kl_data(symbol, element_timeframe, limit=limit)
|
||||
result["element_timeframe"] = element_timeframe
|
||||
result["element_kline_data"] = _serialize_kl_tail(edf, limit) if edf is not None else []
|
||||
|
||||
if sub_sub_timeframe:
|
||||
sdf = get_kl_data(symbol, sub_sub_timeframe, limit=limit)
|
||||
result["sub_sub_timeframe"] = sub_sub_timeframe
|
||||
result["sub_sub_kline_data"] = _serialize_kl_tail(sdf, limit) if sdf is not None else []
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Crypto Wyckoff Screener API + page (independent of /api/analyze)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
|
||||
from crypto_wyckoff.combos import (
|
||||
ALLOWED_TFS,
|
||||
add_combo,
|
||||
delete_combo,
|
||||
get_combo,
|
||||
list_combos,
|
||||
)
|
||||
from crypto_wyckoff.domain_models import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase
|
||||
from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler
|
||||
from crypto_wyckoff import store as wyckoff_store
|
||||
from crypto_wyckoff.symbols_cn import display_name_cn, symbol_name_map
|
||||
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
|
||||
|
||||
bp = Blueprint("wyckoff_crypto", __name__)
|
||||
|
||||
_scheduler_started = False
|
||||
_sched_lock = threading.Lock()
|
||||
|
||||
|
||||
def ensure_scheduler() -> None:
|
||||
global _scheduler_started
|
||||
with _sched_lock:
|
||||
if _scheduler_started:
|
||||
return
|
||||
if os.environ.get("CRYPTO_WYCKOFF_DISABLE", "").lower() in ("1", "true", "yes"):
|
||||
return
|
||||
interval = int(os.environ.get("CRYPTO_WYCKOFF_INTERVAL", "60"))
|
||||
max_sym = os.environ.get("CRYPTO_WYCKOFF_MAX_SYMBOLS")
|
||||
max_symbols = int(max_sym) if max_sym else None
|
||||
start_scheduler(interval_sec=interval, max_symbols=max_symbols)
|
||||
_scheduler_started = True
|
||||
|
||||
|
||||
def _safe_int(raw, default: int, *, lo: int | None = None, hi: int | None = None) -> int:
|
||||
try:
|
||||
v = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
v = default
|
||||
if lo is not None:
|
||||
v = max(lo, v)
|
||||
if hi is not None:
|
||||
v = min(hi, v)
|
||||
return v
|
||||
|
||||
|
||||
@bp.route("/wyckoff_crypto")
|
||||
def page():
|
||||
ensure_scheduler()
|
||||
return render_template("wyckoff_crypto.html")
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/meta")
|
||||
def meta():
|
||||
ensure_scheduler()
|
||||
combo_id = request.args.get("combo_id")
|
||||
combo = get_combo(combo_id)
|
||||
latest = wyckoff_store.latest_trade_date(combo["id"])
|
||||
return jsonify(
|
||||
{
|
||||
"architecture_version": ARCHITECTURE_VERSION,
|
||||
"engine_version": WYCKOFF_ENGINE_VERSION,
|
||||
"latest_trade_date": latest,
|
||||
"scan_count": wyckoff_store.count_for_date(latest, combo["id"]),
|
||||
"cycles": [c.value for c in WyckoffCycle],
|
||||
"phases": [p.value for p in WyckoffPhase],
|
||||
"events": [e.value for e in WyckoffEvent],
|
||||
"decision_signals": [s.value for s in DecisionSignal],
|
||||
"timezone": "Asia/Shanghai",
|
||||
"utc_offset": "+08:00",
|
||||
"timeframes": [combo["low"], combo["mid"], combo["high"]],
|
||||
"combo": combo,
|
||||
"combos": list_combos(),
|
||||
"allowed_tfs": list(ALLOWED_TFS),
|
||||
"symbol_names": symbol_name_map(),
|
||||
"default_symbol": "BTC/USDT:USDT",
|
||||
"status": get_status(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/combos", methods=["GET"])
|
||||
def combos_list():
|
||||
ensure_scheduler()
|
||||
return jsonify({"combos": list_combos(), "allowed_tfs": list(ALLOWED_TFS)})
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/combos", methods=["POST"])
|
||||
def combos_add():
|
||||
ensure_scheduler()
|
||||
body = request.get_json(silent=True) or {}
|
||||
high = (body.get("high") or request.args.get("high") or "").strip()
|
||||
mid = (body.get("mid") or request.args.get("mid") or "").strip()
|
||||
low = (body.get("low") or request.args.get("low") or "").strip()
|
||||
label = (body.get("label") or request.args.get("label") or "").strip() or None
|
||||
try:
|
||||
row = add_combo(high, mid, low, label=label)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
return jsonify({"ok": True, "combo": row, "combos": list_combos()})
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/combos/<combo_id>", methods=["DELETE"])
|
||||
def combos_delete(combo_id: str):
|
||||
ensure_scheduler()
|
||||
try:
|
||||
removed = delete_combo(combo_id)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
if not removed:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify({"ok": True, "combos": list_combos()})
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/status")
|
||||
def status():
|
||||
ensure_scheduler()
|
||||
return jsonify(get_status())
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/scan")
|
||||
def scan():
|
||||
ensure_scheduler()
|
||||
combo = get_combo(request.args.get("combo_id"))
|
||||
rows = wyckoff_store.query_scan(
|
||||
trade_date=request.args.get("trade_date"),
|
||||
combo_id=combo["id"],
|
||||
m_cycle=request.args.get("m_cycle"),
|
||||
w_phase=request.args.get("w_phase"),
|
||||
d_event=request.args.get("d_event"),
|
||||
decision_signal=request.args.get("decision_signal"),
|
||||
min_overall_score=_float_or_none(request.args.get("min_overall_score")),
|
||||
min_alignment=_float_or_none(request.args.get("min_alignment")),
|
||||
sort=request.args.get("sort") or "overall_score",
|
||||
limit=_safe_int(request.args.get("limit"), 100, lo=1, hi=500),
|
||||
offset=_safe_int(request.args.get("offset"), 0, lo=0),
|
||||
)
|
||||
for row in rows:
|
||||
row["name"] = display_name_cn(row.get("ts_code") or "")
|
||||
return jsonify({"rows": rows, "count": len(rows), "combo": combo})
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/symbol/<path:symbol>")
|
||||
def symbol_detail(symbol: str):
|
||||
ensure_scheduler()
|
||||
combo = get_combo(request.args.get("combo_id"))
|
||||
row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"), combo["id"])
|
||||
if not row:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
return jsonify(row)
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/tick", methods=["POST"])
|
||||
def manual_tick():
|
||||
"""Manual one-shot tick (debug). Optional JSON/query max_symbols."""
|
||||
ensure_scheduler()
|
||||
body = request.get_json(silent=True) or {}
|
||||
max_sym = request.args.get("max_symbols") or body.get("max_symbols")
|
||||
max_symbols = int(max_sym) if max_sym not in (None, "") else None
|
||||
|
||||
def _job():
|
||||
try:
|
||||
run_tick(max_symbols=max_symbols, force_rescan=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_job, daemon=True).start()
|
||||
return jsonify({"ok": True, "started": True})
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/klines")
|
||||
def klines():
|
||||
"""Local cached OHLCV for chart (combo TFs)."""
|
||||
ensure_scheduler()
|
||||
from crypto_wyckoff.io import is_intraday_tf, load_bars_with_ts
|
||||
|
||||
symbol = request.args.get("symbol") or ""
|
||||
combo = get_combo(request.args.get("combo_id"))
|
||||
allowed = {combo["low"], combo["mid"], combo["high"]}
|
||||
tf = request.args.get("tf") or combo["low"]
|
||||
limit = _safe_int(request.args.get("limit"), 180, lo=1, hi=500)
|
||||
if not symbol or tf not in allowed:
|
||||
return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400
|
||||
items = load_bars_with_ts(symbol, tf, lookback=limit)
|
||||
return jsonify({
|
||||
"items": items,
|
||||
"symbol": symbol,
|
||||
"tf": tf,
|
||||
"count": len(items),
|
||||
"intraday": is_intraday_tf(tf),
|
||||
"combo": combo,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/api/wyckoff_crypto/overlay")
|
||||
def overlay():
|
||||
"""Phase/event overlay for chart."""
|
||||
ensure_scheduler()
|
||||
from crypto_wyckoff.annotate import annotate_symbol
|
||||
|
||||
symbol = request.args.get("symbol") or ""
|
||||
combo = get_combo(request.args.get("combo_id"))
|
||||
allowed = {combo["low"], combo["mid"], combo["high"]}
|
||||
tf = request.args.get("tf") or combo["low"]
|
||||
bars = _safe_int(request.args.get("bars"), 180, lo=20, hi=400)
|
||||
if not symbol or tf not in allowed:
|
||||
return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400
|
||||
try:
|
||||
data = annotate_symbol(symbol, freq=tf, lookback=bars, combo_id=combo["id"])
|
||||
except Exception:
|
||||
return jsonify({
|
||||
"error": "overlay_failed",
|
||||
"phases": [],
|
||||
"events": [],
|
||||
"levels": {},
|
||||
"zones": [],
|
||||
"combo_id": combo["id"],
|
||||
}), 500
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
def _float_or_none(v):
|
||||
if v in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -15,6 +15,7 @@ from api.analyze import bp as analyze_bp
|
||||
from api.pages import bp as pages_bp
|
||||
from api.symbols import bp as symbols_bp
|
||||
from api.trend import bp as trend_bp
|
||||
from api.wyckoff_crypto import bp as wyckoff_crypto_bp, ensure_scheduler
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
@@ -23,6 +24,12 @@ def create_app() -> Flask:
|
||||
app.register_blueprint(analyze_bp)
|
||||
app.register_blueprint(symbols_bp)
|
||||
app.register_blueprint(trend_bp)
|
||||
app.register_blueprint(wyckoff_crypto_bp)
|
||||
# Start crypto wyckoff tip scheduler (daemon); disable with CRYPTO_WYCKOFF_DISABLE=1
|
||||
try:
|
||||
ensure_scheduler()
|
||||
except Exception:
|
||||
pass
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ def analyze_chan(df, symbol=None, timeframe=None):
|
||||
zs_list = chan.calculate_seg_zs(seg_list)
|
||||
# 计算笔中枢(BI中枢)并拍平成列表
|
||||
|
||||
#bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
|
||||
bi_zs_list = chan.cal_bi_zs(seg_list)
|
||||
bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
|
||||
#bi_zs_list = chan.cal_bi_zs(seg_list)
|
||||
bsp_list = []
|
||||
if len(bi_zs_list) > 0:
|
||||
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
|
||||
|
||||
@@ -94,19 +94,19 @@ def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
|
||||
def compute_timeframe_defaults(labels_ordered):
|
||||
"""
|
||||
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||
默认偏好:主 4h、次 2h、次次 1h(威科夫与结构在小时级更可读)。
|
||||
默认偏好:主 4h、次 1h、次次 15m。
|
||||
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||
"""
|
||||
if not labels_ordered:
|
||||
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||
timeframe_keys = list(labels_ordered.keys())
|
||||
preferred_main = next((tf for tf in ['4h', '2h', '1h'] if tf in labels_ordered), None)
|
||||
preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None)
|
||||
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||
if default_main not in labels_ordered and timeframe_keys:
|
||||
default_main = timeframe_keys[0]
|
||||
|
||||
default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys)
|
||||
default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys)
|
||||
default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys)
|
||||
default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys)
|
||||
|
||||
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||
|
||||
|
||||
+106
-19
@@ -9,11 +9,26 @@ function updateTradingViewData() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前的可视范围
|
||||
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳)
|
||||
const frozen = window._preserveViewOnRefresh;
|
||||
const oldBarCount = window._preserveViewBarCount || 0;
|
||||
let savedScrollPosition = null;
|
||||
if (tvWidget.mainChart) {
|
||||
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
|
||||
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
||||
const ts = tvWidget.mainChart.timeScale();
|
||||
if (frozen) {
|
||||
tvWidget.state.visibleRange = frozen.visibleRange;
|
||||
tvWidget.state.logicalRange = frozen.logicalRange;
|
||||
savedScrollPosition = (typeof frozen.scrollPosition === 'number') ? frozen.scrollPosition : null;
|
||||
} else {
|
||||
tvWidget.state.visibleRange = ts.getVisibleRange();
|
||||
tvWidget.state.logicalRange = ts.getVisibleLogicalRange();
|
||||
try {
|
||||
savedScrollPosition = ts.scrollPosition ? ts.scrollPosition() : null;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
window._preserveViewOnRefresh = null;
|
||||
window._preserveViewBarCount = 0;
|
||||
|
||||
// 检查是否显示原始K线
|
||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||
@@ -71,6 +86,24 @@ function updateTradingViewData() {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// LWC 不允许 null/NaN;时间用整秒,避免 Line 渲染抛 Value is null
|
||||
candles = (candles || []).filter(function (c) {
|
||||
return c && c.time != null &&
|
||||
isFinite(Number(c.open)) && isFinite(Number(c.high)) &&
|
||||
isFinite(Number(c.low)) && isFinite(Number(c.close));
|
||||
}).map(function (c) {
|
||||
return {
|
||||
time: Math.floor(Number(c.time)),
|
||||
open: Number(c.open),
|
||||
high: Number(c.high),
|
||||
low: Number(c.low),
|
||||
close: Number(c.close)
|
||||
};
|
||||
});
|
||||
|
||||
const newBarCount = candles.length;
|
||||
const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0;
|
||||
|
||||
// 更新主系列数据(根据klineType)
|
||||
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||||
@@ -270,23 +303,77 @@ function updateTradingViewData() {
|
||||
// 更新EMA52显示
|
||||
updateEMA52Display(currentData);
|
||||
|
||||
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
|
||||
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。
|
||||
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
|
||||
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
|
||||
if (tvWidget.mainChart) {
|
||||
if (tvWidget.state.visibleRange) {
|
||||
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
|
||||
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||
} else if (tvWidget.state.logicalRange) {
|
||||
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
|
||||
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||
}
|
||||
const charts = [
|
||||
tvWidget.mainChart,
|
||||
tvWidget.volumeChart,
|
||||
tvWidget.atrChart,
|
||||
tvWidget.macdChart,
|
||||
tvWidget.chanMacdChart
|
||||
].filter(Boolean);
|
||||
|
||||
const vr = tvWidget.state.visibleRange;
|
||||
const lr = tvWidget.state.logicalRange;
|
||||
const savedScroll = savedScrollPosition;
|
||||
|
||||
const applyPosition = function (tag) {
|
||||
let ok = false;
|
||||
if (lr && lr.from !== undefined && lr.to !== undefined && newBarCount > 0) {
|
||||
// 视窗超出当前 K 线数量时,LWC Line 绘制会抛 Value is null
|
||||
const span = Math.max(1, lr.to - lr.from);
|
||||
let to = lr.to;
|
||||
let from = lr.from;
|
||||
const maxTo = newBarCount - 1 + 8;
|
||||
if (to > maxTo) {
|
||||
to = maxTo;
|
||||
from = to - span;
|
||||
}
|
||||
if (from < -8) {
|
||||
from = -8;
|
||||
to = from + span;
|
||||
}
|
||||
const clamped = { from: from, to: to };
|
||||
charts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleLogicalRange(clamped);
|
||||
ok = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
if (ok) console.log('🔄 恢复位置 logical' + (tag || '') + ':', clamped);
|
||||
}
|
||||
if (!ok && vr && vr.from !== undefined && vr.to !== undefined) {
|
||||
charts.forEach(c => {
|
||||
try {
|
||||
c.timeScale().setVisibleRange(vr);
|
||||
ok = true;
|
||||
} catch (e) {}
|
||||
});
|
||||
if (ok) console.log('🔄 恢复位置 time' + (tag || '') + ':', vr);
|
||||
}
|
||||
if (!ok && typeof savedScroll === 'number') {
|
||||
const pos = savedScroll + (barDelta || 0);
|
||||
charts.forEach(c => {
|
||||
try { c.timeScale().scrollToPosition(pos, false); } catch (e) {}
|
||||
});
|
||||
console.log('🔄 恢复位置 scroll' + (tag || '') + ':', pos);
|
||||
}
|
||||
};
|
||||
|
||||
applyPosition('');
|
||||
setTimeout(function () { applyPosition('@0'); }, 0);
|
||||
setTimeout(function () { applyPosition('@50'); }, 50);
|
||||
// 增量 setData 常不触发可见时间范围回调,但价格轴会变:补刷分型竖边
|
||||
var bumpFxVert = function () {
|
||||
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
|
||||
window._redrawFxBoxVerticalOverlay();
|
||||
}
|
||||
};
|
||||
bumpFxVert();
|
||||
setTimeout(bumpFxVert, 0);
|
||||
setTimeout(bumpFxVert, 50);
|
||||
}
|
||||
|
||||
console.log('增量更新图表完成');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user