Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41151ae88a |
@@ -44,6 +44,3 @@ data_provider/._config.json
|
|||||||
# ESS gate / engineering-loop working dirs(归档进 docs/runs/)
|
# ESS gate / engineering-loop working dirs(归档进 docs/runs/)
|
||||||
.gates/
|
.gates/
|
||||||
loop/
|
loop/
|
||||||
|
|
||||||
# Crypto Wyckoff Screener local cache
|
|
||||||
data/crypto_wyckoff/
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# 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-...
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
data/
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
"""
|
|
||||||
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"
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""Expectancy Engine — Signal tracking, Bayesian inference, time decay."""
|
|
||||||
from .tracker import SignalTracker
|
|
||||||
from .decay import TimeDecay
|
|
||||||
from .engine import BayesianExpectancyEngine, SufficiencyGuard
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
"""
|
|
||||||
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}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Data fetchers — L0 raw data acquisition."""
|
|
||||||
from .base import BaseFetcher
|
|
||||||
from .ohlcv import OHLCVFetcher
|
|
||||||
from .breadth import BreadthFetcher
|
|
||||||
from .derivatives import DerivativesFetcher
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""
|
|
||||||
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."""
|
|
||||||
...
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Scoring engine — L1 factor computation."""
|
|
||||||
from .base import BaseScorer
|
|
||||||
from .price_structure import PriceStructureScorer
|
|
||||||
from .breadth_scorer import BreadthScorer
|
|
||||||
from .oi_matrix import OIMatrixScorer
|
|
||||||
from .volatility_regime import VolatilityRegimeScorer
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""
|
|
||||||
scoring/base.py — Abstract base class for all scoring modules.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from datetime import date as Date
|
|
||||||
from typing import Optional
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
from models import FactorScore
|
|
||||||
from config import config
|
|
||||||
|
|
||||||
|
|
||||||
class BaseScorer(ABC):
|
|
||||||
"""Abstract base for all factor scorers."""
|
|
||||||
|
|
||||||
def __init__(self, db_path: Optional[str] = None):
|
|
||||||
self.db_path = db_path or config.db_path
|
|
||||||
|
|
||||||
def get_connection(self) -> sqlite3.Connection:
|
|
||||||
conn = sqlite3.connect(self.db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
return conn
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def compute(self, target_date: Date) -> FactorScore:
|
|
||||||
"""Compute factor score for a given date from database records."""
|
|
||||||
...
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
"""
|
|
||||||
scoring/breadth_scorer.py — Market Breadth Score.
|
|
||||||
|
|
||||||
The first citizen of the system. Diffusion always leads price.
|
|
||||||
|
|
||||||
Multi-tier: Top20 / Top30 / Top50.
|
|
||||||
Quantile-based bucketing: EXTREME / STRONG / NORMAL / WEAK / PANIC.
|
|
||||||
|
|
||||||
4 sub-indicators (equal weight):
|
|
||||||
1. Advance/Decline ratio (30%)
|
|
||||||
2. % above EMA20 (35%)
|
|
||||||
3. New 20d highs (20%)
|
|
||||||
4. BTC Dominance change (15%, inverted)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date as Date
|
|
||||||
import sqlite3
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from .base import BaseScorer
|
|
||||||
from .constants import (
|
|
||||||
BREADTH_W_ADVANCE, BREADTH_W_EMA20, BREADTH_W_NEW_HIGHS, BREADTH_W_BTC_DOM,
|
|
||||||
)
|
|
||||||
from models import FactorScore, BreadthScore, BreadthBucket, MacroDirection
|
|
||||||
from config import config
|
|
||||||
|
|
||||||
|
|
||||||
class BreadthScorer(BaseScorer):
|
|
||||||
"""Scores market breadth with quantile-based bucketing."""
|
|
||||||
|
|
||||||
def compute(self, target_date: Date) -> BreadthScore:
|
|
||||||
conn = self.get_connection()
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM breadth_daily WHERE date = ?", (str(target_date),)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
return BreadthScore(
|
|
||||||
name="Breadth",
|
|
||||||
score=50.0,
|
|
||||||
label="No Data",
|
|
||||||
breadth_bucket=BreadthBucket.NORMAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
row = dict(row)
|
|
||||||
total = row.get("total_tracked", 50) or 50
|
|
||||||
|
|
||||||
# 1. Advance/Decline ratio
|
|
||||||
advance = row.get("advance_top50", 0) or 0
|
|
||||||
decline = row.get("decline_top50", 0) or 0
|
|
||||||
if advance + decline > 0:
|
|
||||||
ad_ratio = advance / (advance + decline)
|
|
||||||
else:
|
|
||||||
ad_ratio = 0.5
|
|
||||||
ad_score = ad_ratio * 100
|
|
||||||
|
|
||||||
# 2. % above EMA20
|
|
||||||
above_ema = row.get("above_ema20_top50", 0) or 0
|
|
||||||
ema_pct = above_ema / total if total > 0 else 0.5
|
|
||||||
ema_score = ema_pct * 100
|
|
||||||
|
|
||||||
# 3. New highs
|
|
||||||
new_highs = row.get("new_highs_20d_top50", 0) or 0
|
|
||||||
highs_pct = new_highs / total if total > 0 else 0
|
|
||||||
highs_score = highs_pct * 100
|
|
||||||
|
|
||||||
# 4. BTC Dominance (inverted: BTC.D up = bearish for alts)
|
|
||||||
btc_dom = row.get("btc_dominance")
|
|
||||||
btc_dom_score = 50.0 # neutral default
|
|
||||||
if btc_dom is not None:
|
|
||||||
# Placeholder — needs historical comparison
|
|
||||||
btc_dom_score = 50.0
|
|
||||||
|
|
||||||
# Weighted aggregate
|
|
||||||
score = (
|
|
||||||
ad_score * BREADTH_W_ADVANCE +
|
|
||||||
ema_score * BREADTH_W_EMA20 +
|
|
||||||
highs_score * BREADTH_W_NEW_HIGHS +
|
|
||||||
btc_dom_score * BREADTH_W_BTC_DOM
|
|
||||||
)
|
|
||||||
|
|
||||||
# Multi-tier breadth
|
|
||||||
b20 = self._compute_tier_breadth(row, 20, total)
|
|
||||||
b30 = self._compute_tier_breadth(row, 30, total)
|
|
||||||
b50 = score # Top50 = full score
|
|
||||||
|
|
||||||
# Quantile bucket
|
|
||||||
bucket = self._assign_bucket(score)
|
|
||||||
|
|
||||||
# Divergence
|
|
||||||
divergence = b20 - b50
|
|
||||||
|
|
||||||
# Direction
|
|
||||||
if score >= 60:
|
|
||||||
direction = MacroDirection.BULLISH
|
|
||||||
elif score <= 40:
|
|
||||||
direction = MacroDirection.BEARISH
|
|
||||||
else:
|
|
||||||
direction = MacroDirection.NEUTRAL
|
|
||||||
|
|
||||||
# Narrative
|
|
||||||
narrative = self._build_narrative(bucket, divergence, ema_pct, ad_ratio)
|
|
||||||
|
|
||||||
return BreadthScore(
|
|
||||||
name="Breadth",
|
|
||||||
score=round(score, 1),
|
|
||||||
label=bucket.value,
|
|
||||||
direction=direction,
|
|
||||||
breadth_top20=round(b20, 1),
|
|
||||||
breadth_top30=round(b30, 1),
|
|
||||||
breadth_top50=round(b50, 1),
|
|
||||||
breadth_bucket=bucket,
|
|
||||||
breadth_divergence=round(divergence, 1),
|
|
||||||
advance_pct_top50=round(ad_ratio * 100, 1),
|
|
||||||
above_ema20_pct_top50=round(ema_pct * 100, 1),
|
|
||||||
new_highs_top50=new_highs,
|
|
||||||
sub_scores={
|
|
||||||
"advance_decline": round(ad_score, 1),
|
|
||||||
"above_ema20": round(ema_score, 1),
|
|
||||||
"new_highs": round(highs_score, 1),
|
|
||||||
"btc_dominance": round(btc_dom_score, 1),
|
|
||||||
},
|
|
||||||
narrative=narrative,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _compute_tier_breadth(self, row: dict, tier: int, total: int) -> float:
|
|
||||||
"""Compute breadth score for a specific tier (Top20 or Top30)."""
|
|
||||||
advance = row.get(f"advance_top{tier}", 0) or 0
|
|
||||||
above_ema = row.get(f"above_ema20_top{tier}", 0) or 0
|
|
||||||
new_highs = row.get(f"new_highs_20d_top{tier}", 0) or 0
|
|
||||||
|
|
||||||
tier_actual = min(tier, total)
|
|
||||||
if tier_actual == 0:
|
|
||||||
return 50.0
|
|
||||||
|
|
||||||
ad_ratio = advance / tier_actual if tier_actual > 0 else 0.5
|
|
||||||
ema_ratio = above_ema / tier_actual if tier_actual > 0 else 0.5
|
|
||||||
highs_ratio = new_highs / tier_actual if tier_actual > 0 else 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
ad_ratio * 100 * BREADTH_W_ADVANCE +
|
|
||||||
ema_ratio * 100 * BREADTH_W_EMA20 +
|
|
||||||
highs_ratio * 100 * BREADTH_W_NEW_HIGHS +
|
|
||||||
50 * BREADTH_W_BTC_DOM # neutral for BTC.D
|
|
||||||
)
|
|
||||||
|
|
||||||
def _assign_bucket(self, score: float) -> BreadthBucket:
|
|
||||||
"""Assign quantile-based bucket. V1 uses fixed thresholds until history accumulated."""
|
|
||||||
# V1: fixed thresholds (will switch to quantile when enough history)
|
|
||||||
if score >= 80:
|
|
||||||
return BreadthBucket.EXTREME
|
|
||||||
elif score >= 60:
|
|
||||||
return BreadthBucket.STRONG
|
|
||||||
elif score >= 40:
|
|
||||||
return BreadthBucket.NORMAL
|
|
||||||
elif score >= 20:
|
|
||||||
return BreadthBucket.WEAK
|
|
||||||
else:
|
|
||||||
return BreadthBucket.PANIC
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def compute_quantile_boundaries(db_path: str) -> dict:
|
|
||||||
"""Compute quantile boundaries from historical breadth data.
|
|
||||||
|
|
||||||
This should be called after accumulating enough history (> 1 year).
|
|
||||||
Returns boundaries for pd.qcut.
|
|
||||||
"""
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
df = pd.read_sql_query(
|
|
||||||
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily",
|
|
||||||
conn
|
|
||||||
)
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if len(df) < 100:
|
|
||||||
return {"boundaries": [0, 20, 40, 60, 80, 100], "is_quantile": False}
|
|
||||||
|
|
||||||
df["ad_ratio"] = df["advance_top50"] / (df["advance_top50"] + df["decline_top50"])
|
|
||||||
df["ema_ratio"] = df["above_ema20_top50"] / 50
|
|
||||||
df["breadth_raw"] = (
|
|
||||||
df["ad_ratio"] * BREADTH_W_ADVANCE * 100 +
|
|
||||||
df["ema_ratio"] * BREADTH_W_EMA20 * 100 +
|
|
||||||
40 * BREADTH_W_NEW_HIGHS +
|
|
||||||
50 * BREADTH_W_BTC_DOM
|
|
||||||
)
|
|
||||||
|
|
||||||
boundaries = list(np.percentile(df["breadth_raw"].dropna(), [10, 30, 70, 90]))
|
|
||||||
return {
|
|
||||||
"boundaries": [0] + boundaries + [100],
|
|
||||||
"is_quantile": True,
|
|
||||||
"n_samples": len(df),
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_narrative(bucket: BreadthBucket, divergence: float,
|
|
||||||
ema_pct: float, ad_ratio: float) -> str:
|
|
||||||
parts = []
|
|
||||||
if bucket == BreadthBucket.EXTREME:
|
|
||||||
parts.append(f"全市场极度扩散({ema_pct:.0%}站上EMA20)")
|
|
||||||
elif bucket == BreadthBucket.STRONG:
|
|
||||||
parts.append("市场广度强势")
|
|
||||||
elif bucket == BreadthBucket.NORMAL:
|
|
||||||
parts.append("市场广度中性")
|
|
||||||
elif bucket == BreadthBucket.WEAK:
|
|
||||||
parts.append("市场广度疲弱")
|
|
||||||
else:
|
|
||||||
parts.append("市场广度恐慌")
|
|
||||||
|
|
||||||
if divergence > 10:
|
|
||||||
parts.append("资金集中于大市值(Top20>>Top50)")
|
|
||||||
elif divergence < -10:
|
|
||||||
parts.append("垃圾币狂欢(Top50>>Top20)")
|
|
||||||
|
|
||||||
return ", ".join(parts)
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"""
|
|
||||||
scoring/constants.py — Scoring thresholds, scale factors, and reference values.
|
|
||||||
|
|
||||||
All magic numbers in one place. Tune these via Phase 0 validation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ── Price Structure ──────────────────────────────────────────
|
|
||||||
# ADX thresholds
|
|
||||||
ADX_TREND_THRESHOLD = 25 # ADX > 25 = trending
|
|
||||||
ADX_STRONG_THRESHOLD = 40 # ADX > 40 = strong trend
|
|
||||||
|
|
||||||
# EMA alignment
|
|
||||||
EMA_ALIGNMENT_BULLISH = 1.0 # EMA20 > EMA60 > EMA120
|
|
||||||
EMA_ALIGNMENT_NEUTRAL = 0.5 # mixed
|
|
||||||
EMA_ALIGNMENT_BEARISH = 0.0 # EMA20 < EMA60 < EMA120
|
|
||||||
|
|
||||||
# Volatility compression (BB width relative to 20d average)
|
|
||||||
BB_COMPRESSION_LOW = 0.7 # < 70% of avg = compressing
|
|
||||||
BB_COMPRESSION_HIGH = 1.5 # > 150% of avg = expanding
|
|
||||||
|
|
||||||
# Momentum (ROC annualized)
|
|
||||||
ROC_STRONG_BULLISH = 10.0 # % over period
|
|
||||||
ROC_STRONG_BEARISH = -10.0
|
|
||||||
|
|
||||||
# Consecutive candle threshold
|
|
||||||
CONSECUTIVE_CANDLES_SIGNAL = 4
|
|
||||||
|
|
||||||
# ── Breadth ──────────────────────────────────────────────────
|
|
||||||
# Quantile boundaries for breadth buckets
|
|
||||||
BREADTH_QUANTILES = [0, 0.1, 0.3, 0.7, 0.9, 1.0] # PANIC/WEAK/NORMAL/STRONG/EXTREME
|
|
||||||
|
|
||||||
# Breadth score computation weights
|
|
||||||
BREADTH_W_ADVANCE = 0.30 # advance/decline ratio
|
|
||||||
BREADTH_W_EMA20 = 0.35 # % above EMA20
|
|
||||||
BREADTH_W_NEW_HIGHS = 0.20 # new highs count
|
|
||||||
BREADTH_W_BTC_DOM = 0.15 # BTC dominance change (inverted)
|
|
||||||
|
|
||||||
# ── OI Matrix ────────────────────────────────────────────────
|
|
||||||
OI_PRICE_THRESHOLD = 0.5 # min |price_change%| to classify
|
|
||||||
OI_OI_THRESHOLD = 0.5 # min |OI_change%| to classify
|
|
||||||
|
|
||||||
# Score mapping for OI states
|
|
||||||
OI_STATE_SCORES = {
|
|
||||||
"New Longs": 85,
|
|
||||||
"Short Covering": 60,
|
|
||||||
"New Shorts": 20,
|
|
||||||
"Long Exit": 35,
|
|
||||||
"Neutral": 50,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Volatility Regime ────────────────────────────────────────
|
|
||||||
VOL_LOW = 2.0 # ATR/Close % below this = LOW_VOL
|
|
||||||
VOL_HIGH = 5.0 # ATR/Close % below this = HIGH_VOL (above = EXPLOSIVE)
|
|
||||||
HV_RATIO_LOW = 0.7 # HV(20)/HV(60) below this = compressing
|
|
||||||
HV_RATIO_HIGH = 1.5 # HV(20)/HV(60) above this = expanding
|
|
||||||
|
|
||||||
# Score mapping
|
|
||||||
VOL_REGIME_SCORES = {
|
|
||||||
"LOW_VOL": 40, # Low vol → neutral with breakout potential
|
|
||||||
"NORMAL_VOL": 55,
|
|
||||||
"HIGH_VOL": 75,
|
|
||||||
"EXPLOSIVE_VOL": 90,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Regime ───────────────────────────────────────────────────
|
|
||||||
REGIME_W_PRICE = 0.35
|
|
||||||
REGIME_W_BREADTH = 0.50
|
|
||||||
REGIME_W_VOL = 0.15
|
|
||||||
|
|
||||||
# PANIC: anti-trend + extreme vol (NO Fear/Liquidation)
|
|
||||||
PANIC_W_ANTI_TREND = 0.60
|
|
||||||
PANIC_W_VOL_EXTREME = 0.40
|
|
||||||
|
|
||||||
# ── Trend (L2) ───────────────────────────────────────────────
|
|
||||||
TREND_W_PRICE = 0.30
|
|
||||||
TREND_W_BREADTH = 0.70
|
|
||||||
|
|
||||||
# ── Maturity ─────────────────────────────────────────────────
|
|
||||||
MATURITY_W_TREND = 0.50
|
|
||||||
MATURITY_W_BREADTH = 0.30
|
|
||||||
MATURITY_W_VOL = 0.20
|
|
||||||
|
|
||||||
# ── Expectancy ───────────────────────────────────────────────
|
|
||||||
HALF_LIFE_DAYS = 180
|
|
||||||
SUFFICIENCY_MIN = 30
|
|
||||||
SUFFICIENCY_LOW = 50
|
|
||||||
SUFFICIENCY_MEDIUM = 100
|
|
||||||
LEVEL_MIN_SAMPLES = 50
|
|
||||||
KNN_MAX_DISTANCE = 0.35
|
|
||||||
KNN_K = 200
|
|
||||||
|
|
||||||
# ── Validation ───────────────────────────────────────────────
|
|
||||||
MIN_AVG_DURATION = 5
|
|
||||||
MAX_FLIP_RATE = 0.15
|
|
||||||
MIN_IC_THRESHOLD = 0.03
|
|
||||||
MIN_ICIR_THRESHOLD = 0.5
|
|
||||||
MIN_IG_THRESHOLD = 0.1 # Information Gain for regime factors
|
|
||||||
MIN_KL_THRESHOLD = 0.5 # KL Divergence for regime separation
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"""
|
|
||||||
scoring/oi_matrix.py — OI × Price 2×2 state machine.
|
|
||||||
|
|
||||||
Discrete states, NOT a continuous score:
|
|
||||||
NEW_LONGS: Price↑ OI↑ → new money entering, trend continuation
|
|
||||||
SHORT_COVERING: Price↑ OI↓ → shorts covering, rally fragile
|
|
||||||
NEW_SHORTS: Price↓ OI↑ → new shorts entering, trend continuation
|
|
||||||
LONG_EXIT: Price↓ OI↓ → longs stopping out, panic (possible bottom)
|
|
||||||
NEUTRAL: flat → noise, don't force classification
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date as Date
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
from .base import BaseScorer
|
|
||||||
from .constants import OI_PRICE_THRESHOLD, OI_OI_THRESHOLD, OI_STATE_SCORES
|
|
||||||
from models import FactorScore, OIMatrixScore, OIState, MacroDirection
|
|
||||||
from config import config
|
|
||||||
|
|
||||||
|
|
||||||
class OIMatrixScorer(BaseScorer):
|
|
||||||
"""Classifies OI × Price state and assigns score."""
|
|
||||||
|
|
||||||
def compute(self, target_date: Date) -> OIMatrixScore:
|
|
||||||
conn = self.get_connection()
|
|
||||||
try:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT * FROM derivatives WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
|
|
||||||
(str(target_date),)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
return OIMatrixScore(
|
|
||||||
name="OI Matrix",
|
|
||||||
score=50.0,
|
|
||||||
label="No Data",
|
|
||||||
oi_state=OIState.NEUTRAL,
|
|
||||||
)
|
|
||||||
|
|
||||||
row = dict(row)
|
|
||||||
oi_change = row.get("oi_24h_change_pct") or 0
|
|
||||||
|
|
||||||
# Get price change from OHLCV
|
|
||||||
price_change = self._get_price_change(conn, str(target_date))
|
|
||||||
|
|
||||||
# Classify state
|
|
||||||
oi_state = self._classify(price_change, oi_change)
|
|
||||||
|
|
||||||
# Score from state
|
|
||||||
score = OI_STATE_SCORES.get(oi_state.value, 50)
|
|
||||||
|
|
||||||
# Direction
|
|
||||||
if oi_state == OIState.NEW_LONGS:
|
|
||||||
direction = MacroDirection.BULLISH
|
|
||||||
elif oi_state == OIState.SHORT_COVERING:
|
|
||||||
direction = MacroDirection.BULLISH # bullish but fragile
|
|
||||||
elif oi_state == OIState.NEW_SHORTS:
|
|
||||||
direction = MacroDirection.BEARISH
|
|
||||||
elif oi_state == OIState.LONG_EXIT:
|
|
||||||
direction = MacroDirection.BEARISH # bearish but possible bottom
|
|
||||||
else:
|
|
||||||
direction = MacroDirection.NEUTRAL
|
|
||||||
|
|
||||||
# Narrative
|
|
||||||
narrative = self._build_narrative(oi_state, price_change, oi_change)
|
|
||||||
|
|
||||||
return OIMatrixScore(
|
|
||||||
name="OI Matrix",
|
|
||||||
score=float(score),
|
|
||||||
label=oi_state.value,
|
|
||||||
direction=direction,
|
|
||||||
oi_state=oi_state,
|
|
||||||
price_change_pct=round(price_change, 2),
|
|
||||||
oi_change_pct=round(oi_change, 2),
|
|
||||||
sub_scores={
|
|
||||||
"price_change_pct": round(price_change, 2),
|
|
||||||
"oi_change_pct": round(oi_change, 2),
|
|
||||||
},
|
|
||||||
narrative=narrative,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _get_price_change(self, conn: sqlite3.Connection, date_str: str) -> float:
|
|
||||||
"""Get BTC 24h price change % for a given date."""
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT close FROM ohlcv_daily WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
|
|
||||||
(date_str,)
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
# Get previous day close
|
|
||||||
prev = conn.execute(
|
|
||||||
"SELECT close FROM ohlcv_daily WHERE date < ? AND symbol = 'BTC/USDT:USDT' ORDER BY date DESC LIMIT 1",
|
|
||||||
(date_str,)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if prev is None:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
current_close = float(row["close"])
|
|
||||||
prev_close = float(prev["close"])
|
|
||||||
if prev_close == 0:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
return (current_close - prev_close) / prev_close * 100
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _classify(price_change_pct: float, oi_change_pct: float) -> OIState:
|
|
||||||
"""Classify OI × Price into discrete state."""
|
|
||||||
price_up = price_change_pct > OI_PRICE_THRESHOLD
|
|
||||||
price_down = price_change_pct < -OI_PRICE_THRESHOLD
|
|
||||||
oi_up = oi_change_pct > OI_OI_THRESHOLD
|
|
||||||
oi_down = oi_change_pct < -OI_OI_THRESHOLD
|
|
||||||
|
|
||||||
if price_up and oi_up:
|
|
||||||
return OIState.NEW_LONGS
|
|
||||||
elif price_up and oi_down:
|
|
||||||
return OIState.SHORT_COVERING
|
|
||||||
elif price_down and oi_up:
|
|
||||||
return OIState.NEW_SHORTS
|
|
||||||
elif price_down and oi_down:
|
|
||||||
return OIState.LONG_EXIT
|
|
||||||
else:
|
|
||||||
return OIState.NEUTRAL
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_narrative(state: OIState, price_chg: float, oi_chg: float) -> str:
|
|
||||||
mapping = {
|
|
||||||
OIState.NEW_LONGS: f"新多进场: 价格+{price_chg:.1f}%, OI+{oi_chg:.1f}%, 真金白银推动",
|
|
||||||
OIState.SHORT_COVERING: f"空头回补: 价格+{price_chg:.1f}%, OI{oi_chg:.1f}%, 上涨脆弱",
|
|
||||||
OIState.NEW_SHORTS: f"新空进场: 价格{price_chg:.1f}%, OI+{oi_chg:.1f}%, 趋势延续",
|
|
||||||
OIState.LONG_EXIT: f"多头止损: 价格{price_chg:.1f}%, OI{oi_chg:.1f}%, 恐慌(可能见底)",
|
|
||||||
OIState.NEUTRAL: "OI/价格变化不显著, 噪音区",
|
|
||||||
}
|
|
||||||
return mapping.get(state, "Unknown")
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
"""
|
|
||||||
scoring/price_structure.py — Price Structure Score (OHLCV-only).
|
|
||||||
|
|
||||||
Three sub-dimensions:
|
|
||||||
1. Trend Strength (40%): EMA alignment + ADX
|
|
||||||
2. Volatility Compression (30%): ATR + BB width
|
|
||||||
3. Momentum (30%): ROC + consecutive candles
|
|
||||||
|
|
||||||
This module works with zero external dependencies — just OHLCV data.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date as Date
|
|
||||||
import sqlite3
|
|
||||||
import math
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from .base import BaseScorer
|
|
||||||
from .constants import (
|
|
||||||
ADX_TREND_THRESHOLD, ADX_STRONG_THRESHOLD,
|
|
||||||
BB_COMPRESSION_LOW, BB_COMPRESSION_HIGH,
|
|
||||||
ROC_STRONG_BULLISH, ROC_STRONG_BEARISH,
|
|
||||||
CONSECUTIVE_CANDLES_SIGNAL,
|
|
||||||
)
|
|
||||||
from models import FactorScore, PriceStructureScore, MacroDirection
|
|
||||||
from config import config
|
|
||||||
|
|
||||||
|
|
||||||
class PriceStructureScorer(BaseScorer):
|
|
||||||
"""Scores market structure from OHLCV data alone."""
|
|
||||||
|
|
||||||
def compute(self, target_date: Date) -> PriceStructureScore:
|
|
||||||
conn = self.get_connection()
|
|
||||||
try:
|
|
||||||
df = self._load_ohlcv(conn, str(target_date), lookback=120)
|
|
||||||
if df.empty:
|
|
||||||
return PriceStructureScore(
|
|
||||||
name="Price Structure",
|
|
||||||
score=50.0,
|
|
||||||
label="No Data",
|
|
||||||
)
|
|
||||||
|
|
||||||
trend = self._score_trend_strength(df)
|
|
||||||
vol_comp = self._score_volatility_compression(df)
|
|
||||||
momentum = self._score_momentum(df)
|
|
||||||
|
|
||||||
# Weighted aggregate
|
|
||||||
score = trend * 0.40 + vol_comp * 0.30 + momentum * 0.30
|
|
||||||
|
|
||||||
# Determine direction
|
|
||||||
if trend > 60:
|
|
||||||
direction = MacroDirection.BULLISH
|
|
||||||
elif trend < 40:
|
|
||||||
direction = MacroDirection.BEARISH
|
|
||||||
else:
|
|
||||||
direction = MacroDirection.NEUTRAL
|
|
||||||
|
|
||||||
# Build narrative
|
|
||||||
latest = df.iloc[-1]
|
|
||||||
narrative = self._build_narrative(trend, vol_comp, momentum, latest)
|
|
||||||
|
|
||||||
return PriceStructureScore(
|
|
||||||
name="Price Structure",
|
|
||||||
score=round(score, 1),
|
|
||||||
label=self._label(score),
|
|
||||||
direction=direction,
|
|
||||||
trend_strength=round(trend, 1),
|
|
||||||
volatility_compression=round(vol_comp, 1),
|
|
||||||
momentum=round(momentum, 1),
|
|
||||||
sub_scores={
|
|
||||||
"trend_strength": round(trend, 1),
|
|
||||||
"volatility_compression": round(vol_comp, 1),
|
|
||||||
"momentum": round(momentum, 1),
|
|
||||||
},
|
|
||||||
narrative=narrative,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _load_ohlcv(self, conn: sqlite3.Connection, date_str: str,
|
|
||||||
lookback: int = 120) -> pd.DataFrame:
|
|
||||||
"""Load OHLCV data up to target_date."""
|
|
||||||
df = pd.read_sql_query(
|
|
||||||
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT ?",
|
|
||||||
conn, params=(date_str, lookback)
|
|
||||||
)
|
|
||||||
if df.empty:
|
|
||||||
return df
|
|
||||||
return df.sort_values("date").reset_index(drop=True)
|
|
||||||
|
|
||||||
def _score_trend_strength(self, df: pd.DataFrame) -> float:
|
|
||||||
"""Score trend based on EMA alignment and ADX."""
|
|
||||||
latest = df.iloc[-1]
|
|
||||||
|
|
||||||
# EMA alignment
|
|
||||||
ema20 = latest.get("ema20")
|
|
||||||
ema60 = latest.get("ema60")
|
|
||||||
ema120 = latest.get("ema120")
|
|
||||||
|
|
||||||
ema_score = 50.0
|
|
||||||
if ema20 and ema60 and ema120 and not pd.isna(ema20) and not pd.isna(ema60) and not pd.isna(ema120):
|
|
||||||
alignments = 0
|
|
||||||
if ema20 > ema60: alignments += 1
|
|
||||||
if ema60 > ema120: alignments += 1
|
|
||||||
if ema20 > ema120: alignments += 1
|
|
||||||
|
|
||||||
# Distance from EMAs
|
|
||||||
close = float(latest["close"])
|
|
||||||
ema20_dist = abs(close - ema20) / ema20 * 100 if ema20 else 0
|
|
||||||
|
|
||||||
if alignments == 3:
|
|
||||||
ema_score = 80 + min(ema20_dist, 15) # strong bullish alignment
|
|
||||||
elif alignments == 0:
|
|
||||||
ema_score = 20 - min(ema20_dist, 15) # strong bearish alignment
|
|
||||||
elif alignments == 2:
|
|
||||||
ema_score = 65
|
|
||||||
else:
|
|
||||||
ema_score = 35
|
|
||||||
|
|
||||||
# ADX
|
|
||||||
adx = latest.get("adx_14")
|
|
||||||
adx_score = 50.0
|
|
||||||
if adx and not pd.isna(adx):
|
|
||||||
if adx > ADX_STRONG_THRESHOLD:
|
|
||||||
adx_score = 85
|
|
||||||
elif adx > ADX_TREND_THRESHOLD:
|
|
||||||
adx_score = 65 + (adx - ADX_TREND_THRESHOLD) / (ADX_STRONG_THRESHOLD - ADX_TREND_THRESHOLD) * 20
|
|
||||||
else:
|
|
||||||
adx_score = 50 - (ADX_TREND_THRESHOLD - adx) / ADX_TREND_THRESHOLD * 30
|
|
||||||
|
|
||||||
return ema_score * 0.55 + adx_score * 0.45
|
|
||||||
|
|
||||||
def _score_volatility_compression(self, df: pd.DataFrame) -> float:
|
|
||||||
"""Score volatility compression — expansion = high, compression = low-mid."""
|
|
||||||
latest = df.iloc[-1]
|
|
||||||
|
|
||||||
bb_width = latest.get("bb_width")
|
|
||||||
if not bb_width or pd.isna(bb_width) or len(df) < 20:
|
|
||||||
return 50.0
|
|
||||||
|
|
||||||
# BB width relative to 20d average
|
|
||||||
recent_bb = df["bb_width"].dropna().tail(20)
|
|
||||||
if len(recent_bb) < 10:
|
|
||||||
return 50.0
|
|
||||||
|
|
||||||
bb_avg = recent_bb.mean()
|
|
||||||
bb_ratio = bb_width / bb_avg if bb_avg > 0 else 1.0
|
|
||||||
|
|
||||||
if bb_ratio < BB_COMPRESSION_LOW:
|
|
||||||
# Compression → potential breakout, neutral-bullish
|
|
||||||
return 45 + (BB_COMPRESSION_LOW - bb_ratio) * 30
|
|
||||||
elif bb_ratio > BB_COMPRESSION_HIGH:
|
|
||||||
# Expansion → trending or chaotic
|
|
||||||
return 75 + min((bb_ratio - BB_COMPRESSION_HIGH) * 20, 20)
|
|
||||||
else:
|
|
||||||
# Normal
|
|
||||||
return 55
|
|
||||||
|
|
||||||
def _score_momentum(self, df: pd.DataFrame) -> float:
|
|
||||||
"""Score momentum using ROC and consecutive candles."""
|
|
||||||
if len(df) < 10:
|
|
||||||
return 50.0
|
|
||||||
|
|
||||||
closes = df["close"].astype(float)
|
|
||||||
latest = float(closes.iloc[-1])
|
|
||||||
|
|
||||||
# ROC (5-bar)
|
|
||||||
if len(closes) >= 6:
|
|
||||||
roc5 = (closes.iloc[-1] - closes.iloc[-6]) / closes.iloc[-6] * 100
|
|
||||||
else:
|
|
||||||
roc5 = 0
|
|
||||||
|
|
||||||
# ROC (10-bar)
|
|
||||||
if len(closes) >= 11:
|
|
||||||
roc10 = (closes.iloc[-1] - closes.iloc[-11]) / closes.iloc[-11] * 100
|
|
||||||
else:
|
|
||||||
roc10 = 0
|
|
||||||
|
|
||||||
# ROC (20-bar)
|
|
||||||
if len(closes) >= 21:
|
|
||||||
roc20 = (closes.iloc[-1] - closes.iloc[-21]) / closes.iloc[-21] * 100
|
|
||||||
else:
|
|
||||||
roc20 = 0
|
|
||||||
|
|
||||||
# Score ROC: map to 0-100
|
|
||||||
def roc_to_score(roc, scale=15):
|
|
||||||
return 50 + np.clip(roc / scale * 50, -50, 50)
|
|
||||||
|
|
||||||
roc_score = roc_to_score(roc5, 10) * 0.4 + roc_to_score(roc10, 15) * 0.35 + roc_to_score(roc20, 20) * 0.25
|
|
||||||
|
|
||||||
# Consecutive candle direction
|
|
||||||
consec_score = 50.0
|
|
||||||
consec_up = 0
|
|
||||||
consec_down = 0
|
|
||||||
for i in range(len(closes) - 1, max(0, len(closes) - 10), -1):
|
|
||||||
if closes.iloc[i] > closes.iloc[i - 1]:
|
|
||||||
consec_up += 1
|
|
||||||
consec_down = 0
|
|
||||||
elif closes.iloc[i] < closes.iloc[i - 1]:
|
|
||||||
consec_down += 1
|
|
||||||
consec_up = 0
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
if consec_up >= CONSECUTIVE_CANDLES_SIGNAL:
|
|
||||||
consec_score = 70 + min(consec_up * 5, 25)
|
|
||||||
elif consec_down >= CONSECUTIVE_CANDLES_SIGNAL:
|
|
||||||
consec_score = 30 - min(consec_down * 5, 25)
|
|
||||||
|
|
||||||
return roc_score * 0.70 + consec_score * 0.30
|
|
||||||
|
|
||||||
def _build_narrative(self, trend: float, vol: float, momentum: float,
|
|
||||||
latest: pd.Series) -> str:
|
|
||||||
parts = []
|
|
||||||
if trend > 65:
|
|
||||||
parts.append("EMA多头排列+ADX趋势明确")
|
|
||||||
elif trend > 50:
|
|
||||||
parts.append("趋势温和偏多")
|
|
||||||
elif trend < 35:
|
|
||||||
parts.append("EMA空头排列+ADX趋势明确")
|
|
||||||
elif trend < 50:
|
|
||||||
parts.append("趋势温和偏空")
|
|
||||||
else:
|
|
||||||
parts.append("趋势中性")
|
|
||||||
|
|
||||||
if vol > 70:
|
|
||||||
parts.append("波动率扩张")
|
|
||||||
elif vol < 45:
|
|
||||||
parts.append("波动率压缩(突破前兆)")
|
|
||||||
|
|
||||||
if momentum > 65:
|
|
||||||
parts.append("动量强劲")
|
|
||||||
elif momentum < 35:
|
|
||||||
parts.append("动量疲弱")
|
|
||||||
|
|
||||||
return ", ".join(parts) if parts else "中性"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _label(score: float) -> str:
|
|
||||||
if score >= 75:
|
|
||||||
return "Strong Bullish Structure"
|
|
||||||
elif score >= 60:
|
|
||||||
return "Bullish Structure"
|
|
||||||
elif score >= 40:
|
|
||||||
return "Neutral Structure"
|
|
||||||
elif score >= 25:
|
|
||||||
return "Bearish Structure"
|
|
||||||
return "Weak Bearish Structure"
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""
|
|
||||||
scoring/volatility_regime.py — Volatility Regime Classification.
|
|
||||||
|
|
||||||
4 regimes from OHLCV data:
|
|
||||||
LOW_VOL: ATR/Close < 2% → compression, breakout imminent
|
|
||||||
NORMAL_VOL: ATR/Close 2-5% → normal trading
|
|
||||||
HIGH_VOL: ATR/Close 5-10% → trend acceleration, wider stops
|
|
||||||
EXPLOSIVE_VOL: ATR/Close > 10% → extreme, reduce or wait
|
|
||||||
|
|
||||||
Uses: ATR(14)/Close, HV(20)/HV(60) ratio, BB width ratio.
|
|
||||||
OHLCV-only — never goes offline.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date as Date
|
|
||||||
import sqlite3
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from .base import BaseScorer
|
|
||||||
from .constants import (
|
|
||||||
VOL_LOW, VOL_HIGH, VOL_REGIME_SCORES, HV_RATIO_LOW, HV_RATIO_HIGH,
|
|
||||||
)
|
|
||||||
from models import FactorScore, VolatilityRegimeScore, VolRegime, MacroDirection
|
|
||||||
from config import config
|
|
||||||
|
|
||||||
|
|
||||||
class VolatilityRegimeScorer(BaseScorer):
|
|
||||||
"""Classifies volatility regime from OHLCV data."""
|
|
||||||
|
|
||||||
def compute(self, target_date: Date) -> VolatilityRegimeScore:
|
|
||||||
conn = self.get_connection()
|
|
||||||
try:
|
|
||||||
df = pd.read_sql_query(
|
|
||||||
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT 120",
|
|
||||||
conn, params=(str(target_date),)
|
|
||||||
)
|
|
||||||
if df.empty:
|
|
||||||
return VolatilityRegimeScore(
|
|
||||||
name="Volatility Regime",
|
|
||||||
score=50.0,
|
|
||||||
label="No Data",
|
|
||||||
)
|
|
||||||
|
|
||||||
df = df.sort_values("date").reset_index(drop=True)
|
|
||||||
|
|
||||||
# 1. ATR/Close %
|
|
||||||
latest = df.iloc[-1]
|
|
||||||
atr = latest.get("atr_14")
|
|
||||||
close = float(latest["close"])
|
|
||||||
atr_pct = (atr / close * 100) if atr and not pd.isna(atr) and close > 0 else 3.0
|
|
||||||
|
|
||||||
# 2. HV(20) / HV(60) ratio
|
|
||||||
hv_ratio = self._compute_hv_ratio(df)
|
|
||||||
|
|
||||||
# 3. BB width ratio
|
|
||||||
bb_ratio = self._compute_bb_ratio(df)
|
|
||||||
|
|
||||||
# Classify regime
|
|
||||||
regime = self._classify(atr_pct, hv_ratio, bb_ratio)
|
|
||||||
|
|
||||||
# Score
|
|
||||||
score = VOL_REGIME_SCORES.get(regime.value, 50)
|
|
||||||
|
|
||||||
# Narrative
|
|
||||||
narrative = self._build_narrative(regime, atr_pct, hv_ratio, bb_ratio)
|
|
||||||
|
|
||||||
return VolatilityRegimeScore(
|
|
||||||
name="Volatility Regime",
|
|
||||||
score=float(score),
|
|
||||||
label=regime.value,
|
|
||||||
direction=MacroDirection.NEUTRAL,
|
|
||||||
vol_regime=regime,
|
|
||||||
atr_pct=round(atr_pct, 2),
|
|
||||||
hv_ratio=round(hv_ratio, 2),
|
|
||||||
bb_width_ratio=round(bb_ratio, 2),
|
|
||||||
sub_scores={
|
|
||||||
"atr_pct": round(atr_pct, 2),
|
|
||||||
"hv_ratio": round(hv_ratio, 2),
|
|
||||||
"bb_width_ratio": round(bb_ratio, 2),
|
|
||||||
},
|
|
||||||
narrative=narrative,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _compute_hv_ratio(self, df: pd.DataFrame) -> float:
|
|
||||||
"""Compute HV(20) / HV(60) ratio."""
|
|
||||||
closes = df["close"].astype(float)
|
|
||||||
returns = closes.pct_change().dropna()
|
|
||||||
|
|
||||||
if len(returns) < 60:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
hv20 = returns.tail(20).std() * np.sqrt(365) * 100
|
|
||||||
hv60 = returns.tail(60).std() * np.sqrt(365) * 100
|
|
||||||
|
|
||||||
if hv60 == 0:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
return hv20 / hv60
|
|
||||||
|
|
||||||
def _compute_bb_ratio(self, df: pd.DataFrame) -> float:
|
|
||||||
"""Compute current BB width / 20d average BB width."""
|
|
||||||
bb_widths = df["bb_width"].dropna().tail(40)
|
|
||||||
if len(bb_widths) < 20:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
current = bb_widths.iloc[-1]
|
|
||||||
avg = bb_widths.tail(20).mean()
|
|
||||||
if avg == 0:
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
return current / avg
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _classify(atr_pct: float, hv_ratio: float, bb_ratio: float) -> VolRegime:
|
|
||||||
"""Classify volatility regime from multiple indicators."""
|
|
||||||
# Primary: ATR/Close %
|
|
||||||
if atr_pct > 10.0:
|
|
||||||
return VolRegime.EXPLOSIVE_VOL
|
|
||||||
elif atr_pct > VOL_HIGH:
|
|
||||||
return VolRegime.HIGH_VOL
|
|
||||||
elif atr_pct < VOL_LOW:
|
|
||||||
return VolRegime.LOW_VOL
|
|
||||||
|
|
||||||
# Secondary: HV ratio and BB ratio for edge cases
|
|
||||||
if hv_ratio > HV_RATIO_HIGH and bb_ratio > 1.3:
|
|
||||||
return VolRegime.HIGH_VOL
|
|
||||||
elif hv_ratio < HV_RATIO_LOW and bb_ratio < 0.8:
|
|
||||||
return VolRegime.LOW_VOL
|
|
||||||
|
|
||||||
return VolRegime.NORMAL_VOL
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_narrative(regime: VolRegime, atr_pct: float,
|
|
||||||
hv_ratio: float, bb_ratio: float) -> str:
|
|
||||||
mapping = {
|
|
||||||
VolRegime.LOW_VOL: f"低波动(ATR={atr_pct:.1f}%), 布林带收窄, 突破前兆",
|
|
||||||
VolRegime.NORMAL_VOL: f"正常波动(ATR={atr_pct:.1f}%), 正常交易环境",
|
|
||||||
VolRegime.HIGH_VOL: f"高波动(ATR={atr_pct:.1f}%), 趋势加速, 放宽止损",
|
|
||||||
VolRegime.EXPLOSIVE_VOL: f"极端波动(ATR={atr_pct:.1f}%), 减仓或等待",
|
|
||||||
}
|
|
||||||
return mapping.get(regime, "Unknown")
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
"""
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
// 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();
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
"""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]
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"""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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
"""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},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"""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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"""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,
|
|
||||||
)
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
"""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 []
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"""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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from crypto_wyckoff.rules.registry import rule_registry
|
|
||||||
|
|
||||||
__all__ = ["rule_registry"]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""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."""
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"""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(),
|
|
||||||
]
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
"""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(),
|
|
||||||
]
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"""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()]
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""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", []),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""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}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""Wyckoff Screener engine version — bump when rules change."""
|
|
||||||
|
|
||||||
WYCKOFF_ENGINE_VERSION = "v1.0.0"
|
|
||||||
ARCHITECTURE_VERSION = "1.0"
|
|
||||||
@@ -23,8 +23,6 @@
|
|||||||
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
||||||
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
||||||
- ECR-007 Final Approval / `276481e`:Wyckoff Live Structure(`live.py`);Confirmed ≠ Live;execution 仅 confirmed
|
- 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 开关仅显隐叠层
|
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
|
||||||
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
|
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
|
||||||
|
|
||||||
@@ -32,15 +30,14 @@
|
|||||||
|
|
||||||
- `/api/analyze` 字段可增不可删
|
- `/api/analyze` 字段可增不可删
|
||||||
- 无 ADR 不改笔/段/中枢/买卖点语义
|
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||||
- 威科夫为独立叠层(ECR-003/007);Crypto Screener 为独立页(ECR-009),勿混进缠论引擎
|
- 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法
|
||||||
- Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
- Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||||
|
|
||||||
## 已知债务
|
## 已知债务
|
||||||
|
|
||||||
|
- `chart_tv.js` 单体巨大 → 后续可选 ECR
|
||||||
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
|
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
|
||||||
- 内存泄漏尚无自动化 heap/监听断言
|
- 内存泄漏尚无自动化 heap/监听断言
|
||||||
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
||||||
- 威科夫启发式参数未做 UI 调参
|
- 威科夫启发式参数未做 UI 调参
|
||||||
- ECR-007 待 Human 在 Gitea 开 PR 合入 `dev`
|
- ECR-007 待 PR 合入 `dev`
|
||||||
- `chart_tv_overlays.js` 仍偏大,可后续再拆
|
|
||||||
- ECR-009:月线历史受日线深度限制;Cycle 规则在 crypto 上可能偏 Unknown,看效果再调参
|
|
||||||
|
|||||||
@@ -2,17 +2,6 @@
|
|||||||
|
|
||||||
## Unreleased — 2026-08-07
|
## 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)
|
### ECR-007(L2,LOOP-RUN-005)
|
||||||
|
|
||||||
- Wyckoff **Live Structure**:`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
|
- Wyckoff **Live Structure**:`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
# 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**
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# 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`,可添加新组合
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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` 大改。
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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`。
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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。
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Idea: Crypto Wyckoff Screener(独立页)
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
主站威科夫是图叠层;需要 A_Share_DP 式 D/W/M 多周期选股/决策观察,用于数字货币。
|
|
||||||
|
|
||||||
## Hypothesis
|
|
||||||
|
|
||||||
独立包 + 独立页,币对来自 DATA_SERVICE,本地缓存 1d/1w/1M,每分钟 tip 更新,不碰缠论主链路。
|
|
||||||
|
|
||||||
## Change Level Guess
|
|
||||||
|
|
||||||
**L2**(新行为面;不改 strategies)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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
|
## Active anchors
|
||||||
|
|
||||||
- ECR: ECR-002/003/004 Reviewed;ECR-007 Final Approval(待合入 `dev`);ECR-008 Reviewed(chart_tv 拆分)
|
- ECR: ECR-002/003/004 Reviewed;ECR-007 Final Approval(Live Structure,待合入 `dev`)
|
||||||
- EXP: N/A
|
- EXP: N/A
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
- Memory: `docs/AGENT_MEMORY.md`
|
- Memory: `docs/AGENT_MEMORY.md`
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# STATE
|
# STATE
|
||||||
|
|
||||||
**owner:** engineer
|
**owner:** idle
|
||||||
**active_ecr:** ECR-009(crypto wyckoff screener)
|
**active_ecr:** none(ECR-007 Final Approval;待合入 `dev`)
|
||||||
**phase:** implementing
|
**phase:** post-approval
|
||||||
**system_version:** v1.0.0
|
**system_version:** v1.0.0
|
||||||
**strategy_version:** unchanged
|
**strategy_version:** unchanged
|
||||||
**updated:** 2026-08-07
|
**updated:** 2026-08-07
|
||||||
@@ -16,12 +16,11 @@
|
|||||||
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
|
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分 |
|
||||||
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
|
| ECR-003 | L2 | Done (Reviewed) | `081a57a` 主站威科夫 |
|
||||||
| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
|
| ECR-004 | L2 | Done (Reviewed) | 威科夫硬化 / VP 减负 |
|
||||||
| ECR-007 | L2 | Done (Final Approval) | Live Structure · 待合入 `dev` |
|
| ECR-007 | L2 | Done (Final Approval) | Live Structure · `276481e` · LOOP-RUN-005 |
|
||||||
| ECR-008 | L3 | Done (Reviewed) | chart_tv 拆分 |
|
|
||||||
| ECR-009 | L2 | Implementing | `/wyckoff_crypto` · D/W/M |
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- ECR-009:打开 http://localhost:8128/wyckoff_crypto ;默认组合 `8h/4h/1h`,可下拉切 `1d/1w/1M` 或「添加组合」
|
- ECR-007:**FINAL_APPROVAL** · gate PASS · Confirmed ≠ Live ≠ execution
|
||||||
- 可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流;月线仍由日线 UTC 聚合
|
- 归档:`docs/runs/LOOP-RUN-005/`
|
||||||
- 未请求新 system tag
|
- 未请求新 system tag
|
||||||
|
- 分支 `feature/ECR-007-wyckoff-live-structure` 待 PR → `dev`
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
ecr: ECR-009
|
|
||||||
owner: engineer
|
|
||||||
phase: implementing
|
|
||||||
updated: 2026-08-07
|
|
||||||
notes: crypto wyckoff screener · D/W/M · 24/7 tip
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# 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 未改。
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# 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,19 +53,3 @@
|
|||||||
| ECR-007 | execution 仅 confirmed | BD-2026-007 | `execution_signal_from_wyckoff` | live-only → None | 276481e |
|
| 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 | Summary Confirmed/Live 分区 | PRODUCT | `ui.js` | 人工 + 契约键 | 276481e |
|
||||||
| ECR-007 | LOOP-RUN-005 | — | `docs/runs/LOOP-RUN-005/` | Gate + Artifact | 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 |
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""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,9 +2,6 @@
|
|||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from services.runtime import * # noqa: F403
|
from services.runtime import * # noqa: F403
|
||||||
from services import runtime as R
|
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__)
|
bp = Blueprint("analyze", __name__)
|
||||||
|
|
||||||
@@ -788,77 +785,3 @@ def analyze():
|
|||||||
|
|
||||||
return jsonify(result)
|
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)
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
"""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,7 +15,6 @@ from api.analyze import bp as analyze_bp
|
|||||||
from api.pages import bp as pages_bp
|
from api.pages import bp as pages_bp
|
||||||
from api.symbols import bp as symbols_bp
|
from api.symbols import bp as symbols_bp
|
||||||
from api.trend import bp as trend_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:
|
def create_app() -> Flask:
|
||||||
@@ -24,12 +23,6 @@ def create_app() -> Flask:
|
|||||||
app.register_blueprint(analyze_bp)
|
app.register_blueprint(analyze_bp)
|
||||||
app.register_blueprint(symbols_bp)
|
app.register_blueprint(symbols_bp)
|
||||||
app.register_blueprint(trend_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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ def analyze_chan(df, symbol=None, timeframe=None):
|
|||||||
zs_list = chan.calculate_seg_zs(seg_list)
|
zs_list = chan.calculate_seg_zs(seg_list)
|
||||||
# 计算笔中枢(BI中枢)并拍平成列表
|
# 计算笔中枢(BI中枢)并拍平成列表
|
||||||
|
|
||||||
bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
|
#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(seg_list)
|
||||||
bsp_list = []
|
bsp_list = []
|
||||||
if len(bi_zs_list) > 0:
|
if len(bi_zs_list) > 0:
|
||||||
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
|
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):
|
def compute_timeframe_defaults(labels_ordered):
|
||||||
"""
|
"""
|
||||||
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||||
默认偏好:主 4h、次 1h、次次 15m。
|
默认偏好:主 4h、次 2h、次次 1h(威科夫与结构在小时级更可读)。
|
||||||
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||||
"""
|
"""
|
||||||
if not labels_ordered:
|
if not labels_ordered:
|
||||||
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||||
timeframe_keys = list(labels_ordered.keys())
|
timeframe_keys = list(labels_ordered.keys())
|
||||||
preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None)
|
preferred_main = next((tf for tf in ['4h', '2h', '1h'] if tf in labels_ordered), None)
|
||||||
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||||
if default_main not in labels_ordered and timeframe_keys:
|
if default_main not in labels_ordered and timeframe_keys:
|
||||||
default_main = timeframe_keys[0]
|
default_main = timeframe_keys[0]
|
||||||
|
|
||||||
default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys)
|
default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys)
|
||||||
default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys)
|
default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys)
|
||||||
|
|
||||||
return default_main, default_element, default_sub_sub, timeframe_keys
|
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||||
|
|
||||||
|
|||||||
+19
-106
@@ -9,26 +9,11 @@ function updateTradingViewData() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳)
|
// 保存当前的可视范围
|
||||||
const frozen = window._preserveViewOnRefresh;
|
|
||||||
const oldBarCount = window._preserveViewBarCount || 0;
|
|
||||||
let savedScrollPosition = null;
|
|
||||||
if (tvWidget.mainChart) {
|
if (tvWidget.mainChart) {
|
||||||
const ts = tvWidget.mainChart.timeScale();
|
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
|
||||||
if (frozen) {
|
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
|
||||||
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线
|
// 检查是否显示原始K线
|
||||||
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
const showOriginalKline = $('#showOriginalKline').is(':checked');
|
||||||
@@ -86,24 +71,6 @@ 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)
|
// 更新主系列数据(根据klineType)
|
||||||
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
|
||||||
@@ -303,77 +270,23 @@ function updateTradingViewData() {
|
|||||||
// 更新EMA52显示
|
// 更新EMA52显示
|
||||||
updateEMA52Display(currentData);
|
updateEMA52Display(currentData);
|
||||||
|
|
||||||
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。
|
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
|
||||||
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
|
|
||||||
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
|
|
||||||
if (tvWidget.mainChart) {
|
if (tvWidget.mainChart) {
|
||||||
const charts = [
|
if (tvWidget.state.visibleRange) {
|
||||||
tvWidget.mainChart,
|
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
|
||||||
tvWidget.volumeChart,
|
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||||
tvWidget.atrChart,
|
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||||
tvWidget.macdChart,
|
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||||
tvWidget.chanMacdChart
|
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||||
].filter(Boolean);
|
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
|
||||||
|
} else if (tvWidget.state.logicalRange) {
|
||||||
const vr = tvWidget.state.visibleRange;
|
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
|
||||||
const lr = tvWidget.state.logicalRange;
|
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||||
const savedScroll = savedScrollPosition;
|
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||||
|
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||||
const applyPosition = function (tag) {
|
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||||
let ok = false;
|
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
|
||||||
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('增量更新图表完成');
|
console.log('增量更新图表完成');
|
||||||
|
|||||||
+4681
-13
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user