14 Commits
Author SHA1 Message Date
jackyu66git 542adad583 fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:15:14 +08:00
jackyu66git 29cff47f98 feat: 新增 ChanMacro 宏观 regime 检测模块 2026-08-20 16:03:25 +08:00
jackyu66gitandCursor 340676bfbd fix(web): 分型框竖边 canvas 绘制,换币对强制全量刷新
LWC 折线无法画真竖线;增量刷新时用坐标采样补刷竖边,避免与横边脱节。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:32:18 +08:00
jackyu66gitandCursor 9cf625c413 fix(web): 小周期切换时对齐标记,避免 LWC Value is null
主周期笔/KLC 分型标记在切到 1m/2m 主图时未对齐 K 线 time;过滤均线无效点并钳制视窗恢复。顺带统一 BI 中枢计算路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:06:31 +08:00
jackyu66gitandCursor 18a7f485e6 feat(web): 增量自动刷新、结构区修复与默认指标/周期
自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:45:40 +08:00
jackyu66gitandCursor 0f6eb92a1f test(ECR-009): 补页面/API 路由冒烟与 TEST_REPORT
交付前缺 Flask 常驻与路由断言;现补齐 pytest 与报告。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 16:03:37 +08:00
jackyu66gitandCursor 9880e236a5 docs(ECR-009): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:46:35 +08:00
jackyu66gitandCursor ec08de098e feat(ECR-009): Crypto Wyckoff Screener 独立页(D/W/M)
移植 A_Share_DP 引擎;本地缓存与 60s tip;月线由日线 UTC 聚合;不碰主站 analyze/缠论叠层。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:46:35 +08:00
jackyu66gitandCursor 6c627f009a docs(ECR-008): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:09:48 +08:00
jackyu66gitandCursor dbb6202325 feat(ECR-008): 拆分主站 chart_tv.js 为多模块薄门面
行为冻结物理拆分;保留 initTradingView/dispose 对外 API;无打包器。node --check 全绿。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:09:48 +08:00
jackyu66gitandCursor efad2bb333 docs(ECR-007): archive LOOP-RUN-005 and sync STATE
关门收尾:归档 loop/gate 产物至 docs/runs,同步 CURRENT/MEMORY/PROFILE,并忽略工作目录 .gates/loop。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 15:02:54 +08:00
jackyu66gitandCursor 2964d6f230 docs(ECR-007): mark LOOP-RUN-005 DONE after Final Approval
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:20:39 +08:00
jackyu66gitandCursor 7991a6b2bf docs(ECR-007): record implementation commit in TRACEABILITY
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:14:19 +08:00
jackyu66gitandCursor 276481e02c feat(ECR-007): Wyckoff Live Structure with Confirmed/Live isolation
Add live.py lifecycle and event candidates; assemble confirmed vs live
in engine; Summary partition; execution_signal source=confirmed only.
Keep strategies untouched; do not lower Confirmed thresholds for Live.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 03:14:19 +08:00
554 changed files with 40511 additions and 200497 deletions
+33 -43
View File
@@ -1,59 +1,49 @@
# 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 凭据。**不要提交** # ESS gate / engineering-loop working dirs(归档进 docs/runs/
research/live/deploy/tg.env .gates/
loop/
# 生产状态与信号总线。刻意放在仓库外(LIVE_HOME / BUS_DIR),这几条只防 # Crypto Wyckoff Screener local cache
# 有人把它们指回仓库里:里面是日亏损累计与已处理信号键,被 git clean data/crypto_wyckoff/
# 清掉等于两道闸静默失忆
/live/deploy/live.env
live_state.json
live_trades.jsonl
signals_live.jsonl
+33
View File
@@ -0,0 +1,33 @@
# chan — Agent Entry
本仓受 ESS 约束。不要一上来扫全库或加载全部 governance。
## Boot
1. `docs/PROJECT_PROFILE.md`
2. `docs/PROJECT_RULES.md`
3. `docs/STATE/CURRENT.md` + `docs/AGENT_MEMORY.md`
4. 有进行中任务再读 `docs/TASKS/` / 对应 ECR / HANDOFF
5. 角色文件:ESS 根目录 `agents/{ARCHITECT|ENGINEER|REVIEWER|RELEASE_MANAGER}.md`
## Roles(选一)
| 意图 | 角色 |
|------|------|
| 规格 / 架构 / ECR | ARCHITECT |
| 实现 / 修 bug | ENGINEER |
| 审阅 | REVIEWER |
| 发版 / tag | RELEASE_MANAGER |
## Never
- 无 ECR 改 `config/` / `strategies/` 交易逻辑
- 无 ADR 改缠论算法语义
- 无 ECR 删减 `/api/analyze` 字段
- 把聊天记录当成完成;阶段结束须落盘 `docs/`
## Pointers
- TRACEABILITY: `docs/TRACEABILITY.md`
- CHANGELOG: `docs/CHANGELOG/CHANGELOG.md`
- 人类向导:`CLAUDE.md`
+116
View File
@@ -0,0 +1,116 @@
# 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.
## Governance
- Agent 入口:`AGENTS.md`boot 顺序)· `docs/PROJECT_PROFILE.md` · `docs/AGENT_MEMORY.md` · `docs/STATE/CURRENT.md`
- ESS 文档:`docs/ECR/``docs/ENGINEERING_SPEC/``docs/TRACEABILITY.md``docs/CHANGELOG/`
- **正式引擎包**`chanlun/`strategies / web 已用 `from chanlun import ...`
- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用)
- 变更分级:无 ECR 不改 strategies/config;无 ADR 不改缠论算法语义
## Core Architecture
### Chan Theory Engine (`chanlun/`)
```text
chanlun/
core/ # KLU KLC BI SBI SEG ZS BIZS BSP Enum CTime
pipeline/ # orchestrator(ChanLun) + timeframe(TF_DF) + builders/
indicators/ # ChanMACD*
analysis/ # Zone Classifier Pivot Heng PY Find_Trend ...
```
Data processing pipeline (each step feeds the next):
1. **`chanlun.core.ChanKLU`** — Raw K-line unit with TA indicators and pattern recognition
2. **`chanlun.core.ChanKLC`** — Combined K-line: inclusion + fractal; `.next`/`.pre` linked list
3. **`chanlun.core.ChanBI`** — Stroke (笔)
4. **`chanlun.core.ChanSBI`** — Special stroke → SEG
5. **`chanlun.core.ChanSEG`** — Segment (线段)
6. **`chanlun.core.ChanZS`** / **`ChanBIZS`** — Centers (中枢)
7. **`chanlun.core.ChanBSP`** — Buy/Sell points
8. **`chanlun.pipeline.orchestrator.ChanLun`** — Orchestrator
9. **`chanlun.pipeline.timeframe.TF_DF`** — Timeframe facade;实现拆在 `pipeline/builders/`
### Services
- **外部 DATA_SERVICE** — 行情服务(env: `DATA_SERVICE_URL`);本仓库可不含 data_provider 源码
- **`web/`** — Flask UI`create_app()` + `api/` blueprints + `services/`;前端 `static/js/app/`。默认端口见 `web/config.py``FLASK_PORT`,常见 8128
- **`strategies/`** — Freqtrade strategies(本 ECR 不改)
- **`config/`** — Freqtrade configs(本 ECR 不改)
### Data Flow
```
Exchange / DATA_SERVICE → Freqtrade Strategy / web → ChanLun → TF_DF
→ KLU → KLC → BI → SBI → SEG → ZS → BSP
```
## 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
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanBI import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanBIZS import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanBSP import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanCTime import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanEnum import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanHeng import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanKLC import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanKLU import * # noqa: F403
+3
View File
@@ -0,0 +1,3 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.pipeline.orchestrator import ChanLun # noqa: F401
from chanlun.pipeline.timeframe import TF_DF # noqa: F401
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanLun_Classifier import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.indicators.ChanMACD import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.indicators.ChanMACDHistSet import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.indicators.ChanMACDSeg import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.indicators.ChanMACDUnitTF import * # noqa: F403
+12
View File
@@ -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-...
+1
View File
@@ -0,0 +1 @@
data/
+9
View File
@@ -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"
+224
View File
@@ -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)
+464
View File
@@ -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()
+23
View File
@@ -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
}
+113
View File
@@ -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()
+224
View File
@@ -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
+4
View File
@@ -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
+55
View File
@@ -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)
+295
View File
@@ -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
+271
View File
@@ -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}
+5
View File
@@ -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
+69
View File
@@ -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."""
...
+189
View File
@@ -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()
+66
View File
@@ -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
+157
View File
@@ -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
+17
View File
@@ -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()
+370
View File
@@ -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
+213
View File
@@ -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
View File
+8
View File
@@ -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
+11
View File
@@ -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
+153
View File
@@ -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
+6
View File
@@ -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
+28
View File
@@ -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."""
...
+218
View File
@@ -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)
+98
View File
@@ -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
+137
View File
@@ -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")
+248
View File
@@ -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"
+143
View File
@@ -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")
View File
+134
View File
@@ -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
+173
View File
@@ -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
+130
View File
@@ -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
+109
View File
@@ -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
+121
View File
@@ -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
+81
View File
@@ -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
)
+5
View File
@@ -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
+174
View File
@@ -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)
+192
View File
@@ -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),
}
+144
View File
@@ -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)
+120
View File
@@ -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)
View File
+178
View File
@@ -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)
+160
View File
@@ -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();
+163
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanPY import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanPivotClassifier import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanPivotMonitor import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanSBI import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanSEG import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanZS import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanZone import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.Chan_FX_Box import * # noqa: F403
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.Find_Trend import * # noqa: F403
-130
View File
@@ -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.11pandas 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——这些都**不在**依赖清单里,
是有意为之。要用得自行安装。
+2
View File
@@ -0,0 +1,2 @@
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.pipeline.timeframe import TF_DF # noqa: F401
View File
+2
View File
@@ -14,10 +14,12 @@ from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS from chanlun.core.ChanZS import ChanZS
from chanlun.core.ChanBSP import ChanBSP from chanlun.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 chanlun.pipeline.orchestrator import ChanLun
import xgboost as xgb import xgboost as xgb
+3 -4
View File
@@ -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
-384
View File
@@ -1,384 +0,0 @@
"""快速三类买卖点(引擎内称第四类,B4/S4)。
原本长在 `research/lib/fast_bsp3.py`现移入引擎作为唯一实现研究脚本改为转发导入
这样回测口径与 web 图表永远一致
命名说明缠论原文里没有第四类买卖点 B4/S4 也不只是B3/S3 提前几根
两者的选样口径不同统计性质符号相反故单独立类形态条件是实时可判的
中枢已成 -> 收盘突破 zg -> 收盘未跌回中枢 -> 重新上行
最后一步发生的当根就能下单滞后约 2 而引擎 B3/S3 要等 pullback_bi.sure_time
滞后 9~10 但差别不止滞后
判据 引擎用笔端点事后判回拉笔低点 >= zg本函数用收盘价实时判
方向 引擎由离开笔方向决定本函数由收盘从哪一侧突破决定
口径 627 个中枢里引擎发 625 个信号几乎不筛本函数只认 212 34%
被拒的多数是中枢确认时价格早已离开此后再没回来的历史区间
step30 同条件对拍同一套 pure 笔中枢同一组过滤器同样的 1.5/3.0/48 出场
原始 PF +大级别同向 +同向+阶梯 胜率 t值
引擎 B3/S3 0.66 0.66 0.71 27.4% -18.76
本函数 B4/S4 1.59 1.85 2.26 47.1% +10.09
同一组过滤器对 B4 有效 B3 无效滞后差解释不了这一点入场后移 1~4 根只是
PF 3.18->2.53 的平滑衰减 SL1.5/TP3.0 下随机入场胜率约 33%引擎那 27.4%
低于随机它选中的是一批系统性反向的样本不是晚了所以差
require_touch 控制是否强求回抽碰到中枢边界step32/33 的实测表明强求反而更差
这等于排除掉突破后一去不回头的强势段而那正是缠论里最强的趋势形态
故默认 False笔数 +21%滞后 -1 PF 2.262.37
判据本身收盘越过前一根极值没有独立预测力裸用 15 万笔样本 PF 0.95
把中枢换成近20根高点这类伪阻力位后 PF 0.90alpha 全部来自中枢结构
判据只负责在这个已知价位上确认动能恢复它也不能用于识别笔端点
入场前的回抽极值命中笔端点±2根的比例 19.3%低于随机基准 22%
全部判定只使用当根及之前的数据无未来函数
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from chanlun.core.ChanEnum import Chan_FX_TYPE
# 中枢的「可用时刻」取第几笔的确认时间。
# -1 = 最后一笔(历史默认)。中枢每吸收一根K线,最后一笔就可能后移,
# 于是 available_ts 跟着漂——这是右边缘重画的根因(见 HANDOFF §5.41)。
# 2 = 第三笔。三笔重叠即中枢成立,此后不再变,理论上更早也更稳。
# ⛔ step47 已判定 bis[2] 作废(毛 R 0.933 → 0.000,见 HANDOFF §5.42)。
# 开关保留只为可复现那次 A/B,**不要改默认值**。
import os as _os
AVAIL_BI_INDEX = -1
def _resolve_avail_bi(avail_bi: int | None) -> int:
"""优先级:显式入参 > 环境变量 CHAN_AVAIL_BI > 模块默认。
环境变量在调用时读取而非 import ProcessPoolExecutor fork 启动方式下
子进程会继承已 import 的模块import 时读就固化成父进程的值了
"""
if avail_bi is not None:
return avail_bi
return int(_os.environ.get("CHAN_AVAIL_BI", AVAIL_BI_INDEX))
def timestamps_ms(src: pd.DataFrame) -> np.ndarray:
"""取毫秒时间戳。研究侧的 df 自带 timestampweb 侧的不一定,故按 date 回退。
回退写法不能用 `date.astype("int64") // 10**6`该值单位取决于列精度
对毫秒精度的列会把时间戳砸平
"""
if "timestamp" in src.columns:
return src["timestamp"].to_numpy()
d = pd.to_datetime(src["date"])
if getattr(d.dt, "tz", None) is None:
d = d.dt.tz_localize("UTC")
return (d.dt.tz_convert("UTC").dt.tz_localize(None)
.astype("datetime64[ms]").astype("int64").to_numpy())
def ensure_timestamp(df: pd.DataFrame) -> pd.DataFrame:
"""保证 df 带 timestamp 列,缺失时补一份副本,不改动调用方的对象。"""
if "timestamp" in df.columns:
return df
out = df.copy()
out["timestamp"] = timestamps_ms(out)
return out
def build_htf_zones(df_htf: pd.DataFrame, tf: str, chan=None, avail_bi: int | None = None) -> pd.DataFrame:
"""算 pure 笔中枢,返回带生效时间的区间表。
available_ts 该中枢最早可被使用的时间戳其确认时刻
传入已构建好的 chan 可避免重复跑一遍 pipeline大数据集上省一半时间
"""
if chan is None:
from chanlun import TF_DF
chan = TF_DF(df_htf, 1, tf)
zs_list = chan.cal_bi_zs_list_pure(chan.bi_list)
# 用引擎自己的 dataframe 对齐,避免调用方传入的 df 与引擎内部行数不一致
src = chan.dataframe if getattr(chan, "dataframe", None) is not None else df_htf
return zones_from_zs_list(zs_list, src, avail_bi=avail_bi)
def zones_from_zs_list(zs_list, src: pd.DataFrame, avail_bi: int | None = None) -> pd.DataFrame:
"""把已算好的 pure 笔中枢转成区间表。
调用方手工跑过 cal_bi_zs_list_pure 时走这里免得再算一遍web analyze_chan
就是这种用法
"""
ts_of = dict(zip(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"), timestamps_ms(src)))
# 中枢可用时刻取第几笔。在循环外解析一次,别让每个中枢都去读一遍环境变量。
i = _resolve_avail_bi(avail_bi)
rows = []
for zs in zs_list:
bis = getattr(zs, "bi_list", [])
if not bis:
continue
# 笔数不够时退回最后一笔(i=2 需要至少 3 笔才成立)
key_bi = bis[i] if (i == -1 or len(bis) > i) else bis[-1]
sure_key = str(getattr(key_bi, "sure_time", "") or "")
end_key = str(getattr(key_bi, "end_time", "") or "")
avail = ts_of.get(sure_key) or ts_of.get(end_key)
if avail is None:
continue
start_key = str(bis[0].start_time)
rows.append({
"zg": float(zs.zg), "zd": float(zs.zd),
"gg": float(getattr(zs, "gg", zs.zg)), "dd": float(getattr(zs, "dd", zs.zd)),
"start_ts": ts_of.get(start_key, avail),
"available_ts": int(avail),
})
out = pd.DataFrame(rows)
return out.sort_values("available_ts").reset_index(drop=True) if not out.empty else out
def add_zone_ladder(zones: pd.DataFrame) -> pd.DataFrame:
"""标注每个中枢相对前一个中枢是否同向推进(缠论「趋势 vs 盘整」)。
z_above / z_below 分别对应向上向下推进买信号要求 z_above卖信号要求 z_below
30m/2h PF 2.72 升到 3.41
"""
out = zones.copy()
if out.empty:
out["z_above"], out["z_below"] = pd.Series(dtype=bool), pd.Series(dtype=bool)
return out
pg, pdn = out["zg"].shift(), out["zd"].shift()
out["z_above"] = (out["zd"] > pg).fillna(False)
out["z_below"] = (out["zg"] < pdn).fillna(False)
return out
def htf_fx_timeline(chan_htf, df_htf: pd.DataFrame | None = None) -> pd.DataFrame:
"""把大级别分型压成一条按确认时间排序的时间线。
confirm_ts 是该分型最早可被使用的时刻timestamp 是K线开盘时刻而分型要等这根K线
收盘才算数所以整体后移一个大级别周期否则小级别会提前一整根大级别K线拿到信号
只取方向与确认时刻同向过滤用不到背驰强度省掉 MACD 面积计算
"""
src = chan_htf.dataframe if getattr(chan_htf, "dataframe", None) is not None else df_htf
if src is None or len(src) == 0:
return pd.DataFrame(columns=["confirm_ts", "fx_ts", "direction", "price"])
ts = timestamps_ms(src)
idx_of = {t: i for i, t in enumerate(src["date"].dt.strftime("%Y-%m-%d %H:%M:%S"))}
period = int(np.median(np.diff(ts))) if len(ts) > 1 else 0
rows = []
for klc in getattr(chan_htf, "klc_list", []):
if klc.fx not in (Chan_FX_TYPE.TOP, Chan_FX_TYPE.BOTTOM):
continue
if klc.next is None or klc.next.end_klu is None:
continue
e_key, c_key = str(klc.end_time), str(klc.next.end_klu.time)
if e_key not in idx_of or c_key not in idx_of:
continue
fx_idx, confirm_idx = idx_of[e_key], idx_of[c_key]
if confirm_idx <= fx_idx:
continue
d = 1 if klc.fx == Chan_FX_TYPE.BOTTOM else -1
rows.append({
"confirm_ts": int(ts[confirm_idx]) + period,
"fx_ts": int(ts[fx_idx]),
"direction": d,
"price": float(klc.low if d == 1 else klc.high),
})
out = pd.DataFrame(rows)
if out.empty:
return pd.DataFrame(columns=["confirm_ts", "fx_ts", "direction", "price"])
return out.sort_values("confirm_ts").reset_index(drop=True)
def attach_htf_agree(sig: pd.DataFrame, df_ltf: pd.DataFrame, tl: pd.DataFrame) -> pd.DataFrame:
"""给每个小级别信号挂上「入场时刻之前最近的大级别分型」是否同向。
产出 htf_dir+1 / -1 htf_agree1 同向 / 0 反向 / NaN 无可用分型
"""
out = sig.copy()
if sig.empty or tl.empty:
out["htf_dir"] = np.nan
out["htf_agree"] = np.nan
return out
ts_ltf = timestamps_ms(df_ltf)
entry_ts = ts_ltf[out["entry_idx"].to_numpy().astype(int)]
k = np.searchsorted(tl["confirm_ts"].to_numpy(), entry_ts, side="right") - 1
valid = k >= 0
k_safe = np.clip(k, 0, len(tl) - 1)
fx_dir = tl["direction"].to_numpy()[k_safe].astype(float)
out["htf_dir"] = np.where(valid, fx_dir, np.nan)
out["htf_agree"] = np.where(
valid, (fx_dir == out["direction"].to_numpy()).astype(float), np.nan
)
return out
def attach_zone_ladder(sig: pd.DataFrame, zones: pd.DataFrame) -> pd.DataFrame:
"""按信号方向取该中枢的阶梯标记:买看 z_above、卖看 z_below。
zone_i find_fast_bsp3 enumerate 出的位置序号故用 iloc 定位
"""
out = sig.copy()
if sig.empty:
out["ladder_ok"] = pd.Series(dtype=bool)
return out
z = zones if "z_above" in zones.columns else add_zone_ladder(zones)
above = z["z_above"].to_numpy()
below = z["z_below"].to_numpy()
zi = out["zone_i"].to_numpy().astype(int)
ok = np.where(out["direction"].to_numpy() == 1, above[zi], below[zi])
out["ladder_ok"] = ok.astype(bool)
return out
def find_fast_bsp3(
df: pd.DataFrame,
zones: pd.DataFrame,
scan: int = 200,
pullback_win: int = 30,
tol: float = -1.0,
max_per_zone: int = 1,
diag: dict | None = None,
require_touch: bool = False,
) -> pd.DataFrame:
"""扫描每个中枢,找突破后回抽不回中枢的入场点。
zones 需含 zg / zd / available_ts available_ts 已是可用时刻
max_per_zone > 1 同一中枢在首次入场后继续往后找二次三次突破回抽
用来检验趋势里同一中枢反复给机会是否值得做
tol 算作回抽中的边界容差require_touch=False 时它不再是入场门槛
仅决定哪些K线被视为回抽中而跳过转强判定 tol 越大入场越晚
默认负值 = 完全禁用该跳过突破后每根都检查转强滞后压到 2.2
step34 实测滞后与收益严格单调9.9 PF1.88 / 5.8 2.37 / 2.2 2.86
返回列
entry_idx 实时可下单的K线
direction +1 三买 / -1 三卖
bo_idx 突破根
pb_idx 回抽极值根
lag entry_idx - bo_idx
depth 回抽深度相对中枢边界负值表示曾插入中枢
occ 这是该中枢的第几次入场
"""
if zones.empty:
return pd.DataFrame()
ts = df["timestamp"].to_numpy()
close = df["close"].to_numpy(dtype=float)
high = df["high"].to_numpy(dtype=float)
low = df["low"].to_numpy(dtype=float)
n = len(df)
rows = []
def note(key: str) -> None:
if diag is not None:
diag[key] = diag.get(key, 0) + 1
for zone_i, (_, z) in enumerate(zones.iterrows()):
note("中枢总数")
zg, zd = float(z["zg"]), float(z["zd"])
if zg <= zd:
note("×无效中枢")
continue
start = int(np.searchsorted(ts, z["available_ts"], side="left"))
if start >= n - 2:
note("×中枢太靠后")
continue
# 允许多次入场时按比例放宽扫描窗口,否则后几次机会会被窗口截断
scan_end = min(start + scan * max_per_zone, n)
cursor = start
for occ in range(1, max_per_zone + 1):
if cursor >= n - 2:
break
# 第一步:找突破。要求突破前确实待在中枢内,避免把远处的价格当突破。
was_inside = False
bo_idx, d = None, 0
for j in range(cursor, scan_end):
c = close[j]
if zd <= c <= zg:
was_inside = True
continue
if not was_inside:
continue
bo_idx, d = j, (1 if c > zg else -1)
break
if bo_idx is None:
if occ == 1:
note("×窗口内未突破")
break
edge = zg if d == 1 else zd
# 第二步:突破后监控回抽,回抽不跌回中枢且重新顺势 -> 入场
touched = False
pb_idx = None
pb_ext = None
entry_idx = None
fell_back = False
for j in range(bo_idx + 1, min(bo_idx + pullback_win + 1, n)):
# 收盘跌回中枢 -> 突破失效
if zd <= close[j] <= zg:
fell_back = True
break
# 回抽触及边界附近(允许 tol 的毛刺)
near = (low[j] <= edge * (1 + tol)) if d == 1 else (high[j] >= edge * (1 - tol))
if near:
touched = True
ext = low[j] if d == 1 else high[j]
if pb_ext is None or ((ext < pb_ext) if d == 1 else (ext > pb_ext)):
pb_ext, pb_idx = ext, j
continue
# 回抽后重新顺势:收盘创出前一根之上(三买)/ 之下(三卖)
# require_touch=False 时不强求回抽碰到中枢边界,
# 这样「突破后一去不回头」的强势段也能收进来。
if (touched and pb_idx is not None) or not require_touch:
go = close[j] > high[j - 1] if d == 1 else close[j] < low[j - 1]
if go:
entry_idx = j
break
if entry_idx is None:
if occ == 1:
note("×突破后跌回中枢" if fell_back
else "×回抽未触及边界" if not touched
else "×触及边界但未转强")
# 这次突破没走成,从突破点之后继续找下一次
cursor = bo_idx + 1
continue
if pb_ext is None:
# 未触及边界就转强(require_touch=False):取突破至入场间的实际极值
seg = slice(bo_idx + 1, entry_idx + 1)
pb_ext = float(low[seg].min() if d == 1 else high[seg].max())
pb_idx = int((low[seg].argmin() if d == 1 else high[seg].argmax())
+ bo_idx + 1)
if occ == 1:
note("√成交")
# 回抽深度:>0 表示未插入中枢,越大表示回抽越浅
depth = (pb_ext - zg) / zg if d == 1 else (zd - pb_ext) / zd
rows.append({
"entry_idx": entry_idx, "direction": d,
"bo_idx": bo_idx, "pb_idx": pb_idx,
"lag": entry_idx - bo_idx,
"depth": depth,
"zg": zg, "zd": zd,
"width_pct": (zg - zd) / close[bo_idx],
"occ": occ,
"zone_i": zone_i,
})
cursor = entry_idx + 1
out = pd.DataFrame(rows)
if out.empty:
return out
# 相邻中枢可能突破到同一根K线,同一时刻只能有一个仓位,保留最早成型的那个
return (out.sort_values(["entry_idx", "occ", "zone_i"])
.drop_duplicates("entry_idx", keep="first")
.reset_index(drop=True))
+7
View File
@@ -0,0 +1,7 @@
"""威科夫分析(启发式):交易区间 / 阶段 / 事件 / Volume Profile / Live。"""
from __future__ import annotations
from .engine import analyze_wyckoff
from .live import execution_signal_from_wyckoff
__all__ = ["analyze_wyckoff", "execution_signal_from_wyckoff"]
+196
View File
@@ -0,0 +1,196 @@
"""威科夫分析入口:Cycle → Phase → Event → VP + LiveMULTI-CYCLE / LIVE-STRUCTURE)。
range.py 只产 TradingRangeConfirmed events.pyLive live.py
cycles[0]=ACTIVE禁止 cycles[-1] active
Execution 只消费 Confirmed live.execution_signal_from_wyckoff
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import pandas as pd
from .events import build_phases, detect_bias_and_events
from .live import analyze_live_structure
from .range import detect_trading_ranges
from .volume_profile import compute_volume_profile
def _fmt_time(v) -> Optional[str]:
if v is None:
return None
if hasattr(v, "isoformat"):
try:
return v.isoformat()
except Exception:
pass
return str(v)
def _empty(vp_bins: int) -> Dict[str, Any]:
return {
"cycles": [],
"trading_range": None,
"bias": "unknown",
"phases": [],
"events": [],
"volume_profile": {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": vp_bins},
"volume_confirm": {"avg_volume": 0.0, "event_checks": {}},
"live": None,
}
def _confidence_for_confirmed(
tr: Dict[str, Any],
phases: List[Dict[str, Any]],
events: List[Dict[str, Any]],
) -> Dict[str, float]:
range_c = float(tr.get("range_confidence") or 0.5)
labels = {p.get("phase") for p in phases}
phase_c = 0.35
if "A" in labels and "B" in labels:
phase_c += 0.15
if "C" in labels:
phase_c += 0.2
if "D" in labels or "E" in labels:
phase_c += 0.15
phase_c = min(0.95, phase_c)
types = {e.get("type") for e in events}
event_c = 0.25
for t in ("Spring", "UTAD", "SOS", "SOW", "LPS", "LPSY"):
if t in types:
event_c += 0.12
event_c = min(0.95, event_c)
overall = 0.4 * range_c + 0.3 * phase_c + 0.3 * event_c
return {
"range": round(range_c, 3),
"phase": round(phase_c, 3),
"event": round(event_c, 3),
"overall": round(overall, 3),
}
def _build_cycle(
work: pd.DataFrame,
tr: Dict[str, Any],
cycle_id: int,
vp_bins: int,
) -> Dict[str, Any]:
bias, events, volume_confirm = detect_bias_and_events(work, tr)
phases = build_phases(work, tr, bias, events)
vp = compute_volume_profile(
work,
int(tr["abs_start_idx"]),
int(tr["abs_end_idx"]),
bin_count=vp_bins,
)
for ev in events:
ev["time"] = _fmt_time(ev.get("time"))
for ph in phases:
ph["start_time"] = _fmt_time(ph.get("start_time"))
ph["end_time"] = _fmt_time(ph.get("end_time"))
is_active = cycle_id == 0
trading_range = {
"start_time": _fmt_time(tr.get("start_time")),
"end_time": _fmt_time(tr.get("end_time")),
"high": float(tr["high"]),
"low": float(tr["low"]),
"mid": float(tr["mid"]),
"active": bool(is_active),
"bars": int(tr.get("bars", 0)),
}
conf = _confidence_for_confirmed(tr, phases, events)
# Live 层:仅 ACTIVE 周期做推演;历史周期归档为 COMPLETED
if is_active:
live = analyze_live_structure(
work, tr, confirmed_events=events, confirmed_phases=phases, bias=bias,
)
lifecycle = live.get("lifecycle") or "FORMING"
else:
live = None
lifecycle = "COMPLETED"
return {
"id": int(cycle_id),
"role": "latest" if is_active else "historical",
# MULTI-CYCLE:时间线角色
"status": "ACTIVE" if is_active else "HISTORICAL",
# LIVE-STRUCTURE:生命周期
"lifecycle": lifecycle,
"direction": "latest" if is_active else "historical",
"period": {
"start_time": _fmt_time(tr.get("start_time")),
"end_time": _fmt_time(tr.get("end_time")),
"bars": int(tr.get("bars", 0)),
},
"confidence": conf,
"trading_range": trading_range,
"bias": bias,
# 兼容旧读法:顶层 phases/events = confirmed
"phases": phases,
"events": events,
"confirmed": {
"phases": phases,
"events": events,
"volume_confirm": volume_confirm,
},
"live": live,
"volume_profile": vp,
"volume_confirm": volume_confirm,
}
def analyze_wyckoff(
df: pd.DataFrame,
lookback: int = 120,
vp_bins: int = 50,
min_bars: int = 24,
atr_mult: float = 1.2,
range_start_time=None,
prefer_start_time=None,
max_cycles: int = 8,
) -> Dict[str, Any]:
"""
多周期威科夫分析
cycles[0] = ACTIVE顶层 phases/events 只镜像 Confirmed
顶层 live 镜像 cycles[0].live
"""
empty = _empty(vp_bins)
if df is None or len(df) < 30:
return empty
if not all(c in df.columns for c in ("open", "high", "low", "close")):
return empty
work = df.copy()
if "volume" not in work.columns:
work["volume"] = 1.0
trs = detect_trading_ranges(
work,
lookback=lookback,
min_bars=max(8, int(min_bars)),
atr_mult=atr_mult,
max_cycles=max(1, min(8, int(max_cycles))),
prefer_start_time=prefer_start_time,
range_start_time=range_start_time,
)
if not trs:
return empty
cycles: List[Dict[str, Any]] = []
for i, tr in enumerate(trs):
cycles.append(_build_cycle(work, tr, cycle_id=i, vp_bins=vp_bins))
active = cycles[0]
return {
"cycles": cycles,
"trading_range": active["trading_range"],
"bias": active["bias"],
"phases": active["confirmed"]["phases"],
"events": active["confirmed"]["events"],
"volume_profile": active["volume_profile"],
"volume_confirm": active["volume_confirm"],
"live": active.get("live"),
"lifecycle": active.get("lifecycle"),
}
+369
View File
@@ -0,0 +1,369 @@
"""威科夫阶段与事件(启发式)。"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
def _bar_time(df: pd.DataFrame, i: int):
row = df.iloc[i]
if "date" in df.columns and pd.notna(row["date"]):
return row["date"]
if "timestamp" in df.columns:
return row["timestamp"]
return i
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
a = max(0, i - win + 1)
v = df["volume"].astype(float).iloc[a : i + 1]
m = float(v.mean()) if len(v) else 0.0
return m if m > 0 else 1.0
def detect_bias_and_events(
df: pd.DataFrame,
tr: Dict[str, Any],
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Any]]:
"""
返回 biaseventsvolume_confirm
Spring/UTAD 相对结构高低判定取区间内次低/次高剔除单根极值
避免箱体把假破低点吃进 lo 后永远刺不破从而无 C 阶段
"""
hi = float(tr["high"])
lo = float(tr["low"])
mid = float(tr["mid"])
tol = float(tr.get("tol") or (hi - lo) * 0.05)
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
events: List[Dict[str, Any]] = []
# 结构边界:用次低/次高作假破参照(至少 8 根才启用)
seg = df.iloc[s : e + 1]
event_lo, event_hi = lo, hi
if len(seg) >= 8:
lows = seg["low"].astype(float)
highs = seg["high"].astype(float)
# nsmallest(2) 的较大者 = 次低;nlargest(2) 的较小者 = 次高
event_lo = float(lows.nsmallest(min(2, len(lows))).iloc[-1])
event_hi = float(highs.nlargest(min(2, len(highs))).iloc[-1])
# 勿比公布箱沿更「松」:结构带应在箱内
event_lo = max(event_lo, lo)
event_hi = min(event_hi, hi)
# 若次低仍等于极值(多根同价),略抬参照便于识别收回
if abs(event_lo - lo) < 1e-12:
event_lo = lo + max(tol * 0.35, (hi - lo) * 0.02)
if abs(event_hi - hi) < 1e-12:
event_hi = hi - max(tol * 0.35, (hi - lo) * 0.02)
# 扫描区间内及之后(含 tail_reserve
scan_end = int(tr.get("abs_scan_end_idx", min(len(df) - 1, e + 15)))
scan_end = min(len(df) - 1, max(scan_end, e))
spring = None
utad = None
sos = None
sod = None # sign of weakness / distribution breakdown
lps = None
lpsy = None
for i in range(s + 2, scan_end + 1):
row = df.iloc[i]
low = float(row["low"])
high = float(row["high"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
# Spring: pierce below structural support then close back
if spring is None and low < event_lo - tol * 0.35 and close >= event_lo - tol * 0.35:
vol_ok = ratio <= 1.35 or (i + 1 <= scan_end and float(df.iloc[min(i + 1, scan_end)]["volume"]) / avg_v < 1.2)
spring = {
"type": "Spring",
"time": _bar_time(df, i),
"price": low,
"note": "假破下沿后收回",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# UTAD: pierce above structural resistance then close back
if utad is None and high > event_hi + tol * 0.35 and close <= event_hi + tol * 0.35:
vol_ok = ratio >= 0.8
utad = {
"type": "UTAD",
"time": _bar_time(df, i),
"price": high,
"note": "假破上沿后跌回",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# SOS: close above high with volume
if sos is None and close > hi + tol * 0.15:
vol_ok = ratio >= 1.15
sos = {
"type": "SOS",
"time": _bar_time(df, i),
"price": close,
"note": "放量上破交易区间",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# SOW / breakdown
if sod is None and close < lo - tol * 0.15:
vol_ok = ratio >= 1.15
sod = {
"type": "SOW",
"time": _bar_time(df, i),
"price": close,
"note": "放量下破交易区间",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
# LPS after SOS: pullback that holds above mid/high-band with lighter volume
if sos is not None:
si = int(sos["idx"])
for i in range(si + 1, min(len(df), si + 25)):
row = df.iloc[i]
low = float(row["low"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
if low >= mid - tol and close >= hi - tol * 2:
vol_ok = ratio <= 1.05
lps = {
"type": "LPS",
"time": _bar_time(df, i),
"price": low,
"note": "突破后缩量回踩不破",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
break
if sod is not None:
si = int(sod["idx"])
for i in range(si + 1, min(len(df), si + 25)):
row = df.iloc[i]
high = float(row["high"])
close = float(row["close"])
vol = float(row["volume"]) if "volume" in df.columns else 0.0
avg_v = _avg_vol(df, i)
ratio = vol / avg_v if avg_v else 0.0
if high <= mid + tol and close <= lo + tol * 2:
vol_ok = ratio <= 1.05
lpsy = {
"type": "LPSY",
"time": _bar_time(df, i),
"price": high,
"note": "下跌突破后缩量反抽不过",
"volume_ratio": round(ratio, 3),
"volume_ok": bool(vol_ok),
"idx": i,
}
break
# 冲突清理:已判定吸筹且有 SOS 时,丢弃更早的 UTAD(避免阶段/图面误导)
# 派发且有 SOW 时,丢弃更晚才合理的 Spring 假信号同理在偏置后再滤
keep = []
for ev in (spring, sos, lps, utad, sod, lpsy):
if not ev:
continue
keep.append(ev)
# bias(先算)
last_c = float(df["close"].iloc[-1])
bias = "unknown"
if sos and (not sod or int(sos.get("idx", 0)) >= int(sod.get("idx", 0))):
bias = "accumulation"
elif sod and (not sos or int(sod.get("idx", 0)) > int(sos.get("idx", 0))):
bias = "distribution"
elif spring and not utad:
bias = "accumulation"
elif utad and not spring:
bias = "distribution"
elif last_c >= mid:
bias = "accumulation"
else:
bias = "distribution"
filtered = []
for ev in keep:
if bias == "accumulation" and ev["type"] == "UTAD" and sos and int(ev["idx"]) <= int(sos["idx"]):
continue
if bias == "distribution" and ev["type"] == "Spring" and sod and int(ev["idx"]) <= int(sod["idx"]):
continue
filtered.append(ev)
events = [{k: v for k, v in ev.items() if k != "idx"} for ev in filtered]
avg_volume = float(df["volume"].astype(float).iloc[max(0, e - 20) : e + 1].mean()) if "volume" in df.columns else 0.0
volume_confirm = {
"avg_volume": avg_volume,
"event_checks": {ev["type"]: {"volume_ok": ev.get("volume_ok"), "volume_ratio": ev.get("volume_ratio")} for ev in events},
}
return bias, events, volume_confirm
def build_phases(
df: pd.DataFrame,
tr: Dict[str, Any],
bias: str,
events: List[Dict[str, Any]],
min_bars: int = 3,
) -> List[Dict[str, Any]]:
"""
按威科夫事件锚点切分 AE启发式
吸筹A停止 B筑底 C测试(Spring) D拉升(SOSLPS) E离开
派发A停止 B筑顶 C测试(UTAD) D派发(SOWLPSY) E离开
Spring/UTAD 若已有 SOS/SOW用突破前末次沿带测试补 C仍无则省略 C
"""
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
hi = float(tr["high"])
lo = float(tr["low"])
n_last = len(df) - 1
min_span = max(2, min_bars - 1)
range_len = max(1, e - s)
def _match_idx(t) -> Optional[int]:
if t is None:
return None
lo = max(0, s - 2)
hi = min(len(df), e + 40)
for i in range(lo, hi):
if _bar_time(df, i) == t:
return i
try:
tt = pd.Timestamp(t)
sample = None
if "date" in df.columns and len(df):
sample = df["date"].iloc[min(s, n_last)]
if sample is not None and getattr(sample, "tzinfo", None) is not None and tt.tzinfo is None:
tt = tt.tz_localize(sample.tzinfo)
for i in range(lo, hi):
bt = _bar_time(df, i)
try:
if abs((pd.Timestamp(bt) - tt).total_seconds()) <= 1:
return i
except Exception:
continue
except Exception:
pass
return None
event_idx: Dict[str, int] = {}
for ev in events:
idx = _match_idx(ev.get("time"))
if idx is not None:
event_idx[str(ev.get("type"))] = idx
accum = bias != "distribution"
if accum:
c_ev = event_idx.get("Spring")
d_ev = event_idx.get("SOS")
d_tail = event_idx.get("LPS") or d_ev
else:
c_ev = event_idx.get("UTAD")
d_ev = event_idx.get("SOW")
d_tail = event_idx.get("LPSY") or d_ev
# 有 D 无明确测试事件时:用突破前最后一次触及下/上沿作为 C(次级测试)
if c_ev is None and d_ev is not None:
band = lo + (hi - lo) * 0.28 if accum else hi - (hi - lo) * 0.28
for i in range(int(d_ev) - 1, s + 1, -1):
row = df.iloc[i]
if accum and float(row["low"]) <= band:
c_ev = i
break
if not accum and float(row["high"]) >= band:
c_ev = i
break
def _lab(phase: str) -> str:
if accum:
m = {"A": "A停止下跌", "B": "B筑底", "C": "C测试", "D": "D拉升", "E": "E离开"}
else:
m = {"A": "A停止上涨", "B": "B筑顶", "C": "C测试", "D": "D派发", "E": "E离开"}
return m.get(phase, phase)
a_end = s + max(min_bars, range_len // 5)
c_start = c_end = None
if c_ev is not None:
c_start = max(s, int(c_ev) - 1)
c_end = min(n_last, int(c_ev) + 1)
if d_ev is not None:
d_start = int(d_ev)
d_end = min(n_last, max(int(d_tail or d_ev), d_start) + max(min_bars, range_len // 8))
if d_tail is not None:
d_end = max(d_end, min(n_last, int(d_tail) + 1))
else:
d_start = d_end = None
if c_start is not None:
b_end = max(a_end + 1, c_start)
elif d_start is not None:
b_end = max(a_end + 1, d_start)
else:
b_end = max(a_end + 1, e)
if d_end is not None:
e_start = min(n_last, d_end)
e_end = n_last
else:
e_start = e_end = None
raw = [("A", s, a_end), ("B", a_end, b_end)]
if c_start is not None and c_end is not None:
raw.append(("C", c_start, c_end))
if d_start is not None and d_end is not None:
raw.append(("D", d_start, d_end))
if e_start is not None and e_end is not None and e_end > e_start:
raw.append(("E", e_start, e_end))
phases: List[Dict[str, Any]] = []
cursor = s
for phase, _a, _b in raw:
if cursor >= n_last:
break
a = max(int(_a), cursor)
b = int(max(int(_b), a))
need = 1 if phase == "C" else min_span
if b < a + need:
b = min(n_last, a + need)
b = int(np.clip(b, a, n_last))
if b < a:
continue
if phases and phases[-1].get("_a") == a and phases[-1].get("_b") == b:
continue
phases.append(
{
"phase": phase,
"label": _lab(phase),
"start_time": _bar_time(df, a),
"end_time": _bar_time(df, b),
"_a": a,
"_b": b,
}
)
cursor = b
for p in phases:
p.pop("_a", None)
p.pop("_b", None)
return phases
+258
View File
@@ -0,0 +1,258 @@
"""威科夫 Live / Developing 层(WYCKOFF-LIVE-STRUCTURE-001)。
独立于 Confirmed Engine不修改 events 确认条件不写入 confirmed.events
Execution 不得消费本模块输出
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set
import numpy as np
import pandas as pd
def _avg_vol(df: pd.DataFrame, i: int, win: int = 20) -> float:
a = max(0, i - win + 1)
v = df["volume"].astype(float).iloc[a : i + 1]
m = float(v.mean()) if len(v) else 0.0
return m if m > 0 else 1.0
def _empty_live() -> Dict[str, Any]:
return {
"lifecycle": "UNKNOWN",
"range_formation": None,
"phase_candidate": None,
"event_candidates": [],
"next_expected": None,
"confidence": {
"cycle": 0.0,
"phase": 0.0,
"event": 0.0,
"structure": 0.0,
"volume": 0.0,
"overall": 0.0,
},
"note": "",
}
def analyze_live_structure(
df: pd.DataFrame,
tr: Optional[Dict[str, Any]],
confirmed_events: Optional[List[Dict[str, Any]]] = None,
confirmed_phases: Optional[List[Dict[str, Any]]] = None,
bias: str = "unknown",
) -> Dict[str, Any]:
"""
基于当前 TradingRange 与已确认事件推演 Live candidates
confirmed_* 只读用于避免重复提示已确认事件不修改之
"""
out = _empty_live()
if df is None or len(df) < 20 or tr is None:
out["note"] = "insufficient structure"
return out
confirmed_events = confirmed_events or []
confirmed_phases = confirmed_phases or []
confirmed_types: Set[str] = {str(e.get("type")) for e in confirmed_events if e.get("type")}
s = int(tr["abs_start_idx"])
e = int(tr["abs_end_idx"])
scan_end = int(tr.get("abs_scan_end_idx", len(df) - 1))
scan_end = min(len(df) - 1, max(scan_end, e))
hi = float(tr["high"])
lo = float(tr["low"])
mid = float(tr["mid"])
tol = float(tr.get("tol") or (hi - lo) * 0.05)
atr = float(tr.get("atr") or max((hi - lo) * 0.2, 1e-9))
seg = df.iloc[s : e + 1]
if len(seg) < 8:
out["note"] = "range too short"
return out
# —— Range Formation(横盘 / 波动收敛)——
closes = seg["close"].astype(float)
highs = seg["high"].astype(float)
lows = seg["low"].astype(float)
vols = seg["volume"].astype(float) if "volume" in seg.columns else pd.Series([1.0] * len(seg))
half = max(4, len(seg) // 2)
vol_early = float(np.std(closes.iloc[:half])) if half > 1 else 0.0
vol_late = float(np.std(closes.iloc[-half:])) if half > 1 else 0.0
width = hi - lo
width_atr = width / atr if atr > 0 else 99.0
converging = vol_early > 1e-12 and vol_late < vol_early * 0.85
range_ok = 1.2 <= width_atr <= 10.0 and len(seg) >= 16
structure_score = 0.35
if range_ok:
structure_score += 0.25
if converging:
structure_score += 0.2
if width_atr <= 6.0:
structure_score += 0.1
structure_score = float(min(0.95, structure_score))
out["range_formation"] = {
"potential_trading_range": bool(range_ok),
"converging": bool(converging),
"width_atr": round(width_atr, 3),
"bars": int(len(seg)),
}
# —— 最近 K 形态(Phase C / Event candidates)——
i = scan_end
row = df.iloc[i]
o = float(row["open"])
h = float(row["high"])
l = float(row["low"])
c = float(row["close"])
rng = max(h - l, 1e-9)
lower_wick = min(o, c) - l
upper_wick = h - max(o, c)
avg_v = _avg_vol(df, i)
vol = float(row["volume"]) if "volume" in df.columns else avg_v
vol_ratio = vol / avg_v if avg_v else 1.0
volume_score = float(np.clip(1.1 - abs(vol_ratio - 1.0) * 0.35, 0.2, 0.95))
phase_candidate = None
phase_conf = 0.0
# Phase C:测低 + 下影 + 缩量(吸筹语境)
near_lo = l <= lo + tol * 1.2
test_low = l < mid and lower_wick >= rng * 0.35
vol_contract = vol_ratio <= 1.05
if bias != "distribution" and near_lo and test_low and vol_contract:
phase_candidate = "C"
phase_conf = 0.55 + (0.1 if lower_wick >= rng * 0.5 else 0) + (0.08 if vol_ratio < 0.9 else 0)
# Phase D 候选:价格在箱上半、有上破意图但未确认 SOS
elif c >= mid and (h >= hi - tol or c > hi - tol * 0.5):
phase_candidate = "D"
phase_conf = 0.5 + (0.1 if c > mid else 0)
elif c < mid and (l <= lo + tol):
phase_candidate = "B"
phase_conf = 0.45
# 已有 confirmed phase 时,candidate 取「下一阶段」提示,不覆盖事实
confirmed_phase_set = {str(p.get("phase")) for p in confirmed_phases}
if "E" in confirmed_phase_set:
phase_candidate = phase_candidate or "E"
phase_conf = max(phase_conf, 0.7)
elif "D" in confirmed_phase_set and phase_candidate is None:
phase_candidate = "D"
phase_conf = max(phase_conf, 0.65)
out["phase_candidate"] = phase_candidate
phase_conf = float(min(0.92, phase_conf))
# —— Event candidates(仅 Spring / SOS / LPS / UTAD)——
candidates: List[Dict[str, Any]] = []
def _add(typ: str, conf: float, note: str) -> None:
if typ in confirmed_types:
return # 已确认则不再作为 candidate
candidates.append(
{
"type": typ,
"confidence": round(float(min(0.9, conf)), 3),
"confirmed": False,
"note": note,
}
)
# Spring candidate:刺破或贴近下沿,收盘收回,但未达 Confirmed 规则(或不在 confirmed
pierce_lo = l < lo - tol * 0.15
close_back = c >= lo - tol * 0.5
if pierce_lo and close_back:
_add("Spring", 0.5 + (0.12 if vol_ratio <= 1.2 else 0) + (0.08 if close_back else 0), "假破下沿收回(未确认)")
elif l <= lo + tol * 0.35 and close_back and lower_wick >= rng * 0.4:
_add("Spring", 0.45 + (0.1 if vol_contract else 0), "测下沿长下影(未确认)")
# UTAD candidate
pierce_hi = h > hi + tol * 0.15
close_back_dn = c <= hi + tol * 0.5
if pierce_hi and close_back_dn:
_add("UTAD", 0.5 + (0.1 if vol_ratio >= 0.9 else 0), "假破上沿跌回(未确认)")
# SOS candidate:接近/轻破上沿,量能一般,未确认
if c > hi - tol * 0.4 or h >= hi:
sos_conf = 0.48 + (0.12 if c > hi else 0) + (0.1 if vol_ratio >= 1.05 else 0)
_add("SOS", sos_conf, "上破/逼近箱顶(未确认)")
# LPS candidate:站上 mid/上沿带后回踩
if c >= mid and l >= mid - tol * 1.5 and l > lo + (hi - lo) * 0.25:
_add("LPS", 0.46 + (0.1 if vol_ratio <= 1.0 else 0), "箱内上沿带回踩(未确认)")
candidates.sort(key=lambda x: x["confidence"], reverse=True)
out["event_candidates"] = candidates[:4]
event_score = float(candidates[0]["confidence"]) if candidates else 0.25
# next_expected(简规则)
next_exp = None
if "Spring" in confirmed_types and "SOS" not in confirmed_types:
next_exp = "SOS"
elif "SOS" in confirmed_types and "LPS" not in confirmed_types:
next_exp = "LPS"
elif "UTAD" in confirmed_types and "SOW" not in confirmed_types:
next_exp = "SOW"
elif any(c["type"] == "Spring" for c in candidates):
next_exp = "Test"
elif any(c["type"] == "SOS" for c in candidates):
next_exp = "LPS"
out["next_expected"] = next_exp
# —— lifecycle ——
key_confirmed = confirmed_types & {"Spring", "SOS", "UTAD", "SOW", "LPS", "LPSY"}
if key_confirmed:
lifecycle = "CONFIRMED"
elif range_ok or phase_candidate or candidates:
lifecycle = "FORMING"
else:
lifecycle = "UNKNOWN"
out["lifecycle"] = lifecycle
cycle_c = structure_score
overall = 0.35 * cycle_c + 0.25 * phase_conf + 0.25 * event_score + 0.15 * volume_score
out["confidence"] = {
"cycle": round(cycle_c, 3),
"phase": round(phase_conf, 3),
"event": round(event_score, 3),
"structure": round(structure_score, 3),
"volume": round(volume_score, 3),
"overall": round(float(overall), 3),
}
parts = []
if out["range_formation"]["potential_trading_range"]:
parts.append("Potential TR")
if phase_candidate:
parts.append(f"Phase {phase_candidate} candidate")
if candidates:
parts.append(f"{candidates[0]['type']} candidate")
out["note"] = "; ".join(parts) if parts else "observing"
return out
def execution_signal_from_wyckoff(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Execution 边界只允许 Confirmed
返回 source='confirmed' 的信号描述Live-only 时返回 None
"""
if not payload:
return None
cycles = payload.get("cycles") or []
active = cycles[0] if cycles else None
events = []
if active and isinstance(active.get("confirmed"), dict):
events = list(active["confirmed"].get("events") or [])
if not events:
# 兼容旧顶层 events(均为 confirmed 镜像)
events = list(payload.get("events") or [])
if not events:
return None
last = events[-1]
return {
"source": "confirmed",
"type": last.get("type"),
"time": last.get("time"),
"lifecycle": (active or {}).get("lifecycle") or "CONFIRMED",
}
+442
View File
@@ -0,0 +1,442 @@
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
WYCKOFF-MULTI-CYCLE-001Phase/Event/VP 不得进入本模块
过滤顺序固定detect quality trend overlap(<0.2) accept mask
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
MAX_CYCLES = 8
OVERLAP_RATIO_MAX = 0.2
def _atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
high = df["high"].astype(float)
low = df["low"].astype(float)
close = df["close"].astype(float)
prev_close = close.shift(1)
tr = pd.concat(
[
(high - low).abs(),
(high - prev_close).abs(),
(low - prev_close).abs(),
],
axis=1,
).max(axis=1)
return tr.rolling(period, min_periods=max(3, period // 2)).mean()
def _robust_width(seg: pd.DataFrame) -> float:
"""用 90/10 分位估宽,避免单根影线把长窗卡死。"""
h = seg["high"].astype(float)
l = seg["low"].astype(float)
if len(seg) < 6:
return float(h.max() - l.min())
return float(np.nanpercentile(h, 90) - np.nanpercentile(l, 10))
def _score_segment(
length: int,
near_hi: int,
near_lo: int,
inside: float,
width: float,
atr: float,
) -> float:
"""结构质量分(非 Phase/Event)。"""
touch = min(near_hi, 6) + min(near_lo, 6)
width_pen = (width / atr) if atr > 0 else width
return float(touch) * 4.0 + float(inside) * 25.0 - width_pen * 3.0 + min(length / 40.0, 2.0)
def _time_col(df: pd.DataFrame) -> Optional[str]:
if "date" in df.columns:
return "date"
if "timestamp" in df.columns:
return "timestamp"
return None
def _bar_index_at_or_after(work: pd.DataFrame, ts: Any) -> Optional[int]:
col = _time_col(work)
if col is None or ts is None:
return None
try:
target = pd.Timestamp(ts)
except Exception:
return None
series = pd.to_datetime(work[col], utc=True, errors="coerce")
if target.tzinfo is None:
target = target.tz_localize("UTC")
else:
target = target.tz_convert("UTC")
if series.isna().all():
return None
ge = series >= target
if ge.any():
return int(np.flatnonzero(ge.to_numpy())[0])
return 0
def _pack_range(
work: pd.DataFrame,
df: pd.DataFrame,
start_i: int,
end_i: int,
hi: float,
lo: float,
tol: float,
last_atr: float,
score: float,
n: int,
window_offset: int = 0,
) -> Dict[str, Any]:
"""组装 TradingRange(仅结构字段)。"""
mid = (hi + lo) / 2.0
last_c = float(work["close"].iloc[min(end_i, len(work) - 1)])
price_in_box = (lo - tol * 1.5) <= last_c <= (hi + tol * 1.5)
bars = int(end_i - start_i + 1)
# 结构置信:归一化 score(启发式)
range_conf = float(np.clip(score / 55.0, 0.05, 0.99))
best = {
"start_idx": int(start_i),
"end_idx": int(end_i),
"high": float(hi),
"low": float(lo),
"mid": float(mid),
"active": bool(price_in_box),
"atr": float(last_atr),
"tol": float(tol),
"bars": bars,
"score": float(score),
"quality": float(score),
"range_confidence": range_conf,
}
def _ts(row) -> Any:
col = _time_col(work)
if col and pd.notna(row[col]):
return row[col]
return None
best["start_time"] = _ts(work.iloc[best["start_idx"]])
best["end_time"] = _ts(work.iloc[best["end_idx"]])
# window_offsetslice 相对父 DataFrame 的起点;勿用 len(df)-len(work)
offset = int(window_offset)
best["abs_start_idx"] = offset + best["start_idx"]
best["abs_end_idx"] = offset + best["end_idx"]
best["abs_scan_end_idx"] = offset + n - 1
return best
def _overlap_ratio(a0: int, a1: int, b0: int, b1: int) -> float:
"""两闭区间重叠长度 / 较短区间长度。"""
lo = max(a0, b0)
hi = min(a1, b1)
if hi < lo:
return 0.0
overlap = hi - lo + 1
shorter = min(a1 - a0 + 1, b1 - b0 + 1)
if shorter <= 0:
return 0.0
return float(overlap) / float(shorter)
def _passes_quality(tr: Dict[str, Any], min_bars: int) -> bool:
if tr is None:
return False
if int(tr.get("bars") or 0) < max(8, min_bars // 2):
return False
if float(tr.get("score") or 0) < 12.0:
return False
hi = float(tr["high"])
lo = float(tr["low"])
atr = float(tr.get("atr") or 0) or 1.0
if (hi - lo) / atr > 12.0:
return False
return True
def _passes_trend_filter(work: pd.DataFrame, tr: Dict[str, Any]) -> bool:
"""趋势污染:定向位移过大则非震荡箱。"""
s = int(tr["start_idx"])
e = int(tr["end_idx"])
seg = work.iloc[s : e + 1]
if len(seg) < 8:
return False
c0 = float(seg["close"].iloc[0])
c1 = float(seg["close"].iloc[-1])
atr = float(tr.get("atr") or 0) or 1.0
drift = abs(c1 - c0) / atr
# 相对箱宽:漂移占箱宽过大 → 趋势
width = max(float(tr["high"]) - float(tr["low"]), atr)
drift_frac = abs(c1 - c0) / width
if drift > 6.0 and drift_frac > 0.55:
return False
return True
def _detect_in_window(
df: pd.DataFrame,
win_start: int,
win_end: int,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
prefer_start_time: Any = None,
range_start_time: Any = None,
) -> Optional[Dict[str, Any]]:
"""
df[win_start:win_end+1] 内检测单个 TradingRange
只返回箱体结构不含 Phase/Event/VP
"""
if df is None or win_end < win_start:
return None
slice_df = df.iloc[win_start : win_end + 1].reset_index(drop=True)
lookback = len(slice_df)
if lookback < min_bars + 5:
return None
work = slice_df
n = len(work)
reserve = min(tail_reserve, max(0, n - min_bars - 2))
core_end = n - reserve if reserve > 0 else n
core = work.iloc[:core_end]
if len(core) < min_bars:
core = work
core_end = n
reserve = 0
atr = _atr(work)
last_atr = float(atr.iloc[core_end - 1]) if atr.notna().iloc[:core_end].any() else float(
(core["high"] - core["low"]).mean()
)
if not np.isfinite(last_atr) or last_atr <= 0:
last_atr = float(core["close"].iloc[-1]) * 0.01
eff_atr_mult = float(atr_mult)
if lookback >= 280:
eff_atr_mult = atr_mult * 1.7
elif lookback >= 160:
eff_atr_mult = atr_mult * 1.3
width_factor = 3.8 + min(2.2, max(0.0, (lookback - 80) / 100.0))
max_width = last_atr * eff_atr_mult * width_factor
tol = last_atr * eff_atr_mult * 0.35
prefer_i = None
if prefer_start_time is not None:
prefer_i = _bar_index_at_or_after(work, prefer_start_time)
if range_start_time is not None:
start_i = _bar_index_at_or_after(work, range_start_time)
if start_i is not None and start_i <= core_end - 8:
seg = work.iloc[start_i:core_end]
hi = float(seg["high"].max())
lo = float(seg["low"].min())
rw = _robust_width(seg)
if 0 < rw <= max_width * 1.15:
near_hi = int((seg["high"] >= hi - tol).sum())
near_lo = int((seg["low"] <= lo + tol).sum())
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
if near_hi >= 2 and near_lo >= 2 and inside >= 0.70:
score = _score_segment(len(seg), near_hi, near_lo, inside, rw, last_atr)
return _pack_range(
work, df, start_i, core_end - 1, hi, lo, tol, last_atr, score, n,
window_offset=win_start,
)
eff_min_bars = max(8, int(min_bars))
cn = len(core)
max_bars = min(cn, max(eff_min_bars * 2, min(96, max(eff_min_bars + 8, int(cn * 0.5)))))
cands: List[Tuple[float, int, int, int, float, float, float]] = []
def _try_seg(start_i: int, end_i: int, prefer_boost: float = 0.0) -> None:
if end_i - start_i + 1 < eff_min_bars:
return
if start_i < 0 or end_i >= cn or start_i > end_i:
return
seg = work.iloc[start_i : end_i + 1]
hi = float(seg["high"].max())
lo = float(seg["low"].min())
rw = _robust_width(seg)
if rw <= 0 or rw > max_width:
return
raw_w = hi - lo
if raw_w > max_width * 1.35:
return
near_hi = int((seg["high"] >= hi - tol).sum())
near_lo = int((seg["low"] <= lo + tol).sum())
if near_hi < 2 or near_lo < 2:
return
inside = float(((seg["close"] >= lo - tol) & (seg["close"] <= hi + tol)).mean())
if inside < 0.72:
return
length = end_i - start_i + 1
score = _score_segment(length, near_hi, near_lo, inside, rw, last_atr) + prefer_boost
cands.append((score, length, start_i, end_i, hi, lo, rw))
for length in range(min(cn, max_bars), eff_min_bars - 1, -4):
start_i = cn - length
boost = 0.0
if prefer_i is not None:
dist = abs(start_i - int(prefer_i))
if dist <= 6:
boost = 10.0
elif dist <= 14:
boost = 4.0
elif start_i > int(prefer_i) + 16:
boost = -10.0
_try_seg(start_i, cn - 1, boost)
if prefer_i is not None:
pi = int(prefer_i)
if 0 <= pi < cn:
align_max = min(cn, max(max_bars, int(cn * 0.65)))
alen = cn - pi
if eff_min_bars <= alen <= align_max:
_try_seg(pi, cn - 1, prefer_boost=18.0)
elif alen > align_max:
start_i = max(0, cn - align_max)
if start_i > pi:
start_i = pi
end_i = min(cn - 1, pi + align_max - 1)
else:
end_i = cn - 1
_try_seg(start_i, end_i, prefer_boost=12.0)
if not cands:
return None
cands.sort(key=lambda x: x[0], reverse=True)
best_score = cands[0][0]
band = max(4.0, abs(best_score) * 0.10)
near = [c for c in cands if c[0] >= best_score - band]
chosen = max(near, key=lambda x: (x[1], x[0]))
score, _length, start_i, end_i, hi, lo, _rw = chosen
return _pack_range(work, df, start_i, end_i, hi, lo, tol, last_atr, score, n, window_offset=win_start)
def detect_trading_ranges(
df: pd.DataFrame,
lookback: Optional[int] = None,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
max_cycles: int = MAX_CYCLES,
prefer_start_time: Any = None,
range_start_time: Any = None,
) -> List[Dict[str, Any]]:
"""
倒序切多段 TradingRange
过滤顺序detect quality trend overlap accept mask
返回列表已按时间倒序调用方将 [0] 标为 ACTIVE
"""
if df is None or len(df) < min_bars + 5:
return []
lb = int(lookback) if lookback is not None else len(df)
work = df.tail(lb).reset_index(drop=True)
n = len(work)
occupied: List[Dict[str, Any]] = []
accepted: List[Dict[str, Any]] = []
# 搜索右端从 n-1 往左收缩;每接受一段后右端移到该段 start 之前
search_end = n - 1
prefer = prefer_start_time
hard_start = range_start_time
while len(accepted) < max(1, int(max_cycles)) and search_end >= min_bars + 4:
# 在剩余历史内从右往左试多个右边界,避免历史箱必须贴住 search_end
# (否则中间趋势会挡住更早的真实箱)
cand = None
step = max(4, min(12, (search_end - min_bars) // 10 or 4))
for end_try in range(search_end, min_bars + 4, -step):
trial = _detect_in_window(
work,
0,
end_try,
min_bars=min_bars,
atr_mult=atr_mult,
tail_reserve=tail_reserve,
prefer_start_time=prefer if len(accepted) == 0 and end_try == search_end else None,
range_start_time=hard_start if len(accepted) == 0 and end_try == search_end else None,
)
# 1) detect
if trial is None:
continue
# 2) quality
if not _passes_quality(trial, min_bars):
continue
# 3) trend contamination
if not _passes_trend_filter(work, trial):
continue
# 4) overlap with accepted
a0, a1 = int(trial["abs_start_idx"]), int(trial["abs_end_idx"])
overlap_bad = False
for occ in occupied:
ratio = _overlap_ratio(a0, a1, int(occ["start"]), int(occ["end"]))
if ratio >= OVERLAP_RATIO_MAX:
overlap_bad = True
break
if overlap_bad:
continue
# 取最靠右的合格箱(倒序第一段)
cand = trial
break
if cand is None:
break
# 5) accept
accepted.append(cand)
a0, a1 = int(cand["abs_start_idx"]), int(cand["abs_end_idx"])
# 6) mask
occupied.append(
{
"start": a0,
"end": max(a1, int(cand.get("abs_scan_end_idx", a1))),
"quality": float(cand.get("quality") or 0),
"high": float(cand["high"]),
"low": float(cand["low"]),
}
)
# 下一轮只在更早窗口搜
search_end = int(cand["abs_start_idx"]) - 1
hard_start = None
prefer = None
# abs_* 目前相对 work;若 df 比 work 长需加 offset
offset = len(df) - len(work)
if offset:
for tr in accepted:
tr["abs_start_idx"] = int(tr["abs_start_idx"]) + offset
tr["abs_end_idx"] = int(tr["abs_end_idx"]) + offset
tr["abs_scan_end_idx"] = int(tr["abs_scan_end_idx"]) + offset
return accepted
def detect_trading_range(
df: pd.DataFrame,
lookback: int = 120,
min_bars: int = 24,
atr_mult: float = 1.2,
tail_reserve: int = 12,
range_start_time: Any = None,
prefer_start_time: Any = None,
) -> Optional[Dict[str, Any]]:
"""兼容旧接口:返回倒序列表中的第一段(ACTIVE 候选)。"""
ranges = detect_trading_ranges(
df,
lookback=lookback,
min_bars=min_bars,
atr_mult=atr_mult,
tail_reserve=tail_reserve,
max_cycles=1,
prefer_start_time=prefer_start_time,
range_start_time=range_start_time,
)
return ranges[0] if ranges else None
@@ -0,0 +1,72 @@
"""区间内 Volume Profile。"""
from __future__ import annotations
from typing import Any, Dict, List
import numpy as np
import pandas as pd
def compute_volume_profile(
df: pd.DataFrame,
start_idx: int,
end_idx: int,
bin_count: int = 50,
value_area_pct: float = 0.70,
) -> Dict[str, Any]:
seg = df.iloc[start_idx : end_idx + 1]
if seg.empty:
return {"bins": [], "poc": None, "vah": None, "val": None, "bin_count": bin_count}
typical = (seg["high"].astype(float) + seg["low"].astype(float) + seg["close"].astype(float)) / 3.0
vol = seg["volume"].astype(float).fillna(0.0)
lo = float(seg["low"].min())
hi = float(seg["high"].max())
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
mid = float(seg["close"].iloc[-1])
return {
"bins": [{"price": mid, "volume": float(vol.sum())}],
"poc": mid,
"vah": mid,
"val": mid,
"bin_count": 1,
}
edges = np.linspace(lo, hi, bin_count + 1)
# 右开最后一桶闭合
idx = np.clip(np.digitize(typical.values, edges) - 1, 0, bin_count - 1)
vols = np.zeros(bin_count, dtype=float)
for i, v in zip(idx, vol.values):
vols[i] += float(v)
centers = (edges[:-1] + edges[1:]) / 2.0
poc_i = int(np.argmax(vols)) if vols.sum() > 0 else bin_count // 2
poc = float(centers[poc_i])
# Value Area:从 POC 向两侧扩展直到累计 >= value_area_pct
total = float(vols.sum()) or 1.0
target = total * value_area_pct
left = right = poc_i
acc = float(vols[poc_i])
while acc < target and (left > 0 or right < bin_count - 1):
left_v = vols[left - 1] if left > 0 else -1.0
right_v = vols[right + 1] if right < bin_count - 1 else -1.0
if right_v >= left_v and right < bin_count - 1:
right += 1
acc += float(vols[right])
elif left > 0:
left -= 1
acc += float(vols[left])
else:
break
bins: List[Dict[str, float]] = [
{"price": float(centers[i]), "volume": float(vols[i])} for i in range(bin_count)
]
return {
"bins": bins,
"poc": poc,
"vah": float(centers[right]),
"val": float(centers[left]),
"bin_count": bin_count,
}
+21 -52
View File
@@ -15,12 +15,10 @@ class ChanBI():
self.sure_time = None self.sure_time = None
self.klc_list = [] self.klc_list = []
self.klc_list.append(klc) self.klc_list.append(klc)
self._klc_idx = {klc.index}
self.end_time = klc.end_time self.end_time = klc.end_time
self.start_time = klc.start_time self.start_time = klc.start_time
self._macd_hist = 0 self.macd_hist = 0
self._macd_div = 0 self.macd_div = 0
self._macd_dirty = False
self.seg = None self.seg = None
self.height = 0 self.height = 0
self.width = 0 self.width = 0
@@ -35,57 +33,26 @@ class ChanBI():
def set_seg(self, seg): def set_seg(self, seg):
self.seg = seg self.seg = seg
self.seg_index = len(seg.bi_list)-1 self.seg_index = len(seg.bi_list)-1
# macd_hist / macd_div 改为惰性。原来 add_klc 每加一根 KLC 就把整笔的
# 所有 KLU 重新累加一遍,是 O(k²);而这两个值只有背驰判定(bsp.py)在读,
# lean 模式下 bsp 根本不算,等于全程白算。这里只标脏,取值时才算。
# 语义不变:klc_list 只增不减(set_start_klc 会重置并同时清脏),
# dir 在 klc 累加期间固定,所以延后到读取时算与逐次重算结果相同。
@property
def macd_hist(self):
if self._macd_dirty:
self.cal_macdhist()
return self._macd_hist
@macd_hist.setter
def macd_hist(self, v):
self._macd_hist = v
self._macd_dirty = False
@property
def macd_div(self):
self.cal_macd_div()
return self._macd_div
@macd_div.setter
def macd_div(self, v):
self._macd_div = v
def set_macdhist(self, macd_hist): def set_macdhist(self, macd_hist):
self.macd_hist = macd_hist self.macd_hist = macd_hist
def set_macd_div(self, macd_div): def set_macd_div(self, macd_div):
self.macd_div = macd_div self.macd_div = macd_div
def cal_macd_div(self): def cal_macd_div(self):
self._macd_div = 0.0 self.macd_div = 0.0
if self.pre and self.pre.pre: if self.pre and self.pre.pre:
if self.pre.pre.macd_hist == 0: if self.pre.pre.macd_hist == 0:
self._macd_div = 0.0 self.macd_div = 0.0
else: else:
self._macd_div = self.macd_hist / self.pre.pre.macd_hist self.macd_div = self.macd_hist / self.pre.pre.macd_hist
#print(self.start_time, self.end_time, self.macd_hist, self.pre.pre.macd_hist, self.macd_div) #print(self.start_time, self.end_time, self.macd_hist, self.pre.pre.macd_hist, self.macd_div)
def cal_macdhist(self): def cal_macdhist(self):
self._macd_dirty = False self.macd_hist = 0
self._macd_hist = 0
up = self.dir == Chan_BI_DIR.UP
acc = 0
for klc in self.klc_list: for klc in self.klc_list:
for klu in klc.klu_list: for klu in klc.klu_list:
h = klu.macdhist if self.dir == Chan_BI_DIR.UP and klu.macdhist > 0:
if up: self.macd_hist += klu.macdhist
if h > 0: if self.dir == Chan_BI_DIR.DOWN and klu.macdhist < 0:
acc += h self.macd_hist -= klu.macdhist
elif h < 0:
acc -= h
self._macd_hist = acc
def check_bi_zs_overlap(self): def check_bi_zs_overlap(self):
if self.next and self.next.next: if self.next and self.next.next:
if self.dir == Chan_BI_DIR.UP: if self.dir == Chan_BI_DIR.UP:
@@ -128,10 +95,6 @@ class ChanBI():
self.start_klc = klc self.start_klc = klc
self.klc_list = [] self.klc_list = []
self.klc_list.append(klc) self.klc_list.append(klc)
# klc_list 被整个换掉,去重集合与惰性缓存都要跟着重置,
# 否则后续 add_klc 会以为旧下标还在里面而漏加
self._klc_idx = {klc.index}
self._macd_dirty = True
self.high = klc.high self.high = klc.high
self.low = klc.low self.low = klc.low
self.dir = ddir self.dir = ddir
@@ -140,14 +103,20 @@ class ChanBI():
def set_next(self, bi): def set_next(self, bi):
self.next = bi self.next = bi
def add_klc(self, klc): def add_klc(self, klc):
# 去重原本是对 klc_list 线性扫描,配合下面每次全量重算的 macdhist, added = False
# 让「往一笔里加 k 根 KLC」变成 O(k²)。改用下标集合,O(1)。 if len(self.klc_list) > 0:
if klc.index not in self._klc_idx: for index in range(0, len(self.klc_list)):
self._klc_idx.add(klc.index) if self.klc_list[index].index == klc.index:
added = True
break
if not added:
self.klc_list.append(klc) self.klc_list.append(klc)
#print(self.start_time, klc.start_time)
#print(klc.end_time, klc.index)
self.end_klc = klc self.end_klc = klc
self.end_time = klc.klu_list[-1].time self.end_time = klc.klu_list[-1].time
self._macd_dirty = True self.cal_macdhist()
self.cal_macd_div()
def append_klc_list(self, klc_list): def append_klc_list(self, klc_list):
self.klc_list.append(klc_list) self.klc_list.append(klc_list)
def get_decimal(self, value): def get_decimal(self, value):
-4
View File
@@ -281,10 +281,6 @@ class Chan_BSP_TYPE(Enum):
S1 = auto() S1 = auto()
S2 = auto() S2 = auto()
S3 = auto() S3 = auto()
# 第四类:三类买卖点的低滞后变体,几何位置同 B3/S3,但不等笔确认。
# 见 chanlun/analysis/fast_bsp.py
B4 = auto()
S4 = auto()
NONE = auto() NONE = auto()
""" """
class Chan_BSP_TYPE(Enum): class Chan_BSP_TYPE(Enum):
-40
View File
@@ -1,40 +0,0 @@
from chanlun.core.ChanEnum import Chan_BSP_DIR, Chan_BSP_TYPE
class ChanFastBSP():
"""第四类买卖点(B4/S4)。
ChanBSP 的区别在于它不挂在笔上fast_bsp 刻意不等笔确认入场点是一根具体的
K线而非一笔的端点所以时间与价格直接取自 K 线没有 bi / klc 可依附
htf_agree ladder_ok 是两个独立的过滤标志不在这里合成上层图表或策略
自己决定要不要用怎么组合
"""
def __init__(self, time, price, ddir, entry_idx, bo_time=None, pb_time=None,
lag=0, depth=0.0, zg=None, zd=None, occ=1,
htf_dir=None, htf_agree=None, ladder_ok=None):
self.time = time
self.price = float(price)
self.dir = ddir
self.type = Chan_BSP_TYPE.B4 if ddir == Chan_BSP_DIR.BUY else Chan_BSP_TYPE.S4
self.entry_idx = int(entry_idx)
self.bo_time = bo_time
self.pb_time = pb_time
self.lag = int(lag)
self.depth = float(depth)
self.zg = float(zg) if zg is not None else None
self.zd = float(zd) if zd is not None else None
self.occ = int(occ)
self.htf_dir = htf_dir
self.htf_agree = htf_agree
self.ladder_ok = ladder_ok
# 入场即成立,没有「等待确认」这个状态;留此字段是为了与 ChanBSP 的序列化对齐
self.is_sure = True
self.start_time = time
self.end_time = time
self.sure_time = time
def __repr__(self):
name = str(self.type).replace('Chan_BSP_TYPE.', '')
return f"<ChanFastBSP {name} {self.time} {self.price} lag={self.lag}>"
+8 -32
View File
@@ -63,11 +63,10 @@ class ChanKLC():
self.bsp = False self.bsp = False
self.bsp_type = Chan_BSP_TYPE.NONE self.bsp_type = Chan_BSP_TYPE.NONE
# EMA状态字典:key为EMA名称,value为 {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC} # EMA状态字典:key为EMA名称,value为 {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC}
self._ema_status = {} self.ema_status = {}
self._ema_status_dirty = False
# 向后兼容:保留 ema52_status 和 ema52_pos # 向后兼容:保留 ema52_status 和 ema52_pos
self._ema52_status = 0 self.ema52_status = 0
self._ema52_pos = Chan_EMA_POS.UNKNOWN self.ema52_pos = Chan_EMA_POS.UNKNOWN
self.bb2633upper = klu.bb2633upper self.bb2633upper = klu.bb2633upper
self.bb2633lower = klu.bb2633lower self.bb2633lower = klu.bb2633lower
self.bb2633middle = klu.bb2633middle self.bb2633middle = klu.bb2633middle
@@ -284,44 +283,21 @@ class ChanKLC():
'ema156': self.ema156, 'ema156': self.ema156,
'ema208': self.ema208, 'ema208': self.ema208,
} }
self._ema_status_dirty = False self.ema_status = {}
self._ema_status = {}
for name, value in ema_configs.items(): for name, value in ema_configs.items():
# 按 EMA 值的百分比自动计算阈值 # 按 EMA 值的百分比自动计算阈值
threshold = abs(value) * self.threshold_pct if value and self.threshold_pct > 0 else 0 threshold = abs(value) * self.threshold_pct if value and self.threshold_pct > 0 else 0
pos = ChanKLC.cal_ema_pos(self.high, self.low, self.close, value, threshold) pos = ChanKLC.cal_ema_pos(self.high, self.low, self.close, value, threshold)
semantic = ChanKLC.cal_ema_semantic(pos, self.dir, self.ema_dir) semantic = ChanKLC.cal_ema_semantic(pos, self.dir, self.ema_dir)
self._ema_status[name] = { self.ema_status[name] = {
'pos': pos, 'pos': pos,
'semantic': semantic, 'semantic': semantic,
'value': value, 'value': value,
'threshold': threshold, 'threshold': threshold,
} }
# 向后兼容 # 向后兼容
self._ema52_pos = self._ema_status['ema52']['pos'] self.ema52_pos = self.ema_status['ema52']['pos']
self._ema52_status = ChanKLC.semantic_to_int(self._ema_status['ema52']['semantic']) self.ema52_status = ChanKLC.semantic_to_int(self.ema_status['ema52']['semantic'])
# 以下三个改成惰性求值。原来 set_end_klu 每次合并 KLU 都会立刻重算一遍,
# 实测占 TF_DF 构建的约 25%,而全仓(含前端)没有任何地方读取它的产出。
# 保留属性形式是为了任何外部读取仍拿到正确值,只是推迟到真被读时才算。
@property
def ema_status(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema_status
@property
def ema52_pos(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema52_pos
@property
def ema52_status(self):
if self._ema_status_dirty:
self.cal_all_ema_status()
return self._ema52_status
def get_ema_pos(self, ema_name): def get_ema_pos(self, ema_name):
"""获取指定EMA的客观位置,如 klc.get_ema_pos('ema24')""" """获取指定EMA的客观位置,如 klc.get_ema_pos('ema24')"""
if ema_name in self.ema_status: if ema_name in self.ema_status:
@@ -472,7 +448,7 @@ class ChanKLC():
klu.set_klc(self) klu.set_klc(self)
self.klc_dir = Chan_KLINE_DIR.UP if self.close > self.open else Chan_KLINE_DIR.DOWN self.klc_dir = Chan_KLINE_DIR.UP if self.close > self.open else Chan_KLINE_DIR.DOWN
self.cal_indicators() self.cal_indicators()
self._ema_status_dirty = True self.cal_all_ema_status()
if self.open > self.high: if self.open > self.high:
self.open = self.high self.open = self.high
if self.close > self.high: if self.close > self.high:
+20 -33
View File
@@ -160,40 +160,27 @@ class ChanKLU:
return False return False
else: else:
return True return True
# 属性名 ← dataframe 列名。两条赋值路径(逐行 Series / 预取 ndarray)共用这张表,
# 免得将来加指标时只改一处、另一处静默漏掉。
INDICATOR_FIELDS = (
('macd', 'macd'), ('signal', 'macdsignal'), ('macdhist', 'macdhist'),
('ema26', 'ema26'), ('ema52', 'ema52'), ('ema24', 'ema24'),
('ema104', 'ema104'), ('ema156', 'ema156'), ('ema208', 'ema208'),
('ema13', 'ema13'), ('ema7', 'ema7'), ('ema5', 'ema5'),
('rsi', 'rsi'), ('volume_ratio', 'volume_ratio'),
('bb52upper', 'bb52upper'), ('bb52lower', 'bb52lower'),
('bb2633upper', 'bb2633upper'), ('bb2633lower', 'bb2633lower'),
('bb2633middle', 'bb2633middle'), ('ma5', 'ma5'),
)
def set_indicators(self, item): def set_indicators(self, item):
"""单根赋值。增量追加时每次只有一根,走这条即可。 self.macd = float(item['macd']) if 'macd' in item and item['macd'] else 0
self.signal = float(item['macdsignal']) if 'macdsignal' in item and item['macdsignal'] else 0
原写法是 `float(item[c]) if c in item and item[c] else 0`其中的真值判断 self.macdhist = float(item['macdhist']) if 'macdhist' in item and item['macdhist'] else 0
是空转值为 0.0 float(0.0) 仍是 0值为 NaN NaN 为真值照样透传 self.ema26 = float(item['ema26']) if 'ema26' in item and item['ema26'] else 0
唯一起作用的是列不存在则填 0所以这里只保留那一层 self.ema52 = float(item['ema52']) if 'ema52' in item and item['ema52'] else 0
""" self.ema24 = float(item['ema24']) if 'ema24' in item and item['ema24'] else 0
for attr, col in self.INDICATOR_FIELDS: self.ema104 = float(item['ema104']) if 'ema104' in item and item['ema104'] else 0
v = item[col] if col in item else 0 self.ema156 = float(item['ema156']) if 'ema156' in item and item['ema156'] else 0
setattr(self, attr, float(v) if v else 0) self.ema208 = float(item['ema208']) if 'ema208' in item and item['ema208'] else 0
self.ema13 = float(item['ema13']) if 'ema13' in item and item['ema13'] else 0
def set_indicators_from(self, cols, i): self.ema7 = float(item['ema7']) if 'ema7' in item and item['ema7'] else 0
"""从预取的 {列名: ndarray} 按下标赋值,语义与 set_indicators 相同。 self.rsi = float(item['rsi']) if 'rsi' in item and item['rsi'] else 0
self.volume_ratio = float(item['volume_ratio']) if 'volume_ratio' in item and item['volume_ratio'] else 0
全量构建时用这条避免每根 `df.iloc[i]` 构造一个 Series再在其上做 self.bb52upper = float(item['bb52upper']) if 'bb52upper' in item and item['bb52upper'] else 0
几十次逐键查找那是 TF_DF 构建 96% 的耗时所在 self.bb52lower = float(item['bb52lower']) if 'bb52lower' in item and item['bb52lower'] else 0
""" self.bb2633upper = float(item['bb2633upper']) if 'bb2633upper' in item and item['bb2633upper'] else 0
for attr, col in self.INDICATOR_FIELDS: self.bb2633lower = float(item['bb2633lower']) if 'bb2633lower' in item and item['bb2633lower'] else 0
arr = cols.get(col) self.bb2633middle = float(item['bb2633middle']) if 'bb2633middle' in item and item['bb2633middle'] else 0
v = arr[i] if arr is not None else 0 self.ma5 = float(item['ma5']) if 'ma5' in item and item['ma5'] else 0
setattr(self, attr, float(v) if v else 0) self.ema5 = float(item['ema5']) if 'ema5' in item and item['ema5'] else 0
def cal_macd_state(self): def cal_macd_state(self):
# 按定义精简实现:优先级 CROSS0 > 位置(HIGH/HE/RETURN_ZERO) > NEAR0 > UNKNOWN # 按定义精简实现:优先级 CROSS0 > 位置(HIGH/HE/RETURN_ZERO) > NEAR0 > UNKNOWN
# 首条或缺前一根 # 首条或缺前一根
-196
View File
@@ -1,196 +0,0 @@
"""Drop-in replacement for the `talib.abstract` calls this project makes.
Same call signatures, same column names, same NaN warm-up lengths, so call
sites only change their import line.
Only what the codebase actually uses is implemented: SMA, MA, EMA, RSI, ATR,
MACD, BBANDS. Numerical agreement with TA-Lib is enforced by
`chanlun/tests/test_ta_compat.py`, which skips when talib is absent.
The warm-up conventions below are TA-Lib's, not the textbook ones, and they
differ between functions getting them wrong shifts every downstream Chan
structure by a bar:
SMA/BBANDS first value at index period-1
EMA seeded with the SMA of the first `period` values, at index period-1
RSI/ATR Wilder smoothing (alpha = 1/period), first value at index period
"""
from __future__ import annotations
import numpy as np
import pandas as pd
__all__ = ["SMA", "MA", "EMA", "RSI", "ATR", "MACD", "BBANDS"]
def _series(data, price: str = "close") -> pd.Series:
"""Accept the abstract-API shapes: DataFrame, Series, or ndarray."""
if isinstance(data, pd.DataFrame):
return data[price].astype(float)
if isinstance(data, pd.Series):
return data.astype(float)
return pd.Series(np.asarray(data, dtype=float))
def _recursive(values: np.ndarray, seed: float, start: int, alpha: float, n: int) -> np.ndarray:
"""out[start] = seed; out[i] = alpha*values[i] + (1-alpha)*out[i-1].
Delegates the recursion to pandas' C implementation rather than a Python
loop `research/` runs this over long histories.
"""
out = np.full(n, np.nan)
if start >= n:
return out
tail = values[start:].astype(float).copy()
tail[0] = seed
out[start:] = pd.Series(tail).ewm(alpha=alpha, adjust=False).mean().to_numpy()
return out
def SMA(data, timeperiod: int = 30, price: str = "close") -> pd.Series:
s = _series(data, price)
return s.rolling(window=timeperiod, min_periods=timeperiod).mean()
def MA(data, timeperiod: int = 30, matype: int = 0, price: str = "close") -> pd.Series:
if matype != 0:
raise NotImplementedError(f"MA matype={matype} is not used by this codebase")
return SMA(data, timeperiod, price=price)
def _ema(x: np.ndarray, period: int, start: int) -> np.ndarray:
"""EMA whose first output lands on `start`, seeded by the SMA of the
`period` values ending there.
`start` is a parameter because MACD needs the fast EMA to begin later than
it naturally would; see the note in MACD().
"""
n = x.size
if n <= start or start < period - 1:
return np.full(n, np.nan)
seed = x[start - period + 1: start + 1].mean()
return _recursive(x, seed, start, 2.0 / (period + 1.0), n)
def EMA(data, timeperiod: int = 30, price: str = "close") -> pd.Series:
s = _series(data, price)
x = s.to_numpy(dtype=float)
return pd.Series(_ema(x, timeperiod, timeperiod - 1), index=s.index)
def RSI(data, timeperiod: int = 14, price: str = "close") -> pd.Series:
s = _series(data, price)
x = s.to_numpy(dtype=float)
n = x.size
out = np.full(n, np.nan)
if n <= timeperiod:
return pd.Series(out, index=s.index)
delta = np.diff(x)
gain = np.where(delta > 0.0, delta, 0.0)
loss = np.where(delta < 0.0, -delta, 0.0)
# delta[k] corresponds to bar k+1, so the first `timeperiod` deltas seed bar `timeperiod`.
alpha = 1.0 / timeperiod
avg_gain = _recursive(gain, gain[:timeperiod].mean(), timeperiod - 1, alpha, n - 1)
avg_loss = _recursive(loss, loss[:timeperiod].mean(), timeperiod - 1, alpha, n - 1)
ag = avg_gain[timeperiod - 1:]
al = avg_loss[timeperiod - 1:]
with np.errstate(divide="ignore", invalid="ignore"):
rsi = np.where(al == 0.0, 100.0, 100.0 - 100.0 / (1.0 + ag / al))
out[timeperiod:] = rsi
return pd.Series(out, index=s.index)
def ATR(data, timeperiod: int = 14) -> pd.Series:
if not isinstance(data, pd.DataFrame):
raise TypeError("ATR needs a DataFrame with high/low/close")
high = data["high"].to_numpy(dtype=float)
low = data["low"].to_numpy(dtype=float)
close = data["close"].to_numpy(dtype=float)
n = high.size
out = np.full(n, np.nan)
if n <= timeperiod:
return pd.Series(out, index=data.index)
prev_close = close[:-1]
tr = np.maximum.reduce([
high[1:] - low[1:],
np.abs(high[1:] - prev_close),
np.abs(low[1:] - prev_close),
])
# tr[k] is bar k+1; the first `timeperiod` true ranges seed bar `timeperiod`.
smoothed = _recursive(tr, tr[:timeperiod].mean(), timeperiod - 1, 1.0 / timeperiod, n - 1)
out[timeperiod:] = smoothed[timeperiod - 1:]
return pd.Series(out, index=data.index)
def MACD(
data,
fastperiod: int = 12,
slowperiod: int = 26,
signalperiod: int = 9,
price: str = "close",
) -> pd.DataFrame:
if slowperiod < fastperiod:
fastperiod, slowperiod = slowperiod, fastperiod
s = _series(data, price)
x = s.to_numpy(dtype=float)
n = x.size
macd = np.full(n, np.nan)
signal = np.full(n, np.nan)
hist = np.full(n, np.nan)
empty = pd.DataFrame({"macd": macd, "macdsignal": signal, "macdhist": hist}, index=s.index)
# Both EMAs emit their first value on the same bar. That makes the slow one
# ordinary, but re-seeds the fast one from the SMA of the `fastperiod`
# values ending there instead of carrying the recursion forward from bar
# fastperiod-1 — the two disagree by ~0.2 on a 100-priced series.
macd_start = slowperiod - 1
if n <= macd_start:
return empty
line = _ema(x, fastperiod, macd_start) - _ema(x, slowperiod, macd_start)
# The signal EMA runs over the MACD line, so everything shifts by another
# signalperiod-1 bars, and TA-Lib trims the MACD line to match.
valid = line[macd_start:]
if valid.size < signalperiod:
return empty
sig = _recursive(
valid, valid[:signalperiod].mean(), signalperiod - 1, 2.0 / (signalperiod + 1.0), valid.size
)
start = macd_start + signalperiod - 1
macd[start:] = line[start:]
signal[macd_start:] = sig
hist = macd - signal
return pd.DataFrame({"macd": macd, "macdsignal": signal, "macdhist": hist}, index=s.index)
def BBANDS(
data,
timeperiod: int = 5,
nbdevup: float = 2.0,
nbdevdn: float = 2.0,
matype: int = 0,
price: str = "close",
) -> pd.DataFrame:
if matype != 0:
raise NotImplementedError(f"BBANDS matype={matype} is not used by this codebase")
s = _series(data, price)
middle = s.rolling(window=timeperiod, min_periods=timeperiod).mean()
# TA-Lib uses the population standard deviation.
std = s.rolling(window=timeperiod, min_periods=timeperiod).std(ddof=0)
return pd.DataFrame(
{
"upperband": middle + nbdevup * std,
"middleband": middle,
"lowerband": middle - nbdevdn * std,
},
index=s.index,
)
+4 -2
View File
@@ -6,7 +6,9 @@ from decimal import Decimal
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import talib.abstract as ta
from pandas import DataFrame from pandas import DataFrame
from technical.util import resample_to_interval
from chanlun.core.ChanBI import ChanBI from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS from chanlun.core.ChanBIZS import ChanBIZS
@@ -465,7 +467,7 @@ class BiBuilderMixin:
pre_last_bi = bi_list[-2] pre_last_bi = bi_list[-2]
last_bi = bi_list[-1] last_bi = bi_list[-1]
if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.UP and False: if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.UP and False:
#pre_last_bi.update_bi(klc) pre_last_bi.update_bi(klc)
bi_list.remove(last_bi) bi_list.remove(last_bi)
pre_last_bi.set_next(None) pre_last_bi.set_next(None)
#last_top.set_fx(Chan_FX_TYPE.PTOP) #last_top.set_fx(Chan_FX_TYPE.PTOP)
@@ -583,7 +585,7 @@ class BiBuilderMixin:
pre_last_bi = bi_list[-2] pre_last_bi = bi_list[-2]
last_bi = bi_list[-1] last_bi = bi_list[-1]
if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.DOWN and False: if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.DOWN and False:
#pre_last_bi.update_bi(klc) pre_last_bi.update_bi(klc)
bi_list.remove(last_bi) bi_list.remove(last_bi)
pre_last_bi.set_next(None) pre_last_bi.set_next(None)
last_bottom = klc last_bottom = klc
+2
View File
@@ -6,7 +6,9 @@ from decimal import Decimal
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import talib.abstract as ta
from pandas import DataFrame from pandas import DataFrame
from technical.util import resample_to_interval
from chanlun.core.ChanBI import ChanBI from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS from chanlun.core.ChanBIZS import ChanBIZS
-148
View File
@@ -1,148 +0,0 @@
"""第四类买卖点(B4/S4)接入 TF_DF。
判定逻辑全在 chanlun/analysis/fast_bsp.py这里只负责把引擎的中枢/K线喂进去
再把结果包成 ChanFastBSP
刻意不在 init_TF_DF 里默认计算现有构造路径的开销保持不变由调用方按需触发
"""
from __future__ import annotations
import re
import pandas as pd
from chanlun.analysis.fast_bsp import (
add_zone_ladder,
attach_htf_agree,
attach_zone_ladder,
ensure_timestamp,
find_fast_bsp3,
htf_fx_timeline,
zones_from_zs_list,
)
from chanlun.core.ChanEnum import Chan_BSP_DIR
from chanlun.core.ChanFastBSP import ChanFastBSP
# 区间套配对:小级别出中枢与买卖点,大级别只出分型定方向。
# 取值来自 research/HANDOFF.md §1.5,是回测里实际用过的组合。
FAST_BSP_HTF_PAIR = {
'1m': '5m',
'5m': '30m',
'15m': '1h',
'30m': '2h',
}
# 未列入配对表的周期回落到这个倍数
FAST_BSP_HTF_FALLBACK_RATIO = 4
_TF_UNIT_MINUTES = {'m': 1, 'h': 60, 'd': 1440, 'w': 10080}
def timeframe_minutes(tf: str) -> int | None:
"""'30m' -> 30'2h' -> 120。无法解析时返回 None。"""
if not tf:
return None
m = re.fullmatch(r'(\d+)\s*([mhdw])', str(tf).strip().lower())
if not m:
return None
return int(m.group(1)) * _TF_UNIT_MINUTES[m.group(2)]
def resolve_htf(tf: str) -> tuple[str, int] | None:
"""给小级别找配套的大级别,返回 (标签, 分钟数)。"""
minutes = timeframe_minutes(tf)
if minutes is None:
return None
paired = FAST_BSP_HTF_PAIR.get(str(tf).strip().lower())
if paired:
return paired, timeframe_minutes(paired)
return f'{minutes * FAST_BSP_HTF_FALLBACK_RATIO}m', minutes * FAST_BSP_HTF_FALLBACK_RATIO
class FastBspBuilderMixin:
def build_fast_bsp_htf(self, df, timeframe=None):
"""对同一份 df 重采样得到大级别,不额外拉数据。
大级别只用来取分型方向样本太少就没有过滤意义故重采样后不足 60 根时放弃
"""
tf = timeframe or getattr(self, 'timeframe', None)
htf = resolve_htf(tf)
ltf_minutes = timeframe_minutes(tf)
if htf is None or not ltf_minutes:
return None
label, minutes = htf
if not minutes or len(df) * ltf_minutes < minutes * 60:
return None
try:
from chanlun.pipeline.timeframe import TF_DF
return TF_DF(df, minutes, label)
except Exception:
return None
def cal_fast_bsp(self, df=None, bi_zs_list=None, htf_chan=None, with_htf=True,
timeframe=None, **kw):
"""算第四类买卖点,返回 ChanFastBSP 列表。
bi_zs_list 传入已算好的 pure 笔中枢可免去重复计算
with_htf=False 时跳过大级别构建只留 ladder_ok 这一个过滤标志
kw 透传给 find_fast_bsp3scan / pullback_win / tol / require_touch
"""
src = df if df is not None else getattr(self, 'dataframe', None)
if src is None or len(src) == 0:
self.fast_bsp_list = []
return self.fast_bsp_list
src = ensure_timestamp(src)
if bi_zs_list is None:
bi_zs_list = getattr(self, 'bi_zs_list', None)
if not bi_zs_list:
bi_zs_list = self.cal_bi_zs_list_pure(self.cal_bi_list(self.get_klc_list(self.cal_kl_data(src))))
zones = zones_from_zs_list(bi_zs_list, src)
if zones.empty:
self.fast_bsp_list = []
return self.fast_bsp_list
zones = add_zone_ladder(zones)
sig = find_fast_bsp3(src, zones, **kw)
if sig.empty:
self.fast_bsp_list = []
return self.fast_bsp_list
sig = attach_zone_ladder(sig, zones)
if with_htf:
if htf_chan is None:
htf_chan = self.build_fast_bsp_htf(src, timeframe)
sig = attach_htf_agree(sig, src, htf_fx_timeline(htf_chan) if htf_chan is not None else pd.DataFrame())
else:
sig['htf_dir'] = None
sig['htf_agree'] = None
times = src['date'].dt.strftime('%Y-%m-%d %H:%M:%S').to_numpy()
close = src['close'].to_numpy(dtype=float)
out = []
for r in sig.itertuples(index=False):
entry_idx = int(r.entry_idx)
agree = getattr(r, 'htf_agree', None)
htf_dir = getattr(r, 'htf_dir', None)
out.append(ChanFastBSP(
time=times[entry_idx],
price=close[entry_idx],
ddir=Chan_BSP_DIR.BUY if r.direction == 1 else Chan_BSP_DIR.SELL,
entry_idx=entry_idx,
bo_time=times[int(r.bo_idx)],
pb_time=times[int(r.pb_idx)] if r.pb_idx == r.pb_idx else None,
lag=r.lag,
depth=r.depth,
zg=r.zg,
zd=r.zd,
occ=r.occ,
htf_dir=None if htf_dir is None or htf_dir != htf_dir else int(htf_dir),
htf_agree=None if agree is None or agree != agree else bool(agree),
ladder_ok=bool(r.ladder_ok),
))
self.fast_bsp_list = out
return out
-171
View File
@@ -1,171 +0,0 @@
"""增量更新:新K只追加 KLU/KLC,笔与笔中枢在当前列表上重算。
不改 init_TF_DF 的整段语义笔必须整表重扫最后一笔 is_sure 允许收回
OWN_CHAN_ZS_001 60 天出现 7 笔中枢用 cal_bi_zs_list_pure
"""
from __future__ import annotations
from datetime import datetime
import pandas as pd
from pandas import DataFrame
from chanlun.pipeline.resample import resample_to_interval
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_KLC_STATE
from chanlun.core.ChanKLU import ChanKLU
class IncrementalBuilderMixin:
def init_stream(self, df, interval=1, timeframe=None):
"""用历史K线初始化流式状态,之后用 append_bar / replace_last_bar。"""
if df is None or df.empty:
raise ValueError("DataFrame for stream is empty.")
if "date" not in df.columns:
raise ValueError(f"DataFrame missing 'date' column. Columns: {df.columns.tolist()}")
self.timeframe = timeframe
self.interval = interval
if interval == 1:
self.dataframe = df.copy()
else:
self.dataframe = resample_to_interval(df, interval)
self.dataframe = self.add_indicators(self.dataframe)
self.klu_list = []
self.klc_list = []
self.bi_list = []
self.bi_zs_list = []
self.seg_list = []
self.zs_list = []
self.bsp_list = []
self.klc_fx_list = []
self.big_zs_list = []
self._klc_feed_last_klu = None
for i in range(len(self.dataframe)):
self._append_row_at(i, rebuild=False)
self.rebuild_bi_zs()
return self
def append_bar(self, row):
"""追加一根已收盘K线。同一时间戳则改为替换最后一根。"""
self._ensure_stream_state()
item = self._normalize_row(row)
if self.klu_list and self.klu_list[-1].time == self._row_time_str(item):
return self.replace_last_bar(item)
self._append_item_to_dataframe(item)
self.dataframe = self.add_indicators(self.dataframe)
self._append_row_at(len(self.dataframe) - 1, rebuild=True)
return self
def replace_last_bar(self, row):
"""更新最后一根K(未完成K线走新OHLC)。包含关系从 KLU 列表重放。"""
self._ensure_stream_state()
if not self.klu_list:
return self.append_bar(row)
item = self._normalize_row(row)
idx = self.dataframe.index[-1]
for key, val in item.items():
self.dataframe.at[idx, key] = val
self.dataframe = self.add_indicators(self.dataframe)
self._apply_item_to_klu(self.klu_list[-1], self.dataframe.iloc[-1])
self._rebuild_klc_from_klu()
self.rebuild_bi_zs()
return self
def rebuild_bi_zs(self):
"""在当前 KLC 上重算笔 + cal_bi_zs_list_pure。会先清分型标记。"""
self._reset_klc_bi_marks(self.klc_list)
self.bi_list = self.cal_bi_list(self.klc_list) if self.klc_list else []
self.bi_zs_list = self.cal_bi_zs_list_pure(self.bi_list) if self.bi_list else []
return self.bi_zs_list
def _ensure_stream_state(self):
if not hasattr(self, "klu_list") or self.klu_list is None:
self.klu_list = []
if not hasattr(self, "klc_list") or self.klc_list is None:
self.klc_list = []
if not hasattr(self, "dataframe") or self.dataframe is None:
self.dataframe = DataFrame(
columns=["date", "open", "high", "low", "close", "volume"]
)
if not hasattr(self, "_klc_feed_last_klu"):
self._klc_feed_last_klu = self.klu_list[-1] if self.klu_list else None
if not hasattr(self, "bi_zs_list"):
self.bi_zs_list = []
def _rebuild_klc_from_klu(self):
self.klc_list = []
last_klu = None
for klu in self.klu_list:
self._push_klu_into_klc_list(self.klc_list, klu, last_klu)
last_klu = klu
self._klc_feed_last_klu = last_klu
def _append_row_at(self, idx, rebuild=True):
item = self.dataframe.iloc[idx]
klu = self._klu_from_item(item, idx)
if self.klu_list:
self.klu_list[-1].set_next(klu)
klu.set_pre(self.klu_list[-1])
self._push_klu_into_klc_list(self.klc_list, klu, self._klc_feed_last_klu)
self._klc_feed_last_klu = klu
self.klu_list.append(klu)
if rebuild:
self.rebuild_bi_zs()
def _klu_from_item(self, item, idx):
klu = ChanKLU(
self._item_time_str(item),
item["open"],
item["high"],
item["low"],
item["close"],
item["volume"],
)
klu.set_idx(idx)
if not hasattr(klu, "ema13"):
klu.ema13 = 0
if "macd" in item:
klu.set_indicators(item)
return klu
def _apply_item_to_klu(self, klu, item):
klu.time = self._item_time_str(item)
klu.open = item["open"]
klu.high = item["high"]
klu.low = item["low"]
klu.close = item["close"]
klu.volume = item["volume"]
klu.range = klu.high - klu.low
klu.body = abs(klu.close - klu.open)
if "macd" in item:
klu.set_indicators(item)
def _reset_klc_bi_marks(self, klc_list):
for klc in klc_list:
klc.fx = Chan_FX_TYPE.UNKNOWN
klc.klc_fx_type = Chan_KLC_FX.UNKNOWN
klc.klc_state = Chan_KLC_STATE.UNKNOWN
klc.bi = None
klc.fx_confirmed = False
def _item_time_str(self, item):
date = item["date"]
if hasattr(date, "to_pydatetime"):
date = date.to_pydatetime()
if isinstance(date, datetime):
return date.strftime("%Y-%m-%d %H:%M:%S")
return str(date)
def _row_time_str(self, item):
return self._item_time_str(item)
def _normalize_row(self, row):
if isinstance(row, pd.Series):
return row
return pd.Series(row)
def _append_item_to_dataframe(self, item):
row_df = DataFrame([item])
if self.dataframe is None or self.dataframe.empty:
self.dataframe = row_df
else:
self.dataframe = pd.concat([self.dataframe, row_df], ignore_index=True)
+38 -46
View File
@@ -6,8 +6,9 @@ from decimal import Decimal
import numpy as np import numpy as np
import pandas as pd import pandas as pd
from chanlun.indicators import ta import talib.abstract as ta
from pandas import DataFrame from pandas import DataFrame
from technical.util import resample_to_interval
from chanlun.core.ChanBI import ChanBI from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS from chanlun.core.ChanBIZS import ChanBIZS
@@ -54,14 +55,8 @@ class IndicatorsBuilderMixin:
return None return None
def add_indicators(self, df): def add_indicators(self, df):
"""算指标并一次性挂到 df 上。 fast = 12
slow = 26
这里不逐列 `df['x'] = ...`那样每一列都触发一次 BlockManager 插入
30 多列的开销比全部 TA 计算本身还大2001 行实测 TA 合计 2.5ms
逐列赋值 3.6ms增量路径每根都要走一遍这笔开销是白付的
"""
fast = 26
slow = 52
period = 9 period = 9
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period) macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0) bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
@@ -81,43 +76,40 @@ class IndicatorsBuilderMixin:
bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband']) bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband'])
bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband']) bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband'])
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband']) bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
cols = { df['bb2633upper'] = bb2633['upperband']
'bb2633upper': bb2633['upperband'], df['bb2633lower'] = bb2633['lowerband']
'bb2633lower': bb2633['lowerband'], df['bbp2633'] = bbp2633
'bbp2633': bbp2633, df['bb2633middle'] = bb2633['middleband']
'bb2633middle': bb2633['middleband'], df['atr'] = ta.ATR(df, timeperiod=14)
'atr': ta.ATR(df, timeperiod=14), df['bbup365'] = bb365['upperband']
'bbup365': bb365['upperband'], df['bblow365'] = bb365['lowerband']
'bblow365': bb365['lowerband'], df['bbp365'] = bbp365
'bbp365': bbp365, df['bbup120'] = bb120['upperband']
'bbup120': bb120['upperband'], df['bblow120'] = bb120['lowerband']
'bblow120': bb120['lowerband'], df['bbp120'] = bbp120
'bbp120': bbp120, df['bbup30'] = bb30['upperband']
'bbup30': bb30['upperband'], df['bblow30'] = bb30['lowerband']
'bblow30': bb30['lowerband'], df['bbmiddle30'] = bb30_middle # 添加bb30中轨
'bbmiddle30': bb30_middle, df['bbp30'] = bbp30
'bbp30': bbp30, df['bbup302'] = bb302['upperband']
'bbup302': bb302['upperband'], df['bblow302'] = bb302['lowerband']
'bblow302': bb302['lowerband'], df['bbp302'] = bbp302
'bbp302': bbp302, df['macd'] = macd['macd']
'macd': macd['macd'], df['macdsignal'] = macd['macdsignal']
'macdsignal': macd['macdsignal'], df['macdhist'] = macd['macdhist']
'macdhist': macd['macdhist'], df['ema5'] = ta.EMA(df, timeperiod=5)
} df['ema10'] = ta.EMA(df, timeperiod=10)
for _p, _n in ((5, 'ema5'), (10, 'ema10'), (24, 'ema24'), (52, 'ema52'), df['ema24'] = ta.EMA(df, timeperiod=24)
(104, 'ema104'), (156, 'ema156'), (208, 'ema208'), df['ema52'] = ta.EMA(df, timeperiod=52)
(26, 'ema26'), (13, 'ema13'), (7, 'ema7')): df['ema104'] = ta.EMA(df, timeperiod=104)
cols[_n] = ta.EMA(df, timeperiod=_p) df['ema156'] = ta.EMA(df, timeperiod=156)
cols['rsi'] = ta.RSI(df, timeperiod=14) df['ema208'] = ta.EMA(df, timeperiod=208)
cols['volume_ratio'] = self.cal_volume_ratio(df) df['ema26'] = ta.EMA(df, timeperiod=26)
df['ema13'] = ta.EMA(df, timeperiod=13)
# 重复调用(增量路径每根都会)时先摘掉旧列,否则 concat 出重名列。 df['ema7'] = ta.EMA(df, timeperiod=7)
# 摘掉再接回末尾,列序与逐列覆盖的结果一致。 df['rsi'] = ta.RSI(df, timeperiod=14)
new = pd.DataFrame(cols, index=df.index) df['volume_ratio'] = self.cal_volume_ratio(df)
dup = [c for c in new.columns if c in df.columns] return df
if dup:
df = df.drop(columns=dup)
return pd.concat([df, new], axis=1)
def get_ema_state(self, dataframe): def get_ema_state(self, dataframe):
klu_list = self.get_klu_list(dataframe) klu_list = self.get_klu_list(dataframe)
+95 -119
View File
@@ -6,7 +6,9 @@ from decimal import Decimal
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import talib.abstract as ta
from pandas import DataFrame from pandas import DataFrame
from technical.util import resample_to_interval
from chanlun.core.ChanBI import ChanBI from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS from chanlun.core.ChanBIZS import ChanBIZS
@@ -84,8 +86,7 @@ class KlineBuilderMixin:
return Chan_FX_TYPE.UNKNOWN return Chan_FX_TYPE.UNKNOWN
def check_fx(self, klc): def check_fx(self, klc):
# 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回 if klc.pre and klc.next:
if klc.pre and klc.next and klc.next.end_klu is not None:
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low: if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0: #if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
klc.set_fx(Chan_FX_TYPE.TOP) klc.set_fx(Chan_FX_TYPE.TOP)
@@ -98,21 +99,6 @@ class KlineBuilderMixin:
return Chan_FX_TYPE.BOTTOM return Chan_FX_TYPE.BOTTOM
return Chan_FX_TYPE.UNKNOWN return Chan_FX_TYPE.UNKNOWN
def check_fx3(self, klc):
# 右K未完成(仍在包含合并)时不分型:否则确认笔会随 next 扩区间被 check_*_fx 收回
if klc.pre and klc.next and klc.next.end_klu is not None:
next_klu = klc.next.end_klu.next
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low and klc.high > next_klu.high:
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
klc.set_fx(Chan_FX_TYPE.TOP)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
return Chan_FX_TYPE.TOP
elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high and klc.low < next_klu.low:
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
klc.set_fx(Chan_FX_TYPE.BOTTOM)
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
return Chan_FX_TYPE.BOTTOM
return Chan_FX_TYPE.UNKNOWN
def check_fx2(self, klc): def check_fx2(self, klc):
if klc.pre and klc.next: if klc.pre and klc.next:
if klc.high > klc.pre.close and klc.close > klc.next.close and klc.close > klc.pre.close and klc.close > klc.next.close: if klc.high > klc.pre.close and klc.close > klc.next.close and klc.close > klc.pre.close and klc.close > klc.next.close:
@@ -128,138 +114,128 @@ class KlineBuilderMixin:
return Chan_FX_TYPE.UNKNOWN return Chan_FX_TYPE.UNKNOWN
def check_fx_pattern(self, klc): def check_fx_pattern(self, klc):
"""给分型前后三根 KLC 的裸 K 打 pattern 标记。
原本还会把 `klu.to_string()` 拼成一个字符串那是给下面那行注释掉的
print 用的拼完就丢它在 cal_bi_list 的内层2000 根上要跑近三万次
f-string + 六万次 enum 格式化是纯废动作已删
`klu.pattern` 只被 cal_klu_pattern 自己的双 K / K 判定读
不出这个模块也不进 web 序列化所以 lean 下整个调用可跳
"""
if getattr(self, 'lean', False):
return
klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list
self.cal_klu_pattern(klu_list) self.cal_klu_pattern(klu_list)
p = ""
for klu in klu_list:
p += klu.to_string()
#print(p)
def cal_volume_ratio(self, dataframe, window=10): def cal_volume_ratio(self, dataframe, window=10):
"""当根量 / 前 window 根均量。前 window-1 根无基准,填 1.0。 df = dataframe.copy()
# 计算过去N根K线的平均成交量
原写法先 `dataframe.copy()` 再挂两列为算一列 rolling 复制了整张 df['avg_volume'] = df['volume'].rolling(window=window).mean()
四十列的表直接在 Series 上算结果逐值相同 # 计算量比
""" df['volume_ratio'] = df['volume'] / df['avg_volume']
vol = dataframe['volume'] # 填充缺失值(前N根K线)
return (vol / vol.rolling(window=window).mean()).fillna(1.0).rename('volume_ratio') df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
return df['volume_ratio']
def cal_kl_data(self, dataframe:DataFrame): def cal_kl_data(self, dataframe:DataFrame):
"""按行构造 KLU 链。 fields = "time,open,high,low,close,volume"
这里刻意不用 `dataframe.iloc[i]`那会为每一根新建一个几十列的 Series
随后 set_indicators 再在其上做几十次逐键查找实测这两件事合计占 TF_DF
构建耗时的 96%改为先把用到的列取成 ndarray循环里只做整数下标访问
"""
n = len(dataframe)
if n == 0:
return []
times = self._format_times(dataframe['date'])
o_a = dataframe['open'].to_numpy(dtype=float)
h_a = dataframe['high'].to_numpy(dtype=float)
l_a = dataframe['low'].to_numpy(dtype=float)
c_a = dataframe['close'].to_numpy(dtype=float)
v_a = dataframe['volume'].to_numpy(dtype=float)
has_ind = 'macd' in dataframe.columns
ind_cols = {}
if has_ind:
for _attr, col in ChanKLU.INDICATOR_FIELDS:
if col in dataframe.columns:
ind_cols[col] = dataframe[col].to_numpy(dtype=float)
klu_list = [] klu_list = []
last_klu = None last_klu = None
for i in range(n): for i in range(0, len(dataframe)):
klu = ChanKLU(times[i], o_a[i], h_a[i], l_a[i], c_a[i], v_a[i]) item = dataframe.iloc[i]
date = item['date']
o = item['open']
h = item['high']
l = item['low']
c = item['close']
v = item['volume']
# time_obj = date.fromtimestamp(date)
# date = date + timedelta(hours=8)
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
item_data = [
time_str,
o,
h,
l,
c,
v
]
# klu = KLU(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)))
klu = ChanKLU(time_str, o, h, l, c, v)
# print(klu.time, klu.open, klu.high, klu.low, klu.close, klu.volume)
klu.set_idx(i) klu.set_idx(i)
klu_list.append(klu) klu_list.append(klu)
if last_klu: if last_klu:
last_klu.set_next(klu) last_klu.set_next(klu)
klu.set_pre(last_klu) klu.set_pre(last_klu)
last_klu = klu last_klu = klu
if has_ind: if 'macd' in item:
klu.set_indicators_from(ind_cols, i) klu.set_indicators(item)
return klu_list return klu_list
@staticmethod
def _format_times(col):
"""向量化 strftime。非 datetime 列(少见)退回逐个格式化。"""
fmt = '%Y-%m-%d %H:%M:%S'
try:
return col.dt.strftime(fmt).to_numpy()
except AttributeError:
return np.array([d.strftime(fmt) for d in col], dtype=object)
def get_kl_data(self, dataframe:DataFrame): def get_kl_data(self, dataframe:DataFrame):
return self.cal_kl_data(dataframe) return self.cal_kl_data(dataframe)
def _push_klu_into_klc_list(self, klc_list, klu, last_klu): def get_klc_list(self, klu_list):
"""把一根 KLU 并入包含K线列表。与 get_klc_list 的几何规则相同。""" klc_list = []
if len(klc_list) > 0: last_klu = None
last_klc = klc_list[-1] # ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)
if klu.exception: macd = ChanMACD(klu_list)
ddir = Chan_KLINE_DIR.DOWN klu_list = macd.klu_list
if last_klc.high < klu.high: self._last_chan_macd = macd
ddir = Chan_KLINE_DIR.UP ema_up_list = []
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) ema_down_list = []
klc.high = klu.close if klu.close > klu.open else klu.open ema_up_count = 0
klc.low = klu.open if klu.close > klu.open else klu.close ema_down_count = 0
klc_list.append(klc) last_klu = None
last_klc.set_next(klc) for klu in klu_list:
klc.set_pre(last_klc) ema = klu.ema52
last_klc.set_end_klu(last_klu) last_ema = last_klu.ema52 if last_klu else 0
klc.set_pre_fx() if klu.close >= ema:
else: ema_up_count += 1
included = last_klc.check_klu_included(klu) elif klu.close < ema:
if not included: ema_down_count += 1
if last_klu and last_klu.close >= last_ema and klu.close < ema:
ema_up_list.append(ema_up_count)
#print(last_klu.time, ema_up_count, "UP END")
ema_up_count = 0
elif last_klu and last_klu.close < last_ema and klu.close >= ema:
ema_down_list.append(ema_down_count)
#print(last_klu.time, ema_down_count, "DOWN END")
ema_down_count = 0
if len(klc_list) > 0:
last_klc = klc_list[-1]
if klu.exception:
ddir = Chan_KLINE_DIR.DOWN ddir = Chan_KLINE_DIR.DOWN
if last_klc.high < klu.high: if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.UP ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir) klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc.high = klu.close if klu.close > klu.open else klu.open
klc.low = klu.open if klu.close > klu.open else klu.close
klc_list.append(klc) klc_list.append(klc)
last_klc.set_next(klc) last_klc.set_next(klc)
klc.set_pre(last_klc) klc.set_pre(last_klc)
last_klc.set_end_klu(last_klu) last_klc.set_end_klu(last_klu)
klc.set_pre_fx() klc.set_pre_fx()
#print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception)
else: else:
last_klc.add_klu(klu) included = last_klc.check_klu_included(klu)
else: if not included:
ddir = Chan_KLINE_DIR.UP ddir = Chan_KLINE_DIR.DOWN
if klu.open > klu.close: if last_klc.high < klu.high:
ddir = Chan_KLINE_DIR.DOWN ddir = Chan_KLINE_DIR.UP
klc = ChanKLC(klu, 0, ddir) klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
klc_list.append(klc) klc_list.append(klc)
last_klc.set_next(klc)
def get_klc_list(self, klu_list): klc.set_pre(last_klc)
klc_list = [] last_klc.set_end_klu(last_klu)
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)。 klc.set_pre_fx()
# lean 模式跳过整套 MACD 状态机:它只服务于 bsp/背驰/web 展示,笔与中枢不依赖它。 else:
if getattr(self, 'lean', False): last_klc.add_klu(klu)
self._last_chan_macd = None else:
else: ddir = Chan_KLINE_DIR.UP
macd = ChanMACD(klu_list) if klu.open > klu.close:
klu_list = macd.klu_list ddir = Chan_KLINE_DIR.DOWN
self._last_chan_macd = macd klc = ChanKLC(klu, 0, ddir)
klc_list.append(klc)
last_klu = None
for klu in klu_list:
self._push_klu_into_klc_list(klc_list, klu, last_klu)
last_klu = klu last_klu = klu
# cal_trend 只产出 klc.trend,而全仓只有它自己(经 prev_klcs 读自身序列 klc_list = self.cal_trend(klc_list)
# 状态)、一个 __str__ 和 web 的 klc_trend 图层消费它——笔与中枢不读。 #print(ema52_up_list, ema52_down_list)
# 增量路径的 klc 其 trend 恒为 UNKNOWN 却与批量构建逐字段相同,是实证。
# 所以 lean 下可跳;web 走非 lean,图层不受影响。
if not getattr(self, 'lean', False):
klc_list = self.cal_trend(klc_list)
return klc_list return klc_list
+2
View File
@@ -6,7 +6,9 @@ from decimal import Decimal
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import talib.abstract as ta
from pandas import DataFrame from pandas import DataFrame
from technical.util import resample_to_interval
from chanlun.core.ChanBI import ChanBI from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS from chanlun.core.ChanBIZS import ChanBIZS
+8 -11
View File
@@ -6,7 +6,9 @@ from decimal import Decimal
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import talib.abstract as ta
from pandas import DataFrame from pandas import DataFrame
from technical.util import resample_to_interval
from chanlun.core.ChanBI import ChanBI from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanBIZS import ChanBIZS from chanlun.core.ChanBIZS import ChanBIZS
@@ -353,12 +355,9 @@ class ZsBuilderMixin:
return bi_zs_list return bi_zs_list
def get_zs_range(bis): def get_zs_range(bis):
bis_list = bis[0:3] zg = min(bi.high for bi in bis)
zg = min(bi.high for bi in bis_list) zd = max(bi.low for bi in bis)
zd = max(bi.low for bi in bis_list) return zg, zd
dd = min(bi.low for bi in bis_list)
gg = max(bi.high for bi in bis_list)
return zg, zd, dd, gg
def is_bi_overlap_range(bi, zg, zd): def is_bi_overlap_range(bi, zg, zd):
return bi.high >= zd and bi.low <= zg return bi.high >= zd and bi.low <= zg
@@ -376,8 +375,8 @@ class ZsBuilderMixin:
zs.bi_list = list(bis) zs.bi_list = list(bis)
for bi in zs.bi_list: for bi in zs.bi_list:
bi.set_bi_zs(zs) bi.set_bi_zs(zs)
#zs.set_gg(max(bi.high for bi in zs.bi_list)) zs.set_gg(max(bi.high for bi in zs.bi_list))
#zs.set_dd(min(bi.low for bi in zs.bi_list)) zs.set_dd(min(bi.low for bi in zs.bi_list))
zs.classify_zs() zs.classify_zs()
last_zs = None last_zs = None
@@ -395,7 +394,7 @@ class ZsBuilderMixin:
start_idx += 1 start_idx += 1
continue continue
zg, zd, dd, gg = get_zs_range([bi1, bi2, bi3]) zg, zd = get_zs_range([bi1, bi2, bi3])
if zg <= zd: if zg <= zd:
start_idx += 1 start_idx += 1
continue continue
@@ -421,8 +420,6 @@ class ZsBuilderMixin:
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir) zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
zs.set_zg(zg) zs.set_zg(zg)
zs.set_zd(zd) zs.set_zd(zd)
zs.set_dd(dd)
zs.set_gg(gg)
set_zs_bi_list(zs, bis_for_zs) set_zs_bi_list(zs, bis_for_zs)
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time) zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)

Some files were not shown because too many files have changed in this diff Show More