Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c1232555e |
+28
-45
@@ -1,59 +1,42 @@
|
|||||||
# MacOS
|
# MacOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Python
|
# Python编译文件和缓存
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
.pytest_cache/
|
|
||||||
|
|
||||||
# Logs & databases
|
# 策略文件的缓存
|
||||||
|
strategies/__pycache__/
|
||||||
|
|
||||||
|
# Machine Learning / AI model files
|
||||||
|
*_model*_xgb_model.json
|
||||||
|
*modelchan*.json
|
||||||
|
*.libsvm
|
||||||
|
feature_meta
|
||||||
|
*_model_feature_data.csv
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Database files
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-shm
|
*.sqlite-shm
|
||||||
*.sqlite-wal
|
*.sqlite-wal
|
||||||
|
.DS_Store
|
||||||
# Office documents kept alongside the repo but not part of it.
|
交易记录/~$交易规则.docx
|
||||||
# "~$" files are Excel's lock files, recreated every time a workbook is opened.
|
/datasvc/data
|
||||||
*.xlsx
|
.DS_Store
|
||||||
*.xls
|
.DS_Store
|
||||||
~$*
|
/data_provider/data
|
||||||
|
.DS_Store
|
||||||
# Local data
|
.DS_Store
|
||||||
data/
|
.DS_Store
|
||||||
|
.DS_Store
|
||||||
# Exchange history fetched by research/live/*.py. 40MB and re-fetchable from
|
.DS_Store
|
||||||
# the venue, so it stays local; the small result CSVs it feeds are committed.
|
.DS_Store
|
||||||
research/live/cache/
|
data_provider/._config.json
|
||||||
|
|
||||||
# Scratch outputs from short shakedown runs, superseded by the real collection.
|
|
||||||
research/out/archive/
|
|
||||||
|
|
||||||
# Per-trade simulation dumps from research/step*.py. 70MB+ and regenerable by
|
|
||||||
# rerunning the step; the summaries they feed live in HANDOFF.md.
|
|
||||||
research/out/*.feather
|
|
||||||
|
|
||||||
# Virtualenvs. venv writes its own .gitignore since 3.11, but only for the
|
|
||||||
# directory it creates — declare it here so other layouts are covered too.
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
|
|
||||||
# Local tooling
|
|
||||||
.gstack/
|
.gstack/
|
||||||
research/out/*.jsonl.gz
|
|
||||||
research/out/penetration.csv
|
|
||||||
research/out/shadow_*.csv
|
|
||||||
research/out/run_meta_*.json
|
|
||||||
|
|
||||||
# Telegram 凭据。**不要提交**
|
|
||||||
research/live/deploy/tg.env
|
|
||||||
|
|
||||||
# 生产状态与信号总线。刻意放在仓库外(LIVE_HOME / BUS_DIR),这几条只防
|
|
||||||
# 有人把它们指回仓库里:里面是日亏损累计与已处理信号键,被 git clean
|
|
||||||
# 清掉等于两道闸静默失忆
|
|
||||||
/live/deploy/live.env
|
|
||||||
live_state.json
|
|
||||||
live_trades.jsonl
|
|
||||||
signals_live.jsonl
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
缠论 (Chan Theory) technical analysis system for Freqtrade. Implements Chan Zhong Shui Chan's theory for crypto/stock trading, including fractal (分型), stroke (笔), segment (线段), pivot/center (中枢), and buy/sell point (买卖点) detection.
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
### Chan Theory Engine (`chan/` package)
|
||||||
|
|
||||||
|
引擎已分层解耦;根目录 `Chan*.py` / `TF_DF.py` 仅为兼容旧 import 的 shim。新代码优先:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from chan import ChanLun, TF_DF
|
||||||
|
from chan.indicators import IndicatorEngine, IndicatorStore
|
||||||
|
from chan.analysis.bsp_macd import confirm_bsp, bi_macd_area
|
||||||
|
```
|
||||||
|
|
||||||
|
| 层 | 路径 | 职责 |
|
||||||
|
|----|------|------|
|
||||||
|
| **core** | `chan/core/` | 纯结构:KLU→KLC→BI→SBI→SEG→ZS→BSP 几何;不依赖 talib/指标参数 |
|
||||||
|
| **indicators** | `chan/indicators/` | `IndicatorConfig` / `IndicatorEngine` / `IndicatorStore`;MACD/EMA/BB/RSI 外置计算 |
|
||||||
|
| **analysis** | `chan/analysis/` | 结构+指标接合:`ChanMACD`、`bsp_macd`(ConfirmedBSP)、Zone/Classifier 等 |
|
||||||
|
| **pipeline** | `chan/pipeline/` | `TF_DF` / `ChanLun` 编排:先指标再结构,可选 attach 兼容 |
|
||||||
|
|
||||||
|
流水线:
|
||||||
|
|
||||||
|
1. **`chan/core/ChanKLU.py`** — K 线单元(OHLCV + 结构链);指标字段仅兼容挂载
|
||||||
|
2. **`chan/core/ChanKLC.py`** — 合并 K 线:包含处理、分型
|
||||||
|
3. **`chan/core/ChanBI.py`** — 笔
|
||||||
|
4. **`chan/core/ChanSBI.py`** — 特征笔
|
||||||
|
5. **`chan/core/ChanSEG.py`** — 线段
|
||||||
|
6. **`chan/core/ChanZS.py`** / **`ChanBIZS.py`** — 中枢
|
||||||
|
7. **`chan/core/ChanBSP.py`** — 几何买卖点
|
||||||
|
8. **`chan/analysis/bsp_macd.py`** — 买卖点 × MACD 背驰 → `ConfirmedBSP`
|
||||||
|
9. **`chan/pipeline/ChanLun.py`** / **`TF_DF.py`** — 多周期/单周期编排
|
||||||
|
|
||||||
|
### Support modules
|
||||||
|
|
||||||
|
- **`chan/core/ChanEnum.py`** — 枚举
|
||||||
|
- **`chan/core/ChanCTime.py`** — 时间工具
|
||||||
|
- **`chan/analysis/ChanMACD*.py`** — MACD 状态/段分析(读 KLU 上兼容指标或 Store)
|
||||||
|
- **`chan/analysis/ChanZone.py`** / **`ChanHeng.py`** / **`ChanLun_Classifier.py`** 等 — 分析扩展
|
||||||
|
- **`chan/analysis/ChanPY.py`** — 外部 chan.py 桥接(与本包名冲突已隔离)
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
- **`data_provider/`** — FastAPI data service: fetches crypto data from Binance via CCXT, caches to CSV, serves REST API + WebSocket. Synthesizes derived timeframes (e.g. 5m/15m/4h from 1m/1h base). Port 9009.
|
||||||
|
- **`web/`** — Flask web UI for interactive chart visualization with Chan theory overlays. Port 8123.(本重构分支不改 web)
|
||||||
|
- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (53 strategies)
|
||||||
|
- **`config/`** — Freqtrade JSON config files per pair/timeframe
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Exchange (CCXT) → data_provider (CSV cache) → Freqtrade → Strategy
|
||||||
|
→ ChanLun / TF_DF
|
||||||
|
→ IndicatorEngine → IndicatorStore
|
||||||
|
→ KLU → KLC → BI → SBI → SEG → ZS → 几何 BSP
|
||||||
|
→ analysis.bsp_macd → ConfirmedBSP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Freqtrade Trading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Live trade
|
||||||
|
freqtrade trade -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies
|
||||||
|
|
||||||
|
# Backtest
|
||||||
|
freqtrade backtesting -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies --timerange=20251008-
|
||||||
|
|
||||||
|
# Download data
|
||||||
|
freqtrade download-data -c ./user_data/Chan/config/<config>.json -t 1m 1h 1d --pairs BTC/USDT:USDT --timerange=20240101-
|
||||||
|
|
||||||
|
# Hyperopt
|
||||||
|
freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/<config>.json -e 200 --timerange=20250201-20250901
|
||||||
|
|
||||||
|
# Plot
|
||||||
|
freqtrade plot-dataframe --strategy <StrategyName> --datadir user_data/data/binance -c ./user_data/Chan/config/<config>.json --timerange=20250721-
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Provider
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker
|
||||||
|
cd data_provider && docker compose up -d
|
||||||
|
|
||||||
|
# Direct
|
||||||
|
cd data_provider && python main.py
|
||||||
|
|
||||||
|
# With custom config
|
||||||
|
CONFIG_PATH=./config.json python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && python app.py
|
||||||
|
# or via gunicorn:
|
||||||
|
gunicorn -w 4 -b 0.0.0.0:8123 app:app
|
||||||
|
|
||||||
|
# Deploy scripts:
|
||||||
|
cd web && ./deploy.sh # standard
|
||||||
|
cd web && ./deploy_venv.sh # Ubuntu 22.04+ (venv)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (Freqtrade)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose run --rm chanlun_btc backtesting -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies --timerange=20250721-
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- All Chan theory classes are prefixed with `Chan` (e.g., `ChanBI`, `ChanZS`)
|
||||||
|
- Strategies import `ChanLun` and add `sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))` to import from parent
|
||||||
|
- MACD params: `MACD(26, 52, 9)` by default (slow period 52 instead of standard 26)
|
||||||
|
- Enums in `ChanEnum.py` use `auto()` values
|
||||||
|
- `ChanKLC` is a linked-list style data structure with `.next`/`.pre` pointers
|
||||||
|
- The `TF_DF` class is the primary data container per timeframe
|
||||||
|
- K-line direction uses `Chan_KLINE_DIR` (UP/DOWN/COMBINE/INCLUDED)
|
||||||
|
- All text comments/commits are in Chinese
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanBI")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanBIZS")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanBSP")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanCTime")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanEnum")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanHeng")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanKLC")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanKLU")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.pipeline.ChanLun")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanLun_Classifier")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanMACD")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanMACDHistSet")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanMACDSeg")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanMACDUnitTF")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Data Provider URL (existing chan data_provider service)
|
||||||
|
PROVIDER_URL=http://127.0.0.1:80
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH=data/macro.db
|
||||||
|
|
||||||
|
# Telegram (reuse bsp_monitor config)
|
||||||
|
# TELEGRAM_BOT_TOKEN=your_bot_token
|
||||||
|
# TELEGRAM_CHAT_ID=your_chat_id
|
||||||
|
|
||||||
|
# AI API (for daily report, Phase 5+)
|
||||||
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
data/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
ChanMacro — Crypto Market Memory System (Signal Expectancy Engine).
|
||||||
|
|
||||||
|
V1: 4 factors (Price Structure, Breadth, OI State, Volatility Regime)
|
||||||
|
3 regimes (TREND / RANGE / PANIC)
|
||||||
|
Factor-locked: Regime = f(Price, Breadth, Vol) — forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
chan_integration.py — 缠论引擎集成:检测 BSP 信号并写入 signal_features。
|
||||||
|
|
||||||
|
复用 bsp_monitor/engine.py 的 ChanEngine 管线,对历史日线数据批量跑缠论,
|
||||||
|
提取 B1/B2/B3/S1/S2/S3 信号,通过 SignalTracker 记录到 signal_features。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import date as Date, timedelta
|
||||||
|
from typing import List, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# 确保 Chan 引擎在路径上(与 bsp_monitor/engine.py 相同的路径设置)
|
||||||
|
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _PARENT not in sys.path:
|
||||||
|
sys.path.insert(0, _PARENT)
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR
|
||||||
|
from ChanBSP import ChanBSP
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ChanSignalDetector:
|
||||||
|
"""
|
||||||
|
对历史日线数据运行缠论管线,提取所有 BSP 信号。
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
detector = ChanSignalDetector()
|
||||||
|
signals = detector.detect_from_db("2026-01-01", "2026-06-24")
|
||||||
|
# → [{"date": Date, "signal_type": "B3", "entry_price": 96500, ...}, ...]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
from TF_DF import TF_DF as _TF_DF_Class
|
||||||
|
self._TF_DF_Class = _TF_DF_Class
|
||||||
|
|
||||||
|
def detect_from_db(self, start_date: str, end_date: str) -> list[dict]:
|
||||||
|
"""从数据库加载日线数据,跑缠论管线,提取信号。"""
|
||||||
|
from database import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"SELECT date, open, high, low, close, volume "
|
||||||
|
"FROM ohlcv_daily WHERE symbol='BTC/USDT:USDT' "
|
||||||
|
"AND date BETWEEN ? AND ? ORDER BY date",
|
||||||
|
conn, params=(start_date, end_date)
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if df.empty or len(df) < 50:
|
||||||
|
logger.warning(f"日线数据不足: {len(df)} 根")
|
||||||
|
return []
|
||||||
|
|
||||||
|
return self.detect_from_df(df)
|
||||||
|
|
||||||
|
def detect_from_df(self, df: pd.DataFrame) -> list[dict]:
|
||||||
|
"""从 DataFrame 运行缠论管线,提取 BSP 信号。"""
|
||||||
|
# 需要 datetime 列才能跑 TF_DF
|
||||||
|
df = df.copy()
|
||||||
|
df["timestamp"] = pd.to_datetime(df["date"])
|
||||||
|
df["date"] = df["timestamp"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = self._build_engine(df)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"缠论管线失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
return self._extract_signals(engine)
|
||||||
|
|
||||||
|
def _build_engine(self, df: pd.DataFrame):
|
||||||
|
"""构建缠论管线(对齐 bsp_monitor/engine.py 的 ChanEngine)。"""
|
||||||
|
from TF_DF import TF_DF as _TF_DF_Class
|
||||||
|
|
||||||
|
if df.empty or len(df) < 50:
|
||||||
|
raise ValueError(f"数据不足: {len(df)} 根 K 线")
|
||||||
|
|
||||||
|
if "date" not in df.columns and "timestamp" in df.columns:
|
||||||
|
df["date"] = df["timestamp"]
|
||||||
|
|
||||||
|
# 使用 __new__ 避免触发 TF_DF.__init__
|
||||||
|
engine = type('ChanEngine', (), {})() # 简单容器
|
||||||
|
tf = _TF_DF_Class.__new__(_TF_DF_Class)
|
||||||
|
|
||||||
|
df_with_indicators = tf.add_indicators(df.copy())
|
||||||
|
engine.klu_list = tf.get_klu_list(df_with_indicators)
|
||||||
|
engine.klc_list = tf.get_klc_list(engine.klu_list)
|
||||||
|
engine.bi_list = tf.cal_bi_list(engine.klc_list)
|
||||||
|
engine.seg_list = tf.get_seg_list(engine.bi_list)
|
||||||
|
engine.bi_zs_list = tf.cal_bi_zs(engine.seg_list)
|
||||||
|
engine.bsp_list = tf.find_all_bsp(engine.bi_list, engine.bi_zs_list)
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
def _extract_signals(self, engine) -> list[dict]:
|
||||||
|
"""从 ChanEngine 输出中提取所有 BSP 信号。"""
|
||||||
|
signals = []
|
||||||
|
for bsp in engine.bsp_list:
|
||||||
|
if bsp.type == Chan_BSP_TYPE.NONE:
|
||||||
|
continue
|
||||||
|
if bsp.klc is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
signal_type = self._bsp_type_str(bsp.type)
|
||||||
|
entry_price = bsp.klc.close
|
||||||
|
signal_date = self._klc_date(bsp.klc)
|
||||||
|
|
||||||
|
if signal_date is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 信号质量:根据分型强度判断
|
||||||
|
strength = self._calc_strength(bsp)
|
||||||
|
grade = "A" if strength >= 70 else "B" if strength >= 50 else "C"
|
||||||
|
|
||||||
|
signals.append({
|
||||||
|
"date": signal_date,
|
||||||
|
"signal_type": signal_type,
|
||||||
|
"entry_price": float(entry_price),
|
||||||
|
"signal_grade": grade,
|
||||||
|
"signal_strength": float(strength),
|
||||||
|
})
|
||||||
|
|
||||||
|
details = ", ".join(f"{s['signal_type']}({s['date']})" for s in signals)
|
||||||
|
logger.info(f"检测到 {len(signals)} 个信号: {details}")
|
||||||
|
return signals
|
||||||
|
|
||||||
|
def populate_signal_features(self, start_date: str = "2024-01-01",
|
||||||
|
end_date: Optional[str] = None) -> int:
|
||||||
|
"""
|
||||||
|
完整流程:检测信号 → 计算市场状态 → 写入 signal_features。
|
||||||
|
|
||||||
|
Returns: 写入的信号数量。
|
||||||
|
"""
|
||||||
|
if end_date is None:
|
||||||
|
end_date = Date.today().isoformat()
|
||||||
|
|
||||||
|
logger.info(f"开始信号检测: {start_date} → {end_date}")
|
||||||
|
|
||||||
|
# Step 1: 检测缠论信号
|
||||||
|
signals = self.detect_from_db(start_date, end_date)
|
||||||
|
if not signals:
|
||||||
|
logger.warning("未检测到任何 BSP 信号")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Step 2: 去重 — 跳过已存在的信号
|
||||||
|
from database import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
existing = set()
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT date, signal_type FROM signal_features"
|
||||||
|
).fetchall():
|
||||||
|
existing.add((row[0], row[1]))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
new_signals = [s for s in signals
|
||||||
|
if (str(s["date"]), s["signal_type"]) not in existing]
|
||||||
|
if not new_signals:
|
||||||
|
logger.info("所有信号已存在,跳过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Step 3: 写入 signal_features
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
tracker = SignalTracker()
|
||||||
|
count = tracker.backfill_signals(new_signals)
|
||||||
|
|
||||||
|
logger.info(f"信号入库完成: {count}/{len(signals)}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bsp_type_str(t: Chan_BSP_TYPE) -> str:
|
||||||
|
mapping = {
|
||||||
|
Chan_BSP_TYPE.B1: "B1", Chan_BSP_TYPE.B2: "B2", Chan_BSP_TYPE.B3: "B3",
|
||||||
|
Chan_BSP_TYPE.S1: "S1", Chan_BSP_TYPE.S2: "S2", Chan_BSP_TYPE.S3: "S3",
|
||||||
|
}
|
||||||
|
return mapping.get(t, "UNKNOWN")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _klc_date(klc) -> Optional[Date]:
|
||||||
|
"""从 KLC 提取信号确认日期。"""
|
||||||
|
end_time = getattr(klc, "end_time", None)
|
||||||
|
if end_time is None:
|
||||||
|
start_time = getattr(klc, "start_time", None)
|
||||||
|
if start_time is None:
|
||||||
|
return None
|
||||||
|
end_time = start_time
|
||||||
|
if hasattr(end_time, "date"):
|
||||||
|
return end_time.date()
|
||||||
|
if isinstance(end_time, str):
|
||||||
|
return Date.fromisoformat(end_time[:10])
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_strength(bsp: ChanBSP) -> float:
|
||||||
|
"""根据 BSP 特征计算信号强度 0-100。"""
|
||||||
|
score = 50.0
|
||||||
|
klc = bsp.klc
|
||||||
|
if klc is None:
|
||||||
|
return score
|
||||||
|
|
||||||
|
# 分型强度
|
||||||
|
from ChanEnum import Chan_KLC_FX
|
||||||
|
fx = getattr(klc, "klc_fx_type", None)
|
||||||
|
if fx is not None:
|
||||||
|
strong_fxs = {Chan_KLC_FX.TOP2, Chan_KLC_FX.TOP3, Chan_KLC_FX.BOTTOM2, Chan_KLC_FX.BOTTOM3}
|
||||||
|
medium_fxs = {Chan_KLC_FX.TOP1, Chan_KLC_FX.BOTTOM1, Chan_KLC_FX.TOP4, Chan_KLC_FX.BOTTOM4}
|
||||||
|
if fx in strong_fxs:
|
||||||
|
score += 25
|
||||||
|
elif fx in medium_fxs:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# BSP 类型
|
||||||
|
if bsp.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1):
|
||||||
|
score += 10 # 一类买卖点: 背驰确认, 额外加分
|
||||||
|
|
||||||
|
# 笔特征
|
||||||
|
bi = getattr(bsp, "bi", None)
|
||||||
|
if bi and hasattr(bi, "height") and hasattr(bi, "width"):
|
||||||
|
if bi.width > 3 and abs(bi.height) > 100:
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
return min(score, 100.0)
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"""
|
||||||
|
cli.py — Command-line interface for ChanMacro.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import date as Date, datetime, timedelta
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("chanmacro")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str: str) -> Date:
|
||||||
|
"""Parse YYYY-MM-DD string to Date."""
|
||||||
|
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_market_state(target: Date) -> tuple:
|
||||||
|
"""Shared helper: compute all scores → (MarketStateVector, RegimeResult)."""
|
||||||
|
from config import config
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
from scoring.breadth_scorer import BreadthScorer
|
||||||
|
from scoring.oi_matrix import OIMatrixScorer
|
||||||
|
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketStateVector
|
||||||
|
|
||||||
|
ps = PriceStructureScorer().compute(target)
|
||||||
|
br = BreadthScorer().compute(target)
|
||||||
|
oi = OIMatrixScorer().compute(target)
|
||||||
|
vol = VolatilityRegimeScorer().compute(target)
|
||||||
|
|
||||||
|
detector = RegimeDetector()
|
||||||
|
detector.load_state(config.db_path)
|
||||||
|
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
|
||||||
|
|
||||||
|
state = MarketStateVector(
|
||||||
|
date=target, regime=r.regime, regime_confidence=r.confidence,
|
||||||
|
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
|
||||||
|
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
|
||||||
|
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
|
||||||
|
breadth_divergence=br.breadth_divergence,
|
||||||
|
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
|
||||||
|
price_structure_score=ps, breadth_score=br,
|
||||||
|
oi_matrix_score=oi, volatility_regime_score=vol,
|
||||||
|
)
|
||||||
|
state.market_state_hash = state.compute_hash()
|
||||||
|
|
||||||
|
# Persist regime to DB so subsequent calls have correct state
|
||||||
|
from database import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO regime_history
|
||||||
|
(date, regime, confidence, regime_version, maturity_score, all_scores_json,
|
||||||
|
prior_regime, confirmation_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
str(target), r.regime.value, r.confidence, r.regime_version,
|
||||||
|
r.maturity_score, json.dumps(r.all_scores),
|
||||||
|
r.prior_regime.value if r.prior_regime else None,
|
||||||
|
r.confirmation_days,
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return state, r
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_fetch(args):
|
||||||
|
"""Fetch raw data and store to DB."""
|
||||||
|
from database import init_db
|
||||||
|
from fetchers.ohlcv import OHLCVFetcher
|
||||||
|
from fetchers.breadth import BreadthFetcher
|
||||||
|
|
||||||
|
target = parse_date(args.date) if args.date else Date.today()
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
module = args.module or "all"
|
||||||
|
|
||||||
|
if module in ("ohlcv", "all"):
|
||||||
|
logger.info(f"Fetching OHLCV for {target}...")
|
||||||
|
fetcher = OHLCVFetcher()
|
||||||
|
df = fetcher.fetch(target)
|
||||||
|
if not df.empty:
|
||||||
|
n = fetcher.store_df(df)
|
||||||
|
logger.info(f"OHLCV: stored {n} rows")
|
||||||
|
|
||||||
|
if module in ("breadth", "all"):
|
||||||
|
logger.info(f"Fetching Breadth for {target}...")
|
||||||
|
fetcher = BreadthFetcher()
|
||||||
|
record = fetcher.fetch(target)
|
||||||
|
if record:
|
||||||
|
fetcher.store(record=record)
|
||||||
|
logger.info(f"Breadth: stored (adv={record.get('advance_top50')}, "
|
||||||
|
f"dec={record.get('decline_top50')}, "
|
||||||
|
f"ema20={record.get('above_ema20_top50')})")
|
||||||
|
|
||||||
|
if module in ("derivatives", "all"):
|
||||||
|
logger.info(f"Fetching Derivatives for {target}...")
|
||||||
|
from fetchers.derivatives import DerivativesFetcher
|
||||||
|
fetcher = DerivativesFetcher()
|
||||||
|
records = fetcher.fetch(target)
|
||||||
|
if records:
|
||||||
|
n = fetcher.store(records=records)
|
||||||
|
logger.info(f"Derivatives: stored {n} records")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_score(args):
|
||||||
|
"""Compute all factor scores and regime for a date."""
|
||||||
|
from database import init_db
|
||||||
|
|
||||||
|
target = parse_date(args.date) if args.date else Date.today()
|
||||||
|
init_db()
|
||||||
|
logger.info(f"Computing scores for {target}...")
|
||||||
|
|
||||||
|
state, _ = _build_market_state(target)
|
||||||
|
|
||||||
|
# Output
|
||||||
|
ps = state.price_structure_score
|
||||||
|
br = state.breadth_score
|
||||||
|
oi = state.oi_matrix_score
|
||||||
|
vol = state.volatility_regime_score
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" {target} Market State")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f" Regime: {state.regime.value} (conf={state.regime_confidence:.2f}, "
|
||||||
|
f"v={state.regime_version})")
|
||||||
|
print(f" Maturity: {state.regime_maturity_score:.0f}/100")
|
||||||
|
print(f" Breadth: {state.breadth_bucket.value} "
|
||||||
|
f"(T20={state.breadth_top20:.0f} T30={state.breadth_top30:.0f} "
|
||||||
|
f"T50={state.breadth_top50:.0f} div={state.breadth_divergence:+.0f})")
|
||||||
|
print(f" OI State: {state.oi_state.value}")
|
||||||
|
print(f" Volatility: {state.volatility_regime.value}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f" Scores:")
|
||||||
|
print(f" Price Structure: {ps.score:.0f} {ps.label}")
|
||||||
|
print(f" Breadth: {br.score:.0f} {br.breadth_bucket.value}")
|
||||||
|
print(f" OI Matrix: {oi.score:.0f} {oi.oi_state.value}")
|
||||||
|
print(f" Volatility: {vol.score:.0f} {vol.vol_regime.value}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f" Market State Hash: {state.market_state_hash}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_regime(args):
|
||||||
|
"""Show regime history."""
|
||||||
|
from database import get_connection
|
||||||
|
days = args.days or 30
|
||||||
|
conn = get_connection()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT date, regime, confidence, maturity_score, confirmation_days "
|
||||||
|
"FROM regime_history ORDER BY date DESC LIMIT ?",
|
||||||
|
(days,)
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f" Regime History (last {days} days)")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
for r in rows:
|
||||||
|
print(f" {r['date']} {r['regime']:7s} conf={r['confidence']:.2f} "
|
||||||
|
f"mat={r['maturity_score']:.0f} days={r['confirmation_days']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_track(args):
|
||||||
|
"""Record a trading signal with current market state."""
|
||||||
|
from database import init_db
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
|
||||||
|
target = parse_date(args.date) if args.date else Date.today()
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
logger.info(f"Recording {args.signal} on {target} @ {args.price}")
|
||||||
|
|
||||||
|
state, _ = _build_market_state(target)
|
||||||
|
|
||||||
|
tracker = SignalTracker()
|
||||||
|
rid = tracker.record(
|
||||||
|
date=target, signal_type=args.signal, entry_price=args.price,
|
||||||
|
state=state, signal_grade=args.grade, signal_strength=args.strength,
|
||||||
|
)
|
||||||
|
logger.info(f"Signal recorded: id={rid}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_backfill(args):
|
||||||
|
"""Backfill historical breadth + regime scores."""
|
||||||
|
from datetime import date as Date, timedelta
|
||||||
|
from database import init_db, get_connection
|
||||||
|
from fetchers.ohlcv import OHLCVFetcher
|
||||||
|
from fetchers.breadth import BreadthFetcher
|
||||||
|
from config import config
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
start = parse_date(args.from_date)
|
||||||
|
end = parse_date(args.to_date) if args.to_date else Date.today()
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Step 1: Ensure OHLCV data exists for the range
|
||||||
|
logger.info(f"Step 1/3: Fetching BTC OHLCV...")
|
||||||
|
OHLCVFetcher().store_df(OHLCVFetcher().fetch())
|
||||||
|
|
||||||
|
# Step 2: Backfill breadth — fetch TOP50 daily data and compute per date
|
||||||
|
logger.info(f"Step 2/3: Backfilling breadth {start} → {end}...")
|
||||||
|
provider_url = config.provider_url
|
||||||
|
all_symbol_data = {}
|
||||||
|
|
||||||
|
for sym in config.top50_symbols:
|
||||||
|
try:
|
||||||
|
df = pd.DataFrame(requests.get(
|
||||||
|
f"{provider_url}/api/candles",
|
||||||
|
params={"symbol": sym, "tf": "1d", "limit": 400},
|
||||||
|
timeout=30
|
||||||
|
).json())
|
||||||
|
if not df.empty and "timestamp" in df.columns:
|
||||||
|
df["date"] = pd.to_datetime(df["timestamp"], unit="ms").dt.date
|
||||||
|
df["close"] = df["close"].astype(float)
|
||||||
|
df["high"] = df["high"].astype(float)
|
||||||
|
df["ema20"] = df["close"].ewm(20).mean()
|
||||||
|
all_symbol_data[sym] = df
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f" Skip {sym}: {e}")
|
||||||
|
|
||||||
|
logger.info(f" Fetched {len(all_symbol_data)}/{len(config.top50_symbols)} symbols")
|
||||||
|
|
||||||
|
# Compute breadth for each date
|
||||||
|
conn = get_connection()
|
||||||
|
current = start
|
||||||
|
breadth_count = 0
|
||||||
|
while current <= end:
|
||||||
|
target_str = str(current)
|
||||||
|
try:
|
||||||
|
advances_50 = declines_50 = above_ema20_50 = new_highs_50 = 0
|
||||||
|
advances_30 = advances_20 = above_ema20_30 = above_ema20_20 = 0
|
||||||
|
new_highs_30 = new_highs_20 = 0
|
||||||
|
|
||||||
|
for rank, (sym, df) in enumerate(all_symbol_data.items()):
|
||||||
|
rows = df[df["date"] == current]
|
||||||
|
if rows.empty:
|
||||||
|
continue
|
||||||
|
row = rows.iloc[0]
|
||||||
|
prev_rows = df[df["date"] < current]
|
||||||
|
if prev_rows.empty:
|
||||||
|
continue
|
||||||
|
prev = prev_rows.iloc[-1]
|
||||||
|
|
||||||
|
if row["close"] > prev["close"]:
|
||||||
|
if rank < 50: advances_50 += 1
|
||||||
|
if rank < 30: advances_30 += 1
|
||||||
|
if rank < 20: advances_20 += 1
|
||||||
|
elif row["close"] < prev["close"]:
|
||||||
|
if rank < 50: declines_50 += 1
|
||||||
|
|
||||||
|
if not pd.isna(row.get("ema20")) and row["close"] > row["ema20"]:
|
||||||
|
if rank < 50: above_ema20_50 += 1
|
||||||
|
if rank < 30: above_ema20_30 += 1
|
||||||
|
if rank < 20: above_ema20_20 += 1
|
||||||
|
|
||||||
|
recent_highs = df[(df["date"] < current) & (df["date"] >= current - timedelta(days=20))]
|
||||||
|
if not recent_highs.empty and row["high"] > recent_highs["high"].max():
|
||||||
|
if rank < 50: new_highs_50 += 1
|
||||||
|
if rank < 30: new_highs_30 += 1
|
||||||
|
if rank < 20: new_highs_20 += 1
|
||||||
|
|
||||||
|
conn.execute("""INSERT OR REPLACE INTO breadth_daily
|
||||||
|
(date, total_tracked, advance_top50, decline_top50, above_ema20_top50,
|
||||||
|
new_highs_20d_top50, advance_top30, advance_top20,
|
||||||
|
above_ema20_top30, above_ema20_top20, new_highs_20d_top30, new_highs_20d_top20)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(target_str, len(all_symbol_data),
|
||||||
|
advances_50, declines_50, above_ema20_50, new_highs_50,
|
||||||
|
advances_30, advances_20, above_ema20_30, above_ema20_20,
|
||||||
|
new_highs_30, new_highs_20))
|
||||||
|
breadth_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f" Breadth skip {current}: {e}")
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f" Breadth backfill: {breadth_count} days")
|
||||||
|
|
||||||
|
# Step 3: Compute regime scores for each date
|
||||||
|
logger.info(f"Step 3/3: Computing regime scores {start} → {end}...")
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
from scoring.breadth_scorer import BreadthScorer
|
||||||
|
from scoring.oi_matrix import OIMatrixScorer
|
||||||
|
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
|
||||||
|
detector = RegimeDetector()
|
||||||
|
current = start
|
||||||
|
score_count = 0
|
||||||
|
while current <= end:
|
||||||
|
try:
|
||||||
|
ps = PriceStructureScorer().compute(current)
|
||||||
|
br = BreadthScorer().compute(current)
|
||||||
|
if br.score == 50.0 and br.label == "No Data":
|
||||||
|
current += timedelta(days=1)
|
||||||
|
continue
|
||||||
|
oi = OIMatrixScorer().compute(current)
|
||||||
|
vol = VolatilityRegimeScorer().compute(current)
|
||||||
|
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, current)
|
||||||
|
|
||||||
|
conn.execute("""INSERT OR REPLACE INTO regime_history
|
||||||
|
(date, regime, confidence, regime_version, maturity_score,
|
||||||
|
all_scores_json, confirmation_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(str(current), r.regime.value, r.confidence, r.regime_version,
|
||||||
|
r.maturity_score, json.dumps(r.all_scores), r.confirmation_days))
|
||||||
|
score_count += 1
|
||||||
|
if score_count % 30 == 0:
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f" Scored {score_count} days... ({current})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f" Score skip {current}: {e}")
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info(f"Backfill complete: {breadth_count} breadth + {score_count} regime days")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_expectancy(args):
|
||||||
|
"""Query signal expectancy for current market state."""
|
||||||
|
from database import init_db
|
||||||
|
from expectancy.engine import BayesianExpectancyEngine
|
||||||
|
|
||||||
|
target = parse_date(args.date) if args.date else Date.today()
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
state, _ = _build_market_state(target)
|
||||||
|
|
||||||
|
engine = BayesianExpectancyEngine()
|
||||||
|
signal = args.signal or "B3"
|
||||||
|
report = engine.estimate(state, signal_type=signal, target_date=target)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" {target} Signal Expectancy: {signal}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f" Regime: {state.regime.value} (conf={state.regime_confidence:.2f})")
|
||||||
|
print(f" Breadth: {state.breadth_bucket.value} (T50={state.breadth_top50:.0f})")
|
||||||
|
print(f" OI State: {state.oi_state.value}")
|
||||||
|
print(f" Volatility: {state.volatility_regime.value}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
for layer in report.layers:
|
||||||
|
print(f" {layer.name:15s} N={layer.samples:4d} eff={layer.effective_samples:.0f} "
|
||||||
|
f"raw={layer.raw_winrate or 0:.1%} post={layer.posterior_winrate:.1%} "
|
||||||
|
f"ret={layer.avg_return or 0:+.1f}%")
|
||||||
|
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f" Final: {report.final_estimate:.1%} "
|
||||||
|
f"(sufficiency={report.sufficiency.value}, source={report.source})")
|
||||||
|
if report.profit_factor:
|
||||||
|
print(f" PF={report.profit_factor} MAE={report.max_adverse_excursion}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="ChanMacro — Crypto Market Memory System"
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", help="Commands")
|
||||||
|
|
||||||
|
# fetch
|
||||||
|
p_fetch = sub.add_parser("fetch", help="Fetch raw data")
|
||||||
|
p_fetch.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||||
|
p_fetch.add_argument("--module", choices=["ohlcv", "breadth", "derivatives", "all"])
|
||||||
|
|
||||||
|
# score
|
||||||
|
p_score = sub.add_parser("score", help="Compute scores and regime")
|
||||||
|
p_score.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||||
|
|
||||||
|
# regime
|
||||||
|
p_regime = sub.add_parser("regime", help="Show regime history")
|
||||||
|
p_regime.add_argument("--days", type=int, default=30)
|
||||||
|
|
||||||
|
# track
|
||||||
|
p_track = sub.add_parser("track", help="Record a trading signal")
|
||||||
|
p_track.add_argument("--date", help="Signal date (YYYY-MM-DD)")
|
||||||
|
p_track.add_argument("--signal", required=True, help="Signal type (B1/B2/B3/S1/S2/S3)")
|
||||||
|
p_track.add_argument("--price", type=float, required=True, help="Entry price")
|
||||||
|
p_track.add_argument("--grade", choices=["A", "B", "C"], help="Signal quality grade")
|
||||||
|
p_track.add_argument("--strength", type=float, help="Signal strength 0-100")
|
||||||
|
|
||||||
|
# backfill
|
||||||
|
p_backfill = sub.add_parser("backfill", help="Backfill historical scores")
|
||||||
|
p_backfill.add_argument("--from", dest="from_date", required=True)
|
||||||
|
p_backfill.add_argument("--to", dest="to_date")
|
||||||
|
|
||||||
|
# expectancy
|
||||||
|
p_expectancy = sub.add_parser("expectancy", help="Query signal expectancy")
|
||||||
|
p_expectancy.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||||
|
p_expectancy.add_argument("--signal", default="B3", help="Signal type")
|
||||||
|
|
||||||
|
# validate
|
||||||
|
p_validate = sub.add_parser("validate", help="Run validation framework")
|
||||||
|
|
||||||
|
# cron
|
||||||
|
p_cron = sub.add_parser("cron", help="Run scheduled fetch+score loop")
|
||||||
|
# detect (Chan BSP signals)
|
||||||
|
p_detect = sub.add_parser("detect", help="Detect Chan BSP signals and populate signal_features")
|
||||||
|
p_detect.add_argument("--from", dest="from_date", default="2024-01-01")
|
||||||
|
p_detect.add_argument("--to", dest="to_date")
|
||||||
|
# serve
|
||||||
|
p_serve = sub.add_parser("serve", help="Start web dashboard")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "fetch":
|
||||||
|
cmd_fetch(args)
|
||||||
|
elif args.command == "score":
|
||||||
|
cmd_score(args)
|
||||||
|
elif args.command == "regime":
|
||||||
|
cmd_regime(args)
|
||||||
|
elif args.command == "track":
|
||||||
|
cmd_track(args)
|
||||||
|
elif args.command == "backfill":
|
||||||
|
cmd_backfill(args)
|
||||||
|
elif args.command == "expectancy":
|
||||||
|
cmd_expectancy(args)
|
||||||
|
elif args.command == "validate":
|
||||||
|
from validation.reporter import ValidationReporter
|
||||||
|
report = ValidationReporter().run_all()
|
||||||
|
print(report)
|
||||||
|
elif args.command == "detect":
|
||||||
|
from chan_integration import ChanSignalDetector
|
||||||
|
start = args.from_date
|
||||||
|
end = args.to_date or Date.today().isoformat()
|
||||||
|
detector = ChanSignalDetector()
|
||||||
|
count = detector.populate_signal_features(start, end)
|
||||||
|
logger.info(f"写入 {count} 条信号记录")
|
||||||
|
elif args.command == "serve":
|
||||||
|
from scheduler import get_scheduler
|
||||||
|
get_scheduler().start()
|
||||||
|
logger.info("启动 Web Dashboard: http://127.0.0.1:8124")
|
||||||
|
from web.app import app
|
||||||
|
app.run(host="0.0.0.0", port=8124, debug=False)
|
||||||
|
elif args.command == "cron":
|
||||||
|
from scheduler import get_scheduler
|
||||||
|
logger.info("启动后台调度器 (Ctrl+C 停止)")
|
||||||
|
s = get_scheduler()
|
||||||
|
s.start()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
s.stop()
|
||||||
|
logger.info("调度器已停止")
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"provider_url": "https://provider.jackyu66.com",
|
||||||
|
"db_path": "data/macro.db",
|
||||||
|
"btc_symbol": "BTC/USDT:USDT",
|
||||||
|
"regime_version": "v1_price_breadth_vol",
|
||||||
|
"half_life_days": 180,
|
||||||
|
"sufficiency_min_effective": 30,
|
||||||
|
"sufficiency_low": 50,
|
||||||
|
"sufficiency_medium": 100,
|
||||||
|
"level_min_samples": 50,
|
||||||
|
"knn_max_distance": 0.35,
|
||||||
|
"knn_k": 200,
|
||||||
|
"oi_price_threshold_pct": 0.5,
|
||||||
|
"oi_oi_threshold_pct": 0.5,
|
||||||
|
"vol_low_threshold": 2.0,
|
||||||
|
"vol_high_threshold": 5.0,
|
||||||
|
"vol_explosive_threshold": 10.0,
|
||||||
|
"regime_w_price": 0.35,
|
||||||
|
"regime_w_breadth": 0.50,
|
||||||
|
"regime_w_vol": 0.15,
|
||||||
|
"trend_w_price": 0.30,
|
||||||
|
"trend_w_breadth": 0.70
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
config.py — Global configuration for ChanMacro.
|
||||||
|
|
||||||
|
All weights, thresholds, and paths are configurable.
|
||||||
|
V1 weights are deliberately simple; they will be tuned via Phase 0 validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""Global configuration. Override via config.json or env vars."""
|
||||||
|
|
||||||
|
# ── Paths ──────────────────────────────────────────────
|
||||||
|
db_path: str = "data/macro.db"
|
||||||
|
data_dir: str = "data"
|
||||||
|
|
||||||
|
# ── Data Provider ──────────────────────────────────────
|
||||||
|
provider_url: str = "https://provider.jackyu66.com"
|
||||||
|
btc_symbol: str = "BTC/USDT:USDT"
|
||||||
|
top50_symbols: list[str] = field(default_factory=lambda: [
|
||||||
|
"BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT",
|
||||||
|
"BNB/USDT:USDT", "XRP/USDT:USDT", "DOGE/USDT:USDT",
|
||||||
|
"SUI/USDT:USDT", "TON/USDT:USDT", "ZEC/USDT:USDT",
|
||||||
|
"1000PEPE/USDT:USDT", "SAGA/USDT:USDT",
|
||||||
|
"XAU/USDT:USDT", "XAG/USDT:USDT",
|
||||||
|
"CL/USDT:USDT", "BILL/USDT:USDT", "BZ/USDT:USDT",
|
||||||
|
"LAB/USDT:USDT", "CRCL/USDT:USDT", "SNDK/USDT:USDT",
|
||||||
|
"CHIP/USDT:USDT",
|
||||||
|
])
|
||||||
|
|
||||||
|
# ── Breadth ────────────────────────────────────────────
|
||||||
|
breadth_top_n: list[int] = field(default_factory=lambda: [20, 30, 50])
|
||||||
|
breadth_ema_period: int = 20
|
||||||
|
breadth_new_high_window: int = 20
|
||||||
|
|
||||||
|
# ── Regime (factor-locked: Price + Breadth + Vol) ─────
|
||||||
|
regime_version: str = "v1_price_breadth_vol"
|
||||||
|
# Weights for trend_score within regime detection
|
||||||
|
regime_w_price: float = 0.35
|
||||||
|
regime_w_breadth: float = 0.50
|
||||||
|
regime_w_vol: float = 0.15
|
||||||
|
# Weights for panic_score
|
||||||
|
regime_panic_w_anti_trend: float = 0.60
|
||||||
|
regime_panic_w_vol_extreme: float = 0.40
|
||||||
|
|
||||||
|
# ── Price Structure ────────────────────────────────────
|
||||||
|
ps_ema_fast: int = 20
|
||||||
|
ps_ema_mid: int = 60
|
||||||
|
ps_ema_slow: int = 120
|
||||||
|
ps_adx_period: int = 14
|
||||||
|
ps_adx_threshold: int = 25
|
||||||
|
ps_atr_period: int = 14
|
||||||
|
ps_bb_period: int = 20
|
||||||
|
ps_roc_periods: list[int] = field(default_factory=lambda: [5, 10, 20])
|
||||||
|
|
||||||
|
# ── OI Matrix ──────────────────────────────────────────
|
||||||
|
oi_price_threshold_pct: float = 0.5 # min price change% to classify
|
||||||
|
oi_oi_threshold_pct: float = 0.5 # min OI change% to classify
|
||||||
|
|
||||||
|
# ── Volatility Regime ──────────────────────────────────
|
||||||
|
vol_atr_period: int = 14
|
||||||
|
vol_hv_short: int = 20
|
||||||
|
vol_hv_long: int = 60
|
||||||
|
# Thresholds (ATR/Close %)
|
||||||
|
vol_low_threshold: float = 2.0
|
||||||
|
vol_high_threshold: float = 5.0
|
||||||
|
vol_explosive_threshold: float = 10.0
|
||||||
|
|
||||||
|
# ── Trend (L2 aggregation) ─────────────────────────────
|
||||||
|
trend_w_price: float = 0.30
|
||||||
|
trend_w_breadth: float = 0.70
|
||||||
|
|
||||||
|
# ── Maturity Score ─────────────────────────────────────
|
||||||
|
maturity_w_trend: float = 0.50
|
||||||
|
maturity_w_breadth: float = 0.30
|
||||||
|
maturity_w_vol: float = 0.20
|
||||||
|
|
||||||
|
# ── Expectancy ─────────────────────────────────────────
|
||||||
|
half_life_days: int = 180
|
||||||
|
sufficiency_min_effective: int = 30
|
||||||
|
sufficiency_low: int = 50
|
||||||
|
sufficiency_medium: int = 100
|
||||||
|
level_min_samples: int = 50
|
||||||
|
knn_max_distance: float = 0.35
|
||||||
|
knn_k: int = 200
|
||||||
|
|
||||||
|
# ── Validation ─────────────────────────────────────────
|
||||||
|
min_history_days: int = 365
|
||||||
|
regime_min_avg_duration: int = 5
|
||||||
|
regime_max_flip_rate: float = 0.15
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, path: str = "config.json") -> "Config":
|
||||||
|
"""Load config from JSON file, overriding defaults."""
|
||||||
|
import json
|
||||||
|
config = cls()
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for key, value in data.items():
|
||||||
|
if hasattr(config, key):
|
||||||
|
setattr(config, key, value)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton
|
||||||
|
config = Config()
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
database.py — SQLite schema initialization and connection management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
-- L0: Raw data tables
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ohlcv_daily (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL DEFAULT 'BTC/USDT:USDT',
|
||||||
|
open REAL,
|
||||||
|
high REAL,
|
||||||
|
low REAL,
|
||||||
|
close REAL,
|
||||||
|
volume REAL,
|
||||||
|
ema20 REAL,
|
||||||
|
ema60 REAL,
|
||||||
|
ema120 REAL,
|
||||||
|
atr_14 REAL,
|
||||||
|
bb_width REAL,
|
||||||
|
adx_14 REAL,
|
||||||
|
PRIMARY KEY (date, symbol)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS breadth_daily (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
total_tracked INTEGER DEFAULT 50,
|
||||||
|
advance_top50 INTEGER DEFAULT 0,
|
||||||
|
decline_top50 INTEGER DEFAULT 0,
|
||||||
|
above_ema20_top50 INTEGER DEFAULT 0,
|
||||||
|
new_highs_20d_top50 INTEGER DEFAULT 0,
|
||||||
|
btc_dominance REAL,
|
||||||
|
advance_top20 INTEGER DEFAULT 0,
|
||||||
|
advance_top30 INTEGER DEFAULT 0,
|
||||||
|
above_ema20_top20 INTEGER DEFAULT 0,
|
||||||
|
above_ema20_top30 INTEGER DEFAULT 0,
|
||||||
|
new_highs_20d_top20 INTEGER DEFAULT 0,
|
||||||
|
new_highs_20d_top30 INTEGER DEFAULT 0,
|
||||||
|
fetched_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS derivatives (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL DEFAULT 'BTC/USDT:USDT',
|
||||||
|
funding_rate REAL,
|
||||||
|
open_interest REAL,
|
||||||
|
oi_24h_change_pct REAL,
|
||||||
|
long_liquidations REAL,
|
||||||
|
short_liquidations REAL,
|
||||||
|
basis_annualised_pct REAL,
|
||||||
|
source TEXT DEFAULT 'binance',
|
||||||
|
fetched_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (date, symbol)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS etf_flow (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
product TEXT NOT NULL,
|
||||||
|
net_flow_million REAL NOT NULL,
|
||||||
|
price REAL,
|
||||||
|
source TEXT DEFAULT 'farside',
|
||||||
|
fetched_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (date, product)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stablecoin_supply (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
chain TEXT NOT NULL DEFAULT 'all',
|
||||||
|
supply REAL NOT NULL,
|
||||||
|
source TEXT DEFAULT 'defillama',
|
||||||
|
fetched_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (date, token, chain)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
-- L3: Regime history
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS regime_history (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
regime TEXT NOT NULL,
|
||||||
|
confidence REAL,
|
||||||
|
regime_version TEXT NOT NULL DEFAULT 'v1_price_breadth_vol',
|
||||||
|
maturity_score REAL DEFAULT 50.0,
|
||||||
|
all_scores_json TEXT DEFAULT '{}',
|
||||||
|
prior_regime TEXT,
|
||||||
|
confirmation_days INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
-- ★ signal_features — THE moat
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS signal_features (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
signal_type TEXT NOT NULL,
|
||||||
|
signal_version TEXT NOT NULL DEFAULT 'b3_v1',
|
||||||
|
symbol TEXT DEFAULT 'BTC/USDT:USDT',
|
||||||
|
|
||||||
|
-- ★★ Version control (most important fields)
|
||||||
|
regime_version TEXT NOT NULL DEFAULT 'v1_price_breadth_vol',
|
||||||
|
signal_grade TEXT,
|
||||||
|
signal_strength REAL,
|
||||||
|
|
||||||
|
-- Market State Vector snapshot
|
||||||
|
regime TEXT NOT NULL,
|
||||||
|
regime_confidence REAL,
|
||||||
|
regime_maturity_score REAL DEFAULT 50.0,
|
||||||
|
market_state_hash TEXT,
|
||||||
|
state_embedding TEXT DEFAULT '[]',
|
||||||
|
breadth_top20 REAL,
|
||||||
|
breadth_top30 REAL,
|
||||||
|
breadth_top50 REAL,
|
||||||
|
breadth_bucket TEXT,
|
||||||
|
breadth_divergence REAL,
|
||||||
|
oi_state TEXT,
|
||||||
|
volatility_regime TEXT,
|
||||||
|
price_structure_score REAL,
|
||||||
|
|
||||||
|
-- Chan context (V5+)
|
||||||
|
chan_trend_direction TEXT,
|
||||||
|
chan_pivot_count INTEGER,
|
||||||
|
chan_divergence_type TEXT,
|
||||||
|
|
||||||
|
-- Outcomes
|
||||||
|
entry_price REAL,
|
||||||
|
result_1d REAL,
|
||||||
|
result_3d REAL,
|
||||||
|
result_5d REAL,
|
||||||
|
result_7d REAL,
|
||||||
|
result_14d REAL,
|
||||||
|
max_favorable_excursion REAL,
|
||||||
|
max_adverse_excursion REAL,
|
||||||
|
is_win_7d INTEGER,
|
||||||
|
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sf_regime ON signal_features(regime);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sf_signal ON signal_features(signal_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sf_oi_state ON signal_features(oi_state);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sf_date ON signal_features(date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sf_state_hash ON signal_features(market_state_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sf_regime_version ON signal_features(regime_version);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sf_signal_version ON signal_features(signal_version);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
-- Expectancy cache (raw counts, NOT posteriors)
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS expectancy_cache (
|
||||||
|
state_hash TEXT NOT NULL,
|
||||||
|
signal_type TEXT NOT NULL,
|
||||||
|
wins_weighted REAL DEFAULT 0,
|
||||||
|
losses_weighted REAL DEFAULT 0,
|
||||||
|
sum_return_7d REAL DEFAULT 0,
|
||||||
|
sum_return_sq_7d REAL DEFAULT 0,
|
||||||
|
effective_samples REAL DEFAULT 0,
|
||||||
|
sufficiency TEXT DEFAULT 'INSUFFICIENT',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (state_hash, signal_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
-- Similarity outcome (KNN weight learning, Phase D)
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS similarity_outcome (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
state_a_hash TEXT,
|
||||||
|
state_b_hash TEXT,
|
||||||
|
distance REAL,
|
||||||
|
actual_return_gap REAL,
|
||||||
|
dimension_weights_json TEXT DEFAULT '{}',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
-- chan_context — Chan theory integration (V1 empty)
|
||||||
|
-- ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chan_context (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
timeframe TEXT NOT NULL DEFAULT '1d',
|
||||||
|
trend_direction TEXT,
|
||||||
|
trend_strength REAL,
|
||||||
|
pivot_count INTEGER,
|
||||||
|
pivot_level TEXT,
|
||||||
|
signal_type TEXT,
|
||||||
|
signal_strength REAL,
|
||||||
|
divergence_type TEXT,
|
||||||
|
chan_structure_score REAL,
|
||||||
|
alignment_score REAL,
|
||||||
|
raw_context_json TEXT DEFAULT '{}',
|
||||||
|
PRIMARY KEY (date, timeframe)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: str = "data/macro.db") -> sqlite3.Connection:
|
||||||
|
"""Initialize database: create directory and all tables."""
|
||||||
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection(db_path: str = "data/macro.db") -> sqlite3.Connection:
|
||||||
|
"""Get a database connection. Creates tables if first run."""
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
return init_db(db_path)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Expectancy Engine — Signal tracking, Bayesian inference, time decay."""
|
||||||
|
from .tracker import SignalTracker
|
||||||
|
from .decay import TimeDecay
|
||||||
|
from .engine import BayesianExpectancyEngine, SufficiencyGuard
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
expectancy/decay.py — Time-weighted sample decay.
|
||||||
|
|
||||||
|
2024 market structure ≠ 2026 market structure.
|
||||||
|
Recent samples get higher weight via exponential decay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class TimeDecay:
|
||||||
|
"""Exponential time decay for sample weighting."""
|
||||||
|
|
||||||
|
def __init__(self, half_life_days: int = 180):
|
||||||
|
self.half_life = half_life_days
|
||||||
|
self._decay_rate = np.log(2) / half_life_days
|
||||||
|
|
||||||
|
def weight(self, sample_date: Date, reference_date: Optional[Date] = None) -> float:
|
||||||
|
"""
|
||||||
|
Compute decay weight for a sample.
|
||||||
|
weight = exp(-days_ago * decay_rate)
|
||||||
|
"""
|
||||||
|
if reference_date is None:
|
||||||
|
reference_date = Date.today()
|
||||||
|
days = (reference_date - sample_date).days
|
||||||
|
return np.exp(-days * self._decay_rate)
|
||||||
|
|
||||||
|
def weights(self, dates: list[Date], reference_date: Optional[Date] = None) -> np.ndarray:
|
||||||
|
"""Compute decay weights for a list of dates."""
|
||||||
|
return np.array([self.weight(d, reference_date) for d in dates])
|
||||||
|
|
||||||
|
def weighted_win_rate(self, wins: np.ndarray, weights: np.ndarray) -> float:
|
||||||
|
"""Weighted win rate: sum(wins * weights) / sum(weights)."""
|
||||||
|
total_weight = weights.sum()
|
||||||
|
if total_weight == 0:
|
||||||
|
return 0.0
|
||||||
|
return float((wins * weights).sum() / total_weight)
|
||||||
|
|
||||||
|
def weighted_mean(self, values: np.ndarray, weights: np.ndarray) -> float:
|
||||||
|
"""Weighted mean."""
|
||||||
|
total_weight = weights.sum()
|
||||||
|
if total_weight == 0:
|
||||||
|
return 0.0
|
||||||
|
return float((values * weights).sum() / total_weight)
|
||||||
|
|
||||||
|
def effective_samples(self, weights: np.ndarray) -> float:
|
||||||
|
"""Effective number of samples after decay weighting."""
|
||||||
|
return float(weights.sum())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def weight_at_age(days_ago: int, half_life_days: int = 180) -> float:
|
||||||
|
"""Quick weight lookup for a given age in days."""
|
||||||
|
return np.exp(-days_ago * np.log(2) / half_life_days)
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""
|
||||||
|
expectancy/engine.py — Bayesian Expectancy Engine.
|
||||||
|
|
||||||
|
Core algorithm:
|
||||||
|
1. LeveledExpectancy: filter layer-by-layer, stop at highest valid level
|
||||||
|
2. Empirical Bayes prior: prior = signal's global historical winrate
|
||||||
|
3. Dynamic Beta strength: adaptive to sample size
|
||||||
|
4. Time decay: recent samples weighted higher (half_life=180d)
|
||||||
|
5. SufficiencyGuard: refuse output if effective_samples < 30
|
||||||
|
6. KNN Fallback: similarity search when strict filtering fails (Phase D)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from models import (
|
||||||
|
MarketStateVector, ExpectancyReport, ExpectancyLayer,
|
||||||
|
SufficiencyLevel, MarketRegime,
|
||||||
|
)
|
||||||
|
from config import config
|
||||||
|
from .decay import TimeDecay
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SufficiencyGuard:
|
||||||
|
"""Prevents trading advice from insufficient samples."""
|
||||||
|
|
||||||
|
def __init__(self, min_effective: int = 30, low: int = 50, medium: int = 100):
|
||||||
|
self.MIN = min_effective
|
||||||
|
self.LOW = low
|
||||||
|
self.MEDIUM = medium
|
||||||
|
|
||||||
|
def evaluate(self, effective_samples: float) -> SufficiencyLevel:
|
||||||
|
if effective_samples < self.MIN:
|
||||||
|
return SufficiencyLevel.INSUFFICIENT
|
||||||
|
elif effective_samples < self.LOW:
|
||||||
|
return SufficiencyLevel.LOW
|
||||||
|
elif effective_samples < self.MEDIUM:
|
||||||
|
return SufficiencyLevel.MEDIUM
|
||||||
|
return SufficiencyLevel.HIGH
|
||||||
|
|
||||||
|
|
||||||
|
class BayesianExpectancyEngine:
|
||||||
|
"""
|
||||||
|
Leveled Bayesian Expectancy Engine.
|
||||||
|
|
||||||
|
Query layers from coarse to fine. Stop when effective_samples drops below threshold.
|
||||||
|
Uses Empirical Bayes prior (signal's global winrate, not fixed 50%).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Expectancy query levels: name → WHERE clause template
|
||||||
|
LEVELS = [
|
||||||
|
("Base", "signal_type = '{signal}'"),
|
||||||
|
("+ Regime", "signal_type = '{signal}' AND regime = '{regime}'"),
|
||||||
|
("+ Breadth", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}'"),
|
||||||
|
("+ OI State", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}' AND oi_state = '{oi}'"),
|
||||||
|
("+ Volatility", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}' AND oi_state = '{oi}' AND volatility_regime = '{vol}'"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None,
|
||||||
|
half_life_days: int = 180,
|
||||||
|
level_min_samples: int = 50):
|
||||||
|
self.db_path = db_path or config.db_path
|
||||||
|
self.decay = TimeDecay(half_life_days)
|
||||||
|
self.guard = SufficiencyGuard(
|
||||||
|
min_effective=config.sufficiency_min_effective,
|
||||||
|
low=config.sufficiency_low,
|
||||||
|
medium=config.sufficiency_medium,
|
||||||
|
)
|
||||||
|
self.level_min = level_min_samples
|
||||||
|
|
||||||
|
def estimate(self, state: MarketStateVector,
|
||||||
|
signal_type: str = "B3",
|
||||||
|
target_date: Optional[Date] = None) -> ExpectancyReport:
|
||||||
|
"""
|
||||||
|
Compute layered Bayesian expectancy for a signal in current market state.
|
||||||
|
|
||||||
|
Returns the estimate at the deepest level with >= level_min effective samples.
|
||||||
|
"""
|
||||||
|
if target_date is None:
|
||||||
|
target_date = Date.today()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
|
# Get global signal winrate for Empirical Bayes prior
|
||||||
|
global_rate = self._global_winrate(conn, signal_type)
|
||||||
|
|
||||||
|
layers = []
|
||||||
|
best_result = None
|
||||||
|
|
||||||
|
for level_name, template in self.LEVELS:
|
||||||
|
where = template.format(
|
||||||
|
signal=signal_type,
|
||||||
|
regime=state.regime.value,
|
||||||
|
breadth=state.breadth_bucket.value,
|
||||||
|
oi=state.oi_state.value,
|
||||||
|
vol=state.volatility_regime.value,
|
||||||
|
)
|
||||||
|
query = f"SELECT * FROM signal_features WHERE {where}"
|
||||||
|
df = pd.read_sql_query(query, conn)
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
layers.append(ExpectancyLayer(
|
||||||
|
name=level_name, posterior_winrate=0.0,
|
||||||
|
samples=0, effective_samples=0.0,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Time-weighted stats
|
||||||
|
dates_list = [Date.fromisoformat(d) for d in df["date"]]
|
||||||
|
weights = self.decay.weights(dates_list, target_date)
|
||||||
|
eff_n = self.decay.effective_samples(weights)
|
||||||
|
|
||||||
|
wins = pd.to_numeric(df["is_win_7d"].fillna(0), errors="coerce").fillna(0).values
|
||||||
|
returns = pd.to_numeric(df["result_7d"].fillna(0), errors="coerce").fillna(0).values
|
||||||
|
|
||||||
|
raw_wr = float(wins.mean()) if len(wins) > 0 else 0.0
|
||||||
|
weighted_wr = self.decay.weighted_win_rate(wins, weights)
|
||||||
|
weighted_ret = self.decay.weighted_mean(returns, weights)
|
||||||
|
|
||||||
|
# Empirical Bayes posterior
|
||||||
|
posterior = self._bayesian_posterior(
|
||||||
|
global_rate=global_rate,
|
||||||
|
wins=wins.sum(),
|
||||||
|
samples=len(df),
|
||||||
|
)
|
||||||
|
|
||||||
|
layer = ExpectancyLayer(
|
||||||
|
name=level_name,
|
||||||
|
posterior_winrate=round(posterior, 4),
|
||||||
|
raw_winrate=round(raw_wr, 4),
|
||||||
|
samples=len(df),
|
||||||
|
effective_samples=round(eff_n, 1),
|
||||||
|
avg_return=round(weighted_ret, 2),
|
||||||
|
)
|
||||||
|
layers.append(layer)
|
||||||
|
|
||||||
|
# Level-based fallback: keep going while samples sufficient
|
||||||
|
if eff_n >= self.level_min:
|
||||||
|
best_result = layer
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if best_result is None and layers:
|
||||||
|
# Fallback to the deepest layer that had any samples
|
||||||
|
for layer in reversed(layers):
|
||||||
|
if layer.samples > 0:
|
||||||
|
best_result = layer
|
||||||
|
break
|
||||||
|
|
||||||
|
if best_result is None:
|
||||||
|
return ExpectancyReport(
|
||||||
|
signal_type=signal_type,
|
||||||
|
date=target_date,
|
||||||
|
layers=layers,
|
||||||
|
final_estimate=0.0,
|
||||||
|
sufficiency=SufficiencyLevel.INSUFFICIENT,
|
||||||
|
source="insufficient",
|
||||||
|
)
|
||||||
|
|
||||||
|
sufficiency = self.guard.evaluate(
|
||||||
|
best_result.effective_samples
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compute profit factor and MAE from the SAME level as best_result
|
||||||
|
profit_factor = None
|
||||||
|
avg_mae = None
|
||||||
|
if best_result and best_result.samples > 0:
|
||||||
|
# Re-query the level that produced best_result
|
||||||
|
best_level_idx = next(
|
||||||
|
i for i, l in enumerate(layers) if l.name == best_result.name
|
||||||
|
)
|
||||||
|
where = self.LEVELS[best_level_idx][1].format(
|
||||||
|
signal=signal_type, regime=state.regime.value,
|
||||||
|
breadth=state.breadth_bucket.value, oi=state.oi_state.value,
|
||||||
|
vol=state.volatility_regime.value,
|
||||||
|
)
|
||||||
|
query = f"SELECT result_7d, max_adverse_excursion FROM signal_features WHERE {where}"
|
||||||
|
conn2 = sqlite3.connect(self.db_path)
|
||||||
|
df_detail = pd.read_sql_query(query, conn2)
|
||||||
|
conn2.close()
|
||||||
|
if not df_detail.empty:
|
||||||
|
returns_7d = df_detail["result_7d"].dropna()
|
||||||
|
if len(returns_7d) > 0:
|
||||||
|
gains = returns_7d[returns_7d > 0].sum()
|
||||||
|
losses = abs(returns_7d[returns_7d < 0].sum())
|
||||||
|
profit_factor = round(gains / losses, 2) if losses > 0 else None
|
||||||
|
maes = df_detail["max_adverse_excursion"].dropna()
|
||||||
|
if len(maes) > 0:
|
||||||
|
avg_mae = round(float(maes.mean()), 2)
|
||||||
|
|
||||||
|
return ExpectancyReport(
|
||||||
|
signal_type=signal_type,
|
||||||
|
date=target_date,
|
||||||
|
layers=layers,
|
||||||
|
final_estimate=round(best_result.posterior_winrate, 4),
|
||||||
|
sufficiency=sufficiency,
|
||||||
|
prior_strength=self._prior_strength(best_result.samples),
|
||||||
|
half_life_days=self.decay.half_life,
|
||||||
|
source="bayesian",
|
||||||
|
avg_return_7d=best_result.avg_return,
|
||||||
|
profit_factor=profit_factor,
|
||||||
|
max_adverse_excursion=avg_mae,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _global_winrate(self, conn: sqlite3.Connection,
|
||||||
|
signal_type: str) -> float:
|
||||||
|
"""Get global historical winrate for a signal type (Empirical Bayes prior)."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT AVG(is_win_7d) as wr, COUNT(*) as cnt "
|
||||||
|
"FROM signal_features WHERE signal_type = ? AND is_win_7d IS NOT NULL",
|
||||||
|
(signal_type,)
|
||||||
|
).fetchone()
|
||||||
|
if row and row[1] and row[1] > 0:
|
||||||
|
return float(row[0])
|
||||||
|
return 0.50 # default: neutral
|
||||||
|
|
||||||
|
def _prior_strength(self, samples: int) -> int:
|
||||||
|
"""Dynamic prior strength based on sample count."""
|
||||||
|
if samples < 100:
|
||||||
|
return 20 # Beta(10,10)
|
||||||
|
elif samples < 500:
|
||||||
|
return 40 # Beta(20,20)
|
||||||
|
else:
|
||||||
|
return 100 # Beta(50,50) — data dominates
|
||||||
|
|
||||||
|
def _bayesian_posterior(self, global_rate: float, wins: float,
|
||||||
|
samples: int) -> float:
|
||||||
|
"""
|
||||||
|
Empirical Bayes posterior: prior = global signal winrate.
|
||||||
|
|
||||||
|
posterior = (alpha + wins) / (alpha + beta + samples)
|
||||||
|
where alpha/(alpha+beta) = global_rate
|
||||||
|
"""
|
||||||
|
prior_strength = self._prior_strength(samples)
|
||||||
|
alpha = max(global_rate * prior_strength, 1.0) # floor at 1 to ensure shrinkage
|
||||||
|
beta = max((1 - global_rate) * prior_strength, 1.0)
|
||||||
|
return (alpha + wins) / (alpha + beta + samples)
|
||||||
|
|
||||||
|
def precompute_cache(self):
|
||||||
|
"""
|
||||||
|
Precompute expectancy for all state_hashes in signal_features.
|
||||||
|
Populates expectancy_cache table with raw weighted counts (not posteriors).
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
hashes = conn.execute(
|
||||||
|
"SELECT DISTINCT market_state_hash, signal_type FROM signal_features"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
today = Date.today()
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for row in hashes:
|
||||||
|
h = row["market_state_hash"]
|
||||||
|
sig = row["signal_type"]
|
||||||
|
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"SELECT date, is_win_7d, result_7d "
|
||||||
|
"FROM signal_features WHERE market_state_hash = ? AND signal_type = ?",
|
||||||
|
conn, params=(h, sig)
|
||||||
|
)
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dates_list = [Date.fromisoformat(d) for d in df["date"]]
|
||||||
|
weights = self.decay.weights(dates_list, today)
|
||||||
|
wins_w = (df["is_win_7d"].fillna(0).values * weights).sum()
|
||||||
|
losses_w = ((1 - df["is_win_7d"].fillna(0)).values * weights).sum()
|
||||||
|
ret_sum = (df["result_7d"].fillna(0).values * weights).sum()
|
||||||
|
ret_sq = ((df["result_7d"].fillna(0).values ** 2) * weights).sum()
|
||||||
|
eff_n = weights.sum()
|
||||||
|
|
||||||
|
sufficiency = self.guard.evaluate(eff_n).value
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO expectancy_cache
|
||||||
|
(state_hash, signal_type, wins_weighted, losses_weighted,
|
||||||
|
sum_return_7d, sum_return_sq_7d, effective_samples, sufficiency)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (h, sig, wins_w, losses_w, ret_sum, ret_sq, eff_n, sufficiency))
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info(f"Precomputed expectancy cache: {count} state×signal combos")
|
||||||
|
return count
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
expectancy/tracker.py — SignalTracker: records signals with full market state
|
||||||
|
and computes forward outcomes.
|
||||||
|
|
||||||
|
This is the entry point for populating signal_features — THE moat table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from models import (
|
||||||
|
MarketStateVector, SignalFeatureRecord, MarketRegime,
|
||||||
|
OIState, BreadthBucket, VolRegime, SignalGrade,
|
||||||
|
)
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalTracker:
|
||||||
|
"""
|
||||||
|
Records trading signals with full market state context.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tracker = SignalTracker()
|
||||||
|
tracker.record(
|
||||||
|
date=Date(2026, 6, 24),
|
||||||
|
signal_type="B3",
|
||||||
|
entry_price=96500.0,
|
||||||
|
state=market_state_vector, # from scoring pipeline
|
||||||
|
signal_grade="A",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
|
self.db_path = db_path or config.db_path
|
||||||
|
|
||||||
|
def record(self, date: Date, signal_type: str, entry_price: float,
|
||||||
|
state: MarketStateVector,
|
||||||
|
signal_version: str = "b3_v1",
|
||||||
|
signal_grade: Optional[str] = None,
|
||||||
|
signal_strength: Optional[float] = None) -> int:
|
||||||
|
"""
|
||||||
|
Record a signal with market state snapshot and compute forward outcomes.
|
||||||
|
|
||||||
|
Returns the record ID in signal_features.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
|
# Compute forward outcomes
|
||||||
|
outcomes = self._compute_outcomes(conn, date, entry_price)
|
||||||
|
|
||||||
|
# Build embedding
|
||||||
|
embedding = json.dumps(state.state_embedding())
|
||||||
|
|
||||||
|
record_id = conn.execute("""
|
||||||
|
INSERT INTO signal_features
|
||||||
|
(date, signal_type, signal_version, symbol,
|
||||||
|
regime_version, signal_grade, signal_strength,
|
||||||
|
regime, regime_confidence, regime_maturity_score,
|
||||||
|
market_state_hash, state_embedding,
|
||||||
|
breadth_top20, breadth_top30, breadth_top50,
|
||||||
|
breadth_bucket, breadth_divergence,
|
||||||
|
oi_state, volatility_regime, price_structure_score,
|
||||||
|
entry_price,
|
||||||
|
result_1d, result_3d, result_5d, result_7d, result_14d,
|
||||||
|
max_favorable_excursion, max_adverse_excursion,
|
||||||
|
is_win_7d)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?,
|
||||||
|
?, ?,
|
||||||
|
?, ?, ?,
|
||||||
|
?, ?,
|
||||||
|
?, ?, ?,
|
||||||
|
?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?,
|
||||||
|
?)
|
||||||
|
""", (
|
||||||
|
str(date), signal_type, signal_version, state.symbol,
|
||||||
|
state.regime_version, signal_grade, signal_strength,
|
||||||
|
state.regime.value, state.regime_confidence, state.regime_maturity_score,
|
||||||
|
state.market_state_hash, embedding,
|
||||||
|
state.breadth_top20, state.breadth_top30, state.breadth_top50,
|
||||||
|
state.breadth_bucket.value, state.breadth_divergence,
|
||||||
|
state.oi_state.value, state.volatility_regime.value,
|
||||||
|
state.price_structure_score.score,
|
||||||
|
entry_price,
|
||||||
|
outcomes.get("result_1d"), outcomes.get("result_3d"),
|
||||||
|
outcomes.get("result_5d"), outcomes.get("result_7d"),
|
||||||
|
outcomes.get("result_14d"),
|
||||||
|
outcomes.get("mfe"), outcomes.get("mae"),
|
||||||
|
outcomes.get("is_win_7d"),
|
||||||
|
)).lastrowid
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
is_win = outcomes.get("is_win_7d", 0)
|
||||||
|
ret_7d = outcomes.get("result_7d", 0) or 0
|
||||||
|
logger.info(
|
||||||
|
f"Recorded {signal_type} on {date} @ {entry_price:.0f} "
|
||||||
|
f"(regime={state.regime.value}, breadth={state.breadth_bucket.value}, "
|
||||||
|
f"oi={state.oi_state.value}) → 7d={ret_7d:+.1f}%"
|
||||||
|
)
|
||||||
|
return record_id
|
||||||
|
|
||||||
|
def _compute_outcomes(self, conn: sqlite3.Connection, date: Date,
|
||||||
|
entry_price: float) -> dict:
|
||||||
|
"""
|
||||||
|
Compute forward returns, MFE, MAE from OHLCV data.
|
||||||
|
|
||||||
|
Queries future daily bars relative to the signal date.
|
||||||
|
"""
|
||||||
|
# Get future OHLCV data
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"SELECT date, high, low, close FROM ohlcv_daily "
|
||||||
|
"WHERE date > ? AND symbol = 'BTC/USDT:USDT' "
|
||||||
|
"ORDER BY date ASC LIMIT 20",
|
||||||
|
conn, params=(str(date),)
|
||||||
|
)
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
outcomes = {}
|
||||||
|
entry = entry_price
|
||||||
|
|
||||||
|
# Forward returns
|
||||||
|
for horizon_days, col in [(1, "result_1d"), (3, "result_3d"),
|
||||||
|
(5, "result_5d"), (7, "result_7d"),
|
||||||
|
(14, "result_14d")]:
|
||||||
|
if len(df) >= horizon_days:
|
||||||
|
exit_price = float(df.iloc[horizon_days - 1]["close"])
|
||||||
|
outcomes[col] = round((exit_price - entry) / entry * 100, 2)
|
||||||
|
|
||||||
|
# MFE / MAE
|
||||||
|
if len(df) > 0:
|
||||||
|
highs = df["high"].astype(float).values[:14]
|
||||||
|
lows = df["low"].astype(float).values[:14]
|
||||||
|
outcomes["mfe"] = round((max(highs) - entry) / entry * 100, 2)
|
||||||
|
outcomes["mae"] = round((min(lows) - entry) / entry * 100, 2)
|
||||||
|
|
||||||
|
# is_win_7d
|
||||||
|
outcomes["is_win_7d"] = 1 if outcomes.get("result_7d", 0) > 0 else 0
|
||||||
|
|
||||||
|
return outcomes
|
||||||
|
|
||||||
|
def backfill_signals(self, signals: list[dict]) -> int:
|
||||||
|
"""
|
||||||
|
Backfill multiple signals from historical data.
|
||||||
|
|
||||||
|
Each signal dict:
|
||||||
|
{"date": Date, "signal_type": str, "entry_price": float,
|
||||||
|
"signal_grade": str (optional), "signal_strength": float (optional)}
|
||||||
|
|
||||||
|
This requires the scoring pipeline to have been run for those dates
|
||||||
|
(breadth_daily, ohlcv_daily, derivatives all populated).
|
||||||
|
"""
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
from scoring.breadth_scorer import BreadthScorer
|
||||||
|
from scoring.oi_matrix import OIMatrixScorer
|
||||||
|
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
|
||||||
|
detector = RegimeDetector()
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for sig in signals:
|
||||||
|
target = sig["date"]
|
||||||
|
try:
|
||||||
|
# Compute market state for this date
|
||||||
|
ps = PriceStructureScorer(self.db_path).compute(target)
|
||||||
|
br = BreadthScorer(self.db_path).compute(target)
|
||||||
|
oi = OIMatrixScorer(self.db_path).compute(target)
|
||||||
|
vol = VolatilityRegimeScorer(self.db_path).compute(target)
|
||||||
|
|
||||||
|
regime_result = detector.detect(
|
||||||
|
price_structure_score=ps.score,
|
||||||
|
breadth_score=br.breadth_top50,
|
||||||
|
volatility_regime=vol.vol_regime.value,
|
||||||
|
date=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = MarketStateVector(
|
||||||
|
date=target,
|
||||||
|
regime=regime_result.regime,
|
||||||
|
regime_confidence=regime_result.confidence,
|
||||||
|
regime_version=regime_result.regime_version,
|
||||||
|
regime_maturity_score=regime_result.maturity_score,
|
||||||
|
breadth_top20=br.breadth_top20,
|
||||||
|
breadth_top30=br.breadth_top30,
|
||||||
|
breadth_top50=br.breadth_top50,
|
||||||
|
breadth_bucket=br.breadth_bucket,
|
||||||
|
breadth_divergence=br.breadth_divergence,
|
||||||
|
oi_state=oi.oi_state,
|
||||||
|
volatility_regime=vol.vol_regime,
|
||||||
|
price_structure_score=ps,
|
||||||
|
breadth_score=br,
|
||||||
|
oi_matrix_score=oi,
|
||||||
|
volatility_regime_score=vol,
|
||||||
|
)
|
||||||
|
state.market_state_hash = state.compute_hash()
|
||||||
|
|
||||||
|
self.record(
|
||||||
|
date=target,
|
||||||
|
signal_type=sig["signal_type"],
|
||||||
|
entry_price=sig["entry_price"],
|
||||||
|
state=state,
|
||||||
|
signal_grade=sig.get("signal_grade"),
|
||||||
|
signal_strength=sig.get("signal_strength"),
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to backfill {sig['signal_type']} on {target}: {e}")
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_samples(self, signal_type: Optional[str] = None,
|
||||||
|
regime: Optional[str] = None,
|
||||||
|
breadth_bucket: Optional[str] = None,
|
||||||
|
oi_state: Optional[str] = None,
|
||||||
|
volatility_regime: Optional[str] = None,
|
||||||
|
limit: int = 5000) -> list[dict]:
|
||||||
|
"""Query signal_features with optional filters."""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
query = "SELECT * FROM signal_features WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if signal_type:
|
||||||
|
query += " AND signal_type = ?"
|
||||||
|
params.append(signal_type)
|
||||||
|
if regime:
|
||||||
|
query += " AND regime = ?"
|
||||||
|
params.append(regime)
|
||||||
|
if breadth_bucket:
|
||||||
|
query += " AND breadth_bucket = ?"
|
||||||
|
params.append(breadth_bucket)
|
||||||
|
if oi_state:
|
||||||
|
query += " AND oi_state = ?"
|
||||||
|
params.append(oi_state)
|
||||||
|
if volatility_regime:
|
||||||
|
query += " AND volatility_regime = ?"
|
||||||
|
params.append(volatility_regime)
|
||||||
|
|
||||||
|
query += " ORDER BY date DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def count_samples(self) -> dict:
|
||||||
|
"""Count signal_features by signal_type and regime."""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT signal_type, regime, COUNT(*) as cnt
|
||||||
|
FROM signal_features
|
||||||
|
GROUP BY signal_type, regime
|
||||||
|
ORDER BY signal_type, regime
|
||||||
|
""").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return {f"{r[0]}/{r[1]}": r[2] for r in rows}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Data fetchers — L0 raw data acquisition."""
|
||||||
|
from .base import BaseFetcher
|
||||||
|
from .ohlcv import OHLCVFetcher
|
||||||
|
from .breadth import BreadthFetcher
|
||||||
|
from .derivatives import DerivativesFetcher
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
fetchers/base.py — Abstract base class for all macro data fetchers.
|
||||||
|
|
||||||
|
Provides retry logic, rate limiting, and a common interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFetcher(ABC):
|
||||||
|
"""Abstract base for all macro data fetchers."""
|
||||||
|
|
||||||
|
def __init__(self, timeout: int = 30, max_retries: int = 3):
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_retries = max_retries
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def _get(self, url: str, params: Optional[dict] = None,
|
||||||
|
headers: Optional[dict] = None) -> dict:
|
||||||
|
"""GET with retry and exponential backoff."""
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
url, params=params, headers=headers, timeout=self.timeout
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
self.logger.warning(
|
||||||
|
f"Request failed (attempt {attempt+1}/{self.max_retries}): {e}. "
|
||||||
|
f"Retrying in {wait}s"
|
||||||
|
)
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_raw(self, url: str, params: Optional[dict] = None,
|
||||||
|
headers: Optional[dict] = None) -> bytes:
|
||||||
|
"""GET raw bytes with retry (for non-JSON endpoints)."""
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
url, params=params, headers=headers, timeout=self.timeout
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
except requests.RequestException as e:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch(self, target_date: Optional[Date] = None) -> list[dict]:
|
||||||
|
"""Fetch raw data. Returns list of record dicts."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def store(self, db_path: str, records: list[dict]) -> int:
|
||||||
|
"""Store raw records into SQLite. Returns count of new rows."""
|
||||||
|
...
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""
|
||||||
|
fetchers/breadth.py — Fetches TOP50 OHLCV and computes market breadth metrics.
|
||||||
|
|
||||||
|
Multi-tier: Top20 / Top30 / Top50 for advance/decline, EMA20%, new highs, BTC.D.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .base import BaseFetcher
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class BreadthFetcher(BaseFetcher):
|
||||||
|
"""Fetches TOP50 coin OHLCV data and computes breadth metrics."""
|
||||||
|
|
||||||
|
def __init__(self, provider_url: Optional[str] = None):
|
||||||
|
super().__init__(timeout=60, max_retries=3)
|
||||||
|
self.provider_url = provider_url or config.provider_url
|
||||||
|
self.symbols = config.top50_symbols
|
||||||
|
self.ema_period = config.breadth_ema_period
|
||||||
|
self.new_high_window = config.breadth_new_high_window
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def fetch(self, target_date: Optional[Date] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch daily OHLCV for all TOP50 symbols and compute breadth.
|
||||||
|
|
||||||
|
Returns a dict suitable for storing in breadth_daily table.
|
||||||
|
"""
|
||||||
|
if target_date is None:
|
||||||
|
target_date = Date.today()
|
||||||
|
|
||||||
|
# Fetch last 60 days of daily data for each symbol to compute EMAs and new highs
|
||||||
|
all_data = {}
|
||||||
|
for symbol in self.symbols:
|
||||||
|
try:
|
||||||
|
df = self._fetch_symbol(symbol)
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
all_data[symbol] = df
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Failed to fetch {symbol}: {e}")
|
||||||
|
|
||||||
|
if not all_data:
|
||||||
|
self.logger.error("No symbol data fetched for breadth")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Compute breadth metrics for the target date
|
||||||
|
breadth = self._compute_breadth(all_data, target_date)
|
||||||
|
return breadth
|
||||||
|
|
||||||
|
def _fetch_symbol(self, symbol: str) -> Optional[pd.DataFrame]:
|
||||||
|
"""Fetch daily OHLCV for a single symbol."""
|
||||||
|
url = f"{self.provider_url}/api/candles"
|
||||||
|
params = {
|
||||||
|
"symbol": symbol,
|
||||||
|
"tf": "1d",
|
||||||
|
"limit": 100,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, params=params, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
df["date"] = df["timestamp"].dt.date
|
||||||
|
df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True)
|
||||||
|
df["close"] = df["close"].astype(float)
|
||||||
|
df["ema20"] = df["close"].ewm(span=self.ema_period, adjust=False).mean()
|
||||||
|
return df
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _compute_breadth(self, all_data: dict, target_date: Date) -> dict:
|
||||||
|
"""Compute breadth metrics for a specific date across all symbols."""
|
||||||
|
total = len(all_data)
|
||||||
|
|
||||||
|
advances_50 = declines_50 = 0
|
||||||
|
above_ema20_50 = 0
|
||||||
|
new_highs_50 = 0
|
||||||
|
advances_30 = declines_30 = 0
|
||||||
|
above_ema20_30 = 0
|
||||||
|
new_highs_30 = 0
|
||||||
|
advances_20 = declines_20 = 0
|
||||||
|
above_ema20_20 = 0
|
||||||
|
new_highs_20 = 0
|
||||||
|
|
||||||
|
for i, (symbol, df) in enumerate(all_data.items()):
|
||||||
|
# Get data for target date
|
||||||
|
df["date_str"] = df["date"].astype(str)
|
||||||
|
target_str = str(target_date)
|
||||||
|
idx = df[df["date_str"] == target_str].index
|
||||||
|
|
||||||
|
if len(idx) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
row_idx = idx[0]
|
||||||
|
if row_idx < 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_close = df.loc[row_idx, "close"]
|
||||||
|
prev_close = df.loc[row_idx - 1, "close"]
|
||||||
|
|
||||||
|
# Advance/Decline
|
||||||
|
if current_close > prev_close:
|
||||||
|
if i < 50: advances_50 += 1
|
||||||
|
if i < 30: advances_30 += 1
|
||||||
|
if i < 20: advances_20 += 1
|
||||||
|
elif current_close < prev_close:
|
||||||
|
if i < 50: declines_50 += 1
|
||||||
|
if i < 30: declines_30 += 1
|
||||||
|
if i < 20: declines_20 += 1
|
||||||
|
|
||||||
|
# Above EMA20
|
||||||
|
ema20_val = df.loc[row_idx, "ema20"]
|
||||||
|
if not pd.isna(ema20_val) and current_close > ema20_val:
|
||||||
|
if i < 50: above_ema20_50 += 1
|
||||||
|
if i < 30: above_ema20_30 += 1
|
||||||
|
if i < 20: above_ema20_20 += 1
|
||||||
|
|
||||||
|
# New 20-day highs
|
||||||
|
lookback_start = max(0, row_idx - self.new_high_window)
|
||||||
|
recent_highs = df.loc[lookback_start:row_idx - 1, "high"].astype(float)
|
||||||
|
current_high = df.loc[row_idx, "high"]
|
||||||
|
if len(recent_highs) > 0 and float(current_high) > recent_highs.max():
|
||||||
|
if i < 50: new_highs_50 += 1
|
||||||
|
if i < 30: new_highs_30 += 1
|
||||||
|
if i < 20: new_highs_20 += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": str(target_date),
|
||||||
|
"total_tracked": total,
|
||||||
|
"advance_top50": advances_50,
|
||||||
|
"decline_top50": declines_50,
|
||||||
|
"above_ema20_top50": above_ema20_50,
|
||||||
|
"new_highs_20d_top50": new_highs_50,
|
||||||
|
"advance_top30": advances_30,
|
||||||
|
"advance_top20": advances_20,
|
||||||
|
"above_ema20_top30": above_ema20_30,
|
||||||
|
"above_ema20_top20": above_ema20_20,
|
||||||
|
"new_highs_20d_top30": new_highs_30,
|
||||||
|
"new_highs_20d_top20": new_highs_20,
|
||||||
|
"btc_dominance": None, # Reserved for Coinglass API integration
|
||||||
|
}
|
||||||
|
|
||||||
|
def store(self, db_path: Optional[str] = None, record: Optional[dict] = None) -> int:
|
||||||
|
"""Store a breadth record into SQLite. Returns 1 if inserted/updated."""
|
||||||
|
import sqlite3
|
||||||
|
db_path = db_path or config.db_path
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
||||||
|
if record is None:
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO breadth_daily
|
||||||
|
(date, total_tracked,
|
||||||
|
advance_top50, decline_top50, above_ema20_top50, new_highs_20d_top50,
|
||||||
|
advance_top30, advance_top20,
|
||||||
|
above_ema20_top30, above_ema20_top20,
|
||||||
|
new_highs_20d_top30, new_highs_20d_top20,
|
||||||
|
btc_dominance)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
record["date"], record.get("total_tracked", 50),
|
||||||
|
record.get("advance_top50", 0), record.get("decline_top50", 0),
|
||||||
|
record.get("above_ema20_top50", 0), record.get("new_highs_20d_top50", 0),
|
||||||
|
record.get("advance_top30", 0), record.get("advance_top20", 0),
|
||||||
|
record.get("above_ema20_top30", 0), record.get("above_ema20_top20", 0),
|
||||||
|
record.get("new_highs_20d_top30", 0), record.get("new_highs_20d_top20", 0),
|
||||||
|
record.get("btc_dominance"),
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to store breadth: {e}")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
fetchers/derivatives.py — Fetches derivatives data from data_provider API.
|
||||||
|
|
||||||
|
Clean consumer: no direct ccxt dependency. Just HTTP GET /api/derivatives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .base import BaseFetcher
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class DerivativesFetcher(BaseFetcher):
|
||||||
|
"""Fetches derivatives snapshot from data_provider /api/derivatives."""
|
||||||
|
|
||||||
|
def __init__(self, provider_url: Optional[str] = None):
|
||||||
|
super().__init__(timeout=15, max_retries=3)
|
||||||
|
self.provider_url = provider_url or config.provider_url
|
||||||
|
|
||||||
|
def fetch(self, target_date: Optional[Date] = None) -> list[dict]:
|
||||||
|
"""Fetch derivatives data. Returns list with one record dict."""
|
||||||
|
url = f"{self.provider_url}/api/derivatives"
|
||||||
|
params = {"symbol": config.btc_symbol}
|
||||||
|
try:
|
||||||
|
data = self._get(url, params=params)
|
||||||
|
record = {
|
||||||
|
"date": str(target_date or Date.today()),
|
||||||
|
"symbol": config.btc_symbol,
|
||||||
|
"funding_rate": data.get("funding_rate"),
|
||||||
|
"open_interest": data.get("open_interest"),
|
||||||
|
"oi_24h_change_pct": data.get("oi_change_pct"),
|
||||||
|
"basis_annualised_pct": data.get("basis"),
|
||||||
|
"source": "data_provider",
|
||||||
|
}
|
||||||
|
return [record]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def store(self, db_path: Optional[str] = None, records: Optional[list[dict]] = None) -> int:
|
||||||
|
"""Store derivatives records into SQLite."""
|
||||||
|
import sqlite3
|
||||||
|
db_path = db_path or config.db_path
|
||||||
|
records = records or []
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
count = 0
|
||||||
|
for r in records:
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO derivatives
|
||||||
|
(date, symbol, funding_rate, open_interest, oi_24h_change_pct,
|
||||||
|
long_liquidations, short_liquidations, basis_annualised_pct)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?)
|
||||||
|
""", (
|
||||||
|
r["date"], r.get("symbol", config.btc_symbol),
|
||||||
|
r.get("funding_rate"), r.get("open_interest"),
|
||||||
|
r.get("oi_24h_change_pct"), r.get("basis_annualised_pct"),
|
||||||
|
))
|
||||||
|
count += 1
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return count
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
fetchers/ohlcv.py — Fetches BTC daily OHLCV from the existing data_provider service.
|
||||||
|
|
||||||
|
Also pre-computes EMA20/60/120, ATR(14), BB width, ADX(14).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date, datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .base import BaseFetcher
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class OHLCVFetcher(BaseFetcher):
|
||||||
|
"""Fetches BTC daily K-line data from data_provider API."""
|
||||||
|
|
||||||
|
def __init__(self, provider_url: Optional[str] = None):
|
||||||
|
super().__init__(timeout=30, max_retries=3)
|
||||||
|
self.provider_url = provider_url or config.provider_url
|
||||||
|
self.symbol = config.btc_symbol
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def fetch(self, target_date: Optional[Date] = None) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Fetch daily OHLCV for BTC. Returns DataFrame with computed indicators.
|
||||||
|
|
||||||
|
Fetches enough history (200 bars) to compute EMAs/ATR/BB/ADX accurately.
|
||||||
|
"""
|
||||||
|
url = f"{self.provider_url}/api/candles"
|
||||||
|
params = {
|
||||||
|
"symbol": self.symbol,
|
||||||
|
"tf": "1d",
|
||||||
|
"limit": 200,
|
||||||
|
}
|
||||||
|
resp = requests.get(url, params=params, timeout=self.timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
self.logger.warning("OHLCV API returned empty data")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
df["date"] = df["timestamp"].dt.date
|
||||||
|
df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True)
|
||||||
|
|
||||||
|
# Rename columns to match expected format
|
||||||
|
df = df.rename(columns={
|
||||||
|
"open": "open", "high": "high", "low": "low", "close": "close",
|
||||||
|
"volume": "volume",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Compute indicators
|
||||||
|
df = self._add_indicators(df)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _add_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Add EMA, ATR, BB, ADX indicators."""
|
||||||
|
close = df["close"].astype(float)
|
||||||
|
high = df["high"].astype(float)
|
||||||
|
low = df["low"].astype(float)
|
||||||
|
|
||||||
|
# EMAs
|
||||||
|
df["ema20"] = close.ewm(span=20, adjust=False).mean()
|
||||||
|
df["ema60"] = close.ewm(span=60, adjust=False).mean()
|
||||||
|
df["ema120"] = close.ewm(span=120, adjust=False).mean()
|
||||||
|
|
||||||
|
# ATR(14)
|
||||||
|
tr1 = high - low
|
||||||
|
tr2 = (high - close.shift(1)).abs()
|
||||||
|
tr3 = (low - close.shift(1)).abs()
|
||||||
|
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
||||||
|
df["atr_14"] = tr.rolling(14).mean()
|
||||||
|
|
||||||
|
# Bollinger Bands width
|
||||||
|
sma20 = close.rolling(20).mean()
|
||||||
|
std20 = close.rolling(20).std()
|
||||||
|
df["bb_width"] = (2 * std20) / sma20 * 100 # as percentage
|
||||||
|
|
||||||
|
# ADX(14)
|
||||||
|
df["adx_14"] = self._compute_adx(df, period=14)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||||
|
"""Compute ADX from OHLC data."""
|
||||||
|
high = df["high"].astype(float)
|
||||||
|
low = df["low"].astype(float)
|
||||||
|
close = df["close"].astype(float)
|
||||||
|
|
||||||
|
plus_dm = high.diff()
|
||||||
|
minus_dm = low.diff().abs() * -1
|
||||||
|
plus_dm = plus_dm.where(plus_dm > 0, 0)
|
||||||
|
minus_dm = minus_dm.where(minus_dm < 0, 0).abs()
|
||||||
|
|
||||||
|
tr1 = high - low
|
||||||
|
tr2 = (high - close.shift(1)).abs()
|
||||||
|
tr3 = (low - close.shift(1)).abs()
|
||||||
|
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
||||||
|
|
||||||
|
atr = tr.rolling(period).mean()
|
||||||
|
plus_di = 100 * (plus_dm.rolling(period).mean() / atr)
|
||||||
|
minus_di = 100 * (minus_dm.rolling(period).mean() / atr)
|
||||||
|
|
||||||
|
dx = (abs(plus_di - minus_di) / (plus_di + minus_di)) * 100
|
||||||
|
adx = dx.rolling(period).mean()
|
||||||
|
return adx
|
||||||
|
|
||||||
|
def store(self, db_path: str, records: list[dict]) -> int:
|
||||||
|
"""Store OHLCV records into SQLite. Not used directly — see store_df."""
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def store_df(self, df: pd.DataFrame, db_path: Optional[str] = None) -> int:
|
||||||
|
"""Store the DataFrame into the ohlcv_daily table."""
|
||||||
|
import sqlite3
|
||||||
|
db_path = db_path or config.db_path
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
if pd.isna(row.get("date")):
|
||||||
|
continue
|
||||||
|
date_str = str(row["date"])
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO ohlcv_daily
|
||||||
|
(date, symbol, open, high, low, close, volume,
|
||||||
|
ema20, ema60, ema120, atr_14, bb_width, adx_14)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
date_str, self.symbol,
|
||||||
|
float(row["open"]), float(row["high"]),
|
||||||
|
float(row["low"]), float(row["close"]),
|
||||||
|
float(row.get("volume", 0)),
|
||||||
|
float(row["ema20"]) if not pd.isna(row.get("ema20")) else None,
|
||||||
|
float(row["ema60"]) if not pd.isna(row.get("ema60")) else None,
|
||||||
|
float(row["ema120"]) if not pd.isna(row.get("ema120")) else None,
|
||||||
|
float(row["atr_14"]) if not pd.isna(row.get("atr_14")) else None,
|
||||||
|
float(row["bb_width"]) if not pd.isna(row.get("bb_width")) else None,
|
||||||
|
float(row["adx_14"]) if not pd.isna(row.get("adx_14")) else None,
|
||||||
|
))
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Skip row {date_str}: {e}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
self.logger.info(f"Stored {count} OHLCV rows")
|
||||||
|
return count
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
main.py — ChanMacro entry point.
|
||||||
|
|
||||||
|
CLI: python main.py fetch|score|regime|serve
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure package root is on path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
"""
|
||||||
|
models.py — Pydantic v2 models and enums for ChanMacro.
|
||||||
|
|
||||||
|
All market state types, factor scores, and database record models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Shared validators
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _parse_date(v):
|
||||||
|
"""Reusable date-string parser for field_validator."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return Date.fromisoformat(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Enums
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class MarketRegime(str, Enum):
|
||||||
|
"""V1: 3-state regime (factor-locked: Price + Breadth + Vol)."""
|
||||||
|
TREND = "TREND"
|
||||||
|
RANGE = "RANGE"
|
||||||
|
PANIC = "PANIC"
|
||||||
|
|
||||||
|
|
||||||
|
class OIState(str, Enum):
|
||||||
|
"""Discrete OI × Price state machine. NOT compressed into a score."""
|
||||||
|
NEW_LONGS = "New Longs"
|
||||||
|
SHORT_COVERING = "Short Covering"
|
||||||
|
NEW_SHORTS = "New Shorts"
|
||||||
|
LONG_EXIT = "Long Exit"
|
||||||
|
NEUTRAL = "Neutral"
|
||||||
|
|
||||||
|
|
||||||
|
class BreadthBucket(str, Enum):
|
||||||
|
"""Quantile-based breadth buckets — always have samples regardless of cycle."""
|
||||||
|
EXTREME = "EXTREME"
|
||||||
|
STRONG = "STRONG"
|
||||||
|
NORMAL = "NORMAL"
|
||||||
|
WEAK = "WEAK"
|
||||||
|
PANIC = "PANIC"
|
||||||
|
|
||||||
|
|
||||||
|
class VolRegime(str, Enum):
|
||||||
|
"""Volatility regime classification."""
|
||||||
|
LOW_VOL = "LOW_VOL"
|
||||||
|
NORMAL_VOL = "NORMAL_VOL"
|
||||||
|
HIGH_VOL = "HIGH_VOL"
|
||||||
|
EXPLOSIVE_VOL = "EXPLOSIVE_VOL"
|
||||||
|
|
||||||
|
|
||||||
|
class MacroDirection(str, Enum):
|
||||||
|
BULLISH = "bullish"
|
||||||
|
NEUTRAL = "neutral"
|
||||||
|
BEARISH = "bearish"
|
||||||
|
|
||||||
|
|
||||||
|
class MarketEmotion(str, Enum):
|
||||||
|
EXTREME_FEAR = "Extreme Fear"
|
||||||
|
FEAR = "Fear"
|
||||||
|
NEUTRAL = "Neutral"
|
||||||
|
GREED = "Greed"
|
||||||
|
EXTREME_GREED = "Extreme Greed"
|
||||||
|
|
||||||
|
|
||||||
|
class FlowState(str, Enum):
|
||||||
|
STRONG_INFLOW = "Strong Inflow"
|
||||||
|
INFLOW = "Inflow"
|
||||||
|
NEUTRAL = "Neutral"
|
||||||
|
OUTFLOW = "Outflow"
|
||||||
|
STRONG_OUTFLOW = "Strong Outflow"
|
||||||
|
|
||||||
|
|
||||||
|
class CapitalState(str, Enum):
|
||||||
|
ENTERING = "Entering"
|
||||||
|
STABLE = "Stable"
|
||||||
|
EXITING = "Exiting"
|
||||||
|
|
||||||
|
|
||||||
|
class SufficiencyLevel(str, Enum):
|
||||||
|
HIGH = "HIGH"
|
||||||
|
MEDIUM = "MEDIUM"
|
||||||
|
LOW = "LOW"
|
||||||
|
INSUFFICIENT = "INSUFFICIENT"
|
||||||
|
|
||||||
|
|
||||||
|
class SignalGrade(str, Enum):
|
||||||
|
A = "A"
|
||||||
|
B = "B"
|
||||||
|
C = "C"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# L0: Raw Data Models
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class OHLCVDaily(BaseModel):
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
date: Date
|
||||||
|
symbol: str
|
||||||
|
open: float
|
||||||
|
high: float
|
||||||
|
low: float
|
||||||
|
close: float
|
||||||
|
volume: float
|
||||||
|
ema20: Optional[float] = None
|
||||||
|
ema60: Optional[float] = None
|
||||||
|
ema120: Optional[float] = None
|
||||||
|
atr_14: Optional[float] = None
|
||||||
|
bb_width: Optional[float] = None
|
||||||
|
adx_14: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BreadthRecord(BaseModel):
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
date: Date
|
||||||
|
total_tracked: int = 50
|
||||||
|
advance_top50: int = 0
|
||||||
|
decline_top50: int = 0
|
||||||
|
above_ema20_top50: int = 0
|
||||||
|
new_highs_20d_top50: int = 0
|
||||||
|
btc_dominance: Optional[float] = None
|
||||||
|
advance_top20: int = 0
|
||||||
|
advance_top30: int = 0
|
||||||
|
above_ema20_top20: int = 0
|
||||||
|
above_ema20_top30: int = 0
|
||||||
|
new_highs_20d_top20: int = 0
|
||||||
|
new_highs_20d_top30: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class DerivativesRecord(BaseModel):
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
date: Date
|
||||||
|
symbol: str = "BTC/USDT:USDT"
|
||||||
|
funding_rate: Optional[float] = None
|
||||||
|
open_interest: Optional[float] = None
|
||||||
|
oi_24h_change_pct: Optional[float] = None
|
||||||
|
long_liquidations: Optional[float] = None
|
||||||
|
short_liquidations: Optional[float] = None
|
||||||
|
basis_annualised_pct: Optional[float] = None
|
||||||
|
source: str = "binance"
|
||||||
|
|
||||||
|
|
||||||
|
class ETFFlowRecord(BaseModel):
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
date: Date
|
||||||
|
product: str
|
||||||
|
net_flow_million: float
|
||||||
|
price: Optional[float] = None
|
||||||
|
source: str = "farside"
|
||||||
|
|
||||||
|
|
||||||
|
class StablecoinSupplyRecord(BaseModel):
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
date: Date
|
||||||
|
token: str
|
||||||
|
chain: str = "all"
|
||||||
|
supply: float
|
||||||
|
source: str = "defillama"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# L1: Factor Score Models
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class FactorScore(BaseModel):
|
||||||
|
"""Single factor scoring output."""
|
||||||
|
name: str = ""
|
||||||
|
score: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||||
|
label: str = ""
|
||||||
|
direction: MacroDirection = MacroDirection.NEUTRAL
|
||||||
|
sub_scores: dict = Field(default_factory=dict)
|
||||||
|
narrative: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PriceStructureScore(FactorScore):
|
||||||
|
"""Price Structure — 3 sub-dimensions."""
|
||||||
|
trend_strength: float = 0.0
|
||||||
|
volatility_compression: float = 0.0
|
||||||
|
momentum: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class BreadthScore(FactorScore):
|
||||||
|
"""Breadth — multi-tier market diffusion."""
|
||||||
|
breadth_top20: float = 0.0
|
||||||
|
breadth_top30: float = 0.0
|
||||||
|
breadth_top50: float = 0.0
|
||||||
|
breadth_bucket: BreadthBucket = BreadthBucket.NORMAL
|
||||||
|
breadth_divergence: float = 0.0
|
||||||
|
advance_pct_top50: float = 0.0
|
||||||
|
above_ema20_pct_top50: float = 0.0
|
||||||
|
new_highs_top50: int = 0
|
||||||
|
btc_dominance_7d_chg: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OIMatrixScore(FactorScore):
|
||||||
|
"""OI Matrix — discrete state + continuous score."""
|
||||||
|
oi_state: OIState = OIState.NEUTRAL
|
||||||
|
price_change_pct: float = 0.0
|
||||||
|
oi_change_pct: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class VolatilityRegimeScore(FactorScore):
|
||||||
|
"""Volatility regime classification."""
|
||||||
|
vol_regime: VolRegime = VolRegime.NORMAL_VOL
|
||||||
|
atr_pct: float = 0.0
|
||||||
|
hv_ratio: float = 1.0
|
||||||
|
bb_width_ratio: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# L4: Market State Vector (the final product)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class MarketStateVector(BaseModel):
|
||||||
|
"""L4: Complete market state description. NOT compressed into one number."""
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
|
||||||
|
date: Date
|
||||||
|
symbol: str = "BTC/USDT:USDT"
|
||||||
|
|
||||||
|
regime: MarketRegime
|
||||||
|
regime_confidence: float = Field(ge=0.0, le=1.0)
|
||||||
|
regime_version: str
|
||||||
|
regime_maturity_score: float = Field(ge=0.0, le=100.0, default=50.0)
|
||||||
|
|
||||||
|
breadth_top20: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||||
|
breadth_top30: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||||
|
breadth_top50: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||||
|
breadth_bucket: BreadthBucket = BreadthBucket.NORMAL
|
||||||
|
breadth_divergence: float = 0.0
|
||||||
|
|
||||||
|
oi_state: OIState = OIState.NEUTRAL
|
||||||
|
volatility_regime: VolRegime = VolRegime.NORMAL_VOL
|
||||||
|
|
||||||
|
price_structure_score: FactorScore = Field(default_factory=FactorScore)
|
||||||
|
breadth_score: BreadthScore = Field(default_factory=BreadthScore)
|
||||||
|
oi_matrix_score: OIMatrixScore = Field(default_factory=OIMatrixScore)
|
||||||
|
volatility_regime_score: VolatilityRegimeScore = Field(default_factory=VolatilityRegimeScore)
|
||||||
|
|
||||||
|
market_state_hash: str = ""
|
||||||
|
|
||||||
|
def compute_hash(self) -> str:
|
||||||
|
import hashlib
|
||||||
|
key = f"{self.regime.value}|{self.breadth_bucket.value}|{self.oi_state.value}|{self.volatility_regime.value}"
|
||||||
|
return hashlib.md5(key.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
def state_embedding(self) -> list[float]:
|
||||||
|
return [
|
||||||
|
self.breadth_top20,
|
||||||
|
self.breadth_top30,
|
||||||
|
self.breadth_top50,
|
||||||
|
self.regime_maturity_score,
|
||||||
|
self.price_structure_score.score,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Factor Contribution
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class FactorContribution(BaseModel):
|
||||||
|
"""How much a factor contributed to the overall score."""
|
||||||
|
factor: str
|
||||||
|
raw_score: float
|
||||||
|
weight: float
|
||||||
|
impact: float
|
||||||
|
direction: str # 'bullish' / 'bearish' / 'neutral'
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Regime Result
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class RegimeResult(BaseModel):
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
date: Date
|
||||||
|
regime: MarketRegime
|
||||||
|
confidence: float
|
||||||
|
regime_version: str
|
||||||
|
maturity_score: float
|
||||||
|
all_scores: dict = Field(default_factory=dict)
|
||||||
|
prior_regime: Optional[MarketRegime] = None
|
||||||
|
confirmation_days: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SignalFeatureRecord(BaseModel):
|
||||||
|
"""A single signal → market state → outcome record."""
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
date: Date
|
||||||
|
signal_type: str
|
||||||
|
signal_version: str = "b3_v1"
|
||||||
|
symbol: str = "BTC/USDT:USDT"
|
||||||
|
|
||||||
|
regime_version: str
|
||||||
|
signal_grade: Optional[SignalGrade] = None
|
||||||
|
signal_strength: Optional[float] = None
|
||||||
|
|
||||||
|
regime: MarketRegime
|
||||||
|
regime_confidence: float
|
||||||
|
regime_maturity_score: float
|
||||||
|
market_state_hash: str
|
||||||
|
state_embedding: str = "[]"
|
||||||
|
breadth_top20: float
|
||||||
|
breadth_top30: float
|
||||||
|
breadth_top50: float
|
||||||
|
breadth_bucket: BreadthBucket
|
||||||
|
breadth_divergence: float
|
||||||
|
oi_state: OIState
|
||||||
|
volatility_regime: VolRegime
|
||||||
|
price_structure_score: float
|
||||||
|
|
||||||
|
chan_trend_direction: Optional[str] = None
|
||||||
|
chan_pivot_count: Optional[int] = None
|
||||||
|
chan_divergence_type: Optional[str] = None
|
||||||
|
|
||||||
|
entry_price: Optional[float] = None
|
||||||
|
result_1d: Optional[float] = None
|
||||||
|
result_3d: Optional[float] = None
|
||||||
|
result_5d: Optional[float] = None
|
||||||
|
result_7d: Optional[float] = None
|
||||||
|
result_14d: Optional[float] = None
|
||||||
|
max_favorable_excursion: Optional[float] = None
|
||||||
|
max_adverse_excursion: Optional[float] = None
|
||||||
|
is_win_7d: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpectancyLayer(BaseModel):
|
||||||
|
name: str
|
||||||
|
posterior_winrate: float
|
||||||
|
raw_winrate: Optional[float] = None
|
||||||
|
samples: int = 0
|
||||||
|
effective_samples: float = 0.0
|
||||||
|
avg_return: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpectancyReport(BaseModel):
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
signal_type: str
|
||||||
|
date: Date
|
||||||
|
layers: list[ExpectancyLayer] = Field(default_factory=list)
|
||||||
|
final_estimate: float
|
||||||
|
sufficiency: SufficiencyLevel = SufficiencyLevel.INSUFFICIENT
|
||||||
|
prior_strength: int = 40
|
||||||
|
half_life_days: int = 180
|
||||||
|
source: str = "bayesian"
|
||||||
|
|
||||||
|
avg_return_7d: Optional[float] = None
|
||||||
|
profit_factor: Optional[float] = None
|
||||||
|
max_adverse_excursion: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DailyOutput(BaseModel):
|
||||||
|
"""Final daily output: Market State + Expectancy."""
|
||||||
|
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||||
|
date: Date
|
||||||
|
market_state: MarketStateVector
|
||||||
|
expectancy: dict[str, ExpectancyReport] = Field(default_factory=dict)
|
||||||
|
ai_report_en: Optional[str] = None
|
||||||
|
ai_report_zh: Optional[str] = None
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
regime_detector.py — Market regime detection (V1: 3 states).
|
||||||
|
|
||||||
|
★ FACTOR-LOCKED: Regime = f(Price Structure, Breadth, Volatility) — forever.
|
||||||
|
Fear, Liquidation, ETF, Funding are Context, NOT regime inputs.
|
||||||
|
Adding new factors MUST NOT change regime definition.
|
||||||
|
|
||||||
|
★ VERSIONED: regime_version = 'v1_price_breadth_vol'.
|
||||||
|
Weight changes → new version. Multiple versions coexist.
|
||||||
|
Query: WHERE regime_version = 'v1_price_breadth_vol'.
|
||||||
|
|
||||||
|
★ CONFIDENCE-BASED: Each regime gets a continuous score. Highest wins.
|
||||||
|
No hard thresholds (prevents boundary oscillation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from models import MarketRegime, RegimeResult
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class RegimeDetector:
|
||||||
|
"""
|
||||||
|
Detects market regime from Price + Breadth + Vol.
|
||||||
|
|
||||||
|
V1: 3 regimes (TREND / RANGE / PANIC)
|
||||||
|
V2+: Can split TREND→TREND_UP/TREND_DOWN/EUPHORIA when samples > 500/regime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, regime_version: Optional[str] = None):
|
||||||
|
self.version = regime_version or config.regime_version
|
||||||
|
self.w_price = config.regime_w_price
|
||||||
|
self.w_breadth = config.regime_w_breadth
|
||||||
|
self.w_vol = config.regime_w_vol
|
||||||
|
self.panic_w_anti_trend = config.regime_panic_w_anti_trend
|
||||||
|
self.panic_w_vol_extreme = config.regime_panic_w_vol_extreme
|
||||||
|
|
||||||
|
# State persistence
|
||||||
|
self._current_regime: Optional[MarketRegime] = None
|
||||||
|
self._pending_regime: Optional[MarketRegime] = None
|
||||||
|
self._confirmation_count: int = 0
|
||||||
|
self._consecutive_days: int = 0
|
||||||
|
self._regime_history: deque = deque(maxlen=100)
|
||||||
|
|
||||||
|
# Confirmation: 2 days minimum
|
||||||
|
self.MIN_CONFIRMATION = 2
|
||||||
|
|
||||||
|
def load_state(self, db_path: str):
|
||||||
|
"""Restore regime state from the most recent regime_history record."""
|
||||||
|
import sqlite3
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT regime, confidence, confirmation_days, maturity_score "
|
||||||
|
"FROM regime_history ORDER BY date DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
regime_str = row["regime"]
|
||||||
|
if regime_str in ("TREND", "RANGE", "PANIC"):
|
||||||
|
self._current_regime = MarketRegime(regime_str)
|
||||||
|
self._consecutive_days = row["confirmation_days"] or 1
|
||||||
|
except Exception:
|
||||||
|
pass # DB not initialized yet, use defaults
|
||||||
|
|
||||||
|
def detect(self, price_structure_score: float, breadth_score: float,
|
||||||
|
volatility_regime: str, date: Date) -> RegimeResult:
|
||||||
|
"""
|
||||||
|
Detect regime from the 3 locked factors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
price_structure_score: 0-100 from PriceStructureScorer
|
||||||
|
breadth_score: 0-100 from BreadthScorer
|
||||||
|
volatility_regime: 'LOW_VOL'/'NORMAL_VOL'/'HIGH_VOL'/'EXPLOSIVE_VOL'
|
||||||
|
date: Target date
|
||||||
|
"""
|
||||||
|
# ── Compute regime scores ────────────────────────
|
||||||
|
# TREND: strong price + strong breadth + non-extreme vol
|
||||||
|
trend_score = (
|
||||||
|
price_structure_score * self.w_price +
|
||||||
|
breadth_score * self.w_breadth +
|
||||||
|
self._vol_to_trend(volatility_regime) * self.w_vol
|
||||||
|
)
|
||||||
|
|
||||||
|
# RANGE: neutral price + neutral breadth + low vol
|
||||||
|
# Score how "range-like" each dimension is
|
||||||
|
price_neutral = 60 - abs(price_structure_score - 50)
|
||||||
|
breadth_neutral = 60 - abs(breadth_score - 50)
|
||||||
|
vol_neutral = 80 if volatility_regime in ("LOW_VOL", "NORMAL_VOL") else 30
|
||||||
|
range_score = (
|
||||||
|
price_neutral * 0.40 +
|
||||||
|
breadth_neutral * 0.40 +
|
||||||
|
vol_neutral * 0.20
|
||||||
|
)
|
||||||
|
|
||||||
|
# PANIC: very weak trend + extreme vol (NO Fear/Liquidation!)
|
||||||
|
anti_trend = 100 - trend_score
|
||||||
|
vol_extreme = 100 if volatility_regime == "EXPLOSIVE_VOL" else (
|
||||||
|
60 if volatility_regime == "HIGH_VOL" else 20
|
||||||
|
)
|
||||||
|
panic_score = (
|
||||||
|
anti_trend * self.panic_w_anti_trend +
|
||||||
|
vol_extreme * self.panic_w_vol_extreme
|
||||||
|
)
|
||||||
|
|
||||||
|
scores = {
|
||||||
|
MarketRegime.TREND: round(trend_score, 1),
|
||||||
|
MarketRegime.RANGE: round(range_score, 1),
|
||||||
|
MarketRegime.PANIC: round(panic_score, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
best_regime = max(scores, key=scores.get)
|
||||||
|
|
||||||
|
# ── Persistence check ────────────────────────────
|
||||||
|
prior_regime = self._current_regime
|
||||||
|
|
||||||
|
if best_regime == self._current_regime:
|
||||||
|
self._consecutive_days += 1
|
||||||
|
self._pending_regime = None
|
||||||
|
self._confirmation_count = 0
|
||||||
|
elif best_regime == self._pending_regime:
|
||||||
|
self._confirmation_count += 1
|
||||||
|
if self._confirmation_count >= self.MIN_CONFIRMATION:
|
||||||
|
# Transition confirmed
|
||||||
|
prior_regime = self._current_regime
|
||||||
|
self._current_regime = best_regime
|
||||||
|
self._consecutive_days = self.MIN_CONFIRMATION
|
||||||
|
self._pending_regime = None
|
||||||
|
self._confirmation_count = 0
|
||||||
|
else:
|
||||||
|
self._pending_regime = best_regime
|
||||||
|
self._confirmation_count = 1
|
||||||
|
|
||||||
|
# Fallback: if no current regime yet (first run)
|
||||||
|
if self._current_regime is None:
|
||||||
|
self._current_regime = best_regime
|
||||||
|
self._consecutive_days = 1
|
||||||
|
|
||||||
|
# ── Confidence: for the CONFIRMED regime, not the raw best ──
|
||||||
|
confirmed_regime = self._current_regime
|
||||||
|
confidence = scores[confirmed_regime] / 100.0
|
||||||
|
|
||||||
|
# ── Maturity ─────────────────────────────────────
|
||||||
|
maturity = self._compute_maturity(
|
||||||
|
trend_score, breadth_score, volatility_regime
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track history
|
||||||
|
self._regime_history.append({
|
||||||
|
"date": date,
|
||||||
|
"regime": confirmed_regime.value,
|
||||||
|
"confidence": round(confidence, 3),
|
||||||
|
})
|
||||||
|
|
||||||
|
return RegimeResult(
|
||||||
|
date=date,
|
||||||
|
regime=confirmed_regime,
|
||||||
|
confidence=round(confidence, 3),
|
||||||
|
prior_regime=prior_regime,
|
||||||
|
regime_version=self.version,
|
||||||
|
maturity_score=round(maturity, 1),
|
||||||
|
all_scores={k.value: v for k, v in scores.items()},
|
||||||
|
confirmation_days=self._consecutive_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_regime(self) -> Optional[MarketRegime]:
|
||||||
|
return self._current_regime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_regime(self) -> Optional[MarketRegime]:
|
||||||
|
return self._pending_regime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def confirmation_progress(self) -> tuple[int, int]:
|
||||||
|
"""(confirmed_days, required_days) for pending transition."""
|
||||||
|
return (self._confirmation_count, self.MIN_CONFIRMATION)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _vol_to_trend(vol_regime: str) -> float:
|
||||||
|
"""Convert volatility regime to trend-contributing score."""
|
||||||
|
mapping = {
|
||||||
|
"LOW_VOL": 50, # Low vol: neutral for trend
|
||||||
|
"NORMAL_VOL": 70, # Normal vol: good for trend
|
||||||
|
"HIGH_VOL": 60, # High vol: trending but risky
|
||||||
|
"EXPLOSIVE_VOL": 30, # Explosive: anti-trend
|
||||||
|
}
|
||||||
|
return mapping.get(vol_regime, 50)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_maturity(trend_score: float, breadth_score: float,
|
||||||
|
vol_regime: str) -> float:
|
||||||
|
"""
|
||||||
|
Compute regime maturity: 0-100 continuous.
|
||||||
|
0-30: EMERGING (trend accelerating, breadth expanding)
|
||||||
|
30-70: CONFIRMED (stable)
|
||||||
|
70-100: EXHAUSTING (decelerating, vol abnormal)
|
||||||
|
"""
|
||||||
|
# Trend strength contribution
|
||||||
|
trend_contrib = trend_score * 0.50
|
||||||
|
|
||||||
|
# Breadth contribution
|
||||||
|
breadth_contrib = breadth_score * 0.30
|
||||||
|
|
||||||
|
# Vol contribution (inverted: low vol = early, explosive = late)
|
||||||
|
vol_contrib = {"LOW_VOL": 20, "NORMAL_VOL": 40, "HIGH_VOL": 60, "EXPLOSIVE_VOL": 85}
|
||||||
|
vol_val = vol_contrib.get(vol_regime, 50) * 0.20
|
||||||
|
|
||||||
|
return trend_contrib + breadth_contrib + vol_val
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ccxt>=4.0.0
|
||||||
|
pandas>=2.0.0
|
||||||
|
numpy>=1.21.2
|
||||||
|
pydantic>=2.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
scipy>=1.10.0
|
||||||
|
flask>=3.0.0
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# run_tests.sh — Run the ChanMacro test suite.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./run_tests.sh # All tests
|
||||||
|
# ./run_tests.sh -v # Verbose
|
||||||
|
# ./run_tests.sh -k regime # Only regime tests
|
||||||
|
# ./run_tests.sh --cov # With coverage (requires pytest-cov)
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
python -m pytest tests/ "$@" --tb=short
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
scheduler.py — 后台自动调度:定时拉取数据 + 计算因子 + 制度判定。
|
||||||
|
|
||||||
|
Python main.py cron → 前台阻塞运行,每 N 分钟一个 tick
|
||||||
|
Web app 启动时自动启动调度器 → 后台线程,不阻塞 Web 请求
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("chanmacro.scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
class MacroScheduler:
|
||||||
|
"""后台调度器:定时 fetch + score。"""
|
||||||
|
|
||||||
|
def __init__(self, interval_minutes: int = 60):
|
||||||
|
self.interval = interval_minutes
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._last_run: Optional[datetime] = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""启动后台线程。"""
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(target=self._loop, name="macro-scheduler", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
self._running = True
|
||||||
|
logger.info(f"调度器已启动, 每 {self.interval} 分钟执行一次")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""停止后台线程。"""
|
||||||
|
self._stop.set()
|
||||||
|
self._running = False
|
||||||
|
logger.info("调度器已停止")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_run(self) -> Optional[datetime]:
|
||||||
|
return self._last_run
|
||||||
|
|
||||||
|
def _loop(self) -> None:
|
||||||
|
"""后台循环。"""
|
||||||
|
# 首次启动立即跑一次
|
||||||
|
self._tick()
|
||||||
|
|
||||||
|
while not self._stop.wait(self.interval * 60):
|
||||||
|
self._tick()
|
||||||
|
|
||||||
|
def _tick(self) -> None:
|
||||||
|
"""执行一次:fetch → score。"""
|
||||||
|
try:
|
||||||
|
from fetchers.ohlcv import OHLCVFetcher
|
||||||
|
from fetchers.breadth import BreadthFetcher
|
||||||
|
from fetchers.derivatives import DerivativesFetcher
|
||||||
|
from database import init_db
|
||||||
|
from datetime import date as Date
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
today = Date.today()
|
||||||
|
|
||||||
|
# Fetch
|
||||||
|
ohlcv = OHLCVFetcher()
|
||||||
|
df = ohlcv.fetch()
|
||||||
|
if not df.empty:
|
||||||
|
ohlcv.store_df(df)
|
||||||
|
|
||||||
|
breadth = BreadthFetcher()
|
||||||
|
record = breadth.fetch()
|
||||||
|
if record:
|
||||||
|
breadth.store(record=record)
|
||||||
|
|
||||||
|
deriv = DerivativesFetcher()
|
||||||
|
records = deriv.fetch(today)
|
||||||
|
if records:
|
||||||
|
deriv.store(records=records)
|
||||||
|
|
||||||
|
# Score + Regime (also persisted inside _build_state)
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
from scoring.breadth_scorer import BreadthScorer
|
||||||
|
from scoring.oi_matrix import OIMatrixScorer
|
||||||
|
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketStateVector
|
||||||
|
from config import config
|
||||||
|
import json
|
||||||
|
from database import get_connection
|
||||||
|
|
||||||
|
ps = PriceStructureScorer().compute(today)
|
||||||
|
br = BreadthScorer().compute(today)
|
||||||
|
oi = OIMatrixScorer().compute(today)
|
||||||
|
vol = VolatilityRegimeScorer().compute(today)
|
||||||
|
|
||||||
|
detector = RegimeDetector()
|
||||||
|
detector.load_state(config.db_path)
|
||||||
|
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, today)
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO regime_history
|
||||||
|
(date, regime, confidence, regime_version, maturity_score,
|
||||||
|
all_scores_json, prior_regime, confirmation_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
str(today), r.regime.value, r.confidence, r.regime_version,
|
||||||
|
r.maturity_score, json.dumps(r.all_scores),
|
||||||
|
r.prior_regime.value if r.prior_regime else None,
|
||||||
|
r.confirmation_days,
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 检测新信号(每天运行一次,UTC 0 点后首次触发)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if self._last_run is None or now.date() > self._last_run.date():
|
||||||
|
try:
|
||||||
|
from chan_integration import ChanSignalDetector
|
||||||
|
detector = ChanSignalDetector()
|
||||||
|
# 检测最近 90 天的 4h 信号
|
||||||
|
count = detector.populate_signal_features(
|
||||||
|
start_date=(today - __import__('datetime').timedelta(days=90)).isoformat(),
|
||||||
|
end_date=today.isoformat(),
|
||||||
|
)
|
||||||
|
if count > 0:
|
||||||
|
logger.info(f"新增 {count} 条信号记录")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"信号检测跳过: {e}")
|
||||||
|
|
||||||
|
self._last_run = now
|
||||||
|
logger.info(
|
||||||
|
f"Tick 完成: regime={r.regime.value} conf={r.confidence:.2f} "
|
||||||
|
f"breadth={br.score:.0f}({br.breadth_bucket.value}) "
|
||||||
|
f"price={ps.score:.0f} oi={oi.oi_state.value} vol={vol.vol_regime.value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Tick 失败: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# 单例
|
||||||
|
_scheduler: Optional[MacroScheduler] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler() -> MacroScheduler:
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is None:
|
||||||
|
_scheduler = MacroScheduler(interval_minutes=60)
|
||||||
|
return _scheduler
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Scoring engine — L1 factor computation."""
|
||||||
|
from .base import BaseScorer
|
||||||
|
from .price_structure import PriceStructureScorer
|
||||||
|
from .breadth_scorer import BreadthScorer
|
||||||
|
from .oi_matrix import OIMatrixScorer
|
||||||
|
from .volatility_regime import VolatilityRegimeScorer
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
scoring/base.py — Abstract base class for all scoring modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from models import FactorScore
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class BaseScorer(ABC):
|
||||||
|
"""Abstract base for all factor scorers."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
|
self.db_path = db_path or config.db_path
|
||||||
|
|
||||||
|
def get_connection(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def compute(self, target_date: Date) -> FactorScore:
|
||||||
|
"""Compute factor score for a given date from database records."""
|
||||||
|
...
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
scoring/breadth_scorer.py — Market Breadth Score.
|
||||||
|
|
||||||
|
The first citizen of the system. Diffusion always leads price.
|
||||||
|
|
||||||
|
Multi-tier: Top20 / Top30 / Top50.
|
||||||
|
Quantile-based bucketing: EXTREME / STRONG / NORMAL / WEAK / PANIC.
|
||||||
|
|
||||||
|
4 sub-indicators (equal weight):
|
||||||
|
1. Advance/Decline ratio (30%)
|
||||||
|
2. % above EMA20 (35%)
|
||||||
|
3. New 20d highs (20%)
|
||||||
|
4. BTC Dominance change (15%, inverted)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
import sqlite3
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import BaseScorer
|
||||||
|
from .constants import (
|
||||||
|
BREADTH_W_ADVANCE, BREADTH_W_EMA20, BREADTH_W_NEW_HIGHS, BREADTH_W_BTC_DOM,
|
||||||
|
)
|
||||||
|
from models import FactorScore, BreadthScore, BreadthBucket, MacroDirection
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class BreadthScorer(BaseScorer):
|
||||||
|
"""Scores market breadth with quantile-based bucketing."""
|
||||||
|
|
||||||
|
def compute(self, target_date: Date) -> BreadthScore:
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM breadth_daily WHERE date = ?", (str(target_date),)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return BreadthScore(
|
||||||
|
name="Breadth",
|
||||||
|
score=50.0,
|
||||||
|
label="No Data",
|
||||||
|
breadth_bucket=BreadthBucket.NORMAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = dict(row)
|
||||||
|
total = row.get("total_tracked", 50) or 50
|
||||||
|
|
||||||
|
# 1. Advance/Decline ratio
|
||||||
|
advance = row.get("advance_top50", 0) or 0
|
||||||
|
decline = row.get("decline_top50", 0) or 0
|
||||||
|
if advance + decline > 0:
|
||||||
|
ad_ratio = advance / (advance + decline)
|
||||||
|
else:
|
||||||
|
ad_ratio = 0.5
|
||||||
|
ad_score = ad_ratio * 100
|
||||||
|
|
||||||
|
# 2. % above EMA20
|
||||||
|
above_ema = row.get("above_ema20_top50", 0) or 0
|
||||||
|
ema_pct = above_ema / total if total > 0 else 0.5
|
||||||
|
ema_score = ema_pct * 100
|
||||||
|
|
||||||
|
# 3. New highs
|
||||||
|
new_highs = row.get("new_highs_20d_top50", 0) or 0
|
||||||
|
highs_pct = new_highs / total if total > 0 else 0
|
||||||
|
highs_score = highs_pct * 100
|
||||||
|
|
||||||
|
# 4. BTC Dominance (inverted: BTC.D up = bearish for alts)
|
||||||
|
btc_dom = row.get("btc_dominance")
|
||||||
|
btc_dom_score = 50.0 # neutral default
|
||||||
|
if btc_dom is not None:
|
||||||
|
# Placeholder — needs historical comparison
|
||||||
|
btc_dom_score = 50.0
|
||||||
|
|
||||||
|
# Weighted aggregate
|
||||||
|
score = (
|
||||||
|
ad_score * BREADTH_W_ADVANCE +
|
||||||
|
ema_score * BREADTH_W_EMA20 +
|
||||||
|
highs_score * BREADTH_W_NEW_HIGHS +
|
||||||
|
btc_dom_score * BREADTH_W_BTC_DOM
|
||||||
|
)
|
||||||
|
|
||||||
|
# Multi-tier breadth
|
||||||
|
b20 = self._compute_tier_breadth(row, 20, total)
|
||||||
|
b30 = self._compute_tier_breadth(row, 30, total)
|
||||||
|
b50 = score # Top50 = full score
|
||||||
|
|
||||||
|
# Quantile bucket
|
||||||
|
bucket = self._assign_bucket(score)
|
||||||
|
|
||||||
|
# Divergence
|
||||||
|
divergence = b20 - b50
|
||||||
|
|
||||||
|
# Direction
|
||||||
|
if score >= 60:
|
||||||
|
direction = MacroDirection.BULLISH
|
||||||
|
elif score <= 40:
|
||||||
|
direction = MacroDirection.BEARISH
|
||||||
|
else:
|
||||||
|
direction = MacroDirection.NEUTRAL
|
||||||
|
|
||||||
|
# Narrative
|
||||||
|
narrative = self._build_narrative(bucket, divergence, ema_pct, ad_ratio)
|
||||||
|
|
||||||
|
return BreadthScore(
|
||||||
|
name="Breadth",
|
||||||
|
score=round(score, 1),
|
||||||
|
label=bucket.value,
|
||||||
|
direction=direction,
|
||||||
|
breadth_top20=round(b20, 1),
|
||||||
|
breadth_top30=round(b30, 1),
|
||||||
|
breadth_top50=round(b50, 1),
|
||||||
|
breadth_bucket=bucket,
|
||||||
|
breadth_divergence=round(divergence, 1),
|
||||||
|
advance_pct_top50=round(ad_ratio * 100, 1),
|
||||||
|
above_ema20_pct_top50=round(ema_pct * 100, 1),
|
||||||
|
new_highs_top50=new_highs,
|
||||||
|
sub_scores={
|
||||||
|
"advance_decline": round(ad_score, 1),
|
||||||
|
"above_ema20": round(ema_score, 1),
|
||||||
|
"new_highs": round(highs_score, 1),
|
||||||
|
"btc_dominance": round(btc_dom_score, 1),
|
||||||
|
},
|
||||||
|
narrative=narrative,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _compute_tier_breadth(self, row: dict, tier: int, total: int) -> float:
|
||||||
|
"""Compute breadth score for a specific tier (Top20 or Top30)."""
|
||||||
|
advance = row.get(f"advance_top{tier}", 0) or 0
|
||||||
|
above_ema = row.get(f"above_ema20_top{tier}", 0) or 0
|
||||||
|
new_highs = row.get(f"new_highs_20d_top{tier}", 0) or 0
|
||||||
|
|
||||||
|
tier_actual = min(tier, total)
|
||||||
|
if tier_actual == 0:
|
||||||
|
return 50.0
|
||||||
|
|
||||||
|
ad_ratio = advance / tier_actual if tier_actual > 0 else 0.5
|
||||||
|
ema_ratio = above_ema / tier_actual if tier_actual > 0 else 0.5
|
||||||
|
highs_ratio = new_highs / tier_actual if tier_actual > 0 else 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
ad_ratio * 100 * BREADTH_W_ADVANCE +
|
||||||
|
ema_ratio * 100 * BREADTH_W_EMA20 +
|
||||||
|
highs_ratio * 100 * BREADTH_W_NEW_HIGHS +
|
||||||
|
50 * BREADTH_W_BTC_DOM # neutral for BTC.D
|
||||||
|
)
|
||||||
|
|
||||||
|
def _assign_bucket(self, score: float) -> BreadthBucket:
|
||||||
|
"""Assign quantile-based bucket. V1 uses fixed thresholds until history accumulated."""
|
||||||
|
# V1: fixed thresholds (will switch to quantile when enough history)
|
||||||
|
if score >= 80:
|
||||||
|
return BreadthBucket.EXTREME
|
||||||
|
elif score >= 60:
|
||||||
|
return BreadthBucket.STRONG
|
||||||
|
elif score >= 40:
|
||||||
|
return BreadthBucket.NORMAL
|
||||||
|
elif score >= 20:
|
||||||
|
return BreadthBucket.WEAK
|
||||||
|
else:
|
||||||
|
return BreadthBucket.PANIC
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_quantile_boundaries(db_path: str) -> dict:
|
||||||
|
"""Compute quantile boundaries from historical breadth data.
|
||||||
|
|
||||||
|
This should be called after accumulating enough history (> 1 year).
|
||||||
|
Returns boundaries for pd.qcut.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily",
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if len(df) < 100:
|
||||||
|
return {"boundaries": [0, 20, 40, 60, 80, 100], "is_quantile": False}
|
||||||
|
|
||||||
|
df["ad_ratio"] = df["advance_top50"] / (df["advance_top50"] + df["decline_top50"])
|
||||||
|
df["ema_ratio"] = df["above_ema20_top50"] / 50
|
||||||
|
df["breadth_raw"] = (
|
||||||
|
df["ad_ratio"] * BREADTH_W_ADVANCE * 100 +
|
||||||
|
df["ema_ratio"] * BREADTH_W_EMA20 * 100 +
|
||||||
|
40 * BREADTH_W_NEW_HIGHS +
|
||||||
|
50 * BREADTH_W_BTC_DOM
|
||||||
|
)
|
||||||
|
|
||||||
|
boundaries = list(np.percentile(df["breadth_raw"].dropna(), [10, 30, 70, 90]))
|
||||||
|
return {
|
||||||
|
"boundaries": [0] + boundaries + [100],
|
||||||
|
"is_quantile": True,
|
||||||
|
"n_samples": len(df),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_narrative(bucket: BreadthBucket, divergence: float,
|
||||||
|
ema_pct: float, ad_ratio: float) -> str:
|
||||||
|
parts = []
|
||||||
|
if bucket == BreadthBucket.EXTREME:
|
||||||
|
parts.append(f"全市场极度扩散({ema_pct:.0%}站上EMA20)")
|
||||||
|
elif bucket == BreadthBucket.STRONG:
|
||||||
|
parts.append("市场广度强势")
|
||||||
|
elif bucket == BreadthBucket.NORMAL:
|
||||||
|
parts.append("市场广度中性")
|
||||||
|
elif bucket == BreadthBucket.WEAK:
|
||||||
|
parts.append("市场广度疲弱")
|
||||||
|
else:
|
||||||
|
parts.append("市场广度恐慌")
|
||||||
|
|
||||||
|
if divergence > 10:
|
||||||
|
parts.append("资金集中于大市值(Top20>>Top50)")
|
||||||
|
elif divergence < -10:
|
||||||
|
parts.append("垃圾币狂欢(Top50>>Top20)")
|
||||||
|
|
||||||
|
return ", ".join(parts)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
scoring/constants.py — Scoring thresholds, scale factors, and reference values.
|
||||||
|
|
||||||
|
All magic numbers in one place. Tune these via Phase 0 validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Price Structure ──────────────────────────────────────────
|
||||||
|
# ADX thresholds
|
||||||
|
ADX_TREND_THRESHOLD = 25 # ADX > 25 = trending
|
||||||
|
ADX_STRONG_THRESHOLD = 40 # ADX > 40 = strong trend
|
||||||
|
|
||||||
|
# EMA alignment
|
||||||
|
EMA_ALIGNMENT_BULLISH = 1.0 # EMA20 > EMA60 > EMA120
|
||||||
|
EMA_ALIGNMENT_NEUTRAL = 0.5 # mixed
|
||||||
|
EMA_ALIGNMENT_BEARISH = 0.0 # EMA20 < EMA60 < EMA120
|
||||||
|
|
||||||
|
# Volatility compression (BB width relative to 20d average)
|
||||||
|
BB_COMPRESSION_LOW = 0.7 # < 70% of avg = compressing
|
||||||
|
BB_COMPRESSION_HIGH = 1.5 # > 150% of avg = expanding
|
||||||
|
|
||||||
|
# Momentum (ROC annualized)
|
||||||
|
ROC_STRONG_BULLISH = 10.0 # % over period
|
||||||
|
ROC_STRONG_BEARISH = -10.0
|
||||||
|
|
||||||
|
# Consecutive candle threshold
|
||||||
|
CONSECUTIVE_CANDLES_SIGNAL = 4
|
||||||
|
|
||||||
|
# ── Breadth ──────────────────────────────────────────────────
|
||||||
|
# Quantile boundaries for breadth buckets
|
||||||
|
BREADTH_QUANTILES = [0, 0.1, 0.3, 0.7, 0.9, 1.0] # PANIC/WEAK/NORMAL/STRONG/EXTREME
|
||||||
|
|
||||||
|
# Breadth score computation weights
|
||||||
|
BREADTH_W_ADVANCE = 0.30 # advance/decline ratio
|
||||||
|
BREADTH_W_EMA20 = 0.35 # % above EMA20
|
||||||
|
BREADTH_W_NEW_HIGHS = 0.20 # new highs count
|
||||||
|
BREADTH_W_BTC_DOM = 0.15 # BTC dominance change (inverted)
|
||||||
|
|
||||||
|
# ── OI Matrix ────────────────────────────────────────────────
|
||||||
|
OI_PRICE_THRESHOLD = 0.5 # min |price_change%| to classify
|
||||||
|
OI_OI_THRESHOLD = 0.5 # min |OI_change%| to classify
|
||||||
|
|
||||||
|
# Score mapping for OI states
|
||||||
|
OI_STATE_SCORES = {
|
||||||
|
"New Longs": 85,
|
||||||
|
"Short Covering": 60,
|
||||||
|
"New Shorts": 20,
|
||||||
|
"Long Exit": 35,
|
||||||
|
"Neutral": 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Volatility Regime ────────────────────────────────────────
|
||||||
|
VOL_LOW = 2.0 # ATR/Close % below this = LOW_VOL
|
||||||
|
VOL_HIGH = 5.0 # ATR/Close % below this = HIGH_VOL (above = EXPLOSIVE)
|
||||||
|
HV_RATIO_LOW = 0.7 # HV(20)/HV(60) below this = compressing
|
||||||
|
HV_RATIO_HIGH = 1.5 # HV(20)/HV(60) above this = expanding
|
||||||
|
|
||||||
|
# Score mapping
|
||||||
|
VOL_REGIME_SCORES = {
|
||||||
|
"LOW_VOL": 40, # Low vol → neutral with breakout potential
|
||||||
|
"NORMAL_VOL": 55,
|
||||||
|
"HIGH_VOL": 75,
|
||||||
|
"EXPLOSIVE_VOL": 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Regime ───────────────────────────────────────────────────
|
||||||
|
REGIME_W_PRICE = 0.35
|
||||||
|
REGIME_W_BREADTH = 0.50
|
||||||
|
REGIME_W_VOL = 0.15
|
||||||
|
|
||||||
|
# PANIC: anti-trend + extreme vol (NO Fear/Liquidation)
|
||||||
|
PANIC_W_ANTI_TREND = 0.60
|
||||||
|
PANIC_W_VOL_EXTREME = 0.40
|
||||||
|
|
||||||
|
# ── Trend (L2) ───────────────────────────────────────────────
|
||||||
|
TREND_W_PRICE = 0.30
|
||||||
|
TREND_W_BREADTH = 0.70
|
||||||
|
|
||||||
|
# ── Maturity ─────────────────────────────────────────────────
|
||||||
|
MATURITY_W_TREND = 0.50
|
||||||
|
MATURITY_W_BREADTH = 0.30
|
||||||
|
MATURITY_W_VOL = 0.20
|
||||||
|
|
||||||
|
# ── Expectancy ───────────────────────────────────────────────
|
||||||
|
HALF_LIFE_DAYS = 180
|
||||||
|
SUFFICIENCY_MIN = 30
|
||||||
|
SUFFICIENCY_LOW = 50
|
||||||
|
SUFFICIENCY_MEDIUM = 100
|
||||||
|
LEVEL_MIN_SAMPLES = 50
|
||||||
|
KNN_MAX_DISTANCE = 0.35
|
||||||
|
KNN_K = 200
|
||||||
|
|
||||||
|
# ── Validation ───────────────────────────────────────────────
|
||||||
|
MIN_AVG_DURATION = 5
|
||||||
|
MAX_FLIP_RATE = 0.15
|
||||||
|
MIN_IC_THRESHOLD = 0.03
|
||||||
|
MIN_ICIR_THRESHOLD = 0.5
|
||||||
|
MIN_IG_THRESHOLD = 0.1 # Information Gain for regime factors
|
||||||
|
MIN_KL_THRESHOLD = 0.5 # KL Divergence for regime separation
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""
|
||||||
|
scoring/oi_matrix.py — OI × Price 2×2 state machine.
|
||||||
|
|
||||||
|
Discrete states, NOT a continuous score:
|
||||||
|
NEW_LONGS: Price↑ OI↑ → new money entering, trend continuation
|
||||||
|
SHORT_COVERING: Price↑ OI↓ → shorts covering, rally fragile
|
||||||
|
NEW_SHORTS: Price↓ OI↑ → new shorts entering, trend continuation
|
||||||
|
LONG_EXIT: Price↓ OI↓ → longs stopping out, panic (possible bottom)
|
||||||
|
NEUTRAL: flat → noise, don't force classification
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from .base import BaseScorer
|
||||||
|
from .constants import OI_PRICE_THRESHOLD, OI_OI_THRESHOLD, OI_STATE_SCORES
|
||||||
|
from models import FactorScore, OIMatrixScore, OIState, MacroDirection
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class OIMatrixScorer(BaseScorer):
|
||||||
|
"""Classifies OI × Price state and assigns score."""
|
||||||
|
|
||||||
|
def compute(self, target_date: Date) -> OIMatrixScore:
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM derivatives WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
|
||||||
|
(str(target_date),)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return OIMatrixScore(
|
||||||
|
name="OI Matrix",
|
||||||
|
score=50.0,
|
||||||
|
label="No Data",
|
||||||
|
oi_state=OIState.NEUTRAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = dict(row)
|
||||||
|
oi_change = row.get("oi_24h_change_pct") or 0
|
||||||
|
|
||||||
|
# Get price change from OHLCV
|
||||||
|
price_change = self._get_price_change(conn, str(target_date))
|
||||||
|
|
||||||
|
# Classify state
|
||||||
|
oi_state = self._classify(price_change, oi_change)
|
||||||
|
|
||||||
|
# Score from state
|
||||||
|
score = OI_STATE_SCORES.get(oi_state.value, 50)
|
||||||
|
|
||||||
|
# Direction
|
||||||
|
if oi_state == OIState.NEW_LONGS:
|
||||||
|
direction = MacroDirection.BULLISH
|
||||||
|
elif oi_state == OIState.SHORT_COVERING:
|
||||||
|
direction = MacroDirection.BULLISH # bullish but fragile
|
||||||
|
elif oi_state == OIState.NEW_SHORTS:
|
||||||
|
direction = MacroDirection.BEARISH
|
||||||
|
elif oi_state == OIState.LONG_EXIT:
|
||||||
|
direction = MacroDirection.BEARISH # bearish but possible bottom
|
||||||
|
else:
|
||||||
|
direction = MacroDirection.NEUTRAL
|
||||||
|
|
||||||
|
# Narrative
|
||||||
|
narrative = self._build_narrative(oi_state, price_change, oi_change)
|
||||||
|
|
||||||
|
return OIMatrixScore(
|
||||||
|
name="OI Matrix",
|
||||||
|
score=float(score),
|
||||||
|
label=oi_state.value,
|
||||||
|
direction=direction,
|
||||||
|
oi_state=oi_state,
|
||||||
|
price_change_pct=round(price_change, 2),
|
||||||
|
oi_change_pct=round(oi_change, 2),
|
||||||
|
sub_scores={
|
||||||
|
"price_change_pct": round(price_change, 2),
|
||||||
|
"oi_change_pct": round(oi_change, 2),
|
||||||
|
},
|
||||||
|
narrative=narrative,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _get_price_change(self, conn: sqlite3.Connection, date_str: str) -> float:
|
||||||
|
"""Get BTC 24h price change % for a given date."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT close FROM ohlcv_daily WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
|
||||||
|
(date_str,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Get previous day close
|
||||||
|
prev = conn.execute(
|
||||||
|
"SELECT close FROM ohlcv_daily WHERE date < ? AND symbol = 'BTC/USDT:USDT' ORDER BY date DESC LIMIT 1",
|
||||||
|
(date_str,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if prev is None:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
current_close = float(row["close"])
|
||||||
|
prev_close = float(prev["close"])
|
||||||
|
if prev_close == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return (current_close - prev_close) / prev_close * 100
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classify(price_change_pct: float, oi_change_pct: float) -> OIState:
|
||||||
|
"""Classify OI × Price into discrete state."""
|
||||||
|
price_up = price_change_pct > OI_PRICE_THRESHOLD
|
||||||
|
price_down = price_change_pct < -OI_PRICE_THRESHOLD
|
||||||
|
oi_up = oi_change_pct > OI_OI_THRESHOLD
|
||||||
|
oi_down = oi_change_pct < -OI_OI_THRESHOLD
|
||||||
|
|
||||||
|
if price_up and oi_up:
|
||||||
|
return OIState.NEW_LONGS
|
||||||
|
elif price_up and oi_down:
|
||||||
|
return OIState.SHORT_COVERING
|
||||||
|
elif price_down and oi_up:
|
||||||
|
return OIState.NEW_SHORTS
|
||||||
|
elif price_down and oi_down:
|
||||||
|
return OIState.LONG_EXIT
|
||||||
|
else:
|
||||||
|
return OIState.NEUTRAL
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_narrative(state: OIState, price_chg: float, oi_chg: float) -> str:
|
||||||
|
mapping = {
|
||||||
|
OIState.NEW_LONGS: f"新多进场: 价格+{price_chg:.1f}%, OI+{oi_chg:.1f}%, 真金白银推动",
|
||||||
|
OIState.SHORT_COVERING: f"空头回补: 价格+{price_chg:.1f}%, OI{oi_chg:.1f}%, 上涨脆弱",
|
||||||
|
OIState.NEW_SHORTS: f"新空进场: 价格{price_chg:.1f}%, OI+{oi_chg:.1f}%, 趋势延续",
|
||||||
|
OIState.LONG_EXIT: f"多头止损: 价格{price_chg:.1f}%, OI{oi_chg:.1f}%, 恐慌(可能见底)",
|
||||||
|
OIState.NEUTRAL: "OI/价格变化不显著, 噪音区",
|
||||||
|
}
|
||||||
|
return mapping.get(state, "Unknown")
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""
|
||||||
|
scoring/price_structure.py — Price Structure Score (OHLCV-only).
|
||||||
|
|
||||||
|
Three sub-dimensions:
|
||||||
|
1. Trend Strength (40%): EMA alignment + ADX
|
||||||
|
2. Volatility Compression (30%): ATR + BB width
|
||||||
|
3. Momentum (30%): ROC + consecutive candles
|
||||||
|
|
||||||
|
This module works with zero external dependencies — just OHLCV data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
import sqlite3
|
||||||
|
import math
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import BaseScorer
|
||||||
|
from .constants import (
|
||||||
|
ADX_TREND_THRESHOLD, ADX_STRONG_THRESHOLD,
|
||||||
|
BB_COMPRESSION_LOW, BB_COMPRESSION_HIGH,
|
||||||
|
ROC_STRONG_BULLISH, ROC_STRONG_BEARISH,
|
||||||
|
CONSECUTIVE_CANDLES_SIGNAL,
|
||||||
|
)
|
||||||
|
from models import FactorScore, PriceStructureScore, MacroDirection
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class PriceStructureScorer(BaseScorer):
|
||||||
|
"""Scores market structure from OHLCV data alone."""
|
||||||
|
|
||||||
|
def compute(self, target_date: Date) -> PriceStructureScore:
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
df = self._load_ohlcv(conn, str(target_date), lookback=120)
|
||||||
|
if df.empty:
|
||||||
|
return PriceStructureScore(
|
||||||
|
name="Price Structure",
|
||||||
|
score=50.0,
|
||||||
|
label="No Data",
|
||||||
|
)
|
||||||
|
|
||||||
|
trend = self._score_trend_strength(df)
|
||||||
|
vol_comp = self._score_volatility_compression(df)
|
||||||
|
momentum = self._score_momentum(df)
|
||||||
|
|
||||||
|
# Weighted aggregate
|
||||||
|
score = trend * 0.40 + vol_comp * 0.30 + momentum * 0.30
|
||||||
|
|
||||||
|
# Determine direction
|
||||||
|
if trend > 60:
|
||||||
|
direction = MacroDirection.BULLISH
|
||||||
|
elif trend < 40:
|
||||||
|
direction = MacroDirection.BEARISH
|
||||||
|
else:
|
||||||
|
direction = MacroDirection.NEUTRAL
|
||||||
|
|
||||||
|
# Build narrative
|
||||||
|
latest = df.iloc[-1]
|
||||||
|
narrative = self._build_narrative(trend, vol_comp, momentum, latest)
|
||||||
|
|
||||||
|
return PriceStructureScore(
|
||||||
|
name="Price Structure",
|
||||||
|
score=round(score, 1),
|
||||||
|
label=self._label(score),
|
||||||
|
direction=direction,
|
||||||
|
trend_strength=round(trend, 1),
|
||||||
|
volatility_compression=round(vol_comp, 1),
|
||||||
|
momentum=round(momentum, 1),
|
||||||
|
sub_scores={
|
||||||
|
"trend_strength": round(trend, 1),
|
||||||
|
"volatility_compression": round(vol_comp, 1),
|
||||||
|
"momentum": round(momentum, 1),
|
||||||
|
},
|
||||||
|
narrative=narrative,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _load_ohlcv(self, conn: sqlite3.Connection, date_str: str,
|
||||||
|
lookback: int = 120) -> pd.DataFrame:
|
||||||
|
"""Load OHLCV data up to target_date."""
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT ?",
|
||||||
|
conn, params=(date_str, lookback)
|
||||||
|
)
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
return df.sort_values("date").reset_index(drop=True)
|
||||||
|
|
||||||
|
def _score_trend_strength(self, df: pd.DataFrame) -> float:
|
||||||
|
"""Score trend based on EMA alignment and ADX."""
|
||||||
|
latest = df.iloc[-1]
|
||||||
|
|
||||||
|
# EMA alignment
|
||||||
|
ema20 = latest.get("ema20")
|
||||||
|
ema60 = latest.get("ema60")
|
||||||
|
ema120 = latest.get("ema120")
|
||||||
|
|
||||||
|
ema_score = 50.0
|
||||||
|
if ema20 and ema60 and ema120 and not pd.isna(ema20) and not pd.isna(ema60) and not pd.isna(ema120):
|
||||||
|
alignments = 0
|
||||||
|
if ema20 > ema60: alignments += 1
|
||||||
|
if ema60 > ema120: alignments += 1
|
||||||
|
if ema20 > ema120: alignments += 1
|
||||||
|
|
||||||
|
# Distance from EMAs
|
||||||
|
close = float(latest["close"])
|
||||||
|
ema20_dist = abs(close - ema20) / ema20 * 100 if ema20 else 0
|
||||||
|
|
||||||
|
if alignments == 3:
|
||||||
|
ema_score = 80 + min(ema20_dist, 15) # strong bullish alignment
|
||||||
|
elif alignments == 0:
|
||||||
|
ema_score = 20 - min(ema20_dist, 15) # strong bearish alignment
|
||||||
|
elif alignments == 2:
|
||||||
|
ema_score = 65
|
||||||
|
else:
|
||||||
|
ema_score = 35
|
||||||
|
|
||||||
|
# ADX
|
||||||
|
adx = latest.get("adx_14")
|
||||||
|
adx_score = 50.0
|
||||||
|
if adx and not pd.isna(adx):
|
||||||
|
if adx > ADX_STRONG_THRESHOLD:
|
||||||
|
adx_score = 85
|
||||||
|
elif adx > ADX_TREND_THRESHOLD:
|
||||||
|
adx_score = 65 + (adx - ADX_TREND_THRESHOLD) / (ADX_STRONG_THRESHOLD - ADX_TREND_THRESHOLD) * 20
|
||||||
|
else:
|
||||||
|
adx_score = 50 - (ADX_TREND_THRESHOLD - adx) / ADX_TREND_THRESHOLD * 30
|
||||||
|
|
||||||
|
return ema_score * 0.55 + adx_score * 0.45
|
||||||
|
|
||||||
|
def _score_volatility_compression(self, df: pd.DataFrame) -> float:
|
||||||
|
"""Score volatility compression — expansion = high, compression = low-mid."""
|
||||||
|
latest = df.iloc[-1]
|
||||||
|
|
||||||
|
bb_width = latest.get("bb_width")
|
||||||
|
if not bb_width or pd.isna(bb_width) or len(df) < 20:
|
||||||
|
return 50.0
|
||||||
|
|
||||||
|
# BB width relative to 20d average
|
||||||
|
recent_bb = df["bb_width"].dropna().tail(20)
|
||||||
|
if len(recent_bb) < 10:
|
||||||
|
return 50.0
|
||||||
|
|
||||||
|
bb_avg = recent_bb.mean()
|
||||||
|
bb_ratio = bb_width / bb_avg if bb_avg > 0 else 1.0
|
||||||
|
|
||||||
|
if bb_ratio < BB_COMPRESSION_LOW:
|
||||||
|
# Compression → potential breakout, neutral-bullish
|
||||||
|
return 45 + (BB_COMPRESSION_LOW - bb_ratio) * 30
|
||||||
|
elif bb_ratio > BB_COMPRESSION_HIGH:
|
||||||
|
# Expansion → trending or chaotic
|
||||||
|
return 75 + min((bb_ratio - BB_COMPRESSION_HIGH) * 20, 20)
|
||||||
|
else:
|
||||||
|
# Normal
|
||||||
|
return 55
|
||||||
|
|
||||||
|
def _score_momentum(self, df: pd.DataFrame) -> float:
|
||||||
|
"""Score momentum using ROC and consecutive candles."""
|
||||||
|
if len(df) < 10:
|
||||||
|
return 50.0
|
||||||
|
|
||||||
|
closes = df["close"].astype(float)
|
||||||
|
latest = float(closes.iloc[-1])
|
||||||
|
|
||||||
|
# ROC (5-bar)
|
||||||
|
if len(closes) >= 6:
|
||||||
|
roc5 = (closes.iloc[-1] - closes.iloc[-6]) / closes.iloc[-6] * 100
|
||||||
|
else:
|
||||||
|
roc5 = 0
|
||||||
|
|
||||||
|
# ROC (10-bar)
|
||||||
|
if len(closes) >= 11:
|
||||||
|
roc10 = (closes.iloc[-1] - closes.iloc[-11]) / closes.iloc[-11] * 100
|
||||||
|
else:
|
||||||
|
roc10 = 0
|
||||||
|
|
||||||
|
# ROC (20-bar)
|
||||||
|
if len(closes) >= 21:
|
||||||
|
roc20 = (closes.iloc[-1] - closes.iloc[-21]) / closes.iloc[-21] * 100
|
||||||
|
else:
|
||||||
|
roc20 = 0
|
||||||
|
|
||||||
|
# Score ROC: map to 0-100
|
||||||
|
def roc_to_score(roc, scale=15):
|
||||||
|
return 50 + np.clip(roc / scale * 50, -50, 50)
|
||||||
|
|
||||||
|
roc_score = roc_to_score(roc5, 10) * 0.4 + roc_to_score(roc10, 15) * 0.35 + roc_to_score(roc20, 20) * 0.25
|
||||||
|
|
||||||
|
# Consecutive candle direction
|
||||||
|
consec_score = 50.0
|
||||||
|
consec_up = 0
|
||||||
|
consec_down = 0
|
||||||
|
for i in range(len(closes) - 1, max(0, len(closes) - 10), -1):
|
||||||
|
if closes.iloc[i] > closes.iloc[i - 1]:
|
||||||
|
consec_up += 1
|
||||||
|
consec_down = 0
|
||||||
|
elif closes.iloc[i] < closes.iloc[i - 1]:
|
||||||
|
consec_down += 1
|
||||||
|
consec_up = 0
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if consec_up >= CONSECUTIVE_CANDLES_SIGNAL:
|
||||||
|
consec_score = 70 + min(consec_up * 5, 25)
|
||||||
|
elif consec_down >= CONSECUTIVE_CANDLES_SIGNAL:
|
||||||
|
consec_score = 30 - min(consec_down * 5, 25)
|
||||||
|
|
||||||
|
return roc_score * 0.70 + consec_score * 0.30
|
||||||
|
|
||||||
|
def _build_narrative(self, trend: float, vol: float, momentum: float,
|
||||||
|
latest: pd.Series) -> str:
|
||||||
|
parts = []
|
||||||
|
if trend > 65:
|
||||||
|
parts.append("EMA多头排列+ADX趋势明确")
|
||||||
|
elif trend > 50:
|
||||||
|
parts.append("趋势温和偏多")
|
||||||
|
elif trend < 35:
|
||||||
|
parts.append("EMA空头排列+ADX趋势明确")
|
||||||
|
elif trend < 50:
|
||||||
|
parts.append("趋势温和偏空")
|
||||||
|
else:
|
||||||
|
parts.append("趋势中性")
|
||||||
|
|
||||||
|
if vol > 70:
|
||||||
|
parts.append("波动率扩张")
|
||||||
|
elif vol < 45:
|
||||||
|
parts.append("波动率压缩(突破前兆)")
|
||||||
|
|
||||||
|
if momentum > 65:
|
||||||
|
parts.append("动量强劲")
|
||||||
|
elif momentum < 35:
|
||||||
|
parts.append("动量疲弱")
|
||||||
|
|
||||||
|
return ", ".join(parts) if parts else "中性"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _label(score: float) -> str:
|
||||||
|
if score >= 75:
|
||||||
|
return "Strong Bullish Structure"
|
||||||
|
elif score >= 60:
|
||||||
|
return "Bullish Structure"
|
||||||
|
elif score >= 40:
|
||||||
|
return "Neutral Structure"
|
||||||
|
elif score >= 25:
|
||||||
|
return "Bearish Structure"
|
||||||
|
return "Weak Bearish Structure"
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
scoring/volatility_regime.py — Volatility Regime Classification.
|
||||||
|
|
||||||
|
4 regimes from OHLCV data:
|
||||||
|
LOW_VOL: ATR/Close < 2% → compression, breakout imminent
|
||||||
|
NORMAL_VOL: ATR/Close 2-5% → normal trading
|
||||||
|
HIGH_VOL: ATR/Close 5-10% → trend acceleration, wider stops
|
||||||
|
EXPLOSIVE_VOL: ATR/Close > 10% → extreme, reduce or wait
|
||||||
|
|
||||||
|
Uses: ATR(14)/Close, HV(20)/HV(60) ratio, BB width ratio.
|
||||||
|
OHLCV-only — never goes offline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
import sqlite3
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from .base import BaseScorer
|
||||||
|
from .constants import (
|
||||||
|
VOL_LOW, VOL_HIGH, VOL_REGIME_SCORES, HV_RATIO_LOW, HV_RATIO_HIGH,
|
||||||
|
)
|
||||||
|
from models import FactorScore, VolatilityRegimeScore, VolRegime, MacroDirection
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class VolatilityRegimeScorer(BaseScorer):
|
||||||
|
"""Classifies volatility regime from OHLCV data."""
|
||||||
|
|
||||||
|
def compute(self, target_date: Date) -> VolatilityRegimeScore:
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT 120",
|
||||||
|
conn, params=(str(target_date),)
|
||||||
|
)
|
||||||
|
if df.empty:
|
||||||
|
return VolatilityRegimeScore(
|
||||||
|
name="Volatility Regime",
|
||||||
|
score=50.0,
|
||||||
|
label="No Data",
|
||||||
|
)
|
||||||
|
|
||||||
|
df = df.sort_values("date").reset_index(drop=True)
|
||||||
|
|
||||||
|
# 1. ATR/Close %
|
||||||
|
latest = df.iloc[-1]
|
||||||
|
atr = latest.get("atr_14")
|
||||||
|
close = float(latest["close"])
|
||||||
|
atr_pct = (atr / close * 100) if atr and not pd.isna(atr) and close > 0 else 3.0
|
||||||
|
|
||||||
|
# 2. HV(20) / HV(60) ratio
|
||||||
|
hv_ratio = self._compute_hv_ratio(df)
|
||||||
|
|
||||||
|
# 3. BB width ratio
|
||||||
|
bb_ratio = self._compute_bb_ratio(df)
|
||||||
|
|
||||||
|
# Classify regime
|
||||||
|
regime = self._classify(atr_pct, hv_ratio, bb_ratio)
|
||||||
|
|
||||||
|
# Score
|
||||||
|
score = VOL_REGIME_SCORES.get(regime.value, 50)
|
||||||
|
|
||||||
|
# Narrative
|
||||||
|
narrative = self._build_narrative(regime, atr_pct, hv_ratio, bb_ratio)
|
||||||
|
|
||||||
|
return VolatilityRegimeScore(
|
||||||
|
name="Volatility Regime",
|
||||||
|
score=float(score),
|
||||||
|
label=regime.value,
|
||||||
|
direction=MacroDirection.NEUTRAL,
|
||||||
|
vol_regime=regime,
|
||||||
|
atr_pct=round(atr_pct, 2),
|
||||||
|
hv_ratio=round(hv_ratio, 2),
|
||||||
|
bb_width_ratio=round(bb_ratio, 2),
|
||||||
|
sub_scores={
|
||||||
|
"atr_pct": round(atr_pct, 2),
|
||||||
|
"hv_ratio": round(hv_ratio, 2),
|
||||||
|
"bb_width_ratio": round(bb_ratio, 2),
|
||||||
|
},
|
||||||
|
narrative=narrative,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _compute_hv_ratio(self, df: pd.DataFrame) -> float:
|
||||||
|
"""Compute HV(20) / HV(60) ratio."""
|
||||||
|
closes = df["close"].astype(float)
|
||||||
|
returns = closes.pct_change().dropna()
|
||||||
|
|
||||||
|
if len(returns) < 60:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
hv20 = returns.tail(20).std() * np.sqrt(365) * 100
|
||||||
|
hv60 = returns.tail(60).std() * np.sqrt(365) * 100
|
||||||
|
|
||||||
|
if hv60 == 0:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
return hv20 / hv60
|
||||||
|
|
||||||
|
def _compute_bb_ratio(self, df: pd.DataFrame) -> float:
|
||||||
|
"""Compute current BB width / 20d average BB width."""
|
||||||
|
bb_widths = df["bb_width"].dropna().tail(40)
|
||||||
|
if len(bb_widths) < 20:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
current = bb_widths.iloc[-1]
|
||||||
|
avg = bb_widths.tail(20).mean()
|
||||||
|
if avg == 0:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
return current / avg
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classify(atr_pct: float, hv_ratio: float, bb_ratio: float) -> VolRegime:
|
||||||
|
"""Classify volatility regime from multiple indicators."""
|
||||||
|
# Primary: ATR/Close %
|
||||||
|
if atr_pct > 10.0:
|
||||||
|
return VolRegime.EXPLOSIVE_VOL
|
||||||
|
elif atr_pct > VOL_HIGH:
|
||||||
|
return VolRegime.HIGH_VOL
|
||||||
|
elif atr_pct < VOL_LOW:
|
||||||
|
return VolRegime.LOW_VOL
|
||||||
|
|
||||||
|
# Secondary: HV ratio and BB ratio for edge cases
|
||||||
|
if hv_ratio > HV_RATIO_HIGH and bb_ratio > 1.3:
|
||||||
|
return VolRegime.HIGH_VOL
|
||||||
|
elif hv_ratio < HV_RATIO_LOW and bb_ratio < 0.8:
|
||||||
|
return VolRegime.LOW_VOL
|
||||||
|
|
||||||
|
return VolRegime.NORMAL_VOL
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_narrative(regime: VolRegime, atr_pct: float,
|
||||||
|
hv_ratio: float, bb_ratio: float) -> str:
|
||||||
|
mapping = {
|
||||||
|
VolRegime.LOW_VOL: f"低波动(ATR={atr_pct:.1f}%), 布林带收窄, 突破前兆",
|
||||||
|
VolRegime.NORMAL_VOL: f"正常波动(ATR={atr_pct:.1f}%), 正常交易环境",
|
||||||
|
VolRegime.HIGH_VOL: f"高波动(ATR={atr_pct:.1f}%), 趋势加速, 放宽止损",
|
||||||
|
VolRegime.EXPLOSIVE_VOL: f"极端波动(ATR={atr_pct:.1f}%), 减仓或等待",
|
||||||
|
}
|
||||||
|
return mapping.get(regime, "Unknown")
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
tests/conftest.py — Shared fixtures for ChanMacro tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
import sqlite3
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Ensure package root on path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_path(tmp_path):
|
||||||
|
"""Create a temporary SQLite database with full mock data."""
|
||||||
|
db = str(tmp_path / "test_macro.db")
|
||||||
|
from database import init_db
|
||||||
|
conn = init_db(db)
|
||||||
|
|
||||||
|
np.random.seed(42)
|
||||||
|
base = date(2025, 9, 1)
|
||||||
|
n_days = 300
|
||||||
|
|
||||||
|
# Generate realistic price series with 3 regime periods
|
||||||
|
prices = [90000]
|
||||||
|
regimes = []
|
||||||
|
for i in range(n_days):
|
||||||
|
if i < 100:
|
||||||
|
ret = np.random.normal(0.003, 0.015)
|
||||||
|
regime = "TREND"
|
||||||
|
elif i < 200:
|
||||||
|
ret = np.random.normal(0.000, 0.012)
|
||||||
|
regime = "RANGE"
|
||||||
|
else:
|
||||||
|
ret = np.random.normal(-0.003, 0.025)
|
||||||
|
regime = "PANIC"
|
||||||
|
prices.append(prices[-1] * (1 + ret))
|
||||||
|
regimes.append(regime)
|
||||||
|
|
||||||
|
for i in range(n_days):
|
||||||
|
d = base + timedelta(days=i)
|
||||||
|
c = prices[i]
|
||||||
|
r = regimes[i]
|
||||||
|
|
||||||
|
# OHLCV
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO ohlcv_daily
|
||||||
|
(date,symbol,open,high,low,close,volume,ema20,ema60,ema120,atr_14,bb_width,adx_14)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
d.strftime("%Y-%m-%d"), "BTC/USDT:USDT",
|
||||||
|
c * 0.99, c * 1.03, c * 0.97, c, 1000,
|
||||||
|
c * (0.98 if r == "TREND" else 1.02 if r == "PANIC" else 1.0),
|
||||||
|
c * (0.95 if r == "TREND" else 1.05 if r == "PANIC" else 1.0),
|
||||||
|
c * (0.90 if r == "TREND" else 1.10 if r == "PANIC" else 1.0),
|
||||||
|
c * (0.02 if r == "PANIC" else 0.015),
|
||||||
|
4.5, 28.0 if r == "TREND" else 18.0,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Breadth
|
||||||
|
adv = 42 if r == "TREND" else 25 if r == "RANGE" else 8
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO breadth_daily
|
||||||
|
(date,total_tracked,advance_top50,decline_top50,above_ema20_top50,
|
||||||
|
new_highs_20d_top50,advance_top30,advance_top20,
|
||||||
|
above_ema20_top30,above_ema20_top20,new_highs_20d_top30,new_highs_20d_top20)
|
||||||
|
VALUES (?,50,?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
d.strftime("%Y-%m-%d"), adv, 50 - adv, adv, min(adv, 15),
|
||||||
|
int(adv * 0.7), int(adv * 0.5), int(adv * 0.7), int(adv * 0.5),
|
||||||
|
min(int(adv * 0.7), 12), min(int(adv * 0.5), 8),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Derivatives
|
||||||
|
oi_chg = 3.5 if r == "TREND" else 0.5 if r == "RANGE" else -2.0
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO derivatives
|
||||||
|
(date,symbol,funding_rate,open_interest,oi_24h_change_pct,
|
||||||
|
long_liquidations,short_liquidations,basis_annualised_pct)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
d.strftime("%Y-%m-%d"), "BTC/USDT:USDT",
|
||||||
|
0.0001 + np.random.normal(0, 0.0002),
|
||||||
|
35e9, oi_chg + np.random.normal(0, 1.0),
|
||||||
|
50e6 * np.random.random(), 30e6 * np.random.random(),
|
||||||
|
8.5 if r == "TREND" else 3.0,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Regime history
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO regime_history
|
||||||
|
(date,regime,confidence,regime_version,maturity_score,all_scores_json,confirmation_days)
|
||||||
|
VALUES (?,?,?,?,?,?,?)
|
||||||
|
""", (d.strftime("%Y-%m-%d"), r, 0.75, "v1_price_breadth_vol", 50, "{}", 1))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Override config to use test DB
|
||||||
|
from config import config
|
||||||
|
old_db = config.db_path
|
||||||
|
config.db_path = db
|
||||||
|
yield db
|
||||||
|
config.db_path = old_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_state(db_path):
|
||||||
|
"""Build a MarketStateVector for a known test date."""
|
||||||
|
from models import (
|
||||||
|
MarketStateVector, MarketRegime, BreadthBucket,
|
||||||
|
OIState, VolRegime,
|
||||||
|
)
|
||||||
|
state = MarketStateVector(
|
||||||
|
date=date(2026, 3, 15),
|
||||||
|
regime=MarketRegime.TREND,
|
||||||
|
regime_confidence=0.82,
|
||||||
|
regime_version="v1_price_breadth_vol",
|
||||||
|
regime_maturity_score=55.0,
|
||||||
|
breadth_top20=82.0,
|
||||||
|
breadth_top30=78.0,
|
||||||
|
breadth_top50=74.0,
|
||||||
|
breadth_bucket=BreadthBucket.STRONG,
|
||||||
|
breadth_divergence=8.0,
|
||||||
|
oi_state=OIState.NEW_LONGS,
|
||||||
|
volatility_regime=VolRegime.NORMAL_VOL,
|
||||||
|
)
|
||||||
|
state.market_state_hash = state.compute_hash()
|
||||||
|
return state
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Test SignalTracker, TimeDecay, and BayesianExpectancyEngine."""
|
||||||
|
import pytest
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeDecay:
|
||||||
|
def test_recent_weight_near_one(self):
|
||||||
|
from expectancy.decay import TimeDecay
|
||||||
|
d = TimeDecay(180)
|
||||||
|
w = d.weight(date(2026, 6, 20), date(2026, 6, 24))
|
||||||
|
assert 0.95 < w < 1.0
|
||||||
|
|
||||||
|
def test_old_weight_decays(self):
|
||||||
|
from expectancy.decay import TimeDecay
|
||||||
|
d = TimeDecay(180)
|
||||||
|
w = d.weight(date(2025, 6, 24), date(2026, 6, 24))
|
||||||
|
assert 0.2 < w < 0.3 # ~365 days at half_life=180
|
||||||
|
|
||||||
|
def test_effective_samples(self):
|
||||||
|
from expectancy.decay import TimeDecay
|
||||||
|
d = TimeDecay(180)
|
||||||
|
dates = [date(2026, 6, 24)] * 10
|
||||||
|
weights = d.weights(dates, date(2026, 6, 24))
|
||||||
|
eff = d.effective_samples(weights)
|
||||||
|
assert eff == pytest.approx(10.0, rel=0.01)
|
||||||
|
|
||||||
|
def test_weighted_win_rate(self):
|
||||||
|
from expectancy.decay import TimeDecay
|
||||||
|
d = TimeDecay(180)
|
||||||
|
wins = np.array([1, 0, 1, 0])
|
||||||
|
weights = np.array([1.0, 1.0, 1.0, 1.0])
|
||||||
|
wr = d.weighted_win_rate(wins, weights)
|
||||||
|
assert wr == 0.5
|
||||||
|
|
||||||
|
def test_weight_at_age(self):
|
||||||
|
from expectancy.decay import TimeDecay
|
||||||
|
w = TimeDecay.weight_at_age(180, 180)
|
||||||
|
assert w == pytest.approx(0.5, rel=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalTracker:
|
||||||
|
def test_record_signal(self, db_path, sample_state):
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
tracker = SignalTracker()
|
||||||
|
rid = tracker.record(
|
||||||
|
date(2026, 3, 15), "B3", 98000.0, sample_state,
|
||||||
|
signal_grade="A", signal_strength=75.0,
|
||||||
|
)
|
||||||
|
assert rid is not None
|
||||||
|
assert rid > 0
|
||||||
|
|
||||||
|
def test_get_samples(self, db_path, sample_state):
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
tracker = SignalTracker()
|
||||||
|
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||||
|
tracker.record(date(2026, 3, 16), "B2", 98500.0, sample_state)
|
||||||
|
|
||||||
|
samples = tracker.get_samples(signal_type="B3")
|
||||||
|
assert len(samples) == 1
|
||||||
|
assert samples[0]["signal_type"] == "B3"
|
||||||
|
|
||||||
|
def test_count_samples(self, db_path, sample_state):
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
tracker = SignalTracker()
|
||||||
|
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||||
|
tracker.record(date(2026, 3, 16), "B3", 98500.0, sample_state)
|
||||||
|
|
||||||
|
counts = tracker.count_samples()
|
||||||
|
assert "B3/TREND" in counts
|
||||||
|
assert counts["B3/TREND"] == 2
|
||||||
|
|
||||||
|
def test_filter_by_regime(self, db_path, sample_state):
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
tracker = SignalTracker()
|
||||||
|
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||||
|
|
||||||
|
samples = tracker.get_samples(signal_type="B3", regime="TREND")
|
||||||
|
assert len(samples) == 1
|
||||||
|
|
||||||
|
samples = tracker.get_samples(signal_type="B3", regime="PANIC")
|
||||||
|
assert len(samples) == 0
|
||||||
|
|
||||||
|
def test_backfill_signals(self, db_path, sample_state):
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
tracker = SignalTracker()
|
||||||
|
signals = [
|
||||||
|
{"date": date(2026, 3, 15), "signal_type": "B3", "entry_price": 98000},
|
||||||
|
{"date": date(2026, 3, 20), "signal_type": "B2", "entry_price": 99000},
|
||||||
|
]
|
||||||
|
count = tracker.backfill_signals(signals)
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestBayesianExpectancyEngine:
|
||||||
|
def test_estimate_returns_report(self, db_path, sample_state):
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
from expectancy.engine import BayesianExpectancyEngine
|
||||||
|
|
||||||
|
# Record some signals first
|
||||||
|
tracker = SignalTracker()
|
||||||
|
for i in range(10):
|
||||||
|
tracker.record(
|
||||||
|
date(2026, 3, 15) + timedelta(days=i),
|
||||||
|
"B3", 98000.0, sample_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = BayesianExpectancyEngine(level_min_samples=3)
|
||||||
|
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||||
|
assert report.signal_type == "B3"
|
||||||
|
assert len(report.layers) > 0
|
||||||
|
assert report.source in ("bayesian", "insufficient")
|
||||||
|
|
||||||
|
def test_insufficient_with_no_samples(self, db_path, sample_state):
|
||||||
|
from expectancy.engine import BayesianExpectancyEngine
|
||||||
|
engine = BayesianExpectancyEngine(level_min_samples=10)
|
||||||
|
report = engine.estimate(sample_state, "B1", date(2026, 3, 25))
|
||||||
|
assert report.sufficiency.value in ("INSUFFICIENT", "LOW", "MEDIUM", "HIGH")
|
||||||
|
|
||||||
|
def test_empirical_bayes_shrinks_small_samples(self, db_path, sample_state):
|
||||||
|
"""With N=3, raw=100%, posterior should be pulled toward prior."""
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
from expectancy.engine import BayesianExpectancyEngine
|
||||||
|
|
||||||
|
tracker = SignalTracker()
|
||||||
|
for i in range(3):
|
||||||
|
tracker.record(
|
||||||
|
date(2026, 3, 15) + timedelta(days=i),
|
||||||
|
"B3", 98000.0, sample_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = BayesianExpectancyEngine(level_min_samples=1)
|
||||||
|
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||||
|
|
||||||
|
# With small N, posterior should differ from raw
|
||||||
|
base_layer = report.layers[0]
|
||||||
|
if base_layer.raw_winrate and base_layer.samples < 50:
|
||||||
|
# Posterior should be pulled toward prior (50% or global rate)
|
||||||
|
if base_layer.raw_winrate > 0.8:
|
||||||
|
assert base_layer.posterior_winrate < base_layer.raw_winrate
|
||||||
|
|
||||||
|
def test_leveled_fallback_stops_at_min_samples(self, db_path, sample_state):
|
||||||
|
from expectancy.tracker import SignalTracker
|
||||||
|
from expectancy.engine import BayesianExpectancyEngine
|
||||||
|
|
||||||
|
tracker = SignalTracker()
|
||||||
|
for i in range(20):
|
||||||
|
tracker.record(date(2026, 3, 15) + timedelta(days=i), "B3", 98000.0, sample_state)
|
||||||
|
|
||||||
|
engine = BayesianExpectancyEngine(level_min_samples=15)
|
||||||
|
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||||
|
# Should have stopped at a level with >= 15 effective samples
|
||||||
|
assert report.final_estimate >= 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSufficiencyGuard:
|
||||||
|
def test_insufficient(self):
|
||||||
|
from expectancy.engine import SufficiencyGuard
|
||||||
|
from models import SufficiencyLevel
|
||||||
|
g = SufficiencyGuard()
|
||||||
|
assert g.evaluate(10) == SufficiencyLevel.INSUFFICIENT
|
||||||
|
|
||||||
|
def test_low(self):
|
||||||
|
from expectancy.engine import SufficiencyGuard
|
||||||
|
from models import SufficiencyLevel
|
||||||
|
g = SufficiencyGuard()
|
||||||
|
assert g.evaluate(40) == SufficiencyLevel.LOW
|
||||||
|
|
||||||
|
def test_high(self):
|
||||||
|
from expectancy.engine import SufficiencyGuard
|
||||||
|
from models import SufficiencyLevel
|
||||||
|
g = SufficiencyGuard()
|
||||||
|
assert g.evaluate(200) == SufficiencyLevel.HIGH
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Test all Pydantic models and enums."""
|
||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from models import (
|
||||||
|
MarketRegime, OIState, BreadthBucket, VolRegime,
|
||||||
|
MarketStateVector, FactorScore, RegimeResult,
|
||||||
|
SignalFeatureRecord, ExpectancyReport, DailyOutput,
|
||||||
|
FactorContribution, SufficiencyLevel, SignalGrade,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnums:
|
||||||
|
def test_regime_values(self):
|
||||||
|
assert MarketRegime.TREND.value == "TREND"
|
||||||
|
assert MarketRegime.RANGE.value == "RANGE"
|
||||||
|
assert MarketRegime.PANIC.value == "PANIC"
|
||||||
|
|
||||||
|
def test_oi_state_has_neutral(self):
|
||||||
|
assert OIState.NEUTRAL.value == "Neutral"
|
||||||
|
assert len(OIState) == 5
|
||||||
|
|
||||||
|
def test_breadth_bucket_values(self):
|
||||||
|
assert BreadthBucket.EXTREME.value == "EXTREME"
|
||||||
|
assert len(BreadthBucket) == 5
|
||||||
|
|
||||||
|
def test_vol_regime_values(self):
|
||||||
|
assert VolRegime.LOW_VOL.value == "LOW_VOL"
|
||||||
|
assert VolRegime.EXPLOSIVE_VOL.value == "EXPLOSIVE_VOL"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarketStateVector:
|
||||||
|
def test_minimal_construction(self):
|
||||||
|
sv = MarketStateVector(
|
||||||
|
date="2026-06-24",
|
||||||
|
regime=MarketRegime.TREND,
|
||||||
|
regime_confidence=0.82,
|
||||||
|
regime_version="v1_price_breadth_vol",
|
||||||
|
)
|
||||||
|
assert sv.date == date(2026, 6, 24)
|
||||||
|
assert sv.regime == MarketRegime.TREND
|
||||||
|
assert sv.breadth_top50 == 50.0 # default
|
||||||
|
|
||||||
|
def test_date_string_parsing(self):
|
||||||
|
sv = MarketStateVector(
|
||||||
|
date="2026-01-15",
|
||||||
|
regime=MarketRegime.RANGE,
|
||||||
|
regime_confidence=0.55,
|
||||||
|
regime_version="v1_price_breadth_vol",
|
||||||
|
)
|
||||||
|
assert sv.date == date(2026, 1, 15)
|
||||||
|
|
||||||
|
def test_compute_hash(self):
|
||||||
|
sv = MarketStateVector(
|
||||||
|
date="2026-06-24",
|
||||||
|
regime=MarketRegime.TREND,
|
||||||
|
regime_confidence=0.82,
|
||||||
|
regime_version="v1_price_breadth_vol",
|
||||||
|
breadth_bucket=BreadthBucket.EXTREME,
|
||||||
|
oi_state=OIState.NEW_LONGS,
|
||||||
|
volatility_regime=VolRegime.NORMAL_VOL,
|
||||||
|
)
|
||||||
|
h = sv.compute_hash()
|
||||||
|
assert len(h) == 12
|
||||||
|
# Same state = same hash
|
||||||
|
sv2 = MarketStateVector(
|
||||||
|
date="2026-06-25",
|
||||||
|
regime=MarketRegime.TREND,
|
||||||
|
regime_confidence=0.80,
|
||||||
|
regime_version="v1_price_breadth_vol",
|
||||||
|
breadth_bucket=BreadthBucket.EXTREME,
|
||||||
|
oi_state=OIState.NEW_LONGS,
|
||||||
|
volatility_regime=VolRegime.NORMAL_VOL,
|
||||||
|
)
|
||||||
|
assert sv2.compute_hash() == h
|
||||||
|
|
||||||
|
def test_state_embedding(self):
|
||||||
|
sv = MarketStateVector(
|
||||||
|
date="2026-06-24",
|
||||||
|
regime=MarketRegime.TREND,
|
||||||
|
regime_confidence=0.82,
|
||||||
|
regime_version="v1_price_breadth_vol",
|
||||||
|
breadth_top20=80.0,
|
||||||
|
breadth_top30=75.0,
|
||||||
|
breadth_top50=70.0,
|
||||||
|
regime_maturity_score=60.0,
|
||||||
|
)
|
||||||
|
emb = sv.state_embedding()
|
||||||
|
assert len(emb) == 5
|
||||||
|
assert emb[0] == 80.0
|
||||||
|
assert emb[3] == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegimeResult:
|
||||||
|
def test_construction(self):
|
||||||
|
r = RegimeResult(
|
||||||
|
date="2026-06-24",
|
||||||
|
regime=MarketRegime.TREND,
|
||||||
|
confidence=0.82,
|
||||||
|
regime_version="v1_price_breadth_vol",
|
||||||
|
maturity_score=55.0,
|
||||||
|
all_scores={"TREND": 82.0, "RANGE": 45.0, "PANIC": 20.0},
|
||||||
|
confirmation_days=5,
|
||||||
|
)
|
||||||
|
assert r.regime == MarketRegime.TREND
|
||||||
|
assert r.confirmation_days == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpectancyReport:
|
||||||
|
def test_insufficient(self):
|
||||||
|
r = ExpectancyReport(
|
||||||
|
signal_type="B3",
|
||||||
|
date="2026-06-24",
|
||||||
|
final_estimate=0.0,
|
||||||
|
sufficiency=SufficiencyLevel.INSUFFICIENT,
|
||||||
|
source="insufficient",
|
||||||
|
)
|
||||||
|
assert r.final_estimate == 0.0
|
||||||
|
assert r.sufficiency == SufficiencyLevel.INSUFFICIENT
|
||||||
|
|
||||||
|
|
||||||
|
class TestFactorContribution:
|
||||||
|
def test_construction(self):
|
||||||
|
fc = FactorContribution(
|
||||||
|
factor="ETF Flow",
|
||||||
|
raw_score=85.0,
|
||||||
|
weight=0.1925,
|
||||||
|
impact=6.7,
|
||||||
|
direction="bullish",
|
||||||
|
)
|
||||||
|
assert fc.impact > 0
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Test regime detector and validation."""
|
||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegimeDetector:
|
||||||
|
def test_detects_trend(self):
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketRegime
|
||||||
|
d = RegimeDetector()
|
||||||
|
r = d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||||
|
assert r.regime == MarketRegime.TREND
|
||||||
|
assert r.confidence > 0.5
|
||||||
|
|
||||||
|
def test_detects_range(self):
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketRegime
|
||||||
|
d = RegimeDetector()
|
||||||
|
r = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 24))
|
||||||
|
assert r.regime in (MarketRegime.RANGE, MarketRegime.TREND)
|
||||||
|
|
||||||
|
def test_detects_panic(self):
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketRegime
|
||||||
|
d = RegimeDetector()
|
||||||
|
r = d.detect(15.0, 10.0, "EXPLOSIVE_VOL", date(2026, 6, 24))
|
||||||
|
assert r.regime == MarketRegime.PANIC
|
||||||
|
|
||||||
|
def test_2day_confirmation(self):
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketRegime
|
||||||
|
d = RegimeDetector()
|
||||||
|
# Day 1: RANGE
|
||||||
|
r1 = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 24))
|
||||||
|
assert r1.regime == MarketRegime.RANGE # first run, no confirmation needed
|
||||||
|
# Day 2: still RANGE
|
||||||
|
r2 = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 25))
|
||||||
|
assert r2.regime == MarketRegime.RANGE
|
||||||
|
assert r2.confirmation_days == 2
|
||||||
|
|
||||||
|
def test_transition_needs_confirmation(self):
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketRegime
|
||||||
|
d = RegimeDetector()
|
||||||
|
# Establish TREND
|
||||||
|
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||||
|
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 25))
|
||||||
|
# Day 3: weak scores → raw best = RANGE, but TREND should persist
|
||||||
|
r3 = d.detect(35.0, 40.0, "NORMAL_VOL", date(2026, 6, 26))
|
||||||
|
# First day of pending transition — should still be TREND
|
||||||
|
assert r3.regime == MarketRegime.TREND
|
||||||
|
assert d.pending_regime is not None
|
||||||
|
|
||||||
|
def test_version_is_stored(self):
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
d = RegimeDetector(regime_version="v1_price_breadth_vol")
|
||||||
|
r = d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||||
|
assert r.regime_version == "v1_price_breadth_vol"
|
||||||
|
|
||||||
|
def test_load_state(self, db_path):
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketRegime
|
||||||
|
d = RegimeDetector()
|
||||||
|
d.load_state(db_path)
|
||||||
|
# DB has TREND for first 100 days, so most recent should load
|
||||||
|
assert d.current_regime is not None
|
||||||
|
|
||||||
|
def test_confidence_for_confirmed_regime(self):
|
||||||
|
"""Confidence should be for the confirmed regime, not raw best."""
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketRegime
|
||||||
|
d = RegimeDetector()
|
||||||
|
# Establish TREND
|
||||||
|
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||||
|
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 25))
|
||||||
|
# Now feed weak scores → raw best would be PANIC or RANGE
|
||||||
|
r = d.detect(15.0, 10.0, "EXPLOSIVE_VOL", date(2026, 6, 26))
|
||||||
|
# Should still report TREND (need 2 confirmations to switch)
|
||||||
|
assert r.regime == MarketRegime.TREND
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransitionValidator:
|
||||||
|
def test_stable_regime_passes(self):
|
||||||
|
from validation.transition_validator import TransitionValidator
|
||||||
|
# Create stable regime sequence: long periods
|
||||||
|
seq = pd.Series(
|
||||||
|
["TREND"] * 50 + ["RANGE"] * 50 + ["PANIC"] * 40,
|
||||||
|
index=pd.date_range("2026-01-01", periods=140),
|
||||||
|
)
|
||||||
|
tv = TransitionValidator()
|
||||||
|
report = tv.validate(seq)
|
||||||
|
assert report.is_stable
|
||||||
|
assert report.avg_duration > 20
|
||||||
|
assert report.flip_rate < 0.05
|
||||||
|
|
||||||
|
def test_unstable_regime_fails(self):
|
||||||
|
from validation.transition_validator import TransitionValidator
|
||||||
|
# Create unstable sequence: flips every 2 days
|
||||||
|
seq = pd.Series(
|
||||||
|
["TREND", "TREND", "RANGE", "RANGE", "TREND", "TREND",
|
||||||
|
"PANIC", "PANIC", "RANGE", "RANGE"] * 5,
|
||||||
|
index=pd.date_range("2026-01-01", periods=50),
|
||||||
|
)
|
||||||
|
tv = TransitionValidator()
|
||||||
|
report = tv.validate(seq)
|
||||||
|
assert not report.is_stable
|
||||||
|
assert report.flip_rate > 0.15
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Test all 4 core scorers."""
|
||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
class TestPriceStructureScorer:
|
||||||
|
def test_computes_score(self, db_path):
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
scorer = PriceStructureScorer()
|
||||||
|
result = scorer.compute(date(2026, 3, 15))
|
||||||
|
assert result.name == "Price Structure"
|
||||||
|
assert 0 <= result.score <= 100
|
||||||
|
assert result.trend_strength >= 0
|
||||||
|
assert result.volatility_compression >= 0
|
||||||
|
assert result.momentum >= 0
|
||||||
|
assert result.label
|
||||||
|
|
||||||
|
def test_bullish_in_trend(self, db_path):
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
scorer = PriceStructureScorer()
|
||||||
|
result = scorer.compute(date(2025, 11, 15)) # TREND period
|
||||||
|
assert result.score > 50 # Should be bullish in uptrend
|
||||||
|
|
||||||
|
def test_bearish_in_panic(self, db_path):
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
scorer = PriceStructureScorer()
|
||||||
|
result = scorer.compute(date(2026, 5, 15)) # PANIC period
|
||||||
|
# In panic period, EMA alignment should be bearish
|
||||||
|
assert result.trend_strength < 60
|
||||||
|
|
||||||
|
def test_no_data_handling(self, db_path):
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
scorer = PriceStructureScorer()
|
||||||
|
result = scorer.compute(date(2020, 1, 1))
|
||||||
|
assert result.score == 50.0
|
||||||
|
assert result.label == "No Data"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBreadthScorer:
|
||||||
|
def test_computes_score(self, db_path):
|
||||||
|
from scoring.breadth_scorer import BreadthScorer
|
||||||
|
scorer = BreadthScorer()
|
||||||
|
result = scorer.compute(date(2026, 3, 15))
|
||||||
|
assert result.name == "Breadth"
|
||||||
|
assert 0 <= result.score <= 100
|
||||||
|
assert result.breadth_bucket
|
||||||
|
assert result.breadth_top20 >= 0
|
||||||
|
assert result.breadth_top50 >= 0
|
||||||
|
|
||||||
|
def test_tier_values(self, db_path):
|
||||||
|
from scoring.breadth_scorer import BreadthScorer
|
||||||
|
scorer = BreadthScorer()
|
||||||
|
result = scorer.compute(date(2025, 11, 15)) # TREND period
|
||||||
|
# Top20 should generally be higher than Top50 (large caps lead)
|
||||||
|
assert result.breadth_top20 >= 0
|
||||||
|
assert result.breadth_top50 >= 0
|
||||||
|
|
||||||
|
def test_bucket_assignment(self, db_path):
|
||||||
|
from scoring.breadth_scorer import BreadthScorer, BreadthBucket
|
||||||
|
scorer = BreadthScorer()
|
||||||
|
result = scorer.compute(date(2025, 11, 15)) # TREND: adv=42/50
|
||||||
|
assert result.breadth_bucket in (
|
||||||
|
BreadthBucket.EXTREME, BreadthBucket.STRONG, BreadthBucket.NORMAL
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_data(self, db_path):
|
||||||
|
from scoring.breadth_scorer import BreadthScorer
|
||||||
|
scorer = BreadthScorer()
|
||||||
|
result = scorer.compute(date(2020, 1, 1))
|
||||||
|
assert result.score == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestOIMatrixScorer:
|
||||||
|
def test_computes_state(self, db_path):
|
||||||
|
from scoring.oi_matrix import OIMatrixScorer, OIState
|
||||||
|
scorer = OIMatrixScorer()
|
||||||
|
result = scorer.compute(date(2025, 11, 15)) # TREND period, oi_chg=+3.5
|
||||||
|
assert result.oi_state in OIState
|
||||||
|
assert 0 <= result.score <= 100
|
||||||
|
|
||||||
|
def test_new_longs_in_trend(self, db_path):
|
||||||
|
from scoring.oi_matrix import OIMatrixScorer, OIState
|
||||||
|
scorer = OIMatrixScorer()
|
||||||
|
# Test multiple dates in TREND period — at least one should be NEW_LONGS or NEUTRAL
|
||||||
|
found_bullish = False
|
||||||
|
for d in ["2025-11-15", "2025-11-20", "2025-12-01", "2025-12-15"]:
|
||||||
|
result = scorer.compute(date.fromisoformat(d))
|
||||||
|
if result.oi_state in (OIState.NEW_LONGS, OIState.SHORT_COVERING, OIState.NEUTRAL):
|
||||||
|
found_bullish = True
|
||||||
|
break
|
||||||
|
assert found_bullish, "No bullish OI state found in TREND period"
|
||||||
|
|
||||||
|
def test_no_data(self, db_path):
|
||||||
|
from scoring.oi_matrix import OIMatrixScorer
|
||||||
|
scorer = OIMatrixScorer()
|
||||||
|
result = scorer.compute(date(2020, 1, 1))
|
||||||
|
assert result.score == 50.0
|
||||||
|
assert result.label == "No Data"
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatilityRegimeScorer:
|
||||||
|
def test_computes_regime(self, db_path):
|
||||||
|
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
|
||||||
|
scorer = VolatilityRegimeScorer()
|
||||||
|
result = scorer.compute(date(2026, 3, 15))
|
||||||
|
assert result.vol_regime in VolRegime
|
||||||
|
assert 0 <= result.score <= 100
|
||||||
|
|
||||||
|
def test_higher_vol_in_panic(self, db_path):
|
||||||
|
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
|
||||||
|
scorer = VolatilityRegimeScorer()
|
||||||
|
trend_result = scorer.compute(date(2025, 11, 15))
|
||||||
|
panic_result = scorer.compute(date(2026, 5, 15))
|
||||||
|
# PANIC period has higher ATR → higher vol regime or score
|
||||||
|
assert panic_result.atr_pct >= trend_result.atr_pct * 0.5 # at least comparable
|
||||||
|
|
||||||
|
def test_no_data(self, db_path):
|
||||||
|
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||||
|
scorer = VolatilityRegimeScorer()
|
||||||
|
result = scorer.compute(date(2020, 1, 1))
|
||||||
|
assert result.score == 50.0
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
trend_detector.py — Trend strength and maturity helpers.
|
||||||
|
|
||||||
|
Utility functions for computing trend alignment, acceleration, persistence.
|
||||||
|
Used by regime_detector and price_structure scorer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def ema_alignment_score(close: float, ema20: float, ema60: float, ema120: float) -> float:
|
||||||
|
"""Score EMA alignment: 0=bearish, 50=neutral, 100=bullish."""
|
||||||
|
if any(pd.isna(x) for x in [ema20, ema60, ema120]):
|
||||||
|
return 50.0
|
||||||
|
|
||||||
|
alignments = 0
|
||||||
|
if ema20 > ema60:
|
||||||
|
alignments += 1
|
||||||
|
if ema60 > ema120:
|
||||||
|
alignments += 1
|
||||||
|
if ema20 > ema120:
|
||||||
|
alignments += 1
|
||||||
|
|
||||||
|
if alignments == 3:
|
||||||
|
return 85.0
|
||||||
|
elif alignments == 2:
|
||||||
|
return 65.0
|
||||||
|
elif alignments == 1:
|
||||||
|
return 35.0
|
||||||
|
else:
|
||||||
|
return 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def adx_trend_score(adx: float) -> float:
|
||||||
|
"""Convert ADX value to trend score: 0-100."""
|
||||||
|
if pd.isna(adx):
|
||||||
|
return 50.0
|
||||||
|
if adx > 40:
|
||||||
|
return 90.0
|
||||||
|
elif adx > 25:
|
||||||
|
return 60.0 + (adx - 25) / 15 * 30
|
||||||
|
elif adx > 15:
|
||||||
|
return 40.0 + (adx - 15) / 10 * 20
|
||||||
|
else:
|
||||||
|
return max(10.0, adx / 15 * 40)
|
||||||
|
|
||||||
|
|
||||||
|
def breadth_persistence(breadth_scores: list[float], window: int = 5) -> float:
|
||||||
|
"""How consistently has breadth stayed at its current level? 0-100."""
|
||||||
|
if len(breadth_scores) < window:
|
||||||
|
return 50.0
|
||||||
|
recent = breadth_scores[-window:]
|
||||||
|
mean_val = np.mean(recent)
|
||||||
|
std_val = np.std(recent) if len(recent) > 1 else 0
|
||||||
|
# Low std = high persistence
|
||||||
|
persistence = 100 - min(std_val * 5, 100)
|
||||||
|
# Bias: higher breadth = higher persistence score
|
||||||
|
return persistence * 0.5 + mean_val * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def trend_strength_composite(ema_score: float, adx_score: float,
|
||||||
|
breadth_score: float) -> float:
|
||||||
|
"""Composite trend strength 0-100."""
|
||||||
|
return ema_score * 0.25 + adx_score * 0.25 + breadth_score * 0.50
|
||||||
|
|
||||||
|
|
||||||
|
def compute_maturity(trend_strength: float, breadth_persistence: float,
|
||||||
|
vol_expansion: float) -> float:
|
||||||
|
"""
|
||||||
|
Compute regime maturity score 0-100.
|
||||||
|
|
||||||
|
EMERGING (0-30): trend accelerating, breadth expanding
|
||||||
|
CONFIRMED (30-70): trend stable, breadth stable
|
||||||
|
EXHAUSTING (70-100): trend decelerating, breadth contracting, vol abnormal
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
trend_strength * 0.50 +
|
||||||
|
breadth_persistence * 0.30 +
|
||||||
|
(100 - vol_expansion) * 0.20 # inverted: low vol = early stage
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Validation Framework — Phase 0: verify every factor before trusting it."""
|
||||||
|
from .factor_validator import FactorValidator
|
||||||
|
from .regime_validator import RegimeValidator
|
||||||
|
from .transition_validator import TransitionValidator
|
||||||
|
from .reporter import ValidationReporter
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
validation/factor_validator.py — Validates a factor's predictive power.
|
||||||
|
|
||||||
|
Tests: IC, ICIR, Hit Ratio, Quantile Spread, Lead-Lag analysis.
|
||||||
|
Answers: "Does this factor predict future returns?"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from .metrics import (
|
||||||
|
information_coefficient, icir, hit_ratio,
|
||||||
|
quantile_spread, lead_lag_ic,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FactorReport:
|
||||||
|
"""Structured report for a single factor's validation results."""
|
||||||
|
|
||||||
|
def __init__(self, factor_name: str):
|
||||||
|
self.factor_name = factor_name
|
||||||
|
self.ic_mean: float = 0.0
|
||||||
|
self.ic_std: float = 0.0
|
||||||
|
self.icir: float = 0.0
|
||||||
|
self.hit_ratio: float = 0.0
|
||||||
|
self.quantile_spread: float = 0.0
|
||||||
|
self.is_leading: bool = False
|
||||||
|
self.lead_days: int = 0
|
||||||
|
self.lead_ic: float = 0.0
|
||||||
|
self.n_observations: int = 0
|
||||||
|
self.conclusion: str = ""
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
lines = [
|
||||||
|
f"Factor: {self.factor_name}",
|
||||||
|
f" N={self.n_observations}",
|
||||||
|
f" IC mean={self.ic_mean:.4f} std={self.ic_std:.4f} ICIR={self.icir:.2f}",
|
||||||
|
f" Hit Ratio={self.hit_ratio:.1%} Top-Bot Spread={self.quantile_spread:.4f}",
|
||||||
|
f" Best Lead: {self.lead_days}d (IC={self.lead_ic:.4f})" if self.is_leading else " Leading: No (synchronous/lagging)",
|
||||||
|
f" → {self.conclusion}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class FactorValidator:
|
||||||
|
"""
|
||||||
|
Validates a factor's predictive power using standard quant metrics.
|
||||||
|
|
||||||
|
For each forward horizon (1d, 3d, 5d, 7d, 14d), computes:
|
||||||
|
- IC (Spearman rank correlation)
|
||||||
|
- ICIR (IC stability)
|
||||||
|
- Hit Ratio (direction accuracy)
|
||||||
|
- Quantile spread (top vs bottom bucket)
|
||||||
|
- Lead-lag profile
|
||||||
|
|
||||||
|
A factor is valid if IC > 0.03 and ICIR > 0.5.
|
||||||
|
For regime factors, also check regime_validator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
|
self.db_path = db_path or config.db_path
|
||||||
|
|
||||||
|
def validate(self, factor_name: str, factor_scores: pd.Series,
|
||||||
|
forward_returns: dict[str, pd.Series]) -> FactorReport:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
factor_name: Human-readable name
|
||||||
|
factor_scores: Series indexed by date, values 0-100
|
||||||
|
forward_returns: Dict of horizon → Series indexed by date (e.g. "1d" → returns)
|
||||||
|
"""
|
||||||
|
report = FactorReport(factor_name)
|
||||||
|
|
||||||
|
# Align series to common dates
|
||||||
|
common_idx = factor_scores.index
|
||||||
|
for ret in forward_returns.values():
|
||||||
|
common_idx = common_idx.intersection(ret.index)
|
||||||
|
|
||||||
|
if len(common_idx) < 30:
|
||||||
|
report.conclusion = "INSUFFICIENT DATA (< 30 observations)"
|
||||||
|
return report
|
||||||
|
|
||||||
|
f = factor_scores[common_idx]
|
||||||
|
report.n_observations = len(common_idx)
|
||||||
|
|
||||||
|
# Test against 7d forward returns (primary horizon)
|
||||||
|
primary_ret = forward_returns.get("7d")
|
||||||
|
if primary_ret is None:
|
||||||
|
# Use first available
|
||||||
|
primary_ret = list(forward_returns.values())[0]
|
||||||
|
|
||||||
|
r = primary_ret[common_idx]
|
||||||
|
|
||||||
|
# IC
|
||||||
|
ic = information_coefficient(f, r)
|
||||||
|
report.ic_mean = round(ic, 4)
|
||||||
|
|
||||||
|
# Rolling IC for ICIR
|
||||||
|
rolling_ics = []
|
||||||
|
for i in range(30, len(f)):
|
||||||
|
ic_i = information_coefficient(f.iloc[:i], r.iloc[:i])
|
||||||
|
rolling_ics.append(ic_i)
|
||||||
|
ic_series = pd.Series(rolling_ics)
|
||||||
|
report.ic_std = round(ic_series.std(), 4)
|
||||||
|
report.icir = round(icir(ic_series), 2)
|
||||||
|
|
||||||
|
# Hit ratio
|
||||||
|
report.hit_ratio = round(hit_ratio(f, r), 4)
|
||||||
|
|
||||||
|
# Quantile spread
|
||||||
|
report.quantile_spread = round(quantile_spread(f, r), 4)
|
||||||
|
|
||||||
|
# Lead-lag
|
||||||
|
lead = lead_lag_ic(f, r, max_lag=14)
|
||||||
|
report.is_leading = lead["is_leading"]
|
||||||
|
report.lead_days = lead["lead_days"]
|
||||||
|
report.lead_ic = round(lead["best_ic"], 4)
|
||||||
|
|
||||||
|
# Conclusion
|
||||||
|
if abs(report.ic_mean) > 0.05 and report.icir > 1.0:
|
||||||
|
report.conclusion = "STRONG: significant predictive power"
|
||||||
|
elif abs(report.ic_mean) > 0.03 and report.icir > 0.5:
|
||||||
|
report.conclusion = "VALID: moderate predictive power"
|
||||||
|
elif abs(report.ic_mean) < 0.02:
|
||||||
|
report.conclusion = "CONFIRMING: describes current state, not predictive"
|
||||||
|
else:
|
||||||
|
report.conclusion = "WEAK: borderline, monitor or downweight"
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def validate_from_db(self, factor_name: str,
|
||||||
|
score_query: str,
|
||||||
|
horizon_days: int = 7) -> FactorReport:
|
||||||
|
"""
|
||||||
|
Convenience: load scores from DB and OHLCV returns, then validate.
|
||||||
|
|
||||||
|
score_query: SQL that returns (date, score) pairs.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
|
scores_df = pd.read_sql_query(score_query, conn)
|
||||||
|
if scores_df.empty:
|
||||||
|
conn.close()
|
||||||
|
r = FactorReport(factor_name)
|
||||||
|
r.conclusion = "NO DATA"
|
||||||
|
return r
|
||||||
|
|
||||||
|
scores_df["date"] = pd.to_datetime(scores_df["date"])
|
||||||
|
scores = scores_df.set_index("date")["score"]
|
||||||
|
|
||||||
|
# Load forward returns from OHLCV
|
||||||
|
ohlcv = pd.read_sql_query(
|
||||||
|
"SELECT date, close FROM ohlcv_daily WHERE symbol='BTC/USDT:USDT' ORDER BY date",
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
ohlcv["date"] = pd.to_datetime(ohlcv["date"])
|
||||||
|
ohlcv = ohlcv.set_index("date")
|
||||||
|
ohlcv["ret"] = ohlcv["close"].pct_change().shift(-1) # forward 1d
|
||||||
|
|
||||||
|
# Build forward returns for multiple horizons
|
||||||
|
forward = {}
|
||||||
|
for h in [1, 3, 5, 7, 14]:
|
||||||
|
forward[str(h) + "d"] = ohlcv["close"].pct_change(periods=h).shift(-h)
|
||||||
|
|
||||||
|
return self.validate(factor_name, scores, forward)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
validation/metrics.py — Shared statistical metrics for factor and regime validation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy import stats
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def information_coefficient(factor: pd.Series, forward_returns: pd.Series) -> float:
|
||||||
|
"""Spearman rank IC between factor values and forward returns."""
|
||||||
|
mask = factor.notna() & forward_returns.notna()
|
||||||
|
if mask.sum() < 10:
|
||||||
|
return 0.0
|
||||||
|
ic, _ = stats.spearmanr(factor[mask], forward_returns[mask])
|
||||||
|
return float(ic) if not np.isnan(ic) else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def icir(ic_series: pd.Series) -> float:
|
||||||
|
"""Information Coefficient IR = mean(IC) / std(IC)."""
|
||||||
|
if len(ic_series) < 5 or ic_series.std() == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(ic_series.mean() / ic_series.std())
|
||||||
|
|
||||||
|
|
||||||
|
def hit_ratio(factor: pd.Series, forward_returns: pd.Series) -> float:
|
||||||
|
"""Fraction of times factor direction matches return direction."""
|
||||||
|
mask = factor.notna() & forward_returns.notna()
|
||||||
|
if mask.sum() < 10:
|
||||||
|
return 0.5
|
||||||
|
# Compare sign of factor deviation from median vs sign of returns
|
||||||
|
factor_median = factor[mask].median()
|
||||||
|
factor_sign = np.sign(factor[mask] - factor_median)
|
||||||
|
return_sign = np.sign(forward_returns[mask])
|
||||||
|
return float((factor_sign == return_sign).mean())
|
||||||
|
|
||||||
|
|
||||||
|
def quantile_spread(factor: pd.Series, forward_returns: pd.Series,
|
||||||
|
n_quantiles: int = 5) -> float:
|
||||||
|
"""Top vs bottom quantile return spread (分层回测)."""
|
||||||
|
mask = factor.notna() & forward_returns.notna()
|
||||||
|
if mask.sum() < n_quantiles * 3:
|
||||||
|
return 0.0
|
||||||
|
f = factor[mask]
|
||||||
|
r = forward_returns[mask]
|
||||||
|
labels = pd.qcut(f, n_quantiles, labels=False, duplicates="drop")
|
||||||
|
top_ret = r[labels == labels.max()].mean()
|
||||||
|
bot_ret = r[labels == labels.min()].mean()
|
||||||
|
return float(top_ret - bot_ret)
|
||||||
|
|
||||||
|
|
||||||
|
def lead_lag_ic(factor: pd.Series, returns: pd.Series,
|
||||||
|
max_lag: int = 14) -> dict:
|
||||||
|
"""Find the best leading/trailing relationship by computing IC at each lag."""
|
||||||
|
results = {}
|
||||||
|
for lag in range(-max_lag, max_lag + 1):
|
||||||
|
if lag < 0:
|
||||||
|
shifted = factor.shift(abs(lag))
|
||||||
|
ic = information_coefficient(shifted, returns)
|
||||||
|
results[f"lead_{abs(lag)}d"] = ic
|
||||||
|
elif lag > 0:
|
||||||
|
shifted = returns.shift(lag)
|
||||||
|
ic = information_coefficient(factor, shifted)
|
||||||
|
results[f"lag_{lag}d"] = ic
|
||||||
|
else:
|
||||||
|
ic = information_coefficient(factor, returns)
|
||||||
|
results["sync"] = ic
|
||||||
|
|
||||||
|
# Find best lead period
|
||||||
|
lead_ics = {k: v for k, v in results.items() if k.startswith("lead_")}
|
||||||
|
best_lead = max(lead_ics, key=lead_ics.get) if lead_ics else "sync"
|
||||||
|
best_ic = lead_ics.get(best_lead, results.get("sync", 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"best_lead": best_lead,
|
||||||
|
"best_ic": best_ic,
|
||||||
|
"ic_curve": results,
|
||||||
|
"is_leading": best_lead.startswith("lead_") and abs(best_ic) > 0.03,
|
||||||
|
"lead_days": int(best_lead.split("_")[1].rstrip("d")) if best_lead.startswith("lead_") else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mutual_information(factor: pd.Series, labels: pd.Series,
|
||||||
|
n_bins: int = 10) -> float:
|
||||||
|
"""Mutual information between factor (binned) and discrete regime labels."""
|
||||||
|
mask = factor.notna() & labels.notna()
|
||||||
|
if mask.sum() < 20:
|
||||||
|
return 0.0
|
||||||
|
f = factor[mask]
|
||||||
|
l = labels[mask]
|
||||||
|
try:
|
||||||
|
f_binned = pd.qcut(f, n_bins, labels=False, duplicates="drop")
|
||||||
|
except ValueError:
|
||||||
|
f_binned = pd.cut(f, n_bins, labels=False)
|
||||||
|
mi = 0.0
|
||||||
|
for fi in range(n_bins):
|
||||||
|
p_f = (f_binned == fi).mean()
|
||||||
|
if p_f == 0:
|
||||||
|
continue
|
||||||
|
for li in l.unique():
|
||||||
|
p_l = (l == li).mean()
|
||||||
|
p_joint = ((f_binned == fi) & (l == li)).mean()
|
||||||
|
if p_joint > 0:
|
||||||
|
mi += p_joint * np.log(p_joint / (p_f * p_l))
|
||||||
|
return float(mi)
|
||||||
|
|
||||||
|
|
||||||
|
def kl_divergence(factor: pd.Series, labels: pd.Series,
|
||||||
|
regime_a: str, regime_b: str, n_bins: int = 10) -> float:
|
||||||
|
"""KL divergence between factor distributions in two regimes."""
|
||||||
|
mask_a = (labels == regime_a) & factor.notna()
|
||||||
|
mask_b = (labels == regime_b) & factor.notna()
|
||||||
|
if mask_a.sum() < 10 or mask_b.sum() < 10:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
hist_a, edges = np.histogram(factor[mask_a], bins=n_bins, density=True)
|
||||||
|
hist_b, _ = np.histogram(factor[mask_b], bins=edges, density=True)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
hist_a = np.clip(hist_a, 1e-10, None)
|
||||||
|
hist_b = np.clip(hist_b, 1e-10, None)
|
||||||
|
return float((hist_a * np.log(hist_a / hist_b)).sum())
|
||||||
|
|
||||||
|
|
||||||
|
def anova_f_score(factor: pd.Series, labels: pd.Series) -> float:
|
||||||
|
"""ANOVA F-statistic: how well factor separates different regimes."""
|
||||||
|
mask = factor.notna() & labels.notna()
|
||||||
|
if mask.sum() < 20:
|
||||||
|
return 0.0
|
||||||
|
groups = [factor[mask][labels[mask] == lbl] for lbl in labels[mask].unique()]
|
||||||
|
groups = [g for g in groups if len(g) > 1]
|
||||||
|
if len(groups) < 2:
|
||||||
|
return 0.0
|
||||||
|
f_stat, _ = stats.f_oneway(*groups)
|
||||||
|
return float(f_stat) if not np.isnan(f_stat) else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def transition_matrix(labels: pd.Series) -> pd.DataFrame:
|
||||||
|
"""Compute Markov transition matrix from regime sequence."""
|
||||||
|
unique = sorted(labels.dropna().unique())
|
||||||
|
n = len(unique)
|
||||||
|
matrix = np.zeros((n, n))
|
||||||
|
seq = labels.dropna().values
|
||||||
|
for i in range(len(seq) - 1):
|
||||||
|
from_idx = unique.index(seq[i])
|
||||||
|
to_idx = unique.index(seq[i + 1])
|
||||||
|
matrix[from_idx][to_idx] += 1
|
||||||
|
|
||||||
|
# Row-normalize
|
||||||
|
row_sums = matrix.sum(axis=1, keepdims=True)
|
||||||
|
row_sums[row_sums == 0] = 1
|
||||||
|
matrix = matrix / row_sums
|
||||||
|
|
||||||
|
return pd.DataFrame(matrix, index=unique, columns=unique)
|
||||||
|
|
||||||
|
|
||||||
|
def regime_duration_stats(labels: pd.Series) -> dict:
|
||||||
|
"""Compute average duration, flip rate, state entropy for regime sequence."""
|
||||||
|
seq = labels.dropna().values
|
||||||
|
if len(seq) < 2:
|
||||||
|
return {"avg_duration": 0, "flip_rate": 0, "state_entropy": 0, "n_days": len(seq)}
|
||||||
|
|
||||||
|
# Count durations
|
||||||
|
durations = []
|
||||||
|
current = seq[0]
|
||||||
|
count = 1
|
||||||
|
flips = 0
|
||||||
|
for i in range(1, len(seq)):
|
||||||
|
if seq[i] == current:
|
||||||
|
count += 1
|
||||||
|
else:
|
||||||
|
durations.append(count)
|
||||||
|
current = seq[i]
|
||||||
|
count = 1
|
||||||
|
flips += 1
|
||||||
|
durations.append(count)
|
||||||
|
|
||||||
|
avg_dur = float(np.mean(durations)) if durations else 0
|
||||||
|
flip_rate = flips / len(seq)
|
||||||
|
|
||||||
|
# State entropy
|
||||||
|
_, counts = np.unique(seq, return_counts=True)
|
||||||
|
probs = counts / counts.sum()
|
||||||
|
entropy = float(-(probs * np.log2(probs + 1e-10)).sum())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"avg_duration": round(avg_dur, 1),
|
||||||
|
"flip_rate": round(flip_rate, 3),
|
||||||
|
"state_entropy": round(entropy, 3),
|
||||||
|
"n_days": len(seq),
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
validation/regime_validator.py — Validates factors as regime separators.
|
||||||
|
|
||||||
|
Tests: Mutual Information, KL Divergence, ANOVA F-score.
|
||||||
|
Answers: "Does this factor distinguish different market regimes?"
|
||||||
|
|
||||||
|
Key insight: a factor may have low IC (poor return predictor) but high
|
||||||
|
regime separation (good regime classifier). Breadth is the prime example.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from .metrics import (
|
||||||
|
mutual_information, kl_divergence, anova_f_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RegimeReport:
|
||||||
|
"""Structured report for regime separation validation."""
|
||||||
|
|
||||||
|
def __init__(self, factor_name: str):
|
||||||
|
self.factor_name = factor_name
|
||||||
|
self.mutual_info: float = 0.0
|
||||||
|
self.anova_f: float = 0.0
|
||||||
|
self.kl_pairs: dict = {} # (regime_a, regime_b) → KL divergence
|
||||||
|
self.best_separates: list[str] = []
|
||||||
|
self.separation_score: float = 0.0
|
||||||
|
self.is_regime_factor: bool = False
|
||||||
|
self.conclusion: str = ""
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
lines = [
|
||||||
|
f"Factor: {self.factor_name}",
|
||||||
|
f" Mutual Information: {self.mutual_info:.4f}",
|
||||||
|
f" ANOVA F: {self.anova_f:.1f}",
|
||||||
|
f" Best separates: {', '.join(self.best_separates) if self.best_separates else 'none'}",
|
||||||
|
f" Regime Factor: {'YES' if self.is_regime_factor else 'No'}",
|
||||||
|
f" → {self.conclusion}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class RegimeValidator:
|
||||||
|
"""
|
||||||
|
Validates a factor's ability to separate different market regimes.
|
||||||
|
|
||||||
|
A good regime factor has:
|
||||||
|
- Mutual Information > 0.1
|
||||||
|
- KL Divergence between regimes > 0.5
|
||||||
|
- ANOVA F-score high
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
|
self.db_path = db_path or config.db_path
|
||||||
|
|
||||||
|
def validate(self, factor_name: str, factor_scores: pd.Series,
|
||||||
|
regime_labels: pd.Series) -> RegimeReport:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
factor_name: Human-readable name
|
||||||
|
factor_scores: Series indexed by date, values 0-100
|
||||||
|
regime_labels: Series indexed by date, values = 'TREND'/'RANGE'/'PANIC'
|
||||||
|
"""
|
||||||
|
report = RegimeReport(factor_name)
|
||||||
|
|
||||||
|
# Align
|
||||||
|
common_idx = factor_scores.index.intersection(regime_labels.index)
|
||||||
|
if len(common_idx) < 30:
|
||||||
|
report.conclusion = "INSUFFICIENT DATA"
|
||||||
|
return report
|
||||||
|
|
||||||
|
f = factor_scores[common_idx]
|
||||||
|
labels = regime_labels[common_idx]
|
||||||
|
|
||||||
|
# Mutual Information
|
||||||
|
report.mutual_info = round(mutual_information(f, labels), 4)
|
||||||
|
|
||||||
|
# ANOVA
|
||||||
|
report.anova_f = round(anova_f_score(f, labels), 1)
|
||||||
|
|
||||||
|
# KL Divergence between each pair of regimes
|
||||||
|
unique_regimes = sorted(labels.unique())
|
||||||
|
for i, ra in enumerate(unique_regimes):
|
||||||
|
for rb in unique_regimes[i + 1:]:
|
||||||
|
kl = kl_divergence(f, labels, ra, rb)
|
||||||
|
report.kl_pairs[f"{ra}↔{rb}"] = round(kl, 4)
|
||||||
|
|
||||||
|
# Best separation
|
||||||
|
if report.kl_pairs:
|
||||||
|
sorted_pairs = sorted(report.kl_pairs, key=report.kl_pairs.get, reverse=True)
|
||||||
|
report.best_separates = sorted_pairs[:2]
|
||||||
|
|
||||||
|
# Separation score (0-1 composite)
|
||||||
|
mi_norm = min(report.mutual_info / 0.5, 1.0)
|
||||||
|
kl_avg = np.mean(list(report.kl_pairs.values())) if report.kl_pairs else 0
|
||||||
|
kl_norm = min(kl_avg / 1.0, 1.0)
|
||||||
|
report.separation_score = round(0.5 * mi_norm + 0.5 * kl_norm, 2)
|
||||||
|
|
||||||
|
# Is this a good regime factor?
|
||||||
|
report.is_regime_factor = (
|
||||||
|
report.mutual_info > 0.1 and
|
||||||
|
kl_avg > 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
if report.separation_score > 0.8:
|
||||||
|
report.conclusion = "EXCELLENT regime separator"
|
||||||
|
elif report.separation_score > 0.5:
|
||||||
|
report.conclusion = "GOOD regime separator"
|
||||||
|
elif report.separation_score > 0.3:
|
||||||
|
report.conclusion = "MODERATE — some regime separation"
|
||||||
|
else:
|
||||||
|
report.conclusion = "WEAK regime separator"
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def validate_from_db(self, factor_name: str,
|
||||||
|
score_query: str) -> RegimeReport:
|
||||||
|
"""Load scores and regime labels from DB, then validate."""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
|
||||||
|
scores_df = pd.read_sql_query(score_query, conn)
|
||||||
|
regimes_df = pd.read_sql_query(
|
||||||
|
"SELECT date, regime FROM regime_history", conn
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if scores_df.empty or regimes_df.empty:
|
||||||
|
r = RegimeReport(factor_name)
|
||||||
|
r.conclusion = "NO DATA"
|
||||||
|
return r
|
||||||
|
|
||||||
|
scores = scores_df.set_index("date")["score"]
|
||||||
|
regimes = regimes_df.set_index("date")["regime"]
|
||||||
|
|
||||||
|
return self.validate(factor_name, scores, regimes)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
validation/reporter.py — Aggregates all validation reports into a unified summary.
|
||||||
|
|
||||||
|
Used by: python main.py validate
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date as Date
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .factor_validator import FactorValidator, FactorReport
|
||||||
|
from .regime_validator import RegimeValidator, RegimeReport
|
||||||
|
from .transition_validator import TransitionValidator, TransitionReport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationReporter:
|
||||||
|
"""
|
||||||
|
Orchestrates full validation pipeline:
|
||||||
|
|
||||||
|
1. Factor validation (IC, ICIR, Hit Ratio) for each factor
|
||||||
|
2. Regime validation (MI, KL, ANOVA) for each factor
|
||||||
|
3. Transition validation (stability, flip rate)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
|
from config import config
|
||||||
|
self.db_path = db_path or config.db_path
|
||||||
|
self.factor_validator = FactorValidator(self.db_path)
|
||||||
|
self.regime_validator = RegimeValidator(self.db_path)
|
||||||
|
self.transition_validator = TransitionValidator(self.db_path)
|
||||||
|
|
||||||
|
def run_all(self) -> str:
|
||||||
|
"""Run all validations and return a formatted report string."""
|
||||||
|
lines = []
|
||||||
|
lines.append("=" * 70)
|
||||||
|
lines.append(f" ChanMacro Validation Report — {Date.today()}")
|
||||||
|
lines.append("=" * 70)
|
||||||
|
|
||||||
|
# ── Factor Validation ──────────────────────────
|
||||||
|
lines.append("")
|
||||||
|
lines.append("─" * 50)
|
||||||
|
lines.append(" FACTOR VALIDATION (Predictive Power)")
|
||||||
|
lines.append("─" * 50)
|
||||||
|
|
||||||
|
factor_queries = {
|
||||||
|
"Price Structure": "SELECT date, score FROM ohlcv_daily WHERE ema20 IS NOT NULL",
|
||||||
|
"Breadth": """
|
||||||
|
SELECT bd.date,
|
||||||
|
(bd.advance_top50*1.0/(bd.advance_top50+bd.decline_top50+1)*100*0.30
|
||||||
|
+ bd.above_ema20_top50*1.0/50*100*0.35
|
||||||
|
+ bd.new_highs_20d_top50*1.0/50*100*0.20
|
||||||
|
+ 50*0.15) as score
|
||||||
|
FROM breadth_daily bd
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
factor_reports: list[FactorReport] = []
|
||||||
|
for name, query in factor_queries.items():
|
||||||
|
try:
|
||||||
|
report = self.factor_validator.validate_from_db(name, query)
|
||||||
|
factor_reports.append(report)
|
||||||
|
lines.append(report.summary())
|
||||||
|
lines.append("")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Factor validation failed for {name}: {e}")
|
||||||
|
|
||||||
|
# ── Regime Validation ──────────────────────────
|
||||||
|
lines.append("─" * 50)
|
||||||
|
lines.append(" REGIME VALIDATION (Regime Separation)")
|
||||||
|
lines.append("─" * 50)
|
||||||
|
|
||||||
|
regime_reports: list[RegimeReport] = []
|
||||||
|
for name, query in factor_queries.items():
|
||||||
|
try:
|
||||||
|
report = self.regime_validator.validate_from_db(name, query)
|
||||||
|
regime_reports.append(report)
|
||||||
|
lines.append(report.summary())
|
||||||
|
lines.append("")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Regime validation failed for {name}: {e}")
|
||||||
|
|
||||||
|
# ── Transition Validation ──────────────────────
|
||||||
|
lines.append("─" * 50)
|
||||||
|
lines.append(" TRANSITION VALIDATION (Regime Stability)")
|
||||||
|
lines.append("─" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
t_report = self.transition_validator.validate_from_db()
|
||||||
|
lines.append(t_report.summary())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Transition validation failed: {e}")
|
||||||
|
|
||||||
|
# ── Summary ────────────────────────────────────
|
||||||
|
lines.append("")
|
||||||
|
lines.append("=" * 70)
|
||||||
|
lines.append(" SUMMARY")
|
||||||
|
lines.append("=" * 70)
|
||||||
|
|
||||||
|
# Factor ranking by IC
|
||||||
|
if factor_reports:
|
||||||
|
ranked = sorted(factor_reports, key=lambda r: abs(r.ic_mean), reverse=True)
|
||||||
|
lines.append(" Factor Ranking (by |IC|):")
|
||||||
|
for i, r in enumerate(ranked):
|
||||||
|
tag = "★★★" if abs(r.ic_mean) > 0.05 else "★★" if abs(r.ic_mean) > 0.03 else "★"
|
||||||
|
lines.append(f" {i+1}. {r.factor_name:20s} IC={r.ic_mean:+.4f} {tag} {r.conclusion}")
|
||||||
|
|
||||||
|
# Regime factor ranking
|
||||||
|
if regime_reports:
|
||||||
|
ranked_r = sorted(regime_reports, key=lambda r: r.separation_score, reverse=True)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" Regime Factor Ranking (by Separation Score):")
|
||||||
|
for i, r in enumerate(ranked_r):
|
||||||
|
lines.append(f" {i+1}. {r.factor_name:20s} Score={r.separation_score:.2f} {r.conclusion}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("=" * 70)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
validation/transition_validator.py — Validates regime stability.
|
||||||
|
|
||||||
|
Tests: Transition matrix, average duration, flip rate, state entropy.
|
||||||
|
Answers: "Does the regime design produce stable, persistent states?"
|
||||||
|
|
||||||
|
Hard requirements:
|
||||||
|
- avg_duration > 5 days
|
||||||
|
- flip_rate < 15%
|
||||||
|
- Fails → regime definition needs redesign.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from .metrics import transition_matrix, regime_duration_stats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TransitionReport:
|
||||||
|
"""Structured report for regime stability validation."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.avg_duration: float = 0.0
|
||||||
|
self.flip_rate: float = 0.0
|
||||||
|
self.state_entropy: float = 0.0
|
||||||
|
self.n_days: int = 0
|
||||||
|
self.transition_matrix: Optional[pd.DataFrame] = None
|
||||||
|
self.persistence_score: float = 0.0
|
||||||
|
self.is_stable: bool = False
|
||||||
|
self.conclusion: str = ""
|
||||||
|
self.warnings: list[str] = []
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
lines = [
|
||||||
|
f"Regime Stability (N={self.n_days} days)",
|
||||||
|
f" Avg Duration: {self.avg_duration:.1f} days (need > {config.regime_min_avg_duration})",
|
||||||
|
f" Flip Rate: {self.flip_rate:.1%} (need < {config.regime_max_flip_rate:.0%})",
|
||||||
|
f" State Entropy: {self.state_entropy:.3f}",
|
||||||
|
f" Persistence Score: {self.persistence_score:.2f}",
|
||||||
|
f" Stable: {'YES' if self.is_stable else 'NO — redesign needed'}",
|
||||||
|
]
|
||||||
|
if self.warnings:
|
||||||
|
lines.append(f" Warnings: {'; '.join(self.warnings)}")
|
||||||
|
if self.transition_matrix is not None:
|
||||||
|
lines.append(f" Transition Matrix:\n{self.transition_matrix.to_string()}")
|
||||||
|
lines.append(f" → {self.conclusion}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
class TransitionValidator:
|
||||||
|
"""
|
||||||
|
Validates regime temporal stability.
|
||||||
|
|
||||||
|
Regime must persist — not flip daily.
|
||||||
|
If flip_rate > 20% or avg_duration < 3 days → regime definition failed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None):
|
||||||
|
self.db_path = db_path or config.db_path
|
||||||
|
|
||||||
|
def validate(self, regime_labels: pd.Series) -> TransitionReport:
|
||||||
|
"""Validate a regime sequence for stability."""
|
||||||
|
report = TransitionReport()
|
||||||
|
report.n_days = len(regime_labels)
|
||||||
|
|
||||||
|
if len(regime_labels) < 30:
|
||||||
|
report.conclusion = "INSUFFICIENT DATA (< 30 days)"
|
||||||
|
return report
|
||||||
|
|
||||||
|
# Duration stats
|
||||||
|
stats = regime_duration_stats(regime_labels)
|
||||||
|
report.avg_duration = stats["avg_duration"]
|
||||||
|
report.flip_rate = stats["flip_rate"]
|
||||||
|
report.state_entropy = stats["state_entropy"]
|
||||||
|
|
||||||
|
# Transition matrix
|
||||||
|
report.transition_matrix = transition_matrix(regime_labels)
|
||||||
|
|
||||||
|
# Persistence: how often does regime stay the same?
|
||||||
|
diag = np.diag(report.transition_matrix.values)
|
||||||
|
report.persistence_score = round(float(np.mean(diag)), 2)
|
||||||
|
|
||||||
|
# Stability check
|
||||||
|
report.is_stable = (
|
||||||
|
report.avg_duration >= config.regime_min_avg_duration and
|
||||||
|
report.flip_rate <= config.regime_max_flip_rate
|
||||||
|
)
|
||||||
|
|
||||||
|
# Warnings
|
||||||
|
if report.avg_duration < 3:
|
||||||
|
report.warnings.append(f"CRITICAL: avg duration={report.avg_duration:.1f}d — regime flips too fast")
|
||||||
|
elif report.avg_duration < config.regime_min_avg_duration:
|
||||||
|
report.warnings.append(f"WARNING: avg duration={report.avg_duration:.1f}d < {config.regime_min_avg_duration}")
|
||||||
|
|
||||||
|
if report.flip_rate > 0.20:
|
||||||
|
report.warnings.append(f"CRITICAL: flip rate={report.flip_rate:.1%} — regime unstable")
|
||||||
|
elif report.flip_rate > config.regime_max_flip_rate:
|
||||||
|
report.warnings.append(f"WARNING: flip rate={report.flip_rate:.1%} > {config.regime_max_flip_rate:.0%}")
|
||||||
|
|
||||||
|
if report.state_entropy > 2.0:
|
||||||
|
report.warnings.append(f"NOTE: high state entropy={report.state_entropy:.2f}, regimes may be too fine-grained")
|
||||||
|
|
||||||
|
if report.is_stable:
|
||||||
|
report.conclusion = "PASS: regime design is stable"
|
||||||
|
else:
|
||||||
|
report.conclusion = "FAIL: regime definition needs adjustment"
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def validate_from_db(self) -> TransitionReport:
|
||||||
|
"""Load regime history from DB and validate stability."""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"SELECT date, regime FROM regime_history ORDER BY date", conn
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
r = TransitionReport()
|
||||||
|
r.conclusion = "NO DATA"
|
||||||
|
return r
|
||||||
|
|
||||||
|
regimes = df.set_index("date")["regime"]
|
||||||
|
return self.validate(regimes)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
web/app.py — ChanMacro dashboard (Flask, port 8124).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date as Date
|
||||||
|
from flask import Flask, render_template, jsonify, request
|
||||||
|
|
||||||
|
from database import get_connection
|
||||||
|
from config import config
|
||||||
|
from scoring.price_structure import PriceStructureScorer
|
||||||
|
from scoring.breadth_scorer import BreadthScorer
|
||||||
|
from scoring.oi_matrix import OIMatrixScorer
|
||||||
|
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||||
|
from regime_detector import RegimeDetector
|
||||||
|
from models import MarketStateVector
|
||||||
|
from expectancy.engine import BayesianExpectancyEngine
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_state(target: Date):
|
||||||
|
"""Build MarketStateVector and persist regime to DB."""
|
||||||
|
ps = PriceStructureScorer().compute(target)
|
||||||
|
br = BreadthScorer().compute(target)
|
||||||
|
oi = OIMatrixScorer().compute(target)
|
||||||
|
vol = VolatilityRegimeScorer().compute(target)
|
||||||
|
|
||||||
|
detector = RegimeDetector()
|
||||||
|
detector.load_state(config.db_path)
|
||||||
|
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
|
||||||
|
|
||||||
|
state = MarketStateVector(
|
||||||
|
date=target, regime=r.regime, regime_confidence=r.confidence,
|
||||||
|
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
|
||||||
|
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
|
||||||
|
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
|
||||||
|
breadth_divergence=br.breadth_divergence,
|
||||||
|
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
|
||||||
|
price_structure_score=ps, breadth_score=br,
|
||||||
|
oi_matrix_score=oi, volatility_regime_score=vol,
|
||||||
|
)
|
||||||
|
state.market_state_hash = state.compute_hash()
|
||||||
|
|
||||||
|
# Persist regime to DB so load_state() works across requests
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT OR REPLACE INTO regime_history
|
||||||
|
(date, regime, confidence, regime_version, maturity_score, all_scores_json,
|
||||||
|
prior_regime, confirmation_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
str(target), r.regime.value, r.confidence, r.regime_version,
|
||||||
|
r.maturity_score, json.dumps(r.all_scores),
|
||||||
|
r.prior_regime.value if r.prior_regime else None,
|
||||||
|
r.confirmation_days,
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def dashboard():
|
||||||
|
return render_template("index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/state")
|
||||||
|
def api_state():
|
||||||
|
"""Current market state with all factor scores."""
|
||||||
|
try:
|
||||||
|
target = Date.today()
|
||||||
|
state = _build_state(target)
|
||||||
|
return jsonify({
|
||||||
|
"date": str(state.date),
|
||||||
|
"regime": state.regime.value,
|
||||||
|
"regime_confidence": state.regime_confidence,
|
||||||
|
"regime_maturity": state.regime_maturity_score,
|
||||||
|
"breadth": {
|
||||||
|
"score": state.breadth_score.score,
|
||||||
|
"bucket": state.breadth_bucket.value,
|
||||||
|
"top20": state.breadth_top20,
|
||||||
|
"top30": state.breadth_top30,
|
||||||
|
"top50": state.breadth_top50,
|
||||||
|
"divergence": state.breadth_divergence,
|
||||||
|
"narrative": state.breadth_score.narrative,
|
||||||
|
},
|
||||||
|
"oi_state": state.oi_state.value,
|
||||||
|
"oi_score": state.oi_matrix_score.score,
|
||||||
|
"oi_narrative": state.oi_matrix_score.narrative,
|
||||||
|
"volatility": state.volatility_regime.value,
|
||||||
|
"price_structure": {
|
||||||
|
"score": state.price_structure_score.score,
|
||||||
|
"trend": state.price_structure_score.trend_strength,
|
||||||
|
"vol_comp": state.price_structure_score.volatility_compression,
|
||||||
|
"momentum": state.price_structure_score.momentum,
|
||||||
|
"label": state.price_structure_score.label,
|
||||||
|
"narrative": state.price_structure_score.narrative,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/history")
|
||||||
|
def api_history():
|
||||||
|
"""Regime and factor score history."""
|
||||||
|
days = request.args.get("days", 60, type=int)
|
||||||
|
conn = get_connection()
|
||||||
|
|
||||||
|
# Regime history
|
||||||
|
regimes = conn.execute(
|
||||||
|
"SELECT date, regime, confidence, maturity_score FROM regime_history ORDER BY date DESC LIMIT ?",
|
||||||
|
(days,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Breadth history
|
||||||
|
breadth = conn.execute(
|
||||||
|
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily ORDER BY date DESC LIMIT ?",
|
||||||
|
(days,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"regimes": [{"date": r["date"], "regime": r["regime"],
|
||||||
|
"confidence": r["confidence"], "maturity": r["maturity_score"]}
|
||||||
|
for r in reversed(regimes)],
|
||||||
|
"breadth": [{"date": b["date"], "advance": b["advance_top50"],
|
||||||
|
"decline": b["decline_top50"], "above_ema20": b["above_ema20_top50"]}
|
||||||
|
for b in reversed(breadth)],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/expectancy")
|
||||||
|
def api_expectancy():
|
||||||
|
"""Query signal expectancy."""
|
||||||
|
signal = request.args.get("signal", "B3")
|
||||||
|
try:
|
||||||
|
target = Date.today()
|
||||||
|
state = _build_state(target)
|
||||||
|
engine = BayesianExpectancyEngine(level_min_samples=5)
|
||||||
|
report = engine.estimate(state, signal_type=signal, target_date=target)
|
||||||
|
|
||||||
|
layers = []
|
||||||
|
for l in report.layers:
|
||||||
|
layers.append({
|
||||||
|
"name": l.name,
|
||||||
|
"samples": l.samples,
|
||||||
|
"effective_samples": l.effective_samples,
|
||||||
|
"raw_winrate": l.raw_winrate,
|
||||||
|
"posterior_winrate": l.posterior_winrate,
|
||||||
|
"avg_return": l.avg_return,
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"signal": signal,
|
||||||
|
"final_estimate": report.final_estimate,
|
||||||
|
"sufficiency": report.sufficiency.value,
|
||||||
|
"source": report.source,
|
||||||
|
"avg_return_7d": report.avg_return_7d,
|
||||||
|
"profit_factor": report.profit_factor,
|
||||||
|
"max_adverse": report.max_adverse_excursion,
|
||||||
|
"layers": layers,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from scheduler import get_scheduler
|
||||||
|
get_scheduler().start()
|
||||||
|
app.run(host="0.0.0.0", port=8124, debug=True)
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// dashboard.js — ChanMacro
|
||||||
|
|
||||||
|
const C = { TREND: "#3fb950", RANGE: "#d29922", PANIC: "#f85149" };
|
||||||
|
let regimeChart = null, breadthChart = null;
|
||||||
|
|
||||||
|
async function loadState() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/state");
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { document.getElementById("update-time").textContent = d.error; return; }
|
||||||
|
|
||||||
|
document.getElementById("update-time").textContent = d.date;
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
const regime = d.regime;
|
||||||
|
const names = { TREND: "TREND", RANGE: "RANGE", PANIC: "PANIC" };
|
||||||
|
document.getElementById("hero-regime").textContent = names[regime] || regime;
|
||||||
|
document.getElementById("hero-regime").className = "regime-name " + regime.toLowerCase();
|
||||||
|
document.getElementById("hero-badge").textContent = regime;
|
||||||
|
document.getElementById("hero-badge").className = "regime-badge " + regime.toLowerCase();
|
||||||
|
document.getElementById("hero-conf").textContent = (d.regime_confidence * 100).toFixed(0) + "%";
|
||||||
|
document.getElementById("hero-maturity").textContent = d.regime_maturity.toFixed(0);
|
||||||
|
document.getElementById("hero-ps").textContent = d.price_structure.score.toFixed(0);
|
||||||
|
document.getElementById("hero-ps").style.color =
|
||||||
|
d.price_structure.score >= 60 ? "#3fb950" : d.price_structure.score >= 40 ? "#d29922" : "#f85149";
|
||||||
|
document.getElementById("hero-br").textContent = d.breadth.score.toFixed(0);
|
||||||
|
document.getElementById("hero-br").style.color =
|
||||||
|
d.breadth.bucket === "EXTREME" || d.breadth.bucket === "STRONG" ? "#3fb950" :
|
||||||
|
d.breadth.bucket === "WEAK" || d.breadth.bucket === "PANIC" ? "#f85149" : "#d29922";
|
||||||
|
|
||||||
|
// Factor cards
|
||||||
|
const ps = d.price_structure;
|
||||||
|
document.getElementById("f-price").textContent = ps.score.toFixed(0);
|
||||||
|
document.getElementById("f-price").style.color =
|
||||||
|
ps.score >= 60 ? "#3fb950" : ps.score >= 40 ? "#d29922" : "#f85149";
|
||||||
|
document.getElementById("f-price-sub").textContent =
|
||||||
|
`趋势 ${ps.trend.toFixed(0)} · 波动 ${ps.vol_comp.toFixed(0)} · 动量 ${ps.momentum.toFixed(0)}`;
|
||||||
|
document.getElementById("bar-price").style.width = ps.score + "%";
|
||||||
|
document.getElementById("bar-price").style.background =
|
||||||
|
ps.score >= 60 ? "#3fb950" : ps.score >= 40 ? "#d29922" : "#f85149";
|
||||||
|
|
||||||
|
const br = d.breadth;
|
||||||
|
document.getElementById("f-breadth").textContent = br.score.toFixed(0);
|
||||||
|
document.getElementById("f-breadth").style.color =
|
||||||
|
br.bucket === "EXTREME" || br.bucket === "STRONG" ? "#3fb950" :
|
||||||
|
br.bucket === "WEAK" || br.bucket === "PANIC" ? "#f85149" : "#d29922";
|
||||||
|
document.getElementById("f-breadth-sub").textContent =
|
||||||
|
`${br.bucket} · T20=${br.top20.toFixed(0)} T50=${br.top50.toFixed(0)}`;
|
||||||
|
document.getElementById("bar-breadth").style.width = br.score + "%";
|
||||||
|
document.getElementById("bar-breadth").style.background =
|
||||||
|
br.bucket === "EXTREME" || br.bucket === "STRONG" ? "#3fb950" :
|
||||||
|
br.bucket === "WEAK" || br.bucket === "PANIC" ? "#f85149" : "#d29922";
|
||||||
|
|
||||||
|
document.getElementById("f-oi").textContent = d.oi_state.toUpperCase().replace(" ", "\n");
|
||||||
|
document.getElementById("f-oi").style.color =
|
||||||
|
d.oi_state === "New Longs" ? "#3fb950" : d.oi_state.includes("Short") || d.oi_state === "Long Exit" ? "#f85149" : "#8b949e";
|
||||||
|
document.getElementById("f-oi-sub").textContent = d.oi_narrative;
|
||||||
|
|
||||||
|
const vm = { LOW_VOL: "低波动", NORMAL_VOL: "正常", HIGH_VOL: "高波动", EXPLOSIVE_VOL: "极端" };
|
||||||
|
document.getElementById("f-vol").textContent = vm[d.volatility] || d.volatility;
|
||||||
|
document.getElementById("f-vol").style.color =
|
||||||
|
d.volatility === "LOW_VOL" ? "#58a6ff" : d.volatility === "NORMAL_VOL" ? "#8b949e" :
|
||||||
|
d.volatility === "HIGH_VOL" ? "#d29922" : "#f85149";
|
||||||
|
document.getElementById("f-vol-sub").textContent = d.volatility;
|
||||||
|
document.getElementById("bar-vol").style.width =
|
||||||
|
(d.volatility === "EXPLOSIVE_VOL" ? 95 : d.volatility === "HIGH_VOL" ? 70 :
|
||||||
|
d.volatility === "NORMAL_VOL" ? 40 : 20) + "%";
|
||||||
|
document.getElementById("bar-vol").style.background =
|
||||||
|
d.volatility === "EXPLOSIVE_VOL" ? "#f85149" : d.volatility === "HIGH_VOL" ? "#d29922" :
|
||||||
|
d.volatility === "NORMAL_VOL" ? "#8b949e" : "#58a6ff";
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("update-time").textContent = "连接失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/history?days=60");
|
||||||
|
const d = await r.json();
|
||||||
|
|
||||||
|
const dates = d.regimes.map(x => x.date);
|
||||||
|
const colors = d.regimes.map(x => C[x.regime] || "#5c6675");
|
||||||
|
|
||||||
|
if (regimeChart) regimeChart.destroy();
|
||||||
|
regimeChart = new Chart(document.getElementById("chart-regime").getContext("2d"), {
|
||||||
|
type: "bar",
|
||||||
|
data: { labels: dates, datasets: [{ data: d.regimes.map(x => x.confidence * 100),
|
||||||
|
backgroundColor: colors, borderWidth: 0, borderRadius: 2 }] },
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: "#5c6675", maxTicksLimit: 15, maxRotation: 45, font: { size: 10 } },
|
||||||
|
grid: { color: "#151a23" } },
|
||||||
|
y: { max: 100, ticks: { color: "#5c6675", font: { size: 10 } }, grid: { color: "#151a23" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (breadthChart) breadthChart.destroy();
|
||||||
|
breadthChart = new Chart(document.getElementById("chart-breadth").getContext("2d"), {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: d.breadth.map(x => x.date),
|
||||||
|
datasets: [
|
||||||
|
{ label: "上涨", data: d.breadth.map(x => x.advance), borderColor: "#3fb950",
|
||||||
|
backgroundColor: "rgba(63,185,80,0.08)", fill: true, tension: 0.3, pointRadius: 0 },
|
||||||
|
{ label: "下跌", data: d.breadth.map(x => x.decline), borderColor: "#f85149",
|
||||||
|
backgroundColor: "rgba(248,81,73,0.06)", fill: true, tension: 0.3, pointRadius: 0 },
|
||||||
|
{ label: ">EMA20", data: d.breadth.map(x => x.above_ema20), borderColor: "#58a6ff",
|
||||||
|
borderDash: [3, 3], tension: 0.3, pointRadius: 0 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { labels: { color: "#5c6675", usePointStyle: true, boxWidth: 6, font: { size: 10 } } } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: "#5c6675", maxTicksLimit: 15, maxRotation: 45, font: { size: 10 } },
|
||||||
|
grid: { color: "#151a23" } },
|
||||||
|
y: { ticks: { color: "#5c6675", font: { size: 10 } }, grid: { color: "#151a23" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExpectancy() {
|
||||||
|
const signal = document.getElementById("exp-signal").value;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/expectancy?signal=${signal}`);
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { document.getElementById("exp-layers").innerHTML =
|
||||||
|
`<tr><td colspan="6" style="color:#f85149">${d.error}</td></tr>`; return; }
|
||||||
|
|
||||||
|
const el = document.getElementById("exp-sufficiency");
|
||||||
|
el.textContent = d.sufficiency;
|
||||||
|
el.className = "suff suff-" + d.sufficiency;
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
for (const l of d.layers) {
|
||||||
|
html += `<tr>
|
||||||
|
<td>${l.name}</td><td>${l.samples}</td><td>${l.effective_samples.toFixed(0)}</td>
|
||||||
|
<td>${l.raw_winrate ? (l.raw_winrate * 100).toFixed(1) + "%" : "—"}</td>
|
||||||
|
<td><strong>${(l.posterior_winrate * 100).toFixed(1)}%</strong></td>
|
||||||
|
<td style="color:${l.avg_return > 0 ? '#3fb950' : l.avg_return < 0 ? '#f85149' : '#8b949e'}">${l.avg_return ? (l.avg_return > 0 ? "+" : "") + l.avg_return.toFixed(2) + "%" : "—"}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
document.getElementById("exp-layers").innerHTML = html;
|
||||||
|
|
||||||
|
let s = `后验胜率 <strong style="color:#58a6ff">${(d.final_estimate * 100).toFixed(1)}%</strong>`;
|
||||||
|
if (d.avg_return_7d) s += ` · 平均收益 <strong>${d.avg_return_7d > 0 ? "+" : ""}${d.avg_return_7d.toFixed(2)}%</strong>`;
|
||||||
|
if (d.profit_factor) s += ` · 盈亏比 <strong>${d.profit_factor}</strong>`;
|
||||||
|
if (d.max_adverse) s += ` · MAE <strong>${d.max_adverse.toFixed(1)}%</strong>`;
|
||||||
|
document.getElementById("exp-summary").innerHTML = s;
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadState();
|
||||||
|
loadHistory();
|
||||||
|
loadExpectancy();
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ChanMacro — 市场状态</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #0a0e14; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, "SF Mono", monospace; }
|
||||||
|
.app { max-width: 1200px; margin: 0 auto; padding: 20px 24px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: flex-end; padding: 20px 0 28px;
|
||||||
|
border-bottom: 1px solid #1c2333; margin-bottom: 24px; }
|
||||||
|
.header h1 { font-size: 22px; font-weight: 600; letter-spacing: 1px; }
|
||||||
|
.header h1 span { color: #58a6ff; }
|
||||||
|
.header .time { color: #5c6675; font-size: 13px; }
|
||||||
|
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #3fb950;
|
||||||
|
margin-right: 6px; animation: pulse 2s infinite; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||||
|
|
||||||
|
/* Regime Hero */
|
||||||
|
.hero { display: flex; gap: 16px; margin-bottom: 24px; }
|
||||||
|
.hero-card { flex: 1; background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 20px 24px; }
|
||||||
|
.hero-card.main { flex: 2; display: flex; align-items: center; gap: 28px; }
|
||||||
|
.regime-badge { display: inline-block; padding: 5px 16px; border-radius: 4px; font-size: 13px;
|
||||||
|
font-weight: 600; letter-spacing: 2px; }
|
||||||
|
.regime-badge.trend { background: rgba(63,185,80,0.12); color: #3fb950; border: 1px solid rgba(63,185,80,0.3); }
|
||||||
|
.regime-badge.range { background: rgba(210,153,34,0.12); color: #d29922; border: 1px solid rgba(210,153,34,0.3); }
|
||||||
|
.regime-badge.panic { background: rgba(248,81,73,0.12); color: #f85149; border: 1px solid rgba(248,81,73,0.3); }
|
||||||
|
.regime-name { font-size: 42px; font-weight: 700; letter-spacing: 2px; }
|
||||||
|
.regime-name.trend { color: #3fb950; }
|
||||||
|
.regime-name.range { color: #d29922; }
|
||||||
|
.regime-name.panic { color: #f85149; }
|
||||||
|
.hero-stat { text-align: center; }
|
||||||
|
.hero-stat .val { font-size: 28px; font-weight: 600; color: #e6edf3; }
|
||||||
|
.hero-stat .lbl { font-size: 11px; color: #5c6675; letter-spacing: 1px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Factor Grid */
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
|
||||||
|
.fcard { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
|
||||||
|
.fcard .title { font-size: 11px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 10px; }
|
||||||
|
.fcard .score { font-size: 38px; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.fcard .sub { font-size: 12px; color: #5c6675; }
|
||||||
|
.fcard .bar-wrap { height: 3px; background: #1c2333; border-radius: 2px; margin-top: 12px; }
|
||||||
|
.fcard .bar { height: 100%; border-radius: 2px; transition: width 0.6s; }
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px; }
|
||||||
|
.chart-box { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
|
||||||
|
.chart-box h3 { font-size: 12px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 14px; }
|
||||||
|
.chart-box canvas { max-height: 260px; }
|
||||||
|
|
||||||
|
/* Expectancy */
|
||||||
|
.exp { background: #11161e; border: 1px solid #1c2333; border-radius: 8px; padding: 18px 20px; }
|
||||||
|
.exp h3 { font-size: 12px; color: #5c6675; letter-spacing: 1.5px; margin-bottom: 14px; }
|
||||||
|
.exp-row { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
|
||||||
|
.exp select { background: #0a0e14; color: #c9d1d9; border: 1px solid #1c2333; padding: 6px 12px;
|
||||||
|
border-radius: 4px; font-size: 13px; }
|
||||||
|
.exp button { background: #1c3a5c; color: #58a6ff; border: 1px solid #2d4f7c; padding: 6px 18px;
|
||||||
|
border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||||
|
.exp button:hover { background: #254d7a; }
|
||||||
|
.exp .suff { font-size: 11px; padding: 3px 10px; border-radius: 3px; }
|
||||||
|
.suff-HIGH { background: rgba(63,185,80,0.12); color: #3fb950; }
|
||||||
|
.suff-MEDIUM { background: rgba(210,153,34,0.12); color: #d29922; }
|
||||||
|
.suff-LOW { background: rgba(248,81,73,0.12); color: #f85149; }
|
||||||
|
.suff-INSUFFICIENT { background: rgba(92,102,117,0.12); color: #5c6675; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th { text-align: left; color: #5c6675; font-weight: 500; padding: 8px 10px; border-bottom: 1px solid #1c2333; }
|
||||||
|
td { padding: 7px 10px; border-bottom: 1px solid #0e1219; color: #8b949e; }
|
||||||
|
td strong { color: #e6edf3; }
|
||||||
|
.exp-summary { margin-top: 14px; font-size: 13px; color: #8b949e; padding: 10px 14px;
|
||||||
|
background: #0d1117; border-radius: 6px; border-left: 3px solid #58a6ff; }
|
||||||
|
.exp-summary strong { color: #e6edf3; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1><span>Chan</span>Macro</h1>
|
||||||
|
</div>
|
||||||
|
<div class="time"><span class="dot"></span> <span id="update-time">加载中...</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Regime Hero -->
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero-card main">
|
||||||
|
<div>
|
||||||
|
<div class="regime-badge" id="hero-badge">—</div>
|
||||||
|
<div class="regime-name" id="hero-regime">—</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:32px; margin-left:auto;">
|
||||||
|
<div class="hero-stat"><div class="val" id="hero-conf">—</div><div class="lbl">置信度</div></div>
|
||||||
|
<div class="hero-stat"><div class="val" id="hero-maturity">—</div><div class="lbl">成熟度</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-card" style="flex:1">
|
||||||
|
<div class="hero-stat"><div class="val" id="hero-ps">—</div><div class="lbl">价格结构</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-card" style="flex:1">
|
||||||
|
<div class="hero-stat"><div class="val" id="hero-br">—</div><div class="lbl">市场广度</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4 Factor Cards -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="fcard">
|
||||||
|
<div class="title">价格结构 PRICE STRUCTURE</div>
|
||||||
|
<div class="score" id="f-price">—</div>
|
||||||
|
<div class="sub" id="f-price-sub"></div>
|
||||||
|
<div class="bar-wrap"><div class="bar" id="bar-price"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="fcard">
|
||||||
|
<div class="title">市场广度 BREADTH</div>
|
||||||
|
<div class="score" id="f-breadth">—</div>
|
||||||
|
<div class="sub" id="f-breadth-sub"></div>
|
||||||
|
<div class="bar-wrap"><div class="bar" id="bar-breadth"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="fcard">
|
||||||
|
<div class="title">持仓状态 OI MATRIX</div>
|
||||||
|
<div class="score" id="f-oi" style="font-size:24px">—</div>
|
||||||
|
<div class="sub" id="f-oi-sub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="fcard">
|
||||||
|
<div class="title">波动率 VOLATILITY</div>
|
||||||
|
<div class="score" id="f-vol">—</div>
|
||||||
|
<div class="sub" id="f-vol-sub"></div>
|
||||||
|
<div class="bar-wrap"><div class="bar" id="bar-vol"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div class="charts">
|
||||||
|
<div class="chart-box"><h3>制度历史 REGIME HISTORY</h3><canvas id="chart-regime"></canvas></div>
|
||||||
|
<div class="chart-box"><h3>市场广度 BREADTH</h3><canvas id="chart-breadth"></canvas></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expectancy -->
|
||||||
|
<div class="exp">
|
||||||
|
<h3>信号期望 SIGNAL EXPECTANCY</h3>
|
||||||
|
<div class="exp-row">
|
||||||
|
<select id="exp-signal">
|
||||||
|
<option value="B3">B3 · 三买</option><option value="B2">B2 · 二买</option><option value="B1">B1 · 一买</option>
|
||||||
|
<option value="S3">S3 · 三卖</option><option value="S2">S2 · 二卖</option><option value="S1">S1 · 一卖</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="loadExpectancy()">查询</button>
|
||||||
|
<span class="suff" id="exp-sufficiency">—</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>层级</th><th>样本</th><th>有效样本</th><th>原始胜率</th><th>后验胜率</th><th>平均收益</th></tr></thead>
|
||||||
|
<tbody id="exp-layers"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="exp-summary" id="exp-summary"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanPY")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanPivotClassifier")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanPivotMonitor")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanSBI")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanSEG")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.ChanZS")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.ChanZone")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.core.Chan_FX_Box")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.analysis.Find_Trend")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
均线
|
||||||
|
5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 16h, 1d, 2d, 3d, 1w, 2w, 1M
|
||||||
|
|
||||||
|
参考时间周期
|
||||||
|
大周期:1h
|
||||||
|
小周期:15m
|
||||||
|
|
||||||
|
价格在1h周期ema156之上为大周期上涨,反之为大周期下跌
|
||||||
|
在1h大周期上涨时,小周期15m,下跌触碰到
|
||||||
|
|
||||||
|
顺大逆小
|
||||||
|
大周期看多,小周期跌完做多,跌完:顶分型和EMA52归零轴反弹
|
||||||
|
大周期看空,小周期涨完做空,涨完:顶分型和EMA52归零轴反抽
|
||||||
|
|
||||||
|
中枢分类
|
||||||
|
常规中枢
|
||||||
|
上升中枢
|
||||||
|
收敛中枢
|
||||||
|
扩散中枢
|
||||||
|
下行中枢
|
||||||
|
止损放到顶底分型的高低点
|
||||||
|
1. 从大周期开始找到价格接近ema52,MACD也接近零轴的周期,需要看这个周期的长级别是否高位空,大趋势方向
|
||||||
|
2. 然后去小于这个时间周期的周期找买卖点,小级趋势方向和大趋势相反并且开始反向,小级别需要检查MACD是否归零轴反转,同时价格是否接近EMA52
|
||||||
|
|
||||||
|
|
||||||
|
EMA52线的反弹比零轴的反弹弱
|
||||||
|
EMA52线和MACD白线同时归零轴同时满足的话是完美形态,最佳买卖点
|
||||||
|
|
||||||
|
跟随策略,先调整小级别,然后依次往大级别调整,直到整个周期结束
|
||||||
|
大级别MACD在零轴之上为多头趋势,回调踩EMA52做多,直到跳空背离,隐形,更大级别EMA52顶部归零轴平仓
|
||||||
|
大级别MACD在零轴之下为空头趋势,上涨踩EMA52做空,直到跳空背离,隐形,更大级别EMA52底部归零轴平仓
|
||||||
|
盘整趋势在零轴上下移动,价格在大级别EMA52之间移动,根据连续跳空背离,隐形,归零轴EMA52线开仓和平仓
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
MACD归零轴的两种情况,两者是或的关系,满足任意一种都是归零轴,归零轴的四种走势:
|
||||||
|
1. K线下跌或者上涨后触碰当前时间级别的EMA52附近
|
||||||
|
2. MACD的白线快线无限接近零轴,
|
||||||
|
3. MACD归零轴时,如果此时k线始终保持在EMA24附近,如果一直是EMA24之上之后出现反弹行情就会很大(最强反弹)这种反弹是2个时间级别同时归零轴形成的反弹,容易创新高新低,一般出现在强势行情。
|
||||||
|
4. K线先触碰EMA52,而MACD黄白线都未归零轴
|
||||||
|
|
||||||
|
MACD归零轴反弹/反抽的完美形态是K线触碰EMA52附近,MACD的白线无限接近零轴之后出现上涨或下跌
|
||||||
|
|
||||||
|
高位空
|
||||||
|
当MACD的黄白线远离零轴运行时,与零轴有一定的距离,形成了零轴的高危形态。随着K线出现缓慢上涨或者下跌,或者盘整,MACD的能量柱出现衰减,同时能量柱与MACD黄白线形成空间夹角,随着能量柱越来越小,夹角越来越大形成高位空。这种容易形成回调下跌,特别是导致次一级的MACD穿越零轴
|
||||||
|
|
||||||
|
穿越零轴的定义,需要同时满足以下条件
|
||||||
|
1. 在某个时间级别,k线的价格或者指数有效击穿当前时间级别的EMA52
|
||||||
|
2. MACD黄线慢线有效击穿零轴
|
||||||
|
|
||||||
|
MACD黄白线和零轴的几种形态:
|
||||||
|
离开零轴
|
||||||
|
当MACD黄白线穿过零轴那么进入第一阶段离开零轴,此时能量柱变化越来越大,不断增长,k线加速上涨
|
||||||
|
高位
|
||||||
|
当MACD黄白线离开零轴,到一高点时,能量柱此时处于最大,开始减弱时MACD处于高位,高位时MACD黄白线和能量柱是同向的,高位过后是高位空
|
||||||
|
高位空
|
||||||
|
当MACD黄白线处于高位,随着K线出现缓慢上涨或者横盘整理,MACD黄白线保持高位出现平滑横盘走势,此时,MACD的能量柱出现衰减变化,同时能量柱和黄白线之间形成一定的空间夹脚,随着能量柱的不断衰减就导致黄白线和能量柱之间的空间夹脚越来越大,因此就形成高位空
|
||||||
|
归零轴
|
||||||
|
当MACD黄白线在高位,驱动K线上涨的能量所产生的加速度小于或者等于零,K线减速上涨或者下跌,能量变化越来越小,能量柱呈现出一根比一根短的排列方式
|
||||||
|
|
||||||
|
穿零轴
|
||||||
|
同时满足以下两个条件
|
||||||
|
1. 在某个时间级别,K线的价格或者指数有效击穿当前时间级别的EMA52
|
||||||
|
2. 当前时间级别MACD的黄线慢线有效击穿零轴
|
||||||
|
有效的定义是:当前K线正好击穿EMA52的支撑位后,如果当前这个K线收盘后的第二根K线任然保持在EMA52之下才算有效击穿,如果只是上下影线击穿,后期K线任然运行在EMA52之上不算有效击穿,MACD同理
|
||||||
|
零轴缠绕/纠缠
|
||||||
|
具体是指MACD跟零轴无限接近或者缠绕的状态,或者是已经完成第一次归零轴调整之后,在等待大级别调整的时候。
|
||||||
|
分为无限接近和上下缠绕状态。代表本级别已经调整完毕,即不产生反弹支撑,也不形成阻力压力,不考虑次级别的技术形态,通过更大的时间级别或者其他时间级别进行分析
|
||||||
|
|
||||||
|
隐形形态
|
||||||
|
当MACD的黄白线发生交叉时,必有相应的能量柱释放出来。金叉,则会释放零轴之上的能量柱,反之死叉,则会释放出零轴之下的能量柱。如果黄白线无交叉,而k线出现上涨或者下跌,则代表能量柱的隐形状态,代表K线的运行无能量配合,那么这种上涨或者下跌就变成无效的结果。无能量配合的上涨必下跌,无能量配合的下跌必反弹
|
||||||
|
1. 远离零轴的高位隐形形态
|
||||||
|
某个时间级别的MACD黄白线处在远离零轴的高位,且K线继续拉升上涨或者下跌,但是并没有释放出相对应方向的能量柱,此后,K线将出现归零轴的走势下跌或者上涨。此形态是高位隐形形态+高位空的形态,则当前级别的MACD在后续走势中必将出现会拉零轴甚至穿零轴的走势。因此,远离零轴的高位隐形形态解决的是当前时间级别归零轴的需求。
|
||||||
|
2. 归零轴的隐形形态
|
||||||
|
归零轴后反弹出现的隐形形态,我们称之为归零轴的隐形形态。这种形态必然会导致当前级别的MACD黄白线出现穿零轴的走势。MACD在归零轴的情况下,零轴所提供的反弹或者支撑能量是最大的,而如果零轴所能提供的最大能量都产生不了相应的能量柱,这种支撑就变成了无效的支撑,MACD的黄白线就只能击穿零轴渠道零轴的反方向。
|
||||||
|
如何确认隐形形态的顶部
|
||||||
|
1. 通过顶底分型来确认
|
||||||
|
2. 通过次级别的背离确认隐形高位
|
||||||
|
3. 通过单位调整周期之内的时间级别嵌套逻辑确认隐形的高位
|
||||||
|
顶底分型在K线动能理论的应用
|
||||||
|
1. 分型对应的MACD处在高位空的形态
|
||||||
|
2. 分型所对应的MACD出现隐形形态
|
||||||
|
|
||||||
|
零轴之上高位隐形 + K线顶分型 = 下跌归零轴
|
||||||
|
零轴之上归零轴隐形 + K线顶分型 = 下跌穿零轴
|
||||||
|
|
||||||
|
零轴之下高位隐形 + K线底分型 = 上涨归零轴
|
||||||
|
零轴之下归零轴隐形 + K线底分型 = 上涨穿零轴
|
||||||
|
|
||||||
|
K线的3种盘整结构,上涨和下跌均适用,下面是上涨结构的分析,下跌反之
|
||||||
|
1. K线强势的走势结构
|
||||||
|
一般应用是在单边上涨行情中,某个时间级别的K线经过一轮拉升之后,进入调整的阶段,此时K线如果在高位一直处于横盘震荡走势,而MACD的黄白线却趋于归零轴运行,则当黄白线归零轴之后,出现有效反弹行情。
|
||||||
|
2. K线超强势结构
|
||||||
|
超强势结构往往容易发生破前高的走势。在某个时间级别,当K线经过一轮强势拉升上涨后,变为倾斜向上缓慢上行,K线一直处于这个时间级别的EMA24之上或者附近,经过一段时间的运行,导致当前级别的MACD黄白线无限归零轴的形态,此时,K线往往容易出现速度快,力度强且破前高的走势。对于超强势调整结构,最重要的是次级别不破零轴,并保持在当前级别的EMA24之上或者附近,而当前级别的黄白线运动一段时间后无限趋于零轴。一般来说,超强势调整结构容易出现在某个大的时间级别处于单边行情中。
|
||||||
|
3. 弱势调整结构
|
||||||
|
在弱势调整结构中,K线是通过下跌的方式快速地将当前级别的MACD的黄白线拉回零轴,同时,K线的价格也会快速下跌到本级别EMA52附近位置。在弱势调整结构中,MACD归零轴后所形成的支撑反弹往往不会破前高,而是走出能量不足的走势形态。在此结构中,只有MACD的黄白线出现死叉后才会继续下跌,并且只有在弱势调整结构中,死叉下跌才有效。
|
||||||
|
|
||||||
|
|
||||||
|
单位调整周期
|
||||||
|
指K线在某个时间级别,MACD的黄白线由零轴出发到远离零轴再到回到零轴的区间段称为这个时间级别的调整周期。可以分为零轴同方向和穿零轴出发两种。一个单位调整周期的起点往往是买点,同时终点也是另一个周期的买点。单位周期的判断依据是黄白线归零轴不是量能柱的多少。
|
||||||
|
注意:如果单位调整周期的起始和终止都直接穿零轴的,代表这个时间级别在运行的过程中,是无效的时间级别,也就是这个时间级别在我们的分析的过程中要跳过的。
|
||||||
|
隐形单位调整周期
|
||||||
|
MACD黄白线归零轴后,由于零轴的支撑或者压力而发生的反弹或者反抽,没有释放出相应的能量柱而形成的周期,为隐形单位调整周期。隐形单位调整周期会引起黄白线反向穿零轴的走势。
|
||||||
|
|
||||||
|
零轴粘合
|
||||||
|
MACD黄白线在刚穿零轴的时候会出现:黄白线离零轴的距离比较近,黄白线沿着能量柱运行,黄白线在运行的过程中没有释放出反向能量柱。零轴粘合属性是:强支撑,弱反弹。这种形态我们更强调支撑能量,弱化反弹属性。零轴粘合几乎是贴近零轴运行,因此其反弹的动能就是为无效。斜率小,黄白线喝能量柱之间基本没有空隙。能量柱可以略微减弱但是不能释放下跌方向的能量柱。由此可见黄线没有跟白线有交叉,黄线对白线有支撑作用。如果当前时间级别内部逻辑关系走完,这个时间级别则被视为无效时间级别。
|
||||||
|
|
||||||
|
零轴倒挂
|
||||||
|
MACD黄白线在穿零轴的时候与零轴的距离比较近,同时黄白线沿着能量柱运行,在运行的过程中,能量柱衰减导致它跟黄白线之间形成夹角空位,同时黄白线产生交叉并释放反向能量柱。
|
||||||
|
1. 黄白线穿零轴后未远离零轴形成一定高度,而是靠近零轴运行
|
||||||
|
2. 能量柱的衰减导致其与黄白线之间形成了一定的夹角空位
|
||||||
|
3. 黄白线在运行的过程中发生了交叉而放出反向能量柱
|
||||||
|
弱支撑,弱反弹。如果当前时间级别内部逻辑关系走完,这个时间级别则被视为无效时间级别。如果某个时间级别的盘口形态是零轴倒挂,那么这个时间级别很容易直接击穿零轴,而无法形成有效的反弹和反抽行情。
|
||||||
|
|
||||||
|
零轴粘合和倒挂的有效性
|
||||||
|
在上涨行情中,当K线处在零轴粘合或者零轴倒挂的形态时,如果K线的价格处在当前级别的EMA52之上或者处在多级别EMA均线交汇处之上和附近时,此时由于K线受到EMA均线的支撑,零轴粘合或者零轴倒挂反而容易形成强支撑的特点。此时需要结合MACD的形态和K线均线支撑综合分析盘面。
|
||||||
|
|
||||||
|
线段
|
||||||
|
上涨线段是指MACD的黄白线第一次上穿零轴到下一次下穿零轴中间的运行区域,以黄线穿零轴为准。在这个线段找到阶段性的卖点,阶段性高点。
|
||||||
|
下跌线段是指MACD的黄白线第一次下穿零轴到下一次上穿零轴中间的运行区域,以黄线穿零轴为准。在这个线段找到阶段性的买点,阶段性低点。
|
||||||
|
1. 同一条线段是比较背离的区域,背离的比较不可再跨线段的区域进行
|
||||||
|
2. 线段可以将不同时间级别的K线化繁为简,一个时间级别的形态只需要确定盘面所处的线段即可
|
||||||
|
|
||||||
|
背离
|
||||||
|
在K线分析中,背离是指价格跟能量之间的相悖性,当价格创出阶段性新高点,而推动价格上涨的能量出现衰减,这种情况就是背离,也就是说价格和能量之间产生了不匹配关系。
|
||||||
|
|
||||||
|
顶背离 - 确认卖点
|
||||||
|
在某个时间级别,MACD运行在零轴上方,当K线的价格走势一峰比一峰高,价格一直处在上涨趋势中时,MACD的黄白线或者能量柱的高度却一波比一波低,即当价格的高点比前一次价格的高点高,而MACD指标的高点比前一次高点低,这种形态称为顶背离形态。需要注意的是,这两次高点在运行的过程中,MACD的黄白线始终处在同一条上涨线段周期中,不能跨线段比较。
|
||||||
|
顶背离包括黄白线背离和柱背离两种情况,也可能出现多次背离的情况。线背离以白线的最高点作为参考点,K线上影线最高点作为K线的参考点,能量堆的最高点可能和K线的最高点不是一一对应,但是不影响判断,可以使用能量堆的面积进行计算。
|
||||||
|
|
||||||
|
底背离 - 确认买点
|
||||||
|
在某个时间级别,MACD运行在零轴下方,一般出现价格的低位区。当K线的价格走势持续下跌,而MACD的黄白线或者能量柱却持续靠近零轴,即当前价格的低点比前一次低点好要低,而MACD指标的低点却比前一次低点高,但是下跌能量却在衰减的现象,于是价格跌无可跌,是短期买入信号
|
||||||
|
底背离包括黄白线背离和柱背离两种情况,也可能出现多次背离的情况。线背离以白线的最低点作为参考点,K线上影线最低点作为K线的参考点,能量堆的最低点可能和K线的最低点不是一一对应,但是不影响判断,可以使用能量堆的面积进行计算。
|
||||||
|
|
||||||
|
顶底背离高低点有效性的方法
|
||||||
|
1. 通过顶底分型确认顶底背离的高低点
|
||||||
|
2. 通过次级别的背离确认当前级别的背离高点,这里的次级别不是单指一级次级别,可以是多级的
|
||||||
|
|
||||||
|
单位调整周期内的连续跳空背离
|
||||||
|
K线经过一波上涨或者下跌后,MACD的黄白线由高位回零轴且未归到零轴,能量柱在连续的衰减调整过程中,反而逐渐由衰减转为增长,于是就出现了跳空走势,这种走势称为MACD的连续跳空形态。
|
||||||
|
1. 连续跳空发生在单位调整周期内,线跟柱在零轴同方向
|
||||||
|
2. MACD的能量柱需包含在黄白线之内
|
||||||
|
3. 能量柱在衰减的过程中未放出反向能量柱,而是由衰减转为增长
|
||||||
|
单位调整周期之内的背离是价格跟能量柱之间的关系,同时单位调整周期之内的背离,解决的是归零轴的需求
|
||||||
|
连续跳空的应用和意义
|
||||||
|
单位调整周期之内,能量堆连接在一起的时候,出现的价格跟能量柱之间的关系即为连续跳空,而连续跳空的意义
|
||||||
|
1. 连续跳空背离解决归零轴的需求,主要是小级别的走势,比如5分钟的连续跳空背离则为归零轴走势,因为5分钟级别只包含一个3分钟级别,是5分钟内的小级别,因此,5分钟级别如果出现连续跳空背离,会导致5分钟级别MACD黄白线归零轴走势
|
||||||
|
2. 连续跳空更大的作用是确认穿零轴之后的第一个背离参考点,当MACD黄白线穿零轴的时候,最重要的是确认当前线段的1号参考点,有了1号参考点,后续行情才有参考的对象。连续跳空往往发生在MACD黄白线刚刚穿零轴的位置,一般由零轴粘合的形态演化而成连续跳空,这是因为零轴粘合具有强支撑的特点,容易形成跳空走势。
|
||||||
|
|
||||||
|
穿零轴时的参考点确认方法
|
||||||
|
1. 如果MACD黄白线穿零轴之后出现连续跳空,则以连续跳空的高点作为1号参考点,此时连续跳空形态代表新的单位周期调整周期的开始。当黄白线穿零轴后,黄线之后的第一个能量堆没有更高的能量柱,而是到了第一个跳空高低点出现第一个高低点。穿零轴产生的能量柱左侧黄白线处在下跌线段,右侧处在上涨线段。因此穿零轴的能量柱被切成两半,左侧在下跌线段,所以不能以黄线击穿零轴的左侧作为上涨线段的1号参考点。背离一定要在同一线段中进行比较,而不能跨线段找背离。那么穿零轴之后跳空产生的高低点才能作为1号参考点。后续的背离要以这个参考点进行比较。
|
||||||
|
2. 如果MACD黄白线穿零轴后无连续跳空,没有更高的能量柱出现则不能确定1号参考点,如果穿零轴后没有找到更高低的能量柱,那么这个线段的第一个单位调整周期是无效周期。
|
||||||
|
3. 如果黄线穿零轴之后有更高低的能量柱,则以更高的能量柱作为1号参考点,此参考点可以是穿零轴时的能量柱高点确定。
|
||||||
|
|
||||||
|
单位调整周期之内的分立跳空背离
|
||||||
|
在某个时间级别,当一个单位调整周期之内包含两个或者两个以上的能量堆,能量堆之间被反向能量堆分隔开,同时MACD黄白线一直处于远离零轴的高位,未归零轴,且一直保持原有的趋势运行,被分割的能量堆与黄白线都处在零轴的同方向,能量堆包含在黄白线之内,就形成分离跳空形态。
|
||||||
|
分立跳空背离
|
||||||
|
如果在某个时间级别的单位调整周期内,黄白线未归零轴,K线在经过一段时间的调整并放出反向能量柱之后,继续沿着原有方向运行,导致再一次出现的能量堆,同时黄白线再一次远离零轴,能量堆和能量堆之间形成背离关系,这种就是分离跳空背离。分立跳空背离解决的是归零轴的需求
|
||||||
|
|
||||||
|
分立跳空背离 + 黄白线高位 = 归零轴
|
||||||
|
|
||||||
|
分立跳空不背离 = 单边上涨或者下跌行情
|
||||||
|
|
||||||
|
最佳买卖点
|
||||||
|
单位周期之内 + 隐形 + 分立跳空 + 背离 + 黄白线高位空
|
||||||
|
|
||||||
|
跳空产生的原理:跳空的产生是当前级别之下的小级别归零轴反弹或反抽导致的,比如1小时时间级别的单位调整周期跳空,是因为1小时时间级别之内包含3分钟,5分钟,15分钟,30分钟这些下级别。1小时级别的MACD归零轴的途中,必然导致其内部的小级别先于本级别归零轴。因此,当这些小级别归零轴后,如果产生反弹反抽的走势,则会导致当前1小时级别的MACD黄白线再一次被拉高,此时便产生了跳空的走势。
|
||||||
|
时间级别在高位中归零轴的顺序是:由小级别到大级别依此归零轴。当K线经过一轮拉升下跌后,当前级别的MACD黄白线处在高位,此时如果K线进入调整状态,则这个时间级别所包含的小级别由小到大依次归零轴,直至本级别归零轴为止,如此才完成了次级别的单位调整周期的调整。
|
||||||
|
|
||||||
|
单位周期之内的分立跳空顶背离
|
||||||
|
MACD黄白线在零轴之上第一个单位调整周期 + 分立跳空背离(一次或多次) + 黄白线处在零轴的高位空 + 分立跳空隐形状态
|
||||||
|
单位周期之内的分立跳空底背离
|
||||||
|
MACD黄白线在零轴之下第一个单位调整周期 + 分立跳空背离(一次或多次) + 黄白线处在零轴的高位空 + 分立跳空隐形形态
|
||||||
|
|
||||||
|
单位调整周期之内的跳空非背离
|
||||||
|
如果在单位调整周期内发生了跳空的形态,当跳空能量堆的高度高于前一个能量堆的高度,此时的跳空则为非背离跳空。非背离跳空可以理解为单位周期的单边行情,前一个能量堆失效,以新的最高的能量堆作为后续行情的1号参考点。不管是连续跳空还是分立跳空都遵守这个法则。
|
||||||
|
|
||||||
|
单位调整周期之间的背离
|
||||||
|
单位调整周期之间的背离,是指两个或者两个以上的单位调整周期相比较而建立的关系,相比较的周期必须在同一个线段周期内,不可跨线段比较。当K线在某个时间级别的线段中,MACD经过了一个单位调整周期的运行,黄白线在此回零轴后,由于零轴的支撑反弹或压力反抽的作用,因此出现第二个单位调整周期,当第二个单位调整周期的黄白线这里特指白线离开零轴的距离小于第一个单位调整周期黄白线离开零轴的距离时,单位调整周期之间就产生了相悖的关系,即价格穿新高或新低,而白线能量区出现了减弱或增强,这种背离称为单位调整周期之间的背离,单位调整周期之间的背离也叫区间背离。
|
||||||
|
|
||||||
|
上涨线段单位调整周期之间的顶背离为卖点(前提:长级别MACD在高位)
|
||||||
|
下跌线段单位调整周期之间的底背离为买点(前提:长级别MACD在高位)
|
||||||
|
|
||||||
|
单位调整周期之间的背离,其核心的本质是描述某个时间级别在线段中的运行逻辑,即为线段完成调整的重要依据。
|
||||||
|
单位调整周期之间的背离是为了满足线段调整的需求
|
||||||
|
|
||||||
|
判断某个时间级别线段结束趋势的依据为:在某个时间级别的线段中,第二个单位调整周期与第一个单位调整周期之间产生背离关系,即为此线段终结的依据,而后出现的单位调整周期无论归零轴多少次,从能量产生的逻辑上都是依次减弱直至趋于零。
|
||||||
|
|
||||||
|
单位调整周期之间的背离而穿零轴变盘的依据是:当某个级别在线段中出现周期之间的背离形态,同时完成了线段的调整,但是其长级别MACD的黄白线处在高位空的形态时,当前级别的背离会导致穿零轴走势。
|
||||||
|
|
||||||
|
在K线的时间逻辑中,判断一个时间级别完成自己的当值任务的标准是:本级别在当前的线段中产生了周期间的背离关系,并趋向于零轴的调整。本级别是否穿零轴并非由本级别背离的属性决定,而是由长级别决定。因此,当本级别完成线段调整之后,就要看长级别处在什么形态之下,长级别的形态属性决定了后续的行情走势。
|
||||||
|
|
||||||
|
时间级别升级:是指当本级别在其线段中产生了背离关系而趋向于零轴,达到了平衡状态且保持在零轴之上(代表完成了其线段的调整任务),其长级别同时也处在归零轴的形态,此时当前级别即要发生时间级别升级。时间级别升级之后,本级别将会产生新的线段,之后本级别的线段关系则升级为线段与线段之间的关系。
|
||||||
|
|
||||||
|
确定时间级别升级的条件
|
||||||
|
1. 当前级别多次归零轴背离而完成了线段的调整,之后新的单位调整周期MACD的白线DIF比前一个单位调整周期的白线DIF高
|
||||||
|
2. 当前级别完成线段调整,长级别MACD的黄白线无限接近零轴
|
||||||
|
|
||||||
|
线段背离
|
||||||
|
当某个时间级别升级之后,便产生了一条新的线段,如果线段和线段之间构成相悖关系时,我们称为线段背离,而线段背离则必然导致当前时间级别穿零轴。线段背离比较的是两个线段中最高或最低的白线DIF。
|
||||||
|
|
||||||
|
动能不足
|
||||||
|
如果K线走势中不破前高点上涨动能衰减,或者不破前低点,下跌动能也衰减,这种形态称为动能不足,本质上和背离是一样的,都是能量衰减的一种变现,背离所具有的属性和原理适用于动能不足。
|
||||||
|
单位调整周期内,之间,线段之内,线段之间的隐形背离或隐形动能不足
|
||||||
|
|
||||||
|
主级别归零轴启动反弹的内部过程:
|
||||||
|
1. 第一过程,主级别所包含的小级别在零轴之下首先完成超跌反弹的过程
|
||||||
|
2. 第二过程,小级别完成底部调整后,才正式启动主级别归零轴反弹
|
||||||
|
|
||||||
|
底部形态变盘的四个阶段
|
||||||
|
确认底部区域需要四个条件
|
||||||
|
1. 确认引起下跌行情中的主要时间级别
|
||||||
|
当K线出现下跌行情时,一定是某个时间级别在零轴之上进行的归零轴调整而引起的,受到零轴的引力作用,这个级别的MACD黄白线会被拉回零轴。这个时间级别是:上穿零轴后第一次开始归零轴的时间级别。
|
||||||
|
2. 确认主级别归零轴后的形态属性
|
||||||
|
K线价格要触碰到EMA52均线的位置附近,同时MACD的黄白线无限接近零轴。也就是说,K线的价格一旦触碰到EMA52的位置附近,因为这个位置能否形成支撑反弹,主要是看归零轴的这两个条件能否一直保持,并开始归零轴反弹的第一个过程,即主级别所包含的小级别在零轴之下的超跌反弹。
|
||||||
|
3. 在归零轴的形态满足条件的情况下,确认子级别是否有高位空的形态
|
||||||
|
当主级别第一次触碰到当前级别EMA52均线的位置附近时,我们要看包含在主级别之下的小级别在零轴的下方是否产生了高位空的形态。只有当这些小级别出现高位空的形态时,才能出现有效的归零轴的超跌反弹,而超跌反弹的变向则为主级别归零轴后出现的止跌反弹的走势。
|
||||||
|
4. 确认零轴之下的最大子级别运行底部变盘的四个阶段
|
||||||
|
当主级别进入底部区域后,小级别的超跌反弹将逐级别开始,而时间级别的运行逻辑则是从小级别依次运行。因此,当主级别包含的小级别零轴之下的最后一个子级别完成调整后,主级别的归零轴反弹才正式开启。这些零轴之下的子级别的运行逻辑即为主级别底部变盘的四个阶段。
|
||||||
|
第一阶段:零轴之下的子级别的单边下跌
|
||||||
|
第二阶段:零轴之下的子级别的超跌反弹
|
||||||
|
第三阶段:零轴之下的子级别的归零轴反抽之后产生背离/动能不足
|
||||||
|
第四阶段:零轴之下的子级别跟零轴形成粘合或者零轴纠缠
|
||||||
|
|
||||||
|
底部形态V字反转的条件
|
||||||
|
当主级别归零轴后,由于小级别的超跌反弹和反抽容易在行情的底部走出横盘震荡的走势,当某个时间级别的超跌反弹导致K线突破这个横盘区间时,我们便把这种走势称为V字反转的走势
|
||||||
|
V字反转走势发生的条件
|
||||||
|
1. 主级别保持归零轴形态且MACD出现收敛的状态
|
||||||
|
2. 零轴之下大多数小级别已经完成线段调整,并形成了底部的横盘结构,剩下未调整的级别没有明显的高位空
|
||||||
|
3. 下一个长级别的超跌反弹归零轴所触碰到EMA52均线的位置需要有效突破底部横盘震荡的区间
|
||||||
|
MACD收敛
|
||||||
|
K线在下跌行情中进入底部区域,当MACD的黄白线由倾斜向下趋于零轴的方向转为拐头形态,同时下跌能量柱由逐渐增长转为衰减时,我们把这种形态称为MACD收敛形态
|
||||||
|
抢底原理
|
||||||
|
当行情运行到当前级别底部调整的第三阶段时,即背离/下跌动能不足时,即为我们最佳买入机会。因为一旦启动了V字反转的走势,K线的价格将不容易再出现大的回调机会,而V字反转的行情极容易导致剩下的海味调整的其他小级别直接击穿零轴,或者出现以横代跌的走势而不在出现明显的反抽下跌。这就是V字反转结构形成的条件下的抢底原理。
|
||||||
|
|
||||||
|
第一代时间级别当值的有效性满足两个条件
|
||||||
|
1. 第一代时间级别不能击穿零轴,如果击穿零轴,则本级别当值作用失效
|
||||||
|
2. 每个当值的第一代时间级别的反弹行情必须推动其长级别在上涨线段中的第一个单位调整周期处于高位的形态
|
||||||
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# Chan — 缠论分析引擎
|
|
||||||
|
|
||||||
把 OHLCV K 线拆成缠论结构(K 线单元 → 合并 K 线 → 分型 → 笔 → 线段 → 中枢 → 买卖点),
|
|
||||||
配一个 TradingView Charting Library 的 Web 界面,外加一套验证信号有效性的回测脚本。
|
|
||||||
|
|
||||||
标的不限:加密永续(ccxt)与 A 股(akshare)都走同一条分析链路。
|
|
||||||
|
|
||||||
```
|
|
||||||
chanlun/ 缠论引擎,纯 pandas/numpy,无外部指标库依赖
|
|
||||||
web/ Flask API + 图表界面
|
|
||||||
research/ 信号有效性验证脚本(step1 ~ step30)
|
|
||||||
data/ 本地 K 线(freqtrade 的 feather 格式)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 安装
|
|
||||||
|
|
||||||
需要 Python ≥ 3.11(pandas 3.x / numpy 2.x 的要求,不是本项目代码的限制)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m venv .venv
|
|
||||||
.venv/bin/pip install -r requirements.txt # 核心运行时,8 个包
|
|
||||||
.venv/bin/pip install -r requirements-dev.txt # 另加测试与 research/ 所需
|
|
||||||
```
|
|
||||||
|
|
||||||
## 跑 Web
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
../.venv/bin/python app.py # 默认 http://0.0.0.0:8128
|
|
||||||
```
|
|
||||||
|
|
||||||
从仓库根跑 `.venv/bin/python web/app.py` 也可以——Python 会把脚本所在目录放进 `sys.path`。
|
|
||||||
但**不能用 `python -m web.app`**,也不能 `import web.app`:`web/` 内部是无前缀导入
|
|
||||||
(`import config`、`from api.analyze import bp`),`-m` 方式下 `sys.path` 里是仓库根而不是
|
|
||||||
`web/`,会 `ModuleNotFoundError: No module named 'config'`。
|
|
||||||
|
|
||||||
配置全部走环境变量,见 `web/config.py`:
|
|
||||||
|
|
||||||
| 变量 | 默认值 | 用途 |
|
|
||||||
|------|--------|------|
|
|
||||||
| `FLASK_HOST` / `FLASK_PORT` | `0.0.0.0` / `8128` | 监听地址 |
|
|
||||||
| `DATA_SERVICE_URL` | `https://provider.jackyu66.com` | 行情 REST 源 |
|
|
||||||
| `DATA_SERVICE_WS_URL` | `wss://jackyu66.com/ws` | 行情 WebSocket 源 |
|
|
||||||
| `ASHARE_DP_URL` | `http://103.179.242.166:8000` | A 股数据源 |
|
|
||||||
| `CHAN_HTTP_PROXY` | 未设置则不走代理 | ccxt / HTTP 代理 |
|
|
||||||
| `MACD_FACTOR` / `MACD_SMOOTH` | `1` / `1` | MACD 周期倍数,默认 12/26/9 |
|
|
||||||
|
|
||||||
主要接口:`GET /api/analyze` 返回某标的某周期的完整缠论结构,`/api/klines/recent`
|
|
||||||
取最新 K 线,`/api/trend_filter` 与 `/api/trend_detail` 做多周期趋势筛选,
|
|
||||||
`/api/symbols`、`/api/search_stock`、`/api/sectors` 等负责标的检索。页面在 `/` 与 `/chan_tv`。
|
|
||||||
|
|
||||||
## 作为库使用
|
|
||||||
|
|
||||||
```python
|
|
||||||
import pandas as pd
|
|
||||||
from chanlun import TF_DF
|
|
||||||
|
|
||||||
# 需要 date/open/high/low/close/volume 六列,date 为 datetime
|
|
||||||
df = pd.read_feather("data/binance/futures/BTC_USDT_USDT-1h-futures.feather")
|
|
||||||
|
|
||||||
tf = TF_DF(df, interval=1, timeframe="1h")
|
|
||||||
|
|
||||||
len(tf.klu_list) # K 线单元
|
|
||||||
len(tf.klc_list) # 合并 K 线(处理包含关系后)
|
|
||||||
len(tf.bi_list) # 笔
|
|
||||||
len(tf.seg_list) # 线段
|
|
||||||
len(tf.zs_list) # 中枢
|
|
||||||
tf.bi_list[-1].dir # Chan_BI_DIR.DOWN
|
|
||||||
tf.chanmacd # MACD 结构分析(背驰判定用)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`interval` 的单位是分钟**,对传入的 df 做重采样;`interval=1` 是特例,表示原样使用、
|
|
||||||
不重采样。所以拿 1h 的 feather 要传 `interval=1`,拿 1m 数据想看 1h 才传 `interval=60`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
df1m = pd.read_feather("data/binance/futures/BTC_USDT_USDT-1m-futures.feather")
|
|
||||||
TF_DF(df1m, interval=5, timeframe="5m")
|
|
||||||
TF_DF(df1m, interval=60, timeframe="1h")
|
|
||||||
```
|
|
||||||
|
|
||||||
传错不会报错,只会静默给出错误周期的结构——1h 数据配 `interval=4` 相当于按 4 分钟
|
|
||||||
重采样,结果与 `interval=1` 完全相同。
|
|
||||||
|
|
||||||
## 分析流程
|
|
||||||
|
|
||||||
`TF_DF.init_TF_DF()` 按顺序做这几步,每步的实现在 `chanlun/pipeline/builders/` 下同名文件:
|
|
||||||
|
|
||||||
1. `resample_to_interval` — 重采样(`interval != 1` 时)
|
|
||||||
2. `add_indicators` — 追加 33 列指标(MACD / BBANDS / EMA / RSI / ATR 等)
|
|
||||||
3. `cal_kl_data` → `klu_list` — K 线单元
|
|
||||||
4. `get_klc_list` → `klc_list` — 按包含关系合并 K 线,并标记分型
|
|
||||||
5. `cal_bi_list` → `bi_list` — 笔
|
|
||||||
6. `cal_bi_zs_list_pure` → `bi_zs_list` — 笔中枢
|
|
||||||
7. `get_seg_list` → `seg_list` — 线段
|
|
||||||
8. `get_zs_list` / `get_big_zs_list` — 中枢与大级别中枢
|
|
||||||
9. `ChanMACD(klu_list)` — MACD 段 / 柱堆结构,供背驰判定
|
|
||||||
|
|
||||||
## 数据
|
|
||||||
|
|
||||||
`data/<交易所>/futures/<SYMBOL>-<周期>-futures.feather`,即 freqtrade 的下载格式,
|
|
||||||
如 `data/binance/futures/BTC_USDT_USDT-1h-futures.feather`。
|
|
||||||
`research/lib/data.py` 负责定位:`BTC/USDT:USDT` + `1h` 会解析到上面这个路径,
|
|
||||||
找不到本地文件则回落到远端拉取。
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.venv/bin/python -m pytest chanlun/tests web/tests -q
|
|
||||||
```
|
|
||||||
|
|
||||||
`chanlun/tests/test_ta_compat.py` 有个**需要注意的陷阱**:它把 `chanlun/indicators/ta.py`
|
|
||||||
的输出逐 bar 钉在 TA-Lib 上,但 **TA-Lib 不存在时会静默跳过**。也就是说改了 `ta.py`
|
|
||||||
之后在没装 TA-Lib 的环境里跑,测试会显示通过,其实一项都没验证。改动那个文件时请先装:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get install -y libta-lib0 ta-lib-dev
|
|
||||||
.venv/bin/pip install TA-Lib technical
|
|
||||||
```
|
|
||||||
|
|
||||||
## 已知问题
|
|
||||||
|
|
||||||
- **`web/DEPLOY_GUIDE.md` 已失效**:它引用的 `deploy_venv.sh`、`stop_venv.sh`、
|
|
||||||
`status_venv.sh` 等 6 个脚本都在 `7f393b9` 精简提交里删掉了,目前没有部署脚本。
|
|
||||||
两个 systemd unit 文件(`web/chanlun-web*.service`)仍可参考,但它们用 gunicorn
|
|
||||||
且写死端口 8123,与 `config.py` 默认的 8128 不一致,gunicorn 也不在依赖清单里。
|
|
||||||
- **`web/README.txt` 已过时**:它说的 `web/requirements.txt` 不存在,依赖清单在仓库根目录。
|
|
||||||
- **四个零引用的死文件**:`chanlun/analysis/` 下的 `ChanPY.py`、`ChanLun_Classifier.py`、
|
|
||||||
`Find_Trend.py`、`ChanHeng.py` 全项目无人引用。`ChanPY.py` 依赖未安装的外部 chan.py 库,
|
|
||||||
另外三个需要 matplotlib / mplfinance / xgboost / scikit-learn——这些都**不在**依赖清单里,
|
|
||||||
是有意为之。要用得自行安装。
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
_impl = importlib.import_module("chan.pipeline.TF_DF")
|
||||||
|
sys.modules[__name__] = _impl
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# bsp_monitor 复用 Hermes Agent 的 Telegram bot
|
||||||
|
# notify.py 从 ~/.hermes/.env 直接读取 TELEGRAM_BOT_TOKEN
|
||||||
|
# 此处无需重复配置
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# bsp_monitor - 缠论买卖点监控 (BTC/USDT 1m)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
engine.py - 缠论管线封装:DataFrame → KLU → KLC → BI → SEG → ZS → BSP。
|
||||||
|
|
||||||
|
复用 ~/Project/Chan/ 下的 TF_DF 模块,管线步骤对齐 TF_DF.get_bsp_state()。
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
_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_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_BI_DIR,
|
||||||
|
Chan_ZS_DIR,
|
||||||
|
)
|
||||||
|
from ChanBSP import ChanBSP
|
||||||
|
from ChanBI import ChanBI
|
||||||
|
|
||||||
|
# 仅导入类,不触发 TF_DF.__init__
|
||||||
|
from TF_DF import TF_DF as _TF_DF_Class
|
||||||
|
|
||||||
|
|
||||||
|
class ChanEngine:
|
||||||
|
"""缠论管线,对齐 TF_DF.get_bsp_state() 的调用顺序。"""
|
||||||
|
|
||||||
|
def __init__(self, df: pd.DataFrame):
|
||||||
|
if df.empty or len(df) < 50:
|
||||||
|
raise ValueError("DataFrame 至少需要 50 根 K 线")
|
||||||
|
|
||||||
|
if "date" not in df.columns and "timestamp" in df.columns:
|
||||||
|
df["date"] = df["timestamp"]
|
||||||
|
|
||||||
|
self.df = df
|
||||||
|
self._tf = _TF_DF_Class.__new__(_TF_DF_Class) # 不调用 __init__
|
||||||
|
|
||||||
|
# Step 0: 添加 TA 指标 (MACD/EMA/BB/RSI)
|
||||||
|
self._df_with_indicators = self._tf.add_indicators(df.copy())
|
||||||
|
|
||||||
|
# Step 1: KLU — get_klu_list → get_kl_data → cal_kl_data
|
||||||
|
self.klu_list = self._tf.get_klu_list(self._df_with_indicators)
|
||||||
|
|
||||||
|
# Step 2: KLC — 内部已含 ChanMACD.cal_macd_state() + cal_trend()
|
||||||
|
self.klc_list = self._tf.get_klc_list(self.klu_list)
|
||||||
|
|
||||||
|
# Step 3: BI (stroke)
|
||||||
|
self.bi_list = self._tf.cal_bi_list(self.klc_list)
|
||||||
|
|
||||||
|
# Step 4: SEG (segment)
|
||||||
|
self.seg_list = self._tf.get_seg_list(self.bi_list)
|
||||||
|
|
||||||
|
# Step 5: ZS — cal_bi_zs(seg_list) 对齐 get_bsp_state(从线段计算笔中枢)
|
||||||
|
self.bi_zs_list: List = self._tf.cal_bi_zs(self.seg_list)
|
||||||
|
|
||||||
|
# Step 6: BSP (buy/sell points)
|
||||||
|
self.bsp_list: List[ChanBSP] = self._tf.find_all_bsp(
|
||||||
|
self.bi_list, self.bi_zs_list
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_second_last_bi(self) -> Optional[ChanBI]:
|
||||||
|
"""获取倒数第二笔(最新确认的笔)。"""
|
||||||
|
confirmed = [b for b in self.bi_list if b.is_sure]
|
||||||
|
if len(confirmed) >= 2:
|
||||||
|
return confirmed[-2]
|
||||||
|
elif len(confirmed) == 1:
|
||||||
|
return confirmed[-1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_bsp_for_bi(self, bi: ChanBI) -> Optional[ChanBSP]:
|
||||||
|
"""检查某个 Bi 的 end_klc 是否是买卖点。"""
|
||||||
|
if bi is None or not bi.is_sure:
|
||||||
|
return None
|
||||||
|
klc = bi.end_klc
|
||||||
|
if klc is None:
|
||||||
|
return None
|
||||||
|
if klc.bsp and klc.bsp_type != Chan_BSP_TYPE.NONE:
|
||||||
|
for bsp in self.bsp_list:
|
||||||
|
if bsp.klc is klc:
|
||||||
|
return bsp
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 格式化 ──
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bsp_type_name(t: Chan_BSP_TYPE) -> str:
|
||||||
|
import ChanEnum
|
||||||
|
names = {
|
||||||
|
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 names.get(t, str(t))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bi_dir_name(d) -> str:
|
||||||
|
return "⬆️ 向上" if d == Chan_BI_DIR.UP else "⬇️ 向下"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fx_strength_name(klc_fx_type) -> str:
|
||||||
|
import ChanEnum
|
||||||
|
names = {
|
||||||
|
Chan_KLC_FX.TOP0: "TOP0(弱)", Chan_KLC_FX.TOP1: "TOP1(标准)",
|
||||||
|
Chan_KLC_FX.TOP2: "TOP2(强)", Chan_KLC_FX.TOP3: "TOP3(二类)",
|
||||||
|
Chan_KLC_FX.TOP4: "TOP4(BB上轨)", Chan_KLC_FX.TOP5: "TOP5",
|
||||||
|
Chan_KLC_FX.TOP6: "TOP6(高位空)", Chan_KLC_FX.TOP7: "TOP7(背驰)",
|
||||||
|
Chan_KLC_FX.TOP8: "TOP8(信号线)",
|
||||||
|
Chan_KLC_FX.BOTTOM0: "BOTTOM0(弱)", Chan_KLC_FX.BOTTOM1: "BOTTOM1(标准)",
|
||||||
|
Chan_KLC_FX.BOTTOM2: "BOTTOM2(强)", Chan_KLC_FX.BOTTOM3: "BOTTOM3(二类)",
|
||||||
|
Chan_KLC_FX.BOTTOM4: "BOTTOM4(BB下轨)", Chan_KLC_FX.BOTTOM5: "BOTTOM5(零轴下)",
|
||||||
|
Chan_KLC_FX.BOTTOM6: "BOTTOM6(高位空)", Chan_KLC_FX.BOTTOM7: "BOTTOM7(背驰)",
|
||||||
|
Chan_KLC_FX.BOTTOM8: "BOTTOM8(信号线)",
|
||||||
|
}
|
||||||
|
return names.get(klc_fx_type, f"UNKNOWN({klc_fx_type})")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _utc_to_cst(time_str: str) -> str:
|
||||||
|
"""UTC 时间字符串 → 东八区 (UTC+8)。"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
dt = datetime.fromisoformat(str(time_str))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
cst = dt.astimezone(timezone(timedelta(hours=8)))
|
||||||
|
return cst.strftime("%Y-%m-%d %H:%M:%S CST")
|
||||||
|
|
||||||
|
def format_bsp_detail(self, bsp: ChanBSP, symbol: str = "BTC/USDT:USDT", tf: str = "1m") -> str:
|
||||||
|
bi = bsp.bi
|
||||||
|
klc = bsp.klc
|
||||||
|
bsp_type = bsp.type
|
||||||
|
bsp_dir = bsp.dir
|
||||||
|
|
||||||
|
emoji = "🟢" if bsp_dir == Chan_BSP_DIR.BUY else "🔴"
|
||||||
|
dir_label = "买点" if bsp_dir == Chan_BSP_DIR.BUY else "卖点"
|
||||||
|
|
||||||
|
symbol_short = symbol.split(":")[0].replace("/", "")
|
||||||
|
lines = [
|
||||||
|
f"{emoji} [{dir_label}] {self._bsp_type_name(bsp_type)} — <b>{symbol_short} {tf}</b>",
|
||||||
|
"",
|
||||||
|
f"⏰ 确认: <code>{self._utc_to_cst(klc.end_time)}</code>",
|
||||||
|
f"💰 价格: <b>{klc.close:.2f}</b>",
|
||||||
|
f"📐 笔方向: {self._bi_dir_name(bi.dir)}",
|
||||||
|
f"📏 笔高度: ${bi.height:.2f} 宽度: {bi.width}K 斜率: {bi.slop:.2f}",
|
||||||
|
f"🔩 分型强度: {self._fx_strength_name(klc.klc_fx_type)}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if bsp.zs:
|
||||||
|
zs = bsp.zs
|
||||||
|
zs_dir = "UP" if hasattr(zs, 'dir') and hasattr(Chan_ZS_DIR, 'UP') and zs.dir == Chan_ZS_DIR.UP else "DOWN"
|
||||||
|
lines.append(f"🏠 中枢: {zs.zd:.2f} – {zs.zg:.2f} ({zs_dir}, #{getattr(zs, 'index', 0) + 1})")
|
||||||
|
|
||||||
|
if bsp.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1):
|
||||||
|
lines.append("📊 MACD背驰: 有 (离开段能量 < 进入段)")
|
||||||
|
|
||||||
|
if hasattr(klc, 'ema_status') and klc.ema_status:
|
||||||
|
ema52 = klc.ema_status.get('ema52', {})
|
||||||
|
if ema52:
|
||||||
|
pos = str(ema52.get('pos', '?'))
|
||||||
|
lines.append(f"📈 EMA52: {pos} (值: {klc.ema52:.2f})")
|
||||||
|
|
||||||
|
lines.append(f"📋 KLC状态: {klc.klc_state}")
|
||||||
|
|
||||||
|
if bi.pre:
|
||||||
|
prev = bi.pre
|
||||||
|
lines.extend([
|
||||||
|
"────",
|
||||||
|
f"⬅️ 前一笔: {self._bi_dir_name(prev.dir)} "
|
||||||
|
f"高度: ${prev.height:.2f} 宽度: {prev.width}K",
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
fetcher.py - 从 data_provider HTTP API 拉取 K 线数据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import pandas as pd
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROVIDER_URL = "http://103.179.242.166"
|
||||||
|
PROVIDER_URL = "http://127.0.0.1"
|
||||||
|
FETCH_LIMIT = 1000
|
||||||
|
|
||||||
|
_symbols_cache: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
# 只推送 BTC,其他币对暂不监控
|
||||||
|
_SYMBOL_WHITELIST = {"BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_symbols() -> list[str]:
|
||||||
|
"""获取要监控的币对列表(目前只监控 BTC)。"""
|
||||||
|
global _symbols_cache
|
||||||
|
if _symbols_cache is not None:
|
||||||
|
return _symbols_cache
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{PROVIDER_URL}/health", timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
all_symbols = resp.json().get("symbols", [])
|
||||||
|
_symbols_cache = [s for s in all_symbols if s in _SYMBOL_WHITELIST]
|
||||||
|
logger.info(f"获取到 {len(all_symbols)} 个币对,过滤后监控 {len(_symbols_cache)} 个: {_symbols_cache}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取币对列表失败: {e}")
|
||||||
|
_symbols_cache = ["BTC/USDT:USDT"]
|
||||||
|
return _symbols_cache
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_ohlcv(symbol: str, tf: str = "1m") -> pd.DataFrame:
|
||||||
|
"""从 data_provider API 拉取某个币对最近 FETCH_LIMIT 根 K 线。"""
|
||||||
|
url = f"{PROVIDER_URL}/api/candles"
|
||||||
|
params = {
|
||||||
|
"symbol": symbol,
|
||||||
|
"tf": tf,
|
||||||
|
"limit": FETCH_LIMIT,
|
||||||
|
}
|
||||||
|
resp = requests.get(url, params=params, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
logger.warning(f"{symbol}: API 返回空数据")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||||
|
df["date"] = df["timestamp"]
|
||||||
|
|
||||||
|
df = df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
|
||||||
|
return df
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
main.py - 缠论多周期买卖点监控。
|
||||||
|
|
||||||
|
每整分钟:
|
||||||
|
1. 从 data_provider 拉取所有币对多周期 K 线
|
||||||
|
2. 每个币对 × 每个周期独立跑缠论管线
|
||||||
|
3. 检测新笔确认 → BSP 推送
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from fetcher import fetch_ohlcv, get_symbols
|
||||||
|
from engine import ChanEngine
|
||||||
|
from notify import send_bsp_alert, BOT_TOKEN, CHAT_ID
|
||||||
|
|
||||||
|
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _PARENT not in sys.path:
|
||||||
|
sys.path.insert(0, _PARENT)
|
||||||
|
from ChanEnum import Chan_BI_DIR
|
||||||
|
# from ChanPivotMonitor import ChanPivotMonitor # 暂停中枢监控
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("bsp_monitor")
|
||||||
|
|
||||||
|
TIMEFRAMES = ["1m", "5m", "15m", "1h"]
|
||||||
|
|
||||||
|
|
||||||
|
def _short(symbol: str) -> str:
|
||||||
|
"""BTC/USDT:USDT → BTCUSDT"""
|
||||||
|
return symbol.split(":")[0].replace("/", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _bi_id(bi) -> Optional[tuple]:
|
||||||
|
"""笔的稳定标识,基于首K线时间戳。"""
|
||||||
|
if bi.start_klc is None:
|
||||||
|
return None
|
||||||
|
return (bi.start_klc.start_time,)
|
||||||
|
|
||||||
|
|
||||||
|
def _push_bsp(engine: ChanEngine, bsp, symbol: str, tf: str) -> bool:
|
||||||
|
"""推送 BSP 到 Telegram(带去重)。"""
|
||||||
|
if bsp.klc is None:
|
||||||
|
return False
|
||||||
|
key = f"{symbol}_{bsp.type}_{bsp.klc.end_time}_{tf}"
|
||||||
|
msg = engine.format_bsp_detail(bsp, symbol, tf)
|
||||||
|
msg = _escape_html(msg)
|
||||||
|
if send_bsp_alert(msg, bsp_key=key):
|
||||||
|
logger.info(f"[{_short(symbol)} {tf}] ✅ BSP: {key}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TfState:
|
||||||
|
"""单个周期的状态。"""
|
||||||
|
last_bi_id: Optional[tuple] = None
|
||||||
|
last_df_ts: object = None
|
||||||
|
first_run: bool = True
|
||||||
|
# pivot_monitor: ChanPivotMonitor = None # 暂停中枢监控
|
||||||
|
|
||||||
|
# def __post_init__(self):
|
||||||
|
# if self.pivot_monitor is None:
|
||||||
|
# self.pivot_monitor = ChanPivotMonitor()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SymbolState:
|
||||||
|
symbol: str
|
||||||
|
tfs: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.tfs = {tf: TfState() for tf in TIMEFRAMES}
|
||||||
|
|
||||||
|
|
||||||
|
class BSPMonitor:
|
||||||
|
def __init__(self):
|
||||||
|
symbols = get_symbols()
|
||||||
|
self._states: dict[str, SymbolState] = {
|
||||||
|
s: SymbolState(symbol=s) for s in symbols
|
||||||
|
}
|
||||||
|
logger.info(f"监控 {len(symbols)}×{len(TIMEFRAMES)} 币对×周期: "
|
||||||
|
f"{', '.join(_short(s) for s in symbols)}")
|
||||||
|
|
||||||
|
async def tick(self):
|
||||||
|
tick_start = time.monotonic()
|
||||||
|
logger.info("── tick 开始 ──")
|
||||||
|
|
||||||
|
for symbol, st in self._states.items():
|
||||||
|
await self._tick_symbol(symbol, st)
|
||||||
|
|
||||||
|
elapsed = (time.monotonic() - tick_start) * 1000
|
||||||
|
logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──")
|
||||||
|
|
||||||
|
async def _tick_symbol(self, symbol: str, st: SymbolState):
|
||||||
|
name = _short(symbol)
|
||||||
|
|
||||||
|
for tf in TIMEFRAMES:
|
||||||
|
await self._check_tf(symbol, tf, st.tfs[tf], name)
|
||||||
|
|
||||||
|
async def _check_tf(self, symbol: str, tf: str, ts: TfState, name: str):
|
||||||
|
# 1. 拉取 K 线
|
||||||
|
try:
|
||||||
|
df = fetch_ohlcv(symbol, tf)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[{name} {tf}] 拉取失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 检查是否有新 K 线
|
||||||
|
latest_ts = df.iloc[-1]["timestamp"]
|
||||||
|
if ts.last_df_ts and latest_ts <= ts.last_df_ts:
|
||||||
|
return
|
||||||
|
ts.last_df_ts = latest_ts
|
||||||
|
|
||||||
|
# 3. 运行缠论管线
|
||||||
|
try:
|
||||||
|
engine = ChanEngine(df)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[{name} {tf}] 缠论计算失败: {e}", exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. 中枢特征更新(暂停)
|
||||||
|
# try:
|
||||||
|
# ts.pivot_monitor.update(engine.bi_zs_list)
|
||||||
|
# except Exception as e:
|
||||||
|
# logger.debug(f"[{name} {tf}] 中枢特征更新失败: {e}")
|
||||||
|
|
||||||
|
# 5. BSP 检测
|
||||||
|
confirmed = [b for b in engine.bi_list if b.is_sure]
|
||||||
|
if len(confirmed) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
last_confirmed = confirmed[-1]
|
||||||
|
current_bi_id = _bi_id(last_confirmed)
|
||||||
|
if current_bi_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if ts.first_run:
|
||||||
|
ts.first_run = False
|
||||||
|
ts.last_bi_id = current_bi_id
|
||||||
|
|
||||||
|
bsp = engine.get_bsp_for_bi(last_confirmed)
|
||||||
|
if bsp:
|
||||||
|
_push_bsp(engine, bsp, symbol, tf)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[{name} {tf}] 首次完成 — "
|
||||||
|
f"{len(confirmed)} 笔, {len(engine.bsp_list)} BSP"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if current_bi_id == ts.last_bi_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
ts.last_bi_id = current_bi_id
|
||||||
|
|
||||||
|
bi_dir = "⬆️" if last_confirmed.dir == Chan_BI_DIR.UP else "⬇️"
|
||||||
|
logger.info(f"[{name} {tf}] 新笔确认 — #{len(confirmed)} "
|
||||||
|
f"{bi_dir} 高度: ${last_confirmed.height:.2f}")
|
||||||
|
|
||||||
|
bsp = engine.get_bsp_for_bi(last_confirmed)
|
||||||
|
if bsp:
|
||||||
|
_push_bsp(engine, bsp, symbol, tf)
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(f"bsp_monitor 启动 — {len(self._states)} 币对 "
|
||||||
|
f"× {len(TIMEFRAMES)} 周期 ({', '.join(TIMEFRAMES)})")
|
||||||
|
logger.info(f"Telegram: {'已配置' if BOT_TOKEN and CHAT_ID else '⚠️ 未配置'}")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
|
||||||
|
logger.info("首次运行(初始化)...")
|
||||||
|
await self.tick()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
next_minute = now.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
||||||
|
wait_seconds = max(0.1, (next_minute - now).total_seconds())
|
||||||
|
|
||||||
|
logger.info(f"等待 {wait_seconds:.0f}s 到 {next_minute.strftime('%H:%M:%S')}UTC")
|
||||||
|
await asyncio.sleep(wait_seconds)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.tick()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"tick 异常: {e}", exc_info=True)
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_html(msg: str) -> str:
|
||||||
|
"""HTML 转义,保留已有的 <b>/<code> 标签。"""
|
||||||
|
msg = msg.replace("&", "&")
|
||||||
|
msg = msg.replace("<b>", "\x00B\x00").replace("</b>", "\x00/B\x00")
|
||||||
|
msg = msg.replace("<code>", "\x00C\x00").replace("</code>", "\x00/C\x00")
|
||||||
|
msg = msg.replace("<", "<").replace(">", ">")
|
||||||
|
msg = msg.replace("\x00B\x00", "<b>").replace("\x00/B\x00", "</b>")
|
||||||
|
msg = msg.replace("\x00C\x00", "<code>").replace("\x00/C\x00", "</code>")
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
monitor = BSPMonitor()
|
||||||
|
try:
|
||||||
|
asyncio.run(monitor.run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("收到中断信号,退出")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
notify.py - Telegram 推送。
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BOT_TOKEN = "8742822093:AAGzD1vS7ru7ROhgcOjA-UyHb4R8Cfcqv3Q"
|
||||||
|
CHAT_ID = "580807463"
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_message(text: str) -> bool:
|
||||||
|
"""发送 Telegram 消息(不去重,每次调用都发)。"""
|
||||||
|
if not BOT_TOKEN or not CHAT_ID:
|
||||||
|
logger.warning("Telegram 未配置,跳过推送")
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"chat_id": CHAT_ID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram 推送失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_bsp_alert(text: str, bsp_key: str = "") -> bool:
|
||||||
|
"""推送 BSP 消息。"""
|
||||||
|
if not BOT_TOKEN or not CHAT_ID:
|
||||||
|
logger.warning("Telegram 未配置,跳过推送")
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"chat_id": CHAT_ID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info(f"Telegram 推送成功: {bsp_key or 'no-key'}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram 推送失败: {e}")
|
||||||
|
return False
|
||||||
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# bsp_monitor 启动脚本
|
||||||
|
# 用法: bash run.sh
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
echo "=== bsp_monitor ==="
|
||||||
|
echo "启动时间: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "监控: BTC/USDT:USDT 1m 缠论买卖点"
|
||||||
|
echo "推送: Telegram (复用 Hermes bot)"
|
||||||
|
echo "==================="
|
||||||
|
exec /usr/bin/python3 -u main.py
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
twitter_web.py — Twitter 监控账号管理 Web 界面。
|
||||||
|
单文件,零依赖,只用到 Python 标准库。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
WATCHLIST_PATH = os.path.join(SCRIPT_DIR, "twitter_watchlist.json")
|
||||||
|
STATE_PATH = os.path.join(SCRIPT_DIR, "twitter_state.json")
|
||||||
|
|
||||||
|
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8010
|
||||||
|
|
||||||
|
|
||||||
|
def extract_username(value: str) -> str:
|
||||||
|
value = value.strip().rstrip("/")
|
||||||
|
if value.startswith("@"):
|
||||||
|
return value[1:]
|
||||||
|
for pattern in [r"(?:twitter\.com|x\.com)/(\w+)(?:/|$)", r"/(\w+)$"]:
|
||||||
|
m = re.search(pattern, value)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
if re.match(r"^\w+$", value):
|
||||||
|
return value
|
||||||
|
raise ValueError(f"无法提取用户名: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path):
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path, data):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_watchlist():
|
||||||
|
return load_json(WATCHLIST_PATH).get("users", [])
|
||||||
|
|
||||||
|
|
||||||
|
def save_watchlist(users):
|
||||||
|
save_json(WATCHLIST_PATH, {"users": users})
|
||||||
|
|
||||||
|
|
||||||
|
def get_state():
|
||||||
|
return load_json(STATE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
HTML = """<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<!-- Google tag (gtag.js) -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LVVXH3TL04"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-LVVXH3TL04');
|
||||||
|
</script>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Twitter 监控管理</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --card: #161b22; --border: #30363d;
|
||||||
|
--text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff;
|
||||||
|
--green: #3fb950; --red: #f85149; --yellow: #d2991d;
|
||||||
|
}
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||||
|
background:var(--bg); color:var(--text); padding:24px; max-width:680px; margin:auto; }
|
||||||
|
h1 { font-size:20px; margin-bottom:4px; }
|
||||||
|
.sub { color:var(--muted); font-size:12px; margin-bottom:20px; }
|
||||||
|
.add-bar { display:flex; gap:8px; margin-bottom:20px; }
|
||||||
|
.add-bar input { flex:1; padding:8px 12px; border:1px solid var(--border);
|
||||||
|
border-radius:6px; background:var(--card); color:var(--text); font-size:14px; outline:none; }
|
||||||
|
.add-bar input:focus { border-color:var(--accent); }
|
||||||
|
.add-bar input::placeholder { color:var(--muted); }
|
||||||
|
button { padding:8px 16px; border:none; border-radius:6px; cursor:pointer; font-size:13px;
|
||||||
|
font-weight:500; transition:opacity .15s; }
|
||||||
|
button:hover { opacity:0.85; }
|
||||||
|
.btn-add { background:var(--accent); color:#fff; }
|
||||||
|
.btn-edit, .btn-save { background:var(--yellow); color:#000; }
|
||||||
|
.btn-del { background:var(--red); color:#fff; }
|
||||||
|
.btn-cancel { background:var(--border); color:var(--text); }
|
||||||
|
.account { background:var(--card); border:1px solid var(--border); border-radius:8px;
|
||||||
|
padding:12px 16px; margin-bottom:8px; display:flex; align-items:center; gap:12px; }
|
||||||
|
.account .name { font-weight:600; min-width:160px; }
|
||||||
|
.account .name a { color:var(--accent); text-decoration:none; }
|
||||||
|
.account .name a:hover { text-decoration:underline; }
|
||||||
|
.account .meta { font-size:12px; color:var(--muted); flex:1; }
|
||||||
|
.account .actions { display:flex; gap:6px; flex-shrink:0; }
|
||||||
|
.edit-row { display:flex; gap:6px; align-items:center; width:100%; }
|
||||||
|
.edit-row input { flex:1; padding:6px 10px; border:1px solid var(--accent);
|
||||||
|
border-radius:4px; background:var(--bg); color:var(--text); font-size:13px; outline:none; }
|
||||||
|
.badge { display:inline-block; font-size:11px; padding:2px 8px; border-radius:10px;
|
||||||
|
background:var(--green); color:#000; margin-left:6px; }
|
||||||
|
.empty { text-align:center; padding:60px 20px; color:var(--muted); }
|
||||||
|
.empty p { margin-bottom:8px; }
|
||||||
|
.toast { position:fixed; bottom:20px; right:20px; padding:10px 20px; border-radius:6px;
|
||||||
|
font-size:13px; color:#fff; opacity:0; transition:opacity .3s; z-index:100; }
|
||||||
|
.toast.show { opacity:1; }
|
||||||
|
.toast.ok { background:var(--green); }
|
||||||
|
.toast.err { background:var(--red); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🐦 Twitter 账号监控</h1>
|
||||||
|
<p class="sub">管理 twitterapi.io 监控账号 · 增删改查</p>
|
||||||
|
|
||||||
|
<div class="add-bar">
|
||||||
|
<input id="urlInput" type="text" placeholder="输入 Twitter/X 链接或用户名..." autofocus>
|
||||||
|
<button class="btn-add" onclick="addAccount()">➕ 添加</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="list"></div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/twitter/api/accounts';
|
||||||
|
let editing = null;
|
||||||
|
|
||||||
|
async function api(method, path='', body=null) {
|
||||||
|
const opts = { method, headers:{} };
|
||||||
|
if (body) { opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(body); }
|
||||||
|
const r = await fetch(API + path, opts);
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) throw new Error(data.error || '请求失败');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, ok=true) {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg; t.className = 'toast ' + (ok?'ok':'err') + ' show';
|
||||||
|
setTimeout(() => t.classList.remove('show'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const data = await api('GET');
|
||||||
|
const div = document.getElementById('list');
|
||||||
|
if (!data.accounts.length) {
|
||||||
|
div.innerHTML = '<div class="empty"><p>📭 暂无监控账号</p><p style="font-size:12px;color:var(--muted)">在上方输入 Twitter/X 链接或用户名添加</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
div.innerHTML = data.accounts.map(a => `
|
||||||
|
<div class="account" id="row-${a.username}">
|
||||||
|
${editing===a.username ? `
|
||||||
|
<div class="edit-row">
|
||||||
|
<input id="editInput" value="${esc(a.display_name || a.username)}" placeholder="备注名称">
|
||||||
|
<button class="btn-save" onclick="saveEdit('${esc(a.username)}')">保存</button>
|
||||||
|
<button class="btn-cancel" onclick="cancelEdit()">取消</button>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="name">
|
||||||
|
<a href="https://x.com/${esc(a.username)}" target="_blank">@${esc(a.username)}</a>
|
||||||
|
${a.display_name && a.display_name !== a.username ? `<span style="color:var(--text)">(${esc(a.display_name)})</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
添加: ${a.added_at?.slice(0,10) || '?'}
|
||||||
|
${a.last_check ? ` · 上次检查: ${a.last_check}` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-edit" onclick="startEdit('${esc(a.username)}','${esc(a.display_name||a.username)}')">✏️</button>
|
||||||
|
<button class="btn-del" onclick="removeAccount('${esc(a.username)}')">🗑</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return s.replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>').replace(/'/g,'''); }
|
||||||
|
|
||||||
|
async function addAccount() {
|
||||||
|
const inp = document.getElementById('urlInput');
|
||||||
|
const val = inp.value.trim();
|
||||||
|
if (!val) { toast('请输入链接或用户名', false); return; }
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '', {url: val});
|
||||||
|
toast(r.message || '添加成功');
|
||||||
|
inp.value = '';
|
||||||
|
load();
|
||||||
|
} catch(e) { toast(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAccount(username) {
|
||||||
|
if (!confirm(`确定删除 @${username}?`)) return;
|
||||||
|
try {
|
||||||
|
const r = await api('DELETE', '/' + username);
|
||||||
|
toast(r.message || '已删除');
|
||||||
|
load();
|
||||||
|
} catch(e) { toast(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(username, name) {
|
||||||
|
editing = username;
|
||||||
|
load();
|
||||||
|
setTimeout(() => {
|
||||||
|
const inp = document.getElementById('editInput');
|
||||||
|
if (inp) { inp.focus(); inp.select(); }
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() { editing = null; load(); }
|
||||||
|
|
||||||
|
async function saveEdit(username) {
|
||||||
|
const val = document.getElementById('editInput').value.trim();
|
||||||
|
editing = null;
|
||||||
|
try {
|
||||||
|
const r = await api('PUT', '/' + username, {display_name: val});
|
||||||
|
toast(r.message || '已更新');
|
||||||
|
load();
|
||||||
|
} catch(e) { toast(e.message, false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('urlInput').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') addAccount();
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass # silent
|
||||||
|
|
||||||
|
def _send(self, code, body, content_type="application/json"):
|
||||||
|
body = body.encode() if isinstance(body, str) else json.dumps(body, ensure_ascii=False).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", content_type + "; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _json(self, code, data):
|
||||||
|
self._send(code, data)
|
||||||
|
|
||||||
|
def _error(self, code, msg):
|
||||||
|
self._json(code, {"error": msg})
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/" or path == "/index.html":
|
||||||
|
self._send(200, HTML, "text/html")
|
||||||
|
return
|
||||||
|
if path.startswith("/api/accounts"):
|
||||||
|
username = path[len("/api/accounts"):].strip("/")
|
||||||
|
if username:
|
||||||
|
# GET /api/accounts/<username> — single account
|
||||||
|
users = get_watchlist()
|
||||||
|
state = get_state()
|
||||||
|
for u in users:
|
||||||
|
if u["username"].lower() == username.lower():
|
||||||
|
entry = dict(u)
|
||||||
|
entry["last_check"] = state.get(u["username"], {}).get("last_check")
|
||||||
|
self._json(200, entry)
|
||||||
|
return
|
||||||
|
self._error(404, "账号不存在")
|
||||||
|
return
|
||||||
|
# GET /api/accounts — list all
|
||||||
|
users = get_watchlist()
|
||||||
|
state = get_state()
|
||||||
|
accounts = []
|
||||||
|
for u in users:
|
||||||
|
entry = dict(u)
|
||||||
|
sc = state.get(u["username"], {})
|
||||||
|
ts = sc.get("last_check")
|
||||||
|
if ts:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(ts).strftime("%m-%d %H:%M")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
ts = "从未"
|
||||||
|
entry["last_check"] = ts
|
||||||
|
accounts.append(entry)
|
||||||
|
self._json(200, {"accounts": accounts})
|
||||||
|
else:
|
||||||
|
self._error(404, "Not Found")
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path != "/api/accounts":
|
||||||
|
self._error(404, "Not Found")
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = json.loads(self.rfile.read(length)) if length else {}
|
||||||
|
url = body.get("url", "").strip()
|
||||||
|
if not url:
|
||||||
|
self._error(400, "缺少 url 参数")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
username = extract_username(url)
|
||||||
|
except ValueError:
|
||||||
|
self._error(400, "无法从输入中提取用户名,请输入 Twitter/X 链接或 @用户名")
|
||||||
|
return
|
||||||
|
|
||||||
|
users = get_watchlist()
|
||||||
|
if any(u["username"].lower() == username.lower() for u in users):
|
||||||
|
self._error(409, f"@{username} 已在监控列表中")
|
||||||
|
return
|
||||||
|
|
||||||
|
display_name = body.get("display_name", "").strip() or username
|
||||||
|
users.append({
|
||||||
|
"username": username,
|
||||||
|
"display_name": display_name,
|
||||||
|
"added_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
save_watchlist(users)
|
||||||
|
self._json(201, {"message": f"✅ 已添加 @{username}", "username": username})
|
||||||
|
|
||||||
|
def do_PUT(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
username = path[len("/api/accounts"):].strip("/")
|
||||||
|
if not username:
|
||||||
|
self._error(400, "缺少用户名")
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = json.loads(self.rfile.read(length)) if length else {}
|
||||||
|
display_name = body.get("display_name", "").strip()
|
||||||
|
|
||||||
|
users = get_watchlist()
|
||||||
|
for u in users:
|
||||||
|
if u["username"].lower() == username.lower():
|
||||||
|
if display_name:
|
||||||
|
u["display_name"] = display_name
|
||||||
|
save_watchlist(users)
|
||||||
|
self._json(200, {"message": f"✅ @{username} 已更新"})
|
||||||
|
return
|
||||||
|
self._error(404, "账号不存在")
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
username = path[len("/api/accounts"):].strip("/")
|
||||||
|
if not username:
|
||||||
|
self._error(400, "缺少用户名")
|
||||||
|
return
|
||||||
|
users = get_watchlist()
|
||||||
|
before = len(users)
|
||||||
|
users = [u for u in users if u["username"].lower() != username.lower()]
|
||||||
|
if len(users) < before:
|
||||||
|
save_watchlist(users)
|
||||||
|
self._json(200, {"message": f"🗑 已移除 @{username}"})
|
||||||
|
else:
|
||||||
|
self._error(404, "账号不存在")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"🐦 Twitter 监控管理: http://0.0.0.0:{PORT}")
|
||||||
|
server = HTTPServer(("0.0.0.0", PORT), Handler)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n已停止")
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""缠论引擎包:core(结构)/ indicators(指标)/ analysis(接合)/ pipeline(编排)。"""
|
||||||
|
|
||||||
|
from .pipeline.ChanLun import ChanLun
|
||||||
|
from .pipeline.TF_DF import TF_DF
|
||||||
|
|
||||||
|
__all__ = ["ChanLun", "TF_DF"]
|
||||||
@@ -6,20 +6,22 @@ sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan"))
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX
|
from ..core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX
|
||||||
from chanlun.core.ChanKLU import ChanKLU
|
from ..core.ChanKLU import ChanKLU
|
||||||
from chanlun.core.ChanKLC import ChanKLC
|
from ..core.ChanKLC import ChanKLC
|
||||||
from chanlun.core.ChanBI import ChanBI
|
from ..core.ChanBI import ChanBI
|
||||||
from chanlun.core.ChanSBI import ChanSBI
|
from ..core.ChanSBI import ChanSBI
|
||||||
from chanlun.core.ChanSEG import ChanSEG
|
from ..core.ChanSEG import ChanSEG
|
||||||
from chanlun.core.ChanZS import ChanZS
|
from ..core.ChanZS import ChanZS
|
||||||
from chanlun.core.ChanBSP import ChanBSP
|
from ..core.ChanBSP import ChanBSP
|
||||||
|
import talib.abstract as ta
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from matplotlib.dates import DateFormatter, date2num
|
from matplotlib.dates import DateFormatter, date2num
|
||||||
import matplotlib.patches as patches
|
import matplotlib.patches as patches
|
||||||
|
from technical.util import resample_to_interval
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from chanlun.pipeline.orchestrator import ChanLun
|
from ..pipeline.ChanLun import ChanLun
|
||||||
import xgboost as xgb
|
import xgboost as xgb
|
||||||
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report
|
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from chanlun.core.ChanKLU import ChanKLU
|
from ..core.ChanKLU import ChanKLU
|
||||||
from chanlun.core.ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE
|
from ..core.ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE
|
||||||
from chanlun.indicators.ChanMACDSeg import ChanMACDSeg
|
from .ChanMACDSeg import ChanMACDSeg
|
||||||
from chanlun.indicators.ChanMACDUnitTF import ChanMACDUnitTF
|
from .ChanMACDUnitTF import ChanMACDUnitTF
|
||||||
from chanlun.indicators.ChanMACDHistSet import ChanMACDHistSet
|
from .ChanMACDHistSet import ChanMACDHistSet
|
||||||
|
|
||||||
class ChanMACD():
|
class ChanMACD():
|
||||||
def __init__(self, klu_list: list[ChanKLU]):
|
def __init__(self, klu_list: list[ChanKLU]):
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from chanlun.core.ChanEnum import Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACD_STATE
|
from ..core.ChanEnum import Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACD_STATE
|
||||||
|
|
||||||
class ChanMACDHistSet():
|
class ChanMACDHistSet():
|
||||||
def __init__(self, index, start_time, start_klu, pre_histset, dir):
|
def __init__(self, index, start_time, start_klu, pre_histset, dir):
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from chanlun.core.ChanEnum import Chan_MACDSEG_DIR
|
from ..core.ChanEnum import Chan_MACDSEG_DIR
|
||||||
|
|
||||||
|
|
||||||
class ChanMACDSeg():
|
class ChanMACDSeg():
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from chanlun.core.ChanEnum import Chan_MACD_STATE, Chan_MACDUNITTF_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACDUNITTF_TYPE
|
from ..core.ChanEnum import Chan_MACD_STATE, Chan_MACDUNITTF_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACDUNITTF_TYPE
|
||||||
|
|
||||||
|
|
||||||
class ChanMACDUnitTF():
|
class ChanMACDUnitTF():
|
||||||
@@ -1,20 +1,96 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
#sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/chan.py"))
|
|
||||||
sys.path.append(os.path.abspath("/Users/jack/Project/chan.py"))
|
|
||||||
from Chan import CChan
|
|
||||||
from BuySellPoint.BS_Point import CBS_Point
|
|
||||||
from ChanConfig import CChanConfig
|
|
||||||
from Common.CEnum import AUTYPE, DATA_SRC, KL_TYPE, DATA_FIELD, BSP_TYPE, FX_TYPE, BI_DIR, KLINE_DIR, SEG_DIR
|
|
||||||
from KLine.KLine_Unit import CKLine_Unit
|
|
||||||
from Common.CTime import CTime
|
|
||||||
from Common.func_util import kltype_lt_day, str2float
|
|
||||||
from Bi.Bi import CBi
|
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
# 外部 chan.py 与本仓库包名 `chan` 在 macOS 大小写不敏感磁盘上冲突。
|
||||||
|
# 临时卸下本包后再导入上游,再恢复本包。
|
||||||
|
_EXT_ROOT = os.path.abspath("/Users/jack/Project/chan.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_external_chan():
|
||||||
|
saved = {}
|
||||||
|
for key in list(sys.modules):
|
||||||
|
low = key.lower()
|
||||||
|
if low == "chan" or low.startswith("chan."):
|
||||||
|
saved[key] = sys.modules.pop(key)
|
||||||
|
inserted = False
|
||||||
|
if _EXT_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _EXT_ROOT)
|
||||||
|
inserted = True
|
||||||
|
try:
|
||||||
|
from Chan import CChan as _CChan
|
||||||
|
from BuySellPoint.BS_Point import CBS_Point as _CBS_Point
|
||||||
|
from ChanConfig import CChanConfig as _CChanConfig
|
||||||
|
from Common.CEnum import (
|
||||||
|
AUTYPE as _AUTYPE,
|
||||||
|
DATA_SRC as _DATA_SRC,
|
||||||
|
KL_TYPE as _KL_TYPE,
|
||||||
|
DATA_FIELD as _DATA_FIELD,
|
||||||
|
BSP_TYPE as _BSP_TYPE,
|
||||||
|
FX_TYPE as _FX_TYPE,
|
||||||
|
BI_DIR as _BI_DIR,
|
||||||
|
KLINE_DIR as _KLINE_DIR,
|
||||||
|
SEG_DIR as _SEG_DIR,
|
||||||
|
)
|
||||||
|
from KLine.KLine_Unit import CKLine_Unit as _CKLine_Unit
|
||||||
|
from Common.CTime import CTime as _CTime
|
||||||
|
from Common.func_util import kltype_lt_day as _kltype_lt_day, str2float as _str2float
|
||||||
|
from Bi.Bi import CBi as _CBi
|
||||||
|
return {
|
||||||
|
"CChan": _CChan,
|
||||||
|
"CBS_Point": _CBS_Point,
|
||||||
|
"CChanConfig": _CChanConfig,
|
||||||
|
"AUTYPE": _AUTYPE,
|
||||||
|
"DATA_SRC": _DATA_SRC,
|
||||||
|
"KL_TYPE": _KL_TYPE,
|
||||||
|
"DATA_FIELD": _DATA_FIELD,
|
||||||
|
"BSP_TYPE": _BSP_TYPE,
|
||||||
|
"FX_TYPE": _FX_TYPE,
|
||||||
|
"BI_DIR": _BI_DIR,
|
||||||
|
"KLINE_DIR": _KLINE_DIR,
|
||||||
|
"SEG_DIR": _SEG_DIR,
|
||||||
|
"CKLine_Unit": _CKLine_Unit,
|
||||||
|
"CTime": _CTime,
|
||||||
|
"kltype_lt_day": _kltype_lt_day,
|
||||||
|
"str2float": _str2float,
|
||||||
|
"CBi": _CBi,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
# 清除上游以 Chan/chan 注册的模块,避免污染本包
|
||||||
|
for key in list(sys.modules):
|
||||||
|
low = key.lower()
|
||||||
|
if low == "chan" or low.startswith("chan."):
|
||||||
|
sys.modules.pop(key, None)
|
||||||
|
sys.modules.update(saved)
|
||||||
|
if inserted and _EXT_ROOT in sys.path:
|
||||||
|
try:
|
||||||
|
sys.path.remove(_EXT_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_ext = _load_external_chan()
|
||||||
|
CChan = _ext["CChan"]
|
||||||
|
CBS_Point = _ext["CBS_Point"]
|
||||||
|
CChanConfig = _ext["CChanConfig"]
|
||||||
|
AUTYPE = _ext["AUTYPE"]
|
||||||
|
DATA_SRC = _ext["DATA_SRC"]
|
||||||
|
KL_TYPE = _ext["KL_TYPE"]
|
||||||
|
DATA_FIELD = _ext["DATA_FIELD"]
|
||||||
|
BSP_TYPE = _ext["BSP_TYPE"]
|
||||||
|
FX_TYPE = _ext["FX_TYPE"]
|
||||||
|
BI_DIR = _ext["BI_DIR"]
|
||||||
|
KLINE_DIR = _ext["KLINE_DIR"]
|
||||||
|
SEG_DIR = _ext["SEG_DIR"]
|
||||||
|
CKLine_Unit = _ext["CKLine_Unit"]
|
||||||
|
CTime = _ext["CTime"]
|
||||||
|
kltype_lt_day = _ext["kltype_lt_day"]
|
||||||
|
str2float = _ext["str2float"]
|
||||||
|
CBi = _ext["CBi"]
|
||||||
|
|
||||||
|
|
||||||
def GetColumnNameFromFieldList(fileds: str):
|
def GetColumnNameFromFieldList(fileds: str):
|
||||||
_dict = {
|
_dict = {
|
||||||
@@ -9,7 +9,7 @@ Feature 描述中枢内部结构,Label 记录中枢后实际演化。
|
|||||||
import math
|
import math
|
||||||
import json
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from chanlun.core.ChanEnum import Chan_BI_DIR
|
from ..core.ChanEnum import Chan_BI_DIR
|
||||||
|
|
||||||
|
|
||||||
class ChanPivotClassifier:
|
class ChanPivotClassifier:
|
||||||
@@ -9,7 +9,7 @@ shift / contraction / duration。
|
|||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from chanlun.analysis.ChanPivotClassifier import ChanPivotClassifier
|
from .ChanPivotClassifier import ChanPivotClassifier
|
||||||
|
|
||||||
|
|
||||||
class ChanPivotMonitor:
|
class ChanPivotMonitor:
|
||||||
@@ -2,7 +2,7 @@ import ccxt
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import mplfinance as mpf
|
import mplfinance as mpf
|
||||||
from chanlun.indicators import ta
|
from talib import MACD, SMA
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
@@ -249,9 +249,8 @@ def analyze_higher_timeframe(df_30m):
|
|||||||
# 8. Back-divergence detection (enhanced)
|
# 8. Back-divergence detection (enhanced)
|
||||||
def detect_back_divergence(df, strokes, higher_trend):
|
def detect_back_divergence(df, strokes, higher_trend):
|
||||||
try:
|
try:
|
||||||
macd_df = ta.MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
|
macd, signal, hist = MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
|
||||||
macd, hist = macd_df['macd'], macd_df['macdhist']
|
sma20 = SMA(df['Close'], timeperiod=20)
|
||||||
sma20 = ta.SMA(df['Close'], timeperiod=20)
|
|
||||||
df['macd'] = macd
|
df['macd'] = macd
|
||||||
df['hist'] = hist
|
df['hist'] = hist
|
||||||
df['sma20'] = sma20
|
df['sma20'] = sma20
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""分析层:结构 + 指标的接合(MACD 状态、买卖点确认、Zone 等)。"""
|
||||||
|
|
||||||
|
from .bsp_macd import (
|
||||||
|
ConfirmedBSP,
|
||||||
|
bi_macd_area,
|
||||||
|
check_bi_div,
|
||||||
|
check_bi_pair_div,
|
||||||
|
confirm_bsp,
|
||||||
|
)
|
||||||
|
from .ChanMACD import ChanMACD
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChanMACD",
|
||||||
|
"ConfirmedBSP",
|
||||||
|
"bi_macd_area",
|
||||||
|
"check_bi_div",
|
||||||
|
"check_bi_pair_div",
|
||||||
|
"confirm_bsp",
|
||||||
|
]
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""买卖点 × MACD:几何候选在 core,背驰确认在此接合。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, List, Optional, Sequence
|
||||||
|
|
||||||
|
from ..core.ChanBI import ChanBI
|
||||||
|
from ..core.ChanBSP import ChanBSP
|
||||||
|
from ..core.ChanEnum import Chan_BI_DIR, Chan_BSP_DIR, Chan_BSP_TYPE
|
||||||
|
from ..indicators.store import IndicatorStore
|
||||||
|
|
||||||
|
|
||||||
|
def bi_macd_area(bi: ChanBI, store: Optional[IndicatorStore] = None) -> float:
|
||||||
|
"""笔内同向 macdhist 累积面积。优先用 IndicatorStore,否则回退 klu.macdhist。"""
|
||||||
|
area = 0.0
|
||||||
|
for klc in bi.klc_list:
|
||||||
|
for klu in klc.klu_list:
|
||||||
|
if store is not None:
|
||||||
|
hist = store.get(klu.idx, "macdhist", 0) or 0
|
||||||
|
else:
|
||||||
|
hist = getattr(klu, "macdhist", 0) or 0
|
||||||
|
try:
|
||||||
|
hist = float(hist)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
hist = 0.0
|
||||||
|
if bi.dir == Chan_BI_DIR.UP and hist > 0:
|
||||||
|
area += hist
|
||||||
|
elif bi.dir == Chan_BI_DIR.DOWN and hist < 0:
|
||||||
|
area -= hist
|
||||||
|
return area
|
||||||
|
|
||||||
|
|
||||||
|
def check_bi_div(
|
||||||
|
zs,
|
||||||
|
leave_bi: ChanBI,
|
||||||
|
store: Optional[IndicatorStore] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""一类买卖点背驰:离开笔相对进入笔同向 MACD 柱面积收敛。"""
|
||||||
|
enter_bi = zs.bi_list[0].pre if zs.bi_list else None
|
||||||
|
if not enter_bi or enter_bi.dir != leave_bi.dir:
|
||||||
|
return False
|
||||||
|
leave_area = abs(bi_macd_area(leave_bi, store))
|
||||||
|
enter_area = abs(bi_macd_area(enter_bi, store))
|
||||||
|
return leave_area < enter_area
|
||||||
|
|
||||||
|
|
||||||
|
def check_bi_pair_div(
|
||||||
|
leave_bi: ChanBI,
|
||||||
|
compare_bi: ChanBI,
|
||||||
|
store: Optional[IndicatorStore] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""两笔同向力度比较(离开笔面积 < 比较笔)。"""
|
||||||
|
leave_area = abs(bi_macd_area(leave_bi, store))
|
||||||
|
compare_area = abs(bi_macd_area(compare_bi, store))
|
||||||
|
return leave_area < compare_area
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConfirmedBSP:
|
||||||
|
"""几何买卖点 + MACD 确认结果。"""
|
||||||
|
|
||||||
|
bsp: ChanBSP
|
||||||
|
div_confirmed: bool
|
||||||
|
hist_area: float = 0.0
|
||||||
|
compare_hist_area: float = 0.0
|
||||||
|
score: float = 0.0
|
||||||
|
macd_state: Any = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> Chan_BSP_TYPE:
|
||||||
|
return self.bsp.type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dir(self) -> Chan_BSP_DIR:
|
||||||
|
return self.bsp.dir
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bi(self) -> ChanBI:
|
||||||
|
return self.bsp.bi
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_bsp(
|
||||||
|
geo_bsp_list: Sequence[ChanBSP],
|
||||||
|
store: Optional[IndicatorStore] = None,
|
||||||
|
require_div_for_types: Optional[Sequence[Chan_BSP_TYPE]] = None,
|
||||||
|
) -> List[ConfirmedBSP]:
|
||||||
|
"""
|
||||||
|
将几何 BSP 升格为 ConfirmedBSP。
|
||||||
|
默认:B1/S1/T1 需要背驰确认;B3/S3 几何即可,div_confirmed=True。
|
||||||
|
"""
|
||||||
|
if require_div_for_types is None:
|
||||||
|
require_div_for_types = (
|
||||||
|
Chan_BSP_TYPE.B1,
|
||||||
|
Chan_BSP_TYPE.S1,
|
||||||
|
)
|
||||||
|
out: List[ConfirmedBSP] = []
|
||||||
|
for bsp in geo_bsp_list:
|
||||||
|
area = bi_macd_area(bsp.bi, store)
|
||||||
|
need_div = bsp.type in require_div_for_types
|
||||||
|
if need_div and bsp.zs is not None:
|
||||||
|
div_ok = check_bi_div(bsp.zs, bsp.bi, store)
|
||||||
|
else:
|
||||||
|
div_ok = True
|
||||||
|
out.append(
|
||||||
|
ConfirmedBSP(
|
||||||
|
bsp=bsp,
|
||||||
|
div_confirmed=div_ok,
|
||||||
|
hist_area=area,
|
||||||
|
score=1.0 if div_ok else 0.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user