diff --git a/ChanMacro/.env.example b/ChanMacro/.env.example deleted file mode 100644 index 857693d..0000000 --- a/ChanMacro/.env.example +++ /dev/null @@ -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-... diff --git a/ChanMacro/.gitignore b/ChanMacro/.gitignore deleted file mode 100644 index 8fce603..0000000 --- a/ChanMacro/.gitignore +++ /dev/null @@ -1 +0,0 @@ -data/ diff --git a/ChanMacro/__init__.py b/ChanMacro/__init__.py deleted file mode 100644 index 729473e..0000000 --- a/ChanMacro/__init__.py +++ /dev/null @@ -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" diff --git a/ChanMacro/chan_integration.py b/ChanMacro/chan_integration.py deleted file mode 100644 index 5d619c3..0000000 --- a/ChanMacro/chan_integration.py +++ /dev/null @@ -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) diff --git a/ChanMacro/cli.py b/ChanMacro/cli.py deleted file mode 100644 index 0cc3d0c..0000000 --- a/ChanMacro/cli.py +++ /dev/null @@ -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() diff --git a/ChanMacro/config.json b/ChanMacro/config.json deleted file mode 100644 index e7c5ab9..0000000 --- a/ChanMacro/config.json +++ /dev/null @@ -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 -} diff --git a/ChanMacro/config.py b/ChanMacro/config.py deleted file mode 100644 index 3ee36d5..0000000 --- a/ChanMacro/config.py +++ /dev/null @@ -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() diff --git a/ChanMacro/database.py b/ChanMacro/database.py deleted file mode 100644 index 6d387c4..0000000 --- a/ChanMacro/database.py +++ /dev/null @@ -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 diff --git a/ChanMacro/expectancy/__init__.py b/ChanMacro/expectancy/__init__.py deleted file mode 100644 index baffc40..0000000 --- a/ChanMacro/expectancy/__init__.py +++ /dev/null @@ -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 diff --git a/ChanMacro/expectancy/decay.py b/ChanMacro/expectancy/decay.py deleted file mode 100644 index c1fd42a..0000000 --- a/ChanMacro/expectancy/decay.py +++ /dev/null @@ -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) diff --git a/ChanMacro/expectancy/engine.py b/ChanMacro/expectancy/engine.py deleted file mode 100644 index ce4e198..0000000 --- a/ChanMacro/expectancy/engine.py +++ /dev/null @@ -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 diff --git a/ChanMacro/expectancy/tracker.py b/ChanMacro/expectancy/tracker.py deleted file mode 100644 index c5cc7ea..0000000 --- a/ChanMacro/expectancy/tracker.py +++ /dev/null @@ -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} diff --git a/ChanMacro/fetchers/__init__.py b/ChanMacro/fetchers/__init__.py deleted file mode 100644 index da06603..0000000 --- a/ChanMacro/fetchers/__init__.py +++ /dev/null @@ -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 diff --git a/ChanMacro/fetchers/base.py b/ChanMacro/fetchers/base.py deleted file mode 100644 index 21f2be5..0000000 --- a/ChanMacro/fetchers/base.py +++ /dev/null @@ -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.""" - ... diff --git a/ChanMacro/fetchers/breadth.py b/ChanMacro/fetchers/breadth.py deleted file mode 100644 index 642b66a..0000000 --- a/ChanMacro/fetchers/breadth.py +++ /dev/null @@ -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() diff --git a/ChanMacro/fetchers/derivatives.py b/ChanMacro/fetchers/derivatives.py deleted file mode 100644 index 01a768b..0000000 --- a/ChanMacro/fetchers/derivatives.py +++ /dev/null @@ -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 diff --git a/ChanMacro/fetchers/ohlcv.py b/ChanMacro/fetchers/ohlcv.py deleted file mode 100644 index 4847b8a..0000000 --- a/ChanMacro/fetchers/ohlcv.py +++ /dev/null @@ -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 diff --git a/ChanMacro/main.py b/ChanMacro/main.py deleted file mode 100644 index 4bdcf72..0000000 --- a/ChanMacro/main.py +++ /dev/null @@ -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() diff --git a/ChanMacro/models.py b/ChanMacro/models.py deleted file mode 100644 index e31f488..0000000 --- a/ChanMacro/models.py +++ /dev/null @@ -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 diff --git a/ChanMacro/regime_detector.py b/ChanMacro/regime_detector.py deleted file mode 100644 index 69194d7..0000000 --- a/ChanMacro/regime_detector.py +++ /dev/null @@ -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 diff --git a/ChanMacro/report/__init__.py b/ChanMacro/report/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ChanMacro/requirements.txt b/ChanMacro/requirements.txt deleted file mode 100644 index a04a603..0000000 --- a/ChanMacro/requirements.txt +++ /dev/null @@ -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 diff --git a/ChanMacro/run_tests.sh b/ChanMacro/run_tests.sh deleted file mode 100755 index 5b23f38..0000000 --- a/ChanMacro/run_tests.sh +++ /dev/null @@ -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 diff --git a/ChanMacro/scheduler.py b/ChanMacro/scheduler.py deleted file mode 100644 index 702f524..0000000 --- a/ChanMacro/scheduler.py +++ /dev/null @@ -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 diff --git a/ChanMacro/scoring/__init__.py b/ChanMacro/scoring/__init__.py deleted file mode 100644 index 771ff28..0000000 --- a/ChanMacro/scoring/__init__.py +++ /dev/null @@ -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 diff --git a/ChanMacro/scoring/base.py b/ChanMacro/scoring/base.py deleted file mode 100644 index a135d8d..0000000 --- a/ChanMacro/scoring/base.py +++ /dev/null @@ -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.""" - ... diff --git a/ChanMacro/scoring/breadth_scorer.py b/ChanMacro/scoring/breadth_scorer.py deleted file mode 100644 index a68f726..0000000 --- a/ChanMacro/scoring/breadth_scorer.py +++ /dev/null @@ -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) diff --git a/ChanMacro/scoring/constants.py b/ChanMacro/scoring/constants.py deleted file mode 100644 index a3bb1d2..0000000 --- a/ChanMacro/scoring/constants.py +++ /dev/null @@ -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 diff --git a/ChanMacro/scoring/oi_matrix.py b/ChanMacro/scoring/oi_matrix.py deleted file mode 100644 index 71d1801..0000000 --- a/ChanMacro/scoring/oi_matrix.py +++ /dev/null @@ -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") diff --git a/ChanMacro/scoring/price_structure.py b/ChanMacro/scoring/price_structure.py deleted file mode 100644 index 743fbf7..0000000 --- a/ChanMacro/scoring/price_structure.py +++ /dev/null @@ -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" diff --git a/ChanMacro/scoring/volatility_regime.py b/ChanMacro/scoring/volatility_regime.py deleted file mode 100644 index 241c817..0000000 --- a/ChanMacro/scoring/volatility_regime.py +++ /dev/null @@ -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") diff --git a/ChanMacro/tests/__init__.py b/ChanMacro/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ChanMacro/tests/conftest.py b/ChanMacro/tests/conftest.py deleted file mode 100644 index 35ca5cc..0000000 --- a/ChanMacro/tests/conftest.py +++ /dev/null @@ -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 diff --git a/ChanMacro/tests/test_expectancy.py b/ChanMacro/tests/test_expectancy.py deleted file mode 100644 index cb59a4d..0000000 --- a/ChanMacro/tests/test_expectancy.py +++ /dev/null @@ -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 diff --git a/ChanMacro/tests/test_models.py b/ChanMacro/tests/test_models.py deleted file mode 100644 index c92a871..0000000 --- a/ChanMacro/tests/test_models.py +++ /dev/null @@ -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 diff --git a/ChanMacro/tests/test_regime.py b/ChanMacro/tests/test_regime.py deleted file mode 100644 index 480dc34..0000000 --- a/ChanMacro/tests/test_regime.py +++ /dev/null @@ -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 diff --git a/ChanMacro/tests/test_scoring.py b/ChanMacro/tests/test_scoring.py deleted file mode 100644 index 7a5ce57..0000000 --- a/ChanMacro/tests/test_scoring.py +++ /dev/null @@ -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 diff --git a/ChanMacro/trend_detector.py b/ChanMacro/trend_detector.py deleted file mode 100644 index ffe9bcc..0000000 --- a/ChanMacro/trend_detector.py +++ /dev/null @@ -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 - ) diff --git a/ChanMacro/validation/__init__.py b/ChanMacro/validation/__init__.py deleted file mode 100644 index 23ee1ac..0000000 --- a/ChanMacro/validation/__init__.py +++ /dev/null @@ -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 diff --git a/ChanMacro/validation/factor_validator.py b/ChanMacro/validation/factor_validator.py deleted file mode 100644 index ca7ccc9..0000000 --- a/ChanMacro/validation/factor_validator.py +++ /dev/null @@ -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) diff --git a/ChanMacro/validation/metrics.py b/ChanMacro/validation/metrics.py deleted file mode 100644 index 0bff5a5..0000000 --- a/ChanMacro/validation/metrics.py +++ /dev/null @@ -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), - } diff --git a/ChanMacro/validation/regime_validator.py b/ChanMacro/validation/regime_validator.py deleted file mode 100644 index be1f51b..0000000 --- a/ChanMacro/validation/regime_validator.py +++ /dev/null @@ -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) diff --git a/ChanMacro/validation/reporter.py b/ChanMacro/validation/reporter.py deleted file mode 100644 index 607b182..0000000 --- a/ChanMacro/validation/reporter.py +++ /dev/null @@ -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) diff --git a/ChanMacro/validation/transition_validator.py b/ChanMacro/validation/transition_validator.py deleted file mode 100644 index b5f7e41..0000000 --- a/ChanMacro/validation/transition_validator.py +++ /dev/null @@ -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) diff --git a/ChanMacro/web/__init__.py b/ChanMacro/web/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ChanMacro/web/app.py b/ChanMacro/web/app.py deleted file mode 100644 index 0fc152f..0000000 --- a/ChanMacro/web/app.py +++ /dev/null @@ -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) diff --git a/ChanMacro/web/static/js/dashboard.js b/ChanMacro/web/static/js/dashboard.js deleted file mode 100644 index 6d6fb53..0000000 --- a/ChanMacro/web/static/js/dashboard.js +++ /dev/null @@ -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 = - `
| 层级 | 样本 | 有效样本 | 原始胜率 | 后验胜率 | 平均收益 |
|---|