75 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
jackyu66gitandCursor 1e60ab3bfa docs: 补强 ECR-004 CODE_REVIEW 复审记录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:47:18 +08:00
jackyu66gitandCursor d3188ca83c fix: ECR-004 威科夫区间评分硬化与 VP 绘图减负(已审)
评分选 TR、阶段最小跨度、elements_only 门闩、Top-8 VP;无币种独立参数。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:46:08 +08:00
jackyu66gitandCursor ac6be80278 docs: 开启 ECR-004 威科夫硬化与 VP 减负(Draft)
跟进 ECR-003 Review Findings;待 Approve 后实现。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:35:25 +08:00
jackyu66gitandCursor 081a57a90e feat: ECR-003 主站威科夫分析与图表叠层(已审)
独立 wyckoff 引擎 + 按需 include_wyckoff;主站 Lightweight 绘制区间/阶段/事件/VP。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:33:57 +08:00
jackyu66gitandCursor df27b4dde8 refactor: ECR-002 拆分 runtime 包并加深 analyze 契约(已审)
将 web/services/runtime.py 拆为 runtime/ 子模块并保持门面兼容;补齐 ESS 文档、门面/契约/TF_DF 测试与 CODE_REVIEW Approve。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:15:23 +08:00
jackyu66gitandCursor 9f1e7361b6 fix: 修复主站自动刷新内存泄漏,并完善 chan_tv 图表体验
主站重建前完整 dispose、去掉重复 sync 监听,自动刷新默认增量更新;顺带消除首屏重复 analyze、复用 ChanMACD,以及全版 TV 指标/未完成中枢/布局本地缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:09:48 +08:00
jackyu66gitandCursor 6b0f3b5837 release: 发布系统版本 v1.0.0(ECR-001)
落盘 CODE_REVIEW Approve 与 RELEASE_REPORT,标记首个正式 release。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:53:04 +08:00
jackyu66gitandCursor 74dec4e50b refactor: 缠论引擎包化与 Web 分层(ECR-001)
将根目录引擎迁入 chanlun/ 并保留兼容 shim;拆分 TF_DF 与 web 服务;
前端模块化;strategies 改用 chanlun 导入;补充 ESS 文档与 golden 回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:48:20 +08:00
jackyu66gitandCursor e2e45bc1bc chore: 移除不再使用的 ChanMacro、system、tests。
这些目录已废弃,从仓库中清理。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:11:29 +08:00
jackyu66gitandCursor f2e77e1bdb chore: 将 data_provider 拆出为独立仓库。
数据服务已迁移至 jack/data_provider,不再随 chan 维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:10:29 +08:00
jackyu66gitandCursor b31215057e chore: 将 bsp_monitor 拆出为独立仓库。
监控服务已迁移至 jack/bsp_monitor,不再随 chan 维护。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:09:43 +08:00
jackyu66git 2e905e7238 feat: 所有页面接入 Google Analytics (G-LVVXH3TL04) 2026-07-02 15:57:15 +08:00
jackyu66git 02a52c04dd feat: 所有页面接入 Google Analytics (G-LVVXH3TL04) 2026-07-02 15:38:31 +08:00
jackyu66git 19c8f86862 docs: API 手册新增 Onchain Metrics 专题 + endpoints 表更新 2026-07-02 15:19:45 +08:00
jackyu66git ffe7074fef data_provider: 新增链上指标模块 (btc_netflow/stablecoin_supply/etf_flow/mvrv_zscore)
- onchain_metrics.py: 独立模块,CoinMetrics/CoinGecko/Farside 免费数据源
- main.py: 集成后台线程 + REST API (/api/onchain/metrics, /latest, /available)
- requirements.txt: 添加 requests 依赖
- 5分钟自动刷新,CSV 落盘到 data/onchain/
2026-07-02 15:09:02 +08:00
jackyu66git 7b91f459d7 scheduler: auto-detect new signals once per day, deduplicate existing
- scheduler tick runs detect after fetch+score (once per UTC day)
- ChanSignalDetector skips already-recorded signals
- Prevents duplicate signal_features entries on repeated runs
2026-06-24 19:19:40 +08:00
jackyu66git 3c72aa1310 chan_integration: auto-detect BSP signals from daily+4h Chan pipeline
- ChanSignalDetector: runs TF_DF pipeline on historical OHLCV
- Extracts B1/B2/B3/S1/S2/S3 with entry price, date, signal grade
- Populates signal_features via SignalTracker with forward outcomes
- CLI: python main.py detect --from 2024-01-01
- 15 signals detected (5 daily + 10 4h), all directionally correct
- Expectancy API now returns real conditional probabilities
2026-06-24 19:19:11 +08:00
jackyu66git 8d916371e2 backfill: historical breadth + regime computation from TOP50 OHLCV
- Step 1: fetch BTC OHLCV
- Step 2: fetch TOP50 daily data → compute breadth per date → store breadth_daily
- Step 3: compute Price/Breadth/OI/Vol → detect regime → store regime_history
- 175 days backfilled (2026-01-01 to 2026-06-24)
2026-06-24 18:37:20 +08:00
jackyu66git 7e19c9858e scheduler: auto fetch+score every 60min, integrated into web and CLI 2026-06-24 18:35:47 +08:00
jackyu66git efb721b39f fix: persist regime to DB in shared _build_state, deduplicate save logic
- _build_market_state (CLI) now saves regime_history automatically
- _build_state (web) now saves regime_history automatically
- Remove duplicate regime save from cmd_score
- Remove unused imports (timedelta, get_connection)
- Fix: web dashboard never updated regime_history table
2026-06-24 18:31:00 +08:00
jackyu66git 7813e319b4 web: professional trading-terminal redesign — dark theme, chart grid, progress bars 2026-06-24 18:28:40 +08:00
jackyu66git f391020f78 web: fix dark theme readability — explicit bright colors for all factor values 2026-06-24 18:27:41 +08:00
jackyu66git 0ba5b3bd71 chanmacro: add web dashboard (Flask + Chart.js, port 8124)
- /api/state: current market state with all factor scores
- /api/history: regime + breadth history for charts
- /api/expectancy: signal expectancy query
- Bootstrap 5 + Chart.js dark theme, Chinese UI
- Factor cards, regime timeline, breadth chart, expectancy table
2026-06-24 18:25:33 +08:00
jackyu66git 50a609f7b9 chanmacro: connect to production provider, fix Breadth symbol list and regime crash
- Change provider_url to https://provider.jackyu66.com
- Update top50_symbols to match provider's actual 20 symbols
- Fix cmd_score crash: all_scores keys are already strings, not enums
- Add .gitignore to exclude data/ directory
2026-06-24 18:23:27 +08:00
jackyu66git 48e69179b3 data_provider: add /api/derivatives endpoint documentation 2026-06-24 17:47:42 +08:00
jackyu66gitandClaude 71951019fb chanmacro: Signal Expectancy Engine V1 — Market Memory System
Phase A-C complete: 4 core factors, regime detection, signal tracking, Bayesian expectancy.

chanmacro/ (32 files, ~4000 lines):
- models: 12 enums + 15 Pydantic v2 models (DateAwareModel, MarketStateVector, etc.)
- fetchers: OHLCV + Breadth (from data_provider) + Derivatives (new endpoint)
- scoring: Price Structure / Breadth (quantile buckets) / OI Matrix (5 discrete states) / Volatility Regime
- regime_detector: 3-state (TREND/RANGE/PANIC), factor-locked (Price+Breadth+Vol), versioned, 2-day confirmation
- expectancy: SignalTracker (record+outcomes), TimeDecay (half-life=180d), BayesianExpectancyEngine (Empirical Bayes, Leveled, SufficiencyGuard)
- validation: FactorValidator (IC/ICIR/Hit Ratio), RegimeValidator (MI/KL/ANOVA), TransitionValidator (stability)
- CLI: fetch|score|regime|track|backfill|expectancy|validate|serve
- tests: 52 passing (models, scoring, regime, expectancy)

data_provider:
- /api/derivatives endpoint: funding rate, OI, OI change, basis
- _derivatives storage: same persist pattern as K-line (merge→lock→snapshot→atomic write)
- background refresh every 60s

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 17:44:55 +08:00
jackyu66git 34040575c1 bsp_monitor: fetcher limit=1000, engine/main tweaks 2026-06-04 12:51:58 +08:00
jackyu66git e1116edb7b 更新了本地数据拉取 2026-05-26 14:49:31 +08:00
jackyu66gitandClaude Opus 4.7 8bc23c0507 notify: 移除持久化去重,BSP 由新笔确认驱动不重复
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:46:10 +08:00
jackyu66gitandClaude Opus 4.7 c75d5e11fc notify: Telegram token/chatid 直接硬编码
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:45:41 +08:00
jackyu66gitandClaude Opus 4.7 8eb50e3eae bsp_monitor: 多周期 BSP 推送 (1m/5m/15m/1h),中枢监控代码保留但暂停
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:42:53 +08:00
jackyu66gitandClaude Opus 4.7 42296ef971 refactor: BSP推送提取公共函数 + 清理
- _push_bsp() 提取重复的key构造+推送逻辑
- bsp.klc None防护
- getattr替代hasattr+属性访问
- 修正首轮日志(不再写"不推送")

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:05:20 +08:00
jackyu66gitandClaude Opus 4.7 a84a80cb62 Revert: BSP独立跟踪confirmed[-1],不与中枢监控耦合
B1/B2依赖中枢存在,不能等中枢更新才查BSP。
保持独立bi_id跟踪confirmed[-1]变化,分型确认时即查BSP。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:02:07 +08:00
jackyu66gitandClaude Opus 4.7 63787b173a fix: 新笔确认即查BSP(反向二类分型触发),首轮也查confirmed[-1]
笔被反向二类分型确认时is_sure=True,不需要等下一笔。
- 跟踪confirmed[-1]变化→查新确认那笔自身的BSP
- 首轮也查confirmed[-1],避免漏掉监控启动前刚确认的BSP

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:59:02 +08:00
jackyu66gitandClaude Opus 4.7 07f079067f fix: 跟踪confirmed[-1],新笔确认时查上一轮confirmed[-1]的BSP
以前跟踪bi_list[-1](含未确认)导致时序不对。
现在跟踪confirmed[-1]的稳定ID,变化时在bi_list中精确定位
上一轮的那笔,查其end_klc是否为BSP。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:55:48 +08:00
jackyu66gitandClaude Opus 4.7 ae4c79c133 fix: BSP检测用last_bi_id精确定位旧笔,替代confirmed[-2]索引
管线重算后笔列表可能变化,confirmed[-2]不一定是刚结束的笔。
改为用上一轮的last_bi_id在当前bi_list中精确查找,
找到后再检查其end_klc是否为BSP。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:35:20 +08:00
jackyu66gitandClaude Opus 4.7 743c5d342e fix: review修复 — 补回last_df_ts、枚举替换魔数、移除死代码
- SymbolState 补回 last_df_ts,无效新K线时跳过管线
- _bi_id 添加 start_klc None 防护
- last_bi.dir.value == 1 改为 Chan_BI_DIR.UP 枚举比较
- notify.py 移除未使用的 register_bsp_keys

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:56:17 +08:00
jackyu66gitandClaude Opus 4.7 78d02cf2ef refactor: BSP检测改为新笔驱动,不再逐tick对比BSP列表
- 用 last_bi_id (start_klc.start_time) 跟踪最后一笔
- 新笔确认时检查上一笔终点是否为 BSP → 推送
- 中枢更新同样在新笔产生时触发
- 移除时间过滤、BSP列表diff、持久化去重等冗余逻辑
- 无新笔时快速跳过,tick从40s降到15s

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:52:36 +08:00
jackyu66gitandClaude Opus 4.7 cf9097a540 fix: zs_id 使用稳定时间戳替代 DataFrame 位置索引
_make_zs_id 原来用 start_klc.index,每次新K线导致 index 偏移,
monitor 误判为新中枢,每 tick 都推送。改为 start_klc.start_time,
时间戳不随 DataFrame 窗口偏移变化。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:07:58 +08:00
jackyu66gitandClaude Opus 4.7 881c9d5eac bsp_monitor: 支持全部20个币对 + 数据源切换至data_provider + Python 3.9兼容
- fetcher.py: 数据源从CCXT改为data_provider HTTP API,新增get_symbols()自动获取所有币对
- main.py: 重构为多币对架构,每个币对独立SymbolState(pivot_monitor/BSP去重/首轮抑制)
- engine.py: format_bsp_detail()支持动态币对名
- ChanPivotMonitor/Classifier: 修复Python 3.9类型注解兼容(X|None → Optional[X])
- 首轮初始化时不推送中枢和BSP,避免启动时20条消息轰炸

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:55:23 +08:00
jackyu66gitandClaude Opus 4.7 b1cbdca707 ChanPivotMonitor: 实时中枢特征跟踪 + Telegram推送
- ChanPivotClassifier: 提取 calc_duration/contraction/shift 为 @staticmethod,新增 compute_features()
- ChanPivotMonitor: 实时追踪当前中枢,bi_count 增长时重新计算 shift/contraction/duration
- bsp_monitor/fetcher: 改用 data_provider HTTP API 替代直连 CCXT
- bsp_monitor/notify: 新增 send_telegram_message() 通用推送
- bsp_monitor/main: 集成 ChanPivotMonitor,有新笔或 BSP 时推送到 Telegram

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:35:09 +08:00
jackyu66gitandClaude Opus 4.6 5ad761fad4 添加 ChanPivotClassifier: 中枢结构特征提取 + 标签化
Phase 1 训练数据集构建工具,从笔中枢提取 3 特征 (duration_norm, contraction, shift_norm) + 1 标签 (break_direction)。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 18:33:56 +08:00
jackyu66git 9eae12f07d 修改了一点 2026-05-20 02:02:53 +08:00
jackyu66git 9b876c45ed 修改了bsp state,继续测试 2026-05-20 00:49:53 +08:00
jackyu66git 91148a648a 添加新的策略 2026-05-19 09:58:33 +08:00
jackyu66git 5dc0c4cffd data_provider: 添加 ccxt.pro WebSocket 实时K线监听;端口 9009→80;web/*.sh 权限修正 2026-05-19 09:56:37 +08:00
jackyu66git f0ea6a6065 添加 bsp_monitor: BTC/USDT 1m 缠论买卖点实时监控
- 每整分钟拉取 Binance 永续合约 1m K 线
- 运行完整缠论管线检测买卖点 (BSP)
- 新 BSP 推送到 Telegram
- fix: fetcher 用 limit=1000 替代固定 since,避免 API 500 根限制截断新数据
2026-05-18 08:43:23 +08:00
jackyu66git bc085171f4 添加新策略用第三类买卖点 2026-05-17 14:54:00 +08:00
jackyu66git 050ebeb849 添加tradingview advanced chart lib和实现chan_tv网页 2026-05-14 14:16:06 +08:00
jackyu66git ca2cf86138 Merge origin/dev: resolve conflicts in data_provider main.py 2026-05-13 18:37:53 +08:00
jackyu66git 0dd8f8a585 feat: multi-symbol support and per-tf start_time in data_provider 2026-05-13 18:34:39 +08:00
jackyu66gitandClaude Opus 4.6 ebcb3dce73 添加 StructureZone 结构价值区系统,支持多周期支撑/阻力分析
- 新增 ChanZone.py: 从笔中枢/线段中枢/EMA52 提取价格区,聚类评分
- ChanLun.py 新增 get_structure_zones() 方法
- web/app.py: 独立拉取多周期数据 + 缓存 + limit 传参避免全量传输
- web/index.html: 结构区勾选框 + K线数量输入 + 半透明填充区绘制
- tests/test_chan_zone.py: 24 个单元测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-12 01:31:56 +08:00
jackyu66gitandClaude Opus 4.6 d8069e977f 添加 CLAUDE.md,为 Claude Code 提供项目指引
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 09:52:05 +08:00
jackyu66git 3f68a8305a 修改了一点 2026-05-03 15:43:56 +08:00
jackyu66git deeea55237 ignore update 2026-05-02 02:12:46 +08:00
jackyu66git 5ab69c2a64 更新data_provider逻辑,能够更快开始提供服务,添加说明 2026-05-01 17:13:24 +08:00
jackyu66git 425d513a37 修改笔中枢第三类卖卖点识别,识别好后直接完成笔中枢 2026-04-30 15:20:37 +08:00
jackyu66git 8ff1515f8b 改了ema的颜色 2026-04-24 10:13:30 +08:00
jackyu66git d8e3cdd9e9 修改小周期笔无法显示bug 2026-04-16 23:53:40 +08:00
jackyu66git 04d8f73b94 修正了刷新图表缩放和位置不变的bug 2026-04-16 02:31:48 +08:00
jackyu66git 7815eada00 添加动能理论,修改klc整体显示 2026-04-10 13:01:07 +08:00
2279 changed files with 84713 additions and 20030 deletions
Vendored
BIN
View File
Binary file not shown.
+10
View File
@@ -37,3 +37,13 @@ feature_meta
.DS_Store
.DS_Store
.DS_Store
.DS_Store
data_provider/._config.json
.gstack/
# ESS gate / engineering-loop working dirs(归档进 docs/runs/
.gates/
loop/
# Crypto Wyckoff Screener local cache
data/crypto_wyckoff/
+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 -132
View File
@@ -1,132 +1,2 @@
from decimal import Decimal
import ChanKLC
from ChanEnum import Chan_BI_DIR
class ChanBI():
def __init__(self, klc: ChanKLC, index, ddir=Chan_BI_DIR.UP):
self.start_klc = klc
self.end_klc = klc
self.next = None
self.pre = None
self.dir = ddir
self.index = index
self.is_sure = False
self.high = klc.high
self.low = klc.low
self.sure_time = None
self.klc_list = []
self.klc_list.append(klc)
self.end_time = klc.end_time
self.start_time = klc.start_time
self.macd_hist = 0
self.macd_div = 0
self.seg = None
self.height = 0
self.width = 0
self.slop = 0
self.fib_list = []
self.seg_index = 0
self.bi_zs = None
self.seg_zs = None
def set_bi_zs(self, bi_zs):
for klc in self.klc_list:
klc.set_bi_zs(bi_zs)
def set_seg(self, seg):
self.seg = seg
self.seg_index = len(seg.bi_list)-1
def set_macdhist(self, macd_hist):
self.macd_hist = macd_hist
def set_macd_div(self, macd_div):
self.macd_div = macd_div
def cal_macd_div(self):
self.macd_div = 0.0
if self.pre and self.pre.pre:
if self.pre.pre.macd_hist == 0:
self.macd_div = 0.0
else:
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)
def cal_macdhist(self):
self.macd_hist = 0
for klc in self.klc_list:
for klu in klc.klu_list:
if self.dir == Chan_BI_DIR.UP and klu.macdhist > 0:
self.macd_hist += klu.macdhist
if self.dir == Chan_BI_DIR.DOWN and klu.macdhist < 0:
self.macd_hist -= klu.macdhist
def check_bi_zs_overlap(self):
if self.next and self.next.next:
if self.dir == Chan_BI_DIR.UP:
return self.low < self.next.next.high
else:
return self.high > self.next.next.low
else:
return False
def check_overlap(self):
if self.next and self.next.next and self.next.next.is_sure:
if self.dir == Chan_BI_DIR.UP:
return self.high > self.next.low and self.high < self.next.next.high
else:
return self.high > self.next.high and self.low > self.next.next.low
else:
return False
def set_end_klc(self, klc, sure_klc):
if self.dir == Chan_BI_DIR.UP and klc.high > self.high:
self.high = klc.high
if self.dir == Chan_BI_DIR.DOWN and klc.low < self.low:
self.low = klc.low
self.end_klc = klc
self.set_is_sure(True, sure_klc.end_time)
self.end_time = klc.end_time
self.cal_properties()
#print(self.start_time, klc.fx, "This bi is ended", len(self.klc_list), klc.index - self.start_klc.index)
def cal_properties(self):
if self.is_sure:
self.height = float(format(self.high - self.low, ".2f"))
self.width = self.end_klc.index - self.start_klc.index
self.slop = float(format(self.height / self.width, ".2f"))
fib_list = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0]
for fib in fib_list:
self.fib_list.append(float(format(self.height * fib + self.low, ".2f")))
#print(self.end_time, self.height, self.width, self.slop, self.fib_list)
def set_is_sure(self, is_sure, time):
self.is_sure = is_sure
self.sure_time = time
def set_start_klc(self, klc, ddir):
self.start_klc = klc
self.klc_list = []
self.klc_list.append(klc)
self.high = klc.high
self.low = klc.low
self.dir = ddir
def set_pre(self, bi):
self.pre = bi
def set_next(self, bi):
self.next = bi
def add_klc(self, klc):
added = False
if len(self.klc_list) > 0:
for index in range(0, len(self.klc_list)):
if self.klc_list[index].index == klc.index:
added = True
break
if not added:
self.klc_list.append(klc)
#print(self.start_time, klc.start_time)
#print(klc.end_time, klc.index)
self.end_klc = klc
self.end_time = klc.klu_list[-1].time
self.cal_macdhist()
self.cal_macd_div()
def append_klc_list(self, klc_list):
self.klc_list.append(klc_list)
def get_decimal(self, value):
return Decimal("{:.2f}".format(value))
def update_bi(self, klc):
self.end_klc = None
if self.dir == Chan_BI_DIR.UP and klc.high > self.high:
self.high = klc.high
if self.dir == Chan_BI_DIR.DOWN and klc.low < self.low:
self.low = klc.low
self.is_sure = False
self.sure_time = None
#print(self.start_time, klc.start_time, klc.fx, "This bi is extended")
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanBI import * # noqa: F403
+2 -161
View File
@@ -1,161 +1,2 @@
from ChanEnum import Chan_ZS_DIR, Chan_ZS_TYPE, Chan_BI_DIR
import ChanBI
# 中枢
class ChanBIZS():
def __init__(self, start_bi: ChanBI, index, ddir: Chan_ZS_DIR):
self.start_klc = start_bi.start_klc
self.start_time = self.start_klc.start_time
self.end_time = None
self.index = index
self.start_bi = start_bi
self.bi_list = []
self.bi_list.append(start_bi)
self.end_bi = None
self.bi_out = None
self.is_sure = False
self.zg = 0
self.zd = 0
self.gg = 0
self.dd = 0
self.dir = ddir
self.sure_time = None
self.end_klc = None
self.zs_type = Chan_ZS_TYPE.NORMAL
start_bi.set_bi_zs(self)
def set_end_bi(self, end_bi, sure_time):
self.end_bi = end_bi
self.set_end_time(end_bi.end_klc.end_time)
self.is_sure = True
self.sure_time = sure_time
end_bi.set_bi_zs(self)
#print(self.start_time, self.is_sure, len(self.bi_list), self.dir, self.zs_type)
def set_end_time(self, end_time):
self.end_time = end_time
def set_zg(self, zg):
self.zg = zg
def set_zd(self, zd):
self.zd = zd
def set_gg(self, gg):
self.gg = gg
def set_dd(self, dd):
self.dd = dd
def add_bi(self, bi: ChanBI):
if bi:
self.bi_list.append(bi)
if bi.high > self.gg:
self.gg = bi.high
if bi.low < self.dd:
self.dd = bi.low
bi.set_bi_zs(self)
self.classify_zs()
def set_pre(self, pre):
self.pre = pre
def set_next(self, next):
self.next = next
def classify_zs(self):
"""
根据中枢内笔的高低点变化趋势,对中枢进行分类
分类逻辑:
- 取中枢内向上笔的高点(peaks)和向下笔的低点(valleys
- 比较前半段和后半段的均值,判断高点和低点的整体趋势
分类结果:
- RISING 上升中枢:高点抬高 + 低点抬高 → 多方占优,可能向上突破
- FALLING 下行中枢:高点降低 + 低点降低 → 空方占优,可能向下突破
- CONVERGING 收敛中枢:高点降低 + 低点抬高 → 区间收窄,即将选择方向
- DIVERGING 扩散中枢:高点抬高 + 低点降低 → 波动加剧,市场不稳定
- NORMAL 常规中枢:无明显趋势 → 多空均衡,区间震荡
"""
if len(self.bi_list) < 3:
self.zs_type = Chan_ZS_TYPE.NORMAL
return
# 提取向上笔的高点(peaks)和向下笔的低点(valleys
peaks = [bi.high for bi in self.bi_list if bi.dir == Chan_BI_DIR.UP]
valleys = [bi.low for bi in self.bi_list if bi.dir == Chan_BI_DIR.DOWN]
high_trend = self._calc_trend(peaks)
low_trend = self._calc_trend(valleys)
if high_trend > 0 and low_trend > 0:
self.zs_type = Chan_ZS_TYPE.RISING
elif high_trend < 0 and low_trend < 0:
self.zs_type = Chan_ZS_TYPE.FALLING
elif high_trend < 0 and low_trend > 0:
self.zs_type = Chan_ZS_TYPE.CONVERGING
elif high_trend > 0 and low_trend < 0:
self.zs_type = Chan_ZS_TYPE.DIVERGING
else:
self.zs_type = Chan_ZS_TYPE.NORMAL
def _calc_trend(self, values):
"""
计算序列的趋势方向
将序列分为前后两半,比较均值:
- 后半均值 > 前半均值 → 返回 1(上升趋势)
- 后半均值 < 前半均值 → 返回 -1(下降趋势)
- 相等或数据不足 → 返回 0(无趋势)
使用均值比较而非首尾比较,可以过滤单笔异常波动带来的误判
"""
if len(values) < 2:
return 0
mid = len(values) // 2
first_half = values[:mid] if mid > 0 else values[:1]
second_half = values[mid:]
avg_first = sum(first_half) / len(first_half)
avg_second = sum(second_half) / len(second_half)
# 使用中枢区间的一定比例作为阈值,避免微小波动误判
threshold = abs(avg_first) * 0.005 if avg_first != 0 else 0
if avg_second - avg_first > threshold:
return 1
elif avg_first - avg_second > threshold:
return -1
else:
return 0
def is_weakening(self):
"""
判断中枢是否在衰弱(即将反向突破的信号)
衰弱条件:
1. 中枢内笔数 >= 5(有足够的数据判断)
2. 最后一笔的MACD面积相比同方向前一笔出现背驰(macd_div < 1
3. 中枢类型为收敛型或常规型
返回: True表示中枢力量衰弱,可能反向
"""
if len(self.bi_list) < 5:
return False
last_bi = self.bi_list[-1]
# 最后一笔与同方向前一笔比较MACD面积是否背驰
if last_bi.macd_div > 0 and last_bi.macd_div < 1.0:
return True
return False
def get_zs_strength(self):
"""
计算中枢强度,用于辅助判断中枢延续还是反向
返回字典包含:
- type: 中枢类型 (Chan_ZS_TYPE)
- bi_count: 中枢内笔数
- range_ratio: 中枢区间占比 = (zg - zd) / (gg - dd),越小说明中枢越紧密
- last_bi_div: 最后一笔的MACD背驰比率
- is_weakening: 是否衰弱
- is_extending: 是否在延伸(笔数 >= 9 可能升级)
"""
total_range = self.gg - self.dd if self.gg != self.dd else 1
zs_range = self.zg - self.zd if self.zg != self.zd else 0
range_ratio = zs_range / total_range if total_range > 0 else 0
last_bi_div = self.bi_list[-1].macd_div if len(self.bi_list) > 0 else 0
return {
'type': self.zs_type,
'bi_count': len(self.bi_list),
'range_ratio': round(range_ratio, 4),
'last_bi_div': round(last_bi_div, 4),
'is_weakening': self.is_weakening(),
'is_extending': len(self.bi_list) >= 9, # 9段可能升级
}
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanBIZS import * # noqa: F403
+2 -24
View File
@@ -1,24 +1,2 @@
import ChanBI
from ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR
class ChanBSP():
def __init__(self, bi: ChanBI, index, type: Chan_BSP_TYPE, ddir: Chan_BSP_DIR, sure_time, zs_count, zs, seg):
self.bi = bi
self.klc = bi.end_klc
self.index = index
self.type = type
self.start_time = self.klc.start_time
self.end_time = self.klc.end_time
if sure_time:
self.is_sure = True
self.sure_time = sure_time
else:
self.is_sure = False
self.sure_time = None
self.dir = ddir
self.zs_count = zs_count
self.zs = zs
self.seg = bi.seg
def set_sure_time(self, sure_time):
self.is_sure = True
self.sure_time = sure_time
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanBSP import * # noqa: F403
+2 -44
View File
@@ -1,44 +1,2 @@
from datetime import datetime
class ChanCTime:
def __init__(self, year, month, day, hour, minute, second=0, auto=True):
self.year = year
self.month = month
self.day = day
self.hour = hour
self.minute = minute
self.second = second
self.auto = auto # 自适应对天的理解
self.set_timestamp() # set self.ts
def __str__(self):
if self.hour == 0 and self.minute == 0:
return f"{self.year:04}/{self.month:02}/{self.day:02}"
else:
return f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}"
def to_str(self):
if self.hour == 0 and self.minute == 0:
return f"{self.year:04}/{self.month:02}/{self.day:02}"
else:
return f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}"
def toDateStr(self, splt=''):
return f"{self.year:04}{splt}{self.month:02}{splt}{self.day:02}"
def toDate(self):
return ChanCTime(self.year, self.month, self.day, 0, 0, auto=False)
def set_timestamp(self):
if self.hour == 0 and self.minute == 0 and self.auto:
date = datetime(self.year, self.month, self.day, 23, 59, self.second)
else:
date = datetime(self.year, self.month, self.day, self.hour, self.minute, self.second)
self.ts = date.timestamp()
def __gt__(self, t2):
return self.ts > t2.ts
def __ge__(self, t2):
return self.ts >= t2.ts
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanCTime import * # noqa: F403
+2 -368
View File
@@ -1,368 +1,2 @@
from enum import Enum, auto
from typing import Literal
class Chan_DATA_SRC(Enum):
BAO_STOCK = auto()
CCXT = auto()
CSV = auto()
class Chan_ZS_DIR(Enum):
UP = auto()
DOWN = auto()
class Chan_ZS_TYPE(Enum):
"""中枢类型分类"""
NORMAL = auto() # 常规中枢:高低点无明显趋势,区间震荡
RISING = auto() # 上升中枢:高点抬高,低点也抬高,重心上移
FALLING = auto() # 下行中枢:高点降低,低点也降低,重心下移
CONVERGING = auto() # 收敛中枢:高点降低,低点抬高,区间收窄(三角收敛)
DIVERGING = auto() # 扩散中枢:高点抬高,低点降低,区间扩大(喇叭口)
class Chan_K_DIR(Enum):
BULL = auto()
BEAR = auto()
CROSS = auto()
class Chan_EMA_POS(Enum):
"""K线与任意EMA的位置关系(与趋势方向无关的客观分类,支持threshold容差)"""
ABOVE = auto() # 完全在EMA上方(远离):low > ema + threshold
NEAR_ABOVE = auto() # 在EMA上方但接近:ema < low <= ema + threshold
CROSS_CLOSE_ABOVE = auto() # 跨越EMA,收盘在上方:close > ema, low <= ema(含threshold范围内触碰)
ON_EMA = auto() # 收盘价在EMA附近:abs(close - ema) <= threshold
CROSS_CLOSE_BELOW = auto() # 跨越EMA,收盘在下方:close < ema, high >= ema(含threshold范围内触碰)
NEAR_BELOW = auto() # 在EMA下方但接近:ema - threshold <= high < ema
BELOW = auto() # 完全在EMA下方(远离):high < ema - threshold
UNKNOWN = auto() # 未知(EMA值无效)
class Chan_EMA_SEMANTIC(Enum):
"""K线与EMA结合趋势方向的语义状态(用于交易判断)"""
STRONG_TREND = auto() # 7: 顺势K线完全在EMA趋势侧(强势,远未及EMA)
TREND_SIDE = auto() # 6: 完全在EMA趋势侧(正常趋势运行)
RECOVER = auto() # 5: 逆势后穿越EMA回到趋势侧(收复EMA,趋势恢复)
TOUCH_FAIL = auto() # 4: 逆势触碰EMA但未穿越(反弹/反抽力度不足)
DEEP_COUNTER = auto() # 3: 完全在EMA逆势侧(深度回调/反抽)
BREAK = auto() # 2: 穿越EMA,收盘在逆势侧(支撑/压力失败)
TOUCH_HOLD = auto() # 1: 触碰EMA,收盘守住趋势侧(支撑/压力有效)
WEAK_COUNTER = auto() # 8: 逆势K线完全在EMA逆势侧(弱势,远未到EMA)
APPROACHING = auto() # 9: K线接近EMA但未触碰(即将测试支撑/压力)
NEUTRAL = auto() # 0: 盘整/无法判断
class Chan_KL_TYPE(Enum):
K_1S = auto()
K_1M = auto()
K_DAY = auto()
K_WEEK = auto()
K_MON = auto()
K_YEAR = auto()
K_5M = auto()
K_15M = auto()
K_30M = auto()
K_60M = auto()
K_1H = auto()
K_2H = auto()
K_4H = auto()
K_6H = auto()
K_8H = auto()
K_12H = auto()
K_1D = auto()
K_3D = auto()
K_3M = auto()
K_QUARTER = auto()
class Chan_KLINE_DIR(Enum):
UP = auto()
DOWN = auto()
COMBINE = auto()
INCLUDED = auto()
class Chan_KLU_TYPE(Enum):
BigBull = auto()
MiddleBull = auto()
SmallBull = auto()
BigBear = auto()
MiddleBear = auto()
SmallBear = auto()
Cross = auto()
class Chan_KLU_PATTERN(Enum):
# 单根K线形态
HAMMER = auto() # 锤子线
INVERTED_HAMMER = auto() # 倒锤子线
SHOOTING_STAR = auto() # 射击之星
HANGING_MAN = auto() # 上吊线
DOJI = auto() # 十字星
LONG_LEGGED_DOJI = auto() # 长腿十字星
GRAVESTONE_DOJI = auto() # 墓碑十字星
DRAGONFLY_DOJI = auto() # 蜻蜓十字星
MARUBOZU = auto() # 光头光脚
SPINNING_TOP = auto() # 纺锤线
# 双根K线形态
BULLISH_ENGULFING = auto() # 看涨吞没
BEARISH_ENGULFING = auto() # 看跌吞没
PIERCING_LINE = auto() # 刺透形态
DARK_CLOUD_COVER = auto() # 乌云盖顶
TWEEZER_TOP = auto() # 镊子顶
TWEEZER_BOTTOM = auto() # 镊子底
HARAMI = auto() # 孕线
BULLISH_HARAMI = auto() # 看涨孕线
BEARISH_HARAMI = auto() # 看跌孕线
# 三根K线形态
MORNING_STAR = auto() # 早晨之星
EVENING_STAR = auto() # 黄昏之星
THREE_WHITE_SOLDIERS = auto() # 红三兵
THREE_BLACK_CROWS = auto() # 三只乌鸦
THREE_INNER_UP = auto() # 上升三法
THREE_INNER_DOWN = auto() # 下降三法
ABANDONED_BABY = auto() # 弃婴形态
# 多根K线形态
DOUBLE_TOP = auto() # 双顶
DOUBLE_BOTTOM = auto() # 双底
TRIPLE_TOP = auto() # 三顶
TRIPLE_BOTTOM = auto() # 三底
HEAD_AND_SHOULDERS = auto() # 头肩顶
INVERSE_HEAD_SHOULDERS = auto() # 头肩底
ROUNDING_BOTTOM = auto() # 圆弧底
ROUNDING_TOP = auto() # 圆弧顶
# 缺口形态
BREAKAWAY_GAP = auto() # 突破缺口
RUNAWAY_GAP = auto() # 持续缺口
EXHAUSTION_GAP = auto() # 衰竭缺口
# 特殊形态
ISLAND_REVERSAL = auto() # 岛形反转
KEY_REVERSAL = auto() # 关键反转
INSIDE_BAR = auto() # 内包线
OUTSIDE_BAR = auto() # 外包线
# 趋势形态
HIGHER_HIGH = auto() # 更高高点
HIGHER_LOW = auto() # 更高低点
LOWER_HIGH = auto() # 更低高点
LOWER_LOW = auto() # 更低低点
# 支撑阻力形态
SUPPORT_BOUNCE = auto() # 支撑反弹
RESISTANCE_REJECTION = auto() # 阻力拒绝
BREAKOUT = auto() # 突破
BREAKDOWN = auto() # 跌破
# 成交量相关形态
VOLUME_SPIKE = auto() # 成交量激增
VOLUME_DECLINE = auto() # 成交量萎缩
# 未知/无形态
UNKNOWN = auto() # 未知形态
class Chan_FX_TYPE(Enum):
BOTTOM = auto()
TOP = auto()
UNKNOWN = auto()
UP = auto()
DOWN = auto()
TT = auto()
BB = auto()
PTOP = auto()
PBOTTOM = auto()
class Chan_FX(Enum):
CONTINUATION = auto()
REVERSAL = auto()
UNKNOWN = auto()
class Chan_PRICE_TREND(Enum):
UP = auto()
DOWN = auto()
FLAT = auto()
UNKNOWN = auto()
class Chan_KLC_FX(Enum):
TOP0 = auto()
TOP1 = auto()
TOP2 = auto()
TOP3 = auto()
TOP4 = auto()
TOP5 = auto()
TOP6 = auto()
TOP7 = auto()
TOP8 = auto()
BOTTOM0 = auto()
BOTTOM1 = auto()
BOTTOM2 = auto()
BOTTOM3 = auto()
BOTTOM4 = auto()
BOTTOM5 = auto()
BOTTOM6 = auto()
BOTTOM7 = auto()
BOTTOM8 = auto()
UNKNOWN = auto()
# 统一的MACD状态枚举,包含所有可能的状态
class Chan_MACD_STATE(Enum):
"""MACD状态枚举 - 包含所有可能的状态"""
# 穿越状态
CROSS0_UP = auto() # 穿零轴后快速向上,能量柱呈现一根比一根长的排列方式
CROSS0_DOWN = auto() # 穿零轴后快速向下,能量柱呈现一根比一根短的排列方式
CROSS_OS = auto() # 穿零轴后缠绕/粘合,黄白线沿着能量柱运行,黄白线在运行的过程中没有释放出反向能量柱
CROSS_REV = auto() # 穿零轴后倒挂,MACD黄白线在穿零轴的时候与零轴的距离比较近,同时黄白线沿着能量柱运行,在运行的过程中,能量柱衰减导致它跟黄白线之间形成夹角空位,同时黄白线产生交叉并释放反向能量柱。
# 趋势状态
NEAR0 = auto()
NEAR0_52 = auto() # 价格在EMA52附近/价格接触EMA52并马上离开,需要观察离开强度
NEAR0_DIFF = auto() # MACD白线接近零轴,价格未到EMA52
NEAR0_PERFECT = auto() # MACD白线接近零轴和价格接触或短暂击穿EMA52,而MACD黄线不穿零轴,完美形态
NEAR0_24 = auto() # MACD黄白线接近零轴和价格在EMA24附近
# 位置状态
HIGH = auto() # 高位:MACD黄白线离开能量柱到高点,能量柱最大开始减弱
HIGH_EMPTY = auto() # 高位空:MACD黄白线处于高位,能量柱衰减,与黄白线形成空间夹角
RETURN_ZERO = auto() # 归零轴:能量柱呈现一根比一根短的排列方式
RZ_UP = auto() # 归零轴后的零轴上涨
RZ_DOWN = auto() # 归零轴后的零轴下跌
UP = auto() # 穿零轴后向上
DOWN = auto() # 穿零轴后向下
PEAK = auto() # 峰值:MACD白线处于高位
# 基础状态
UNKNOWN = auto() # 未知
START = auto() # 开始
class Chan_MACDSEG_DIR(Enum):
ABOVE = auto()
UNDER = auto()
class Chan_MACDUNITTF_TYPE(Enum):
START = auto()
CROSS0 = auto()
NEAR0 = auto()
class Chan_MACDUNITTF_JUMP(Enum):
CONTUNE = auto()
DISCRETE = auto()
class Chan_MACDUNITTF_DIV(Enum):
CONTUNE = auto()
DISCRETE = auto()
UNDIV = auto()
class Chan_MACDHISTSET_DIR(Enum):
ABOVE = auto()
UNDER = auto()
class Chan_MACDUNITTF_DIR(Enum):
ABOVE = auto()
UNDER = auto()
class Chan_MACDHIST_STATE(Enum):
UP = auto()
DOWN = auto()
PEAK = auto()
UNKNOWN = auto()
class Chan_BI_DIR(Enum):
UP = auto()
DOWN = auto()
class Chan_SEG_DIR(Enum):
UP = auto()
DOWN = auto()
class Chan_BI_TYPE(Enum):
UNKNOWN = auto()
STRICT = auto()
SUB_VALUE = auto() # 次高低点成笔
TIAOKONG_THRED = auto()
DAHENG = auto()
TUIBI = auto()
UNSTRICT = auto()
TIAOKONG_VALUE = auto()
Chan_BSP_MAIN_TYPE = Literal['1', '2', '3']
class Chan_BSP_DIR(Enum):
BUY = auto()
SELL = auto()
class Chan_BSP_TYPE(Enum):
B1 = auto()
B2 = auto()
B3 = auto()
S1 = auto()
S2 = auto()
S3 = auto()
NONE = auto()
"""
class Chan_BSP_TYPE(Enum):
T1 = '1'
T1P = '1p'
T2 = '2'
T2S = '2s'
T3A = '3a' # 中枢在1类后面
T3B = '3b' # 中枢在1类前面
T3 = '3'
T3E ='3e' # T3退出点
QJT = 'qjt' # 区间套突破
QJT1 = 'qjt1' # 区间套一类买点
QJT2 = 'qjt2' # 区间套一类卖点
QJT3 = 'qjt3' # 区间套三类买点
def main_type(self) -> Chan_BSP_MAIN_TYPE:
return self.value[0] # type: ignore
"""
class Chan_AUTYPE(Enum):
QFQ = auto()
HFQ = auto()
NONE = auto()
class Chan_TREND_TYPE(Enum):
MEAN = "mean"
MAX = "max"
MIN = "min"
class Chan_TREND_LINE_SIDE(Enum):
INSIDE = auto()
OUTSIDE = auto()
class Chan_LEFT_SEG_METHOD(Enum):
ALL = auto()
PEAK = auto()
class Chan_FX_CHECK_METHOD(Enum):
STRICT = auto()
LOSS = auto()
HALF = auto()
TOTALLY = auto()
class Chan_SEG_TYPE(Enum):
BI = auto()
SEG = auto()
class Chan_MACD_ALGO(Enum):
AREA = auto()
PEAK = auto()
FULL_AREA = auto()
DIFF = auto()
SLOPE = auto()
AMP = auto()
VOLUMN = auto()
AMOUNT = auto()
VOLUMN_AVG = auto()
AMOUNT_AVG = auto()
TURNRATE_AVG = auto()
RSI = auto()
class Chan_DATA_FIELD:
FIELD_TIME = "time_key"
FIELD_OPEN = "open"
FIELD_HIGH = "high"
FIELD_LOW = "low"
FIELD_CLOSE = "close"
FIELD_VOLUME = "volume" # 成交量
FIELD_TURNOVER = "turnover" # 成交额
FIELD_TURNRATE = "turnover_rate" # 换手率
class Chan_KLC_STATE:
"""笔当下状态(缠论笔定理)。任意时刻必属其一。"""
S10 = "(1, 0)" # 顶分型构造中 (1,0)
S_10 = "(-1, 0)" # 底分型构造中 (-1,0)
S11 = "(1,1)" # 向上笔延续中
S_11 = "(-1,1)" # 向下笔延续中
UNKNOWN = "Unknown" # 初始状态
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanEnum import * # noqa: F403
+2 -414
View File
@@ -1,414 +1,2 @@
#!/usr/bin/env python3
from __future__ import annotations
"""
使用 ccxt 获取币安交易所所有 `*/USDT` 交易对最新 100 根 1 小时 K 线数据,并筛选出长期横盘的币种。
横盘判定基于以下三项指标(均可通过命令行参数调整):
1. 价格振幅占均价的比例(默认 ≤ 5%
2. 收盘价线性回归斜率占均价的比例(默认 ≤ 0.05%
3. 收盘价标准差占均价的比例(默认 ≤ 1.5%
满足以上全部条件的交易对会被视为长期横盘。
"""
import argparse
import csv
import logging
import math
import statistics
import sys
import time
from dataclasses import dataclass
from typing import Iterable, List, Optional, Sequence
import ccxt
# python ChanHeng.py --range-threshold 5 --slope-threshold 5 --std-threshold 0.015
DEFAULT_LIMIT = 100
DEFAULT_TIMEFRAME = "1h"
STABLECOINS = {
"USDT",
"USDC",
"BUSD",
"TUSD",
"USDP",
"DAI",
"FDUSD",
"SUSD",
"UST",
"USTC",
"EUR",
"TRY",
"BFUSD",
"USDE",
"XUSD",
"USD1",
"XUSD"
}
@dataclass
class SidewaysMetrics:
symbol: str
price_range_pct: float
slope_pct: float
std_pct: float
mean_close: float
last_close: float
data_points: int
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="筛选币安长期横盘币种(默认 500 根 1 小时 K 线)"
)
parser.add_argument(
"--timeframe",
default=DEFAULT_TIMEFRAME,
help="K 线周期(默认:1h",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help="每个交易对获取的 K 线数量(默认:500)",
)
parser.add_argument(
"--range-threshold",
type=float,
default=0.05,
help="最大价格振幅占均价比例阈值(默认:0.05,表示 5%%",
)
parser.add_argument(
"--slope-threshold",
type=float,
default=0.0005,
help="线性回归斜率占均价比例阈值(默认:0.0005,约 0.05%%",
)
parser.add_argument(
"--std-threshold",
type=float,
default=0.015,
help="标准差占均价比例阈值(默认:0.015,表示 1.5%%",
)
parser.add_argument(
"--quote",
action="append",
default=[],
help="只保留指定计价货币的交易对,可重复指定(示例:--quote USDT --quote FDUSD",
)
parser.add_argument(
"--symbol",
action="append",
default=[],
help="仅检测指定交易对,可重复(不指定则遍历所有符合条件的现货交易对)",
)
parser.add_argument(
"--max-symbols",
type=int,
default=None,
help="限制最多检测的交易对数量(用于调试)",
)
parser.add_argument(
"--sleep",
type=float,
default=0.35,
help="请求失败后的基础重试等待秒数(默认:0.35)",
)
parser.add_argument(
"--retries",
type=int,
default=3,
help="单个交易对请求失败后的最大重试次数(默认:3)",
)
parser.add_argument(
"--include-inactive",
action="store_true",
help="包含已下架/不可交易的交易对(默认不包含)",
)
parser.add_argument(
"--export",
type=str,
default=None,
help="将筛选结果导出为 CSV 文件的路径",
)
parser.add_argument(
"--verbose",
action="store_true",
help="输出更详细的日志信息",
)
return parser.parse_args(argv)
def setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def create_exchange() -> ccxt.binance:
exchange = ccxt.binance({"enableRateLimit": True})
exchange.options["defaultType"] = "spot"
return exchange
def iter_target_symbols(
exchange: ccxt.binance,
quotes: Sequence[str],
includes: Sequence[str],
include_inactive: bool,
) -> List[str]:
markets = exchange.load_markets()
filtered = []
quote_set = {quote.upper() for quote in quotes}
include_set = {sym.upper() for sym in includes}
for symbol, meta in markets.items():
if not meta.get("spot", False):
continue
if not include_inactive and meta.get("active") is False:
continue
normalized_symbol = symbol.upper()
if include_set and normalized_symbol not in include_set:
continue
parts = symbol.split("/")
if len(parts) != 2:
continue
base_asset, quote_asset = parts[0].upper(), parts[1].upper()
target_quote = quote_set or {"USDT"}
if quote_asset not in target_quote:
continue
if base_asset in STABLECOINS:
continue
filtered.append(symbol)
filtered.sort()
logging.info(
"已筛选 %s 个目标交易对(quote 过滤:%s,专门列表:%s",
len(filtered),
",".join(sorted(quote_set or {"USDT"})),
",".join(sorted(include_set)) or "",
)
return filtered
def fetch_ohlcv_with_retry(
exchange: ccxt.binance,
symbol: str,
timeframe: str,
limit: int,
retries: int,
base_sleep: float,
) -> List[List[float]]:
attempt = 0
while True:
try:
return exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
except ccxt.RateLimitExceeded as exc:
wait_time = max(exchange.rateLimit / 1000.0 if exchange.rateLimit else 0, base_sleep)
logging.debug("触发限频,等待 %.2f 秒后重试 %s%s", wait_time, symbol, exc)
time.sleep(wait_time)
except (ccxt.NetworkError, ccxt.ExchangeError) as exc:
attempt += 1
if attempt > retries:
logging.warning("多次获取失败,跳过 %s%s", symbol, exc)
return []
wait_time = base_sleep * attempt
logging.debug("请求失败,等待 %.2f 秒后重试 %s(第 %d 次):%s", wait_time, symbol, attempt, exc)
time.sleep(wait_time)
def linear_regression_slope(values: Sequence[float]) -> float:
n = len(values)
if n < 2:
return 0.0
mean_x = (n - 1) / 2.0
mean_y = sum(values) / n
numerator = 0.0
denominator = 0.0
for idx, value in enumerate(values):
dx = idx - mean_x
numerator += dx * (value - mean_y)
denominator += dx * dx
if denominator == 0:
return 0.0
return numerator / denominator
def compute_sideways_metrics(closes: Sequence[float], symbol: str) -> Optional[SidewaysMetrics]:
if not closes:
return None
mean_close = sum(closes) / len(closes)
if math.isclose(mean_close, 0.0):
return None
max_close = max(closes)
min_close = min(closes)
price_range_pct = (max_close - min_close) / mean_close
slope = linear_regression_slope(closes)
slope_pct = slope / mean_close
std_dev = statistics.pstdev(closes) if len(closes) > 1 else 0.0
std_pct = std_dev / mean_close
return SidewaysMetrics(
symbol=symbol,
price_range_pct=price_range_pct,
slope_pct=slope_pct,
std_pct=std_pct,
mean_close=mean_close,
last_close=closes[-1],
data_points=len(closes),
)
def is_sideways(metrics: SidewaysMetrics, range_threshold: float, slope_threshold: float, std_threshold: float) -> bool:
return (
metrics.price_range_pct <= range_threshold
and abs(metrics.slope_pct) <= slope_threshold
and metrics.std_pct <= std_threshold
)
def export_results(path: str, results: Sequence[SidewaysMetrics]) -> None:
fieldnames = [
"symbol",
"price_range_pct",
"slope_pct",
"std_pct",
"mean_close",
"last_close",
"data_points",
]
with open(path, "w", newline="", encoding="utf-8") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for item in results:
writer.writerow(
{
"symbol": item.symbol,
"price_range_pct": f"{item.price_range_pct:.6f}",
"slope_pct": f"{item.slope_pct:.6f}",
"std_pct": f"{item.std_pct:.6f}",
"mean_close": f"{item.mean_close:.8f}",
"last_close": f"{item.last_close:.8f}",
"data_points": item.data_points,
}
)
logging.info("结果已导出至 %s", path)
def run(argv: Optional[Sequence[str]] = None) -> int:
args = parse_args(argv)
if not args.quote:
args.quote = ["USDT"]
setup_logging(args.verbose)
exchange = create_exchange()
symbols = iter_target_symbols(
exchange=exchange,
quotes=args.quote,
includes=args.symbol,
include_inactive=args.include_inactive,
)
if args.max_symbols is not None:
symbols = symbols[: args.max_symbols]
logging.info("出于调试目的,仅检测前 %d 个交易对。", len(symbols))
if not symbols:
logging.error("未找到任何满足条件的交易对,请检查过滤条件。")
return 1
sideways_results: List[SidewaysMetrics] = []
total = len(symbols)
for idx, symbol in enumerate(symbols, start=1):
logging.info("(%d/%d) 正在获取 %s%s K 线(limit=%d", idx, total, symbol, args.timeframe, args.limit)
ohlcv = fetch_ohlcv_with_retry(
exchange=exchange,
symbol=symbol,
timeframe=args.timeframe,
limit=args.limit,
retries=args.retries,
base_sleep=args.sleep,
)
if len(ohlcv) < max(100, args.limit // 2):
logging.debug("交易对 %s 返回数据不足(%d 根),跳过。", symbol, len(ohlcv))
continue
closes = [entry[4] for entry in ohlcv if entry[4] is not None]
metrics = compute_sideways_metrics(closes, symbol)
if not metrics:
continue
if is_sideways(metrics, args.range_threshold, args.slope_threshold, args.std_threshold):
sideways_results.append(metrics)
logging.info(
"识别为横盘:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%",
symbol,
metrics.price_range_pct * 100,
metrics.slope_pct * 100,
metrics.std_pct * 100,
)
else:
logging.debug(
"未满足条件:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%",
symbol,
metrics.price_range_pct * 100,
metrics.slope_pct * 100,
metrics.std_pct * 100,
)
if not sideways_results:
logging.warning("未检测到满足定义的长期横盘交易对。")
return 0
sideways_results.sort(key=lambda item: (item.price_range_pct, abs(item.slope_pct), item.std_pct))
print("=" * 88)
print(
f"共识别 {len(sideways_results)} 个长期横盘交易对(阈值:振幅≤{args.range_threshold:.2%}"
f"斜率≤{args.slope_threshold:.2%},标准差≤{args.std_threshold:.2%}"
)
print("=" * 88)
header = f"{'Symbol':15s} {'Range%':>10s} {'Slope%':>10s} {'STD%':>10s} {'Mean':>14s} {'Last':>14s} {'Count':>6s}"
print(header)
print("-" * len(header))
for item in sideways_results:
print(
f"{item.symbol:15s}"
f" {item.price_range_pct * 100:10.4f}"
f" {item.slope_pct * 100:10.4f}"
f" {item.std_pct * 100:10.4f}"
f" {item.mean_close:14.8f}"
f" {item.last_close:14.8f}"
f" {item.data_points:6d}"
)
if args.export:
export_results(args.export, sideways_results)
logging.info("任务完成。")
return 0
if __name__ == "__main__":
sys.exit(run())
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanHeng import * # noqa: F403
+2 -620
View File
@@ -1,620 +1,2 @@
import copy
from typing import Dict, Optional
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_KLC_FX
from ChanEnum import Chan_K_DIR, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_EMA_POS
from ChanEnum import Chan_EMA_SEMANTIC, Chan_BSP_TYPE, Chan_KLC_STATE, Chan_FX
import ChanKLU
import ChanCTime
import Chan_FX_Box
# 根据结合律合并K线后的K线
class ChanKLC():
def __init__(self, klu: ChanKLU, index, ddir=Chan_KLINE_DIR.UP):
self.start_time = klu.time
self.end_time = None
self.high = klu.high
self.low = klu.low
self.dir = ddir
self.index = index
self.klu_list = []
self.add_klu(klu)
self.fx = Chan_FX_TYPE.UNKNOWN
self.next = None
self.pre = None
self.start_klu = klu
self.end_klu = None
self.state = "00"
self.klc_state = Chan_KLC_STATE.UNKNOWN
self.open = klu.open
self.close = klu.close
self.volume = klu.volume
self.bi = None
self.distance = 0
self.klc_fx_type = Chan_KLC_FX.UNKNOWN
self.rsi = klu.rsi
self.volume_ratio = klu.volume_ratio
self.macdhist = klu.macdhist
self.body = klu.body
self.upper_shadow = klu.upper_shadow
self.lower_shadow = klu.lower_shadow
self.body_ratio = klu.body_ratio
self.upper_shadow_ratio = klu.upper_shadow_ratio
self.lower_shadow_ratio = klu.lower_shadow_ratio
self.candle_dir = klu.candle_dir
self.range = klu.range
self.bb_out = True
self.macd = klu.macd
self.signal = klu.signal
self.state = Chan_MACD_STATE.UNKNOWN
self.continue_div = False
self.separate_div = False
self.ema24 = klu.ema24
self.ema26 = klu.ema26
self.ema52 = klu.ema52
self.ema104 = klu.ema104
self.ema156 = klu.ema156
self.ema208 = klu.ema208
self.ema13 = klu.ema13
self.ema7 = klu.ema7
self.trend = Chan_PRICE_TREND.UNKNOWN
self.exception = klu.exception
self.klc_dir = Chan_KLINE_DIR.UP if klu.close > klu.open else Chan_KLINE_DIR.DOWN
self.ema_dir = klu.ema_dir
self.bsp = False
self.bsp_type = Chan_BSP_TYPE.NONE
# EMA状态字典:key为EMA名称,value为 {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC}
self.ema_status = {}
# 向后兼容:保留 ema52_status 和 ema52_pos
self.ema52_status = 0
self.ema52_pos = Chan_EMA_POS.UNKNOWN
self.bb2633upper = klu.bb2633upper
self.bb2633lower = klu.bb2633lower
self.bb2633middle = klu.bb2633middle
self.ema5 = klu.ema5
self.ma5 = klu.ma5
self.fx_box = None
self.in_fx = False
self.fx_confirmed = False
self.ema52_dis = klu.high - klu.ema52 if klu.close > klu.ema52 else klu.ema52 - klu.low
self.ema26_dis = klu.high - klu.ema26 if klu.close > klu.ema26 else klu.ema26 - klu.low
self.macd_signal_dis = abs(klu.macd - klu.signal)
self.ema52_ema26_dis = abs(klu.ema52 - klu.ema26)
self.fx_type = Chan_FX.UNKNOWN
self.bi_zs = None
self.seg_zs = None
self.last_bi_zs = None
# ==================== EMA 通用计算方法 ====================
@staticmethod
def cal_ema_pos(high, low, close, ema_value, threshold=0):
"""
计算K线与任意EMA的客观位置关系(与趋势方向无关,支持threshold容差)
参数:
high, low, close: K线的高低收盘价
ema_value: EMA的值
threshold: 容差值(绝对值),在此范围内视为"接近/触碰"
例如 BTC 价格 $100,000 时 threshold=100 表示差100点视为触碰
返回:
Chan_EMA_POS 枚举值
判断逻辑(以threshold=100, ema=97000为例):
ema_zone = [96900, 97100] EMA上下各扩展threshold
ABOVE: low > 97100 K线完全在zone上方(远离EMA)
NEAR_ABOVE: 97000 < low <= 97100 K线在上方但下影线进入zone(接近EMA)
CROSS_CLOSE_ABOVE: close > 97000, low <= 97000 K线穿越EMA,收盘在上方
ON_EMA: abs(close - 97000) <= 100 收盘价在zone内
CROSS_CLOSE_BELOW: close < 97000, high >= 97000 K线穿越EMA,收盘在下方
NEAR_BELOW: 96900 <= high < 97000 K线在下方但上影线进入zone(接近EMA)
BELOW: high < 96900 K线完全在zone下方(远离EMA)
"""
if ema_value is None or ema_value == 0:
return Chan_EMA_POS.UNKNOWN
ema_upper = ema_value + threshold # EMA zone 上界
ema_lower = ema_value - threshold # EMA zone 下界
# 1. 收盘价在EMA附近(zone内)
if threshold > 0 and abs(close - ema_value) <= threshold:
# 收盘价在zone内,但还需要看是否有实际穿越
if low <= ema_value and close >= ema_value:
return Chan_EMA_POS.CROSS_CLOSE_ABOVE # 实际穿越了精确EMA线
elif high >= ema_value and close <= ema_value:
return Chan_EMA_POS.CROSS_CLOSE_BELOW
return Chan_EMA_POS.ON_EMA
# 2. K线实际穿越了精确的EMA线
if close > ema_value and low <= ema_value:
return Chan_EMA_POS.CROSS_CLOSE_ABOVE
if close < ema_value and high >= ema_value:
return Chan_EMA_POS.CROSS_CLOSE_BELOW
if close == ema_value:
return Chan_EMA_POS.ON_EMA
# 3. 没有实际穿越,检查是否"接近"(在threshold zone内)
if close > ema_value:
# K线在EMA上方
if threshold > 0 and low <= ema_upper:
return Chan_EMA_POS.NEAR_ABOVE # 下影线进入zone,接近但未触碰
return Chan_EMA_POS.ABOVE # 远离EMA
else:
# K线在EMA下方
if threshold > 0 and high >= ema_lower:
return Chan_EMA_POS.NEAR_BELOW # 上影线进入zone,接近但未触碰
return Chan_EMA_POS.BELOW # 远离EMA
@staticmethod
def cal_ema_semantic(ema_pos, kline_dir, ema_dir):
"""
根据客观位置 + K线方向 + 趋势方向,计算语义状态
参数:
ema_pos: Chan_EMA_POS 客观位置
kline_dir: Chan_KLINE_DIR K线方向 (UP/DOWN/COMBINE/INCLUDED)
ema_dir: int 趋势方向 (1=多头, -1=空头, 0=盘整)
返回:
Chan_EMA_SEMANTIC 枚举值
语义含义(以多头为例,空头完全对称):
TOUCH_HOLD: 触碰EMA,收盘守住趋势侧(支撑/压力有效)
BREAK: 穿越EMA,收盘在逆势侧(支撑/压力失败)
DEEP_COUNTER: 完全在EMA逆势侧(深度回调/反抽)
TOUCH_FAIL: 逆势触碰EMA但未穿越(反弹/反抽力度不足)
RECOVER: 逆势后穿越EMA回到趋势侧(收复EMA)
TREND_SIDE: 完全在EMA趋势侧(正常运行)
STRONG_TREND: 顺势K线完全在EMA趋势侧(强势,远未及EMA)
WEAK_COUNTER: 逆势K线完全在EMA逆势侧(弱势,远未到EMA)
"""
if ema_pos == Chan_EMA_POS.UNKNOWN:
return Chan_EMA_SEMANTIC.NEUTRAL
# 统一处理:将多头/盘整和空头映射到同一套逻辑
# is_bull=True 时,"趋势侧"=上方,"逆势侧"=下方
# is_bull=False时,"趋势侧"=下方,"逆势侧"=上方
is_bull = ema_dir >= 0 # 多头和盘整都按多头逻辑处理
# K线是否是顺势方向(多头下UP为顺势,空头下DOWN为顺势)
is_trend_kline = (kline_dir == Chan_KLINE_DIR.UP) if is_bull else (kline_dir == Chan_KLINE_DIR.DOWN)
is_counter_kline = (kline_dir == Chan_KLINE_DIR.DOWN) if is_bull else (kline_dir == Chan_KLINE_DIR.UP)
# 位置映射:多头下 ABOVE=趋势侧, BELOW=逆势侧; 空头反过来
trend_side = Chan_EMA_POS.ABOVE if is_bull else Chan_EMA_POS.BELOW
counter_side = Chan_EMA_POS.BELOW if is_bull else Chan_EMA_POS.ABOVE
near_trend = Chan_EMA_POS.NEAR_ABOVE if is_bull else Chan_EMA_POS.NEAR_BELOW
near_counter = Chan_EMA_POS.NEAR_BELOW if is_bull else Chan_EMA_POS.NEAR_ABOVE
cross_to_trend = Chan_EMA_POS.CROSS_CLOSE_ABOVE if is_bull else Chan_EMA_POS.CROSS_CLOSE_BELOW
cross_to_counter = Chan_EMA_POS.CROSS_CLOSE_BELOW if is_bull else Chan_EMA_POS.CROSS_CLOSE_ABOVE
# COMBINE / INCLUDED 方向:只看位置,不区分强弱
if not is_trend_kline and not is_counter_kline:
if ema_pos == trend_side:
return Chan_EMA_SEMANTIC.TREND_SIDE
elif ema_pos in (near_trend, cross_to_trend, Chan_EMA_POS.ON_EMA):
return Chan_EMA_SEMANTIC.APPROACHING
elif ema_pos in (near_counter, cross_to_counter):
return Chan_EMA_SEMANTIC.APPROACHING
elif ema_pos == counter_side:
return Chan_EMA_SEMANTIC.DEEP_COUNTER
return Chan_EMA_SEMANTIC.NEUTRAL
# 逆势K线(多头下的下跌K线 / 空头下的上涨K线)
if is_counter_kline:
if ema_pos == trend_side:
return Chan_EMA_SEMANTIC.STRONG_TREND # 逆势K线仍在趋势侧(回调很浅)
elif ema_pos == near_trend:
return Chan_EMA_SEMANTIC.APPROACHING # 接近EMA,即将测试支撑/压力
elif ema_pos == cross_to_trend:
return Chan_EMA_SEMANTIC.TOUCH_HOLD # 触碰EMA后守住趋势侧
elif ema_pos == Chan_EMA_POS.ON_EMA:
return Chan_EMA_SEMANTIC.TOUCH_HOLD # 收盘在EMA附近,视为守住
elif ema_pos == cross_to_counter:
return Chan_EMA_SEMANTIC.BREAK # 穿越EMA到逆势侧
elif ema_pos == near_counter:
return Chan_EMA_SEMANTIC.BREAK # 接近EMA但收盘在逆势侧,也视为击穿
elif ema_pos == counter_side:
return Chan_EMA_SEMANTIC.DEEP_COUNTER # 完全在逆势侧
# 顺势K线(多头下的上涨K线 / 空头下的下跌K线)
if is_trend_kline:
if ema_pos == counter_side:
return Chan_EMA_SEMANTIC.WEAK_COUNTER # 顺势K线却在逆势侧(弱势)
elif ema_pos == near_counter:
return Chan_EMA_SEMANTIC.APPROACHING # 从逆势侧接近EMA
elif ema_pos == cross_to_counter:
return Chan_EMA_SEMANTIC.TOUCH_FAIL # 触碰EMA但未穿越回趋势侧
elif ema_pos == Chan_EMA_POS.ON_EMA:
return Chan_EMA_SEMANTIC.TOUCH_FAIL # 收盘在EMA附近,未确认突破
elif ema_pos == cross_to_trend:
return Chan_EMA_SEMANTIC.RECOVER # 从逆势侧穿越回趋势侧
elif ema_pos == near_trend:
return Chan_EMA_SEMANTIC.RECOVER # 接近趋势侧(刚收复EMA附近)
elif ema_pos == trend_side:
return Chan_EMA_SEMANTIC.TREND_SIDE # 完全在趋势侧(正常)
return Chan_EMA_SEMANTIC.NEUTRAL
@staticmethod
def semantic_to_int(semantic):
"""将 Chan_EMA_SEMANTIC 枚举转换为整数,兼容旧的 ema52_status 数值"""
mapping = {
Chan_EMA_SEMANTIC.TOUCH_HOLD: 1,
Chan_EMA_SEMANTIC.BREAK: 2,
Chan_EMA_SEMANTIC.DEEP_COUNTER: 3,
Chan_EMA_SEMANTIC.TOUCH_FAIL: 4,
Chan_EMA_SEMANTIC.RECOVER: 5,
Chan_EMA_SEMANTIC.TREND_SIDE: 6,
Chan_EMA_SEMANTIC.STRONG_TREND: 7,
Chan_EMA_SEMANTIC.WEAK_COUNTER: 8,
Chan_EMA_SEMANTIC.APPROACHING: 9,
Chan_EMA_SEMANTIC.NEUTRAL: 0,
}
return mapping.get(semantic, 0)
# threshold_pct: 阈值百分比,用于自动计算绝对阈值
# 例如 0.001 表示 EMA 值的 0.1%BTC $100,000 时 threshold = $100
threshold_pct = 0.001
def set_bsp_type(self, bsp_type):
if bsp_type and bsp_type != Chan_BSP_TYPE.NONE:
self.bsp_type = bsp_type
self.bsp = True
def cal_all_ema_status(self):
"""
统一计算所有EMA与K线的位置关系和语义状态
threshold 自动按 EMA 值的百分比计算(cls.threshold_pct,默认0.1%
- BTC $100,000 时:threshold ≈ $100
- ETH $3,000 时:threshold ≈ $3
- SOL $200 时:threshold ≈ $0.2
结果存储在 self.ema_status 字典中,格式:
{
'ema24': {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC, 'value': float, 'threshold': float},
'ema52': {...},
...
}
同时保持向后兼容:self.ema52_pos 和 self.ema52_status
"""
ema_configs = {
'ema24': self.ema24,
'ema52': self.ema52,
'ema104': self.ema104,
'ema156': self.ema156,
'ema208': self.ema208,
}
self.ema_status = {}
for name, value in ema_configs.items():
# 按 EMA 值的百分比自动计算阈值
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)
semantic = ChanKLC.cal_ema_semantic(pos, self.dir, self.ema_dir)
self.ema_status[name] = {
'pos': pos,
'semantic': semantic,
'value': value,
'threshold': threshold,
}
# 向后兼容
self.ema52_pos = self.ema_status['ema52']['pos']
self.ema52_status = ChanKLC.semantic_to_int(self.ema_status['ema52']['semantic'])
def get_ema_pos(self, ema_name):
"""获取指定EMA的客观位置,如 klc.get_ema_pos('ema24')"""
if ema_name in self.ema_status:
return self.ema_status[ema_name]['pos']
return Chan_EMA_POS.UNKNOWN
def check_ema_pos(self):
if len(self.ema_status) > 0:
for ema_name, pos in self.ema_status.items():
#print(self.end_time, ema_name, pos['pos'])
if ((self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2) and pos['pos'] == Chan_EMA_POS.CROSS_CLOSE_BELOW) or ((self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2) and pos['pos'] == Chan_EMA_POS.CROSS_CLOSE_ABOVE):
#print("---------------------")
return ema_name
return None
def get_ema_semantic(self, ema_name):
"""获取指定EMA的语义状态,如 klc.get_ema_semantic('ema52')"""
if ema_name in self.ema_status:
return self.ema_status[ema_name]['semantic']
return Chan_EMA_SEMANTIC.NEUTRAL
def set_trend(self, trend):
self.trend = trend
def to_string(self):
out = ""
start = self.start_time if self.start_time is not None else ""
end = self.end_time if self.end_time is not None else ""
price_diff = getattr(self, 'price_diff', None)
out += str(start) + " " + str(end) + " " + str(self.close) + " " + str(self.ema24) + " " + str(self.ema52) + " " + str(self.trend) + " " + str(self.close - self.ema52)
return out
def set_bi_zs(self, bi_zs):
if bi_zs:
self.bi_zs = bi_zs
def set_klc_fx_type(self, klc_fx_type):
#print(self.start_time, klc_fx_type, self.get_feature_data()['klu_macd'], self.get_feature_data()['klu_macdhist'], self.get_feature_data()['klu_rsi'])
self.klc_fx_type = klc_fx_type
#self.cal_fx()
ema_name = self.check_ema_pos()
hist_div = abs(self.macdhist - self.next.macdhist)
#print(self.end_time, self.dir, abs(self.macdhist), hist_div)
#if ema_name:
#print(self.end_time, ema_name, self.ema_status[ema_name]['semantic'], hist_div)
#self.cal_bb_out()
#print(self.pre.start_time, self.next.end_time, self.klc_fx_type)
if klc_fx_type == Chan_KLC_FX.TOP1 or klc_fx_type == Chan_KLC_FX.TOP2 or klc_fx_type == Chan_KLC_FX.BOTTOM1 or klc_fx_type == Chan_KLC_FX.BOTTOM2:
self.cal_fx_box()
self.cal_fx_type()
def cal_fx_type(self):
if self.fx == Chan_FX_TYPE.TOP and self.next:
if self.ema52_dis > self.ema26_dis:
if self.pre.macd < self.macd and self.macd < self.next.macd:
self.fx_type = Chan_FX.CONTINUATION
else:
self.fx_type = Chan_FX.REVERSAL
elif self.fx == Chan_FX_TYPE.BOTTOM and self.next:
if self.ema52_dis < self.ema26_dis:
if self.pre.macd > self.macd and self.macd > self.next.macd:
self.fx_type = Chan_FX.CONTINUATION
else:
self.fx_type = Chan_FX.REVERSAL
#if self.fx_type != Chan_FX.UNKNOWN and self.fx_type != Chan_FX.CONTINUATION:
#print(self.end_time, self.fx_type)
def cal_fx_box(self):
# 每次重算前先清空,避免旧box残留
self.fx_box = None
start_time = None
end_time = None
high = 0
low = 0
display = False
if self.pre and self.next and self.next.end_time:
self.next.in_fx = True
if self.fx == Chan_FX_TYPE.TOP:
start_time = self.pre.end_time
end_time = self.next.end_time
high = self.high
low = self.pre.low if self.pre.low < self.next.low else self.next.low
if self.next.close < self.pre.low:
display = True
elif self.fx == Chan_FX_TYPE.BOTTOM:
start_time = self.pre.end_time
end_time = self.next.end_time
high = self.pre.high if self.pre.high > self.next.high else self.next.high
low = self.low
if self.next.close > self.pre.high:
display = True
if high > 0 and self.next.end_time and display:
#print(start_time, end_time, high, low)
# Chan_FX_BOX 这里导入的是模块,类名在模块内部为 Chan_FX_Box
self.fx_confirmed = True
self.fx_box = Chan_FX_Box.Chan_FX_Box(start_time, end_time, high, low)
def check_fx_confirmed(self, last_top, last_bottom):
if last_top and last_bottom and False:
if last_top.index > last_bottom.index:
if self.in_fx == False and last_top.fx_confirmed == False:
pre = last_top.pre
if pre.low > self.close:
last_top.fx_confirmed = True
if last_top.fx_box:
last_top.fx_box.end_time = self.end_time
#print(self.end_time, "fx_confirmed top")
else:
high = last_top.high
low = self.low
last_top.fx_box = Chan_FX_Box.Chan_FX_Box(last_top.pre.start_time, self.end_time, high, low)
#print(self.end_time, "fx_confirmed new box top")
elif self.in_fx == False and last_bottom.fx_confirmed == False:
pre = last_bottom.pre
if pre.high < self.close:
last_bottom.fx_confirmed = True
if last_bottom.fx_box:
last_bottom.fx_box.end_time = self.end_time
#print(self.end_time, "fx_confirmed bottom")
else:
high = self.high
low = last_bottom.low
last_bottom.fx_box = Chan_FX_Box.Chan_FX_Box(last_bottom.pre.start_time, self.end_time, high, low)
#print(self.end_time, "fx_confirmed new box bottom")
def add_klu(self, klu):
self.klu_list.append(klu)
def check_klc_state(self, last_fx_klc):
if last_fx_klc and last_fx_klc.fx == Chan_FX_TYPE.TOP:
if self.high > last_fx_klc.high:
self.klc_state = Chan_KLC_STATE.S11
else:
self.klc_state = Chan_KLC_STATE.S_11
elif last_fx_klc and last_fx_klc.fx == Chan_FX_TYPE.BOTTOM:
if self.low < last_fx_klc.low:
self.klc_state = Chan_KLC_STATE.S_11
else:
self.klc_state = Chan_KLC_STATE.S11
if self.pre and self.pre.fx == Chan_FX_TYPE.TOP:
self.klc_state = Chan_KLC_STATE.S10
elif self.pre and self.pre.fx == Chan_FX_TYPE.BOTTOM:
self.klc_state = Chan_KLC_STATE.S_10
#print(self.end_time, self.klc_state)
def set_end_klu(self, klu):
self.end_klu = klu
self.end_time = klu.time
self.close = klu.close
for klu in self.klu_list:
if klu.exception:
self.exception = True
print(klu.time, "exception")
if klu.separate_div > 0:
self.separate_div = True
if klu.continue_div:
self.continue_div = klu.continue_div
if klu.macd_state != Chan_MACD_STATE.UNKNOWN:
self.state = klu.macd_state
klu.set_klc(self)
self.klc_dir = Chan_KLINE_DIR.UP if self.close > self.open else Chan_KLINE_DIR.DOWN
self.cal_indicators()
self.cal_all_ema_status()
if self.open > self.high:
self.open = self.high
if self.close > self.high:
self.close = self.high
if self.close < self.low:
self.close = self.low
if self.open < self.low:
self.open = self.low
#print(self.end_time, self.open, self.close, self.high, self.low)
#print(klu.time, klu.open, klu.close, klu.high, klu.low)
def cal_fx(self):
if self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2:
#print(self.end_time, self.fx, self.macd, self.macdhist, len(self.klu_list))
if self.state == Chan_MACD_STATE.HIGH_EMPTY and self.macd > 0:
#print(self.end_time, self.state, self.macd, self.klc_fx_type)
self.klc_fx_type = Chan_KLC_FX.TOP6
if self.separate_div or self.continue_div:
self.klc_fx_type = Chan_KLC_FX.TOP7
if self.signal > 0 and self.macd > self.signal:
self.klc_fx_type = Chan_KLC_FX.TOP8
else:
if self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2:
if self.macdhist > 0 and self.macd < 0:
self.klc_fx_type = Chan_KLC_FX.BOTTOM5
return
if self.state == Chan_MACD_STATE.HIGH_EMPTY and self.macd < 0:
self.klc_fx_type = Chan_KLC_FX.BOTTOM6
#print(self.end_time, self.state, self.macd, self.klc_fx_type)
if self.separate_div or self.continue_div:
self.klc_fx_type = Chan_KLC_FX.BOTTOM7
if self.signal < 0 and self.macd < self.signal:
self.klc_fx_type = Chan_KLC_FX.BOTTOM8
def cal_bb_out(self):
for klu in self.klu_list:
if self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2:
#print(self.start_time, self.klc_fx_type, klu.high, klu.bb52upper, self.macd, self.next.macd, klu.time)
if self.high >= klu.bb52upper and klu.bb52upper > 0 and self.next and self.high > self.next.high:
self.klc_fx_type = Chan_KLC_FX.TOP4
print(self.end_time, self.klc_fx_type)
if self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2:
#print(self.start_time, self.klc_fx_type, klu.low, klu.bb52lower, self.macd, self.next.macd, klu.time)
if self.low <= klu.bb52lower and klu.bb52lower > 0 and self.next and self.low < self.next.low:
self.klc_fx_type = Chan_KLC_FX.BOTTOM4
print(self.end_time, self.klc_fx_type)
def cal_indicators(self):
for index in range(1, len(self.klu_list)):
self.volume += self.klu_list[index].volume
self.rsi += self.klu_list[index].rsi
self.volume_ratio += self.klu_list[index].volume_ratio
self.macdhist += self.klu_list[index].macdhist
self.ema26 += self.klu_list[index].ema26
self.ema24 += self.klu_list[index].ema24
self.ema52 += self.klu_list[index].ema52
self.ema104 += self.klu_list[index].ema104
self.ema156 += self.klu_list[index].ema156
self.ema208 += self.klu_list[index].ema208
self.ema13 += self.klu_list[index].ema13
self.ema7 += self.klu_list[index].ema7
self.bb2633upper += self.klu_list[index].bb2633upper
self.bb2633lower += self.klu_list[index].bb2633lower
self.bb2633middle += self.klu_list[index].bb2633middle
self.ma5 += self.klu_list[index].ma5
self.ema5 += self.klu_list[index].ema5
if self.ema_dir != self.klu_list[index].ema_dir:
self.ema_dir = 0
n = len(self.klu_list)
self.rsi = self.rsi / n
self.volume_ratio = self.volume_ratio / n
self.volume = self.volume / n
self.macdhist = self.macdhist / n
self.ema26 = self.ema26 / n
self.ema24 = self.ema24 / n
self.ema52 = self.ema52 / n
self.ema104 = self.ema104 / n
self.ema156 = self.ema156 / n
self.ema208 = self.ema208 / n
self.ema13 = self.ema13 / n
self.ema7 = self.ema7 / n
self.ma5 = self.ma5 / n
self.ema5 = self.ema5 / n
self.bb2633upper = self.bb2633upper / n
self.bb2633lower = self.bb2633lower / n
self.bb2633middle = self.bb2633middle / n
if len(self.klu_list) > 0:
self.macd = self.klu_list[-1].macd
self.signal = self.klu_list[-1].signal
self.body = abs(self.close - self.open)
self.upper_shadow = self.high - max(self.close, self.open)
self.lower_shadow = min(self.close, self.open) - self.low
self.body_ratio = self.body / self.open
self.upper_shadow_ratio = self.upper_shadow / self.open
self.lower_shadow_ratio = self.lower_shadow / self.open
self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR
self.range = self.high - self.low
def set_next(self, klc):
self.next = klc
def set_pre(self, klc):
self.pre = klc
def set_state(self, state):
self.state = state
def check_klu_included(self, klu):
if self.high >= klu.high:
# high大于,low小于,左包含
if self.low <= klu.low:
self.add_klu(klu=klu)
# gn>gn-1
if self.dir == Chan_KLINE_DIR.UP:
# UP -> max(dn)
self.low = klu.low
else:
# DOWN -> min(gn)
self.high = klu.high
#self.print(klu, "Z")
return True
# high大于,low大于,不包含
else:
# if self.low > klu.low
# high相等,右包含
if self.high == klu.high:
self.add_klu(klu=klu)
# UP -> max(gn)
if self.dir == Chan_KLINE_DIR.UP:
self.high = klu.high
else:
# DOWN -> min(dn)
self.low = klu.low
return True
else:
return False
else:
# high小于,low大于,右包含
if self.low >= klu.low:
self.add_klu(klu=klu)
# gn>gn-1
if self.dir == Chan_KLINE_DIR.UP:
# UP -> max(gn)
self.high = klu.high
else:
# DOWN -> min(dn)
self.low = klu.low
#self.print(klu, "Y")
return True
else:
# high小于,low小于,不包含
return False
def set_fx(self, fx: Chan_FX_TYPE):
self.fx = fx
def cal_invisible(self):
if self.fx == Chan_FX_TYPE.TOP:
if self.macdhist < 0 and self.macd > 0:
self.klc_fx_type = Chan_KLC_FX.TOP5
else:
if self.fx == Chan_FX_TYPE.BOTTOM:
if self.macdhist > 0 and self.macd < 0:
self.klc_fx_type = Chan_KLC_FX.BOTTOM5
def set_pre_fx(self):
if self.pre and self.pre.pre:
self.pre.fx = self.check_fx(self.pre.pre, self.pre)
def check_fx(self, k1, k2):
if k2.high > k1.high and k2.high > self.high:
return Chan_FX_TYPE.TOP
elif k2.low < k1.low and k2.low < self.low:
return Chan_FX_TYPE.BOTTOM
else:
return Chan_FX_TYPE.UNKNOWN
def set_bi(self, bi):
self.bi = bi
self.distance = self.index - bi.start_klc.index
#print(self.start_time, self.distance, bi.index, bi.dir)
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanKLC import * # noqa: F403
+2 -400
View File
@@ -1,400 +1,2 @@
from ChanEnum import Chan_FX_TYPE, Chan_KLU_TYPE, Chan_K_DIR, Chan_MACD_STATE, Chan_MACDHIST_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN, Chan_KLC_FX
class ChanKLU:
def __init__(self, time, open, high, low, close, volume):
# _time, _close, _open, _high, _low, _extra_info={}
self.kl_type = None
self.time = time
self.close = close
self.open = open
self.high = high
self.low = low
self.volume = volume
self.idx = 0
self.index = 0
self.macd = 0
self.signal = 0
self.macdhist = 0
self.klc = None
self.rsi = 0
self.volume_ratio = 0
self.bb52upper = 0
self.bb52lower = 0
# === 新增:K线类型 ===
self.kline_type = None # K线类型:大阳线、大阴线、小阳线、小阴线
self.pattern = Chan_KLU_PATTERN.UNKNOWN
# === 新增:实时分型相关属性 ===
self.pre = None # 前一根K线
self.next = None # 后一根K线
self.fx_type = Chan_FX_TYPE.UNKNOWN # 分型类型:0=无分型,1=顶分型,-1=底分型
self.fx_strength = 0 # 分型强度:0-100
self.fx_confirmed = False # 分型是否确认
self.klu_type = None
self.range = self.high - self.low
self.body = abs(self.close - self.open)
self.upper_shadow = self.high - max(self.close, self.open)
self.lower_shadow = min(self.close, self.open) - self.low
self.body_ratio = self.body / self.range if self.range != 0 else 0
self.upper_shadow_ratio = self.upper_shadow / self.body if self.body != 0 else float('inf')
self.lower_shadow_ratio = self.lower_shadow / self.body if self.body != 0 else float('inf')
self.exception = False
#self.cal_exception()
self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR
self.continue_div = 0
self.separate_div = 0
self.near0_return = 0
self.ema52 = 0
self.ema24 = 0
self.ema26 = 0
self.ema104 = 0
self.ema156 = 0
self.ema208 = 0
self.macd_slop = 0
self.signal_slop = 0
self.hist_slop = 0
self.hist_state = Chan_MACDHIST_STATE.UNKNOWN
self.macd_state = Chan_MACD_STATE.UNKNOWN
self.macd_hist_gap = 0
self.trend = Chan_PRICE_TREND.UNKNOWN
self.seg_histset_index = 0
# === 归零轴细化与模式/背离 ===
self.zero_axis = False # 是否归零轴(穿越或接近)
self.zero_axis_state = "none" # {none,crossing,near}
self.zero_axis_side = 0 # 1:above, -1:under, 0:none
self.zero_axis_score = 0 # 0-100 综合评分
self.mode1_touch_ema52 = False # 单边后触碰EMA52
self.mode2_fast_to_zero = False # 快线向零收敛
self.mode3_double_tf = False # 双周期归零(近似占位,由上层填充高周期确认)
self.mode3_dir = "none" # {long_strong_rebound, short_strong_rebound, none}
self.mode4_touch52_no_zero = False # 先触碰EMA52但黄白线未归零
self.div_type = "none" # {bearish, bullish, hidden_bearish, hidden_bullish, none}
self.div_score = 0.0 # 背离强度(0-100)
self.ema_dir = 0
self.get_ema_dir()
self.bb2633upper = 0
self.bb2633lower = 0
self.bb2633middle = 0
self.ma5 = 0
self.ema5 = 0
#print(self.open, self.close, self.high, self.low, self.candle_dir, self.strength)
def set_macd_state(self, state):
self.macd_state = state
def set_pattern(self, pattern):
self.pattern = pattern
def set_seg_histset_index(self, seg_histset_index):
self.seg_histset_index = seg_histset_index
#print(self.time, self.seg_histset_index)
def to_string(self):
return f"{self.time} {self.candle_dir} {self.pattern}"
def cal_exception(self):
if self.upper_shadow_ratio > 5 or self.lower_shadow_ratio > 5:
self.exception = True
#print(self.time, self.upper_shadow_ratio, self.lower_shadow_ratio, self.body, self.lower_shadow, self.upper_shadow, self.high, self.low, self.close, self.open)
#self.exception = False
def set_trend(self, trend):
self.trend = trend
def set_separate_div(self, separate_div):
self.separate_div = separate_div
bb2633_status = self.check_bb2633()
if self.klc and self.klc.pre and self.klc.next:
fx = self.check_fx_dir(self.klc.pre, self.klc.next)
if fx == Chan_FX_TYPE.TOP:
if self.macdhist > 0:
self.separate_div = separate_div
else:
self.separate_div = 0
elif fx == Chan_FX_TYPE.BOTTOM:
if self.macdhist < 0:
self.separate_div = separate_div
else:
self.separate_div = 0
if bb2633_status == 0:
self.separate_div = 0
def check_bb2633(self, threadhold=300):
#print(self.time, self.high, self.bb2633upper, self.low, self.bb2633lower)
if abs(self.high - self.bb2633upper) < threadhold:
#print(self.time, self.high, self.bb2633upper)
return 1
if abs(self.low - self.bb2633lower) < threadhold:
#print(self.time, self.low, self.bb2633lower)
return -1
return 0
def check_fx_dir(self, pre, next):
fx = Chan_FX_TYPE.UNKNOWN
if pre.klc_fx_type == Chan_KLC_FX.TOP1 or pre.klc_fx_type == Chan_KLC_FX.TOP2 or next.klc_fx_type == Chan_KLC_FX.TOP1 or next.klc_fx_type == Chan_KLC_FX.TOP2 or self.klc.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc.klc_fx_type == Chan_KLC_FX.TOP2:
fx = Chan_FX_TYPE.TOP
elif pre.klc_fx_type == Chan_KLC_FX.BOTTOM1 or pre.klc_fx_type == Chan_KLC_FX.BOTTOM2 or next.klc_fx_type == Chan_KLC_FX.BOTTOM1 or next.klc_fx_type == Chan_KLC_FX.BOTTOM2 or self.klc.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc.klc_fx_type == Chan_KLC_FX.BOTTOM2:
fx = Chan_FX_TYPE.BOTTOM
return fx
def set_next(self, next):
self.next = next
#if self.fx_type != Chan_FX_TYPE.UNKNOWN and self.fx_strength > 1:
#print(self.index, self.time, self.fx_type, self.fx_confirmed, self.fx_strength)
def set_pre(self, pre):
self.pre = pre
def set_klc(self, klc):
self.klc = klc
def set_histset(self, histset):
"""设置HistSet关联"""
self.histset = histset
def set_seg(self, seg):
"""设置Seg关联"""
self.seg = seg
def set_unittf(self, unittf):
"""设置UnitTF关联"""
self.unittf = unittf
def set_idx(self, idx):
self.idx = idx
self.index = idx
def check_price_ema156(self):
if self.check_indicators():
if self.close > self.ema156:
return 1
elif self.close < self.ema156:
return -1
else:
return 0
else:
return 0
def get_ema_dir(self):
if self.check_indicators():
if self.ema24 > self.ema52 and self.ema52 > self.ema104 and self.ema104 > self.ema156:
self.ema_dir = 1
elif self.ema24 < self.ema52 and self.ema52 < self.ema104 and self.ema104 < self.ema156:
self.ema_dir = -1
else:
self.ema_dir = 0
def check_indicators(self):
if self.ema156 == 0:
return False
else:
return True
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
self.macdhist = float(item['macdhist']) if 'macdhist' in item and item['macdhist'] else 0
self.ema26 = float(item['ema26']) if 'ema26' in item and item['ema26'] else 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
self.ema104 = float(item['ema104']) if 'ema104' in item and item['ema104'] else 0
self.ema156 = float(item['ema156']) if 'ema156' in item and item['ema156'] 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
self.ema7 = float(item['ema7']) if 'ema7' in item and item['ema7'] else 0
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
self.bb52upper = float(item['bb52upper']) if 'bb52upper' in item and item['bb52upper'] else 0
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
self.bb2633lower = float(item['bb2633lower']) if 'bb2633lower' in item and item['bb2633lower'] else 0
self.bb2633middle = float(item['bb2633middle']) if 'bb2633middle' in item and item['bb2633middle'] else 0
self.ma5 = float(item['ma5']) if 'ma5' in item and item['ma5'] else 0
self.ema5 = float(item['ema5']) if 'ema5' in item and item['ema5'] else 0
def cal_macd_state(self):
# 按定义精简实现:优先级 CROSS0 > 位置(HIGH/HE/RETURN_ZERO) > NEAR0 > UNKNOWN
# 首条或缺前一根
if not hasattr(self, 'pre') or self.pre is None:
self.macd_state = Chan_MACD_STATE.START
return self.macd_state
# 基本校验
if (self.macd == 0 and self.signal == 0 and self.macdhist == 0) or self.ema52 == 0:
self.macd_state = Chan_MACD_STATE.UNKNOWN
return self.macd_state
# 归零轴判断
if self.signal > 0:
if self.macd < self.signal:
if 0 < self.low - self.ema52 < 100:
self.near0_return = 0
elif self.close > self.ema52 and self.low < self.ema52 and self.open > self.ema52:
self.near0_return = 0
elif self.close < self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close < self.ema52 and self.open < self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
else:
if self.macd > self.signal:
if 0 < self.ema52 - self.high < 100:
self.near0_return = 0
elif self.close < self.ema52 and self.high > self.ema52 and self.open < self.ema52:
self.near0_return = 0
elif self.close < self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.open < self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.high > self.ema52 and self.open >= self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
# 向上穿越EMA52 7
if self.close > self.ema52 and self.open < self.ema52:
self.near0_return = 0
# 向下穿越EMA52 8
elif self.close < self.ema52 and self.open > self.ema52:
self.near0_return = 0
if self.pre.near0_return == 7:
# 向上穿越后的一根价格再EMA52上方 9
if self.low > self.ema52 and self.close > self.open:
self.near0_return = 9
if self.pre.near0_return == 8:
# 向下穿越后的一根价格再EMA52下方 10
if self.high < self.ema52 and self.close < self.open:
self.near0_return = 10
# CROSS0 仅以 Signal 穿越零轴判定
if self.pre.signal >= 0 and self.signal < 0:
self.macd_state = Chan_MACD_STATE.CROSS0_DOWN
return self.macd_state
if self.pre.signal <= 0 and self.signal > 0:
self.macd_state = Chan_MACD_STATE.CROSS0_UP
return self.macd_state
# 穿零轴后的形态:缠绕/倒挂(基于前一状态为CROSS0_*)
if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP or self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN:
direction = 1 if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP else -1
hist_same_dir = (self.macdhist * direction) > 0
hist_decreasing = abs(self.macdhist) < abs(self.pre.macdhist)
lines_tight = abs(self.macd - self.signal) <= 12
# 倒挂:能量柱衰减且黄白线相对方向不利/出现反向能量释放
if hist_decreasing and (((self.macd - self.signal) * direction) < 0 or not hist_same_dir):
self.macd_state = Chan_MACD_STATE.CROSS_REV
return self.macd_state
# 缠绕/粘合:紧贴能量柱运行,无反向能量释放
if lines_tight and hist_same_dir:
self.macd_state = Chan_MACD_STATE.CROSS_OS
return self.macd_state
# 趋近零轴:细化 NEAR0_* 判定
NEAR0_EPS = 15
lines_near_zero = abs(self.macd) <= NEAR0_EPS or abs(self.signal) <= NEAR0_EPS
touch_52 = (self.ema52 != 0) and ((abs(self.close - self.ema52) <= NEAR0_EPS) or (self.low <= self.ema52 <= self.high))
touch_24 = (self.ema24 != 0) and ((abs(self.close - self.ema24) <= NEAR0_EPS) or (self.low <= self.ema24 <= self.high))
# 完美形态:白线接近零轴 + 价格触碰/轻破EMA52 + 黄线不穿零轴
if abs(self.macd) <= NEAR0_EPS and touch_52 and (not (self.pre.signal >= 0 and self.signal < 0)) and (not (self.pre.signal <= 0 and self.signal > 0)):
self.macd_state = Chan_MACD_STATE.NEAR0_PERFECT
#self.near0_return = 1
return self.macd_state
# EMA24 附近
if lines_near_zero and touch_24:
self.macd_state = Chan_MACD_STATE.NEAR0_24
#self.near0_return = 2
return self.macd_state
# EMA52 附近
if lines_near_zero and touch_52:
self.macd_state = Chan_MACD_STATE.NEAR0_52
#self.near0_return = 3
return self.macd_state
# 白线接近零轴但价格未至EMA52
if abs(self.macd) <= NEAR0_EPS and not touch_52:
self.macd_state = Chan_MACD_STATE.NEAR0_DIFF
#self.near0_return = 4
return self.macd_state
# 一般近零轴
if lines_near_zero or touch_52:
self.macd_state = Chan_MACD_STATE.NEAR0
#self.near0_return = 5
return self.macd_state
# 穿零轴后离开零轴
if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP and ((self.macd >= self.pre.macd and self.signal >= self.pre.signal) or (abs(self.macdhist) >= abs(self.pre.macdhist))):
self.macd_state = Chan_MACD_STATE.UP
return self.macd_state
if self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN and ((self.macd <= self.pre.macd and self.signal <= self.pre.signal) or (abs(self.macdhist) >= abs(self.pre.macdhist))):
self.macd_state = Chan_MACD_STATE.DOWN
return self.macd_state
if self.pre.macd_state == Chan_MACD_STATE.UP and self.macd > self.pre.macd and self.signal > self.pre.signal:
self.macd_state = Chan_MACD_STATE.UP
return self.macd_state
if self.pre.macd_state == Chan_MACD_STATE.DOWN and self.macd < self.pre.macd and self.signal < self.pre.signal:
self.macd_state = Chan_MACD_STATE.DOWN
return self.macd_state
# 趋势兜底:强势同步上行/下行直接进入 UP/DOWN
if self.macd > 0 and self.signal > 0 and (self.macd >= self.pre.macd and self.signal >= self.pre.signal):
self.macd_state = Chan_MACD_STATE.UP
return self.macd_state
if self.macd < 0 and self.signal < 0 and (self.macd <= self.pre.macd and self.signal <= self.pre.signal):
self.macd_state = Chan_MACD_STATE.DOWN
return self.macd_state
# 峰值:白线高位出现局部顶
if hasattr(self.pre, 'pre') and self.pre and self.pre.pre and self.macd > 0:
if self.pre.macd > self.pre.pre.macd and self.pre.macd > self.macd:
self.macd_state = Chan_MACD_STATE.PEAK
return self.macd_state
# 高位状态的位置状态, 高位,高位空,归零轴
if (self.pre.macd_state == Chan_MACD_STATE.UP or self.pre.macd_state == Chan_MACD_STATE.HIGH or self.pre.macd_state == Chan_MACD_STATE.RZ_UP or self.pre.macd_state == Chan_MACD_STATE.PEAK or self.pre.macd_state == Chan_MACD_STATE.HIGH_EMPTY) and self.macd > 0:
# 高位空(正区间):能量柱衰减且黄白线间距较大
if abs(self.pre.macdhist) > 0 and abs(self.macdhist) < abs(self.pre.macdhist) and abs(self.macd - self.signal) > 5:
self.macd_state = Chan_MACD_STATE.HIGH_EMPTY
return self.macd_state
if abs(self.pre.macd - self.macd) < 10:
self.macd_state = Chan_MACD_STATE.HIGH
return self.macd_state
else:
if self.macd > self.pre.macd and self.signal > self.pre.signal:
self.macd_state = Chan_MACD_STATE.UP
return self.macd_state
elif self.macd < self.pre.macd and self.signal < self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
if (self.pre.macd_state == Chan_MACD_STATE.DOWN or self.pre.macd_state == Chan_MACD_STATE.HIGH or self.pre.macd_state == Chan_MACD_STATE.RZ_DOWN or self.pre.macd_state == Chan_MACD_STATE.PEAK or self.pre.macd_state == Chan_MACD_STATE.HIGH_EMPTY) and self.macd < 0:
# 高位空(负区间):能量柱衰减且黄白线间距较大
if abs(self.pre.macdhist) > 0 and abs(self.macdhist) < abs(self.pre.macdhist) and abs(self.macd - self.signal) > 5:
self.macd_state = Chan_MACD_STATE.HIGH_EMPTY
return self.macd_state
if abs(self.pre.macd - self.macd) < 10:
self.macd_state = Chan_MACD_STATE.HIGH
return self.macd_state
else:
if self.macd > self.pre.macd and self.signal > self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
elif self.macd < self.pre.macd and self.signal < self.pre.signal:
self.macd_state = Chan_MACD_STATE.DOWN
return self.macd_state
# 离开0轴开始上涨或者下跌阶段,高位之前的
if self.macd > 0 and self.pre:
if (self.pre.macd_state == Chan_MACD_STATE.NEAR0 or self.pre.macd_state == Chan_MACD_STATE.RZ_UP or self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP) and (self.signal > self.pre.signal or self.close > self.ema52):
self.macd_state = Chan_MACD_STATE.RZ_UP
return self.macd_state
elif self.macd < 0 and self.pre:
if (self.pre.macd_state == Chan_MACD_STATE.NEAR0 or self.pre.macd_state == Chan_MACD_STATE.RZ_DOWN or self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN) and (self.signal < self.pre.signal or self.close < self.ema52):
self.macd_state = Chan_MACD_STATE.RZ_DOWN
return self.macd_state
# 归零轴走势
if self.pre.macd_state == Chan_MACD_STATE.RETURN_ZERO:
if self.macd > 0:
if self.pre.macd > self.macd or abs(self.macdhist) <= abs(self.pre.macdhist) or self.signal <= self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
else:
if self.pre.macd < self.macd or abs(self.macdhist) <= abs(self.pre.macdhist) or self.signal >= self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
# 从 NEAR0 收敛到零轴的归零轴承接(正负两侧)
if self.pre.macd_state == Chan_MACD_STATE.NEAR0:
# 正区间朝零轴收敛
if self.macd > 0 and self.pre.macd > 0 and self.macd <= self.pre.macd and self.signal <= self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
# 负区间朝零轴收敛
if self.macd < 0 and self.pre.macd < 0 and self.macd >= self.pre.macd and self.signal >= self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
# 其余情况
if self.pre.macd_state == Chan_MACD_STATE.UNKNOWN:
if self.macd > 0 and self.close > self.ema52 and self.pre.pre and (self.pre.pre.macd_state == Chan_MACD_STATE.UP or self.pre.pre.macd_state == Chan_MACD_STATE.RZ_UP):
self.macd_state = self.pre.pre.macd_state
return self.macd_state
elif self.macd < 0 and self.close < self.ema52 and self.pre.pre and (self.pre.pre.macd_state == Chan_MACD_STATE.DOWN or self.pre.pre.macd_state == Chan_MACD_STATE.RZ_DOWN):
self.macd_state = self.pre.pre.macd_state
return self.macd_state
else:
self.macd_state = Chan_MACD_STATE.UNKNOWN
return self.macd_state
return self.macd_state
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanKLU import * # noqa: F403
+3 -174
View File
@@ -1,174 +1,3 @@
import warnings
# 抑制 Docker 内 technical.util 的 fillna/ffill/bfill 的 pandas FutureWarningpandas 2.x 弃用 object 静默 downcast
warnings.filterwarnings(
"ignore",
category=FutureWarning,
message=".*Downcasting object dtype arrays on \\.fillna.*",
)
from datetime import timedelta
from pandas import DataFrame
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN
from ChanKLU import ChanKLU
from ChanKLC import ChanKLC
from ChanBI import ChanBI
from ChanSBI import ChanSBI
from ChanSEG import ChanSEG
from ChanZS import ChanZS
from ChanBSP import ChanBSP
import talib.abstract as ta
import pandas as pd
from technical.util import resample_to_interval
from decimal import Decimal
import numpy as np
from ChanMACD import ChanMACD
from TF_DF import TF_DF
class ChanLun():
def __init__(self):
self.time2m = 2
self.time3m = 3
self.time5m = 5
self.time10m = 10
self.time20m = 20
self.time_m_intervals = [2, 3, 5, 10, 20]
self.time_m_symbols = ['2m', '3m', '5m', '10m', '20m']
self.time30m = 30
self.time45m = 45
self.time_m15_intervals = [30, 45]
self.time_m15_symbols = ['30m', '45m']
self.time2h = 2*60
self.time4h = 4*60
self.time6h = 6*60
self.time8h = 8*60
self.time12h = 12*60
self.time16h = 16*60
self.time_h_intervals = [2*60, 4*60, 6*60, 8*60, 12*60, 16*60]
self.time_h_symbols = ['2h', '4h', '6h', '8h', '12h', '16h']
self.time2d = 2*24*60
self.time3d = 3*24*60
self.time_d_intervals = [2*24*60, 3*24*60]
self.time_d_symbols = ['2d', '3d']
self.time1w = 7*24*60
self.time2w = 14*24*60
self.time_w_intervals = [14*24*60]
self.time_w_symbols = ['2w']
self.time2M = 2*30*24*60
self.time3M = 3*30*24*60
self.time6M = 6*30*24*60
self.time1y = 12*30*24*60
self.time_M_intervals = [2*30*24*60, 3*30*24*60, 6*30*24*60, 12*30*24*60]
self.time_M_symbols = ['2M', '3M', '6M', '1y']
self.time_symbols = ['1m', '2m', '3m', '5m', '10m', '15m', '20m', '30m', '45m','1h', '2h', '4h', '6h', '8h', '12h', '16h', '1d', '2d', '3d']
self.tf_df_dict = {}
self.ema_symbols = ['5m', '15m', '30m', '45m', '1h', '2h', '4h', '8h', '12h', '1d', '2d', '3d']
self.tf_df = TF_DF()
def init_data(self, dataframe, intervals, timeframes):
for index in range(0, len(intervals)):
timeframe = timeframes[index]
interval = intervals[index]
self.tf_df_dict[timeframe] = TF_DF(dataframe, interval, timeframe)
def init_dataframes(self, dataframe_m=None, dataframe_15m=None, dataframe_h=None, dataframe_d=None, dataframe_w=None, dataframe_M=None):
self.tf_df_dict = {}
if dataframe_m is not None:
self.tf_df_dict['1m'] = TF_DF(dataframe_m, 1, '1m')
self.init_data(dataframe_m, self.time_m_intervals, self.time_m_symbols)
if dataframe_15m is not None:
self.tf_df_dict['15m'] = TF_DF(dataframe_15m, 1, '15m')
self.init_data(dataframe_15m, self.time_m15_intervals, self.time_m15_symbols)
if dataframe_h is not None:
self.tf_df_dict['1h'] = TF_DF(dataframe_h, 1, '1h')
self.init_data(dataframe_h, self.time_h_intervals, self.time_h_symbols)
if dataframe_d is not None:
self.tf_df_dict['1d'] = TF_DF(dataframe_d, 1, '1d')
self.init_data(dataframe_d, self.time_d_intervals, self.time_d_symbols)
if dataframe_w is not None and False:
self.tf_df_dict['1w'] = TF_DF(dataframe_w, 1, '1w')
self.init_data(dataframe_w, self.time_w_intervals, self.time_w_symbols)
if dataframe_M is not None and False:
self.tf_df_dict['1M'] = TF_DF(dataframe_M, 1, '1M')
self.init_data(dataframe_M, self.time_M_intervals, self.time_M_symbols)
def get_ema52_dict(self):
if len(self.tf_df_dict) > 0:
return {key: self.tf_df_dict[key].get_ema52() for key in self.ema_symbols}
return None
def get_ema24_dict(self):
if len(self.tf_df_dict) > 0:
return {key: self.tf_df_dict[key].get_ema24() for key in self.ema_symbols}
return None
def get_current_klc_dict(self):
if len(self.tf_df_dict) > 0:
return {key: self.tf_df_dict[key].get_current_klc() for key in self.ema_symbols}
return None
def get_tf_df_by_timeframe(self, timeframe):
if timeframe in self.tf_df_dict:
return self.tf_df_dict[timeframe]
return None
def check_price_ema52(self, price):
key_list = []
if len(self.tf_df_dict) > 0:
ema52_dict = self.get_ema52_dict()
for key in self.ema_symbols:
if ema52_dict[key] is not None:
if abs(price - ema52_dict[key]) < 100:
key_list.append(key)
return key_list
def get_ema_bsp(self, long_tf='1h', short_tf='15m'):
if long_tf in self.tf_df_dict and short_tf in self.tf_df_dict:
long_df = self.tf_df_dict[long_tf]
short_df = self.tf_df_dict[short_tf]
return long_df.get_ema_bsp(short_df)
return None
# TF_DF methods ------------------------------------------
def get_ema_state(self, dataframe):
return self.tf_df.get_ema_state(dataframe)
def get_klu_state(self, dataframe):
return self.tf_df.get_klu_state(dataframe)
def check_fx(self, klc):
return self.tf_df.check_fx(klc)
def add_indicators1(self, df):
return self.tf_df.add_indicators(df)
def get_bi_list(self, dataframe):
return self.tf_df.get_bi_list(dataframe)
def get_kl_data(self, dataframe:DataFrame):
return self.tf_df.cal_kl_data(dataframe)
def cal_volume_ratio(self, dataframe, window=10):
return self.tf_df.cal_volume_ratio(dataframe, window)
def calculate_seg_zs(self, bi_list, seg_list):
return self.get_seg_zs_list(bi_list, seg_list)
def get_seg_list(self, bi_list):
return self.tf_df.get_seg_list(bi_list)
def cal_trend(self, klc_list):
return self.tf_df.cal_trend(klc_list)
def check_top_fx(self, last_bottom, klc):
return self.tf_df.check_top_fx(last_bottom, klc)
def check_bottom_fx(self, last_top, klc):
return self.tf_df.check_bottom_fx(last_top, klc)
def cal_bi_list(self, klc_list):
return self.tf_df.cal_bi_list(klc_list)
def find_first_bsp(self, bi_list, bi_zs_list):
return self.tf_df.find_first_bsp(bi_list, bi_zs_list)
def find_second_bsp(self, bi_list, first_bsp_list):
return self.tf_df.find_second_bsp(bi_list, first_bsp_list)
def find_all_bsp(self, bi_list, bi_zs_list):
return self.tf_df.find_all_bsp(bi_list, bi_zs_list)
def get_zs_list(self, bi_list, seg_list):
return self.tf_df.get_zs_list(bi_list, seg_list)
def cal_bi_zs(self, seg_list):
return self.tf_df.cal_bi_zs(seg_list)
def cal_bi_zs_list(self, bi_list):
#return self.tf_df.cal_bi_zs(bi_list)
return self.tf_df.cal_bi_zs_list(bi_list)
def get_decimal(self, value):
return Decimal("{:.2f}".format(value))
def get_klc_list(self, klu_list):
return self.tf_df.get_klc_list(klu_list)
def get_klu_list(self, dataframe):
return self.tf_df.cal_klu_pattern(self.get_kl_data(dataframe))
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.pipeline.orchestrator import ChanLun # noqa: F401
from chanlun.pipeline.timeframe import TF_DF # noqa: F401
+2 -529
View File
@@ -1,529 +1,2 @@
import sys
import os
#sys.setrecursionlimit(1000000) #例如这里设置为一百万
#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan"))
sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan"))
import numpy as np
from datetime import timedelta
from pandas import DataFrame
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX
from ChanKLU import ChanKLU
from ChanKLC import ChanKLC
from ChanBI import ChanBI
from ChanSBI import ChanSBI
from ChanSEG import ChanSEG
from ChanZS import ChanZS
from ChanBSP import ChanBSP
import talib.abstract as ta
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import DateFormatter, date2num
import matplotlib.patches as patches
from technical.util import resample_to_interval
from decimal import Decimal
from ChanLun import ChanLun
import xgboost as xgb
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report
class ChanLunClassifier:
def __init__(self, dataframe: DataFrame):
self.dataframe = dataframe
self.model = None
chan = ChanLun()
def train_model(self, dataframe=None, data_file_path=None, model_file_path='chan_xgb_model.json', use_cv=False, custom_params=None, model_name=None):
"""
使用dataframe前80%的数据训练XGBoost模型
:param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe
:param data_file_path: 特征数据保存路径,可选
:param model_file_path: 模型保存路径
:param use_cv: 是否使用交叉验证寻找最佳参数
:param custom_params: 自定义模型参数
:return: 训练好的模型
"""
if dataframe is None:
dataframe = self.dataframe
# 分割数据集,前80%用于训练
train_size = int(len(dataframe) * 0.8)
train_df = dataframe.iloc[:train_size].copy()
# 获取训练集特征和标签
save_csv = True if data_file_path else False
X_train, y_train = self.get_feature_data(train_df, save_csv=save_csv, csv_path=data_file_path if data_file_path else 'feature_data.csv')
if len(X_train) == 0:
print("没有提取到足够的特征数据进行训练")
return None
# 保存特征数据的步骤已经移到get_feature_data方法中处理
# 以下是原有代码
#{'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3},
# 默认XGBoost参数
default_params = {
'objective': 'binary:logistic',
'max_depth': 8,
'eta': 0.01,
'subsample': 0.8,
'colsample_bytree': 0.8,
'eval_metric': 'auc',
'gamma': 0.0,
'min_child_weight': 1,
'alpha': 0, # L1正则化
'lambda': 0.5, # L2正则化
'scale_pos_weight': 1
}
# 使用自定义参数覆盖默认参数
if custom_params:
for key, value in custom_params.items():
default_params[key] = value
params = default_params
dtrain = xgb.DMatrix(X_train, label=y_train)
# 如果使用交叉验证寻找最佳参数
if use_cv:
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.metrics import make_scorer, accuracy_score, f1_score
import numpy as np
# 转换为sklearn兼容格式
xgb_model = xgb.XGBClassifier(
objective=params['objective'],
max_depth=params['max_depth'],
learning_rate=params['eta'],
subsample=params['subsample'],
colsample_bytree=params['colsample_bytree'],
gamma=params['gamma'],
min_child_weight=params['min_child_weight'],
reg_alpha=params['alpha'],
reg_lambda=params['lambda'],
scale_pos_weight=params['scale_pos_weight'],
use_label_encoder=False,
eval_metric='auc'
)
# 参数网格
param_grid = {
'max_depth': [3, 5, 7, 9],
'learning_rate': [0.01, 0.05, 0.1, 0.2],
'subsample': [0.6, 0.8, 1.0],
'colsample_bytree': [0.6, 0.8, 1.0],
'min_child_weight': [1, 3, 5],
'gamma': [0, 0.1, 0.2],
'n_estimators': [50, 100, 200]
}
# 使用随机搜索寻找最佳参数(比网格搜索快)
random_search = RandomizedSearchCV(
estimator=xgb_model,
param_distributions=param_grid,
n_iter=10, # 随机尝试的参数组合数
scoring=make_scorer(f1_score),
cv=5,
verbose=1,
n_jobs=-1,
random_state=42
)
print("进行交叉验证参数搜索...")
random_search.fit(X_train, y_train)
# 获取最佳参数
best_params = random_search.best_params_
print(f"最佳参数: {best_params}")
# 使用最佳参数更新模型参数
params['max_depth'] = best_params['max_depth']
params['eta'] = best_params['learning_rate']
params['subsample'] = best_params['subsample']
params['colsample_bytree'] = best_params['colsample_bytree']
params['min_child_weight'] = best_params['min_child_weight']
params['gamma'] = best_params['gamma']
num_round = best_params['n_estimators']
# 使用最佳参数训练最终模型
self.model = xgb.train(params, dtrain, num_round)
else:
# 标准训练(不使用交叉验证)
# 使用早停机制避免过拟合
# 分割训练集为训练和验证
eval_size = int(len(X_train) * 0.2)
X_eval = X_train[-eval_size:]
y_eval = y_train[-eval_size:]
X_train_part = X_train[:-eval_size]
y_train_part = y_train[:-eval_size]
dtrain_part = xgb.DMatrix(X_train_part, label=y_train_part)
deval = xgb.DMatrix(X_eval, label=y_eval)
# 评估列表
evallist = [(dtrain_part, 'train'), (deval, 'eval')]
# 训练模型,使用早停
num_round = 1000 # 设置较大的轮数,让早停机制决定何时停止
self.model = xgb.train(
params,
dtrain_part,
num_round,
evallist,
early_stopping_rounds=50, # 50轮内评估指标无改善则停止
verbose_eval=True
)
# 使用全部训练数据重新训练最终模型,使用最佳轮数
# best_rounds = self.model.best_ntree_limit
# 兼容新版本的XGBoost
if hasattr(self.model, 'best_ntree_limit'):
best_rounds = self.model.best_ntree_limit
elif hasattr(self.model, 'best_iteration'):
best_rounds = self.model.best_iteration
elif hasattr(self.model, 'best_ntree_idx'):
best_rounds = self.model.best_ntree_idx
else:
# 如果都不存在,使用默认值
best_rounds = num_round
print(f"最佳轮数: {best_rounds}")
# 使用全部训练数据和最佳轮数训练最终模型
self.model = xgb.train(params, dtrain, best_rounds)
# 保存模型
if model_file_path:
self.model.save_model(model_name + model_file_path)
# 特征重要性分析
if hasattr(self.model, 'get_score'):
importance = self.model.get_score(importance_type='gain')
print("\n特征重要性 (gain):")
for key, value in sorted(importance.items(), key=lambda x: x[1], reverse=True):
print(f"{key}: {value}")
return self.model
def load_model(self, model_name=None, model_file_path='chan_xgb_model.json'):
if model_name:
self.model = xgb.Booster()
self.model.load_model(model_name + model_file_path)
else:
self.model = xgb.Booster()
self.model.load_model(model_file_path)
def find_best_params(self, dataframe=None, save_csv=False, csv_path_prefix='param_', model_name=None):
"""
寻找最佳参数组合
:param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path_prefix: CSV文件保存路径前缀,会自动添加参数信息
:return: 最佳参数
"""
# 不同参数组合
param_combinations = [
# 低学习率,深树
{'eta': 0.01, 'max_depth': 8, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0, 'min_child_weight': 1},
# 中等学习率,中等树深度
{'eta': 0.05, 'max_depth': 5, 'subsample': 0.7, 'colsample_bytree': 0.7, 'gamma': 0.1, 'min_child_weight': 3},
# 高学习率,浅树
{'eta': 0.1, 'max_depth': 3, 'subsample': 0.6, 'colsample_bytree': 0.6, 'gamma': 0.2, 'min_child_weight': 5},
# 正则化较强 best here
{'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3},
# 正则化较弱
{'eta': 0.08, 'max_depth': 6, 'subsample': 0.9, 'colsample_bytree': 0.9, 'gamma': 0, 'min_child_weight': 1, 'alpha': 0, 'lambda': 0.5},
]
best_score = 0
best_params = None
best_model = None
for i, params in enumerate(param_combinations):
print(f"\n尝试参数组合: {params}")
# 生成CSV文件名,包含一些参数信息
param_info = f"eta{params['eta']}_depth{params['max_depth']}"
train_csv_path = f"{csv_path_prefix}train_{param_info}.csv" if save_csv else None
model = self.train_model(dataframe=dataframe, data_file_path=train_csv_path, custom_params=params, model_name=model_name)
# 分割数据集,后20%用于测试
if dataframe is None:
dataframe = self.dataframe
train_size = int(len(dataframe) * 0.8)
test_df = dataframe.iloc[train_size:].copy()
# 获取测试集特征和标签
test_csv_path = f"{csv_path_prefix}test_{param_info}.csv" if save_csv else None
X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=test_csv_path)
if len(X_test) == 0:
print("没有提取到足够的测试特征数据")
continue
# 预测
dtest = xgb.DMatrix(X_test)
y_pred_prob = model.predict(dtest)
y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob]
# 计算F1分数
f1 = f1_score(y_test, y_pred, zero_division=0)
print(f"F1分数: {f1:.4f}")
if f1 > best_score:
best_score = f1
best_params = params
best_model = model
print(f"\n最佳参数组合 (F1={best_score:.4f}):")
print(best_params)
self.model = best_model
return best_params
def get_feature_data(self, dataframe, save_csv=False, csv_path='feature_data.csv'):
"""
从dataframe提取特征数据
:param dataframe: 输入的DataFrame
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 特征矩阵X和标签y
"""
# 使用ChanLun获取bi_list
klc_list = self.chan.get_klc_list(dataframe)
bi_list = self.chan.cal_bi_list(klc_list)
# 筛选方向为UP的bi的起始klc
feature_data = []
labels = []
feature_keys = [] # 用于保存特征名称
bi_index = 1
sample_list = []
for klc in klc_list:
if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
sample_list.append(klc)
klc_count = 0
print('Processing data...')
for klc in sample_list:
if bi_index >= len(bi_list):
bi_index = len(bi_list) - 1
#bi = bi_list[bi_index]
#if klc.end_klu and bi.end_klc and klc.start_klu.index >= bi.start_klc.start_klu.index and klc.end_klu.index <= bi.end_klc.end_klu.index:
#klc.set_bi(bi)
# 提取特征
features = klc.get_feature_data()
# 保存第一个样本的特征名称,用于CSV列名
if len(feature_keys) == 0:
feature_keys = list(features.keys())
# 将特征转换为模型可用的格式
feature_vec = []
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
# 判断这个bi是否赚钱(这里简单定义为:如果bi的结束价格高于起始价格,则标记为1,否则为0)
# 这个标签定义可以根据实际需求修改
matched = False
for bi in bi_list:
if bi.end_klc and bi.end_klc.index == klc.index:
#print(bi.start_time, bi.start_klc.start_time, bi.dir)
label = 1
matched = True
break
if not matched:
label = 0
feature_data.append(feature_vec)
labels.append(label)
klc_count += 1
percent = klc_count/len(sample_list)*100
if percent % 10 == 0:
print('Data processed:', percent, '%')
for index, key in enumerate(feature_keys):
print(index, key, feature_data[0][index])
# 如果需要保存到CSV
if save_csv:
# 创建DataFrame保存特征数据
# 只保留数值型特征
numeric_feature_keys = [key for i, key in enumerate(feature_keys)
if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))]
# 创建特征数据的DataFrame
df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys)
# 添加标签列
df_features['label'] = labels
# 添加时间信息便于分析
if len(sample_list) > 0:
times = [klc.start_time for klc in sample_list]
df_features['time'] = times
# 保存到CSV
df_features.to_csv(csv_path, index=False)
print(f"特征数据已保存到 {csv_path}")
# 在return前添加
positive_count = np.sum(labels)
print(f"正样本数量: {positive_count}, 负样本数量: {len(labels) - positive_count}")
print("Trainning data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------")
return np.array(feature_data), np.array(labels)
def get_validate_feature_data(self, dataframe, save_csv=False, csv_path='validate_feature_data.csv'):
"""
从dataframe提取特征数据
:param dataframe: 输入的DataFrame
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 特征矩阵X和标签y
"""
# 使用ChanLun获取bi_list
klc_list = self.chan.get_klc_list(dataframe)
bi_list = self.chan.cal_bi_list(klc_list)
seg_list = self.chan.get_seg_list(bi_list)
# 筛选方向为UP的bi的起始klc
feature_data = []
labels = []
feature_keys = [] # 用于保存特征名称
bi_index = 1
sample_list = []
for klc in klc_list:
if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
sample_list.append(klc)
for klc in sample_list:
if bi_index >= len(bi_list):
bi_index = len(bi_list) - 1
bi = bi_list[bi_index]
# 提取特征
features = klc.get_feature_data()
# 保存第一个样本的特征名称,用于CSV列名
if len(feature_keys) == 0:
feature_keys = list(features.keys())
# 将特征转换为模型可用的格式
feature_vec = []
# 与get_feature_data保持一致,只使用相同的特征集
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
seg = seg_list[bi_index]
matched = False
for bi in bi_list:
if bi.end_klc and bi.end_klc.index == klc.index:
label = 1
matched = True
break
if not matched:
label = 0
feature_data.append(feature_vec)
labels.append(label)
# 如果需要保存到CSV
if save_csv:
# 创建DataFrame保存特征数据
# 只保留数值型特征
numeric_feature_keys = [key for i, key in enumerate(feature_keys)
if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))]
# 创建特征数据的DataFrame
df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys)
# 添加标签列
df_features['label'] = labels
# 添加时间信息便于分析
if len(sample_list) > 0:
times = [klc.start_time for klc in sample_list]
df_features['time'] = times
# 保存到CSV
df_features.to_csv(csv_path, index=False)
print(f"验证特征数据已保存到 {csv_path}")
print("Validating data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------")
return np.array(feature_data), np.array(labels)
def validate_model(self, dataframe=None, save_csv=False, csv_path='validate_feature_data.csv'):
"""
使用dataframe后20%的数据验证模型
:param dataframe: 输入的DataFrame,如果为None则使用初始化时的dataframe
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 验证结果
"""
if self.model is None:
print("模型尚未训练,请先调用train_model方法")
return None
if dataframe is None:
dataframe = self.dataframe
# 分割数据集,后20%用于测试
train_size = int(len(dataframe) * 0.8)
test_df = dataframe.iloc[train_size:].copy()
# 获取测试集特征和标签
X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=csv_path)
if len(X_test) == 0:
print("没有提取到足够的测试特征数据")
return None
# 预测
dtest = xgb.DMatrix(X_test)
y_pred_prob = self.model.predict(dtest)
y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob]
# 计算评估指标
accuracy = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred, zero_division=0)
recall = recall_score(y_test, y_pred, zero_division=0)
f1 = f1_score(y_test, y_pred, zero_division=0)
# 打印评估报告
print("模型评估结果:")
print(f"准确率: {accuracy:.4f}")
print(f"精确率: {precision:.4f}")
print(f"召回率: {recall:.4f}")
print(f"F1分数: {f1:.4f}")
print("\n分类报告:")
print(classification_report(y_test, y_pred, zero_division=0))
return {
'accuracy': accuracy,
'precision': precision,
'recall': recall,
'f1': f1,
'y_test': y_test,
'y_pred': y_pred,
'y_pred_prob': y_pred_prob
}
def predict(self, klc):
"""
使用训练好的模型预测单个KLC
:param klc: 需要预测的ChanKLC对象
:return: 预测结果(概率值)
"""
if self.model is None:
print("模型尚未训练,请先调用train_model方法")
return None
# 提取特征
features = klc.get_feature_data()
feature_vec = []
# 与get_feature_data保持一致,只使用相同的特征集
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
# 转换为模型输入格式
dtest = xgb.DMatrix(np.array([feature_vec]))
# 预测
return self.get_decimal(self.model.predict(dtest)[0])
def get_decimal(self, value):
return Decimal("{:.4f}".format(value))
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.ChanLun_Classifier import * # noqa: F403
+2 -274
View File
@@ -1,274 +1,2 @@
from ChanKLU import ChanKLU
from ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE
from ChanMACDSeg import ChanMACDSeg
from ChanMACDUnitTF import ChanMACDUnitTF
from ChanMACDHistSet import ChanMACDHistSet
class ChanMACD():
def __init__(self, klu_list: list[ChanKLU]):
self.klu_list = klu_list
self.seg_list = []
self.unittf_list = []
self.histset_list = []
# 状态标记列表
self.high_position_list = [] # 高位列表
self.high_empty_list = [] # 高位空列表
self.return_zero_list = [] # 归零轴列表
self.cross0_up_list = [] # 向上穿越零轴列表
self.cross0_down_list = [] # 向下穿越零轴列表
# 计算段 / UnitTF / HistSet 及状态标记
self.cal_macd_state()
self.get_klu_sd_list()
def get_klu_sd(self):
if self.klu_list:
sd = self.klu_list[-1].separate_div
if sd > 1:
print(self.klu_list[-1].time, sd)
return True
return False
def get_klu_sd_list(self):
sd_list = []
if self.klu_list:
for klu in self.klu_list:
hist = klu.macdhist
singal = False
if klu.pre and klu.next:
if klu.signal > 0:
signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal
else:
signal = klu.pre.signal < klu.signal and klu.next.signal > klu.signal
sd = klu.separate_div
if sd > 1 and ((hist > 0 and hist < 200) or (hist < 0 and hist > -200)):
sd_list.append(klu.time)
#print(klu.time, sd)
return sd_list
def cal_macd_state(self):
last_seg = None
last_unittf = None
last_histset = None
last_klu = None
for klu in self.klu_list:
# initialise first histset
if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0:
continue
if last_histset is None:
if klu.macdhist > 0:
last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE)
self.histset_list.append(last_histset)
else:
last_histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(last_histset)
else:
# initialise first seg and unittf
if last_seg is None:
# create histset afterwards
if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
if klu.macdhist > 0:
last_histset.add_klu(klu)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
last_histset.set_next(histset)
histset.set_pre(last_histset)
last_histset.set_end_klu(last_klu)
last_histset = histset
else:
if klu.macdhist < 0:
last_histset.add_klu(klu)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE)
self.histset_list.append(histset)
last_histset.set_next(histset)
histset.set_pre(last_histset)
last_histset.set_end_klu(last_klu)
last_histset = histset
if last_klu.signal >= 0 and klu.signal < 0:
last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
self.unittf_list.append(last_unittf)
last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.UNDER, last_unittf)
self.seg_list.append(last_seg)
elif last_klu.signal <= 0 and klu.signal > 0:
last_unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
self.unittf_list.append(last_unittf)
last_seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, Chan_MACDSEG_DIR.ABOVE, last_unittf)
self.seg_list.append(last_seg)
# after the first seg and unittf
else:
# create histset afterwards
if last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
if klu.macdhist > 0:
last_histset.add_klu(klu)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
last_histset.set_next(histset)
histset.set_pre(last_histset)
last_histset.set_end_klu(last_klu)
last_histset = histset
if last_unittf:
last_unittf.add_histset(last_histset)
else:
if klu.macdhist < 0:
last_histset.add_klu(klu)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE)
self.histset_list.append(histset)
last_histset.set_next(histset)
histset.set_pre(last_histset)
last_histset.set_end_klu(last_klu)
last_histset = histset
if last_unittf:
last_unittf.add_histset(last_histset)
if last_klu.signal >= 0 and klu.signal < 0:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.UNDER, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_seg.set_end_klu(last_klu)
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.UNDER, unittf)
self.seg_list.append(seg)
last_seg.set_next(seg)
last_seg = seg
last_unittf = unittf
elif last_klu.signal <= 0 and klu.signal > 0:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.CROSS0, last_histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_seg.set_end_klu(last_klu)
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, Chan_MACDSEG_DIR.ABOVE, unittf)
self.seg_list.append(seg)
last_seg.set_next(seg)
last_seg = seg
last_unittf = unittf
elif last_unittf.is_end and last_klu.macd < klu.macd and klu.macd > klu.signal:
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, Chan_MACDUNITTF_DIR.ABOVE, Chan_MACDUNITTF_TYPE.NEAR0, last_histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_seg.add_unittf(unittf)
last_unittf = unittf
last_seg.add_klu(klu)
else:
if not last_unittf.is_end:
last_unittf.add_klu(klu)
last_seg.add_klu(klu)
last_klu = klu
klu.cal_macd_state()
#print(klu.time, klu.macd_state, klu.continue_div, klu.separate_div, klu.macd, klu.signal, klu.macdhist, klu.ema24, klu.ema52, klu.close)
return self.klu_list
def cal_macd(self):
last_seg = None
last_unittf = None
last_histset = None
histset = None
last_klu = None
for klu in self.klu_list:
klu.cal_macd_state()
print(klu.time, klu.macd_state)
# 1) 只有当 MACD 已可用(非 UNKNOWN)时,才开始初始化段/单元
if last_seg is None:
if klu.macd_state != Chan_MACD_STATE.UNKNOWN:
# 初始化首个直方图集合(根据当前柱体正负)
if klu.macdhist >= 0:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.ABOVE)
else:
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, None, Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
last_histset = histset
# 初始化首段
seg_dir = Chan_MACDSEG_DIR.ABOVE if klu.signal >= 0 else Chan_MACDSEG_DIR.UNDER
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, None, seg_dir, last_unittf)
self.seg_list.append(seg)
last_seg = seg
# 初始化首个UnitTF
unittf_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, None, unittf_dir, Chan_MACDUNITTF_TYPE.START, histset)
self.unittf_list.append(unittf)
last_unittf = unittf
last_seg.add_unittf(unittf)
# 未就绪则继续等下一根;已就绪亦已完成首个结构初始化,继续下一根
last_klu = klu
continue
# 3) 直方图集合(基于当前 unittf)
if klu.macdhist >= 0:
if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
last_histset.add_klu(klu)
else:
# 结束旧 histset(以前一根结束更合理)
if last_histset and last_klu:
last_histset.set_end_klu(last_klu)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
if last_histset:
last_histset.set_next(histset)
last_histset = histset
if last_unittf:
last_unittf.add_histset(histset)
else:
if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER:
last_histset.add_klu(klu)
else:
# 结束旧 histset(以前一根结束更合理)
if last_histset and last_klu:
last_histset.set_end_klu(last_klu)
histset = ChanMACDHistSet(len(self.histset_list), klu.time, klu, last_histset, Chan_MACDHISTSET_DIR.ABOVE if klu.macdhist >= 0 else Chan_MACDHISTSET_DIR.UNDER)
self.histset_list.append(histset)
if last_histset:
last_histset.set_next(histset)
last_histset = histset
if last_unittf:
last_unittf.add_histset(histset)
# 2) 过零切段(使用KLU中的穿越状态)
if (klu.macd_state == Chan_MACD_STATE.CROSS0_UP or
klu.macd_state == Chan_MACD_STATE.CROSS0_DOWN):
# 结束旧 unittf
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.CROSS0)
# 新的单位时间周期
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.CROSS0, histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_unittf = unittf
# 收尾旧段
last_seg.set_end_klu(last_klu)
# 新段方向取反
new_dir = Chan_MACDSEG_DIR.UNDER if last_seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else Chan_MACDSEG_DIR.ABOVE
seg = ChanMACDSeg(len(self.seg_list), klu.time, klu, last_seg, new_dir, last_unittf)
self.seg_list.append(seg)
last_seg.set_next(seg)
last_seg = seg
last_seg.add_unittf(unittf)
else:
# 4) UnitTF 状态机:用黄线Signal的归零轴
if last_klu.macd_state == Chan_MACD_STATE.NEAR0 and last_unittf.div_count > 1:
#print(klu.time, klu.macd_state)
if klu.macd_state == Chan_MACD_STATE.RZ_UP:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0)
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_unittf = unittf
last_seg.add_unittf(unittf)
elif klu.macd_state == Chan_MACD_STATE.RZ_DOWN:
last_unittf.set_end_klu(last_klu, Chan_MACDUNITTF_TYPE.NEAR0)
new_dir = Chan_MACDUNITTF_DIR.ABOVE if klu.signal >= 0 else Chan_MACDUNITTF_DIR.UNDER
unittf = ChanMACDUnitTF(len(self.unittf_list), klu.time, klu, last_unittf, new_dir, Chan_MACDUNITTF_TYPE.NEAR0, histset)
self.unittf_list.append(unittf)
last_unittf.set_next(unittf)
last_unittf = unittf
last_seg.add_unittf(unittf)
else:
last_unittf.add_klu(klu)
last_seg.add_klu(klu)
else:
last_unittf.add_klu(klu)
last_seg.add_klu(klu)
last_klu = klu
last_histset.set_end_klu(last_klu)
last_unittf.set_end_klu(last_klu, None)
last_seg.set_end_klu(last_klu)
return self.klu_list
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.indicators.ChanMACD import * # noqa: F403
+2 -117
View File
@@ -1,117 +1,2 @@
from ChanEnum import Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACD_STATE
class ChanMACDHistSet():
def __init__(self, index, start_time, start_klu, pre_histset, dir):
self.index = index
self.start_time = start_time
self.end_time = None
self.klu_list = []
self.klu_list.append(start_klu)
self.histset_dir = dir
self.next = None
self.pre = pre_histset
self.peak_klu = None
self.area = start_klu.macdhist
self.unittf_div = Chan_MACDUNITTF_DIV.UNDIV
self.middle_klu = None
self.div_count = 0
self.last_klu = start_klu
self.start_klu = start_klu
self.peak_div_list = []
self.middle_area = 0
self.total_macdhist = 0
def set_next(self, next_histset):
self.next = next_histset
def set_pre(self, pre_histset):
self.pre = pre_histset
def set_middle_klu(self, middle_klu):
self.middle_klu = middle_klu
#self.middle_area = abs(middle_klu.macdhist)
#self.middle_klu = None
def set_unittf_div(self, unittf_div):
self.unittf_div = unittf_div
def add_klu(self, klu):
klu.set_histset(self)
self.klu_list.append(klu)
self.area += abs(klu.macdhist)
if self.middle_klu:
self.middle_area += abs(klu.macdhist)
if self.middle_klu and self.middle_klu.index + 1 == klu.index:
self.low_klu = None
self.peak_klu = None
self.div_count = 0
self.peak_div_list = []
else:
if self.last_klu:
self.cal_macdhist_klu(klu)
self.last_klu = klu
def cal_macdhist_klu(self, klu):
if self.middle_klu:
if klu.index >= self.middle_klu.index + 2:
if klu.pre.pre:
if abs(klu.pre.macdhist) > abs(klu.pre.pre.macdhist) and abs(klu.pre.macdhist) > abs(klu.macdhist):
if self.peak_klu:
if abs(klu.pre.macdhist) > abs(self.peak_klu.macdhist):
self.peak_klu = klu.pre
#self.div_count = 0
#self.peak_div_list = []
else:
if klu.pre.macd * klu.pre.macdhist > 0:
self.peak_div_list.append(klu.pre)
self.div_count += 1
klu.pre.continue_div = True
else:
self.peak_klu = klu.pre
else:
if len(self.klu_list) >= 3:
if klu.pre.pre:
if abs(klu.pre.macdhist) > abs(klu.pre.pre.macdhist) and abs(klu.pre.macdhist) > abs(klu.macdhist):
if self.peak_klu:
if abs(klu.pre.macdhist) > abs(self.peak_klu.macdhist):
self.peak_klu = klu.pre
#self.div_count = 0
#self.peak_div_list = []
else:
if klu.pre.macd * klu.pre.macdhist > 0 and klu.pre.signal * klu.pre.macdhist > 0:
self.peak_div_list.append(klu.pre)
self.div_count += 1
klu.pre.continue_div = True
else:
self.peak_klu = klu.pre
def set_end_klu(self, end_klu):
self.end_klu = end_klu
self.end_time = end_klu.time
#if len(self.peak_div_list) > 0:
#klu = self.peak_div_list[-1]
#if klu.macd * klu.macdhist > 0:
#end_klu.continue_div = True
#print(end_klu.time, "Continue Div")
if self.start_klu.index == end_klu.index:
self.peak_klu = self.start_klu
if self.start_klu.index + 1 == end_klu.index:
if abs(self.start_klu.macdhist) > abs(end_klu.macdhist):
self.peak_klu = self.start_klu
else:
self.peak_klu = end_klu
if len(self.klu_list) >= 3 and self.peak_klu == None:
self.peak_klu = self.klu_list[0]
for klu in self.klu_list:
if abs(klu.macdhist) > abs(self.peak_klu.macdhist):
self.peak_klu = klu
peak_str = ""
state_str = ""
for peak_div in self.peak_div_list:
peak_str += f"{peak_div.time}, "
state_str += f"{peak_div.macd_state}, "
total_macdhist = 0
first_klu = self.klu_list[0]
last_klu = self.klu_list[-1]
if (first_klu.macd > 0 and last_klu.macd > 0 and first_klu.macdhist > 0) or (first_klu.macd < 0 and last_klu.macd < 0 and first_klu.macdhist < 0):
for klu in self.klu_list:
self.total_macdhist += klu.macdhist
if abs(self.total_macdhist) < 150:
#print(self.end_time, "Total MACDHist: ", self.total_macdhist)
last_klu.separate_div = 99999
#if self.peak_klu and len(self.peak_div_list) > 0:
#print("Continue Div: ",self.start_time, "Peak:", self.peak_klu.time, "Div: ", peak_str, state_str)
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.indicators.ChanMACDHistSet import * # noqa: F403
+2 -49
View File
@@ -1,49 +1,2 @@
from ChanEnum import Chan_MACDSEG_DIR
class ChanMACDSeg():
def __init__(self, index, start_time, start_klu, pre_seg, seg_dir, start_unittf):
self.index = index
self.start_time = start_time
self.end_time = None
self.start_klu = start_klu
self.end_klu = None
self.klu_list = []
self.klu_list.append(start_klu)
self.unittf_list = []
self.seg_dir = seg_dir
self.pre = pre_seg
self.next = None
self.high_klu = start_klu
self.low_klu = start_klu
self.ref_klu = None
self.unittf_list.append(start_unittf)
def set_next(self, next_seg):
self.next = next_seg
def set_pre(self, pre_seg):
self.pre = pre_seg
def add_klu(self, klu):
if klu:
self.klu_list.append(klu)
klu.set_seg(self)
if self.seg_dir == Chan_MACDSEG_DIR.ABOVE:
if klu.macdhist > self.high_klu.macdhist:
self.high_klu = klu
else:
if klu.macdhist < self.low_klu.macdhist:
self.low_klu = klu
else:
if klu.macdhist < self.high_klu.macdhist:
self.high_klu = klu
else:
if klu.macdhist > self.low_klu.macdhist:
self.low_klu = klu
if self.high_klu.index != self.start_klu.index:
self.ref_klu = self.high_klu
def add_unittf(self, unittf):
self.unittf_list.append(unittf)
unittf.set_next(self)
def set_end_klu(self, end_klu):
self.add_klu(end_klu)
self.end_klu = end_klu
self.end_time = end_klu.time
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.indicators.ChanMACDSeg import * # noqa: F403
+2 -141
View File
@@ -1,141 +1,2 @@
from ChanEnum import Chan_MACD_STATE, Chan_MACDUNITTF_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIV, Chan_MACDUNITTF_TYPE
class ChanMACDUnitTF():
def __init__(self, index, start_time, start_klu, pre_unittf, unittf_dir, start_type, start_histset):
self.index = index
self.start_time = start_time
self.end_time = None
self.start_klu = start_klu
self.end_klu = None
self.klu_list = []
self.klu_list.append(start_klu)
self.histset_list = []
self.histset_list.append(start_histset)
self.div_count = 0
start_histset.set_middle_klu(start_klu)
self.next = None
self.pre = pre_unittf
self.unittf_dir = unittf_dir
self.start_type = start_type
self.end_type = None
self.peak_klu = None
self.div_type = Chan_MACDUNITTF_DIV.UNDIV
self.div_peak_list = []
self.is_end = False
def set_next(self, next_unittf):
self.next = next_unittf
def set_pre(self, pre_unittf):
self.pre = pre_unittf
def add_histset(self, histset):
self.histset_list.append(histset)
def add_klu(self, klu):
self.klu_list.append(klu)
self.cal_peak_div()
self.cal_macd_state()
def cal_peak_div(self):
self.div_count = 0
self.div_peak_list = []
self.peak_klu = None
if len(self.histset_list) == 0:
return
if len(self.histset_list) == 1:
self.div_type = self.histset_list[0].unittf_div
self.peak_klu = self.histset_list[0].peak_klu
else:
for index in range(0, len(self.histset_list)):
histset = self.histset_list[index]
if self.same_dir(histset):
#if histset.peak_klu:
#print("Unittf: ", self.start_klu.time, len(self.histset_list), histset.peak_klu.time)
if self.peak_klu:
if histset.peak_klu:
if abs(histset.peak_klu.macdhist) >= abs(self.peak_klu.macdhist):
self.peak_klu = histset.peak_klu
self.div_type = Chan_MACDUNITTF_DIV.UNDIV
self.div_count = 0
else:
self.div_type = Chan_MACDUNITTF_DIV.DISCRETE
self.div_count += 1
self.div_peak_list.append(histset.peak_klu)
#print("Unittf: ", self.start_klu.time)
if histset.peak_klu.macd > 0 and histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
histset.peak_klu.set_separate_div(self.div_count)
elif histset.peak_klu.macd < 0 and histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER:
histset.peak_klu.set_separate_div(self.div_count)
else:
if histset.peak_klu:
self.peak_klu = histset.peak_klu
def cal_macd_state(self):
if len(self.klu_list) > 0:
last_klu = self.klu_list[0]
macd_peak_klu = None
signal_peak_klu = None
for index in range(1, len(self.klu_list)):
klu = self.klu_list[index]
if klu.macd == 0 and klu.signal == 0 and klu.macdhist == 0:
continue
if self.start_type == Chan_MACDUNITTF_TYPE.CROSS0 or self.start_type == Chan_MACDUNITTF_TYPE.NEAR0:
if self.unittf_dir == Chan_MACDUNITTF_DIR.ABOVE:
if macd_peak_klu is None:
if last_klu.macd < klu.macd:
if last_klu.signal < last_klu.macdhist:
last_klu.set_macd_state(Chan_MACD_STATE.UP)
else:
last_klu.set_macd_state(Chan_MACD_STATE.HIGH)
else:
macd_peak_klu = last_klu
last_klu.set_macd_state(Chan_MACD_STATE.PEAK)
elif klu.macd > macd_peak_klu.macd:
macd_peak_klu = None
last_klu.set_macd_state(Chan_MACD_STATE.HIGH)
elif last_klu.signal < klu.signal:
last_klu.set_macd_state(Chan_MACD_STATE.HIGH_EMPTY)
elif signal_peak_klu is None:
signal_peak_klu = last_klu
last_klu.set_macd_state(Chan_MACD_STATE.HIGH_EMPTY)
elif klu.signal > signal_peak_klu.signal:
signal_peak_klu = None
last_klu.set_macd_state(Chan_MACD_STATE.HIGH)
elif last_klu.macd < last_klu.signal:
last_klu.set_macd_state(Chan_MACD_STATE.RETURN_ZERO)
if self.return_zero(last_klu, klu):
self.end_type = Chan_MACDUNITTF_TYPE.NEAR0
self.is_end = True
self.end_klu = klu
self.end_time = klu.time
klu.set_macd_state(Chan_MACD_STATE.NEAR0)
#print(self.index, last_klu.time, last_klu.macd, last_klu.signal, last_klu.macd_state, klu.macd_state)
break
#print(self.index, last_klu.time, last_klu.macd, last_klu.signal, last_klu.macd_state)
last_klu = klu
def return_zero(self, last_klu, klu):
return_zero = False
if last_klu.close < last_klu.ema52 and klu.close > klu.ema52:
return_zero = True
return_zero = False
return return_zero
def same_dir(self, histset):
if self.unittf_dir == Chan_MACDUNITTF_DIR.ABOVE:
return histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE
else:
return histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER
def set_end_klu(self, end_klu, end_type):
self.end_type = end_type
self.end_klu = end_klu
self.end_time = end_klu.time
self.is_end = True
self.cal_macd_state()
div_time = ""
for div in self.div_peak_list:
div_time += f"{div.time}, "
histset_time = ""
for histset in self.histset_list:
histset_time += f"{histset.start_time}, "
#if self.peak_klu and self.div_type == Chan_MACDUNITTF_DIV.DISCRETE:
#print("Cross Div: ", self.start_klu.time, self.peak_klu.time, self.div_count, self.div_type, self.unittf_dir, div_time, len(self.histset_list))
"""兼容 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 TRENDTREND_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 -402
View File
@@ -1,402 +1,2 @@
import sys
import os
#sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/chan.py"))
sys.path.append(os.path.abspath("/Users/jack/Project/chan.py"))
from Chan import CChan
from BuySellPoint.BS_Point import CBS_Point
from ChanConfig import CChanConfig
from Common.CEnum import AUTYPE, DATA_SRC, KL_TYPE, DATA_FIELD, BSP_TYPE, FX_TYPE, BI_DIR, KLINE_DIR, SEG_DIR
from KLine.KLine_Unit import CKLine_Unit
from Common.CTime import CTime
from Common.func_util import kltype_lt_day, str2float
from Bi.Bi import CBi
from typing import Dict, List
from functools import reduce
from pandas import DataFrame
from datetime import datetime, timedelta, timezone
def GetColumnNameFromFieldList(fileds: str):
_dict = {
"time": DATA_FIELD.FIELD_TIME,
"open": DATA_FIELD.FIELD_OPEN,
"high": DATA_FIELD.FIELD_HIGH,
"low": DATA_FIELD.FIELD_LOW,
"close": DATA_FIELD.FIELD_CLOSE,
"volume": DATA_FIELD.FIELD_VOLUME
}
return [_dict[x] for x in fileds.split(",")]
class ChanPY():
k_type = KL_TYPE.K_5M
config = CChanConfig({
"bi_strict": True,
"bi_algo": "normal",
"trigger_step": True,
"skip_step": 0,
"divergence_rate": float("inf"),
"bsp2_follow_1": False,
"bsp3_follow_1": False,
"min_zs_cnt": 1,
"bs1_peak": False,
"macd_algo": "peak",
"bs_type": '1,2,3a,1p,2s,3b',
"print_warning": True,
"zs_algo": "normal",
})
chan = CChan(
code="BTC/USDT:USDT",
data_src=DATA_SRC.CCXT,
lv_list=[k_type],
config=config,
autype=AUTYPE.QFQ,
)
klu_list = []
bsps = []
chanIn = True
#def __init__(self, dataframe):
#self.klu_list = self.get_kl_data(dataframe)
#for klu in self.klu_list:
#self.chan.trigger_load({self.k_type: [klu]})
def add_klu(self, klu):
if klu:
self.chan.trigger_load({self.k_type: [klu]})
self.klu_list.append(klu)
def add_klu_from_dataframe(self, dataframe):
if len(dataframe) > len(self.klu_list) and len(dataframe) - len(self.klu_list) == 1:
klu = self.get_last_klu(dataframe)
self.chan.trigger_load({self.k_type: [klu]})
self.klu_list.append(klu)
def parse_time_column(self, inp):
if len(inp) == 10:
year = int(inp[:4])
month = int(inp[5:7])
day = int(inp[8:10])
hour = minute = 0
elif len(inp) == 17:
year = int(inp[:4])
month = int(inp[4:6])
day = int(inp[6:8])
hour = int(inp[8:10])
minute = int(inp[10:12])
elif len(inp) == 19:
year = int(inp[:4])
month = int(inp[5:7])
day = int(inp[8:10])
hour = int(inp[11:13])
minute = int(inp[14:16])
else:
raise Exception(f"unknown time column from TradingView:{inp}")
return CTime(year, month, day, hour, minute, auto=not kltype_lt_day(self.k_type))
def create_item_dict(self, data, column_name):
for i in range(len(data)):
data[i] = self.parse_time_column(data[i]) if i == 0 else str2float(data[i])
return dict(zip(column_name, data))
def get_last_klu(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
item = dataframe.iloc[-1]
date = item['date']
o = item['open']
h = item['high']
l = item['low']
c = item['close']
v = item['volume']
#time_obj = date.fromtimestamp(date)
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
item_data = [
time_str,
o,
h,
l,
c,
v
]
klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True)
klu.set_idx(len(dataframe)-1)
return klu
def get_kl_data(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
klu_list = []
for i in range(0, len(dataframe)):
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)
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
item_data = [
time_str,
o,
h,
l,
c,
v
]
klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True)
klu.set_idx(i)
klu_list.append(klu)
return klu_list
def get_bsp_type(self, bsp_type, is_buy):
if is_buy:
if bsp_type == BSP_TYPE.T1:
return 1
if bsp_type == BSP_TYPE.T1P:
return 2
if bsp_type == BSP_TYPE.T2:
return 3
if bsp_type == BSP_TYPE.T2S:
return 4
if bsp_type == BSP_TYPE.T3A:
return 5
if bsp_type == BSP_TYPE.T3B:
return 6
else:
if bsp_type == BSP_TYPE.T1:
return -1
if bsp_type == BSP_TYPE.T1P:
return -2
if bsp_type == BSP_TYPE.T2:
return -3
if bsp_type == BSP_TYPE.T2S:
return -4
if bsp_type == BSP_TYPE.T3A:
return -5
if bsp_type == BSP_TYPE.T3B:
return -6
def get_bsps(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
bsps = []
updown = []
bi_sure = []
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
bsp_list = []
bsp_list_pre_len = 0
last_bsp_value = 0
last_updown = -1
bi_list_pre_len = 0
pre_bi = None
zs_list_pre_len = 0
pre_zs = None
for klu in kl_data: # 获取单根K线
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
kl_datas = self.chan.kl_datas[self.k_type]
bi_list = kl_datas.bi_list
lst = kl_datas.lst
if len(bsp_list) > 0:
last_bsp = bsp_list[-1]
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close)
if bsp_list_pre_len > len(bsp_list):
if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
bsps.append(1)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98)
else:
bsps.append(99)
else:
if bsp_list_pre_len == len(bsp_list):
if klu.idx == last_bsp.klu.idx:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
bsps.append(last_bsp_value)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value)
else:
bsps.append(0)
else:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
bsps.append(last_bsp_value)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value)
else:
bsps.append(0)
bsp_list_pre_len = len(bsp_list)
#Check zs -----------------------------------
zs_list = kl_datas.zs_list
if len(zs_list) > 0:
zs = zs_list[-1]
#if zs_list_pre_len > len(zs_list):
#print("No zs", zs.begin.time)
#if len(zs_list) > zs_list_pre_len:
#print(zs.begin.time, zs.end.time, zs.end.idx, zs.high, zs.low, zs.peak_high, zs.peak_low)
zs_list_pre_len = len(zs_list)
pre_zs = zs
#Check Bi -----------------------------------
if len(bi_list) > 0:
last_bi = bi_list[-1]
if len(bi_list) == 1:
if last_bi.dir == BI_DIR.UP:
updown.append(1)
last_updown = 1
else:
updown.append(-1)
last_updown = -1
else:
if last_updown == 1:
if last_bi.dir == BI_DIR.UP:
updown.append(0)
else:
updown.append(-1)
last_updown = -1
else:
if last_bi.dir == BI_DIR.DOWN:
updown.append(0)
else:
updown.append(1)
last_updown = 1
else:
updown.append(0)
bi_list = kl_datas.bi_list
if len(bi_list) > 0:
last_bi = bi_list[-1]
#if bi_list_pre_len > len(bi_list):
#print("Bi ", klu.time, pre_bi.idx, pre_bi.is_sure, bi_list[-1].idx, bi_list[-1].is_sure)
if last_bi.is_sure:
bi_sure.append(1)
#print(klu.time, last_bi.is_sure)
else:
bi_sure.append(0)
pre_bi = bi_list[-1]
bi_list_pre_len = len(bi_list)
else:
bi_sure.append(0)
#if bsps[-1] != 0 or updown[-1] != 0:
#print(klu.time, bsps[-1], updown[-1], bi_list[-1].is_sure)
self.chanIn = False
else:
klu = self.get_last_klu(dataframe)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
for index in range(0, len(bsps)):
if not (abs(bsps[index]) == 1 or abs(bsps[index]) == 2):
bsps[index] = 0
else:
if bsps[index] == 2:
bsps[index] = 1
else:
if bsps[index] == -2:
bsps[index] = -1
else:
bsps[index] = 0
#print(bsps)
#print(updown)
kl_datas = self.chan.kl_datas[self.k_type]
#for zs in kl_datas.zs_list:
#print(zs.begin.time, zs.end.time)
return bsps, updown, bi_sure
def get_bsp_state1(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
bsps = []
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
self.chan.trigger_load({self.k_type: kl_data})
bsp_list = self.chan.get_bsp()
bsp_index = 0
for klu in kl_data:
if bsp_index >= len(bsp_list):
bsp_index = len(bsp_list) - 1
bsp = bsp_list[bsp_index]
if klu.idx == bsp.klu.idx:
bsp_type = self.get_bsp_type(bsp.type[0], bsp.is_buy)
if abs(bsp_type) == 1 or abs(bsp_type) == 10:
bsps.append(1)
else:
bsps.append(0)
bsp_index = bsp_index + 1
else:
bsps.append(0)
self.chanIn = False
else:
klu = CKLine_Unit(self.create_item_dict(self.get_last_item_data(dataframe), GetColumnNameFromFieldList(fields)), autofix=True)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
return bsps
def get_bsp_state(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
bsp_list = []
bsp_list_pre_len = 0
last_bsp_value = 0
last_bsp_index = 0
for klu in kl_data: # 获取单根K线
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
kl_datas = self.chan.kl_datas[self.k_type]
bi_list = kl_datas.bi_list
lst = kl_datas.lst
if len(bsp_list) > 0:
last_bsp = bsp_list[-1]
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close)
if bsp_list_pre_len > len(bsp_list):
if abs(last_bsp_value) == 1:
self.bsps.append(1)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98)
else:
self.bsps.append(99)
else:
if bsp_list_pre_len == len(bsp_list):
if klu.idx == last_bsp.klu.idx:
if last_bsp.klu.idx - last_bsp_index > 3:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
self.bsps.append(last_bsp_value)
else:
self.bsps.append(0)
last_bsp_index = last_bsp.klu.idx
#if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw")
else:
self.bsps.append(0)
else:
if klu.idx == last_bsp.klu.idx:
if last_bsp.klu.idx - last_bsp_index > 3:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
self.bsps.append(last_bsp_value)
else:
self.bsps.append(0)
last_bsp_index = last_bsp.klu.idx
#if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw")
else:
self.bsps.append(0)
else:
self.bsps.append(0)
bsp_list_pre_len = len(bsp_list)
self.chanIn = False
else:
klu = self.get_last_klu(dataframe)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
last_bsp = bsp_list[-1]
if last_bsp.klu.idx == klu.idx:
self.bsps.append(self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy))
else:
self.bsps.append(0)
for index in range(0, len(self.bsps)):
if not (abs(self.bsps[index]) == 1 or abs(self.bsps[index]) == 2):
self.bsps[index] = 0
else:
if self.bsps[index] == 2:
self.bsps[index] = 10
else:
if self.bsps[index] == -2:
self.bsps[index] = -10
else:
if self.bsps[index] == 1:
self.bsps[index] = 1
else:
if self.bsps[index] == -1:
self.bsps[index] = -1
else:
self.bsps[index] = 0
return self.bsps
"""兼容 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 -91
View File
@@ -1,91 +1,2 @@
import copy
from typing import Dict, Optional
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR
import ChanKLU
from ChanBI import ChanBI
class ChanSBI():
def __init__(self, start_bi: ChanBI, index, dir=Chan_BI_DIR.UP):
self.start_bi = start_bi
self.end_bi = None
self.index = index
self.dir = dir
self.high = start_bi.high
self.low = start_bi.low
self.pre = None
self.next = None
self.fx = Chan_FX_TYPE.UNKNOWN
self.bi_list = []
self.bi_list.append(start_bi)
self.has_fx_gap = False
def set_fx(self, fx):
self.fx = fx
def set_end_bi(self, bi):
self.end_bi = bi
def set_pre(self, sbi):
self.pre = sbi
def set_next(self, sbi):
self.next = sbi
def add_bi(self, bi):
self.bi_list.append(bi)
def check_fx(self):
if self.pre and self.next:
#print(self.pre.start_bi.start_time, self.start_bi.start_time, self.end_bi.end_time, self.next.start_bi.start_time, self.pre.high, self.high, self.next.high, self.pre.low, self.low, self.next.low, self.dir)
if self.high > self.pre.high and self.high > self.next.high:
self.fx = Chan_FX_TYPE.TOP
#print(self.start_bi.start_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.fx)
if self.low > self.pre.high:
self.has_fx_gap = True
#print(self.start_bi.start_time, self.end_bi.end_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.dir, self.has_fx_gap, self.fx)
return Chan_FX_TYPE.TOP
else:
if self.low < self.pre.low and self.low < self.next.low:
self.fx = Chan_FX_TYPE.BOTTOM
#print(self.start_bi.start_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.fx)
if self.high < self.pre.low:
self.has_fx_gap = True
#print(self.start_bi.start_time, self.end_bi.end_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.dir, self.has_fx_gap, self.fx)
return Chan_FX_TYPE.BOTTOM
return Chan_FX_TYPE.UNKNOWN
def check_seg_bi_broken(self):
broken = False
if self.fx == Chan_FX_TYPE.TOP:
if self.next.low < self.pre.high:
broken = True
elif self.fx == Chan_FX_TYPE.BOTTOM:
if self.next.high > self.pre.low:
broken = True
return broken
def check_bi_included(self, bi):
included = False
if self.high > bi.high:
# high大于,low小于,左包含
if self.low < bi.low:
included = True
# high大于,low大于,不包含
else:
# if self.low > bi.low
# high相等,右包含
included = False
else:
included = False
# high小于,low大于,右包含
#if self.low > bi.low:
#included = True
if included:
if self.pre:
if self.high > self.pre.high and self.low < self.pre.low:
included = True
if included:
self.add_bi(bi)
# gn>gn-1
if self.dir == Chan_BI_DIR.DOWN:
# UP -> max(dn)
self.low = bi.low
else:
# DOWN -> min(gn)
self.high = bi.high
#self.print(bi, "Z")
#print(self.start_bi.start_time, bi.start_time, included)
return included
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanSBI import * # noqa: F403
+2 -191
View File
@@ -1,191 +1,2 @@
import copy
from typing import Dict, Optional
from ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_SEG_DIR, Chan_BI_DIR, Chan_ZS_DIR
import ChanCTime
from ChanBI import ChanBI
from ChanBIZS import ChanBIZS
class ChanSEG():
def __init__(self, start_bi: ChanBI, index, ddir=Chan_SEG_DIR.UP, pre_end_bi: ChanBI = None):
self.start_bi = start_bi
self.start_time = start_bi.start_time
self.end_time = None
self.end_bi = None
self.dir = ddir
self.low = 0
self.high = 0
if self.dir == Chan_SEG_DIR.UP and start_bi:
self.low = start_bi.low
else:
if start_bi:
self.high = start_bi.high
self.index = index
self.pre = None
self.next = None
self.bi_list = []
self.bi_list.append(start_bi)
self.is_sure = False
self.sure_time = None
self.macd_hist = 0
self.macd_div = 0
self.start_bi.set_seg(self)
self.pre_end_bi = pre_end_bi
if self.pre_end_bi:
self.ini_seg()
def ini_seg(self):
next_bi = self.start_bi.next
for index in range(self.start_bi.index+1, self.pre_end_bi.index):
if next_bi:
self.bi_list.append(next_bi)
next_bi.set_seg(self)
next_bi = next_bi.next
def set_macdhist(self, macd_hist):
self.macd_hist = macd_hist
def set_macd_div(self, macd_div):
self.macd_div = macd_div
def set_end_bi(self, bi: ChanBI, sure_bi: ChanBI):
self.end_bi = bi
if bi and bi.is_sure:
if self.dir == Chan_SEG_DIR.UP:
self.high = bi.high
else:
self.low = bi.low
self.is_sure = True
self.end_time = bi.end_klc.end_time
if sure_bi.is_sure:
self.sure_time = sure_bi.sure_time
self.format_bi_list()
def pre_set_end_bi(self, bi: ChanBI):
self.end_bi = bi
if bi and bi.is_sure:
if self.dir == Chan_SEG_DIR.UP:
self.high = bi.high
else:
self.low = bi.low
self.end_time = bi.end_klc.end_time
self.format_bi_list()
def set_pre(self, seg):
self.pre = seg
def set_next(self, seg):
self.next = seg
def set_sure(self, sure_bi):
if sure_bi.is_sure:
self.sure_time = sure_bi.sure_time
self.is_sure = True
self.format_bi_list()
def format_bi_list(self):
self.bi_list = []
self.bi_list.append(self.start_bi)
if self.end_bi:
next_bi = self.start_bi.next
for i in range(self.start_bi.index, self.end_bi.index):
if next_bi:
self.bi_list.append(next_bi)
next_bi.set_seg(self)
next_bi = next_bi.next
def add_bi(self, bi: ChanBI):
if len(self.bi_list) > 0:
self.bi_list.append(bi)
bi.set_seg(self)
self.end_time = bi.end_time
self.end_bi = bi
def cal_bi_zs(self):
zs_list = []
if len(self.bi_list) > 3:
last_zs = None
if self.dir == Chan_SEG_DIR.UP:
for index in range(1, len(self.bi_list)):
bi = self.bi_list[index]
if bi.next == None or bi.next.next == None:
continue
bi2 = bi.next
bi3 = bi.next.next
if len(zs_list) == 0 or (last_zs and last_zs.is_sure):
if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN:
zg = min(bi.high, bi2.high, bi3.high)
zd = max(bi.low, bi2.low, bi3.low)
gg = max(bi.high, bi2.high, bi3.high)
dd = min(bi.low, bi2.low, bi3.low)
zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.UP)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_gg(gg)
zs.set_dd(dd)
zs.add_bi(bi2)
zs.add_bi(bi3)
zs_list.append(zs)
last_zs = zs
else:
if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.DOWN and bi.is_sure:
if bi.low > last_zs.zg or bi.high < last_zs.zd:
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN:
zg = min(bi.high, bi2.high, bi3.high)
zd = max(bi.low, bi2.low, bi3.low)
gg = max(bi.high, bi2.high, bi3.high)
dd = min(bi.low, bi2.low, bi3.low)
zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.UP)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_gg(gg)
zs.set_dd(dd)
zs.add_bi(bi2)
zs.add_bi(bi3)
zs_list.append(zs)
last_zs = zs
else:
last_zs.add_bi(bi.pre)
last_zs.add_bi(bi)
if index == len(self.bi_list) - 1 and last_zs and not last_zs.is_sure:
#print(bi.start_time, "BI", last_zs.is_sure)
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
else:
for index in range(1, len(self.bi_list)):
bi = self.bi_list[index]
if bi.next == None or bi.next.next == None:
continue
bi2 = bi.next
bi3 = bi.next.next
if len(zs_list) == 0 or (last_zs and last_zs.is_sure):
if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.UP:
zg = min(bi.high, bi2.high, bi3.high)
zd = max(bi.low, bi2.low, bi3.low)
gg = max(bi.high, bi2.high, bi3.high)
dd = min(bi.low, bi2.low, bi3.low)
zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.DOWN)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_gg(gg)
zs.set_dd(dd)
zs.add_bi(bi2)
zs.add_bi(bi3)
zs_list.append(zs)
last_zs = zs
else:
if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.UP and bi.is_sure:
if bi.low > last_zs.zg or bi.high < last_zs.zd:
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.UP:
zg = min(bi.high, bi2.high, bi3.high)
zd = max(bi.low, bi2.low, bi3.low)
gg = max(bi.high, bi2.high, bi3.high)
dd = min(bi.low, bi2.low, bi3.low)
zs = ChanBIZS(bi, len(zs_list), Chan_ZS_DIR.DOWN)
zs.set_zg(zg)
zs.set_zd(zd)
zs.set_gg(gg)
zs.set_dd(dd)
zs.add_bi(bi2)
zs.add_bi(bi3)
zs_list.append(zs)
last_zs = zs
else:
last_zs.add_bi(bi.pre)
last_zs.add_bi(bi)
if index == len(self.bi_list) - 1 and last_zs and not last_zs.is_sure:
#print(bi.start_time, "BI", last_zs.is_sure)
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
#print(self.start_time, len(zs_list))
#print(self.bi_list[-1].end_time, "end_bi")
return zs_list
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.ChanSEG import * # noqa: F403
+2 -108
View File
@@ -1,108 +1,2 @@
from typing import Dict, Optional
import ChanKLC, ChanSEG
import ChanCTime
from ChanEnum import Chan_ZS_DIR
# 中枢
class ChanZS():
def __init__(self, start_seg: ChanSEG, index, ddir: Chan_ZS_DIR):
self.start_klc = start_seg.start_bi.start_klc
self.start_time = self.start_klc.start_time
self.end_time = None
self.index = index
self.next = None
self.pre = None
self.start_seg = start_seg
self.seg_list = []
self.seg_list.append(start_seg)
self.end_seg = None
self.last_bi_in = None
self.bi_out = None
self.is_sure = False
self.zg = 0
self.zd = 0
self.gg = 0
self.dd = 0
self.dir = ddir
self.sure_time = None
self.end_klc = None
self.bi_out_count = 0
self.bi_out_list = []
self.bi_out_seg_list = []
self.bi_out_seg = None
self.is_extended = False
def set_last_bi_in(self, last_bi_in):
self.last_bi_in = last_bi_in
def set_bi_out(self, bi_out, bi_out_seg):
if bi_out:
#print(bi_out.start_klc.start_time, bi_out.sure_time, bi_out.dir, bi_out_seg.dir, len(self.bi_out_list))
if len(self.bi_out_list) > 0:
last_bi = self.bi_out_list[-1]
if last_bi.index != bi_out.index:
self.bi_out_list.append(bi_out)
self.bi_out_seg_list.append(bi_out_seg)
else:
self.bi_out_list.append(bi_out)
self.bi_out_seg_list.append(bi_out_seg)
self.bi_out = bi_out
self.bi_out_seg = bi_out_seg
def set_end_klc(self, end_klc, sure_time, bi_out_count, seg):
self.end_klc = end_klc
self.set_end_time(end_klc.end_time)
self.is_sure = True
self.sure_time = sure_time
self.bi_out_count = bi_out_count
self.end_seg = seg
def set_end_seg(self, end_seg):
self.end_seg = end_seg
def set_pre(self, pre):
self.pre = pre
def set_next(self, next):
self.next = next
def set_end_time(self, end_time):
self.end_time = end_time
def add_klc(self, klc):
self.klc_list.append(klc)
def add_seg(self, seg):
self.seg_list.append(seg)
self.end_time = seg.end_time
self.end_seg = seg
def set_zg(self, zg):
self.zg = zg
def set_zd(self, zd):
self.zd = zd
def set_gg(self, gg):
self.gg = gg
def set_dd(self, dd):
self.dd = dd
def extend_zs(self, seg_list):
self.is_sure = False
self.end_seg = None
self.end_klc = None
self.sure_time = None
for seg in seg_list:
if seg.end_bi.high > self.gg:
self.set_gg(seg.end_bi.high)
if seg.end_bi.low < self.dd:
self.set_dd(seg.end_bi.low)
self.seg_list.append(seg)
self.is_extended = True
#print(self.start_time, "extend zs", seg_list[-1].end_time)
# 大级别中枢:由多个区间重叠(扩张)的笔/线段中枢合并而成,用于显示更大级别的震荡区间
class ChanZS_Big():
def __init__(self, zs_list):
assert len(zs_list) >= 1
self.zs_list = list(zs_list)
first = self.zs_list[0]
last = self.zs_list[-1]
self.start_time = first.start_time
self.end_time = last.end_time if last.end_time else None
self.start_klc = first.start_klc
self.end_klc = last.end_klc
# 大级别区间取并集:包住所有子中枢
self.zd = min(zs.zd for zs in self.zs_list)
self.zg = max(zs.zg for zs in self.zs_list)
self.dd = min(zs.dd for zs in self.zs_list)
self.gg = max(zs.gg for zs in self.zs_list)
self.index = 0 # 由外部设置
"""兼容 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 -7
View File
@@ -1,7 +1,2 @@
class Chan_FX_Box():
def __init__(self, start_time, end_time, high, low):
self.start_time = start_time
self.end_time = end_time
self.high = high
self.low = low
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.core.Chan_FX_Box import * # noqa: F403
+2 -448
View File
@@ -1,448 +1,2 @@
import ccxt
import pandas as pd
import numpy as np
import mplfinance as mpf
from talib import MACD, SMA
from datetime import datetime, timedelta
import logging
import datetime as dt
# Configure logging
logging.basicConfig(
filename='chanlun_trading.log',
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# Configuration (user to modify)
BINANCE_API_KEY = 'your_api_key' # Replace with your Binance API key
BINANCE_API_SECRET = 'your_api_secret' # Replace with your Binance API secret
SIMULATION_MODE = True # Set to False for live trading
# 1. Fetch K-line data from Binance (multi-timeframe support)
def fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500):
try:
exchange = ccxt.binance({
'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '',
'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '',
'enableRateLimit': True,
'options': {'defaultType': 'spot'}
})
since = exchange.parse8601((datetime.now(dt.UTC) - timedelta(days=7)).isoformat())
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since, limit)
df = pd.DataFrame(ohlcv, columns=['Date', 'Open', 'High', 'Low', 'Close', 'Volume'])
df['Date'] = pd.to_datetime(df['Date'], unit='ms')
df.set_index('Date', inplace=True)
logging.info(f"Fetched {len(df)} K-lines for {symbol} ({timeframe})")
return df
except Exception as e:
logging.error(f"Failed to fetch data: {e}")
raise
# 2. K-line merging (vectorized)
def merge_kline(df):
try:
df = df.copy()
merged_data = []
trend = np.sign(df['Close'].diff().shift(-1)) # 1: up, -1: down, 0: neutral
# Detect inclusion
is_included = ((df['High'].shift(-1) <= df['High']) & (df['Low'].shift(-1) >= df['Low'])) | \
((df['High'].shift(-1) >= df['High']) & (df['Low'].shift(-1) <= df['Low']))
i = 0
while i < len(df) - 1:
if is_included.iloc[i]:
current_k = df.iloc[i]
next_k = df.iloc[i + 1]
high = max(current_k['High'], next_k['High'])
low = min(current_k['Low'], next_k['Low'])
open_price = current_k['Open']
close_price = next_k['Close'] if trend.iloc[i] >= 0 else next_k['Close']
volume = current_k['Volume'] + next_k['Volume']
merged_data.append({
'Date': next_k.name,
'Open': open_price,
'High': high,
'Low': low,
'Close': close_price,
'Volume': volume
})
i += 2
else:
current_k = df.iloc[i]
merged_data.append({
'Date': current_k.name,
'Open': current_k['Open'],
'High': current_k['High'],
'Low': current_k['Low'],
'Close': current_k['Close'],
'Volume': current_k['Volume']
})
i += 1
if i == len(df) - 1:
last_k = df.iloc[i]
merged_data.append({
'Date': last_k.name,
'Open': last_k['Open'],
'High': last_k['High'],
'Low': last_k['Low'],
'Close': last_k['Close'],
'Volume': last_k['Volume']
})
merged_df = pd.DataFrame(merged_data)
merged_df['Date'] = pd.to_datetime(merged_df['Date'])
merged_df.set_index('Date', inplace=True)
logging.info(f"Merged K-lines: {len(df)} -> {len(merged_df)}")
return merged_df
except Exception as e:
logging.error(f"K-line merging failed: {e}")
raise
# 3. Detect fractals (vectorized)
def detect_fractals(df):
try:
df = df.copy()
df['is_top'] = (df['High'] > df['High'].shift(1)) & (df['High'] > df['High'].shift(-1)) & \
(df['High'] > df['High'].shift(2)) & (df['High'] > df['High'].shift(-2))
df['is_bottom'] = (df['Low'] < df['Low'].shift(1)) & (df['Low'] < df['Low'].shift(-1)) & \
(df['Low'] < df['Low'].shift(2)) & (df['Low'] < df['Low'].shift(-2))
df['is_top'] = df['is_top'].fillna(False)
df['is_bottom'] = df['is_bottom'].fillna(False)
logging.info(f"Detected {df['is_top'].sum()} top fractals and {df['is_bottom'].sum()} bottom fractals")
return df
except Exception as e:
logging.error(f"Fractal detection failed: {e}")
raise
# 4. Detect strokes
def detect_strokes(df):
try:
strokes = []
last_fractal = None
last_price = None
last_index = None
for i in range(len(df)):
if df['is_top'].iloc[i] or df['is_bottom'].iloc[i]:
current_fractal = 'top' if df['is_top'].iloc[i] else 'bottom'
current_price = df['High'].iloc[i] if current_fractal == 'top' else df['Low'].iloc[i]
if last_fractal is None:
last_fractal = current_fractal
last_price = current_price
last_index = df.index[i]
continue
if (last_fractal == 'top' and current_fractal == 'bottom' and current_price < last_price) or \
(last_fractal == 'bottom' and current_fractal == 'top' and current_price > last_price):
strokes.append({
'start_time': last_index,
'end_time': df.index[i],
'start_price': last_price,
'end_price': current_price,
'type': 'down' if current_fractal == 'bottom' else 'up',
'volume': df['Volume'].loc[last_index:df.index[i]].sum()
})
last_fractal = current_fractal
last_price = current_price
last_index = df.index[i]
logging.info(f"Detected {len(strokes)} strokes")
return strokes
except Exception as e:
logging.error(f"Stroke detection failed: {e}")
raise
# 5. Detect segments
def detect_segments(strokes):
try:
segments = []
if len(strokes) < 3:
return segments
i = 0
while i < len(strokes) - 2:
stroke1, stroke2, stroke3 = strokes[i], strokes[i+1], strokes[i+2]
if stroke1['type'] == 'up' and stroke2['type'] == 'down' and stroke3['type'] == 'up':
if stroke3['end_price'] > stroke1['end_price']:
segments.append({
'start_time': stroke1['start_time'],
'end_time': stroke3['end_time'],
'start_price': stroke1['start_price'],
'end_price': stroke3['end_price'],
'type': 'up'
})
i += 3
else:
i += 1
elif stroke1['type'] == 'down' and stroke2['type'] == 'up' and stroke3['type'] == 'down':
if stroke3['end_price'] < stroke1['end_price']:
segments.append({
'start_time': stroke1['start_time'],
'end_time': stroke3['end_time'],
'start_price': stroke1['start_price'],
'end_price': stroke3['end_price'],
'type': 'down'
})
i += 3
else:
i += 1
else:
i += 1
logging.info(f"Detected {len(segments)} segments")
return segments
except Exception as e:
logging.error(f"Segment detection failed: {e}")
raise
# 6. Detect pivots (midlines)
def detect_pivots(strokes):
try:
pivots = []
if len(strokes) < 3:
return pivots
for i in range(len(strokes) - 2):
s1, s2, s3 = strokes[i:i+3]
high = min(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'],
s3['start_price'], s3['end_price'])
low = max(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'],
s3['start_price'], s3['end_price'])
if high > low:
pivots.append({
'start_time': s1['start_time'],
'end_time': s3['end_time'],
'high': high,
'low': low
})
logging.info(f"Detected {len(pivots)} pivots")
return pivots
except Exception as e:
logging.error(f"Pivot detection failed: {e}")
raise
# 7. Analyze higher timeframe (30m)
def analyze_higher_timeframe(df_30m):
try:
df_30m = detect_fractals(df_30m)
strokes_30m = detect_strokes(df_30m)
if not strokes_30m:
return 'neutral'
last_stroke = strokes_30m[-1]
logging.info(f"30m trend: {last_stroke['type']}")
return last_stroke['type']
except Exception as e:
logging.error(f"Higher timeframe analysis failed: {e}")
raise
# 8. Back-divergence detection (enhanced)
def detect_back_divergence(df, strokes, higher_trend):
try:
macd, signal, hist = MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
sma20 = SMA(df['Close'], timeperiod=20)
df['macd'] = macd
df['hist'] = hist
df['sma20'] = sma20
df['buy_signal'] = False
df['sell_signal'] = False
stroke_metrics = []
for stroke in strokes:
start_idx = df.index.get_loc(stroke['start_time'])
end_idx = df.index.get_loc(stroke['end_time'])
hist_segment = df['hist'].iloc[start_idx:end_idx+1]
price_change = abs(stroke['end_price'] - stroke['start_price'])
hist_area = sum(abs(h) for h in hist_segment if not np.isnan(h))
volume = stroke['volume']
stroke_metrics.append({
'start_time': stroke['start_time'],
'end_time': stroke['end_time'],
'type': stroke['type'],
'price_change': price_change,
'hist_area': hist_area,
'volume': volume
})
for i in range(2, len(stroke_metrics)):
current_stroke = stroke_metrics[i]
prev_stroke = stroke_metrics[i-2]
if current_stroke['type'] != prev_stroke['type']:
continue
current_end_idx = df.index.get_loc(current_stroke['end_time'])
# Uptrend back-divergence (sell signal)
if current_stroke['type'] == 'up':
price_increase = df['High'].loc[current_stroke['end_time']] > df['High'].loc[prev_stroke['end_time']]
hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area']
volume_decrease = current_stroke['volume'] < prev_stroke['volume']
is_top_fractal = df['is_top'].loc[current_stroke['end_time']]
hist_positive = df['hist'].iloc[current_end_idx] > 0 or \
(df['hist'].iloc[current_end_idx] < 0 and df['hist'].iloc[current_end_idx-1] > 0)
sma_trend = df['Close'].iloc[current_end_idx] > df['sma20'].iloc[current_end_idx]
trend_match = higher_trend in ['up', 'neutral']
if price_increase and hist_decrease and volume_decrease and is_top_fractal and \
hist_positive and sma_trend and trend_match:
df.loc[df.index[current_end_idx], 'sell_signal'] = True
# Downtrend back-divergence (buy signal)
elif current_stroke['type'] == 'down':
price_decrease = df['Low'].loc[current_stroke['end_time']] < df['Low'].loc[prev_stroke['end_time']]
hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area']
volume_decrease = current_stroke['volume'] < prev_stroke['volume']
is_bottom_fractal = df['is_bottom'].loc[current_stroke['end_time']]
hist_negative = df['hist'].iloc[current_end_idx] < 0 or \
(df['hist'].iloc[current_end_idx] > 0 and df['hist'].iloc[current_end_idx-1] < 0)
sma_trend = df['Close'].iloc[current_end_idx] < df['sma20'].iloc[current_end_idx]
trend_match = higher_trend in ['down', 'neutral']
if price_decrease and hist_decrease and volume_decrease and is_bottom_fractal and \
hist_negative and sma_trend and trend_match:
df.loc[df.index[current_end_idx], 'buy_signal'] = True
logging.info(f"Detected {df['buy_signal'].sum()} buy signals and {df['sell_signal'].sum()} sell signals")
return df
except Exception as e:
logging.error(f"Back-divergence detection failed: {e}")
raise
# 9. Execute trade
def execute_trade(exchange, symbol, signal, amount=0.001):
try:
if SIMULATION_MODE:
msg = f"[SIMULATION] {'Buy' if signal == 'buy' else 'Sell'} {amount} {symbol} at {datetime.now(dt.UTC)}"
print(msg)
logging.info(msg)
return
if signal == 'buy':
order = exchange.create_market_buy_order(symbol, amount)
msg = f"Buy order executed: {order}"
print(msg)
logging.info(msg)
elif signal == 'sell':
order = exchange.create_market_sell_order(symbol, amount)
msg = f"Sell order executed: {order}"
print(msg)
logging.info(msg)
except Exception as e:
msg = f"Trade execution failed: {e}"
print(msg)
logging.error(msg)
# 10. Plot chart
def plot_chart(df, strokes, segments, pivots):
try:
# Initialize additional plots
apds = []
alines = [] # For line segments
# Plot strokes as line segments
for stroke in strokes:
alines.append([(stroke['start_time'], stroke['start_price']),
(stroke['end_time'], stroke['end_price'])])
# Plot segments as line segments
for segment in segments:
alines.append([(segment['start_time'], segment['start_price']),
(segment['end_time'], segment['end_price'])])
# Plot pivots as horizontal lines
for pivot in pivots:
alines.append([(pivot['start_time'], pivot['high']),
(pivot['end_time'], pivot['high'])])
alines.append([(pivot['start_time'], pivot['low']),
(pivot['end_time'], pivot['low'])])
# Add alines to plot (single color for simplicity, can customize)
if alines:
apds.append(mpf.make_addplot(
None, # No y-data needed for alines
alines=alines,
type='line',
color=['blue' if i < len(strokes) else 'purple' if i < len(strokes) + len(segments) else 'orange'
for i in range(len(alines))],
linestyle=['--' if i < len(strokes) else '-' if i < len(strokes) + len(segments) else ':'
for i in range(len(alines))]
))
# Plot buy/sell signals
buy_signals = df[df['buy_signal']]['Close']
sell_signals = df[df['sell_signal']]['Close']
apds.append(mpf.make_addplot(buy_signals, type='scatter', markersize=100, marker='^', color='green'))
apds.append(mpf.make_addplot(sell_signals, type='scatter', markersize=100, marker='v', color='red'))
# Plot K-line chart
mpf.plot(df, type='candle', addplot=apds, title='Chanlun Advanced Analysis', style='yahoo')
logging.info("Chart plotted successfully")
except Exception as e:
logging.error(f"Chart plotting failed: {e}")
raise
# 11. Main function
def main():
try:
# Initialize exchange
exchange = ccxt.binance({
'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '',
'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '',
'enableRateLimit': True,
'options': {'defaultType': 'spot'}
})
# Fetch data
df_5m = fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500)
df_30m = fetch_binance_data(symbol='BTC/USDT', timeframe='30m', limit=200)
# Merge 5m K-lines
df_5m = merge_kline(df_5m)
# Detect fractals, strokes, segments, pivots
df_5m = detect_fractals(df_5m)
strokes = detect_strokes(df_5m)
segments = detect_segments(strokes)
pivots = detect_pivots(strokes)
# Analyze 30m trend
higher_trend = analyze_higher_timeframe(df_30m)
print(f"30m Trend: {higher_trend}")
# Detect back-divergence
df_5m = detect_back_divergence(df_5m, strokes, higher_trend)
# Plot chart
plot_chart(df_5m, strokes, segments, pivots)
# Output and execute trades
print("Buy Signals:")
buy_signals = df_5m[df_5m['buy_signal']][['Close']]
print(buy_signals)
for idx, row in buy_signals.iterrows():
execute_trade(exchange, 'BTC/USDT', 'buy', amount=0.001)
print("Sell Signals:")
sell_signals = df_5m[df_5m['sell_signal']][['Close']]
print(sell_signals)
for idx, row in sell_signals.iterrows():
execute_trade(exchange, 'BTC/USDT', 'sell', amount=0.001)
logging.info("Main function completed successfully")
except Exception as e:
logging.error(f"Main function failed: {e}")
raise
if __name__ == "__main__":
main()
"""兼容 shim — 请优先 from chanlun import ..."""
from chanlun.analysis.Find_Trend import * # noqa: F403
+2 -2260
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
"""缠论引擎正式包。
推荐::
from chanlun import ChanLun, TF_DF
from chanlun.core.ChanEnum import Chan_BI_DIR
"""
from chanlun.pipeline.orchestrator import ChanLun
from chanlun.pipeline.timeframe import TF_DF
__all__ = ["ChanLun", "TF_DF"]
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
from __future__ import annotations
"""
使用 ccxt 获取币安交易所所有 `*/USDT` 交易对最新 100 1 小时 K 线数据并筛选出长期横盘的币种
横盘判定基于以下三项指标均可通过命令行参数调整
1. 价格振幅占均价的比例默认 5%
2. 收盘价线性回归斜率占均价的比例默认 0.05%
3. 收盘价标准差占均价的比例默认 1.5%
满足以上全部条件的交易对会被视为长期横盘
"""
import argparse
import csv
import logging
import math
import statistics
import sys
import time
from dataclasses import dataclass
from typing import Iterable, List, Optional, Sequence
import ccxt
# python ChanHeng.py --range-threshold 5 --slope-threshold 5 --std-threshold 0.015
DEFAULT_LIMIT = 100
DEFAULT_TIMEFRAME = "1h"
STABLECOINS = {
"USDT",
"USDC",
"BUSD",
"TUSD",
"USDP",
"DAI",
"FDUSD",
"SUSD",
"UST",
"USTC",
"EUR",
"TRY",
"BFUSD",
"USDE",
"XUSD",
"USD1",
"XUSD"
}
@dataclass
class SidewaysMetrics:
symbol: str
price_range_pct: float
slope_pct: float
std_pct: float
mean_close: float
last_close: float
data_points: int
def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="筛选币安长期横盘币种(默认 500 根 1 小时 K 线)"
)
parser.add_argument(
"--timeframe",
default=DEFAULT_TIMEFRAME,
help="K 线周期(默认:1h",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help="每个交易对获取的 K 线数量(默认:500)",
)
parser.add_argument(
"--range-threshold",
type=float,
default=0.05,
help="最大价格振幅占均价比例阈值(默认:0.05,表示 5%%",
)
parser.add_argument(
"--slope-threshold",
type=float,
default=0.0005,
help="线性回归斜率占均价比例阈值(默认:0.0005,约 0.05%%",
)
parser.add_argument(
"--std-threshold",
type=float,
default=0.015,
help="标准差占均价比例阈值(默认:0.015,表示 1.5%%",
)
parser.add_argument(
"--quote",
action="append",
default=[],
help="只保留指定计价货币的交易对,可重复指定(示例:--quote USDT --quote FDUSD",
)
parser.add_argument(
"--symbol",
action="append",
default=[],
help="仅检测指定交易对,可重复(不指定则遍历所有符合条件的现货交易对)",
)
parser.add_argument(
"--max-symbols",
type=int,
default=None,
help="限制最多检测的交易对数量(用于调试)",
)
parser.add_argument(
"--sleep",
type=float,
default=0.35,
help="请求失败后的基础重试等待秒数(默认:0.35)",
)
parser.add_argument(
"--retries",
type=int,
default=3,
help="单个交易对请求失败后的最大重试次数(默认:3)",
)
parser.add_argument(
"--include-inactive",
action="store_true",
help="包含已下架/不可交易的交易对(默认不包含)",
)
parser.add_argument(
"--export",
type=str,
default=None,
help="将筛选结果导出为 CSV 文件的路径",
)
parser.add_argument(
"--verbose",
action="store_true",
help="输出更详细的日志信息",
)
return parser.parse_args(argv)
def setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def create_exchange() -> ccxt.binance:
exchange = ccxt.binance({"enableRateLimit": True})
exchange.options["defaultType"] = "spot"
return exchange
def iter_target_symbols(
exchange: ccxt.binance,
quotes: Sequence[str],
includes: Sequence[str],
include_inactive: bool,
) -> List[str]:
markets = exchange.load_markets()
filtered = []
quote_set = {quote.upper() for quote in quotes}
include_set = {sym.upper() for sym in includes}
for symbol, meta in markets.items():
if not meta.get("spot", False):
continue
if not include_inactive and meta.get("active") is False:
continue
normalized_symbol = symbol.upper()
if include_set and normalized_symbol not in include_set:
continue
parts = symbol.split("/")
if len(parts) != 2:
continue
base_asset, quote_asset = parts[0].upper(), parts[1].upper()
target_quote = quote_set or {"USDT"}
if quote_asset not in target_quote:
continue
if base_asset in STABLECOINS:
continue
filtered.append(symbol)
filtered.sort()
logging.info(
"已筛选 %s 个目标交易对(quote 过滤:%s,专门列表:%s",
len(filtered),
",".join(sorted(quote_set or {"USDT"})),
",".join(sorted(include_set)) or "",
)
return filtered
def fetch_ohlcv_with_retry(
exchange: ccxt.binance,
symbol: str,
timeframe: str,
limit: int,
retries: int,
base_sleep: float,
) -> List[List[float]]:
attempt = 0
while True:
try:
return exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
except ccxt.RateLimitExceeded as exc:
wait_time = max(exchange.rateLimit / 1000.0 if exchange.rateLimit else 0, base_sleep)
logging.debug("触发限频,等待 %.2f 秒后重试 %s%s", wait_time, symbol, exc)
time.sleep(wait_time)
except (ccxt.NetworkError, ccxt.ExchangeError) as exc:
attempt += 1
if attempt > retries:
logging.warning("多次获取失败,跳过 %s%s", symbol, exc)
return []
wait_time = base_sleep * attempt
logging.debug("请求失败,等待 %.2f 秒后重试 %s(第 %d 次):%s", wait_time, symbol, attempt, exc)
time.sleep(wait_time)
def linear_regression_slope(values: Sequence[float]) -> float:
n = len(values)
if n < 2:
return 0.0
mean_x = (n - 1) / 2.0
mean_y = sum(values) / n
numerator = 0.0
denominator = 0.0
for idx, value in enumerate(values):
dx = idx - mean_x
numerator += dx * (value - mean_y)
denominator += dx * dx
if denominator == 0:
return 0.0
return numerator / denominator
def compute_sideways_metrics(closes: Sequence[float], symbol: str) -> Optional[SidewaysMetrics]:
if not closes:
return None
mean_close = sum(closes) / len(closes)
if math.isclose(mean_close, 0.0):
return None
max_close = max(closes)
min_close = min(closes)
price_range_pct = (max_close - min_close) / mean_close
slope = linear_regression_slope(closes)
slope_pct = slope / mean_close
std_dev = statistics.pstdev(closes) if len(closes) > 1 else 0.0
std_pct = std_dev / mean_close
return SidewaysMetrics(
symbol=symbol,
price_range_pct=price_range_pct,
slope_pct=slope_pct,
std_pct=std_pct,
mean_close=mean_close,
last_close=closes[-1],
data_points=len(closes),
)
def is_sideways(metrics: SidewaysMetrics, range_threshold: float, slope_threshold: float, std_threshold: float) -> bool:
return (
metrics.price_range_pct <= range_threshold
and abs(metrics.slope_pct) <= slope_threshold
and metrics.std_pct <= std_threshold
)
def export_results(path: str, results: Sequence[SidewaysMetrics]) -> None:
fieldnames = [
"symbol",
"price_range_pct",
"slope_pct",
"std_pct",
"mean_close",
"last_close",
"data_points",
]
with open(path, "w", newline="", encoding="utf-8") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
for item in results:
writer.writerow(
{
"symbol": item.symbol,
"price_range_pct": f"{item.price_range_pct:.6f}",
"slope_pct": f"{item.slope_pct:.6f}",
"std_pct": f"{item.std_pct:.6f}",
"mean_close": f"{item.mean_close:.8f}",
"last_close": f"{item.last_close:.8f}",
"data_points": item.data_points,
}
)
logging.info("结果已导出至 %s", path)
def run(argv: Optional[Sequence[str]] = None) -> int:
args = parse_args(argv)
if not args.quote:
args.quote = ["USDT"]
setup_logging(args.verbose)
exchange = create_exchange()
symbols = iter_target_symbols(
exchange=exchange,
quotes=args.quote,
includes=args.symbol,
include_inactive=args.include_inactive,
)
if args.max_symbols is not None:
symbols = symbols[: args.max_symbols]
logging.info("出于调试目的,仅检测前 %d 个交易对。", len(symbols))
if not symbols:
logging.error("未找到任何满足条件的交易对,请检查过滤条件。")
return 1
sideways_results: List[SidewaysMetrics] = []
total = len(symbols)
for idx, symbol in enumerate(symbols, start=1):
logging.info("(%d/%d) 正在获取 %s%s K 线(limit=%d", idx, total, symbol, args.timeframe, args.limit)
ohlcv = fetch_ohlcv_with_retry(
exchange=exchange,
symbol=symbol,
timeframe=args.timeframe,
limit=args.limit,
retries=args.retries,
base_sleep=args.sleep,
)
if len(ohlcv) < max(100, args.limit // 2):
logging.debug("交易对 %s 返回数据不足(%d 根),跳过。", symbol, len(ohlcv))
continue
closes = [entry[4] for entry in ohlcv if entry[4] is not None]
metrics = compute_sideways_metrics(closes, symbol)
if not metrics:
continue
if is_sideways(metrics, args.range_threshold, args.slope_threshold, args.std_threshold):
sideways_results.append(metrics)
logging.info(
"识别为横盘:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%",
symbol,
metrics.price_range_pct * 100,
metrics.slope_pct * 100,
metrics.std_pct * 100,
)
else:
logging.debug(
"未满足条件:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%",
symbol,
metrics.price_range_pct * 100,
metrics.slope_pct * 100,
metrics.std_pct * 100,
)
if not sideways_results:
logging.warning("未检测到满足定义的长期横盘交易对。")
return 0
sideways_results.sort(key=lambda item: (item.price_range_pct, abs(item.slope_pct), item.std_pct))
print("=" * 88)
print(
f"共识别 {len(sideways_results)} 个长期横盘交易对(阈值:振幅≤{args.range_threshold:.2%}"
f"斜率≤{args.slope_threshold:.2%},标准差≤{args.std_threshold:.2%}"
)
print("=" * 88)
header = f"{'Symbol':15s} {'Range%':>10s} {'Slope%':>10s} {'STD%':>10s} {'Mean':>14s} {'Last':>14s} {'Count':>6s}"
print(header)
print("-" * len(header))
for item in sideways_results:
print(
f"{item.symbol:15s}"
f" {item.price_range_pct * 100:10.4f}"
f" {item.slope_pct * 100:10.4f}"
f" {item.std_pct * 100:10.4f}"
f" {item.mean_close:14.8f}"
f" {item.last_close:14.8f}"
f" {item.data_points:6d}"
)
if args.export:
export_results(args.export, sideways_results)
logging.info("任务完成。")
return 0
if __name__ == "__main__":
sys.exit(run())
+529
View File
@@ -0,0 +1,529 @@
import sys
import os
#sys.setrecursionlimit(1000000) #例如这里设置为一百万
#sys.path.append(os.path.abspath("/freqtrade/user_data/Chan"))
sys.path.append(os.path.abspath("/Users/jack/Project/freqtrade/user_data/Chan"))
import numpy as np
from datetime import timedelta
from pandas import DataFrame
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_SEG_DIR, Chan_ZS_DIR, Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX
from chanlun.core.ChanKLU import ChanKLU
from chanlun.core.ChanKLC import ChanKLC
from chanlun.core.ChanBI import ChanBI
from chanlun.core.ChanSBI import ChanSBI
from chanlun.core.ChanSEG import ChanSEG
from chanlun.core.ChanZS import ChanZS
from chanlun.core.ChanBSP import ChanBSP
import talib.abstract as ta
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import DateFormatter, date2num
import matplotlib.patches as patches
from technical.util import resample_to_interval
from decimal import Decimal
from chanlun.pipeline.orchestrator import ChanLun
import xgboost as xgb
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report
class ChanLunClassifier:
def __init__(self, dataframe: DataFrame):
self.dataframe = dataframe
self.model = None
chan = ChanLun()
def train_model(self, dataframe=None, data_file_path=None, model_file_path='chan_xgb_model.json', use_cv=False, custom_params=None, model_name=None):
"""
使用dataframe前80%的数据训练XGBoost模型
:param dataframe: 输入的DataFrame如果为None则使用初始化时的dataframe
:param data_file_path: 特征数据保存路径可选
:param model_file_path: 模型保存路径
:param use_cv: 是否使用交叉验证寻找最佳参数
:param custom_params: 自定义模型参数
:return: 训练好的模型
"""
if dataframe is None:
dataframe = self.dataframe
# 分割数据集,前80%用于训练
train_size = int(len(dataframe) * 0.8)
train_df = dataframe.iloc[:train_size].copy()
# 获取训练集特征和标签
save_csv = True if data_file_path else False
X_train, y_train = self.get_feature_data(train_df, save_csv=save_csv, csv_path=data_file_path if data_file_path else 'feature_data.csv')
if len(X_train) == 0:
print("没有提取到足够的特征数据进行训练")
return None
# 保存特征数据的步骤已经移到get_feature_data方法中处理
# 以下是原有代码
#{'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3},
# 默认XGBoost参数
default_params = {
'objective': 'binary:logistic',
'max_depth': 8,
'eta': 0.01,
'subsample': 0.8,
'colsample_bytree': 0.8,
'eval_metric': 'auc',
'gamma': 0.0,
'min_child_weight': 1,
'alpha': 0, # L1正则化
'lambda': 0.5, # L2正则化
'scale_pos_weight': 1
}
# 使用自定义参数覆盖默认参数
if custom_params:
for key, value in custom_params.items():
default_params[key] = value
params = default_params
dtrain = xgb.DMatrix(X_train, label=y_train)
# 如果使用交叉验证寻找最佳参数
if use_cv:
from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from sklearn.metrics import make_scorer, accuracy_score, f1_score
import numpy as np
# 转换为sklearn兼容格式
xgb_model = xgb.XGBClassifier(
objective=params['objective'],
max_depth=params['max_depth'],
learning_rate=params['eta'],
subsample=params['subsample'],
colsample_bytree=params['colsample_bytree'],
gamma=params['gamma'],
min_child_weight=params['min_child_weight'],
reg_alpha=params['alpha'],
reg_lambda=params['lambda'],
scale_pos_weight=params['scale_pos_weight'],
use_label_encoder=False,
eval_metric='auc'
)
# 参数网格
param_grid = {
'max_depth': [3, 5, 7, 9],
'learning_rate': [0.01, 0.05, 0.1, 0.2],
'subsample': [0.6, 0.8, 1.0],
'colsample_bytree': [0.6, 0.8, 1.0],
'min_child_weight': [1, 3, 5],
'gamma': [0, 0.1, 0.2],
'n_estimators': [50, 100, 200]
}
# 使用随机搜索寻找最佳参数(比网格搜索快)
random_search = RandomizedSearchCV(
estimator=xgb_model,
param_distributions=param_grid,
n_iter=10, # 随机尝试的参数组合数
scoring=make_scorer(f1_score),
cv=5,
verbose=1,
n_jobs=-1,
random_state=42
)
print("进行交叉验证参数搜索...")
random_search.fit(X_train, y_train)
# 获取最佳参数
best_params = random_search.best_params_
print(f"最佳参数: {best_params}")
# 使用最佳参数更新模型参数
params['max_depth'] = best_params['max_depth']
params['eta'] = best_params['learning_rate']
params['subsample'] = best_params['subsample']
params['colsample_bytree'] = best_params['colsample_bytree']
params['min_child_weight'] = best_params['min_child_weight']
params['gamma'] = best_params['gamma']
num_round = best_params['n_estimators']
# 使用最佳参数训练最终模型
self.model = xgb.train(params, dtrain, num_round)
else:
# 标准训练(不使用交叉验证)
# 使用早停机制避免过拟合
# 分割训练集为训练和验证
eval_size = int(len(X_train) * 0.2)
X_eval = X_train[-eval_size:]
y_eval = y_train[-eval_size:]
X_train_part = X_train[:-eval_size]
y_train_part = y_train[:-eval_size]
dtrain_part = xgb.DMatrix(X_train_part, label=y_train_part)
deval = xgb.DMatrix(X_eval, label=y_eval)
# 评估列表
evallist = [(dtrain_part, 'train'), (deval, 'eval')]
# 训练模型,使用早停
num_round = 1000 # 设置较大的轮数,让早停机制决定何时停止
self.model = xgb.train(
params,
dtrain_part,
num_round,
evallist,
early_stopping_rounds=50, # 50轮内评估指标无改善则停止
verbose_eval=True
)
# 使用全部训练数据重新训练最终模型,使用最佳轮数
# best_rounds = self.model.best_ntree_limit
# 兼容新版本的XGBoost
if hasattr(self.model, 'best_ntree_limit'):
best_rounds = self.model.best_ntree_limit
elif hasattr(self.model, 'best_iteration'):
best_rounds = self.model.best_iteration
elif hasattr(self.model, 'best_ntree_idx'):
best_rounds = self.model.best_ntree_idx
else:
# 如果都不存在,使用默认值
best_rounds = num_round
print(f"最佳轮数: {best_rounds}")
# 使用全部训练数据和最佳轮数训练最终模型
self.model = xgb.train(params, dtrain, best_rounds)
# 保存模型
if model_file_path:
self.model.save_model(model_name + model_file_path)
# 特征重要性分析
if hasattr(self.model, 'get_score'):
importance = self.model.get_score(importance_type='gain')
print("\n特征重要性 (gain):")
for key, value in sorted(importance.items(), key=lambda x: x[1], reverse=True):
print(f"{key}: {value}")
return self.model
def load_model(self, model_name=None, model_file_path='chan_xgb_model.json'):
if model_name:
self.model = xgb.Booster()
self.model.load_model(model_name + model_file_path)
else:
self.model = xgb.Booster()
self.model.load_model(model_file_path)
def find_best_params(self, dataframe=None, save_csv=False, csv_path_prefix='param_', model_name=None):
"""
寻找最佳参数组合
:param dataframe: 输入的DataFrame如果为None则使用初始化时的dataframe
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path_prefix: CSV文件保存路径前缀会自动添加参数信息
:return: 最佳参数
"""
# 不同参数组合
param_combinations = [
# 低学习率,深树
{'eta': 0.01, 'max_depth': 8, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0, 'min_child_weight': 1},
# 中等学习率,中等树深度
{'eta': 0.05, 'max_depth': 5, 'subsample': 0.7, 'colsample_bytree': 0.7, 'gamma': 0.1, 'min_child_weight': 3},
# 高学习率,浅树
{'eta': 0.1, 'max_depth': 3, 'subsample': 0.6, 'colsample_bytree': 0.6, 'gamma': 0.2, 'min_child_weight': 5},
# 正则化较强 best here
{'eta': 0.03, 'max_depth': 4, 'subsample': 0.8, 'colsample_bytree': 0.8, 'gamma': 0.1, 'min_child_weight': 3, 'alpha': 1, 'lambda': 3},
# 正则化较弱
{'eta': 0.08, 'max_depth': 6, 'subsample': 0.9, 'colsample_bytree': 0.9, 'gamma': 0, 'min_child_weight': 1, 'alpha': 0, 'lambda': 0.5},
]
best_score = 0
best_params = None
best_model = None
for i, params in enumerate(param_combinations):
print(f"\n尝试参数组合: {params}")
# 生成CSV文件名,包含一些参数信息
param_info = f"eta{params['eta']}_depth{params['max_depth']}"
train_csv_path = f"{csv_path_prefix}train_{param_info}.csv" if save_csv else None
model = self.train_model(dataframe=dataframe, data_file_path=train_csv_path, custom_params=params, model_name=model_name)
# 分割数据集,后20%用于测试
if dataframe is None:
dataframe = self.dataframe
train_size = int(len(dataframe) * 0.8)
test_df = dataframe.iloc[train_size:].copy()
# 获取测试集特征和标签
test_csv_path = f"{csv_path_prefix}test_{param_info}.csv" if save_csv else None
X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=test_csv_path)
if len(X_test) == 0:
print("没有提取到足够的测试特征数据")
continue
# 预测
dtest = xgb.DMatrix(X_test)
y_pred_prob = model.predict(dtest)
y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob]
# 计算F1分数
f1 = f1_score(y_test, y_pred, zero_division=0)
print(f"F1分数: {f1:.4f}")
if f1 > best_score:
best_score = f1
best_params = params
best_model = model
print(f"\n最佳参数组合 (F1={best_score:.4f}):")
print(best_params)
self.model = best_model
return best_params
def get_feature_data(self, dataframe, save_csv=False, csv_path='feature_data.csv'):
"""
从dataframe提取特征数据
:param dataframe: 输入的DataFrame
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 特征矩阵X和标签y
"""
# 使用ChanLun获取bi_list
klc_list = self.chan.get_klc_list(dataframe)
bi_list = self.chan.cal_bi_list(klc_list)
# 筛选方向为UP的bi的起始klc
feature_data = []
labels = []
feature_keys = [] # 用于保存特征名称
bi_index = 1
sample_list = []
for klc in klc_list:
if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
sample_list.append(klc)
klc_count = 0
print('Processing data...')
for klc in sample_list:
if bi_index >= len(bi_list):
bi_index = len(bi_list) - 1
#bi = bi_list[bi_index]
#if klc.end_klu and bi.end_klc and klc.start_klu.index >= bi.start_klc.start_klu.index and klc.end_klu.index <= bi.end_klc.end_klu.index:
#klc.set_bi(bi)
# 提取特征
features = klc.get_feature_data()
# 保存第一个样本的特征名称,用于CSV列名
if len(feature_keys) == 0:
feature_keys = list(features.keys())
# 将特征转换为模型可用的格式
feature_vec = []
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
# 判断这个bi是否赚钱(这里简单定义为:如果bi的结束价格高于起始价格,则标记为1,否则为0)
# 这个标签定义可以根据实际需求修改
matched = False
for bi in bi_list:
if bi.end_klc and bi.end_klc.index == klc.index:
#print(bi.start_time, bi.start_klc.start_time, bi.dir)
label = 1
matched = True
break
if not matched:
label = 0
feature_data.append(feature_vec)
labels.append(label)
klc_count += 1
percent = klc_count/len(sample_list)*100
if percent % 10 == 0:
print('Data processed:', percent, '%')
for index, key in enumerate(feature_keys):
print(index, key, feature_data[0][index])
# 如果需要保存到CSV
if save_csv:
# 创建DataFrame保存特征数据
# 只保留数值型特征
numeric_feature_keys = [key for i, key in enumerate(feature_keys)
if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))]
# 创建特征数据的DataFrame
df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys)
# 添加标签列
df_features['label'] = labels
# 添加时间信息便于分析
if len(sample_list) > 0:
times = [klc.start_time for klc in sample_list]
df_features['time'] = times
# 保存到CSV
df_features.to_csv(csv_path, index=False)
print(f"特征数据已保存到 {csv_path}")
# 在return前添加
positive_count = np.sum(labels)
print(f"正样本数量: {positive_count}, 负样本数量: {len(labels) - positive_count}")
print("Trainning data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------")
return np.array(feature_data), np.array(labels)
def get_validate_feature_data(self, dataframe, save_csv=False, csv_path='validate_feature_data.csv'):
"""
从dataframe提取特征数据
:param dataframe: 输入的DataFrame
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 特征矩阵X和标签y
"""
# 使用ChanLun获取bi_list
klc_list = self.chan.get_klc_list(dataframe)
bi_list = self.chan.cal_bi_list(klc_list)
seg_list = self.chan.get_seg_list(bi_list)
# 筛选方向为UP的bi的起始klc
feature_data = []
labels = []
feature_keys = [] # 用于保存特征名称
bi_index = 1
sample_list = []
for klc in klc_list:
if klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
sample_list.append(klc)
for klc in sample_list:
if bi_index >= len(bi_list):
bi_index = len(bi_list) - 1
bi = bi_list[bi_index]
# 提取特征
features = klc.get_feature_data()
# 保存第一个样本的特征名称,用于CSV列名
if len(feature_keys) == 0:
feature_keys = list(features.keys())
# 将特征转换为模型可用的格式
feature_vec = []
# 与get_feature_data保持一致,只使用相同的特征集
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
seg = seg_list[bi_index]
matched = False
for bi in bi_list:
if bi.end_klc and bi.end_klc.index == klc.index:
label = 1
matched = True
break
if not matched:
label = 0
feature_data.append(feature_vec)
labels.append(label)
# 如果需要保存到CSV
if save_csv:
# 创建DataFrame保存特征数据
# 只保留数值型特征
numeric_feature_keys = [key for i, key in enumerate(feature_keys)
if i < len(feature_data[0]) if isinstance(feature_data[0][i], (int, float))]
# 创建特征数据的DataFrame
df_features = pd.DataFrame(feature_data, columns=numeric_feature_keys)
# 添加标签列
df_features['label'] = labels
# 添加时间信息便于分析
if len(sample_list) > 0:
times = [klc.start_time for klc in sample_list]
df_features['time'] = times
# 保存到CSV
df_features.to_csv(csv_path, index=False)
print(f"验证特征数据已保存到 {csv_path}")
print("Validating data: ", len(feature_data), klc_list[-1].start_time, klc_list[-1].klc_fx_type , "---------------------")
return np.array(feature_data), np.array(labels)
def validate_model(self, dataframe=None, save_csv=False, csv_path='validate_feature_data.csv'):
"""
使用dataframe后20%的数据验证模型
:param dataframe: 输入的DataFrame如果为None则使用初始化时的dataframe
:param save_csv: 是否保存特征数据到CSV文件
:param csv_path: CSV文件保存路径
:return: 验证结果
"""
if self.model is None:
print("模型尚未训练,请先调用train_model方法")
return None
if dataframe is None:
dataframe = self.dataframe
# 分割数据集,后20%用于测试
train_size = int(len(dataframe) * 0.8)
test_df = dataframe.iloc[train_size:].copy()
# 获取测试集特征和标签
X_test, y_test = self.get_validate_feature_data(test_df, save_csv=save_csv, csv_path=csv_path)
if len(X_test) == 0:
print("没有提取到足够的测试特征数据")
return None
# 预测
dtest = xgb.DMatrix(X_test)
y_pred_prob = self.model.predict(dtest)
y_pred = [1 if p > 0.5 else 0 for p in y_pred_prob]
# 计算评估指标
accuracy = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred, zero_division=0)
recall = recall_score(y_test, y_pred, zero_division=0)
f1 = f1_score(y_test, y_pred, zero_division=0)
# 打印评估报告
print("模型评估结果:")
print(f"准确率: {accuracy:.4f}")
print(f"精确率: {precision:.4f}")
print(f"召回率: {recall:.4f}")
print(f"F1分数: {f1:.4f}")
print("\n分类报告:")
print(classification_report(y_test, y_pred, zero_division=0))
return {
'accuracy': accuracy,
'precision': precision,
'recall': recall,
'f1': f1,
'y_test': y_test,
'y_pred': y_pred,
'y_pred_prob': y_pred_prob
}
def predict(self, klc):
"""
使用训练好的模型预测单个KLC
:param klc: 需要预测的ChanKLC对象
:return: 预测结果概率值
"""
if self.model is None:
print("模型尚未训练,请先调用train_model方法")
return None
# 提取特征
features = klc.get_feature_data()
feature_vec = []
# 与get_feature_data保持一致,只使用相同的特征集
for key, value in features.items():
if isinstance(value, (int, float)):
feature_vec.append(value)
else:
feature_vec.append(0)
# 转换为模型输入格式
dtest = xgb.DMatrix(np.array([feature_vec]))
# 预测
return self.get_decimal(self.model.predict(dtest)[0])
def get_decimal(self, value):
return Decimal("{:.4f}".format(value))
+402
View File
@@ -0,0 +1,402 @@
import sys
import os
#sys.path.append(os.path.abspath("/Users/jack/Documents/GitHub/chan.py"))
sys.path.append(os.path.abspath("/Users/jack/Project/chan.py"))
from Chan import CChan
from BuySellPoint.BS_Point import CBS_Point
from ChanConfig import CChanConfig
from Common.CEnum import AUTYPE, DATA_SRC, KL_TYPE, DATA_FIELD, BSP_TYPE, FX_TYPE, BI_DIR, KLINE_DIR, SEG_DIR
from KLine.KLine_Unit import CKLine_Unit
from Common.CTime import CTime
from Common.func_util import kltype_lt_day, str2float
from Bi.Bi import CBi
from typing import Dict, List
from functools import reduce
from pandas import DataFrame
from datetime import datetime, timedelta, timezone
def GetColumnNameFromFieldList(fileds: str):
_dict = {
"time": DATA_FIELD.FIELD_TIME,
"open": DATA_FIELD.FIELD_OPEN,
"high": DATA_FIELD.FIELD_HIGH,
"low": DATA_FIELD.FIELD_LOW,
"close": DATA_FIELD.FIELD_CLOSE,
"volume": DATA_FIELD.FIELD_VOLUME
}
return [_dict[x] for x in fileds.split(",")]
class ChanPY():
k_type = KL_TYPE.K_5M
config = CChanConfig({
"bi_strict": True,
"bi_algo": "normal",
"trigger_step": True,
"skip_step": 0,
"divergence_rate": float("inf"),
"bsp2_follow_1": False,
"bsp3_follow_1": False,
"min_zs_cnt": 1,
"bs1_peak": False,
"macd_algo": "peak",
"bs_type": '1,2,3a,1p,2s,3b',
"print_warning": True,
"zs_algo": "normal",
})
chan = CChan(
code="BTC/USDT:USDT",
data_src=DATA_SRC.CCXT,
lv_list=[k_type],
config=config,
autype=AUTYPE.QFQ,
)
klu_list = []
bsps = []
chanIn = True
#def __init__(self, dataframe):
#self.klu_list = self.get_kl_data(dataframe)
#for klu in self.klu_list:
#self.chan.trigger_load({self.k_type: [klu]})
def add_klu(self, klu):
if klu:
self.chan.trigger_load({self.k_type: [klu]})
self.klu_list.append(klu)
def add_klu_from_dataframe(self, dataframe):
if len(dataframe) > len(self.klu_list) and len(dataframe) - len(self.klu_list) == 1:
klu = self.get_last_klu(dataframe)
self.chan.trigger_load({self.k_type: [klu]})
self.klu_list.append(klu)
def parse_time_column(self, inp):
if len(inp) == 10:
year = int(inp[:4])
month = int(inp[5:7])
day = int(inp[8:10])
hour = minute = 0
elif len(inp) == 17:
year = int(inp[:4])
month = int(inp[4:6])
day = int(inp[6:8])
hour = int(inp[8:10])
minute = int(inp[10:12])
elif len(inp) == 19:
year = int(inp[:4])
month = int(inp[5:7])
day = int(inp[8:10])
hour = int(inp[11:13])
minute = int(inp[14:16])
else:
raise Exception(f"unknown time column from TradingView:{inp}")
return CTime(year, month, day, hour, minute, auto=not kltype_lt_day(self.k_type))
def create_item_dict(self, data, column_name):
for i in range(len(data)):
data[i] = self.parse_time_column(data[i]) if i == 0 else str2float(data[i])
return dict(zip(column_name, data))
def get_last_klu(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
item = dataframe.iloc[-1]
date = item['date']
o = item['open']
h = item['high']
l = item['low']
c = item['close']
v = item['volume']
#time_obj = date.fromtimestamp(date)
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
item_data = [
time_str,
o,
h,
l,
c,
v
]
klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True)
klu.set_idx(len(dataframe)-1)
return klu
def get_kl_data(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
klu_list = []
for i in range(0, len(dataframe)):
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)
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
item_data = [
time_str,
o,
h,
l,
c,
v
]
klu = CKLine_Unit(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)), autofix=True)
klu.set_idx(i)
klu_list.append(klu)
return klu_list
def get_bsp_type(self, bsp_type, is_buy):
if is_buy:
if bsp_type == BSP_TYPE.T1:
return 1
if bsp_type == BSP_TYPE.T1P:
return 2
if bsp_type == BSP_TYPE.T2:
return 3
if bsp_type == BSP_TYPE.T2S:
return 4
if bsp_type == BSP_TYPE.T3A:
return 5
if bsp_type == BSP_TYPE.T3B:
return 6
else:
if bsp_type == BSP_TYPE.T1:
return -1
if bsp_type == BSP_TYPE.T1P:
return -2
if bsp_type == BSP_TYPE.T2:
return -3
if bsp_type == BSP_TYPE.T2S:
return -4
if bsp_type == BSP_TYPE.T3A:
return -5
if bsp_type == BSP_TYPE.T3B:
return -6
def get_bsps(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
bsps = []
updown = []
bi_sure = []
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
bsp_list = []
bsp_list_pre_len = 0
last_bsp_value = 0
last_updown = -1
bi_list_pre_len = 0
pre_bi = None
zs_list_pre_len = 0
pre_zs = None
for klu in kl_data: # 获取单根K线
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
kl_datas = self.chan.kl_datas[self.k_type]
bi_list = kl_datas.bi_list
lst = kl_datas.lst
if len(bsp_list) > 0:
last_bsp = bsp_list[-1]
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close)
if bsp_list_pre_len > len(bsp_list):
if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
bsps.append(1)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98)
else:
bsps.append(99)
else:
if bsp_list_pre_len == len(bsp_list):
if klu.idx == last_bsp.klu.idx:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
bsps.append(last_bsp_value)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value)
else:
bsps.append(0)
else:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
bsps.append(last_bsp_value)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value)
else:
bsps.append(0)
bsp_list_pre_len = len(bsp_list)
#Check zs -----------------------------------
zs_list = kl_datas.zs_list
if len(zs_list) > 0:
zs = zs_list[-1]
#if zs_list_pre_len > len(zs_list):
#print("No zs", zs.begin.time)
#if len(zs_list) > zs_list_pre_len:
#print(zs.begin.time, zs.end.time, zs.end.idx, zs.high, zs.low, zs.peak_high, zs.peak_low)
zs_list_pre_len = len(zs_list)
pre_zs = zs
#Check Bi -----------------------------------
if len(bi_list) > 0:
last_bi = bi_list[-1]
if len(bi_list) == 1:
if last_bi.dir == BI_DIR.UP:
updown.append(1)
last_updown = 1
else:
updown.append(-1)
last_updown = -1
else:
if last_updown == 1:
if last_bi.dir == BI_DIR.UP:
updown.append(0)
else:
updown.append(-1)
last_updown = -1
else:
if last_bi.dir == BI_DIR.DOWN:
updown.append(0)
else:
updown.append(1)
last_updown = 1
else:
updown.append(0)
bi_list = kl_datas.bi_list
if len(bi_list) > 0:
last_bi = bi_list[-1]
#if bi_list_pre_len > len(bi_list):
#print("Bi ", klu.time, pre_bi.idx, pre_bi.is_sure, bi_list[-1].idx, bi_list[-1].is_sure)
if last_bi.is_sure:
bi_sure.append(1)
#print(klu.time, last_bi.is_sure)
else:
bi_sure.append(0)
pre_bi = bi_list[-1]
bi_list_pre_len = len(bi_list)
else:
bi_sure.append(0)
#if bsps[-1] != 0 or updown[-1] != 0:
#print(klu.time, bsps[-1], updown[-1], bi_list[-1].is_sure)
self.chanIn = False
else:
klu = self.get_last_klu(dataframe)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
for index in range(0, len(bsps)):
if not (abs(bsps[index]) == 1 or abs(bsps[index]) == 2):
bsps[index] = 0
else:
if bsps[index] == 2:
bsps[index] = 1
else:
if bsps[index] == -2:
bsps[index] = -1
else:
bsps[index] = 0
#print(bsps)
#print(updown)
kl_datas = self.chan.kl_datas[self.k_type]
#for zs in kl_datas.zs_list:
#print(zs.begin.time, zs.end.time)
return bsps, updown, bi_sure
def get_bsp_state1(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
bsps = []
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
self.chan.trigger_load({self.k_type: kl_data})
bsp_list = self.chan.get_bsp()
bsp_index = 0
for klu in kl_data:
if bsp_index >= len(bsp_list):
bsp_index = len(bsp_list) - 1
bsp = bsp_list[bsp_index]
if klu.idx == bsp.klu.idx:
bsp_type = self.get_bsp_type(bsp.type[0], bsp.is_buy)
if abs(bsp_type) == 1 or abs(bsp_type) == 10:
bsps.append(1)
else:
bsps.append(0)
bsp_index = bsp_index + 1
else:
bsps.append(0)
self.chanIn = False
else:
klu = CKLine_Unit(self.create_item_dict(self.get_last_item_data(dataframe), GetColumnNameFromFieldList(fields)), autofix=True)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
return bsps
def get_bsp_state(self, dataframe:DataFrame):
fields = "time,open,high,low,close,volume"
if self.chanIn:
kl_data = self.get_kl_data(dataframe)
bsp_list = []
bsp_list_pre_len = 0
last_bsp_value = 0
last_bsp_index = 0
for klu in kl_data: # 获取单根K线
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
kl_datas = self.chan.kl_datas[self.k_type]
bi_list = kl_datas.bi_list
lst = kl_datas.lst
if len(bsp_list) > 0:
last_bsp = bsp_list[-1]
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, lst[-2].fx, bi_list[-1].dir, bi_list[-1].is_sure,klu.close)
if bsp_list_pre_len > len(bsp_list):
if abs(last_bsp_value) == 1:
self.bsps.append(1)
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, 98)
else:
self.bsps.append(99)
else:
if bsp_list_pre_len == len(bsp_list):
if klu.idx == last_bsp.klu.idx:
if last_bsp.klu.idx - last_bsp_index > 3:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
self.bsps.append(last_bsp_value)
else:
self.bsps.append(0)
last_bsp_index = last_bsp.klu.idx
#if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw")
else:
self.bsps.append(0)
else:
if klu.idx == last_bsp.klu.idx:
if last_bsp.klu.idx - last_bsp_index > 3:
last_bsp_value = self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy)
self.bsps.append(last_bsp_value)
else:
self.bsps.append(0)
last_bsp_index = last_bsp.klu.idx
#if abs(last_bsp_value) == 1 or abs(last_bsp_value) == 2:
#print(klu.time, klu.idx, last_bsp.klu.time, last_bsp.klu.idx, last_bsp_value, "Knonw")
else:
self.bsps.append(0)
else:
self.bsps.append(0)
bsp_list_pre_len = len(bsp_list)
self.chanIn = False
else:
klu = self.get_last_klu(dataframe)
if self.last_kline.time < klu.time:
self.chan.trigger_load({self.k_type: [klu]}) # 喂给CChan新增k线
self.last_kline = klu
bsp_list = self.chan.get_bsp()
last_bsp = bsp_list[-1]
if last_bsp.klu.idx == klu.idx:
self.bsps.append(self.get_bsp_type(last_bsp.type[0], last_bsp.is_buy))
else:
self.bsps.append(0)
for index in range(0, len(self.bsps)):
if not (abs(self.bsps[index]) == 1 or abs(self.bsps[index]) == 2):
self.bsps[index] = 0
else:
if self.bsps[index] == 2:
self.bsps[index] = 10
else:
if self.bsps[index] == -2:
self.bsps[index] = -10
else:
if self.bsps[index] == 1:
self.bsps[index] = 1
else:
if self.bsps[index] == -1:
self.bsps[index] = -1
else:
self.bsps[index] = 0
return self.bsps
+292
View File
@@ -0,0 +1,292 @@
"""
中枢结构特征提取 + 标签化
Market Structure Dataset Builder Phase 1
定位: 训练数据集构建工具不是交易信号生成器
Feature 描述中枢内部结构Label 记录中枢后实际演化
"""
import math
import json
from typing import Optional
from chanlun.core.ChanEnum import Chan_BI_DIR
class ChanPivotClassifier:
"""
中枢结构特征提取 + 标签化
输入: bi_zs_list (list[ChanBIZS])
输出: 结构化数据集 (list[dict])
"""
DATASET_VERSION = "pivot_v1"
FEATURE_SCHEMA = ["duration_norm", "contraction", "shift_norm"]
LABEL_SCHEMA = {"name": "break_direction", "values": ["up", "down", "none"]}
def __init__(self, bi_zs_list: list, symbol: str = "", timeframe: str = ""):
self.bi_zs_list = bi_zs_list
self.symbol = symbol
self.timeframe = timeframe
# ------------------------------------------------------------------
# Feature extraction
# ------------------------------------------------------------------
@staticmethod
def calc_duration(zs) -> int:
"""持续时间: 第一笔首K → 最后一笔末K 的 index 差"""
bi_list = zs.bi_list
start_idx = bi_list[0].start_klc.index
end_idx = bi_list[-1].end_klc.index
return end_idx - start_idx
@staticmethod
def calc_contraction(zs) -> float:
"""收敛率: 后窗口振幅均值 / 前窗口振幅均值"""
bi_list = zs.bi_list
if len(bi_list) < 4:
return 1.0
n = min(3, len(bi_list) // 2)
first_ranges = [bi.high - bi.low for bi in bi_list[:n]]
last_ranges = [bi.high - bi.low for bi in bi_list[-n:]]
first_mean = sum(first_ranges) / len(first_ranges)
last_mean = sum(last_ranges) / len(last_ranges)
if first_mean == 0:
return 1.0
return last_mean / first_mean
@staticmethod
def calc_shift(zs) -> tuple[float, float]:
"""重心漂移: 前后半段重心均值差 (原始值, 归一化值)"""
bi_list = zs.bi_list
mid = len(bi_list) // 2
first_centers = [(bi.high + bi.low) / 2 for bi in bi_list[:mid]]
last_centers = [(bi.high + bi.low) / 2 for bi in bi_list[mid:]]
shift_raw = (
sum(last_centers) / len(last_centers)
- sum(first_centers) / len(first_centers)
)
zs_height = zs.zg - zs.zd
if zs_height == 0:
shift_norm = 0.0
else:
shift_norm = shift_raw / zs_height
return shift_raw, shift_norm
@staticmethod
def compute_duration_norm(duration_raw: int, historical_durations: list) -> float:
"""用历史窗口均值归一化 duration"""
if not historical_durations:
return 1.0
avg = sum(historical_durations) / len(historical_durations)
if avg == 0:
return 1.0
return duration_raw / avg
@staticmethod
def compute_features(zs, historical_durations: Optional[list] = None):
"""计算单个中枢的全部结构特征(实时友好)"""
duration_raw = ChanPivotClassifier.calc_duration(zs)
contraction = ChanPivotClassifier.calc_contraction(zs)
shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs)
if historical_durations is not None and len(historical_durations) > 0:
duration_norm = ChanPivotClassifier.compute_duration_norm(
duration_raw, historical_durations
)
else:
duration_norm = 1.0
return {
"duration_raw": duration_raw,
"duration_norm": round(duration_norm, 4),
"contraction": round(contraction, 4),
"shift_raw": round(shift_raw, 6),
"shift_norm": round(shift_norm, 4),
"zs_height": round(zs.zg - zs.zd, 6),
}
# ------------------------------------------------------------------
# Label computation
# ------------------------------------------------------------------
@staticmethod
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, x))
def _compute_label(self, zs, contraction: float, shift_norm: float) -> dict:
"""计算标签: up / down / none + 连续置信度"""
bi_out = zs.bi_out
if bi_out is None:
return {
"label": "none",
"label_confidence": 0.0,
"label_detail": {
"bi_out_dir": "none",
"score_breakout": 0.0,
"score_shift": 0.0,
"score_contraction": 0.0,
},
}
zs_height = zs.zg - zs.zd
if zs_height == 0:
zs_height = 1e-8
# ---- 向上突破分数 ----
if bi_out.dir == Chan_BI_DIR.UP:
raw_breakout = (bi_out.high - zs.gg) / zs_height
score_breakout_up = self._clamp(raw_breakout)
score_shift_up = math.tanh(self._clamp(shift_norm, -3.0, 3.0))
score_contraction_up = max(0.0, 1.0 - contraction)
else:
score_breakout_up = 0.0
score_shift_up = 0.0
score_contraction_up = 0.0
up_score = (
score_breakout_up * 0.5
+ score_shift_up * 0.3
+ score_contraction_up * 0.2
)
# ---- 向下突破分数 ----
if bi_out.dir == Chan_BI_DIR.DOWN:
raw_breakout = (zs.dd - bi_out.low) / zs_height
score_breakout_down = self._clamp(raw_breakout)
score_shift_down = math.tanh(self._clamp(-shift_norm, -3.0, 3.0))
score_contraction_down = max(0.0, 1.0 - contraction)
else:
score_breakout_down = 0.0
score_shift_down = 0.0
score_contraction_down = 0.0
down_score = (
score_breakout_down * 0.5
+ score_shift_down * 0.3
+ score_contraction_down * 0.2
)
# ---- 判定 ----
threshold = 0.15
if up_score > down_score and up_score > threshold:
label = "up"
confidence = up_score
detail = {
"bi_out_dir": "up",
"score_breakout": round(score_breakout_up, 4),
"score_shift": round(score_shift_up, 4),
"score_contraction": round(score_contraction_up, 4),
}
elif down_score > up_score and down_score > threshold:
label = "down"
confidence = down_score
detail = {
"bi_out_dir": "down",
"score_breakout": round(score_breakout_down, 4),
"score_shift": round(score_shift_down, 4),
"score_contraction": round(score_contraction_down, 4),
}
else:
label = "none"
confidence = max(up_score, down_score)
bi_dir = "up" if bi_out.dir == Chan_BI_DIR.UP else "down"
detail = {
"bi_out_dir": bi_dir,
"score_breakout": round(max(score_breakout_up, score_breakout_down), 4),
"score_shift": round(max(score_shift_up, score_shift_down), 4),
"score_contraction": round(max(score_contraction_up, score_contraction_down), 4),
}
return {
"label": label,
"label_confidence": round(confidence, 4),
"label_detail": detail,
}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def extract(self) -> list[dict]:
"""主入口:对每个中枢提取 3 特征 + 1 标签"""
# 第一遍:计算原始值
raw = []
for i, zs in enumerate(self.bi_zs_list):
if not zs.is_sure or len(zs.bi_list) < 3:
continue
duration_raw = ChanPivotClassifier.calc_duration(zs)
contraction = ChanPivotClassifier.calc_contraction(zs)
shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs)
raw.append({
"zs": zs,
"zs_index": i,
"duration_raw": duration_raw,
"contraction": contraction,
"shift_raw": shift_raw,
"shift_norm": shift_norm,
"zs_height": zs.zg - zs.zd,
})
# 第二遍:组装输出 + 计算 label
result = []
for r in raw:
zs = r["zs"]
historical = [x["duration_raw"] for x in raw]
duration_norm = ChanPivotClassifier.compute_duration_norm(
r["duration_raw"], historical
)
label_info = self._compute_label(zs, r["contraction"], r["shift_norm"])
# 时间处理
start_time = None
end_time = None
if hasattr(zs, "start_time") and zs.start_time is not None:
start_time = str(zs.start_time)
if hasattr(zs, "end_time") and zs.end_time is not None:
end_time = str(zs.end_time)
result.append({
"dataset_version": self.DATASET_VERSION,
"feature_schema": self.FEATURE_SCHEMA,
"label_schema": self.LABEL_SCHEMA,
"symbol": self.symbol,
"timeframe": self.timeframe,
"zs_index": r["zs_index"],
"zs_start_time": start_time,
"zs_end_time": end_time,
"duration_norm": round(duration_norm, 4),
"contraction": round(r["contraction"], 4),
"shift_norm": round(r["shift_norm"], 4),
"label": label_info["label"],
"label_confidence": label_info["label_confidence"],
"label_detail": label_info["label_detail"],
"duration_raw": r["duration_raw"],
"shift_raw": round(r["shift_raw"], 6),
"zs_height": round(r["zs_height"], 6),
})
return result
def export_json(self, path: str):
"""导出为 JSON 文件"""
data = self.extract()
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
return len(data)
+145
View File
@@ -0,0 +1,145 @@
"""
实时中枢特征跟踪器
Real-time Pivot Feature Tracker
定位: 观察者 不修改管线只观察 bi_zs_list 中当前中枢的特征变化
每次管线重算后调用 update()检测 bi_count 是否增长若增长则重新计算
shift / contraction / duration
"""
from collections import deque
from typing import Optional
from chanlun.analysis.ChanPivotClassifier import ChanPivotClassifier
class ChanPivotMonitor:
"""
实时追踪当前中枢的结构特征
update() 每次管线重算后调用对比 bi_count 判断是否有新笔加入中枢
bi_count 增长则重新计算 3 个结构特征并返回最新值
"""
def __init__(self, window_size: int = 10):
self._window_size = window_size
self._duration_history: deque[int] = deque(maxlen=window_size)
self._current_zs_id: Optional[tuple] = None
self._current_bi_count: int = 0
self._current_is_sure: bool = False
self._current_state: Optional[dict] = None
self._duration_added_for_zs: set = set() # 已加入窗口的中枢 ID(上限 200)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def update(self, bi_zs_list: list) -> Optional[dict]:
"""
主入口检测当前中枢特征变化
参数:
bi_zs_list: 当前管线产出的笔中枢列表
返回:
特征 dict有变化时无变化返回 None
"""
if not bi_zs_list:
self._current_zs_id = None
self._current_bi_count = 0
self._current_is_sure = False
self._current_state = None
return None
zs = self._find_current_zs(bi_zs_list)
if zs is None:
return None
zs_id = self._make_zs_id(zs)
bi_count = len(zs.bi_list)
is_sure = zs.is_sure
# 无变化 → 跳过
if (zs_id == self._current_zs_id
and bi_count == self._current_bi_count
and is_sure == self._current_is_sure):
return None
# 中枢切换 → 将旧中枢 duration 加入窗口
if zs_id != self._current_zs_id:
self._maybe_add_to_history()
self._current_zs_id = zs_id
self._current_bi_count = bi_count
self._current_is_sure = is_sure
features = ChanPivotClassifier.compute_features(
zs, list(self._duration_history)
)
self._current_state = {
"zs_id": zs_id,
"zs_index": zs.index,
"zs_dir": str(zs.dir),
"bi_count": bi_count,
"is_sure": zs.is_sure,
"zg": round(zs.zg, 6),
"zd": round(zs.zd, 6),
"gg": round(zs.gg, 6),
"dd": round(zs.dd, 6),
**features,
"start_time": str(t) if (t := getattr(zs, "start_time", None)) else None,
}
# 中枢刚变为已确认时,将其 duration 加入滚动窗口
if is_sure and zs_id not in self._duration_added_for_zs:
self._add_duration(features["duration_raw"])
self._duration_added_for_zs.add(zs_id)
return self._current_state
def get_current(self) -> Optional[dict]:
"""返回当前中枢的最新特征"""
return self._current_state
def get_duration_history(self) -> list[int]:
"""返回用于归一化的 duration 滚动窗口"""
return list(self._duration_history)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
@staticmethod
def _make_zs_id(zs) -> tuple:
"""生成中枢的稳定标识(基于首笔首K线时间戳,不随 DataFrame 窗口偏移而变化)"""
bi0 = zs.bi_list[0]
return (bi0.start_klc.start_time,)
@staticmethod
def _find_current_zs(bi_zs_list: list):
"""
找到当前活跃中枢
优先取最后一个 is_sure=False形成中的中枢
没有则取最后一个 is_sure=True 的中枢
"""
forming = None
last_sure = None
for zs in bi_zs_list:
if len(zs.bi_list) < 3:
continue
if not zs.is_sure:
forming = zs
else:
last_sure = zs
return forming if forming is not None else last_sure
def _add_duration(self, duration_raw: int):
"""将已确认中枢的 duration 加入滚动窗口"""
self._duration_history.append(duration_raw)
def _maybe_add_to_history(self):
"""旧中枢切换前,若已确认且未记录过,则将其 duration 加入窗口"""
if (self._current_state and self._current_state["is_sure"]
and self._current_zs_id not in self._duration_added_for_zs):
self._add_duration(self._current_state["duration_raw"])
self._duration_added_for_zs.add(self._current_zs_id)
+566
View File
@@ -0,0 +1,566 @@
"""
结构价值区 (Structure Zone) 系统
将多时间周期的 Chan 中枢边界 (ZD/ZG/GG/DD) EMA52 统一表示为带强度评分的价值区对象
"""
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from datetime import datetime
# ============================================================
# Dataclasses
# ============================================================
@dataclass
class RawZonePoint:
"""内部中间结构:从 Chan 中枢提取的单个价格点"""
price: float
timeframe: str # '5m', '1h', '4h' 等
structure_type: str # 'bi_zhongshu' | 'xd_zhongshu' | 'ema52'
boundary_type: str # 'ZD' | 'ZG' | 'GG' | 'DD' | 'EMA52'
source_zs_id: int # 来源 ZS 在列表中的 index(调试用)
is_sure: bool # 来源 ZS 是否已完成
candle_time: Optional[str] = None # 来源 ZS 的 end_time(用于 recency 计算)
@dataclass
class StructureZone:
"""统一的价值区对象"""
id: int
lower: float
upper: float
center: float # (lower + upper) / 2
width_pct: float # (upper - lower) / center * 100
zone_type: str # 'support' | 'resistance' | 'neutral'
timeframes: List[str] # 参与形成此区间的时间周期
structure_types: List[str] # 参与形成的结构类型
boundary_types: List[str] # 参与形成的边界类型
overlap_count: int # 聚类中的原始点数
touch_count: int # MVP: 等于 overlap_count
recency_score: float # 0.0 - 1.0, 1.0 = 最近
ema52_distance_pct: float # 到最近 EMA52 的距离百分比
ema52_aligned: bool # 是否有 EMA52 落在区间内
strength_score: float # 0-100 综合评分
confidence: float # 0.0 - 1.0
first_seen: Optional[str] # 最早的 candle_time
last_seen: Optional[str] # 最晚的 candle_time
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class StructureZoneConfig:
"""StructureZone 提取与评分配置"""
cluster_radius_pct: float = 0.5 # 价格聚类半径(百分比)
min_overlap_for_zone: int = 2 # 最少重叠点数才能形成区间
max_zones: int = 20 # 返回的最大区间数
recency_halflife_bars: int = 50 # recency 衰减半衰期(K线数)
zone_timeframes: List[str] = field(default_factory=lambda: ['4h', '1h', '30m', '15m', '5m'])
kl_lines_per_tf: int = 500 # 每个时间周期使用最近多少根K线
structure_weights: Dict[str, float] = field(default_factory=lambda: {
'bi_zhongshu': 1.0, # 笔中枢 — 最直接的价格行为
'xd_zhongshu': 0.8, # 线段中枢 — 较高级别但粒度较粗
'ema52': 0.4, # EMA — 趋势参考,弱于结构
})
# ============================================================
# Extraction
# ============================================================
def extract_raw_points_from_tf_df(
tf_df_dict: Dict[str, Any],
ema_symbols: List[str],
config: StructureZoneConfig,
) -> List[RawZonePoint]:
"""
ChanLun.tf_df_dict 中提取所有原始价格点
仅处理 config.zone_timeframes 中存在的时间周期
"""
points: List[RawZonePoint] = []
for tf_name in config.zone_timeframes:
if tf_name not in tf_df_dict:
continue
tf_df = tf_df_dict[tf_name]
# 1. 笔中枢 (ChanBIZS)
try:
if hasattr(tf_df, 'seg_list') and tf_df.seg_list:
bi_zs_result = tf_df.cal_bi_zs(tf_df.seg_list)
if bi_zs_result:
_extract_from_zs_objects(
points, tf_name, 'bi_zhongshu', bi_zs_result, config.kl_lines_per_tf
)
except Exception:
pass
# 2. 线段中枢 (ChanZS)
try:
zs_list = getattr(tf_df, 'zs_list', None)
if zs_list:
_extract_from_zs_objects(
points, tf_name, 'xd_zhongshu', zs_list, config.kl_lines_per_tf
)
except Exception:
pass
# 3. EMA52 值
for tf_name in config.zone_timeframes:
if tf_name in tf_df_dict:
try:
ema_val = tf_df_dict[tf_name].get_ema52()
if ema_val is not None and ema_val > 0:
points.append(RawZonePoint(
price=float(ema_val),
timeframe=tf_name,
structure_type='ema52',
boundary_type='EMA52',
source_zs_id=-1,
is_sure=True,
candle_time=None,
))
except Exception:
pass
return points
def _extract_from_zs_objects(
points: List[RawZonePoint],
tf_name: str,
structure_type: str,
zs_list,
kl_limit: int,
):
"""从 ZS 链表中提取 ZD/ZG/GG/DD 点"""
count = 0
node = zs_list
while hasattr(node, 'next'):
node = node.next
# 从链表头开始遍历
head = zs_list
# 收集所有节点
all_nodes = []
cur = head
while cur is not None and hasattr(cur, 'next'):
all_nodes.append(cur)
cur = cur.next
# 只取最近 kl_limit 根K线内的 ZS
all_nodes = all_nodes[-kl_limit:] if len(all_nodes) > kl_limit else all_nodes
for idx, zs in enumerate(all_nodes):
if not getattr(zs, 'is_sure', False):
continue
try:
zg = float(zs.zg)
zd = float(zs.zd)
gg = float(zs.gg) if getattr(zs, 'gg', 0) else zg
dd = float(zs.dd) if getattr(zs, 'dd', 0) else zd
end_time = str(zs.end_time) if hasattr(zs, 'end_time') and zs.end_time else None
except (ValueError, TypeError, AttributeError):
continue
if zg <= 0 or zd <= 0:
continue
zs_id = getattr(zs, 'index', idx)
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type=structure_type,
boundary_type='ZG', source_zs_id=zs_id, is_sure=True,
candle_time=end_time))
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type=structure_type,
boundary_type='ZD', source_zs_id=zs_id, is_sure=True,
candle_time=end_time))
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type=structure_type,
boundary_type='GG', source_zs_id=zs_id, is_sure=True,
candle_time=end_time))
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type=structure_type,
boundary_type='DD', source_zs_id=zs_id, is_sure=True,
candle_time=end_time))
def extract_raw_points_from_serialized(
analyses: Dict[str, Dict],
ema52_dict: Dict[str, Optional[float]],
config: StructureZoneConfig,
) -> List[RawZonePoint]:
"""
从已序列化的分析结果中提取价格点用于 web API避免重复计算
analyses: {'5m': {'zs_list': [...], 'bi_zs_list': [...]}, '15m': {...}, ...}
ema52_dict: {'5m': 123.45, '15m': None, ...}
"""
points: List[RawZonePoint] = []
for tf_name in config.zone_timeframes:
if tf_name not in analyses:
continue
analysis = analyses[tf_name]
# 笔中枢
bi_zs_items = analysis.get('bi_zs_list', [])
for idx, zs in enumerate(bi_zs_items):
if not zs.get('is_sure', False):
continue
try:
zg = float(zs['zg']); zd = float(zs['zd'])
gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd))
end_time = zs.get('end_time')
except (ValueError, KeyError):
continue
if zg <= 0 or zd <= 0:
continue
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='bi_zhongshu',
boundary_type='ZG', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='bi_zhongshu',
boundary_type='ZD', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='bi_zhongshu',
boundary_type='GG', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='bi_zhongshu',
boundary_type='DD', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
# 线段中枢
zs_items = analysis.get('zs_list', [])
for idx, zs in enumerate(zs_items):
if not zs.get('is_sure', False):
continue
try:
zg = float(zs['zg']); zd = float(zs['zd'])
gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd))
end_time = zs.get('end_time')
except (ValueError, KeyError):
continue
if zg <= 0 or zd <= 0:
continue
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='xd_zhongshu',
boundary_type='ZG', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='xd_zhongshu',
boundary_type='ZD', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='xd_zhongshu',
boundary_type='GG', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='xd_zhongshu',
boundary_type='DD', source_zs_id=idx, is_sure=True,
candle_time=str(end_time) if end_time else None))
# EMA52
for tf_name in config.zone_timeframes:
ema_val = ema52_dict.get(tf_name)
if ema_val is not None and ema_val > 0:
points.append(RawZonePoint(
price=float(ema_val),
timeframe=tf_name,
structure_type='ema52',
boundary_type='EMA52',
source_zs_id=-1,
is_sure=True,
candle_time=None,
))
return points
# ============================================================
# Clustering
# ============================================================
def cluster_raw_points(
points: List[RawZonePoint],
config: StructureZoneConfig,
) -> List[List[RawZonePoint]]:
"""
贪心单通聚类将价格相近的 RawZonePoint 归为一组
仅在 1D 价格轴上操作O(n log n)
"""
if not points:
return []
sorted_points = sorted(points, key=lambda p: p.price)
clusters: List[List[RawZonePoint]] = []
for p in sorted_points:
placed = False
for cluster in reversed(clusters):
# 检查是否可以放入当前聚类(与聚类均价比较)
avg_price = sum(pt.price for pt in cluster) / len(cluster)
if abs(p.price - avg_price) / avg_price * 100 <= config.cluster_radius_pct:
cluster.append(p)
placed = True
break
if not placed:
clusters.append([p])
# 过滤点数不足的聚类
return [c for c in clusters if len(c) >= config.min_overlap_for_zone]
# ============================================================
# Scoring & Building
# ============================================================
def build_structure_zones(
clusters: List[List[RawZonePoint]],
current_price: float,
ema52_values: Dict[str, Optional[float]],
latest_candle_time: Optional[str],
config: StructureZoneConfig,
) -> List[StructureZone]:
"""
从聚类构建 StructureZone 列表计算所有字段和评分
"""
zones: List[StructureZone] = []
# 收集所有 EMA52 值
ema_prices = [v for v in ema52_values.values() if v is not None and v > 0]
for zone_id, cluster in enumerate(clusters):
prices = [p.price for p in cluster]
lower = min(prices)
upper = max(prices)
center = (lower + upper) / 2
width_pct = (upper - lower) / center * 100 if center > 0 else 0.0
# 区间类型
if upper < current_price:
zone_type = 'support' # 区间在当前价格下方 → 支撑
elif lower > current_price:
zone_type = 'resistance' # 区间在当前价格上方 → 阻力
else:
zone_type = 'neutral' # 区间跨越当前价格
timeframes = sorted(set(p.timeframe for p in cluster))
structure_types = sorted(set(p.structure_type for p in cluster))
boundary_types = sorted(set(p.boundary_type for p in cluster))
overlap_count = len(cluster)
# Recency
times = [p.candle_time for p in cluster if p.candle_time]
first_seen = min(times) if times else None
last_seen = max(times) if times else None
recency_score = _calc_recency(last_seen, latest_candle_time, config.recency_halflife_bars)
# EMA52 alignment
ema52_distance_pct = 999.0
ema52_aligned = False
if ema_prices:
distances = [abs(center - ep) / ep * 100 for ep in ema_prices]
ema52_distance_pct = round(min(distances), 2)
ema52_aligned = any(lower <= ep <= upper for ep in ema_prices)
# Strength score
strength_score = _calc_strength(cluster, config, recency_score, ema52_aligned, ema52_distance_pct, width_pct)
# Confidence
confidence = _calc_confidence(overlap_count, len(timeframes), cluster)
zones.append(StructureZone(
id=zone_id + 1,
lower=round(lower, 2),
upper=round(upper, 2),
center=round(center, 2),
width_pct=round(width_pct, 2),
zone_type=zone_type,
timeframes=timeframes,
structure_types=structure_types,
boundary_types=boundary_types,
overlap_count=overlap_count,
touch_count=overlap_count, # MVP: 等于 overlap_count
recency_score=round(recency_score, 3),
ema52_distance_pct=ema52_distance_pct,
ema52_aligned=ema52_aligned,
strength_score=round(strength_score, 1),
confidence=round(confidence, 2),
first_seen=first_seen,
last_seen=last_seen,
))
# 按强度降序排列
zones.sort(key=lambda z: z.strength_score, reverse=True)
# 截断
if config.max_zones > 0 and len(zones) > config.max_zones:
zones = zones[:config.max_zones]
return zones
def _calc_recency(
last_seen: Optional[str],
latest_time: Optional[str],
halflife_bars: int,
) -> float:
"""计算 recency 分数:越近越高"""
if not last_seen or not latest_time:
return 0.5
try:
# 尝试解析 ISO 格式时间
from dateutil import parser
t_last = parser.parse(last_seen)
t_latest = parser.parse(latest_time)
offset_seconds = (t_latest - t_last).total_seconds()
if offset_seconds < 0:
return 1.0
# 假设每根K线平均 5 分钟
bar_seconds = 300
offset_bars = offset_seconds / bar_seconds
# 指数衰减: 2 ^ (-offset / halflife)
score = 2.0 ** (-offset_bars / halflife_bars)
return float(score)
except Exception:
return 0.5
def _calc_strength(
cluster: List[RawZonePoint],
config: StructureZoneConfig,
recency_score: float,
ema52_aligned: bool,
ema52_distance_pct: float,
width_pct: float,
) -> float:
"""计算综合强度评分 (0-100)"""
# 组件 1: 结构类型多样性 (0-40)
structure_type_counts: Dict[str, int] = {}
for p in cluster:
structure_type_counts[p.structure_type] = structure_type_counts.get(p.structure_type, 0) + 1
total = sum(structure_type_counts.values())
structure_score = 0.0
for st, count in structure_type_counts.items():
weight = config.structure_weights.get(st, 0.5)
structure_score += weight * count
structure_score = min(structure_score / max(1, total), 1.0)
c1 = structure_score * 40
# 组件 2: 多周期确认 (0-25)
tf_set = set(p.timeframe for p in cluster)
tf_diversity = len(tf_set)
c2 = min(tf_diversity / 5, 1.0) * 25
# 组件 3: 区间紧密度 (0-15) — 越窄越强
tightness = max(0.0, 1.0 - (width_pct / 3.0))
c3 = tightness * 15
# 组件 4: Recency (0-10)
c4 = recency_score * 10
# 组件 5: EMA52 共振 (0-10)
if ema52_aligned:
ema_proximity = max(0.0, 1.0 - (ema52_distance_pct / 2.0))
c5 = ema_proximity * 10
else:
c5 = 0.0
return c1 + c2 + c3 + c4 + c5
def _calc_confidence(
overlap_count: int,
tf_count: int,
cluster: List[RawZonePoint],
) -> float:
"""计算置信度 (0-1)"""
base = min(overlap_count / 6.0, 0.85)
# 多周期加分
tf_bonus = min(tf_count / 5.0, 0.1)
# 是否所有点都来自 sure 的 ZS
all_sure = all(p.is_sure for p in cluster)
sure_bonus = 0.05 if all_sure else 0.0
return min(base + tf_bonus + sure_bonus, 1.0)
# ============================================================
# Top-level pipeline
# ============================================================
def analyze_structure_zones(
tf_df_dict: Dict[str, Any],
ema_symbols: List[str],
current_price: Optional[float] = None,
config: Optional[StructureZoneConfig] = None,
) -> List[StructureZone]:
"""
一站式分析提取 聚类 评分 返回排序后的 StructureZone 列表
"""
if config is None:
config = StructureZoneConfig()
# 提取
raw_points = extract_raw_points_from_tf_df(tf_df_dict, ema_symbols, config)
if not raw_points:
return []
# 获取当前价格
if current_price is None:
for tf_name in config.zone_timeframes:
if tf_name in tf_df_dict:
try:
ema_val = tf_df_dict[tf_name].get_ema52()
if ema_val and ema_val > 0:
current_price = float(ema_val)
break
except Exception:
pass
if current_price is None:
current_price = 0.0
# EMA52 值
ema52_values = {}
for tf_name in config.zone_timeframes:
if tf_name in tf_df_dict:
try:
ema52_values[tf_name] = tf_df_dict[tf_name].get_ema52()
except Exception:
ema52_values[tf_name] = None
# 最晚时间
latest_time = None
times = [p.candle_time for p in raw_points if p.candle_time]
if times:
latest_time = max(times)
# 聚类
clusters = cluster_raw_points(raw_points, config)
# 构建 & 评分
return build_structure_zones(clusters, current_price, ema52_values, latest_time, config)
def analyze_structure_zones_from_serialized(
analyses: Dict[str, Dict],
ema52_dict: Dict[str, Optional[float]],
current_price: float,
config: Optional[StructureZoneConfig] = None,
) -> List[StructureZone]:
"""
从已序列化的分析结果构建 StructureZone用于 web API
"""
if config is None:
config = StructureZoneConfig()
raw_points = extract_raw_points_from_serialized(analyses, ema52_dict, config)
if not raw_points:
return []
# 最晚时间
latest_time = None
times = [p.candle_time for p in raw_points if p.candle_time]
if times:
latest_time = max(times)
# EMA52 值(用于 alignment 检测)
ema_values = {tf: v for tf, v in ema52_dict.items() if v is not None and v > 0}
clusters = cluster_raw_points(raw_points, config)
return build_structure_zones(clusters, current_price, ema_values, latest_time, config)
+448
View File
@@ -0,0 +1,448 @@
import ccxt
import pandas as pd
import numpy as np
import mplfinance as mpf
from talib import MACD, SMA
from datetime import datetime, timedelta
import logging
import datetime as dt
# Configure logging
logging.basicConfig(
filename='chanlun_trading.log',
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# Configuration (user to modify)
BINANCE_API_KEY = 'your_api_key' # Replace with your Binance API key
BINANCE_API_SECRET = 'your_api_secret' # Replace with your Binance API secret
SIMULATION_MODE = True # Set to False for live trading
# 1. Fetch K-line data from Binance (multi-timeframe support)
def fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500):
try:
exchange = ccxt.binance({
'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '',
'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '',
'enableRateLimit': True,
'options': {'defaultType': 'spot'}
})
since = exchange.parse8601((datetime.now(dt.UTC) - timedelta(days=7)).isoformat())
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, since, limit)
df = pd.DataFrame(ohlcv, columns=['Date', 'Open', 'High', 'Low', 'Close', 'Volume'])
df['Date'] = pd.to_datetime(df['Date'], unit='ms')
df.set_index('Date', inplace=True)
logging.info(f"Fetched {len(df)} K-lines for {symbol} ({timeframe})")
return df
except Exception as e:
logging.error(f"Failed to fetch data: {e}")
raise
# 2. K-line merging (vectorized)
def merge_kline(df):
try:
df = df.copy()
merged_data = []
trend = np.sign(df['Close'].diff().shift(-1)) # 1: up, -1: down, 0: neutral
# Detect inclusion
is_included = ((df['High'].shift(-1) <= df['High']) & (df['Low'].shift(-1) >= df['Low'])) | \
((df['High'].shift(-1) >= df['High']) & (df['Low'].shift(-1) <= df['Low']))
i = 0
while i < len(df) - 1:
if is_included.iloc[i]:
current_k = df.iloc[i]
next_k = df.iloc[i + 1]
high = max(current_k['High'], next_k['High'])
low = min(current_k['Low'], next_k['Low'])
open_price = current_k['Open']
close_price = next_k['Close'] if trend.iloc[i] >= 0 else next_k['Close']
volume = current_k['Volume'] + next_k['Volume']
merged_data.append({
'Date': next_k.name,
'Open': open_price,
'High': high,
'Low': low,
'Close': close_price,
'Volume': volume
})
i += 2
else:
current_k = df.iloc[i]
merged_data.append({
'Date': current_k.name,
'Open': current_k['Open'],
'High': current_k['High'],
'Low': current_k['Low'],
'Close': current_k['Close'],
'Volume': current_k['Volume']
})
i += 1
if i == len(df) - 1:
last_k = df.iloc[i]
merged_data.append({
'Date': last_k.name,
'Open': last_k['Open'],
'High': last_k['High'],
'Low': last_k['Low'],
'Close': last_k['Close'],
'Volume': last_k['Volume']
})
merged_df = pd.DataFrame(merged_data)
merged_df['Date'] = pd.to_datetime(merged_df['Date'])
merged_df.set_index('Date', inplace=True)
logging.info(f"Merged K-lines: {len(df)} -> {len(merged_df)}")
return merged_df
except Exception as e:
logging.error(f"K-line merging failed: {e}")
raise
# 3. Detect fractals (vectorized)
def detect_fractals(df):
try:
df = df.copy()
df['is_top'] = (df['High'] > df['High'].shift(1)) & (df['High'] > df['High'].shift(-1)) & \
(df['High'] > df['High'].shift(2)) & (df['High'] > df['High'].shift(-2))
df['is_bottom'] = (df['Low'] < df['Low'].shift(1)) & (df['Low'] < df['Low'].shift(-1)) & \
(df['Low'] < df['Low'].shift(2)) & (df['Low'] < df['Low'].shift(-2))
df['is_top'] = df['is_top'].fillna(False)
df['is_bottom'] = df['is_bottom'].fillna(False)
logging.info(f"Detected {df['is_top'].sum()} top fractals and {df['is_bottom'].sum()} bottom fractals")
return df
except Exception as e:
logging.error(f"Fractal detection failed: {e}")
raise
# 4. Detect strokes
def detect_strokes(df):
try:
strokes = []
last_fractal = None
last_price = None
last_index = None
for i in range(len(df)):
if df['is_top'].iloc[i] or df['is_bottom'].iloc[i]:
current_fractal = 'top' if df['is_top'].iloc[i] else 'bottom'
current_price = df['High'].iloc[i] if current_fractal == 'top' else df['Low'].iloc[i]
if last_fractal is None:
last_fractal = current_fractal
last_price = current_price
last_index = df.index[i]
continue
if (last_fractal == 'top' and current_fractal == 'bottom' and current_price < last_price) or \
(last_fractal == 'bottom' and current_fractal == 'top' and current_price > last_price):
strokes.append({
'start_time': last_index,
'end_time': df.index[i],
'start_price': last_price,
'end_price': current_price,
'type': 'down' if current_fractal == 'bottom' else 'up',
'volume': df['Volume'].loc[last_index:df.index[i]].sum()
})
last_fractal = current_fractal
last_price = current_price
last_index = df.index[i]
logging.info(f"Detected {len(strokes)} strokes")
return strokes
except Exception as e:
logging.error(f"Stroke detection failed: {e}")
raise
# 5. Detect segments
def detect_segments(strokes):
try:
segments = []
if len(strokes) < 3:
return segments
i = 0
while i < len(strokes) - 2:
stroke1, stroke2, stroke3 = strokes[i], strokes[i+1], strokes[i+2]
if stroke1['type'] == 'up' and stroke2['type'] == 'down' and stroke3['type'] == 'up':
if stroke3['end_price'] > stroke1['end_price']:
segments.append({
'start_time': stroke1['start_time'],
'end_time': stroke3['end_time'],
'start_price': stroke1['start_price'],
'end_price': stroke3['end_price'],
'type': 'up'
})
i += 3
else:
i += 1
elif stroke1['type'] == 'down' and stroke2['type'] == 'up' and stroke3['type'] == 'down':
if stroke3['end_price'] < stroke1['end_price']:
segments.append({
'start_time': stroke1['start_time'],
'end_time': stroke3['end_time'],
'start_price': stroke1['start_price'],
'end_price': stroke3['end_price'],
'type': 'down'
})
i += 3
else:
i += 1
else:
i += 1
logging.info(f"Detected {len(segments)} segments")
return segments
except Exception as e:
logging.error(f"Segment detection failed: {e}")
raise
# 6. Detect pivots (midlines)
def detect_pivots(strokes):
try:
pivots = []
if len(strokes) < 3:
return pivots
for i in range(len(strokes) - 2):
s1, s2, s3 = strokes[i:i+3]
high = min(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'],
s3['start_price'], s3['end_price'])
low = max(s1['start_price'], s1['end_price'], s2['start_price'], s2['end_price'],
s3['start_price'], s3['end_price'])
if high > low:
pivots.append({
'start_time': s1['start_time'],
'end_time': s3['end_time'],
'high': high,
'low': low
})
logging.info(f"Detected {len(pivots)} pivots")
return pivots
except Exception as e:
logging.error(f"Pivot detection failed: {e}")
raise
# 7. Analyze higher timeframe (30m)
def analyze_higher_timeframe(df_30m):
try:
df_30m = detect_fractals(df_30m)
strokes_30m = detect_strokes(df_30m)
if not strokes_30m:
return 'neutral'
last_stroke = strokes_30m[-1]
logging.info(f"30m trend: {last_stroke['type']}")
return last_stroke['type']
except Exception as e:
logging.error(f"Higher timeframe analysis failed: {e}")
raise
# 8. Back-divergence detection (enhanced)
def detect_back_divergence(df, strokes, higher_trend):
try:
macd, signal, hist = MACD(df['Close'], fastperiod=12, slowperiod=26, signalperiod=9)
sma20 = SMA(df['Close'], timeperiod=20)
df['macd'] = macd
df['hist'] = hist
df['sma20'] = sma20
df['buy_signal'] = False
df['sell_signal'] = False
stroke_metrics = []
for stroke in strokes:
start_idx = df.index.get_loc(stroke['start_time'])
end_idx = df.index.get_loc(stroke['end_time'])
hist_segment = df['hist'].iloc[start_idx:end_idx+1]
price_change = abs(stroke['end_price'] - stroke['start_price'])
hist_area = sum(abs(h) for h in hist_segment if not np.isnan(h))
volume = stroke['volume']
stroke_metrics.append({
'start_time': stroke['start_time'],
'end_time': stroke['end_time'],
'type': stroke['type'],
'price_change': price_change,
'hist_area': hist_area,
'volume': volume
})
for i in range(2, len(stroke_metrics)):
current_stroke = stroke_metrics[i]
prev_stroke = stroke_metrics[i-2]
if current_stroke['type'] != prev_stroke['type']:
continue
current_end_idx = df.index.get_loc(current_stroke['end_time'])
# Uptrend back-divergence (sell signal)
if current_stroke['type'] == 'up':
price_increase = df['High'].loc[current_stroke['end_time']] > df['High'].loc[prev_stroke['end_time']]
hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area']
volume_decrease = current_stroke['volume'] < prev_stroke['volume']
is_top_fractal = df['is_top'].loc[current_stroke['end_time']]
hist_positive = df['hist'].iloc[current_end_idx] > 0 or \
(df['hist'].iloc[current_end_idx] < 0 and df['hist'].iloc[current_end_idx-1] > 0)
sma_trend = df['Close'].iloc[current_end_idx] > df['sma20'].iloc[current_end_idx]
trend_match = higher_trend in ['up', 'neutral']
if price_increase and hist_decrease and volume_decrease and is_top_fractal and \
hist_positive and sma_trend and trend_match:
df.loc[df.index[current_end_idx], 'sell_signal'] = True
# Downtrend back-divergence (buy signal)
elif current_stroke['type'] == 'down':
price_decrease = df['Low'].loc[current_stroke['end_time']] < df['Low'].loc[prev_stroke['end_time']]
hist_decrease = current_stroke['hist_area'] < prev_stroke['hist_area']
volume_decrease = current_stroke['volume'] < prev_stroke['volume']
is_bottom_fractal = df['is_bottom'].loc[current_stroke['end_time']]
hist_negative = df['hist'].iloc[current_end_idx] < 0 or \
(df['hist'].iloc[current_end_idx] > 0 and df['hist'].iloc[current_end_idx-1] < 0)
sma_trend = df['Close'].iloc[current_end_idx] < df['sma20'].iloc[current_end_idx]
trend_match = higher_trend in ['down', 'neutral']
if price_decrease and hist_decrease and volume_decrease and is_bottom_fractal and \
hist_negative and sma_trend and trend_match:
df.loc[df.index[current_end_idx], 'buy_signal'] = True
logging.info(f"Detected {df['buy_signal'].sum()} buy signals and {df['sell_signal'].sum()} sell signals")
return df
except Exception as e:
logging.error(f"Back-divergence detection failed: {e}")
raise
# 9. Execute trade
def execute_trade(exchange, symbol, signal, amount=0.001):
try:
if SIMULATION_MODE:
msg = f"[SIMULATION] {'Buy' if signal == 'buy' else 'Sell'} {amount} {symbol} at {datetime.now(dt.UTC)}"
print(msg)
logging.info(msg)
return
if signal == 'buy':
order = exchange.create_market_buy_order(symbol, amount)
msg = f"Buy order executed: {order}"
print(msg)
logging.info(msg)
elif signal == 'sell':
order = exchange.create_market_sell_order(symbol, amount)
msg = f"Sell order executed: {order}"
print(msg)
logging.info(msg)
except Exception as e:
msg = f"Trade execution failed: {e}"
print(msg)
logging.error(msg)
# 10. Plot chart
def plot_chart(df, strokes, segments, pivots):
try:
# Initialize additional plots
apds = []
alines = [] # For line segments
# Plot strokes as line segments
for stroke in strokes:
alines.append([(stroke['start_time'], stroke['start_price']),
(stroke['end_time'], stroke['end_price'])])
# Plot segments as line segments
for segment in segments:
alines.append([(segment['start_time'], segment['start_price']),
(segment['end_time'], segment['end_price'])])
# Plot pivots as horizontal lines
for pivot in pivots:
alines.append([(pivot['start_time'], pivot['high']),
(pivot['end_time'], pivot['high'])])
alines.append([(pivot['start_time'], pivot['low']),
(pivot['end_time'], pivot['low'])])
# Add alines to plot (single color for simplicity, can customize)
if alines:
apds.append(mpf.make_addplot(
None, # No y-data needed for alines
alines=alines,
type='line',
color=['blue' if i < len(strokes) else 'purple' if i < len(strokes) + len(segments) else 'orange'
for i in range(len(alines))],
linestyle=['--' if i < len(strokes) else '-' if i < len(strokes) + len(segments) else ':'
for i in range(len(alines))]
))
# Plot buy/sell signals
buy_signals = df[df['buy_signal']]['Close']
sell_signals = df[df['sell_signal']]['Close']
apds.append(mpf.make_addplot(buy_signals, type='scatter', markersize=100, marker='^', color='green'))
apds.append(mpf.make_addplot(sell_signals, type='scatter', markersize=100, marker='v', color='red'))
# Plot K-line chart
mpf.plot(df, type='candle', addplot=apds, title='Chanlun Advanced Analysis', style='yahoo')
logging.info("Chart plotted successfully")
except Exception as e:
logging.error(f"Chart plotting failed: {e}")
raise
# 11. Main function
def main():
try:
# Initialize exchange
exchange = ccxt.binance({
'apiKey': BINANCE_API_KEY if not SIMULATION_MODE else '',
'secret': BINANCE_API_SECRET if not SIMULATION_MODE else '',
'enableRateLimit': True,
'options': {'defaultType': 'spot'}
})
# Fetch data
df_5m = fetch_binance_data(symbol='BTC/USDT', timeframe='5m', limit=500)
df_30m = fetch_binance_data(symbol='BTC/USDT', timeframe='30m', limit=200)
# Merge 5m K-lines
df_5m = merge_kline(df_5m)
# Detect fractals, strokes, segments, pivots
df_5m = detect_fractals(df_5m)
strokes = detect_strokes(df_5m)
segments = detect_segments(strokes)
pivots = detect_pivots(strokes)
# Analyze 30m trend
higher_trend = analyze_higher_timeframe(df_30m)
print(f"30m Trend: {higher_trend}")
# Detect back-divergence
df_5m = detect_back_divergence(df_5m, strokes, higher_trend)
# Plot chart
plot_chart(df_5m, strokes, segments, pivots)
# Output and execute trades
print("Buy Signals:")
buy_signals = df_5m[df_5m['buy_signal']][['Close']]
print(buy_signals)
for idx, row in buy_signals.iterrows():
execute_trade(exchange, 'BTC/USDT', 'buy', amount=0.001)
print("Sell Signals:")
sell_signals = df_5m[df_5m['sell_signal']][['Close']]
print(sell_signals)
for idx, row in sell_signals.iterrows():
execute_trade(exchange, 'BTC/USDT', 'sell', amount=0.001)
logging.info("Main function completed successfully")
except Exception as e:
logging.error(f"Main function failed: {e}")
raise
if __name__ == "__main__":
main()
View File
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
分型强度检测配置文件
用于调整分型强度计算的各项参数和权重
"""
class FxStrengthConfig:
"""分型强度检测配置类"""
def __init__(self):
# ===== 权重配置 (总分100分) =====
self.price_difference_weight = 40 # 价格差异强度权重
self.breakthrough_weight = 20 # 突破历史点位权重
self.volume_weight = 15 # 成交量确认权重
self.rsi_divergence_weight = 15 # RSI背离权重
self.macd_divergence_weight = 10 # MACD背离权重
# ===== 价格差异参数 =====
self.price_diff_multiplier = 1000 # 价格差异放大倍数
self.max_price_score = 20 # 价格差异最高得分
# ===== 突破检测参数 =====
self.breakthrough_lookback = 10 # 回看K线数量
self.breakthrough_multiplier = 500 # 突破幅度放大倍数
self.max_breakthrough_score = 20 # 突破最高得分
# ===== 成交量参数 =====
self.volume_lookback = 5 # 计算平均成交量的回看期数
self.volume_multiplier = 10 # 成交量放大倍数
self.max_volume_score = 15 # 成交量最高得分
self.min_volume_ratio = 1.0 # 最小成交量比率
# ===== RSI背离参数 =====
self.rsi_divergence_divisor = 2 # RSI背离除数
self.max_rsi_score = 15 # RSI最高得分
# ===== MACD背离参数 =====
self.macd_divergence_multiplier = 100 # MACD背离放大倍数
self.max_macd_score = 10 # MACD最高得分
# ===== 强度等级阈值 =====
self.extreme_threshold = 80 # 极强分型阈值
self.strong_threshold = 60 # 强分型阈值
self.medium_threshold = 40 # 中等分型阈值
self.weak_threshold = 20 # 弱分型阈值
# ===== 其他参数 =====
self.min_strength = 0 # 最小强度分数
self.max_strength = 100 # 最大强度分数
def get_strength_level_name(self, strength):
"""根据强度分数获取等级名称"""
if strength >= self.extreme_threshold:
return "极强"
elif strength >= self.strong_threshold:
return ""
elif strength >= self.medium_threshold:
return "中等"
elif strength >= self.weak_threshold:
return ""
else:
return "极弱"
def is_strong_fractal(self, strength, custom_threshold=None):
"""判断是否为强分型"""
threshold = custom_threshold if custom_threshold is not None else self.strong_threshold
return strength >= threshold
def validate_config(self):
"""验证配置参数的合理性"""
total_weight = (self.price_difference_weight +
self.breakthrough_weight +
self.volume_weight +
self.rsi_divergence_weight +
self.macd_divergence_weight)
if total_weight != 100:
print(f"警告: 权重总和为{total_weight},不等于100")
if not (0 <= self.extreme_threshold <= 100):
print(f"警告: 极强阈值{self.extreme_threshold}不在合理范围内")
if not (self.weak_threshold < self.medium_threshold <
self.strong_threshold < self.extreme_threshold):
print("警告: 强度阈值设置不合理")
return True
def print_config(self):
"""打印当前配置"""
print("=== 分型强度检测配置 ===")
print(f"价格差异权重: {self.price_difference_weight}")
print(f"突破点位权重: {self.breakthrough_weight}")
print(f"成交量权重: {self.volume_weight}")
print(f"RSI背离权重: {self.rsi_divergence_weight}")
print(f"MACD背离权重: {self.macd_divergence_weight}")
print()
print("=== 强度等级阈值 ===")
print(f"极强: >={self.extreme_threshold}")
print(f"强: {self.strong_threshold}-{self.extreme_threshold-1}")
print(f"中等: {self.medium_threshold}-{self.strong_threshold-1}")
print(f"弱: {self.weak_threshold}-{self.medium_threshold-1}")
print(f"极弱: <{self.weak_threshold}")
# 默认配置实例
DEFAULT_CONFIG = FxStrengthConfig()
# 保守配置 (更严格的分型识别)
CONSERVATIVE_CONFIG = FxStrengthConfig()
CONSERVATIVE_CONFIG.price_difference_weight = 50
CONSERVATIVE_CONFIG.breakthrough_weight = 25
CONSERVATIVE_CONFIG.volume_weight = 15
CONSERVATIVE_CONFIG.rsi_divergence_weight = 10
CONSERVATIVE_CONFIG.macd_divergence_weight = 0
CONSERVATIVE_CONFIG.strong_threshold = 70
CONSERVATIVE_CONFIG.extreme_threshold = 85
# 激进配置 (更宽松的分型识别)
AGGRESSIVE_CONFIG = FxStrengthConfig()
AGGRESSIVE_CONFIG.price_difference_weight = 30
AGGRESSIVE_CONFIG.breakthrough_weight = 15
AGGRESSIVE_CONFIG.volume_weight = 20
AGGRESSIVE_CONFIG.rsi_divergence_weight = 20
AGGRESSIVE_CONFIG.macd_divergence_weight = 15
AGGRESSIVE_CONFIG.strong_threshold = 50
AGGRESSIVE_CONFIG.extreme_threshold = 70
# 技术指标重点配置 (重视技术指标背离)
TECHNICAL_CONFIG = FxStrengthConfig()
TECHNICAL_CONFIG.price_difference_weight = 25
TECHNICAL_CONFIG.breakthrough_weight = 15
TECHNICAL_CONFIG.volume_weight = 10
TECHNICAL_CONFIG.rsi_divergence_weight = 25
TECHNICAL_CONFIG.macd_divergence_weight = 25
if __name__ == "__main__":
print("=== 分型强度配置演示 ===\n")
configs = {
"默认配置": DEFAULT_CONFIG,
"保守配置": CONSERVATIVE_CONFIG,
"激进配置": AGGRESSIVE_CONFIG,
"技术指标配置": TECHNICAL_CONFIG
}
for name, config in configs.items():
print(f"=== {name} ===")
config.print_config()
config.validate_config()
print()
+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,
}
+132
View File
@@ -0,0 +1,132 @@
from decimal import Decimal
import chanlun.core.ChanKLC as ChanKLC
from chanlun.core.ChanEnum import Chan_BI_DIR
class ChanBI():
def __init__(self, klc: ChanKLC, index, ddir=Chan_BI_DIR.UP):
self.start_klc = klc
self.end_klc = klc
self.next = None
self.pre = None
self.dir = ddir
self.index = index
self.is_sure = False
self.high = klc.high
self.low = klc.low
self.sure_time = None
self.klc_list = []
self.klc_list.append(klc)
self.end_time = klc.end_time
self.start_time = klc.start_time
self.macd_hist = 0
self.macd_div = 0
self.seg = None
self.height = 0
self.width = 0
self.slop = 0
self.fib_list = []
self.seg_index = 0
self.bi_zs = None
self.seg_zs = None
def set_bi_zs(self, bi_zs):
for klc in self.klc_list:
klc.set_bi_zs(bi_zs)
def set_seg(self, seg):
self.seg = seg
self.seg_index = len(seg.bi_list)-1
def set_macdhist(self, macd_hist):
self.macd_hist = macd_hist
def set_macd_div(self, macd_div):
self.macd_div = macd_div
def cal_macd_div(self):
self.macd_div = 0.0
if self.pre and self.pre.pre:
if self.pre.pre.macd_hist == 0:
self.macd_div = 0.0
else:
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)
def cal_macdhist(self):
self.macd_hist = 0
for klc in self.klc_list:
for klu in klc.klu_list:
if self.dir == Chan_BI_DIR.UP and klu.macdhist > 0:
self.macd_hist += klu.macdhist
if self.dir == Chan_BI_DIR.DOWN and klu.macdhist < 0:
self.macd_hist -= klu.macdhist
def check_bi_zs_overlap(self):
if self.next and self.next.next:
if self.dir == Chan_BI_DIR.UP:
return self.low < self.next.next.high
else:
return self.high > self.next.next.low
else:
return False
def check_overlap(self):
if self.next and self.next.next and self.next.next.is_sure:
if self.dir == Chan_BI_DIR.UP:
return self.high > self.next.low and self.high < self.next.next.high
else:
return self.high > self.next.high and self.low > self.next.next.low
else:
return False
def set_end_klc(self, klc, sure_klc):
if self.dir == Chan_BI_DIR.UP and klc.high > self.high:
self.high = klc.high
if self.dir == Chan_BI_DIR.DOWN and klc.low < self.low:
self.low = klc.low
self.end_klc = klc
self.set_is_sure(True, sure_klc.end_time)
self.end_time = klc.end_time
self.cal_properties()
#print(self.start_time, klc.fx, "This bi is ended", len(self.klc_list), klc.index - self.start_klc.index)
def cal_properties(self):
if self.is_sure:
self.height = float(format(self.high - self.low, ".2f"))
self.width = self.end_klc.index - self.start_klc.index
self.slop = float(format(self.height / self.width, ".2f"))
fib_list = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0]
for fib in fib_list:
self.fib_list.append(float(format(self.height * fib + self.low, ".2f")))
#print(self.end_time, self.height, self.width, self.slop, self.fib_list)
def set_is_sure(self, is_sure, time):
self.is_sure = is_sure
self.sure_time = time
def set_start_klc(self, klc, ddir):
self.start_klc = klc
self.klc_list = []
self.klc_list.append(klc)
self.high = klc.high
self.low = klc.low
self.dir = ddir
def set_pre(self, bi):
self.pre = bi
def set_next(self, bi):
self.next = bi
def add_klc(self, klc):
added = False
if len(self.klc_list) > 0:
for index in range(0, len(self.klc_list)):
if self.klc_list[index].index == klc.index:
added = True
break
if not added:
self.klc_list.append(klc)
#print(self.start_time, klc.start_time)
#print(klc.end_time, klc.index)
self.end_klc = klc
self.end_time = klc.klu_list[-1].time
self.cal_macdhist()
self.cal_macd_div()
def append_klc_list(self, klc_list):
self.klc_list.append(klc_list)
def get_decimal(self, value):
return Decimal("{:.2f}".format(value))
def update_bi(self, klc):
self.end_klc = None
if self.dir == Chan_BI_DIR.UP and klc.high > self.high:
self.high = klc.high
if self.dir == Chan_BI_DIR.DOWN and klc.low < self.low:
self.low = klc.low
self.is_sure = False
self.sure_time = None
#print(self.start_time, klc.start_time, klc.fx, "This bi is extended")
+161
View File
@@ -0,0 +1,161 @@
from chanlun.core.ChanEnum import Chan_ZS_DIR, Chan_ZS_TYPE, Chan_BI_DIR
import chanlun.core.ChanBI as ChanBI
# 中枢
class ChanBIZS():
def __init__(self, start_bi: ChanBI, index, ddir: Chan_ZS_DIR):
self.start_klc = start_bi.start_klc
self.start_time = self.start_klc.start_time
self.end_time = None
self.index = index
self.start_bi = start_bi
self.bi_list = []
self.bi_list.append(start_bi)
self.end_bi = None
self.bi_out = None
self.is_sure = False
self.zg = 0
self.zd = 0
self.gg = 0
self.dd = 0
self.dir = ddir
self.sure_time = None
self.end_klc = None
self.zs_type = Chan_ZS_TYPE.NORMAL
start_bi.set_bi_zs(self)
def set_end_bi(self, end_bi, sure_time):
self.end_bi = end_bi
self.set_end_time(end_bi.end_klc.end_time)
self.is_sure = True
self.sure_time = sure_time
end_bi.set_bi_zs(self)
#print(self.start_time, self.is_sure, len(self.bi_list), self.dir, self.zs_type)
def set_end_time(self, end_time):
self.end_time = end_time
def set_zg(self, zg):
self.zg = zg
def set_zd(self, zd):
self.zd = zd
def set_gg(self, gg):
self.gg = gg
def set_dd(self, dd):
self.dd = dd
def add_bi(self, bi: ChanBI):
if bi:
self.bi_list.append(bi)
if bi.high > self.gg:
self.gg = bi.high
if bi.low < self.dd:
self.dd = bi.low
bi.set_bi_zs(self)
self.classify_zs()
def set_pre(self, pre):
self.pre = pre
def set_next(self, next):
self.next = next
def classify_zs(self):
"""
根据中枢内笔的高低点变化趋势对中枢进行分类
分类逻辑
- 取中枢内向上笔的高点peaks和向下笔的低点valleys
- 比较前半段和后半段的均值判断高点和低点的整体趋势
分类结果
- RISING 上升中枢高点抬高 + 低点抬高 多方占优可能向上突破
- FALLING 下行中枢高点降低 + 低点降低 空方占优可能向下突破
- CONVERGING 收敛中枢高点降低 + 低点抬高 区间收窄即将选择方向
- DIVERGING 扩散中枢高点抬高 + 低点降低 波动加剧市场不稳定
- NORMAL 常规中枢无明显趋势 多空均衡区间震荡
"""
if len(self.bi_list) < 3:
self.zs_type = Chan_ZS_TYPE.NORMAL
return
# 提取向上笔的高点(peaks)和向下笔的低点(valleys
peaks = [bi.high for bi in self.bi_list if bi.dir == Chan_BI_DIR.UP]
valleys = [bi.low for bi in self.bi_list if bi.dir == Chan_BI_DIR.DOWN]
high_trend = self._calc_trend(peaks)
low_trend = self._calc_trend(valleys)
if high_trend > 0 and low_trend > 0:
self.zs_type = Chan_ZS_TYPE.RISING
elif high_trend < 0 and low_trend < 0:
self.zs_type = Chan_ZS_TYPE.FALLING
elif high_trend < 0 and low_trend > 0:
self.zs_type = Chan_ZS_TYPE.CONVERGING
elif high_trend > 0 and low_trend < 0:
self.zs_type = Chan_ZS_TYPE.DIVERGING
else:
self.zs_type = Chan_ZS_TYPE.NORMAL
def _calc_trend(self, values):
"""
计算序列的趋势方向
将序列分为前后两半比较均值
- 后半均值 > 前半均值 返回 1上升趋势
- 后半均值 < 前半均值 返回 -1下降趋势
- 相等或数据不足 返回 0无趋势
使用均值比较而非首尾比较可以过滤单笔异常波动带来的误判
"""
if len(values) < 2:
return 0
mid = len(values) // 2
first_half = values[:mid] if mid > 0 else values[:1]
second_half = values[mid:]
avg_first = sum(first_half) / len(first_half)
avg_second = sum(second_half) / len(second_half)
# 使用中枢区间的一定比例作为阈值,避免微小波动误判
threshold = abs(avg_first) * 0.005 if avg_first != 0 else 0
if avg_second - avg_first > threshold:
return 1
elif avg_first - avg_second > threshold:
return -1
else:
return 0
def is_weakening(self):
"""
判断中枢是否在衰弱即将反向突破的信号
衰弱条件
1. 中枢内笔数 >= 5有足够的数据判断
2. 最后一笔的MACD面积相比同方向前一笔出现背驰macd_div < 1
3. 中枢类型为收敛型或常规型
返回: True表示中枢力量衰弱可能反向
"""
if len(self.bi_list) < 5:
return False
last_bi = self.bi_list[-1]
# 最后一笔与同方向前一笔比较MACD面积是否背驰
if last_bi.macd_div > 0 and last_bi.macd_div < 1.0:
return True
return False
def get_zs_strength(self):
"""
计算中枢强度用于辅助判断中枢延续还是反向
返回字典包含
- type: 中枢类型 (Chan_ZS_TYPE)
- bi_count: 中枢内笔数
- range_ratio: 中枢区间占比 = (zg - zd) / (gg - dd)越小说明中枢越紧密
- last_bi_div: 最后一笔的MACD背驰比率
- is_weakening: 是否衰弱
- is_extending: 是否在延伸笔数 >= 9 可能升级
"""
total_range = self.gg - self.dd if self.gg != self.dd else 1
zs_range = self.zg - self.zd if self.zg != self.zd else 0
range_ratio = zs_range / total_range if total_range > 0 else 0
last_bi_div = self.bi_list[-1].macd_div if len(self.bi_list) > 0 else 0
return {
'type': self.zs_type,
'bi_count': len(self.bi_list),
'range_ratio': round(range_ratio, 4),
'last_bi_div': round(last_bi_div, 4),
'is_weakening': self.is_weakening(),
'is_extending': len(self.bi_list) >= 9, # 9段可能升级
}
+24
View File
@@ -0,0 +1,24 @@
import chanlun.core.ChanBI as ChanBI
from chanlun.core.ChanEnum import Chan_BSP_TYPE, Chan_BSP_DIR
class ChanBSP():
def __init__(self, bi: ChanBI, index, type: Chan_BSP_TYPE, ddir: Chan_BSP_DIR, sure_time, zs_count, zs, seg):
self.bi = bi
self.klc = bi.end_klc
self.index = index
self.type = type
self.start_time = self.klc.start_time
self.end_time = self.klc.end_time
if sure_time:
self.is_sure = True
self.sure_time = sure_time
else:
self.is_sure = False
self.sure_time = None
self.dir = ddir
self.zs_count = zs_count
self.zs = zs
self.seg = bi.seg
def set_sure_time(self, sure_time):
self.is_sure = True
self.sure_time = sure_time
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime
class ChanCTime:
def __init__(self, year, month, day, hour, minute, second=0, auto=True):
self.year = year
self.month = month
self.day = day
self.hour = hour
self.minute = minute
self.second = second
self.auto = auto # 自适应对天的理解
self.set_timestamp() # set self.ts
def __str__(self):
if self.hour == 0 and self.minute == 0:
return f"{self.year:04}/{self.month:02}/{self.day:02}"
else:
return f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}"
def to_str(self):
if self.hour == 0 and self.minute == 0:
return f"{self.year:04}/{self.month:02}/{self.day:02}"
else:
return f"{self.year:04}/{self.month:02}/{self.day:02} {self.hour:02}:{self.minute:02}"
def toDateStr(self, splt=''):
return f"{self.year:04}{splt}{self.month:02}{splt}{self.day:02}"
def toDate(self):
return ChanCTime(self.year, self.month, self.day, 0, 0, auto=False)
def set_timestamp(self):
if self.hour == 0 and self.minute == 0 and self.auto:
date = datetime(self.year, self.month, self.day, 23, 59, self.second)
else:
date = datetime(self.year, self.month, self.day, self.hour, self.minute, self.second)
self.ts = date.timestamp()
def __gt__(self, t2):
return self.ts > t2.ts
def __ge__(self, t2):
return self.ts >= t2.ts
+368
View File
@@ -0,0 +1,368 @@
from enum import Enum, auto
from typing import Literal
class Chan_DATA_SRC(Enum):
BAO_STOCK = auto()
CCXT = auto()
CSV = auto()
class Chan_ZS_DIR(Enum):
UP = auto()
DOWN = auto()
class Chan_ZS_TYPE(Enum):
"""中枢类型分类"""
NORMAL = auto() # 常规中枢:高低点无明显趋势,区间震荡
RISING = auto() # 上升中枢:高点抬高,低点也抬高,重心上移
FALLING = auto() # 下行中枢:高点降低,低点也降低,重心下移
CONVERGING = auto() # 收敛中枢:高点降低,低点抬高,区间收窄(三角收敛)
DIVERGING = auto() # 扩散中枢:高点抬高,低点降低,区间扩大(喇叭口)
class Chan_K_DIR(Enum):
BULL = auto()
BEAR = auto()
CROSS = auto()
class Chan_EMA_POS(Enum):
"""K线与任意EMA的位置关系(与趋势方向无关的客观分类,支持threshold容差)"""
ABOVE = auto() # 完全在EMA上方(远离):low > ema + threshold
NEAR_ABOVE = auto() # 在EMA上方但接近:ema < low <= ema + threshold
CROSS_CLOSE_ABOVE = auto() # 跨越EMA,收盘在上方:close > ema, low <= ema(含threshold范围内触碰)
ON_EMA = auto() # 收盘价在EMA附近:abs(close - ema) <= threshold
CROSS_CLOSE_BELOW = auto() # 跨越EMA,收盘在下方:close < ema, high >= ema(含threshold范围内触碰)
NEAR_BELOW = auto() # 在EMA下方但接近:ema - threshold <= high < ema
BELOW = auto() # 完全在EMA下方(远离):high < ema - threshold
UNKNOWN = auto() # 未知(EMA值无效)
class Chan_EMA_SEMANTIC(Enum):
"""K线与EMA结合趋势方向的语义状态(用于交易判断)"""
STRONG_TREND = auto() # 7: 顺势K线完全在EMA趋势侧(强势,远未及EMA)
TREND_SIDE = auto() # 6: 完全在EMA趋势侧(正常趋势运行)
RECOVER = auto() # 5: 逆势后穿越EMA回到趋势侧(收复EMA,趋势恢复)
TOUCH_FAIL = auto() # 4: 逆势触碰EMA但未穿越(反弹/反抽力度不足)
DEEP_COUNTER = auto() # 3: 完全在EMA逆势侧(深度回调/反抽)
BREAK = auto() # 2: 穿越EMA,收盘在逆势侧(支撑/压力失败)
TOUCH_HOLD = auto() # 1: 触碰EMA,收盘守住趋势侧(支撑/压力有效)
WEAK_COUNTER = auto() # 8: 逆势K线完全在EMA逆势侧(弱势,远未到EMA)
APPROACHING = auto() # 9: K线接近EMA但未触碰(即将测试支撑/压力)
NEUTRAL = auto() # 0: 盘整/无法判断
class Chan_KL_TYPE(Enum):
K_1S = auto()
K_1M = auto()
K_DAY = auto()
K_WEEK = auto()
K_MON = auto()
K_YEAR = auto()
K_5M = auto()
K_15M = auto()
K_30M = auto()
K_60M = auto()
K_1H = auto()
K_2H = auto()
K_4H = auto()
K_6H = auto()
K_8H = auto()
K_12H = auto()
K_1D = auto()
K_3D = auto()
K_3M = auto()
K_QUARTER = auto()
class Chan_KLINE_DIR(Enum):
UP = auto()
DOWN = auto()
COMBINE = auto()
INCLUDED = auto()
class Chan_KLU_TYPE(Enum):
BigBull = auto()
MiddleBull = auto()
SmallBull = auto()
BigBear = auto()
MiddleBear = auto()
SmallBear = auto()
Cross = auto()
class Chan_KLU_PATTERN(Enum):
# 单根K线形态
HAMMER = auto() # 锤子线
INVERTED_HAMMER = auto() # 倒锤子线
SHOOTING_STAR = auto() # 射击之星
HANGING_MAN = auto() # 上吊线
DOJI = auto() # 十字星
LONG_LEGGED_DOJI = auto() # 长腿十字星
GRAVESTONE_DOJI = auto() # 墓碑十字星
DRAGONFLY_DOJI = auto() # 蜻蜓十字星
MARUBOZU = auto() # 光头光脚
SPINNING_TOP = auto() # 纺锤线
# 双根K线形态
BULLISH_ENGULFING = auto() # 看涨吞没
BEARISH_ENGULFING = auto() # 看跌吞没
PIERCING_LINE = auto() # 刺透形态
DARK_CLOUD_COVER = auto() # 乌云盖顶
TWEEZER_TOP = auto() # 镊子顶
TWEEZER_BOTTOM = auto() # 镊子底
HARAMI = auto() # 孕线
BULLISH_HARAMI = auto() # 看涨孕线
BEARISH_HARAMI = auto() # 看跌孕线
# 三根K线形态
MORNING_STAR = auto() # 早晨之星
EVENING_STAR = auto() # 黄昏之星
THREE_WHITE_SOLDIERS = auto() # 红三兵
THREE_BLACK_CROWS = auto() # 三只乌鸦
THREE_INNER_UP = auto() # 上升三法
THREE_INNER_DOWN = auto() # 下降三法
ABANDONED_BABY = auto() # 弃婴形态
# 多根K线形态
DOUBLE_TOP = auto() # 双顶
DOUBLE_BOTTOM = auto() # 双底
TRIPLE_TOP = auto() # 三顶
TRIPLE_BOTTOM = auto() # 三底
HEAD_AND_SHOULDERS = auto() # 头肩顶
INVERSE_HEAD_SHOULDERS = auto() # 头肩底
ROUNDING_BOTTOM = auto() # 圆弧底
ROUNDING_TOP = auto() # 圆弧顶
# 缺口形态
BREAKAWAY_GAP = auto() # 突破缺口
RUNAWAY_GAP = auto() # 持续缺口
EXHAUSTION_GAP = auto() # 衰竭缺口
# 特殊形态
ISLAND_REVERSAL = auto() # 岛形反转
KEY_REVERSAL = auto() # 关键反转
INSIDE_BAR = auto() # 内包线
OUTSIDE_BAR = auto() # 外包线
# 趋势形态
HIGHER_HIGH = auto() # 更高高点
HIGHER_LOW = auto() # 更高低点
LOWER_HIGH = auto() # 更低高点
LOWER_LOW = auto() # 更低低点
# 支撑阻力形态
SUPPORT_BOUNCE = auto() # 支撑反弹
RESISTANCE_REJECTION = auto() # 阻力拒绝
BREAKOUT = auto() # 突破
BREAKDOWN = auto() # 跌破
# 成交量相关形态
VOLUME_SPIKE = auto() # 成交量激增
VOLUME_DECLINE = auto() # 成交量萎缩
# 未知/无形态
UNKNOWN = auto() # 未知形态
class Chan_FX_TYPE(Enum):
BOTTOM = auto()
TOP = auto()
UNKNOWN = auto()
UP = auto()
DOWN = auto()
TT = auto()
BB = auto()
PTOP = auto()
PBOTTOM = auto()
class Chan_FX(Enum):
CONTINUATION = auto()
REVERSAL = auto()
UNKNOWN = auto()
class Chan_PRICE_TREND(Enum):
UP = auto()
DOWN = auto()
FLAT = auto()
UNKNOWN = auto()
class Chan_KLC_FX(Enum):
TOP0 = auto()
TOP1 = auto()
TOP2 = auto()
TOP3 = auto()
TOP4 = auto()
TOP5 = auto()
TOP6 = auto()
TOP7 = auto()
TOP8 = auto()
BOTTOM0 = auto()
BOTTOM1 = auto()
BOTTOM2 = auto()
BOTTOM3 = auto()
BOTTOM4 = auto()
BOTTOM5 = auto()
BOTTOM6 = auto()
BOTTOM7 = auto()
BOTTOM8 = auto()
UNKNOWN = auto()
# 统一的MACD状态枚举,包含所有可能的状态
class Chan_MACD_STATE(Enum):
"""MACD状态枚举 - 包含所有可能的状态"""
# 穿越状态
CROSS0_UP = auto() # 穿零轴后快速向上,能量柱呈现一根比一根长的排列方式
CROSS0_DOWN = auto() # 穿零轴后快速向下,能量柱呈现一根比一根短的排列方式
CROSS_OS = auto() # 穿零轴后缠绕/粘合,黄白线沿着能量柱运行,黄白线在运行的过程中没有释放出反向能量柱
CROSS_REV = auto() # 穿零轴后倒挂,MACD黄白线在穿零轴的时候与零轴的距离比较近,同时黄白线沿着能量柱运行,在运行的过程中,能量柱衰减导致它跟黄白线之间形成夹角空位,同时黄白线产生交叉并释放反向能量柱。
# 趋势状态
NEAR0 = auto()
NEAR0_52 = auto() # 价格在EMA52附近/价格接触EMA52并马上离开,需要观察离开强度
NEAR0_DIFF = auto() # MACD白线接近零轴,价格未到EMA52
NEAR0_PERFECT = auto() # MACD白线接近零轴和价格接触或短暂击穿EMA52,而MACD黄线不穿零轴,完美形态
NEAR0_24 = auto() # MACD黄白线接近零轴和价格在EMA24附近
# 位置状态
HIGH = auto() # 高位:MACD黄白线离开能量柱到高点,能量柱最大开始减弱
HIGH_EMPTY = auto() # 高位空:MACD黄白线处于高位,能量柱衰减,与黄白线形成空间夹角
RETURN_ZERO = auto() # 归零轴:能量柱呈现一根比一根短的排列方式
RZ_UP = auto() # 归零轴后的零轴上涨
RZ_DOWN = auto() # 归零轴后的零轴下跌
UP = auto() # 穿零轴后向上
DOWN = auto() # 穿零轴后向下
PEAK = auto() # 峰值:MACD白线处于高位
# 基础状态
UNKNOWN = auto() # 未知
START = auto() # 开始
class Chan_MACDSEG_DIR(Enum):
ABOVE = auto()
UNDER = auto()
class Chan_MACDUNITTF_TYPE(Enum):
START = auto()
CROSS0 = auto()
NEAR0 = auto()
class Chan_MACDUNITTF_JUMP(Enum):
CONTUNE = auto()
DISCRETE = auto()
class Chan_MACDUNITTF_DIV(Enum):
CONTUNE = auto()
DISCRETE = auto()
UNDIV = auto()
class Chan_MACDHISTSET_DIR(Enum):
ABOVE = auto()
UNDER = auto()
class Chan_MACDUNITTF_DIR(Enum):
ABOVE = auto()
UNDER = auto()
class Chan_MACDHIST_STATE(Enum):
UP = auto()
DOWN = auto()
PEAK = auto()
UNKNOWN = auto()
class Chan_BI_DIR(Enum):
UP = auto()
DOWN = auto()
class Chan_SEG_DIR(Enum):
UP = auto()
DOWN = auto()
class Chan_BI_TYPE(Enum):
UNKNOWN = auto()
STRICT = auto()
SUB_VALUE = auto() # 次高低点成笔
TIAOKONG_THRED = auto()
DAHENG = auto()
TUIBI = auto()
UNSTRICT = auto()
TIAOKONG_VALUE = auto()
Chan_BSP_MAIN_TYPE = Literal['1', '2', '3']
class Chan_BSP_DIR(Enum):
BUY = auto()
SELL = auto()
class Chan_BSP_TYPE(Enum):
B1 = auto()
B2 = auto()
B3 = auto()
S1 = auto()
S2 = auto()
S3 = auto()
NONE = auto()
"""
class Chan_BSP_TYPE(Enum):
T1 = '1'
T1P = '1p'
T2 = '2'
T2S = '2s'
T3A = '3a' # 中枢在1类后面
T3B = '3b' # 中枢在1类前面
T3 = '3'
T3E ='3e' # T3退出点
QJT = 'qjt' # 区间套突破
QJT1 = 'qjt1' # 区间套一类买点
QJT2 = 'qjt2' # 区间套一类卖点
QJT3 = 'qjt3' # 区间套三类买点
def main_type(self) -> Chan_BSP_MAIN_TYPE:
return self.value[0] # type: ignore
"""
class Chan_AUTYPE(Enum):
QFQ = auto()
HFQ = auto()
NONE = auto()
class Chan_TREND_TYPE(Enum):
MEAN = "mean"
MAX = "max"
MIN = "min"
class Chan_TREND_LINE_SIDE(Enum):
INSIDE = auto()
OUTSIDE = auto()
class Chan_LEFT_SEG_METHOD(Enum):
ALL = auto()
PEAK = auto()
class Chan_FX_CHECK_METHOD(Enum):
STRICT = auto()
LOSS = auto()
HALF = auto()
TOTALLY = auto()
class Chan_SEG_TYPE(Enum):
BI = auto()
SEG = auto()
class Chan_MACD_ALGO(Enum):
AREA = auto()
PEAK = auto()
FULL_AREA = auto()
DIFF = auto()
SLOPE = auto()
AMP = auto()
VOLUMN = auto()
AMOUNT = auto()
VOLUMN_AVG = auto()
AMOUNT_AVG = auto()
TURNRATE_AVG = auto()
RSI = auto()
class Chan_DATA_FIELD:
FIELD_TIME = "time_key"
FIELD_OPEN = "open"
FIELD_HIGH = "high"
FIELD_LOW = "low"
FIELD_CLOSE = "close"
FIELD_VOLUME = "volume" # 成交量
FIELD_TURNOVER = "turnover" # 成交额
FIELD_TURNRATE = "turnover_rate" # 换手率
class Chan_KLC_STATE:
"""笔当下状态(缠论笔定理)。任意时刻必属其一。"""
S10 = "(1, 0)" # 顶分型构造中 (1,0)
S_10 = "(-1, 0)" # 底分型构造中 (-1,0)
S11 = "(1,1)" # 向上笔延续中
S_11 = "(-1,1)" # 向下笔延续中
UNKNOWN = "Unknown" # 初始状态
+620
View File
@@ -0,0 +1,620 @@
import copy
from typing import Dict, Optional
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR, Chan_KLC_FX
from chanlun.core.ChanEnum import Chan_K_DIR, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_EMA_POS
from chanlun.core.ChanEnum import Chan_EMA_SEMANTIC, Chan_BSP_TYPE, Chan_KLC_STATE, Chan_FX
import chanlun.core.ChanKLU as ChanKLU
import chanlun.core.ChanCTime as ChanCTime
import chanlun.core.Chan_FX_Box as Chan_FX_Box
# 根据结合律合并K线后的K线
class ChanKLC():
def __init__(self, klu: ChanKLU, index, ddir=Chan_KLINE_DIR.UP):
self.start_time = klu.time
self.end_time = None
self.high = klu.high
self.low = klu.low
self.dir = ddir
self.index = index
self.klu_list = []
self.add_klu(klu)
self.fx = Chan_FX_TYPE.UNKNOWN
self.next = None
self.pre = None
self.start_klu = klu
self.end_klu = None
self.state = "00"
self.klc_state = Chan_KLC_STATE.UNKNOWN
self.open = klu.open
self.close = klu.close
self.volume = klu.volume
self.bi = None
self.distance = 0
self.klc_fx_type = Chan_KLC_FX.UNKNOWN
self.rsi = klu.rsi
self.volume_ratio = klu.volume_ratio
self.macdhist = klu.macdhist
self.body = klu.body
self.upper_shadow = klu.upper_shadow
self.lower_shadow = klu.lower_shadow
self.body_ratio = klu.body_ratio
self.upper_shadow_ratio = klu.upper_shadow_ratio
self.lower_shadow_ratio = klu.lower_shadow_ratio
self.candle_dir = klu.candle_dir
self.range = klu.range
self.bb_out = True
self.macd = klu.macd
self.signal = klu.signal
self.state = Chan_MACD_STATE.UNKNOWN
self.continue_div = False
self.separate_div = False
self.ema24 = klu.ema24
self.ema26 = klu.ema26
self.ema52 = klu.ema52
self.ema104 = klu.ema104
self.ema156 = klu.ema156
self.ema208 = klu.ema208
self.ema13 = klu.ema13
self.ema7 = klu.ema7
self.trend = Chan_PRICE_TREND.UNKNOWN
self.exception = klu.exception
self.klc_dir = Chan_KLINE_DIR.UP if klu.close > klu.open else Chan_KLINE_DIR.DOWN
self.ema_dir = klu.ema_dir
self.bsp = False
self.bsp_type = Chan_BSP_TYPE.NONE
# EMA状态字典:key为EMA名称,value为 {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC}
self.ema_status = {}
# 向后兼容:保留 ema52_status 和 ema52_pos
self.ema52_status = 0
self.ema52_pos = Chan_EMA_POS.UNKNOWN
self.bb2633upper = klu.bb2633upper
self.bb2633lower = klu.bb2633lower
self.bb2633middle = klu.bb2633middle
self.ema5 = klu.ema5
self.ma5 = klu.ma5
self.fx_box = None
self.in_fx = False
self.fx_confirmed = False
self.ema52_dis = klu.high - klu.ema52 if klu.close > klu.ema52 else klu.ema52 - klu.low
self.ema26_dis = klu.high - klu.ema26 if klu.close > klu.ema26 else klu.ema26 - klu.low
self.macd_signal_dis = abs(klu.macd - klu.signal)
self.ema52_ema26_dis = abs(klu.ema52 - klu.ema26)
self.fx_type = Chan_FX.UNKNOWN
self.bi_zs = None
self.seg_zs = None
self.last_bi_zs = None
# ==================== EMA 通用计算方法 ====================
@staticmethod
def cal_ema_pos(high, low, close, ema_value, threshold=0):
"""
计算K线与任意EMA的客观位置关系与趋势方向无关支持threshold容差
参数:
high, low, close: K线的高低收盘价
ema_value: EMA的值
threshold: 容差值绝对值在此范围内视为"接近/触碰"
例如 BTC 价格 $100,000 threshold=100 表示差100点视为触碰
返回:
Chan_EMA_POS 枚举值
判断逻辑以threshold=100, ema=97000为例
ema_zone = [96900, 97100] EMA上下各扩展threshold
ABOVE: low > 97100 K线完全在zone上方远离EMA
NEAR_ABOVE: 97000 < low <= 97100 K线在上方但下影线进入zone接近EMA
CROSS_CLOSE_ABOVE: close > 97000, low <= 97000 K线穿越EMA收盘在上方
ON_EMA: abs(close - 97000) <= 100 收盘价在zone内
CROSS_CLOSE_BELOW: close < 97000, high >= 97000 K线穿越EMA收盘在下方
NEAR_BELOW: 96900 <= high < 97000 K线在下方但上影线进入zone接近EMA
BELOW: high < 96900 K线完全在zone下方远离EMA
"""
if ema_value is None or ema_value == 0:
return Chan_EMA_POS.UNKNOWN
ema_upper = ema_value + threshold # EMA zone 上界
ema_lower = ema_value - threshold # EMA zone 下界
# 1. 收盘价在EMA附近(zone内)
if threshold > 0 and abs(close - ema_value) <= threshold:
# 收盘价在zone内,但还需要看是否有实际穿越
if low <= ema_value and close >= ema_value:
return Chan_EMA_POS.CROSS_CLOSE_ABOVE # 实际穿越了精确EMA线
elif high >= ema_value and close <= ema_value:
return Chan_EMA_POS.CROSS_CLOSE_BELOW
return Chan_EMA_POS.ON_EMA
# 2. K线实际穿越了精确的EMA线
if close > ema_value and low <= ema_value:
return Chan_EMA_POS.CROSS_CLOSE_ABOVE
if close < ema_value and high >= ema_value:
return Chan_EMA_POS.CROSS_CLOSE_BELOW
if close == ema_value:
return Chan_EMA_POS.ON_EMA
# 3. 没有实际穿越,检查是否"接近"(在threshold zone内)
if close > ema_value:
# K线在EMA上方
if threshold > 0 and low <= ema_upper:
return Chan_EMA_POS.NEAR_ABOVE # 下影线进入zone,接近但未触碰
return Chan_EMA_POS.ABOVE # 远离EMA
else:
# K线在EMA下方
if threshold > 0 and high >= ema_lower:
return Chan_EMA_POS.NEAR_BELOW # 上影线进入zone,接近但未触碰
return Chan_EMA_POS.BELOW # 远离EMA
@staticmethod
def cal_ema_semantic(ema_pos, kline_dir, ema_dir):
"""
根据客观位置 + K线方向 + 趋势方向计算语义状态
参数:
ema_pos: Chan_EMA_POS 客观位置
kline_dir: Chan_KLINE_DIR K线方向 (UP/DOWN/COMBINE/INCLUDED)
ema_dir: int 趋势方向 (1=多头, -1=空头, 0=盘整)
返回:
Chan_EMA_SEMANTIC 枚举值
语义含义以多头为例空头完全对称
TOUCH_HOLD: 触碰EMA收盘守住趋势侧支撑/压力有效
BREAK: 穿越EMA收盘在逆势侧支撑/压力失败
DEEP_COUNTER: 完全在EMA逆势侧深度回调/反抽
TOUCH_FAIL: 逆势触碰EMA但未穿越反弹/反抽力度不足
RECOVER: 逆势后穿越EMA回到趋势侧收复EMA
TREND_SIDE: 完全在EMA趋势侧正常运行
STRONG_TREND: 顺势K线完全在EMA趋势侧强势远未及EMA
WEAK_COUNTER: 逆势K线完全在EMA逆势侧弱势远未到EMA
"""
if ema_pos == Chan_EMA_POS.UNKNOWN:
return Chan_EMA_SEMANTIC.NEUTRAL
# 统一处理:将多头/盘整和空头映射到同一套逻辑
# is_bull=True 时,"趋势侧"=上方,"逆势侧"=下方
# is_bull=False时,"趋势侧"=下方,"逆势侧"=上方
is_bull = ema_dir >= 0 # 多头和盘整都按多头逻辑处理
# K线是否是顺势方向(多头下UP为顺势,空头下DOWN为顺势)
is_trend_kline = (kline_dir == Chan_KLINE_DIR.UP) if is_bull else (kline_dir == Chan_KLINE_DIR.DOWN)
is_counter_kline = (kline_dir == Chan_KLINE_DIR.DOWN) if is_bull else (kline_dir == Chan_KLINE_DIR.UP)
# 位置映射:多头下 ABOVE=趋势侧, BELOW=逆势侧; 空头反过来
trend_side = Chan_EMA_POS.ABOVE if is_bull else Chan_EMA_POS.BELOW
counter_side = Chan_EMA_POS.BELOW if is_bull else Chan_EMA_POS.ABOVE
near_trend = Chan_EMA_POS.NEAR_ABOVE if is_bull else Chan_EMA_POS.NEAR_BELOW
near_counter = Chan_EMA_POS.NEAR_BELOW if is_bull else Chan_EMA_POS.NEAR_ABOVE
cross_to_trend = Chan_EMA_POS.CROSS_CLOSE_ABOVE if is_bull else Chan_EMA_POS.CROSS_CLOSE_BELOW
cross_to_counter = Chan_EMA_POS.CROSS_CLOSE_BELOW if is_bull else Chan_EMA_POS.CROSS_CLOSE_ABOVE
# COMBINE / INCLUDED 方向:只看位置,不区分强弱
if not is_trend_kline and not is_counter_kline:
if ema_pos == trend_side:
return Chan_EMA_SEMANTIC.TREND_SIDE
elif ema_pos in (near_trend, cross_to_trend, Chan_EMA_POS.ON_EMA):
return Chan_EMA_SEMANTIC.APPROACHING
elif ema_pos in (near_counter, cross_to_counter):
return Chan_EMA_SEMANTIC.APPROACHING
elif ema_pos == counter_side:
return Chan_EMA_SEMANTIC.DEEP_COUNTER
return Chan_EMA_SEMANTIC.NEUTRAL
# 逆势K线(多头下的下跌K线 / 空头下的上涨K线)
if is_counter_kline:
if ema_pos == trend_side:
return Chan_EMA_SEMANTIC.STRONG_TREND # 逆势K线仍在趋势侧(回调很浅)
elif ema_pos == near_trend:
return Chan_EMA_SEMANTIC.APPROACHING # 接近EMA,即将测试支撑/压力
elif ema_pos == cross_to_trend:
return Chan_EMA_SEMANTIC.TOUCH_HOLD # 触碰EMA后守住趋势侧
elif ema_pos == Chan_EMA_POS.ON_EMA:
return Chan_EMA_SEMANTIC.TOUCH_HOLD # 收盘在EMA附近,视为守住
elif ema_pos == cross_to_counter:
return Chan_EMA_SEMANTIC.BREAK # 穿越EMA到逆势侧
elif ema_pos == near_counter:
return Chan_EMA_SEMANTIC.BREAK # 接近EMA但收盘在逆势侧,也视为击穿
elif ema_pos == counter_side:
return Chan_EMA_SEMANTIC.DEEP_COUNTER # 完全在逆势侧
# 顺势K线(多头下的上涨K线 / 空头下的下跌K线)
if is_trend_kline:
if ema_pos == counter_side:
return Chan_EMA_SEMANTIC.WEAK_COUNTER # 顺势K线却在逆势侧(弱势)
elif ema_pos == near_counter:
return Chan_EMA_SEMANTIC.APPROACHING # 从逆势侧接近EMA
elif ema_pos == cross_to_counter:
return Chan_EMA_SEMANTIC.TOUCH_FAIL # 触碰EMA但未穿越回趋势侧
elif ema_pos == Chan_EMA_POS.ON_EMA:
return Chan_EMA_SEMANTIC.TOUCH_FAIL # 收盘在EMA附近,未确认突破
elif ema_pos == cross_to_trend:
return Chan_EMA_SEMANTIC.RECOVER # 从逆势侧穿越回趋势侧
elif ema_pos == near_trend:
return Chan_EMA_SEMANTIC.RECOVER # 接近趋势侧(刚收复EMA附近)
elif ema_pos == trend_side:
return Chan_EMA_SEMANTIC.TREND_SIDE # 完全在趋势侧(正常)
return Chan_EMA_SEMANTIC.NEUTRAL
@staticmethod
def semantic_to_int(semantic):
"""将 Chan_EMA_SEMANTIC 枚举转换为整数,兼容旧的 ema52_status 数值"""
mapping = {
Chan_EMA_SEMANTIC.TOUCH_HOLD: 1,
Chan_EMA_SEMANTIC.BREAK: 2,
Chan_EMA_SEMANTIC.DEEP_COUNTER: 3,
Chan_EMA_SEMANTIC.TOUCH_FAIL: 4,
Chan_EMA_SEMANTIC.RECOVER: 5,
Chan_EMA_SEMANTIC.TREND_SIDE: 6,
Chan_EMA_SEMANTIC.STRONG_TREND: 7,
Chan_EMA_SEMANTIC.WEAK_COUNTER: 8,
Chan_EMA_SEMANTIC.APPROACHING: 9,
Chan_EMA_SEMANTIC.NEUTRAL: 0,
}
return mapping.get(semantic, 0)
# threshold_pct: 阈值百分比,用于自动计算绝对阈值
# 例如 0.001 表示 EMA 值的 0.1%BTC $100,000 时 threshold = $100
threshold_pct = 0.001
def set_bsp_type(self, bsp_type):
if bsp_type and bsp_type != Chan_BSP_TYPE.NONE:
self.bsp_type = bsp_type
self.bsp = True
def cal_all_ema_status(self):
"""
统一计算所有EMA与K线的位置关系和语义状态
threshold 自动按 EMA 值的百分比计算cls.threshold_pct默认0.1%
- BTC $100,000 threshold $100
- ETH $3,000 threshold $3
- SOL $200 threshold $0.2
结果存储在 self.ema_status 字典中格式
{
'ema24': {'pos': Chan_EMA_POS, 'semantic': Chan_EMA_SEMANTIC, 'value': float, 'threshold': float},
'ema52': {...},
...
}
同时保持向后兼容self.ema52_pos self.ema52_status
"""
ema_configs = {
'ema24': self.ema24,
'ema52': self.ema52,
'ema104': self.ema104,
'ema156': self.ema156,
'ema208': self.ema208,
}
self.ema_status = {}
for name, value in ema_configs.items():
# 按 EMA 值的百分比自动计算阈值
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)
semantic = ChanKLC.cal_ema_semantic(pos, self.dir, self.ema_dir)
self.ema_status[name] = {
'pos': pos,
'semantic': semantic,
'value': value,
'threshold': threshold,
}
# 向后兼容
self.ema52_pos = self.ema_status['ema52']['pos']
self.ema52_status = ChanKLC.semantic_to_int(self.ema_status['ema52']['semantic'])
def get_ema_pos(self, ema_name):
"""获取指定EMA的客观位置,如 klc.get_ema_pos('ema24')"""
if ema_name in self.ema_status:
return self.ema_status[ema_name]['pos']
return Chan_EMA_POS.UNKNOWN
def check_ema_pos(self):
if len(self.ema_status) > 0:
for ema_name, pos in self.ema_status.items():
#print(self.end_time, ema_name, pos['pos'])
if ((self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2) and pos['pos'] == Chan_EMA_POS.CROSS_CLOSE_BELOW) or ((self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2) and pos['pos'] == Chan_EMA_POS.CROSS_CLOSE_ABOVE):
#print("---------------------")
return ema_name
return None
def get_ema_semantic(self, ema_name):
"""获取指定EMA的语义状态,如 klc.get_ema_semantic('ema52')"""
if ema_name in self.ema_status:
return self.ema_status[ema_name]['semantic']
return Chan_EMA_SEMANTIC.NEUTRAL
def set_trend(self, trend):
self.trend = trend
def to_string(self):
out = ""
start = self.start_time if self.start_time is not None else ""
end = self.end_time if self.end_time is not None else ""
price_diff = getattr(self, 'price_diff', None)
out += str(start) + " " + str(end) + " " + str(self.close) + " " + str(self.ema24) + " " + str(self.ema52) + " " + str(self.trend) + " " + str(self.close - self.ema52)
return out
def set_bi_zs(self, bi_zs):
if bi_zs:
self.bi_zs = bi_zs
def set_klc_fx_type(self, klc_fx_type):
#print(self.start_time, klc_fx_type, self.get_feature_data()['klu_macd'], self.get_feature_data()['klu_macdhist'], self.get_feature_data()['klu_rsi'])
self.klc_fx_type = klc_fx_type
#self.cal_fx()
ema_name = self.check_ema_pos()
hist_div = abs(self.macdhist - self.next.macdhist)
#print(self.end_time, self.dir, abs(self.macdhist), hist_div)
#if ema_name:
#print(self.end_time, ema_name, self.ema_status[ema_name]['semantic'], hist_div)
#self.cal_bb_out()
#print(self.pre.start_time, self.next.end_time, self.klc_fx_type)
if klc_fx_type == Chan_KLC_FX.TOP1 or klc_fx_type == Chan_KLC_FX.TOP2 or klc_fx_type == Chan_KLC_FX.BOTTOM1 or klc_fx_type == Chan_KLC_FX.BOTTOM2:
self.cal_fx_box()
self.cal_fx_type()
def cal_fx_type(self):
if self.fx == Chan_FX_TYPE.TOP and self.next:
if self.ema52_dis > self.ema26_dis:
if self.pre.macd < self.macd and self.macd < self.next.macd:
self.fx_type = Chan_FX.CONTINUATION
else:
self.fx_type = Chan_FX.REVERSAL
elif self.fx == Chan_FX_TYPE.BOTTOM and self.next:
if self.ema52_dis < self.ema26_dis:
if self.pre.macd > self.macd and self.macd > self.next.macd:
self.fx_type = Chan_FX.CONTINUATION
else:
self.fx_type = Chan_FX.REVERSAL
#if self.fx_type != Chan_FX.UNKNOWN and self.fx_type != Chan_FX.CONTINUATION:
#print(self.end_time, self.fx_type)
def cal_fx_box(self):
# 每次重算前先清空,避免旧box残留
self.fx_box = None
start_time = None
end_time = None
high = 0
low = 0
display = False
if self.pre and self.next and self.next.end_time:
self.next.in_fx = True
if self.fx == Chan_FX_TYPE.TOP:
start_time = self.pre.end_time
end_time = self.next.end_time
high = self.high
low = self.pre.low if self.pre.low < self.next.low else self.next.low
if self.next.close < self.pre.low or True:
display = True
elif self.fx == Chan_FX_TYPE.BOTTOM:
start_time = self.pre.end_time
end_time = self.next.end_time
high = self.pre.high if self.pre.high > self.next.high else self.next.high
low = self.low
if self.next.close > self.pre.high or True:
display = True
if high > 0 and self.next.end_time and display:
#print(start_time, end_time, high, low)
# Chan_FX_BOX 这里导入的是模块,类名在模块内部为 Chan_FX_Box
self.fx_confirmed = True
self.fx_box = Chan_FX_Box.Chan_FX_Box(start_time, end_time, high, low)
def check_fx_confirmed(self, last_top, last_bottom):
if last_top and last_bottom and False:
if last_top.index > last_bottom.index:
if self.in_fx == False and last_top.fx_confirmed == False:
pre = last_top.pre
if pre.low > self.close:
last_top.fx_confirmed = True
if last_top.fx_box:
last_top.fx_box.end_time = self.end_time
#print(self.end_time, "fx_confirmed top")
else:
high = last_top.high
low = self.low
last_top.fx_box = Chan_FX_Box.Chan_FX_Box(last_top.pre.start_time, self.end_time, high, low)
#print(self.end_time, "fx_confirmed new box top")
elif self.in_fx == False and last_bottom.fx_confirmed == False:
pre = last_bottom.pre
if pre.high < self.close:
last_bottom.fx_confirmed = True
if last_bottom.fx_box:
last_bottom.fx_box.end_time = self.end_time
#print(self.end_time, "fx_confirmed bottom")
else:
high = self.high
low = last_bottom.low
last_bottom.fx_box = Chan_FX_Box.Chan_FX_Box(last_bottom.pre.start_time, self.end_time, high, low)
#print(self.end_time, "fx_confirmed new box bottom")
def add_klu(self, klu):
self.klu_list.append(klu)
def check_klc_state(self, last_fx_klc):
if last_fx_klc and last_fx_klc.fx == Chan_FX_TYPE.TOP:
if self.high > last_fx_klc.high:
self.klc_state = Chan_KLC_STATE.S11
else:
self.klc_state = Chan_KLC_STATE.S_11
elif last_fx_klc and last_fx_klc.fx == Chan_FX_TYPE.BOTTOM:
if self.low < last_fx_klc.low:
self.klc_state = Chan_KLC_STATE.S_11
else:
self.klc_state = Chan_KLC_STATE.S11
if self.pre and self.pre.fx == Chan_FX_TYPE.TOP:
self.klc_state = Chan_KLC_STATE.S10
elif self.pre and self.pre.fx == Chan_FX_TYPE.BOTTOM:
self.klc_state = Chan_KLC_STATE.S_10
#print(self.end_time, self.klc_state)
def set_end_klu(self, klu):
self.end_klu = klu
self.end_time = klu.time
self.close = klu.close
for klu in self.klu_list:
if klu.exception:
self.exception = True
print(klu.time, "exception")
if klu.separate_div > 0:
self.separate_div = True
if klu.continue_div:
self.continue_div = klu.continue_div
if klu.macd_state != Chan_MACD_STATE.UNKNOWN:
self.state = klu.macd_state
klu.set_klc(self)
self.klc_dir = Chan_KLINE_DIR.UP if self.close > self.open else Chan_KLINE_DIR.DOWN
self.cal_indicators()
self.cal_all_ema_status()
if self.open > self.high:
self.open = self.high
if self.close > self.high:
self.close = self.high
if self.close < self.low:
self.close = self.low
if self.open < self.low:
self.open = self.low
#print(self.end_time, self.open, self.close, self.high, self.low)
#print(klu.time, klu.open, klu.close, klu.high, klu.low)
def cal_fx(self):
if self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2:
#print(self.end_time, self.fx, self.macd, self.macdhist, len(self.klu_list))
if self.state == Chan_MACD_STATE.HIGH_EMPTY and self.macd > 0:
#print(self.end_time, self.state, self.macd, self.klc_fx_type)
self.klc_fx_type = Chan_KLC_FX.TOP6
if self.separate_div or self.continue_div:
self.klc_fx_type = Chan_KLC_FX.TOP7
if self.signal > 0 and self.macd > self.signal:
self.klc_fx_type = Chan_KLC_FX.TOP8
else:
if self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2:
if self.macdhist > 0 and self.macd < 0:
self.klc_fx_type = Chan_KLC_FX.BOTTOM5
return
if self.state == Chan_MACD_STATE.HIGH_EMPTY and self.macd < 0:
self.klc_fx_type = Chan_KLC_FX.BOTTOM6
#print(self.end_time, self.state, self.macd, self.klc_fx_type)
if self.separate_div or self.continue_div:
self.klc_fx_type = Chan_KLC_FX.BOTTOM7
if self.signal < 0 and self.macd < self.signal:
self.klc_fx_type = Chan_KLC_FX.BOTTOM8
def cal_bb_out(self):
for klu in self.klu_list:
if self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2:
#print(self.start_time, self.klc_fx_type, klu.high, klu.bb52upper, self.macd, self.next.macd, klu.time)
if self.high >= klu.bb52upper and klu.bb52upper > 0 and self.next and self.high > self.next.high:
self.klc_fx_type = Chan_KLC_FX.TOP4
print(self.end_time, self.klc_fx_type)
if self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2:
#print(self.start_time, self.klc_fx_type, klu.low, klu.bb52lower, self.macd, self.next.macd, klu.time)
if self.low <= klu.bb52lower and klu.bb52lower > 0 and self.next and self.low < self.next.low:
self.klc_fx_type = Chan_KLC_FX.BOTTOM4
print(self.end_time, self.klc_fx_type)
def cal_indicators(self):
for index in range(1, len(self.klu_list)):
self.volume += self.klu_list[index].volume
self.rsi += self.klu_list[index].rsi
self.volume_ratio += self.klu_list[index].volume_ratio
self.macdhist += self.klu_list[index].macdhist
self.ema26 += self.klu_list[index].ema26
self.ema24 += self.klu_list[index].ema24
self.ema52 += self.klu_list[index].ema52
self.ema104 += self.klu_list[index].ema104
self.ema156 += self.klu_list[index].ema156
self.ema208 += self.klu_list[index].ema208
self.ema13 += self.klu_list[index].ema13
self.ema7 += self.klu_list[index].ema7
self.bb2633upper += self.klu_list[index].bb2633upper
self.bb2633lower += self.klu_list[index].bb2633lower
self.bb2633middle += self.klu_list[index].bb2633middle
self.ma5 += self.klu_list[index].ma5
self.ema5 += self.klu_list[index].ema5
if self.ema_dir != self.klu_list[index].ema_dir:
self.ema_dir = 0
n = len(self.klu_list)
self.rsi = self.rsi / n
self.volume_ratio = self.volume_ratio / n
self.volume = self.volume / n
self.macdhist = self.macdhist / n
self.ema26 = self.ema26 / n
self.ema24 = self.ema24 / n
self.ema52 = self.ema52 / n
self.ema104 = self.ema104 / n
self.ema156 = self.ema156 / n
self.ema208 = self.ema208 / n
self.ema13 = self.ema13 / n
self.ema7 = self.ema7 / n
self.ma5 = self.ma5 / n
self.ema5 = self.ema5 / n
self.bb2633upper = self.bb2633upper / n
self.bb2633lower = self.bb2633lower / n
self.bb2633middle = self.bb2633middle / n
if len(self.klu_list) > 0:
self.macd = self.klu_list[-1].macd
self.signal = self.klu_list[-1].signal
self.body = abs(self.close - self.open)
self.upper_shadow = self.high - max(self.close, self.open)
self.lower_shadow = min(self.close, self.open) - self.low
self.body_ratio = self.body / self.open
self.upper_shadow_ratio = self.upper_shadow / self.open
self.lower_shadow_ratio = self.lower_shadow / self.open
self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR
self.range = self.high - self.low
def set_next(self, klc):
self.next = klc
def set_pre(self, klc):
self.pre = klc
def set_state(self, state):
self.state = state
def check_klu_included(self, klu):
if self.high >= klu.high:
# high大于,low小于,左包含
if self.low <= klu.low:
self.add_klu(klu=klu)
# gn>gn-1
if self.dir == Chan_KLINE_DIR.UP:
# UP -> max(dn)
self.low = klu.low
else:
# DOWN -> min(gn)
self.high = klu.high
#self.print(klu, "Z")
return True
# high大于,low大于,不包含
else:
# if self.low > klu.low
# high相等,右包含
if self.high == klu.high:
self.add_klu(klu=klu)
# UP -> max(gn)
if self.dir == Chan_KLINE_DIR.UP:
self.high = klu.high
else:
# DOWN -> min(dn)
self.low = klu.low
return True
else:
return False
else:
# high小于,low大于,右包含
if self.low >= klu.low:
self.add_klu(klu=klu)
# gn>gn-1
if self.dir == Chan_KLINE_DIR.UP:
# UP -> max(gn)
self.high = klu.high
else:
# DOWN -> min(dn)
self.low = klu.low
#self.print(klu, "Y")
return True
else:
# high小于,low小于,不包含
return False
def set_fx(self, fx: Chan_FX_TYPE):
self.fx = fx
def cal_invisible(self):
if self.fx == Chan_FX_TYPE.TOP:
if self.macdhist < 0 and self.macd > 0:
self.klc_fx_type = Chan_KLC_FX.TOP5
else:
if self.fx == Chan_FX_TYPE.BOTTOM:
if self.macdhist > 0 and self.macd < 0:
self.klc_fx_type = Chan_KLC_FX.BOTTOM5
def set_pre_fx(self):
if self.pre and self.pre.pre:
self.pre.fx = self.check_fx(self.pre.pre, self.pre)
def check_fx(self, k1, k2):
if k2.high > k1.high and k2.high > self.high:
return Chan_FX_TYPE.TOP
elif k2.low < k1.low and k2.low < self.low:
return Chan_FX_TYPE.BOTTOM
else:
return Chan_FX_TYPE.UNKNOWN
def set_bi(self, bi):
self.bi = bi
self.distance = self.index - bi.start_klc.index
#print(self.start_time, self.distance, bi.index, bi.dir)
+388
View File
@@ -0,0 +1,388 @@
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLU_TYPE, Chan_K_DIR, Chan_MACD_STATE, Chan_MACDHIST_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN, Chan_KLC_FX
class ChanKLU:
def __init__(self, time, open, high, low, close, volume):
# _time, _close, _open, _high, _low, _extra_info={}
self.kl_type = None
self.time = time
self.close = close
self.open = open
self.high = high
self.low = low
self.volume = volume
self.idx = 0
self.index = 0
self.macd = 0
self.signal = 0
self.macdhist = 0
self.klc = None
self.rsi = 0
self.volume_ratio = 0
self.bb52upper = 0
self.bb52lower = 0
# === 新增:K线类型 ===
self.kline_type = None # K线类型:大阳线、大阴线、小阳线、小阴线
self.pattern = Chan_KLU_PATTERN.UNKNOWN
# === 新增:实时分型相关属性 ===
self.pre = None # 前一根K线
self.next = None # 后一根K线
self.fx_type = Chan_FX_TYPE.UNKNOWN # 分型类型:0=无分型,1=顶分型,-1=底分型
self.fx_strength = 0 # 分型强度:0-100
self.fx_confirmed = False # 分型是否确认
self.klu_type = None
self.range = self.high - self.low
self.body = abs(self.close - self.open)
self.upper_shadow = self.high - max(self.close, self.open)
self.lower_shadow = min(self.close, self.open) - self.low
self.body_ratio = self.body / self.range if self.range != 0 else 0
self.upper_shadow_ratio = self.upper_shadow / self.body if self.body != 0 else float('inf')
self.lower_shadow_ratio = self.lower_shadow / self.body if self.body != 0 else float('inf')
self.exception = False
#self.cal_exception()
self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR
self.continue_div = 0
self.separate_div = 0
self.near0_return = 0
self.ema52 = 0
self.ema24 = 0
self.ema26 = 0
self.ema104 = 0
self.ema156 = 0
self.ema208 = 0
self.macd_slop = 0
self.signal_slop = 0
self.hist_slop = 0
self.hist_state = Chan_MACDHIST_STATE.UNKNOWN
self.macd_state = Chan_MACD_STATE.UNKNOWN
self.macd_hist_gap = 0
self.trend = Chan_PRICE_TREND.UNKNOWN
self.seg_histset_index = 0
# === 归零轴细化与模式/背离 ===
self.zero_axis = False # 是否归零轴(穿越或接近)
self.zero_axis_state = "none" # {none,crossing,near}
self.zero_axis_side = 0 # 1:above, -1:under, 0:none
self.zero_axis_score = 0 # 0-100 综合评分
self.mode1_touch_ema52 = False # 单边后触碰EMA52
self.mode2_fast_to_zero = False # 快线向零收敛
self.mode3_double_tf = False # 双周期归零(近似占位,由上层填充高周期确认)
self.mode3_dir = "none" # {long_strong_rebound, short_strong_rebound, none}
self.mode4_touch52_no_zero = False # 先触碰EMA52但黄白线未归零
self.div_type = "none" # {bearish, bullish, hidden_bearish, hidden_bullish, none}
self.div_score = 0.0 # 背离强度(0-100)
self.ema_dir = 0
self.get_ema_dir()
self.bb2633upper = 0
self.bb2633lower = 0
self.bb2633middle = 0
self.ma5 = 0
self.ema5 = 0
#print(self.open, self.close, self.high, self.low, self.candle_dir, self.strength)
def set_macd_state(self, state):
self.macd_state = state
def set_pattern(self, pattern):
self.pattern = pattern
def set_seg_histset_index(self, seg_histset_index):
self.seg_histset_index = seg_histset_index
#print(self.time, self.seg_histset_index)
def to_string(self):
return f"{self.time} {self.candle_dir} {self.pattern}"
def cal_exception(self):
if self.upper_shadow_ratio > 5 or self.lower_shadow_ratio > 5:
self.exception = True
#print(self.time, self.upper_shadow_ratio, self.lower_shadow_ratio, self.body, self.lower_shadow, self.upper_shadow, self.high, self.low, self.close, self.open)
#self.exception = False
def set_trend(self, trend):
self.trend = trend
def set_separate_div(self, separate_div):
self.separate_div = separate_div
if self.klc and self.klc.pre and self.klc.next:
fx = self.check_fx_dir(self.klc.pre, self.klc.next)
if fx == Chan_FX_TYPE.TOP:
if self.macdhist > 0:
self.separate_div = separate_div
else:
self.separate_div = 0
elif fx == Chan_FX_TYPE.BOTTOM:
if self.macdhist < 0:
self.separate_div = separate_div
else:
self.separate_div = 0
def check_fx_dir(self, pre, next):
fx = Chan_FX_TYPE.UNKNOWN
if pre.klc_fx_type == Chan_KLC_FX.TOP1 or pre.klc_fx_type == Chan_KLC_FX.TOP2 or next.klc_fx_type == Chan_KLC_FX.TOP1 or next.klc_fx_type == Chan_KLC_FX.TOP2 or self.klc.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc.klc_fx_type == Chan_KLC_FX.TOP2:
fx = Chan_FX_TYPE.TOP
elif pre.klc_fx_type == Chan_KLC_FX.BOTTOM1 or pre.klc_fx_type == Chan_KLC_FX.BOTTOM2 or next.klc_fx_type == Chan_KLC_FX.BOTTOM1 or next.klc_fx_type == Chan_KLC_FX.BOTTOM2 or self.klc.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc.klc_fx_type == Chan_KLC_FX.BOTTOM2:
fx = Chan_FX_TYPE.BOTTOM
return fx
def set_next(self, next):
self.next = next
#if self.fx_type != Chan_FX_TYPE.UNKNOWN and self.fx_strength > 1:
#print(self.index, self.time, self.fx_type, self.fx_confirmed, self.fx_strength)
def set_pre(self, pre):
self.pre = pre
def set_klc(self, klc):
self.klc = klc
def set_histset(self, histset):
"""设置HistSet关联"""
self.histset = histset
def set_seg(self, seg):
"""设置Seg关联"""
self.seg = seg
def set_unittf(self, unittf):
"""设置UnitTF关联"""
self.unittf = unittf
def set_idx(self, idx):
self.idx = idx
self.index = idx
def check_price_ema156(self):
if self.check_indicators():
if self.close > self.ema156:
return 1
elif self.close < self.ema156:
return -1
else:
return 0
else:
return 0
def get_ema_dir(self):
if self.check_indicators():
if self.ema24 > self.ema52 and self.ema52 > self.ema104 and self.ema104 > self.ema156:
self.ema_dir = 1
elif self.ema24 < self.ema52 and self.ema52 < self.ema104 and self.ema104 < self.ema156:
self.ema_dir = -1
else:
self.ema_dir = 0
def check_indicators(self):
if self.ema156 == 0:
return False
else:
return True
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
self.macdhist = float(item['macdhist']) if 'macdhist' in item and item['macdhist'] else 0
self.ema26 = float(item['ema26']) if 'ema26' in item and item['ema26'] else 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
self.ema104 = float(item['ema104']) if 'ema104' in item and item['ema104'] else 0
self.ema156 = float(item['ema156']) if 'ema156' in item and item['ema156'] 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
self.ema7 = float(item['ema7']) if 'ema7' in item and item['ema7'] else 0
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
self.bb52upper = float(item['bb52upper']) if 'bb52upper' in item and item['bb52upper'] else 0
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
self.bb2633lower = float(item['bb2633lower']) if 'bb2633lower' in item and item['bb2633lower'] else 0
self.bb2633middle = float(item['bb2633middle']) if 'bb2633middle' in item and item['bb2633middle'] else 0
self.ma5 = float(item['ma5']) if 'ma5' in item and item['ma5'] else 0
self.ema5 = float(item['ema5']) if 'ema5' in item and item['ema5'] else 0
def cal_macd_state(self):
# 按定义精简实现:优先级 CROSS0 > 位置(HIGH/HE/RETURN_ZERO) > NEAR0 > UNKNOWN
# 首条或缺前一根
if not hasattr(self, 'pre') or self.pre is None:
self.macd_state = Chan_MACD_STATE.START
return self.macd_state
# 基本校验
if (self.macd == 0 and self.signal == 0 and self.macdhist == 0) or self.ema52 == 0:
self.macd_state = Chan_MACD_STATE.UNKNOWN
return self.macd_state
# 归零轴判断
if self.signal > 0:
if self.macd < self.signal:
if 0 < self.low - self.ema52 < 100:
self.near0_return = 0
elif self.close > self.ema52 and self.low < self.ema52 and self.open > self.ema52:
self.near0_return = 0
elif self.close < self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close < self.ema52 and self.open < self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
else:
if self.macd > self.signal:
if 0 < self.ema52 - self.high < 100:
self.near0_return = 0
elif self.close < self.ema52 and self.high > self.ema52 and self.open < self.ema52:
self.near0_return = 0
elif self.close < self.ema52 and self.open > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.open < self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.high > self.ema52 and self.open >= self.ema52 and self.low < self.ema52:
self.near0_return = 0
elif self.close > self.ema52 and self.high > self.ema52 and self.low < self.ema52:
self.near0_return = 0
# 向上穿越EMA52 7
if self.close > self.ema52 and self.open < self.ema52:
self.near0_return = 0
# 向下穿越EMA52 8
elif self.close < self.ema52 and self.open > self.ema52:
self.near0_return = 0
if self.pre.near0_return == 7:
# 向上穿越后的一根价格再EMA52上方 9
if self.low > self.ema52 and self.close > self.open:
self.near0_return = 9
if self.pre.near0_return == 8:
# 向下穿越后的一根价格再EMA52下方 10
if self.high < self.ema52 and self.close < self.open:
self.near0_return = 10
# CROSS0 仅以 Signal 穿越零轴判定
if self.pre.signal >= 0 and self.signal < 0:
self.macd_state = Chan_MACD_STATE.CROSS0_DOWN
return self.macd_state
if self.pre.signal <= 0 and self.signal > 0:
self.macd_state = Chan_MACD_STATE.CROSS0_UP
return self.macd_state
# 穿零轴后的形态:缠绕/倒挂(基于前一状态为CROSS0_*)
if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP or self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN:
direction = 1 if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP else -1
hist_same_dir = (self.macdhist * direction) > 0
hist_decreasing = abs(self.macdhist) < abs(self.pre.macdhist)
lines_tight = abs(self.macd - self.signal) <= 12
# 倒挂:能量柱衰减且黄白线相对方向不利/出现反向能量释放
if hist_decreasing and (((self.macd - self.signal) * direction) < 0 or not hist_same_dir):
self.macd_state = Chan_MACD_STATE.CROSS_REV
return self.macd_state
# 缠绕/粘合:紧贴能量柱运行,无反向能量释放
if lines_tight and hist_same_dir:
self.macd_state = Chan_MACD_STATE.CROSS_OS
return self.macd_state
# 趋近零轴:细化 NEAR0_* 判定
NEAR0_EPS = 15
lines_near_zero = abs(self.macd) <= NEAR0_EPS or abs(self.signal) <= NEAR0_EPS
touch_52 = (self.ema52 != 0) and ((abs(self.close - self.ema52) <= NEAR0_EPS) or (self.low <= self.ema52 <= self.high))
touch_24 = (self.ema24 != 0) and ((abs(self.close - self.ema24) <= NEAR0_EPS) or (self.low <= self.ema24 <= self.high))
# 完美形态:白线接近零轴 + 价格触碰/轻破EMA52 + 黄线不穿零轴
if abs(self.macd) <= NEAR0_EPS and touch_52 and (not (self.pre.signal >= 0 and self.signal < 0)) and (not (self.pre.signal <= 0 and self.signal > 0)):
self.macd_state = Chan_MACD_STATE.NEAR0_PERFECT
#self.near0_return = 1
return self.macd_state
# EMA24 附近
if lines_near_zero and touch_24:
self.macd_state = Chan_MACD_STATE.NEAR0_24
#self.near0_return = 2
return self.macd_state
# EMA52 附近
if lines_near_zero and touch_52:
self.macd_state = Chan_MACD_STATE.NEAR0_52
#self.near0_return = 3
return self.macd_state
# 白线接近零轴但价格未至EMA52
if abs(self.macd) <= NEAR0_EPS and not touch_52:
self.macd_state = Chan_MACD_STATE.NEAR0_DIFF
#self.near0_return = 4
return self.macd_state
# 一般近零轴
if lines_near_zero or touch_52:
self.macd_state = Chan_MACD_STATE.NEAR0
#self.near0_return = 5
return self.macd_state
# 穿零轴后离开零轴
if self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP and ((self.macd >= self.pre.macd and self.signal >= self.pre.signal) or (abs(self.macdhist) >= abs(self.pre.macdhist))):
self.macd_state = Chan_MACD_STATE.UP
return self.macd_state
if self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN and ((self.macd <= self.pre.macd and self.signal <= self.pre.signal) or (abs(self.macdhist) >= abs(self.pre.macdhist))):
self.macd_state = Chan_MACD_STATE.DOWN
return self.macd_state
if self.pre.macd_state == Chan_MACD_STATE.UP and self.macd > self.pre.macd and self.signal > self.pre.signal:
self.macd_state = Chan_MACD_STATE.UP
return self.macd_state
if self.pre.macd_state == Chan_MACD_STATE.DOWN and self.macd < self.pre.macd and self.signal < self.pre.signal:
self.macd_state = Chan_MACD_STATE.DOWN
return self.macd_state
# 趋势兜底:强势同步上行/下行直接进入 UP/DOWN
if self.macd > 0 and self.signal > 0 and (self.macd >= self.pre.macd and self.signal >= self.pre.signal):
self.macd_state = Chan_MACD_STATE.UP
return self.macd_state
if self.macd < 0 and self.signal < 0 and (self.macd <= self.pre.macd and self.signal <= self.pre.signal):
self.macd_state = Chan_MACD_STATE.DOWN
return self.macd_state
# 峰值:白线高位出现局部顶
if hasattr(self.pre, 'pre') and self.pre and self.pre.pre and self.macd > 0:
if self.pre.macd > self.pre.pre.macd and self.pre.macd > self.macd:
self.macd_state = Chan_MACD_STATE.PEAK
return self.macd_state
# 高位状态的位置状态, 高位,高位空,归零轴
if (self.pre.macd_state == Chan_MACD_STATE.UP or self.pre.macd_state == Chan_MACD_STATE.HIGH or self.pre.macd_state == Chan_MACD_STATE.RZ_UP or self.pre.macd_state == Chan_MACD_STATE.PEAK or self.pre.macd_state == Chan_MACD_STATE.HIGH_EMPTY) and self.macd > 0:
# 高位空(正区间):能量柱衰减且黄白线间距较大
if abs(self.pre.macdhist) > 0 and abs(self.macdhist) < abs(self.pre.macdhist) and abs(self.macd - self.signal) > 5:
self.macd_state = Chan_MACD_STATE.HIGH_EMPTY
return self.macd_state
if abs(self.pre.macd - self.macd) < 10:
self.macd_state = Chan_MACD_STATE.HIGH
return self.macd_state
else:
if self.macd > self.pre.macd and self.signal > self.pre.signal:
self.macd_state = Chan_MACD_STATE.UP
return self.macd_state
elif self.macd < self.pre.macd and self.signal < self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
if (self.pre.macd_state == Chan_MACD_STATE.DOWN or self.pre.macd_state == Chan_MACD_STATE.HIGH or self.pre.macd_state == Chan_MACD_STATE.RZ_DOWN or self.pre.macd_state == Chan_MACD_STATE.PEAK or self.pre.macd_state == Chan_MACD_STATE.HIGH_EMPTY) and self.macd < 0:
# 高位空(负区间):能量柱衰减且黄白线间距较大
if abs(self.pre.macdhist) > 0 and abs(self.macdhist) < abs(self.pre.macdhist) and abs(self.macd - self.signal) > 5:
self.macd_state = Chan_MACD_STATE.HIGH_EMPTY
return self.macd_state
if abs(self.pre.macd - self.macd) < 10:
self.macd_state = Chan_MACD_STATE.HIGH
return self.macd_state
else:
if self.macd > self.pre.macd and self.signal > self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
elif self.macd < self.pre.macd and self.signal < self.pre.signal:
self.macd_state = Chan_MACD_STATE.DOWN
return self.macd_state
# 离开0轴开始上涨或者下跌阶段,高位之前的
if self.macd > 0 and self.pre:
if (self.pre.macd_state == Chan_MACD_STATE.NEAR0 or self.pre.macd_state == Chan_MACD_STATE.RZ_UP or self.pre.macd_state == Chan_MACD_STATE.CROSS0_UP) and (self.signal > self.pre.signal or self.close > self.ema52):
self.macd_state = Chan_MACD_STATE.RZ_UP
return self.macd_state
elif self.macd < 0 and self.pre:
if (self.pre.macd_state == Chan_MACD_STATE.NEAR0 or self.pre.macd_state == Chan_MACD_STATE.RZ_DOWN or self.pre.macd_state == Chan_MACD_STATE.CROSS0_DOWN) and (self.signal < self.pre.signal or self.close < self.ema52):
self.macd_state = Chan_MACD_STATE.RZ_DOWN
return self.macd_state
# 归零轴走势
if self.pre.macd_state == Chan_MACD_STATE.RETURN_ZERO:
if self.macd > 0:
if self.pre.macd > self.macd or abs(self.macdhist) <= abs(self.pre.macdhist) or self.signal <= self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
else:
if self.pre.macd < self.macd or abs(self.macdhist) <= abs(self.pre.macdhist) or self.signal >= self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
# 从 NEAR0 收敛到零轴的归零轴承接(正负两侧)
if self.pre.macd_state == Chan_MACD_STATE.NEAR0:
# 正区间朝零轴收敛
if self.macd > 0 and self.pre.macd > 0 and self.macd <= self.pre.macd and self.signal <= self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
# 负区间朝零轴收敛
if self.macd < 0 and self.pre.macd < 0 and self.macd >= self.pre.macd and self.signal >= self.pre.signal:
self.macd_state = Chan_MACD_STATE.RETURN_ZERO
return self.macd_state
# 其余情况
if self.pre.macd_state == Chan_MACD_STATE.UNKNOWN:
if self.macd > 0 and self.close > self.ema52 and self.pre.pre and (self.pre.pre.macd_state == Chan_MACD_STATE.UP or self.pre.pre.macd_state == Chan_MACD_STATE.RZ_UP):
self.macd_state = self.pre.pre.macd_state
return self.macd_state
elif self.macd < 0 and self.close < self.ema52 and self.pre.pre and (self.pre.pre.macd_state == Chan_MACD_STATE.DOWN or self.pre.pre.macd_state == Chan_MACD_STATE.RZ_DOWN):
self.macd_state = self.pre.pre.macd_state
return self.macd_state
else:
self.macd_state = Chan_MACD_STATE.UNKNOWN
return self.macd_state
return self.macd_state
+91
View File
@@ -0,0 +1,91 @@
import copy
from typing import Dict, Optional
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_BI_DIR
import chanlun.core.ChanKLU as ChanKLU
from chanlun.core.ChanBI import ChanBI
class ChanSBI():
def __init__(self, start_bi: ChanBI, index, dir=Chan_BI_DIR.UP):
self.start_bi = start_bi
self.end_bi = None
self.index = index
self.dir = dir
self.high = start_bi.high
self.low = start_bi.low
self.pre = None
self.next = None
self.fx = Chan_FX_TYPE.UNKNOWN
self.bi_list = []
self.bi_list.append(start_bi)
self.has_fx_gap = False
def set_fx(self, fx):
self.fx = fx
def set_end_bi(self, bi):
self.end_bi = bi
def set_pre(self, sbi):
self.pre = sbi
def set_next(self, sbi):
self.next = sbi
def add_bi(self, bi):
self.bi_list.append(bi)
def check_fx(self):
if self.pre and self.next:
#print(self.pre.start_bi.start_time, self.start_bi.start_time, self.end_bi.end_time, self.next.start_bi.start_time, self.pre.high, self.high, self.next.high, self.pre.low, self.low, self.next.low, self.dir)
if self.high > self.pre.high and self.high > self.next.high:
self.fx = Chan_FX_TYPE.TOP
#print(self.start_bi.start_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.fx)
if self.low > self.pre.high:
self.has_fx_gap = True
#print(self.start_bi.start_time, self.end_bi.end_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.dir, self.has_fx_gap, self.fx)
return Chan_FX_TYPE.TOP
else:
if self.low < self.pre.low and self.low < self.next.low:
self.fx = Chan_FX_TYPE.BOTTOM
#print(self.start_bi.start_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.fx)
if self.high < self.pre.low:
self.has_fx_gap = True
#print(self.start_bi.start_time, self.end_bi.end_time, self.pre.start_bi.start_time, self.next.start_bi.start_time, self.dir, self.has_fx_gap, self.fx)
return Chan_FX_TYPE.BOTTOM
return Chan_FX_TYPE.UNKNOWN
def check_seg_bi_broken(self):
broken = False
if self.fx == Chan_FX_TYPE.TOP:
if self.next.low < self.pre.high:
broken = True
elif self.fx == Chan_FX_TYPE.BOTTOM:
if self.next.high > self.pre.low:
broken = True
return broken
def check_bi_included(self, bi):
included = False
if self.high > bi.high:
# high大于,low小于,左包含
if self.low < bi.low:
included = True
# high大于,low大于,不包含
else:
# if self.low > bi.low
# high相等,右包含
included = False
else:
included = False
# high小于,low大于,右包含
#if self.low > bi.low:
#included = True
if included:
if self.pre:
if self.high > self.pre.high and self.low < self.pre.low:
included = True
if included:
self.add_bi(bi)
# gn>gn-1
if self.dir == Chan_BI_DIR.DOWN:
# UP -> max(dn)
self.low = bi.low
else:
# DOWN -> min(gn)
self.high = bi.high
#self.print(bi, "Z")
#print(self.start_bi.start_time, bi.start_time, included)
return included

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