Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0fa9ac375 | ||
|
|
6c627f009a | ||
|
|
dbb6202325 | ||
|
|
efad2bb333 | ||
|
|
2964d6f230 | ||
|
|
7991a6b2bf | ||
|
|
276481e02c | ||
|
|
1e60ab3bfa | ||
|
|
d3188ca83c | ||
|
|
ac6be80278 | ||
|
|
081a57a90e | ||
|
|
df27b4dde8 | ||
|
|
9f1e7361b6 | ||
|
|
6b0f3b5837 | ||
|
|
74dec4e50b | ||
|
|
e2e45bc1bc | ||
|
|
f2e77e1bdb | ||
|
|
b31215057e | ||
|
|
2e905e7238 | ||
|
|
02a52c04dd | ||
|
|
19c8f86862 | ||
|
|
ffe7074fef | ||
|
|
7b91f459d7 | ||
|
|
3c72aa1310 | ||
|
|
8d916371e2 | ||
|
|
7e19c9858e | ||
|
|
efb721b39f | ||
|
|
7813e319b4 | ||
|
|
f391020f78 | ||
|
|
0ba5b3bd71 | ||
|
|
50a609f7b9 | ||
|
|
48e69179b3 | ||
|
|
71951019fb | ||
|
|
34040575c1 | ||
|
|
e1116edb7b | ||
|
|
8bc23c0507 | ||
|
|
c75d5e11fc | ||
|
|
8eb50e3eae | ||
|
|
42296ef971 | ||
|
|
a84a80cb62 | ||
|
|
63787b173a | ||
|
|
07f079067f | ||
|
|
ae4c79c133 | ||
|
|
743c5d342e | ||
|
|
78d02cf2ef | ||
|
|
cf9097a540 | ||
|
|
881c9d5eac | ||
|
|
b1cbdca707 | ||
|
|
5ad761fad4 | ||
|
|
9eae12f07d | ||
|
|
9b876c45ed | ||
|
|
91148a648a | ||
|
|
5dc0c4cffd | ||
|
|
f0ea6a6065 | ||
|
|
bc085171f4 | ||
|
|
050ebeb849 | ||
|
|
ca2cf86138 | ||
|
|
0dd8f8a585 | ||
|
|
ebcb3dce73 | ||
|
|
d8069e977f | ||
|
|
3f68a8305a | ||
|
|
deeea55237 | ||
|
|
5ab69c2a64 | ||
|
|
425d513a37 | ||
|
|
8ff1515f8b | ||
|
|
d8e3cdd9e9 | ||
|
|
04d8f73b94 | ||
|
|
7815eada00 |
@@ -37,3 +37,10 @@ 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/
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,174 +1,3 @@
|
||||
import warnings
|
||||
|
||||
# 抑制 Docker 内 technical.util 的 fillna/ffill/bfill 的 pandas FutureWarning(pandas 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""兼容 shim — 请优先 from chanlun import ..."""
|
||||
from chanlun.analysis.ChanPivotClassifier import * # noqa: F403
|
||||
@@ -0,0 +1,2 @@
|
||||
"""兼容 shim — 请优先 from chanlun import ..."""
|
||||
from chanlun.analysis.ChanPivotMonitor import * # noqa: F403
|
||||
+2
-91
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""兼容 shim — 请优先 from chanlun import ..."""
|
||||
from chanlun.analysis.ChanZone import * # noqa: F403
|
||||
+2
-7
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,196 @@
|
||||
"""威科夫分析入口:Cycle → Phase → Event → VP + Live(MULTI-CYCLE / LIVE-STRUCTURE)。
|
||||
|
||||
range.py 只产 TradingRange;Confirmed 走 events.py;Live 走 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"),
|
||||
}
|
||||
@@ -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]]:
|
||||
"""
|
||||
返回 bias、events、volume_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]]:
|
||||
"""
|
||||
按威科夫事件锚点切分 A–E(启发式)。
|
||||
|
||||
吸筹:A停止 → B筑底 → C测试(Spring) → D拉升(SOS…LPS) → E离开
|
||||
派发:A停止 → B筑顶 → C测试(UTAD) → D派发(SOW…LPSY) → 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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
"""交易区间检测:仅负责 TradingRange(起止/高低/结构分)。
|
||||
|
||||
WYCKOFF-MULTI-CYCLE-001:Phase/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_offset:slice 相对父 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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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段可能升级
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" # 初始状态
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,197 @@
|
||||
import copy
|
||||
from typing import Dict, Optional
|
||||
|
||||
from chanlun.core.ChanEnum import Chan_FX_TYPE, Chan_KLINE_DIR, Chan_SEG_DIR, Chan_BI_DIR, Chan_ZS_DIR
|
||||
import chanlun.core.ChanCTime as ChanCTime
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.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]
|
||||
#print(bi.end_time, bi.next,"UP SEG BI ZS Index")
|
||||
if bi.next == None or bi.next.next == None:
|
||||
if last_zs and (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)
|
||||
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:
|
||||
#print(bi.end_time, "UP SEG BI ZS End")
|
||||
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:
|
||||
if last_zs and (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)
|
||||
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
|
||||
@@ -0,0 +1,109 @@
|
||||
from typing import Dict, Optional
|
||||
|
||||
import chanlun.core.ChanKLC as ChanKLC
|
||||
import chanlun.core.ChanSEG as ChanSEG
|
||||
import chanlun.core.ChanCTime as ChanCTime
|
||||
from chanlun.core.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 # 由外部设置
|
||||
@@ -0,0 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
from chanlun.core.ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR, Chan_MACDUNITTF_DIR, Chan_MACDUNITTF_TYPE
|
||||
from chanlun.indicators.ChanMACDSeg import ChanMACDSeg
|
||||
from chanlun.indicators.ChanMACDUnitTF import ChanMACDUnitTF
|
||||
from chanlun.indicators.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
|
||||
signal = 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
|
||||
@@ -0,0 +1,117 @@
|
||||
from chanlun.core.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)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from chanlun.core.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
|
||||
@@ -0,0 +1,141 @@
|
||||
from chanlun.core.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))
|
||||
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
from chanlun.core.ChanEnum import (
|
||||
Chan_BI_DIR,
|
||||
Chan_BSP_DIR,
|
||||
Chan_BSP_TYPE,
|
||||
Chan_FX_TYPE,
|
||||
Chan_K_DIR,
|
||||
Chan_KLC_FX,
|
||||
Chan_KLC_STATE,
|
||||
Chan_KLINE_DIR,
|
||||
Chan_KLU_PATTERN,
|
||||
Chan_PRICE_TREND,
|
||||
Chan_SEG_DIR,
|
||||
Chan_ZS_DIR,
|
||||
)
|
||||
from chanlun.core.ChanKLC import ChanKLC
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
|
||||
|
||||
class BiBuilderMixin:
|
||||
def cal_trend(self, klc_list):
|
||||
"""
|
||||
基于价格与EMA24/EMA52的位置关系、以及MACD/Signal/Hist的方向,
|
||||
为每个KLC打上趋势标签:'UP' / 'DOWN' / 'FLAT'。
|
||||
仅设置 klc.trend,不影响其它字段。
|
||||
"""
|
||||
if not klc_list:
|
||||
return klc_list
|
||||
last_trend = Chan_PRICE_TREND.UNKNOWN
|
||||
# 趋势延续性:参考近 N 根已完成的KLC
|
||||
lookback_n = 5
|
||||
prev_klcs = []
|
||||
for klc in klc_list:
|
||||
price = getattr(klc, 'close', None)
|
||||
ema24 = getattr(klc, 'ema24', None)
|
||||
ema52 = getattr(klc, 'ema52', None)
|
||||
macd_raw = getattr(klc, 'macd', None)
|
||||
signal_raw = getattr(klc, 'signal', None)
|
||||
hist_raw = getattr(klc, 'macdhist', None)
|
||||
macd = macd_raw if macd_raw is not None else 0
|
||||
signal = signal_raw if signal_raw is not None else 0
|
||||
hist = hist_raw if hist_raw is not None else 0
|
||||
rsi = getattr(klc, 'rsi', None)
|
||||
macd_ready = macd_raw is not None and signal_raw is not None
|
||||
hist_ready = hist_raw is not None
|
||||
trend = Chan_PRICE_TREND.UNKNOWN
|
||||
score = 0
|
||||
try:
|
||||
# 有效性
|
||||
price_valid = price is not None and price != 0
|
||||
ema24_valid = ema24 is not None and ema24 != 0
|
||||
ema52_valid = ema52 is not None and ema52 != 0
|
||||
# 多因子投票
|
||||
|
||||
# 1) 均线结构 + 价位
|
||||
if ema24_valid or ema52_valid:
|
||||
ma_votes = 0
|
||||
if ema24_valid and ema52_valid:
|
||||
ma_votes += 1 if ema24 > ema52 else -1
|
||||
if price_valid and ema24_valid:
|
||||
ma_votes += 1 if price > ema24 else 0
|
||||
if price_valid and ema52_valid:
|
||||
ma_votes += 1 if price > ema52 else -1
|
||||
# 限幅,避免相关因子重复计分
|
||||
score += max(-2, min(2, ma_votes))
|
||||
# 2) MACD结构
|
||||
if macd_ready:
|
||||
score += 1 if macd >= signal else -1
|
||||
if hist_ready and hist != 0:
|
||||
score += 1 if hist > 0 else -1
|
||||
# 3) 动量与均线差分斜率
|
||||
pre = getattr(klc, 'pre', None)
|
||||
pre_hist = getattr(pre, 'macdhist', None) if pre else None
|
||||
if pre:
|
||||
pre_close = getattr(pre, 'close', None)
|
||||
if price_valid and pre_close is not None:
|
||||
score += 1 if price >= pre_close else -1
|
||||
pre_ema24 = getattr(pre, 'ema24', None)
|
||||
pre_ema52 = getattr(pre, 'ema52', None)
|
||||
if ema24_valid and ema52_valid and pre_ema24 not in (None, 0) and pre_ema52 not in (None, 0):
|
||||
spread_now = ema24 - ema52
|
||||
spread_pre = pre_ema24 - pre_ema52
|
||||
score += 1 if spread_now >= spread_pre else -1
|
||||
# 3.1) MACD柱体动量趋势:考虑 macdhist 的斜率与过零
|
||||
if hist_ready and pre_hist is not None:
|
||||
# 柱体斜率:上升加分,下降减分
|
||||
if hist > pre_hist:
|
||||
score += 1
|
||||
elif hist < pre_hist:
|
||||
score -= 1
|
||||
# 过零加权:负转正更偏多,正转负更偏空
|
||||
if pre_hist < 0 and hist > 0:
|
||||
score += 1
|
||||
elif pre_hist > 0 and hist < 0:
|
||||
score -= 1
|
||||
# 3.2) EMA52 突破/跌破加权
|
||||
if ema52_valid and price_valid and pre_close is not None and pre_ema52 not in (None, 0):
|
||||
# 看多突破:从均线下方上破且动量配合
|
||||
if pre_close <= pre_ema52 and price > ema52 and (hist is None or pre_hist is None or hist >= pre_hist):
|
||||
score += 1
|
||||
# 看空跌破:从均线上方下破且动量配合
|
||||
if pre_close >= pre_ema52 and price < ema52 and (hist is None or pre_hist is None or hist <= pre_hist):
|
||||
score -= 1
|
||||
# 3.3) EMA52 支撑/阻力触碰(非强穿越)
|
||||
if ema52_valid and price_valid:
|
||||
low_v = getattr(klc, 'low', None)
|
||||
high_v = getattr(klc, 'high', None)
|
||||
if low_v is not None and high_v is not None and ema52 not in (None, 0):
|
||||
# 触碰容差(相对EMA52的0.15%)
|
||||
touch_tol = 0.0015
|
||||
# 作为支撑:收盘在上,最低靠近EMA52
|
||||
near_support_touch = (price > ema52) and (abs(low_v - ema52) / abs(ema52) <= touch_tol)
|
||||
# 作为阻力:收盘在下,最高靠近EMA52
|
||||
near_resistance_touch = (price < ema52) and (abs(high_v - ema52) / abs(ema52) <= touch_tol)
|
||||
if near_support_touch:
|
||||
# 若动量不弱,则更偏多
|
||||
score += 1 if (hist is None or pre_hist is None or hist >= pre_hist) else 0
|
||||
if near_resistance_touch:
|
||||
# 若动量不强,则更偏空
|
||||
score -= 1 if (hist is None or pre_hist is None or hist <= pre_hist) else 0
|
||||
# 3.4) 多次对 EMA52 的"拒绝"配合 MACD 逆向:易形成压/支并反向
|
||||
# 统计近窗口内的上/下拒绝次数:
|
||||
# - 上拒绝:价格位于 EMA52 下方,最高触及/越过 EMA52 但收盘仍在下方
|
||||
# - 下拒绝:价格位于 EMA52 上方,最低触及/跌破 EMA52 但收盘仍在上方
|
||||
recent_up_rejects = 0
|
||||
recent_down_rejects = 0
|
||||
if ema52_valid:
|
||||
window_rej = prev_klcs[-lookback_n:] if len(prev_klcs) > 0 else []
|
||||
rej_tol = 0.0015
|
||||
for wk in window_rej:
|
||||
wk_close = getattr(wk, 'close', None)
|
||||
wk_ema52 = getattr(wk, 'ema52', None)
|
||||
wk_high = getattr(wk, 'high', None)
|
||||
wk_low = getattr(wk, 'low', None)
|
||||
if wk_close is None or wk_ema52 in (None, 0):
|
||||
continue
|
||||
# 上拒绝(阻力):下方多次试图上破但未站上
|
||||
if wk_close < wk_ema52 and wk_high is not None:
|
||||
if wk_high >= wk_ema52 or abs(wk_high - wk_ema52) / abs(wk_ema52) <= rej_tol:
|
||||
recent_up_rejects += 1
|
||||
# 下拒绝(支撑):上方多次试图下破但未跌破
|
||||
if wk_close > wk_ema52 and wk_low is not None:
|
||||
if wk_low <= wk_ema52 or abs(wk_low - wk_ema52) / abs(wk_ema52) <= rej_tol:
|
||||
recent_down_rejects += 1
|
||||
# 定义 MACD 的方向偏好
|
||||
macd_bias_up = macd_ready and (macd >= signal) and (not hist_ready or pre_hist is None or hist >= pre_hist)
|
||||
macd_bias_down = macd_ready and (macd <= signal) and (not hist_ready or pre_hist is None or hist <= pre_hist)
|
||||
# 若多次上拒绝且 MACD 偏空,则更偏向下行;若多次下拒绝且 MACD 偏多,则更偏向上行
|
||||
if recent_up_rejects >= 2 and macd_bias_down:
|
||||
score -= 2
|
||||
if recent_down_rejects >= 2 and macd_bias_up:
|
||||
score += 2
|
||||
# 4) RSI 辅助
|
||||
if rsi is not None:
|
||||
if rsi >= 55:
|
||||
score += 1
|
||||
elif rsi <= 45:
|
||||
score -= 1
|
||||
# 5) 指标未就绪回退(EMA/MACD缺失时,用动量与RSI辅助,延续趋势)
|
||||
has_full_ind = ema24_valid and ema52_valid and not (macd == 0 and signal == 0 and hist == 0)
|
||||
if not has_full_ind:
|
||||
# 仅根据价动量/RSI做轻量判断,默认延续 last_trend,除非出现强反向
|
||||
strong_up = False
|
||||
strong_down = False
|
||||
pre = getattr(klc, 'pre', None)
|
||||
if pre:
|
||||
pre_close = getattr(pre, 'close', None)
|
||||
if price_valid and pre_close is not None:
|
||||
strong_up = (price >= pre_close)
|
||||
strong_down = (price < pre_close)
|
||||
if rsi is not None:
|
||||
if rsi >= 60:
|
||||
strong_up = True
|
||||
elif rsi <= 40:
|
||||
strong_down = True
|
||||
if last_trend == Chan_PRICE_TREND.UP and not strong_down:
|
||||
trend = Chan_PRICE_TREND.UP
|
||||
elif last_trend == Chan_PRICE_TREND.DOWN and not strong_up:
|
||||
trend = Chan_PRICE_TREND.DOWN
|
||||
else:
|
||||
trend = Chan_PRICE_TREND.UP if strong_up and not strong_down else (Chan_PRICE_TREND.DOWN if strong_down and not strong_up else Chan_PRICE_TREND.FLAT)
|
||||
else:
|
||||
# 6) 震荡过滤(仅当极近EMA52且MACD贴合时判作震荡)
|
||||
near_flat = False
|
||||
if price_valid and ema52_valid:
|
||||
near_ema52 = abs(price - ema52) / abs(ema52) <= 0.0005 # 0.05%
|
||||
if macd_ready:
|
||||
macd_scale = max(abs(macd), abs(signal), 1e-6)
|
||||
near_macd = abs(macd - signal) / macd_scale <= 0.05
|
||||
else:
|
||||
near_macd = False
|
||||
near_flat = near_ema52 and near_macd
|
||||
# 7) 动态阈值 + 趋势记忆(更强粘滞:趋势中容忍小幅反分)
|
||||
# 引入过去 N 根KLC 的趋势延续性来动态调整翻转阈值,并结合 EMA52 支撑/阻力触碰强化门槛
|
||||
force_flip_down = False
|
||||
force_flip_up = False
|
||||
if near_flat:
|
||||
trend = Chan_PRICE_TREND.FLAT
|
||||
else:
|
||||
# 计算过去窗口的趋势一致性
|
||||
window = prev_klcs[-lookback_n:] if len(prev_klcs) > 0 else []
|
||||
persist_up = 0
|
||||
persist_down = 0
|
||||
for wk in window:
|
||||
if getattr(wk, 'trend', None) == Chan_PRICE_TREND.UP:
|
||||
persist_up += 1
|
||||
elif getattr(wk, 'trend', None) == Chan_PRICE_TREND.DOWN:
|
||||
persist_down += 1
|
||||
persist_ratio_up = (persist_up / len(window)) if len(window) > 0 else 0
|
||||
persist_ratio_down = (persist_down / len(window)) if len(window) > 0 else 0
|
||||
# 基准阈值
|
||||
down_flip_threshold = -2
|
||||
up_flip_threshold = 2
|
||||
# 若最近多为UP,则从UP翻转需更强反向信号;同理对DOWN
|
||||
if last_trend == Chan_PRICE_TREND.UP and persist_ratio_up >= 0.6:
|
||||
down_flip_threshold = -3
|
||||
elif last_trend == Chan_PRICE_TREND.DOWN and persist_ratio_down >= 0.6:
|
||||
up_flip_threshold = 3
|
||||
# EMA52 触碰强化门槛:UP时若出现支撑触碰,下翻更难;DOWN时若出现阻力触碰,上翻更难
|
||||
if ema52_valid and price_valid:
|
||||
low_v = getattr(klc, 'low', None)
|
||||
high_v = getattr(klc, 'high', None)
|
||||
if low_v is not None and high_v is not None and ema52 not in (None, 0):
|
||||
touch_tol = 0.0015
|
||||
near_support_touch = (price > ema52) and (abs(low_v - ema52) / abs(ema52) <= touch_tol)
|
||||
near_resistance_touch = (price < ema52) and (abs(high_v - ema52) / abs(ema52) <= touch_tol)
|
||||
if last_trend == Chan_PRICE_TREND.UP and near_support_touch:
|
||||
# 强化维持UP:进一步降低向下翻转阈值
|
||||
down_flip_threshold = min(down_flip_threshold - 1, -3)
|
||||
if last_trend == Chan_PRICE_TREND.DOWN and near_resistance_touch:
|
||||
# 强化维持DOWN:进一步提高向上翻转阈值
|
||||
up_flip_threshold = max(up_flip_threshold + 1, 3)
|
||||
# 7.1) 复合拐头信号:MACD/Signal 同向拐头 + hist 连续减弱 + 多次未能越过 EMA52
|
||||
pre_macd = getattr(pre, 'macd', None) if pre else None
|
||||
pre_signal = getattr(pre, 'signal', None) if pre else None
|
||||
macd_slope = (macd - pre_macd) if (macd_ready and pre_macd is not None) else 0
|
||||
signal_slope = (signal - pre_signal) if (macd_ready and pre_signal is not None) else 0
|
||||
# hist 连续减弱(绝对值缩小)
|
||||
hist_seq = []
|
||||
for wk in prev_klcs[-2:]:
|
||||
val = getattr(wk, 'macdhist', None)
|
||||
if val is not None:
|
||||
hist_seq.append(val)
|
||||
if hist is not None:
|
||||
hist_seq.append(hist)
|
||||
weaken_steps = 0
|
||||
for i in range(1, len(hist_seq)):
|
||||
if abs(hist_seq[i]) < abs(hist_seq[i-1]):
|
||||
weaken_steps += 1
|
||||
# 近窗口对 EMA52 的"未能站上/跌破"统计(放宽窗口与条件)
|
||||
window_ema = prev_klcs[-4:] if len(prev_klcs) > 0 else []
|
||||
no_up_break = False
|
||||
no_down_break = False
|
||||
if ema52_valid:
|
||||
# 未能有效上破:最近若干根收盘大多数不在 EMA52 上方,且高点多次触及/接近
|
||||
cnt_touch_up = 0
|
||||
cnt_close_above = 0
|
||||
for wk in window_ema:
|
||||
wk_close = getattr(wk, 'close', None)
|
||||
wk_high = getattr(wk, 'high', None)
|
||||
wk_ema = getattr(wk, 'ema52', None)
|
||||
if wk_close is not None and wk_ema not in (None, 0):
|
||||
if wk_close > wk_ema:
|
||||
cnt_close_above += 1
|
||||
if wk_high is not None and (wk_high >= wk_ema or abs(wk_high - wk_ema) / abs(wk_ema) <= 0.0015):
|
||||
cnt_touch_up += 1
|
||||
no_up_break = (cnt_close_above <= 1 and cnt_touch_up >= 1 and price <= ema52)
|
||||
# 未能有效下破:最近若干根收盘大多数不在 EMA52 下方,且低点多次触及/接近
|
||||
cnt_touch_down = 0
|
||||
cnt_close_below = 0
|
||||
for wk in window_ema:
|
||||
wk_close = getattr(wk, 'close', None)
|
||||
wk_low = getattr(wk, 'low', None)
|
||||
wk_ema = getattr(wk, 'ema52', None)
|
||||
if wk_close is not None and wk_ema not in (None, 0):
|
||||
if wk_close < wk_ema:
|
||||
cnt_close_below += 1
|
||||
if wk_low is not None and (wk_low <= wk_ema or abs(wk_low - wk_ema) / abs(wk_ema) <= 0.0015):
|
||||
cnt_touch_down += 1
|
||||
no_down_break = (cnt_close_below <= 1 and cnt_touch_down >= 1 and price >= ema52)
|
||||
# 若当前为UP趋势,出现明显拐头+hist减弱+未能上破EMA52,则加速看空
|
||||
if last_trend == Chan_PRICE_TREND.UP and macd_slope < 0 and signal_slope < 0 and weaken_steps >= 1 and no_up_break and macd_bias_down:
|
||||
score -= 3
|
||||
down_flip_threshold = max(down_flip_threshold, 0)
|
||||
force_flip_down = True
|
||||
# 若当前为DOWN趋势,出现明显拐头+hist减弱+未能下破EMA52,则加速看多
|
||||
if last_trend == Chan_PRICE_TREND.DOWN and macd_slope > 0 and signal_slope > 0 and weaken_steps >= 1 and no_down_break and macd_bias_up:
|
||||
score += 3
|
||||
up_flip_threshold = min(up_flip_threshold, 0)
|
||||
force_flip_up = True
|
||||
# 多次对 EMA52 的拒绝配合 MACD 逆向:加速反向翻转(降低相反方向阈值)
|
||||
if recent_up_rejects >= 2 and macd_bias_down:
|
||||
# 从 UP 向 DOWN 的翻转更容易
|
||||
down_flip_threshold = max(down_flip_threshold, -1)
|
||||
if recent_down_rejects >= 2 and macd_bias_up:
|
||||
# 从 DOWN 向 UP 的翻转更容易
|
||||
up_flip_threshold = min(up_flip_threshold, 1)
|
||||
if force_flip_down:
|
||||
trend = Chan_PRICE_TREND.DOWN
|
||||
elif force_flip_up:
|
||||
trend = Chan_PRICE_TREND.UP
|
||||
elif last_trend == Chan_PRICE_TREND.UP:
|
||||
if score <= down_flip_threshold:
|
||||
trend = Chan_PRICE_TREND.DOWN
|
||||
else:
|
||||
trend = Chan_PRICE_TREND.UP
|
||||
elif last_trend == Chan_PRICE_TREND.DOWN:
|
||||
if score >= up_flip_threshold:
|
||||
trend = Chan_PRICE_TREND.UP
|
||||
else:
|
||||
trend = Chan_PRICE_TREND.DOWN
|
||||
else:
|
||||
# 初始无记忆时,降低进入门槛
|
||||
if score >= 1:
|
||||
trend = Chan_PRICE_TREND.UP
|
||||
elif score <= -1:
|
||||
trend = Chan_PRICE_TREND.DOWN
|
||||
else:
|
||||
trend = Chan_PRICE_TREND.FLAT
|
||||
except Exception:
|
||||
trend = Chan_PRICE_TREND.UNKNOWN
|
||||
# 写回趋势
|
||||
if klc.end_time is None:
|
||||
trend = Chan_PRICE_TREND.FLAT
|
||||
if hasattr(klc, 'set_trend'):
|
||||
klc.set_trend(trend)
|
||||
else:
|
||||
setattr(klc, 'trend', trend)
|
||||
last_trend = trend
|
||||
# 更新滑窗:仅向后看
|
||||
prev_klcs.append(klc)
|
||||
price_diff = klc.close - klc.pre.close if klc.pre else 0
|
||||
#if klc.index > len(klc_list) - 10:
|
||||
#print(klc.start_time, klc.end_time, klc.close, klc.ema24, klc.ema52, klc.macd, klc.signal, klc.macdhist, klc.trend, price_diff, score)
|
||||
#print(klc.start_time, klc.end_time, klc.trend, price_diff, score)
|
||||
return klc_list
|
||||
|
||||
def get_bi_list(self, dataframe):
|
||||
bi_list = self.cal_bi_list(self.get_klc_list(dataframe))
|
||||
#bi_list = self.cal_bi_list_chanlun(self.get_klc_list(dataframe))
|
||||
return bi_list
|
||||
|
||||
def cal_bi_list(self, klc_list):
|
||||
bi_list = []
|
||||
last_top = None
|
||||
last_bottom = None
|
||||
bi_klc_min = 4
|
||||
last_fx_klc = None
|
||||
for klc in klc_list:
|
||||
if last_fx_klc:
|
||||
klc.check_klc_state(last_fx_klc)
|
||||
klc.check_fx_confirmed(last_top, last_bottom)
|
||||
fx = self.check_fx(klc)
|
||||
if fx == Chan_FX_TYPE.TOP:
|
||||
if last_bottom:
|
||||
if self.check_top_fx(last_bottom, klc) == False:
|
||||
fx = Chan_FX_TYPE.UNKNOWN
|
||||
if fx == Chan_FX_TYPE.BOTTOM:
|
||||
if last_top:
|
||||
if self.check_bottom_fx(last_top, klc) == False:
|
||||
#print(klc.end_time, last_top.end_time, "---")
|
||||
fx = Chan_FX_TYPE.UNKNOWN
|
||||
# Do nothing
|
||||
if fx == Chan_FX_TYPE.UNKNOWN:
|
||||
if len(bi_list) > 0:
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#continue
|
||||
if len(bi_list) > 0 and klc.end_klu:
|
||||
last_bi = bi_list[-1]
|
||||
#print(klc.start_time, last_bi.start_time, last_bi.end_time, last_bi.dir, last_bi.high, last_bi.low, last_bottom.end_time, "last bi")
|
||||
if last_top and last_bi.dir == Chan_BI_DIR.DOWN:
|
||||
if last_bottom and klc.high > last_bi.high:
|
||||
#print(klc.end_time, "Top 7, 1", last_bi.start_time, klc.high, last_bi.high)
|
||||
#klc.klc_fx_type = Chan_KLC_FX.TOP7
|
||||
#klc.fx = Chan_FX_TYPE.TOP
|
||||
"""
|
||||
last_bi.set_end_klc(last_bottom, klc)
|
||||
bi = ChanBI(last_bottom, len(bi_list), Chan_BI_DIR.UP)
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM7)
|
||||
#klc.bb_out = True
|
||||
last_bi.set_next(bi)
|
||||
bi.set_pre(last_bi)
|
||||
for klc_index in range(last_bi.end_klc.index, len(klc_list)):
|
||||
bi.add_klc(klc_list[klc_index])
|
||||
bi_list.append(bi)
|
||||
last_top = klc
|
||||
klc.set_bi(bi)
|
||||
#print(klc.start_time, bi.start_time, bi.end_time, bi.dir, bi.high, bi.low, bi.is_sure)
|
||||
"""
|
||||
else:
|
||||
if last_bottom and last_bi.dir == Chan_BI_DIR.UP:
|
||||
if last_top and klc.low < last_bi.low:
|
||||
#print(klc.end_time, "Bottom 8, 2", last_bi.start_time)
|
||||
#klc.klc_fx_type = Chan_KLC_FX.BOTTOM8
|
||||
#klc.fx = Chan_FX_TYPE.BOTTOM
|
||||
"""
|
||||
last_bi.set_end_klc(last_top, klc)
|
||||
bi = ChanBI(last_top, len(bi_list), Chan_BI_DIR.DOWN)
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.TOP6)
|
||||
#klc.bb_out = True
|
||||
last_bi.set_next(bi)
|
||||
bi.set_pre(last_bi)
|
||||
for klc_index in range(last_bi.end_klc.index, len(klc_list)):
|
||||
bi.add_klc(klc_list[klc_index])
|
||||
bi_list.append(bi)
|
||||
last_bottom = klc
|
||||
klc.set_bi(bi)
|
||||
#print(klc.start_time, bi.start_time, bi.end_time, bi.dir, bi.high, bi.low, bi.is_sure)
|
||||
"""
|
||||
else:
|
||||
last_fx_klc = klc
|
||||
if fx == Chan_FX_TYPE.TOP:
|
||||
#print(klc.end_time, fx, klc.pre.high, klc.high, klc.pre.start_time, klc.pre.end_time)
|
||||
if last_top:
|
||||
if last_bottom:
|
||||
#print(klc.start_time, last_bottom.start_time, last_top.start_time)
|
||||
if last_bottom.index < last_top.index:
|
||||
# Second top lower to be second sell point
|
||||
if last_top.high > klc.high:
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.TOP3)
|
||||
#print(klc.end_time, klc.fx, "二类卖点Sell 1")
|
||||
else:
|
||||
# A new top found
|
||||
#last_top.set_fx(Chan_FX_TYPE.UNKNOWN)
|
||||
last_top = klc
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 1")
|
||||
klc.set_klc_fx_type(Chan_KLC_FX.TOP1)
|
||||
self.check_fx_pattern(klc)
|
||||
#print(klc.end_time, klc.fx, "一类卖点Sell 1")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
# 不满足结合律的分型
|
||||
else:
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.TOP0)
|
||||
#print(klc.end_time, klc.klc_fx_type)
|
||||
if last_bottom.index + bi_klc_min > klc.index:
|
||||
if last_top.high > klc.high:
|
||||
#print(klc.start_time, klc.fx, "二类卖点Sell 1")
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.TOP8)
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
# New TOP Found前面的UKNOWN可能出现TOP7,但是这里的也可能出现TOP8分型
|
||||
else:
|
||||
# 顶分型在出现2之前超过前一个笔的顶 TOP8
|
||||
if last_top.index + bi_klc_min < klc.index and len(bi_list) > 1:
|
||||
pre_last_bi = bi_list[-2]
|
||||
last_bi = bi_list[-1]
|
||||
if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.UP and False:
|
||||
pre_last_bi.update_bi(klc)
|
||||
bi_list.remove(last_bi)
|
||||
pre_last_bi.set_next(None)
|
||||
#last_top.set_fx(Chan_FX_TYPE.PTOP)
|
||||
last_top = klc
|
||||
last_bottom = pre_last_bi.start_klc
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Bottom Change 1")
|
||||
klc.set_klc_fx_type(Chan_KLC_FX.TOP2)
|
||||
#print(klc.start_time, last_bi.start_klc.start_time, "New TOP Found reset last bi")
|
||||
#klc.set_state("10")
|
||||
#print(klc.start_time, klc.fx, "笔卖点Sell 1")
|
||||
###klc.set_klc_fx_type(Chan_KLC_FX.TOP2) # when bi is down but the fx is top
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.TOP8)
|
||||
#print(klc.start_time, last_bi.start_klc.start_time, "New TOP Found reset last bi")
|
||||
else:
|
||||
#klc.set_fx(Chan_FX_TYPE.PTOP)
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.end_time, klc.fx, "无效顶分型")
|
||||
# 满足结合律
|
||||
else:
|
||||
# New Temp TOP and last bottom confirmed ***** confirm last down bi(last bottom and last top)
|
||||
last_bi = bi_list[-1]
|
||||
if not last_bi.is_sure:
|
||||
last_bi.set_end_klc(last_bottom, klc)
|
||||
bi = ChanBI(last_bottom, len(bi_list), Chan_BI_DIR.UP)
|
||||
last_bi.set_next(bi)
|
||||
bi.set_pre(last_bi)
|
||||
bi.add_klc(klc)
|
||||
bi_list.append(bi)
|
||||
last_top = klc
|
||||
#print(klc.end_time, klc.fx, bi_list[-1].dir, "Last Top Change 2")
|
||||
klc.set_klc_fx_type(Chan_KLC_FX.TOP2)
|
||||
self.check_fx_pattern(klc)
|
||||
#bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.start_time, last_bottom.start_time, "Normal TOP Found, Confirm down bi 4")
|
||||
# last bottom = None 初始化的时候用,其他时间不用
|
||||
else:
|
||||
# 初始化的时候用,其他时间不用
|
||||
if last_top.high < klc.high:
|
||||
last_bi = bi_list[-1]
|
||||
last_bi.set_start_klc(klc, Chan_BI_DIR.DOWN)
|
||||
last_top = klc
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 3")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
# 初始化的时候用,其他时间不用
|
||||
else:
|
||||
#klc.set_fx(Chan_FX_TYPE.TT)
|
||||
#print(klc.start_time, klc.fx, "二类卖点Sell 2")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
# last_top == None 初始化的时候用,其他时间不用
|
||||
else:
|
||||
if last_bottom:
|
||||
# 不满足结合律的分型
|
||||
if last_bottom.index + bi_klc_min > klc.index:
|
||||
#klc.set_fx(Chan_FX_TYPE.PTOP)
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.start_time, klc.fx, "中枢卖点Sell 1")
|
||||
else:
|
||||
# First temp top and last bottom confirmed
|
||||
last_top = klc
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 4")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
# Last top = None, last bottom = None, create first down bi 初始化的时候用,其他时间不用
|
||||
else:
|
||||
# First temp top
|
||||
last_top = klc
|
||||
bi = ChanBI(klc, len(bi_list), Chan_BI_DIR.DOWN)
|
||||
bi_list.append(bi)
|
||||
bi.add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Change 5")
|
||||
#klc.fx = Bottom ========================
|
||||
else:
|
||||
if last_bottom:
|
||||
if last_top:
|
||||
# Bottom after top and find a new bottom
|
||||
if last_top.index < last_bottom.index:
|
||||
# Second bottom uppper to be second buy point and confirm last bi
|
||||
if last_bottom.low < klc.low:
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM3)
|
||||
#print(last_bottom.start_time, last_bottom.end_time, "--------------------------------1")
|
||||
#print(klc.end_time, klc.fx, "二类买点Buy 1")
|
||||
else:
|
||||
# A new bottom found
|
||||
last_bottom = klc
|
||||
#print(klc.end_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 1")
|
||||
klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM1)
|
||||
self.check_fx_pattern(klc)
|
||||
#print(klc.end_time, klc.fx, "一类买点Buy 1")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
# 不满足结合律的分型
|
||||
else:
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM0)
|
||||
#print(klc.end_time, klc.klc_fx_type)
|
||||
if last_top.index + bi_klc_min > klc.index:
|
||||
if last_bottom.low < klc.low:
|
||||
#print(klc.end_time, klc.fx, "中枢买点Buy 1")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM8)
|
||||
# Found new bottom没有意义,上面UNKNOWN的时候已经是笔破坏了
|
||||
else:
|
||||
#print(klc.end_time, last_bottom.end_time, "Found a new bottom")
|
||||
if last_bottom.index + bi_klc_min < klc.index and len(bi_list) > 1:
|
||||
pre_last_bi = bi_list[-2]
|
||||
last_bi = bi_list[-1]
|
||||
if pre_last_bi.is_sure and not last_bi.is_sure and pre_last_bi.dir == Chan_BI_DIR.DOWN and False:
|
||||
pre_last_bi.update_bi(klc)
|
||||
bi_list.remove(last_bi)
|
||||
pre_last_bi.set_next(None)
|
||||
last_bottom = klc
|
||||
last_top = pre_last_bi.start_klc
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Top Bottom Change 2")
|
||||
klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2)
|
||||
#print(klc.start_time, last_bi.start_klc.start_time, "New BOTTOM Found reset last bi")
|
||||
#print(klc.start_time, klc.fx, "笔买点Buy 1")
|
||||
###klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2) # when bi is up but the fx is bottom
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM8)
|
||||
else:
|
||||
#klc.set_fx(Chan_FX_TYPE.UNKNOWN)
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
print(klc.end_time, klc.fx, "无效底分型")
|
||||
# 满足结合律的分型
|
||||
else:
|
||||
# New Temp Bottom and last top confirmed ***** confirm last up bi(last bottom and last top)
|
||||
last_bi = bi_list[-1]
|
||||
if not last_bi.is_sure:
|
||||
last_bi.set_end_klc(last_top, klc)
|
||||
bi = ChanBI(last_top, len(bi_list), Chan_BI_DIR.DOWN)
|
||||
last_bi.set_next(bi)
|
||||
bi.set_pre(last_bi)
|
||||
bi.add_klc(klc)
|
||||
bi_list.append(bi)
|
||||
last_bottom = klc
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 2")
|
||||
klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM2)
|
||||
self.check_fx_pattern(klc)
|
||||
#bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.start_time, last_top.start_time, "Normal Bottom Found, Confirm up bi 6")
|
||||
# last_top = None 初始化的时候用,其他时间不用
|
||||
else:
|
||||
if last_bottom.low > klc.low:
|
||||
last_bi = bi_list[-1]
|
||||
last_bi.set_start_klc(klc, Chan_BI_DIR.UP)
|
||||
#last_bottom.set_fx(Chan_FX_TYPE.UNKNOWN)
|
||||
last_bottom = klc
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 3")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.start_time, klc.fx, "笔买点Buy 3")
|
||||
else:
|
||||
#klc.set_fx(Chan_FX_TYPE.BB)
|
||||
#klc.set_state('-20')
|
||||
#print(klc.start_time, klc.fx, "二类买点Buy 2")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
# last_bottom = None 初始化的时候用,其他时间不用
|
||||
else:
|
||||
if last_top:
|
||||
# 不满足结合律的分型
|
||||
if last_top.index + bi_klc_min > klc.index:
|
||||
#klc.set_fx(Chan_FX_TYPE.PBOTTOM)
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.start_time, klc.fx, "中枢买点Buy 1")
|
||||
else:
|
||||
# First temp bottom and last top confirmed
|
||||
last_bottom = klc
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 4")
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.start_time, klc.fx, "一类买点Buy 1")
|
||||
# Last top = None, last bottom = None, create first up bi
|
||||
else:
|
||||
# First temp bottom and no top yet
|
||||
last_bottom = klc
|
||||
bi = ChanBI(klc, len(bi_list), Chan_BI_DIR.UP)
|
||||
#klc.set_klc_fx_type(Chan_KLC_FX.BOTTOM7)
|
||||
bi_list.append(bi)
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.start_time, klc.fx, bi_list[-1].dir, "Last Bottom Change 5")
|
||||
#print(klc.start_time, klc.fx, "笔买点Buy 4")
|
||||
self.get_above_zero_bsp(klc_list)
|
||||
#print(bi_list[-1].start_time, bi_list[-1].end_time, len(bi_list[-1].klc_list))
|
||||
return bi_list
|
||||
|
||||
def check_top_fx(self, last_bottom, klc):
|
||||
if (last_bottom.high > klc.pre.low or last_bottom.high > klc.next.low) and (klc.index - last_bottom.index < 100):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_bottom_fx(self, last_top, klc):
|
||||
if (last_top.low < klc.pre.high or last_top.low < klc.next.high) and (klc.index - last_top.index < 100):
|
||||
return False
|
||||
return True
|
||||
# 线段内的中枢
|
||||
@@ -0,0 +1,412 @@
|
||||
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
from chanlun.core.ChanEnum import (
|
||||
Chan_BI_DIR,
|
||||
Chan_BSP_DIR,
|
||||
Chan_BSP_TYPE,
|
||||
Chan_FX_TYPE,
|
||||
Chan_K_DIR,
|
||||
Chan_KLC_FX,
|
||||
Chan_KLC_STATE,
|
||||
Chan_KLINE_DIR,
|
||||
Chan_KLU_PATTERN,
|
||||
Chan_PRICE_TREND,
|
||||
Chan_SEG_DIR,
|
||||
Chan_ZS_DIR,
|
||||
)
|
||||
from chanlun.core.ChanKLC import ChanKLC
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
|
||||
|
||||
class BspBuilderMixin:
|
||||
def get_bsp_state(self, dataframe):
|
||||
klu_list = self.get_klu_list(dataframe)
|
||||
klc_list = self.get_klc_list(klu_list)
|
||||
bi_list = self.cal_bi_list(klc_list)
|
||||
seg_list = self.get_seg_list(bi_list)
|
||||
bi_zs_list = self.cal_bi_zs(seg_list)
|
||||
bsp_list = self.find_all_bsp(bi_list, bi_zs_list)
|
||||
bsp_state_list = [0] * len(dataframe)
|
||||
klc_index = 0
|
||||
for index in range(0, len(dataframe)):
|
||||
if klc_index == len(klc_list):
|
||||
klc_index = len(klc_list) - 1
|
||||
klc = klc_list[klc_index]
|
||||
if klc.end_klu and klc.end_klu.idx == index:
|
||||
if klc.klc_fx_type == Chan_KLC_FX.TOP2:
|
||||
bi = klc.bi.pre
|
||||
if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.B3:
|
||||
# 第三类买点
|
||||
bsp_state_list[index] = -1
|
||||
#print(klc.end_time, "B3")
|
||||
else:
|
||||
bsp_state_list[index] = 0
|
||||
elif klc.klc_fx_type == Chan_KLC_FX.BOTTOM2:
|
||||
bi = klc.bi.pre
|
||||
if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.S3:
|
||||
# 第三类卖点
|
||||
bsp_state_list[index] = 1
|
||||
#print(klc.end_time, "S3")
|
||||
else:
|
||||
bsp_state_list[index] = 0
|
||||
klc_index += 1
|
||||
else:
|
||||
bsp_state_list[index] = 0
|
||||
return bsp_state_list
|
||||
|
||||
def get_above_zero_bsp(self, klc_list):
|
||||
buy_bsp_list = []
|
||||
sell_bsp_list = []
|
||||
above_zero = False
|
||||
buy_bsp = None
|
||||
sell_bsp = None
|
||||
for klc in klc_list:
|
||||
if klc.pre and klc.pre.signal < 0 and klc.signal > 0:
|
||||
above_zero = True
|
||||
if klc.pre and klc.pre.signal > 0 and klc.signal < 0:
|
||||
above_zero = False
|
||||
if above_zero and klc.klc_fx_type == Chan_KLC_FX.BOTTOM2 and klc.macd > 0:
|
||||
buy_bsp = klc
|
||||
buy_bsp_list.append(klc)
|
||||
#print(klc.end_time, "MACD 0轴上穿,回调笔底分型做多")
|
||||
if buy_bsp and klc.pre and klc.pre.macdhist > 0 and klc.macdhist < 0:
|
||||
sell_bsp = klc
|
||||
sell_bsp_list.append(klc)
|
||||
buy_bsp = None
|
||||
#print(klc.end_time, "Sell BSP Found")
|
||||
return buy_bsp_list
|
||||
|
||||
def find_all_bsp(self, bi_list, bi_zs_list):
|
||||
"""
|
||||
笔中枢的三类买卖点识别
|
||||
|
||||
三类买点:中枢形成后,一笔向上离开中枢(低点 > zg),
|
||||
随后回拉的一笔低点不跌回中枢(低点 >= zg),确认支撑有效。
|
||||
三类卖点:中枢形成后,一笔向下离开中枢(高点 < zd),
|
||||
随后反弹的一笔高点不回到中枢(高点 <= zd),确认压力有效。
|
||||
|
||||
参数:
|
||||
bi_list: 笔列表
|
||||
bi_zs_list: 笔中枢列表(二维列表,每个seg内的中枢列表)
|
||||
|
||||
返回:
|
||||
bsp_list: ChanBSP 列表,包含所有识别到的三类买卖点
|
||||
"""
|
||||
bsp_list = []
|
||||
if len(bi_list) < 4 or len(bi_zs_list) == 0:
|
||||
return bsp_list
|
||||
|
||||
for zs in bi_zs_list:
|
||||
if not zs.is_sure or len(zs.bi_list) < 3:
|
||||
continue
|
||||
#print(zs.start_time, zs.end_time, zs.dir, zs.is_sure, len(zs.bi_list))
|
||||
# 中枢结束后的第一笔(离开笔)
|
||||
last_zs_bi = zs.bi_list[-1]
|
||||
if last_zs_bi.dir == Chan_BI_DIR.UP:
|
||||
if last_zs_bi.is_sure and last_zs_bi.end_klc.high <= zs.zg or (last_zs_bi.next and last_zs_bi.next.is_sure and last_zs_bi.next.end_klc.low < zs.zd):
|
||||
leave_bi = last_zs_bi.next
|
||||
else:
|
||||
leave_bi = last_zs_bi
|
||||
else:
|
||||
if last_zs_bi.is_sure and last_zs_bi.end_klc.low >= zs.zd or (last_zs_bi.next and last_zs_bi.next.is_sure and last_zs_bi.next.end_klc.high > zs.zg):
|
||||
leave_bi = last_zs_bi.next
|
||||
else:
|
||||
leave_bi = last_zs_bi
|
||||
#print(zs.zg, zs.zd)
|
||||
if leave_bi is None or not leave_bi.is_sure:
|
||||
continue
|
||||
if (zs.dir == Chan_ZS_DIR.UP and leave_bi.dir == Chan_BI_DIR.UP and leave_bi.end_klc.high < zs.zg and leave_bi.end_klc.high > zs.zd) or (zs.dir == Chan_ZS_DIR.DOWN and leave_bi.dir == Chan_BI_DIR.DOWN and leave_bi.end_klc.low < zs.zg and leave_bi.end_klc.low > zs.zd):
|
||||
#print("--------------------", leave_bi.dir, leave_bi.end_klc.high, leave_bi.end_klc.low, zs.zg, zs.zd)
|
||||
leave_bi = leave_bi.next
|
||||
# 三类买点:向上离开中枢后回拉不破 zg
|
||||
#print("Leave bi:", leave_bi.start_time, leave_bi.end_time, leave_bi.dir, leave_bi.is_sure, leave_bi.low, leave_bi.high)
|
||||
if leave_bi.dir == Chan_BI_DIR.UP:
|
||||
first_bsp_bi_div = self.check_bi_div(zs, leave_bi)
|
||||
# 确认一类卖点:离开断能量小于进入段能量
|
||||
if first_bsp_bi_div:
|
||||
bsp = ChanBSP(
|
||||
leave_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.S1,
|
||||
Chan_BSP_DIR.SELL,
|
||||
leave_bi.sure_time,
|
||||
zs.index+1, zs, None
|
||||
)
|
||||
leave_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.S1)
|
||||
bsp_list.append(bsp)
|
||||
# 回拉笔
|
||||
pullback_bi = leave_bi.next
|
||||
#print(pullback_bi.start_klc.start_time, pullback_bi.dir, pullback_bi.is_sure, pullback_bi.low, pullback_bi.high)
|
||||
if pullback_bi and pullback_bi.is_sure and pullback_bi.dir == Chan_BI_DIR.DOWN:
|
||||
if pullback_bi.low >= zs.zg:
|
||||
# 确认三类买点:回拉笔的低点不跌回中枢
|
||||
bsp = ChanBSP(
|
||||
pullback_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.B3,
|
||||
Chan_BSP_DIR.BUY,
|
||||
pullback_bi.sure_time,
|
||||
zs.index+1, zs, None
|
||||
)
|
||||
pullback_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B3)
|
||||
bsp_list.append(bsp)
|
||||
# 二类卖点
|
||||
if first_bsp_bi_div:
|
||||
second_bsp_bi = pullback_bi.next
|
||||
if second_bsp_bi and second_bsp_bi.is_sure and second_bsp_bi.end_klc.high < leave_bi.end_klc.high:
|
||||
# 确认二类卖点:一类卖点后回拉不超过一类卖点高点
|
||||
bsp = ChanBSP(
|
||||
second_bsp_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.S2,
|
||||
Chan_BSP_DIR.SELL,
|
||||
second_bsp_bi.sure_time,
|
||||
zs.index+1, zs, None
|
||||
)
|
||||
second_bsp_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B2)
|
||||
bsp_list.append(bsp)
|
||||
# 三类卖点:向下离开中枢后反弹不破 zd
|
||||
elif leave_bi.dir == Chan_BI_DIR.DOWN:
|
||||
first_bsp_bi_div = self.check_bi_div(zs, leave_bi)
|
||||
# 确认一类买点:离开段能量小于进入段
|
||||
if first_bsp_bi_div:
|
||||
bsp = ChanBSP(
|
||||
leave_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.B1,
|
||||
Chan_BSP_DIR.BUY,
|
||||
leave_bi.sure_time,
|
||||
zs.index+1, zs, None
|
||||
)
|
||||
leave_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B1)
|
||||
bsp_list.append(bsp)
|
||||
# 反弹笔
|
||||
bounce_bi = leave_bi.next
|
||||
#print(bounce_bi.start_klc.start_time, bounce_bi.dir, bounce_bi.is_sure, bounce_bi.low, bounce_bi.high)
|
||||
if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP:
|
||||
if bounce_bi.high <= zs.zd:
|
||||
# 确认三类卖点:反弹笔的高点不回到中枢
|
||||
bsp = ChanBSP(
|
||||
bounce_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.S3,
|
||||
Chan_BSP_DIR.SELL,
|
||||
bounce_bi.sure_time,
|
||||
zs.index+1, zs, None
|
||||
)
|
||||
bounce_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.S3)
|
||||
bsp_list.append(bsp)
|
||||
# 二类卖点
|
||||
if first_bsp_bi_div:
|
||||
second_bsp_bi = bounce_bi.next
|
||||
if second_bsp_bi and second_bsp_bi.is_sure and second_bsp_bi.end_klc.low > leave_bi.end_klc.low:
|
||||
# 确认二类买点:一类买点后回拉不超过一类卖点高点
|
||||
bsp = ChanBSP(
|
||||
second_bsp_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.B2,
|
||||
Chan_BSP_DIR.BUY,
|
||||
second_bsp_bi.sure_time,
|
||||
zs.index+1, zs, None
|
||||
)
|
||||
second_bsp_bi.end_klc.set_bsp_type(Chan_BSP_TYPE.B2)
|
||||
bsp_list.append(bsp)
|
||||
return bsp_list
|
||||
|
||||
def check_bi_div(self, zs, leave_bi):
|
||||
enter_bi = zs.bi_list[0].pre
|
||||
macdhist_div = 0
|
||||
if enter_bi and enter_bi.dir == leave_bi.dir:
|
||||
macdhist_div = abs(leave_bi.macd_hist) - abs(enter_bi.macd_hist)
|
||||
#print(enter_bi.end_time, leave_bi.end_time, macdhist_div < 0)
|
||||
return macdhist_div < 0
|
||||
|
||||
def find_first_bsp(self, bi_list, bi_zs_list):
|
||||
"""
|
||||
笔中枢的一类买卖点识别
|
||||
|
||||
一类买点:下跌趋势中,最后一个中枢完成后,向下离开中枢的笔创新低,
|
||||
但该笔与进入中枢前的最后一笔下跌形成底背驰(力度减弱),
|
||||
即趋势力竭的转折点。
|
||||
一类卖点:上涨趋势中,最后一个中枢完成后,向上离开中枢的笔创新高,
|
||||
但该笔与进入中枢前的最后一笔上涨形成顶背驰(力度减弱),
|
||||
即趋势力竭的转折点。
|
||||
|
||||
简化判断:中枢形成后,离开中枢的笔(突破笔)本身即为一类买卖点的触发笔。
|
||||
|
||||
参数:
|
||||
bi_list: 笔列表
|
||||
bi_zs_list: 笔中枢列表(扁平列表,每个元素是一个中枢对象)
|
||||
|
||||
返回:
|
||||
bsp_list: ChanBSP 列表,包含所有识别到的一类买卖点
|
||||
"""
|
||||
bsp_list = []
|
||||
if len(bi_list) < 4 or len(bi_zs_list) == 0:
|
||||
return bsp_list
|
||||
|
||||
for zs in bi_zs_list:
|
||||
if not zs.is_sure or len(zs.bi_list) < 3:
|
||||
continue
|
||||
|
||||
# 找到中枢的最后一笔
|
||||
last_zs_bi = zs.bi_list[-1]
|
||||
|
||||
# 确定离开笔:中枢最后一笔之后的第一笔
|
||||
if last_zs_bi.dir == Chan_BI_DIR.UP:
|
||||
# 中枢最后一笔向上,如果没有真正离开中枢,取下一笔
|
||||
if last_zs_bi.is_sure and last_zs_bi.end_klc.high <= zs.zg:
|
||||
leave_bi = last_zs_bi.next
|
||||
else:
|
||||
leave_bi = last_zs_bi
|
||||
else:
|
||||
# 中枢最后一笔向下,如果没有真正离开中枢,取下一笔
|
||||
if last_zs_bi.is_sure and last_zs_bi.end_klc.low >= zs.zd:
|
||||
leave_bi = last_zs_bi.next
|
||||
else:
|
||||
leave_bi = last_zs_bi
|
||||
|
||||
if leave_bi is None or not leave_bi.is_sure:
|
||||
continue
|
||||
|
||||
# 一类买点:向下离开中枢(leave_bi向下,低点 < zd),趋势力竭
|
||||
if leave_bi.dir == Chan_BI_DIR.DOWN and leave_bi.low < zs.zd:
|
||||
# 背驰判断:比较离开笔与中枢内最后一笔同向笔的MACD柱状累积面积
|
||||
# 缠论原文:两段同向走势的MACD柱状面积比较,面积缩小即为背驰
|
||||
compare_bi = None
|
||||
for bi in reversed(zs.bi_list):
|
||||
if bi.dir == Chan_BI_DIR.DOWN and bi is not leave_bi:
|
||||
compare_bi = bi
|
||||
break
|
||||
|
||||
is_divergence = False
|
||||
if compare_bi:
|
||||
# 笔的macd_hist是该笔内所有KLU的macdhist累积面积
|
||||
leave_macd_area = abs(leave_bi.macd_hist)
|
||||
compare_macd_area = abs(compare_bi.macd_hist)
|
||||
|
||||
# 价格创新低但MACD面积缩小 = 底背驰
|
||||
if leave_bi.low <= compare_bi.low and leave_macd_area < compare_macd_area:
|
||||
is_divergence = True
|
||||
# 即使没创新低,MACD面积明显缩小也算背驰
|
||||
elif leave_macd_area < compare_macd_area * 0.5:
|
||||
is_divergence = True
|
||||
else:
|
||||
# 没有对比笔时,只要离开中枢就算一类买点
|
||||
is_divergence = True
|
||||
|
||||
if is_divergence:
|
||||
bsp = ChanBSP(
|
||||
leave_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.T1,
|
||||
Chan_BSP_DIR.BUY,
|
||||
leave_bi.sure_time,
|
||||
1, zs, None
|
||||
)
|
||||
bsp_list.append(bsp)
|
||||
|
||||
# 一类卖点:向上离开中枢(leave_bi向上,高点 > zg),趋势力竭
|
||||
elif leave_bi.dir == Chan_BI_DIR.UP and leave_bi.high > zs.zg:
|
||||
# 背驰判断:比较离开笔与中枢内最后一笔同向笔的MACD柱状累积面积
|
||||
compare_bi = None
|
||||
for bi in reversed(zs.bi_list):
|
||||
if bi.dir == Chan_BI_DIR.UP and bi is not leave_bi:
|
||||
compare_bi = bi
|
||||
break
|
||||
|
||||
is_divergence = False
|
||||
if compare_bi:
|
||||
leave_macd_area = abs(leave_bi.macd_hist)
|
||||
compare_macd_area = abs(compare_bi.macd_hist)
|
||||
|
||||
# 价格创新高但MACD面积缩小 = 顶背驰
|
||||
if leave_bi.high >= compare_bi.high and leave_macd_area < compare_macd_area:
|
||||
is_divergence = True
|
||||
# 即使没创新高,MACD面积明显缩小也算背驰
|
||||
elif leave_macd_area < compare_macd_area * 0.5:
|
||||
is_divergence = True
|
||||
else:
|
||||
is_divergence = True
|
||||
|
||||
if is_divergence:
|
||||
bsp = ChanBSP(
|
||||
leave_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.T1,
|
||||
Chan_BSP_DIR.SELL,
|
||||
leave_bi.sure_time,
|
||||
1, zs, None
|
||||
)
|
||||
bsp_list.append(bsp)
|
||||
|
||||
return bsp_list
|
||||
|
||||
|
||||
def find_second_bsp(self, bi_list, first_bsp_list):
|
||||
"""
|
||||
笔中枢的二类买卖点识别
|
||||
|
||||
二类买点:一类买点出现后,价格向上反弹一笔,再回落一笔,
|
||||
回落笔的低点不跌破一类买点的低点,确认底部成立。
|
||||
二类卖点:一类卖点出现后,价格向下回落一笔,再反弹一笔,
|
||||
反弹笔的高点不超过一类卖点的高点,确认顶部成立。
|
||||
|
||||
参数:
|
||||
bi_list: 笔列表
|
||||
first_bsp_list: 一类买卖点列表(find_first_bsp 的返回值)
|
||||
|
||||
返回:
|
||||
bsp_list: ChanBSP 列表,包含所有识别到的二类买卖点
|
||||
"""
|
||||
bsp_list = []
|
||||
if not first_bsp_list or len(bi_list) < 4:
|
||||
return bsp_list
|
||||
|
||||
for first_bsp in first_bsp_list:
|
||||
trigger_bi = first_bsp.bi # 一类买卖点的触发笔
|
||||
|
||||
if first_bsp.dir == Chan_BSP_DIR.BUY:
|
||||
# 一买之后:trigger_bi 向下 -> 反弹笔(向上) -> 回落笔(向下)
|
||||
# 回落笔的低点 > trigger_bi 的低点 => 二类买点
|
||||
bounce_bi = trigger_bi.next # 反弹笔(向上)
|
||||
if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP:
|
||||
pullback_bi = bounce_bi.next # 回落笔(向下)
|
||||
if pullback_bi and pullback_bi.is_sure and pullback_bi.dir == Chan_BI_DIR.DOWN:
|
||||
if pullback_bi.low > trigger_bi.low:
|
||||
bsp = ChanBSP(
|
||||
pullback_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.T2,
|
||||
Chan_BSP_DIR.BUY,
|
||||
pullback_bi.sure_time,
|
||||
1, first_bsp.zs, None
|
||||
)
|
||||
bsp_list.append(bsp)
|
||||
|
||||
elif first_bsp.dir == Chan_BSP_DIR.SELL:
|
||||
# 一卖之后:trigger_bi 向上 -> 回落笔(向下) -> 反弹笔(向上)
|
||||
# 反弹笔的高点 < trigger_bi 的高点 => 二类卖点
|
||||
drop_bi = trigger_bi.next # 回落笔(向下)
|
||||
if drop_bi and drop_bi.is_sure and drop_bi.dir == Chan_BI_DIR.DOWN:
|
||||
bounce_bi = drop_bi.next # 反弹笔(向上)
|
||||
if bounce_bi and bounce_bi.is_sure and bounce_bi.dir == Chan_BI_DIR.UP:
|
||||
if bounce_bi.high < trigger_bi.high:
|
||||
bsp = ChanBSP(
|
||||
bounce_bi, len(bsp_list),
|
||||
Chan_BSP_TYPE.T2,
|
||||
Chan_BSP_DIR.SELL,
|
||||
bounce_bi.sure_time,
|
||||
1, first_bsp.zs, None
|
||||
)
|
||||
bsp_list.append(bsp)
|
||||
|
||||
return bsp_list
|
||||
@@ -0,0 +1,133 @@
|
||||
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
from chanlun.core.ChanEnum import (
|
||||
Chan_BI_DIR,
|
||||
Chan_BSP_DIR,
|
||||
Chan_BSP_TYPE,
|
||||
Chan_FX_TYPE,
|
||||
Chan_K_DIR,
|
||||
Chan_KLC_FX,
|
||||
Chan_KLC_STATE,
|
||||
Chan_KLINE_DIR,
|
||||
Chan_KLU_PATTERN,
|
||||
Chan_PRICE_TREND,
|
||||
Chan_SEG_DIR,
|
||||
Chan_ZS_DIR,
|
||||
)
|
||||
from chanlun.core.ChanKLC import ChanKLC
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
|
||||
|
||||
class IndicatorsBuilderMixin:
|
||||
def get_ema52(self, index=-1):
|
||||
if self.klu_list:
|
||||
ema52_value = self.klu_list[index].ema52
|
||||
# 处理NaN值
|
||||
if pd.isna(ema52_value) or ema52_value is None:
|
||||
return None
|
||||
return float(ema52_value)
|
||||
return None
|
||||
|
||||
def get_ema24(self, index=-1):
|
||||
if self.klu_list:
|
||||
ema24_value = self.klu_list[index].ema24
|
||||
# 处理NaN值
|
||||
if pd.isna(ema24_value) or ema24_value is None:
|
||||
return None
|
||||
return float(ema24_value)
|
||||
return None
|
||||
|
||||
def add_indicators(self, df):
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
bb120 = ta.BBANDS(df, timeperiod=120, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
|
||||
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
# 计算布林带中轨(移动平均线)
|
||||
bb30_middle = ta.SMA(df, timeperiod=90)
|
||||
|
||||
# 手动计算布林带 %B 指标 (BBP)
|
||||
# %B = (Price - Lower Band) / (Upper Band - Lower Band)
|
||||
bbp365 = (df['close'] - bb365['lowerband']) / (bb365['upperband'] - bb365['lowerband'])
|
||||
bbp120 = (df['close'] - bb120['lowerband']) / (bb120['upperband'] - bb120['lowerband'])
|
||||
bbp30 = (df['close'] - bb30['lowerband']) / (bb30['upperband'] - bb30['lowerband'])
|
||||
bbp302 = (df['close'] - bb302['lowerband']) / (bb302['upperband'] - bb302['lowerband'])
|
||||
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
|
||||
df['bb2633upper'] = bb2633['upperband']
|
||||
df['bb2633lower'] = bb2633['lowerband']
|
||||
df['bbp2633'] = bbp2633
|
||||
df['bb2633middle'] = bb2633['middleband']
|
||||
df['atr'] = ta.ATR(df, timeperiod=14)
|
||||
df['bbup365'] = bb365['upperband']
|
||||
df['bblow365'] = bb365['lowerband']
|
||||
df['bbp365'] = bbp365
|
||||
df['bbup120'] = bb120['upperband']
|
||||
df['bblow120'] = bb120['lowerband']
|
||||
df['bbp120'] = bbp120
|
||||
df['bbup30'] = bb30['upperband']
|
||||
df['bblow30'] = bb30['lowerband']
|
||||
df['bbmiddle30'] = bb30_middle # 添加bb30中轨
|
||||
df['bbp30'] = bbp30
|
||||
df['bbup302'] = bb302['upperband']
|
||||
df['bblow302'] = bb302['lowerband']
|
||||
df['bbp302'] = bbp302
|
||||
df['macd'] = macd['macd']
|
||||
df['macdsignal'] = macd['macdsignal']
|
||||
df['macdhist'] = macd['macdhist']
|
||||
df['ema5'] = ta.EMA(df, timeperiod=5)
|
||||
df['ema10'] = ta.EMA(df, timeperiod=10)
|
||||
df['ema24'] = ta.EMA(df, timeperiod=24)
|
||||
df['ema52'] = ta.EMA(df, timeperiod=52)
|
||||
df['ema104'] = ta.EMA(df, timeperiod=104)
|
||||
df['ema156'] = ta.EMA(df, timeperiod=156)
|
||||
df['ema208'] = ta.EMA(df, timeperiod=208)
|
||||
df['ema26'] = ta.EMA(df, timeperiod=26)
|
||||
df['ema13'] = ta.EMA(df, timeperiod=13)
|
||||
df['ema7'] = ta.EMA(df, timeperiod=7)
|
||||
df['rsi'] = ta.RSI(df, timeperiod=14)
|
||||
df['volume_ratio'] = self.cal_volume_ratio(df)
|
||||
return df
|
||||
|
||||
def get_ema_state(self, dataframe):
|
||||
klu_list = self.get_klu_list(dataframe)
|
||||
klc_list = self.get_klc_list(klu_list)
|
||||
bi_list = self.cal_bi_list(klc_list)
|
||||
klu_state_list = []
|
||||
for klu in klu_list:
|
||||
if klu.near0_return == 1:
|
||||
klu_state_list.append("1")
|
||||
elif klu.near0_return == 9:
|
||||
klu_state_list.append("-1")
|
||||
elif klu.candle_dir == Chan_K_DIR.BULL:
|
||||
klu_state_list.append("2")
|
||||
elif klu.candle_dir == Chan_K_DIR.BEAR:
|
||||
klu_state_list.append("-2")
|
||||
else:
|
||||
klu_state_list.append("0")
|
||||
return klu_state_list
|
||||
|
||||
def get_decimal(self, value):
|
||||
return Decimal("{:.2f}".format(value))
|
||||
@@ -0,0 +1,549 @@
|
||||
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
from chanlun.core.ChanEnum import (
|
||||
Chan_BI_DIR,
|
||||
Chan_BSP_DIR,
|
||||
Chan_BSP_TYPE,
|
||||
Chan_FX_TYPE,
|
||||
Chan_K_DIR,
|
||||
Chan_KLC_FX,
|
||||
Chan_KLC_STATE,
|
||||
Chan_KLINE_DIR,
|
||||
Chan_KLU_PATTERN,
|
||||
Chan_PRICE_TREND,
|
||||
Chan_SEG_DIR,
|
||||
Chan_ZS_DIR,
|
||||
)
|
||||
from chanlun.core.ChanKLC import ChanKLC
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
|
||||
|
||||
class KlineBuilderMixin:
|
||||
def get_klu_state(self, dataframe):
|
||||
klc_list = self.get_klc_list(self.get_klu_list(dataframe))
|
||||
bi_list = self.cal_bi_list(klc_list)
|
||||
klu_state_list = []
|
||||
klc_index = 0
|
||||
for index in range(0, len(dataframe)):
|
||||
if klc_index == len(klc_list):
|
||||
klc_index = len(klc_list) - 1
|
||||
klc = klc_list[klc_index]
|
||||
if klc.end_klu and klc.end_klu.idx == index:
|
||||
if klc.klc_state == Chan_KLC_STATE.S10:
|
||||
klu_state_list.append("10")
|
||||
#print(klc.end_time, klc.klc_fx_type)
|
||||
elif klc.klc_state == Chan_KLC_STATE.S_10:
|
||||
klu_state_list.append("-10")
|
||||
#print(klc.end_time, klc.klc_fx_type)
|
||||
elif klc.klc_state == Chan_KLC_STATE.S11:
|
||||
klu_state_list.append("11")
|
||||
#print(klc.end_time, klc.klc_fx_type)
|
||||
elif klc.klc_state == Chan_KLC_STATE.S_11:
|
||||
klu_state_list.append("-11")
|
||||
#print(klc.end_time, klc.klc_fx_type)
|
||||
else:
|
||||
klu_state_list.append("00")
|
||||
klc_index += 1
|
||||
else:
|
||||
klu_state_list.append("00")
|
||||
print(klu_state_list[:20])
|
||||
return klu_state_list
|
||||
|
||||
|
||||
def check_fx1(self, klc):
|
||||
if klc.pre and klc.next:
|
||||
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
|
||||
if klc.pre.pre and klc.next.next:
|
||||
if klc.high > klc.pre.pre.high and klc.high > klc.next.next.high:
|
||||
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
|
||||
klc.set_fx(Chan_FX_TYPE.TOP)
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
|
||||
return Chan_FX_TYPE.TOP
|
||||
elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high:
|
||||
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
|
||||
if klc.pre.pre and klc.next.next:
|
||||
if klc.low < klc.pre.pre.low and klc.low < klc.next.next.low:
|
||||
klc.set_fx(Chan_FX_TYPE.BOTTOM)
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
|
||||
return Chan_FX_TYPE.BOTTOM
|
||||
return Chan_FX_TYPE.UNKNOWN
|
||||
|
||||
def check_fx(self, klc):
|
||||
if klc.pre and klc.next:
|
||||
if klc.high > klc.pre.high and klc.high > klc.next.high and klc.low > klc.pre.low and klc.low > klc.next.low:
|
||||
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
|
||||
klc.set_fx(Chan_FX_TYPE.TOP)
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
|
||||
return Chan_FX_TYPE.TOP
|
||||
elif klc.low < klc.pre.low and klc.low < klc.next.low and klc.high < klc.pre.high and klc.high < klc.next.high:
|
||||
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
|
||||
klc.set_fx(Chan_FX_TYPE.BOTTOM)
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
|
||||
return Chan_FX_TYPE.BOTTOM
|
||||
return Chan_FX_TYPE.UNKNOWN
|
||||
|
||||
def check_fx2(self, klc):
|
||||
if klc.pre and klc.next:
|
||||
if klc.high > klc.pre.close and klc.close > klc.next.close and klc.close > klc.pre.close and klc.close > klc.next.close:
|
||||
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
|
||||
klc.set_fx(Chan_FX_TYPE.TOP)
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
|
||||
return Chan_FX_TYPE.TOP
|
||||
elif klc.low < klc.pre.close and klc.close < klc.next.close and klc.close < klc.pre.close and klc.close < klc.next.close:
|
||||
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
|
||||
klc.set_fx(Chan_FX_TYPE.BOTTOM)
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
|
||||
return Chan_FX_TYPE.BOTTOM
|
||||
return Chan_FX_TYPE.UNKNOWN
|
||||
|
||||
def check_fx_pattern(self, klc):
|
||||
klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list
|
||||
|
||||
self.cal_klu_pattern(klu_list)
|
||||
p = ""
|
||||
for klu in klu_list:
|
||||
p += klu.to_string()
|
||||
#print(p)
|
||||
|
||||
def cal_volume_ratio(self, dataframe, window=10):
|
||||
df = dataframe.copy()
|
||||
# 计算过去N根K线的平均成交量
|
||||
df['avg_volume'] = df['volume'].rolling(window=window).mean()
|
||||
# 计算量比
|
||||
df['volume_ratio'] = df['volume'] / df['avg_volume']
|
||||
# 填充缺失值(前N根K线)
|
||||
df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
|
||||
return df['volume_ratio']
|
||||
|
||||
def cal_kl_data(self, dataframe:DataFrame):
|
||||
fields = "time,open,high,low,close,volume"
|
||||
klu_list = []
|
||||
last_klu = None
|
||||
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)
|
||||
# date = date + timedelta(hours=8)
|
||||
time_str = date.strftime('%Y-%m-%d %H:%M:%S')
|
||||
item_data = [
|
||||
time_str,
|
||||
o,
|
||||
h,
|
||||
l,
|
||||
c,
|
||||
v
|
||||
]
|
||||
# klu = KLU(self.create_item_dict(item_data, GetColumnNameFromFieldList(fields)))
|
||||
klu = ChanKLU(time_str, o, h, l, c, v)
|
||||
# print(klu.time, klu.open, klu.high, klu.low, klu.close, klu.volume)
|
||||
klu.set_idx(i)
|
||||
klu_list.append(klu)
|
||||
if last_klu:
|
||||
last_klu.set_next(klu)
|
||||
klu.set_pre(last_klu)
|
||||
last_klu = klu
|
||||
if 'macd' in item:
|
||||
klu.set_indicators(item)
|
||||
return klu_list
|
||||
|
||||
def get_kl_data(self, dataframe:DataFrame):
|
||||
return self.cal_kl_data(dataframe)
|
||||
|
||||
def get_klc_list(self, klu_list):
|
||||
klc_list = []
|
||||
last_klu = None
|
||||
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)
|
||||
macd = ChanMACD(klu_list)
|
||||
klu_list = macd.klu_list
|
||||
self._last_chan_macd = macd
|
||||
ema_up_list = []
|
||||
ema_down_list = []
|
||||
ema_up_count = 0
|
||||
ema_down_count = 0
|
||||
last_klu = None
|
||||
for klu in klu_list:
|
||||
ema = klu.ema52
|
||||
last_ema = last_klu.ema52 if last_klu else 0
|
||||
if klu.close >= ema:
|
||||
ema_up_count += 1
|
||||
elif klu.close < ema:
|
||||
ema_down_count += 1
|
||||
if last_klu and last_klu.close >= last_ema and klu.close < ema:
|
||||
ema_up_list.append(ema_up_count)
|
||||
#print(last_klu.time, ema_up_count, "UP END")
|
||||
ema_up_count = 0
|
||||
elif last_klu and last_klu.close < last_ema and klu.close >= ema:
|
||||
ema_down_list.append(ema_down_count)
|
||||
#print(last_klu.time, ema_down_count, "DOWN END")
|
||||
ema_down_count = 0
|
||||
if len(klc_list) > 0:
|
||||
last_klc = klc_list[-1]
|
||||
if klu.exception:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
if last_klc.high < klu.high:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
|
||||
klc.high = klu.close if klu.close > klu.open else klu.open
|
||||
klc.low = klu.open if klu.close > klu.open else klu.close
|
||||
klc_list.append(klc)
|
||||
last_klc.set_next(klc)
|
||||
klc.set_pre(last_klc)
|
||||
last_klc.set_end_klu(last_klu)
|
||||
klc.set_pre_fx()
|
||||
#print(klu.time, klu.high, klu.low, klu.close, klu.open, klu.exception)
|
||||
else:
|
||||
included = last_klc.check_klu_included(klu)
|
||||
if not included:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
if last_klc.high < klu.high:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
klc = ChanKLC(klu, index=len(klc_list), ddir=ddir)
|
||||
klc_list.append(klc)
|
||||
last_klc.set_next(klc)
|
||||
klc.set_pre(last_klc)
|
||||
last_klc.set_end_klu(last_klu)
|
||||
klc.set_pre_fx()
|
||||
else:
|
||||
last_klc.add_klu(klu)
|
||||
else:
|
||||
ddir = Chan_KLINE_DIR.UP
|
||||
if klu.open > klu.close:
|
||||
ddir = Chan_KLINE_DIR.DOWN
|
||||
klc = ChanKLC(klu, 0, ddir)
|
||||
klc_list.append(klc)
|
||||
last_klu = klu
|
||||
klc_list = self.cal_trend(klc_list)
|
||||
#print(ema52_up_list, ema52_down_list)
|
||||
return klc_list
|
||||
|
||||
|
||||
def get_klu_list(self, dataframe):
|
||||
klu_list = self.get_kl_data(dataframe)
|
||||
#klu_list = self.cal_klu_pattern(klu_list)
|
||||
return klu_list
|
||||
|
||||
def cal_klu_pattern(self, klu_list):
|
||||
"""
|
||||
计算裸K的pattern - 识别反转形态
|
||||
"""
|
||||
if not klu_list or len(klu_list) < 3:
|
||||
return klu_list
|
||||
|
||||
for i, klu in enumerate(klu_list):
|
||||
# 单根K线反转模式识别
|
||||
self._detect_single_reversal_pattern(klu)
|
||||
|
||||
# 双根K线形态识别
|
||||
if i >= 1:
|
||||
self._detect_double_pattern(klu_list[i-1], klu)
|
||||
|
||||
# 三根K线形态识别
|
||||
if i >= 2:
|
||||
self._detect_triple_pattern(klu_list[i-2], klu_list[i-1], klu)
|
||||
|
||||
#if klu.pattern != Chan_KLU_PATTERN.UNKNOWN:
|
||||
#print(klu.time, klu.pattern, klu.lower_shadow_ratio, klu.upper_shadow_ratio, klu.body_ratio, klu.lower_shadow_ratio/klu.body_ratio, klu.upper_shadow_ratio/klu.body_ratio)
|
||||
return klu_list
|
||||
|
||||
|
||||
def _detect_single_reversal_pattern(self, klu):
|
||||
"""检测单根K线反转模式"""
|
||||
body = abs(klu.close - klu.open)
|
||||
upper_shadow = klu.high - max(klu.close, klu.open)
|
||||
lower_shadow = min(klu.close, klu.open) - klu.low
|
||||
total_range = klu.high - klu.low
|
||||
|
||||
# 避免除零
|
||||
if total_range == 0:
|
||||
return
|
||||
|
||||
body_ratio = body / total_range
|
||||
upper_ratio = upper_shadow / total_range
|
||||
lower_ratio = lower_shadow / total_range
|
||||
#print(klu.time, upper_ratio, lower_ratio, body_ratio, upper_ratio/body_ratio, lower_ratio/body_ratio)
|
||||
# 避免body_ratio为0时的除零错误
|
||||
if body_ratio == 0:
|
||||
return
|
||||
# 锤子线/上吊线 - 反转信号
|
||||
if lower_ratio / body_ratio >= 2:
|
||||
# 锤子线:底部反转,需要前面一段
|
||||
if klu.close > klu.open and klu.pre:
|
||||
klu.set_pattern(Chan_KLU_PATTERN.HAMMER) # 底部反转
|
||||
# 上吊线:顶部反转,需要前一根是上涨趋势
|
||||
elif klu.close < klu.open and klu.pre:
|
||||
klu.set_pattern(Chan_KLU_PATTERN.HANGING_MAN) # 顶部反转
|
||||
|
||||
# 倒锤子线/射击之星 - 反转信号
|
||||
elif upper_ratio / body_ratio >= 2:
|
||||
# 倒锤子线:底部反转,需要前一根是下跌趋势
|
||||
if klu.close > klu.open and klu.pre:
|
||||
klu.set_pattern(Chan_KLU_PATTERN.INVERTED_HAMMER) # 底部反转
|
||||
# 射击之星:顶部反转,需要前一根是上涨趋势
|
||||
elif klu.close < klu.open and klu.pre:
|
||||
klu.set_pattern(Chan_KLU_PATTERN.SHOOTING_STAR) # 顶部反转
|
||||
|
||||
# 十字星 - 反转信号
|
||||
elif body_ratio <= 0.1:
|
||||
if upper_ratio > 0.4 and lower_ratio > 0.4:
|
||||
klu.set_pattern(Chan_KLU_PATTERN.LONG_LEGGED_DOJI) # 强烈反转信号
|
||||
elif upper_ratio > 0.4 and lower_ratio <= 0.1:
|
||||
# 墓碑十字星:顶部反转,需要前一根是上涨趋势
|
||||
if klu.pre and klu.pre.close > klu.pre.open:
|
||||
klu.set_pattern(Chan_KLU_PATTERN.GRAVESTONE_DOJI) # 顶部反转
|
||||
elif lower_ratio > 0.4 and upper_ratio <= 0.1:
|
||||
# 蜻蜓十字星:底部反转,需要前一根是下跌趋势
|
||||
if klu.pre and klu.pre.close < klu.pre.open:
|
||||
klu.set_pattern(Chan_KLU_PATTERN.DRAGONFLY_DOJI) # 底部反转
|
||||
else:
|
||||
klu.set_pattern(Chan_KLU_PATTERN.DOJI) # 一般反转信号
|
||||
|
||||
|
||||
def _detect_double_pattern(self, prev_klu, curr_klu):
|
||||
"""检测两根K线形成的形态
|
||||
包括:吞没形态(看涨/看跌)、乌云盖顶、曙光初现
|
||||
"""
|
||||
# 如果前一根K线已经有形态,不再识别双K线形态
|
||||
if prev_klu.pattern != Chan_KLU_PATTERN.UNKNOWN:
|
||||
return
|
||||
|
||||
# 计算K线实体
|
||||
prev_body = abs(prev_klu.close - prev_klu.open)
|
||||
curr_body = abs(curr_klu.close - curr_klu.open)
|
||||
|
||||
# 判断K线颜色(阴阳)
|
||||
prev_bullish = prev_klu.close > prev_klu.open
|
||||
curr_bullish = curr_klu.close > curr_klu.open
|
||||
|
||||
# 检查是否存在长期趋势(至少需要5根K线的趋势)
|
||||
def check_long_trend(klu, bullish_trend=True, min_bars=5):
|
||||
"""检查是否存在长期趋势
|
||||
bullish_trend=True: 检查上涨趋势
|
||||
bullish_trend=False: 检查下跌趋势
|
||||
min_bars: 最少需要多少根K线形成趋势
|
||||
"""
|
||||
if not klu or not klu.pre:
|
||||
return False
|
||||
return True
|
||||
|
||||
# 使用EMA指标判断长期趋势
|
||||
if klu.ema52 > 0:
|
||||
if bullish_trend and klu.close < klu.ema52:
|
||||
return False
|
||||
if not bullish_trend and klu.close > klu.ema52:
|
||||
return False
|
||||
|
||||
# 检查连续的K线方向
|
||||
count = 0
|
||||
current = klu.pre
|
||||
|
||||
while current and count < min_bars:
|
||||
if not current.pre:
|
||||
break
|
||||
|
||||
if bullish_trend:
|
||||
# 上涨趋势:当前收盘价高于前一根收盘价
|
||||
if current.close <= current.pre.close:
|
||||
break
|
||||
else:
|
||||
# 下跌趋势:当前收盘价低于前一根收盘价
|
||||
if current.close >= current.pre.close:
|
||||
break
|
||||
|
||||
count += 1
|
||||
current = current.pre
|
||||
|
||||
return count >= min_bars
|
||||
|
||||
# 1. 看涨吞没形态:前阴后阳,后者完全吞没前者
|
||||
# 要求前面有明显的下跌趋势
|
||||
if not prev_bullish and curr_bullish and \
|
||||
abs(curr_klu.open - prev_klu.close) < 10 and \
|
||||
curr_klu.close > prev_klu.open and \
|
||||
check_long_trend(prev_klu, bullish_trend=False, min_bars=5):
|
||||
curr_klu.set_pattern(Chan_KLU_PATTERN.BULLISH_ENGULFING)
|
||||
return
|
||||
|
||||
# 2. 看跌吞没形态:前阳后阴,后者完全吞没前者
|
||||
# 要求前面有明显的上涨趋势
|
||||
if prev_bullish and not curr_bullish and \
|
||||
abs(curr_klu.open - prev_klu.close) < 10 and \
|
||||
curr_klu.close < prev_klu.open and \
|
||||
check_long_trend(prev_klu, bullish_trend=True, min_bars=5):
|
||||
curr_klu.set_pattern(Chan_KLU_PATTERN.BEARISH_ENGULFING)
|
||||
return
|
||||
|
||||
# 3. 乌云盖顶:前阳后阴,后者开盘价高于前者最高价,收盘价在前者实体中部以下
|
||||
# 要求前面有明显的上涨趋势
|
||||
if prev_bullish and not curr_bullish and \
|
||||
curr_klu.open > prev_klu.high and \
|
||||
curr_klu.close < (prev_klu.open + prev_klu.close) / 2 and \
|
||||
curr_klu.close > prev_klu.open and \
|
||||
check_long_trend(prev_klu, bullish_trend=True, min_bars=5):
|
||||
curr_klu.set_pattern(Chan_KLU_PATTERN.DARK_CLOUD_COVER)
|
||||
return
|
||||
|
||||
# 4. 曙光初现:前阴后阳,后者开盘价低于前者最低价,收盘价在前者实体中部以上
|
||||
# 要求前面有明显的下跌趋势
|
||||
if not prev_bullish and curr_bullish and \
|
||||
curr_klu.open < prev_klu.low and \
|
||||
curr_klu.close > (prev_klu.open + prev_klu.close) / 2 and \
|
||||
curr_klu.close < prev_klu.open and \
|
||||
check_long_trend(prev_klu, bullish_trend=False, min_bars=5):
|
||||
curr_klu.set_pattern(Chan_KLU_PATTERN.PIERCING_LINE)
|
||||
return
|
||||
|
||||
# 平顶和平底移至三根K线形态中判断
|
||||
|
||||
|
||||
def _detect_triple_pattern(self, first_klu, second_klu, third_klu):
|
||||
"""检测三根K线形成的形态
|
||||
包括:早晨之星、黄昏之星、平顶、平底
|
||||
"""
|
||||
# 如果前两根K线已经有形态,不再识别三K线形态
|
||||
if first_klu.pattern != Chan_KLU_PATTERN.UNKNOWN or \
|
||||
second_klu.pattern != Chan_KLU_PATTERN.UNKNOWN:
|
||||
return
|
||||
|
||||
# 判断K线颜色(阴阳)
|
||||
first_bullish = first_klu.close > first_klu.open
|
||||
second_bullish = second_klu.close > second_klu.open
|
||||
third_bullish = third_klu.close > third_klu.open
|
||||
|
||||
# 计算实体大小
|
||||
first_body = abs(first_klu.close - first_klu.open)
|
||||
second_body = abs(second_klu.close - second_klu.open)
|
||||
third_body = abs(third_klu.close - third_klu.open)
|
||||
|
||||
# 检查是否存在长期趋势(至少需要5根K线的趋势)
|
||||
def check_long_trend(klu, bullish_trend=True, min_bars=5):
|
||||
"""检查是否存在长期趋势
|
||||
bullish_trend=True: 检查上涨趋势
|
||||
bullish_trend=False: 检查下跌趋势
|
||||
min_bars: 最少需要多少根K线形成趋势
|
||||
"""
|
||||
if not klu or not klu.pre:
|
||||
return False
|
||||
|
||||
# 使用EMA指标判断长期趋势
|
||||
if klu.ema52 > 0:
|
||||
if bullish_trend and klu.close < klu.ema52:
|
||||
return False
|
||||
if not bullish_trend and klu.close > klu.ema52:
|
||||
return False
|
||||
|
||||
# 检查连续的K线方向
|
||||
count = 0
|
||||
current = klu.pre
|
||||
|
||||
while current and count < min_bars:
|
||||
if not current.pre:
|
||||
break
|
||||
|
||||
if bullish_trend:
|
||||
# 上涨趋势:当前收盘价高于前一根收盘价
|
||||
if current.close <= current.pre.close:
|
||||
break
|
||||
else:
|
||||
# 下跌趋势:当前收盘价低于前一根收盘价
|
||||
if current.close >= current.pre.close:
|
||||
break
|
||||
|
||||
count += 1
|
||||
current = current.pre
|
||||
|
||||
return count >= min_bars
|
||||
|
||||
# 1. 早晨之星:第一根阴线,第二根十字星或小实体,第三根阳线
|
||||
# 要求前面有明显的下跌趋势
|
||||
if not first_bullish and third_bullish and \
|
||||
second_body < first_body * 0.3 and \
|
||||
third_body > first_body * 0.5 and \
|
||||
max(second_klu.open, second_klu.close) < first_klu.close and \
|
||||
min(second_klu.open, second_klu.close) < third_klu.open and \
|
||||
third_klu.close > (first_klu.open + first_klu.close) / 2 and \
|
||||
check_long_trend(first_klu, bullish_trend=False, min_bars=7):
|
||||
third_klu.set_pattern(Chan_KLU_PATTERN.MORNING_STAR)
|
||||
return
|
||||
|
||||
# 2. 黄昏之星:第一根阳线,第二根十字星或小实体,第三根阴线
|
||||
# 要求前面有明显的上涨趋势
|
||||
if first_bullish and not third_bullish and \
|
||||
second_body < first_body * 0.3 and \
|
||||
third_body > first_body * 0.5 and \
|
||||
min(second_klu.open, second_klu.close) > first_klu.close and \
|
||||
max(second_klu.open, second_klu.close) > third_klu.open and \
|
||||
third_klu.close < (first_klu.open + first_klu.close) / 2 and \
|
||||
check_long_trend(first_klu, bullish_trend=True, min_bars=7):
|
||||
third_klu.set_pattern(Chan_KLU_PATTERN.EVENING_STAR)
|
||||
return
|
||||
|
||||
# 3. 平顶:三根K线的最高点几乎相同(上升趋势中更有意义)
|
||||
# 要求前面有明显的上涨趋势
|
||||
if (abs(first_klu.high - second_klu.high) / first_klu.high < 0.0002 and
|
||||
abs(second_klu.high - third_klu.high) / second_klu.high < 0.0002 and
|
||||
check_long_trend(first_klu, bullish_trend=True, min_bars=7)):
|
||||
# 额外确认:价格接近阻力位或关键技术指标
|
||||
is_near_resistance = False
|
||||
|
||||
# 检查是否接近EMA52阻力位
|
||||
if first_klu.ema52 > 0:
|
||||
resistance_level = first_klu.ema52
|
||||
if abs(first_klu.high - resistance_level) / resistance_level < 0.01:
|
||||
is_near_resistance = True
|
||||
|
||||
# 检查是否有成交量确认(成交量减少表示上涨动能减弱)
|
||||
volume_confirmation = False
|
||||
if (first_klu.volume > 0 and second_klu.volume > 0 and third_klu.volume > 0 and
|
||||
third_klu.volume < second_klu.volume and second_klu.volume < first_klu.volume):
|
||||
volume_confirmation = True
|
||||
|
||||
if is_near_resistance or volume_confirmation:
|
||||
third_klu.set_pattern(Chan_KLU_PATTERN.TWEEZER_TOP)
|
||||
return
|
||||
|
||||
# 4. 平底:三根K线的最低点几乎相同(下降趋势中更有意义)
|
||||
# 要求前面有明显的下跌趋势
|
||||
if (abs(first_klu.low - second_klu.low) / first_klu.low < 0.0002 and
|
||||
abs(second_klu.low - third_klu.low) / second_klu.low < 0.0002 and
|
||||
check_long_trend(first_klu, bullish_trend=False, min_bars=7)):
|
||||
# 额外确认:价格接近支撑位或关键技术指标
|
||||
is_near_support = False
|
||||
|
||||
# 检查是否接近EMA52支撑位
|
||||
if first_klu.ema52 > 0:
|
||||
support_level = first_klu.ema52
|
||||
if abs(first_klu.low - support_level) / support_level < 0.01:
|
||||
is_near_support = True
|
||||
|
||||
# 检查是否有成交量确认(成交量减少表示下跌动能减弱)
|
||||
volume_confirmation = False
|
||||
if (first_klu.volume > 0 and second_klu.volume > 0 and third_klu.volume > 0 and
|
||||
third_klu.volume < second_klu.volume and second_klu.volume < first_klu.volume):
|
||||
volume_confirmation = True
|
||||
|
||||
if is_near_support or volume_confirmation:
|
||||
third_klu.set_pattern(Chan_KLU_PATTERN.TWEEZER_BOTTOM)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
from chanlun.core.ChanEnum import (
|
||||
Chan_BI_DIR,
|
||||
Chan_BSP_DIR,
|
||||
Chan_BSP_TYPE,
|
||||
Chan_FX_TYPE,
|
||||
Chan_K_DIR,
|
||||
Chan_KLC_FX,
|
||||
Chan_KLC_STATE,
|
||||
Chan_KLINE_DIR,
|
||||
Chan_KLU_PATTERN,
|
||||
Chan_PRICE_TREND,
|
||||
Chan_SEG_DIR,
|
||||
Chan_ZS_DIR,
|
||||
)
|
||||
from chanlun.core.ChanKLC import ChanKLC
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
|
||||
|
||||
class SegBuilderMixin:
|
||||
def get_seg_list(self, bi_list):
|
||||
seg_list = []
|
||||
up_bi_list = []
|
||||
down_bi_list = []
|
||||
last_up_bi = None
|
||||
last_down_bi = None
|
||||
last_up_sbi = None
|
||||
last_down_sbi = None
|
||||
last_seg = None
|
||||
up_sbi_list = []
|
||||
down_sbi_list = []
|
||||
look_for_bottom = False
|
||||
look_for_top = False
|
||||
for bi in bi_list:
|
||||
#print(len(up_sbi_list), len(down_sbi_list))
|
||||
if len(seg_list) > 0:
|
||||
# Last seg is up
|
||||
if last_seg.dir == Chan_SEG_DIR.UP:
|
||||
if bi.dir == Chan_BI_DIR.DOWN:
|
||||
if len(down_sbi_list) > 1:
|
||||
# Check down sbi inclusion
|
||||
included = last_down_sbi.check_bi_included(bi)
|
||||
if not included:
|
||||
down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
|
||||
last_down_sbi.set_next(down_sbi)
|
||||
last_down_sbi.set_end_bi(last_down_bi)
|
||||
down_sbi.set_pre(last_down_sbi)
|
||||
down_sbi_list.append(down_sbi)
|
||||
fx = last_down_sbi.check_fx()
|
||||
# Found top
|
||||
if fx == Chan_FX_TYPE.TOP:
|
||||
if look_for_top:
|
||||
seg_list[-2].set_sure(bi)
|
||||
look_for_top = False
|
||||
#print(bi.start_time, look_for_top, "UP 1")
|
||||
# Has gap and search for bottom fx
|
||||
if last_down_sbi.has_fx_gap:
|
||||
look_for_bottom = True
|
||||
last_seg.pre_set_end_bi(bi_list[last_down_sbi.start_bi.index - 1])
|
||||
seg = ChanSEG(last_down_sbi.start_bi, len(seg_list), Chan_SEG_DIR.DOWN, bi)
|
||||
seg_list.append(seg)
|
||||
last_seg.set_next(seg)
|
||||
seg.set_pre(last_seg)
|
||||
last_seg = seg
|
||||
up_sbi_list = []
|
||||
last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir)
|
||||
up_sbi_list.append(last_up_sbi)
|
||||
#up_sbi_list.append(last_up_sbi)
|
||||
#print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 1")
|
||||
#print(bi.start_time, look_for_top, "UP 2")
|
||||
# No gap end SEG
|
||||
else:
|
||||
if look_for_bottom:
|
||||
look_for_bottom = False
|
||||
last_seg.set_start_bi(last_down_sbi.start_bi)
|
||||
seg_list[-2].set_end_bi(bi_list[last_down_sbi.start_bi.index - 1], bi)
|
||||
up_sbi_list = []
|
||||
last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir)
|
||||
up_sbi_list.append(last_up_sbi)
|
||||
last_seg.add_bi(bi)
|
||||
#up_sbi_list.append(last_up_sbi)
|
||||
#print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 2")
|
||||
#print(bi.start_time, look_for_top, "UP 3")
|
||||
else:
|
||||
last_seg.set_end_bi(bi_list[last_down_sbi.start_bi.index - 1], bi)
|
||||
seg = ChanSEG(last_down_sbi.start_bi, len(seg_list), Chan_SEG_DIR.DOWN, bi)
|
||||
seg_list.append(seg)
|
||||
last_seg.set_next(seg)
|
||||
seg.set_pre(last_seg)
|
||||
last_seg = seg
|
||||
#print(last_down_sbi.end_bi.start_time, "Normal UP SEG", last_up_sbi.start_bi.start_time, bi.start_time)
|
||||
#l_up_sbi = up_sbi_list[-1]
|
||||
up_sbi_list = []
|
||||
last_up_sbi = ChanSBI(last_up_bi, len(up_sbi_list), last_up_bi.dir)
|
||||
up_sbi_list.append(last_up_sbi)
|
||||
#up_sbi_list.append(last_up_sbi)
|
||||
#print(last_up_bi.start_time, last_up_sbi.start_bi.start_time, "Reset up sbi list 3")
|
||||
last_down_sbi = down_sbi
|
||||
last_seg.add_bi(bi)
|
||||
else:
|
||||
if len(down_sbi_list) == 1:
|
||||
included = last_down_sbi.check_bi_included(bi)
|
||||
if not included:
|
||||
down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
|
||||
last_down_sbi.set_next(down_sbi)
|
||||
last_down_sbi.set_end_bi(last_down_bi)
|
||||
down_sbi.set_pre(last_down_sbi)
|
||||
down_sbi_list.append(down_sbi)
|
||||
last_down_sbi = down_sbi
|
||||
#print(bi.start_time, look_for_top, "UP 4")
|
||||
last_seg.add_bi(bi)
|
||||
|
||||
else:
|
||||
last_down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
|
||||
down_sbi_list.append(last_down_sbi)
|
||||
last_seg.add_bi(bi)
|
||||
#print(bi.start_time, look_for_top, "UP 5")
|
||||
else:
|
||||
if last_up_sbi:
|
||||
included = last_up_sbi.check_bi_included(bi)
|
||||
if not included:
|
||||
up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
|
||||
last_up_sbi.set_next(up_sbi)
|
||||
last_up_sbi.set_end_bi(last_up_bi)
|
||||
up_sbi.set_pre(last_up_sbi)
|
||||
up_sbi_list.append(up_sbi)
|
||||
last_up_sbi = up_sbi
|
||||
#print(bi.start_time, look_for_top, "UP 6")
|
||||
last_seg.add_bi(bi)
|
||||
|
||||
# Last seg is down
|
||||
else:
|
||||
if bi.dir == Chan_BI_DIR.UP:
|
||||
if len(up_sbi_list) > 1:
|
||||
# Check down sbi inclusion
|
||||
included = last_up_sbi.check_bi_included(bi)
|
||||
if not included:
|
||||
up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
|
||||
last_up_sbi.set_next(up_sbi)
|
||||
last_up_sbi.set_end_bi(last_up_bi)
|
||||
up_sbi.set_pre(last_up_sbi)
|
||||
up_sbi_list.append(up_sbi)
|
||||
fx = last_up_sbi.check_fx()
|
||||
# Found bottom
|
||||
if fx == Chan_FX_TYPE.BOTTOM:
|
||||
if look_for_bottom:
|
||||
seg_list[-2].set_sure(bi)
|
||||
look_for_bottom = False
|
||||
#print(bi.start_time, look_for_top, "DOWN 1")
|
||||
# Has gap and search for bottom fx
|
||||
if last_up_sbi.has_fx_gap:
|
||||
look_for_top = True
|
||||
last_seg.pre_set_end_bi(bi_list[last_up_sbi.start_bi.index - 1])
|
||||
seg = ChanSEG(last_up_sbi.start_bi, len(seg_list), Chan_SEG_DIR.UP, bi)
|
||||
seg_list.append(seg)
|
||||
last_seg.set_next(seg)
|
||||
seg.set_pre(last_seg)
|
||||
last_seg = seg
|
||||
down_sbi_list = []
|
||||
last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir)
|
||||
down_sbi_list.append(last_down_sbi)
|
||||
#down_sbi_list.append(last_down_sbi)
|
||||
#print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 1")
|
||||
#print(bi.start_time, look_for_top, "DOWN 2")
|
||||
# No gap end SEG
|
||||
else:
|
||||
if look_for_top:
|
||||
look_for_top = False
|
||||
last_seg.set_start_bi(last_up_sbi.start_bi)
|
||||
seg_list[-2].set_end_bi(bi_list[last_up_sbi.start_bi.index - 1], bi)
|
||||
down_sbi_list = []
|
||||
last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir)
|
||||
down_sbi_list.append(last_down_sbi)
|
||||
last_seg.add_bi(bi)
|
||||
#down_sbi_list.append(last_down_sbi)
|
||||
#print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 2")
|
||||
#print(bi.start_time, look_for_top, "DOWN 3")
|
||||
else:
|
||||
last_seg.set_end_bi(bi_list[last_up_sbi.start_bi.index - 1], bi)
|
||||
seg = ChanSEG(last_up_sbi.start_bi, len(seg_list), Chan_SEG_DIR.UP, bi)
|
||||
#print(last_up_sbi.start_bi.start_time)
|
||||
last_seg.set_next(seg)
|
||||
seg.set_pre(last_seg)
|
||||
seg_list.append(seg)
|
||||
last_seg = seg
|
||||
#print(last_up_sbi.end_bi.start_time, "Normal DOWN SEG", last_down_sbi.start_bi.start_time, bi.start_time)
|
||||
down_sbi_list = []
|
||||
last_down_sbi = ChanSBI(last_down_bi, len(down_sbi_list), last_down_bi.dir)
|
||||
down_sbi_list.append(last_down_sbi)
|
||||
#down_sbi_list.append(last_down_sbi)
|
||||
#print(last_down_bi.start_time, last_down_sbi.start_bi.start_time, "Reset down sbi list 3")
|
||||
last_up_sbi = up_sbi
|
||||
last_seg.add_bi(bi)
|
||||
else:
|
||||
if len(up_sbi_list) == 1:
|
||||
#last_up_sbi = up_sbi_list[-1]
|
||||
included = last_up_sbi.check_bi_included(bi)
|
||||
if not included:
|
||||
up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
|
||||
last_up_sbi.set_next(up_sbi)
|
||||
last_up_sbi.set_end_bi(last_up_bi)
|
||||
up_sbi.set_pre(last_up_sbi)
|
||||
up_sbi_list.append(up_sbi)
|
||||
last_up_sbi = up_sbi
|
||||
last_seg.add_bi(bi)
|
||||
#print(bi.start_time, look_for_top, "DOWN 4")
|
||||
else:
|
||||
last_up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
|
||||
up_sbi_list.append(last_up_sbi)
|
||||
last_seg.add_bi(bi)
|
||||
#print(bi.start_time, look_for_top, "DOWN 5")
|
||||
else:
|
||||
if last_down_sbi:
|
||||
included = last_down_sbi.check_bi_included(bi)
|
||||
if not included:
|
||||
down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
|
||||
last_down_sbi.set_next(down_sbi)
|
||||
last_down_sbi.set_end_bi(last_down_bi)
|
||||
down_sbi.set_pre(last_down_sbi)
|
||||
down_sbi_list.append(down_sbi)
|
||||
last_down_sbi = down_sbi
|
||||
last_seg.add_bi(bi)
|
||||
#print(bi.start_time, look_for_top, look_for_bottom, "DOWN 6")
|
||||
# len(seg_list) = 0
|
||||
else:
|
||||
if bi.check_overlap():
|
||||
if bi.dir == Chan_BI_DIR.UP:
|
||||
seg = ChanSEG(bi, len(seg_list), Chan_SEG_DIR.UP, bi)
|
||||
last_up_bi = bi
|
||||
last_up_sbi = ChanSBI(bi, len(up_sbi_list), bi.dir)
|
||||
seg_list.append(seg)
|
||||
last_seg = seg
|
||||
#print(bi.start_time, 'Create first UP SEG')
|
||||
else:
|
||||
seg = ChanSEG(bi, len(seg_list), Chan_SEG_DIR.DOWN, bi)
|
||||
last_down_bi = bi
|
||||
last_down_sbi = ChanSBI(bi, len(down_sbi_list), bi.dir)
|
||||
seg_list.append(seg)
|
||||
last_seg = seg
|
||||
#print(bi.start_time, 'Create first DOWN SEG')
|
||||
if bi.dir == Chan_BI_DIR.UP:
|
||||
last_up_bi = bi
|
||||
up_bi_list.append(bi)
|
||||
else:
|
||||
last_down_bi = bi
|
||||
down_bi_list.append(bi)
|
||||
"""
|
||||
if len(seg_list) > 1:
|
||||
seg = seg_list[-1]
|
||||
last_seg = seg_list[-2]
|
||||
last_seg_bi = last_seg.bi_list[-3]
|
||||
bi_index = seg.start_bi.index
|
||||
for i in range(bi_index, len(bi_list) - 1):
|
||||
# last seg is down
|
||||
if seg.dir == Chan_SEG_DIR.UP:
|
||||
if bi_list[i].dir == Chan_BI_DIR.UP:
|
||||
last_seg_peak = last_seg_bi.high
|
||||
if bi_list[i].high > last_seg_peak:
|
||||
# The confirmed
|
||||
print("Last UP seg is broken, create a new seg. 1")
|
||||
seg.pre_set_end_bi(bi_list[i])
|
||||
seg = ChanSEG(bi_list[i+1], len(seg_list), Chan_SEG_DIR.DOWN, bi)
|
||||
seg_list.append(seg)
|
||||
last_seg = seg_list[-2]
|
||||
if len(last_seg.bi_list) > 3:
|
||||
last_seg_bi = last_seg.bi_list[-3]
|
||||
|
||||
else:
|
||||
if bi_list[i].dir == Chan_BI_DIR.DOWN:
|
||||
last_seg_peak = last_seg_bi.low
|
||||
if bi_list[i].low < last_seg_peak:
|
||||
print("Last DOWN seg is broken, create a new seg. 1")
|
||||
seg.pre_set_end_bi(bi_list[i])
|
||||
seg = ChanSEG(bi_list[i+1], len(seg_list), Chan_SEG_DIR.UP, bi)
|
||||
seg_list.append(seg)
|
||||
last_seg = seg_list[-2]
|
||||
if len(last_seg.bi_list) > 3:
|
||||
last_seg_bi = last_seg.bi_list[-3]
|
||||
else:
|
||||
if len(seg_list) == 1:
|
||||
last_seg = seg_list[-1]
|
||||
bi_index = last_seg.bi_list[0].index
|
||||
for i in range(bi_index, len(bi_list) - 1):
|
||||
if i > bi_index + 2:
|
||||
last_seg_peak = bi_list[i-2].high
|
||||
# last seg is down
|
||||
if last_seg.dir == Chan_SEG_DIR.DOWN:
|
||||
if bi_list[i].dir == Chan_BI_DIR.UP:
|
||||
if bi_list[i].high > last_seg_peak:
|
||||
print("Last seg is broken, create a new seg. 2")
|
||||
last_seg.pre_set_end_bi(bi_list[i-1])
|
||||
seg = ChanSEG(bi_list[i], len(seg_list), Chan_SEG_DIR.UP, bi)
|
||||
seg_list.append(seg)
|
||||
last_seg = seg
|
||||
last_seg_bi = bi_list[i]
|
||||
break
|
||||
"""
|
||||
#self.cal_bi_zs(seg_list)
|
||||
return seg_list
|
||||
@@ -0,0 +1,699 @@
|
||||
"""TF_DF builder mixin — 由 split_tfdf_builders 自动生成,逻辑与原 TF_DF 一致。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
from chanlun.core.ChanEnum import (
|
||||
Chan_BI_DIR,
|
||||
Chan_BSP_DIR,
|
||||
Chan_BSP_TYPE,
|
||||
Chan_FX_TYPE,
|
||||
Chan_K_DIR,
|
||||
Chan_KLC_FX,
|
||||
Chan_KLC_STATE,
|
||||
Chan_KLINE_DIR,
|
||||
Chan_KLU_PATTERN,
|
||||
Chan_PRICE_TREND,
|
||||
Chan_SEG_DIR,
|
||||
Chan_ZS_DIR,
|
||||
)
|
||||
from chanlun.core.ChanKLC import ChanKLC
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
|
||||
|
||||
class ZsBuilderMixin:
|
||||
def get_zs_state(self, df):
|
||||
bi_list = self.cal_bi_list(self.get_klc_list(self.get_kl_data(df)))
|
||||
seg_list = self.get_seg_list(bi_list)
|
||||
zs_list = self.calculate_zs(seg_list)
|
||||
for zs in zs_list:
|
||||
last_zs = zs
|
||||
return zs_list
|
||||
|
||||
def cal_bi_zs(self, seg_list):
|
||||
bi_zs_list = []
|
||||
for seg in seg_list:
|
||||
zs_list = seg.cal_bi_zs()
|
||||
if len(zs_list) > 0:
|
||||
bi_zs_list = list(bi_zs_list) + list(zs_list)
|
||||
return bi_zs_list
|
||||
# 跨段不相连的中枢
|
||||
|
||||
def cal_bi_zs_list(self, bi_list):
|
||||
"""
|
||||
根据缠论笔中枢定义计算中枢(参照 get_zs_list 线段中枢判断规则)
|
||||
从第4根笔开始(索引3),每3根笔为一组检查
|
||||
上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移)
|
||||
下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移)
|
||||
中枢可按两笔一组继续扩展到5根、7根...
|
||||
"""
|
||||
bi_zs_list = []
|
||||
if len(bi_list) < 3:
|
||||
return bi_zs_list
|
||||
|
||||
last_zs = None
|
||||
start_idx = 3
|
||||
|
||||
while start_idx < len(bi_list):
|
||||
if start_idx + 2 >= len(bi_list):
|
||||
break
|
||||
|
||||
bi1 = bi_list[start_idx]
|
||||
bi2 = bi_list[start_idx + 1]
|
||||
bi3 = bi_list[start_idx + 2]
|
||||
|
||||
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zg = min(bi1.high, bi2.high, bi3.high)
|
||||
zd = max(bi1.low, bi2.low, bi3.low)
|
||||
|
||||
if zg <= zd:
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
valid = False
|
||||
if last_zs is None:
|
||||
if bi1.dir == Chan_BI_DIR.DOWN:
|
||||
zs_dir = Chan_ZS_DIR.UP
|
||||
valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
|
||||
else:
|
||||
zs_dir = Chan_ZS_DIR.DOWN
|
||||
valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
|
||||
else:
|
||||
is_up_zs = zg > last_zs.zg
|
||||
is_down_zs = zd < last_zs.zd
|
||||
|
||||
if is_up_zs:
|
||||
zs_dir = Chan_ZS_DIR.UP
|
||||
valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
|
||||
elif is_down_zs:
|
||||
zs_dir = Chan_ZS_DIR.DOWN
|
||||
valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
|
||||
|
||||
if not valid:
|
||||
start_idx += 1
|
||||
continue
|
||||
gg = max(bi1.high, bi2.high, bi3.high)
|
||||
dd = min(bi1.low, bi2.low, bi3.low)
|
||||
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
|
||||
zs.set_zg(zg)
|
||||
zs.set_zd(zd)
|
||||
zs.set_gg(gg)
|
||||
zs.set_dd(dd)
|
||||
zs.is_sure = False
|
||||
zs.bi_list = [bi1, bi2, bi3]
|
||||
|
||||
added_after_leave = []
|
||||
leave_index = start_idx + 4
|
||||
while leave_index < len(bi_list):
|
||||
b = bi_list[leave_index]
|
||||
if not b.is_sure:
|
||||
break
|
||||
if b.high >= zs.zd and b.low <= zs.zg:
|
||||
added_after_leave.append(b.pre)
|
||||
added_after_leave.append(b)
|
||||
else:
|
||||
break
|
||||
leave_index += 2
|
||||
|
||||
if added_after_leave:
|
||||
bis_for_zs = list(zs.bi_list) + list(added_after_leave)
|
||||
bi_highs = [bi.high for bi in bis_for_zs]
|
||||
bi_lows = [bi.low for bi in bis_for_zs]
|
||||
zs.set_gg(max(bi_highs))
|
||||
zs.set_dd(min(bi_lows))
|
||||
zs.bi_list = bis_for_zs
|
||||
bi = bis_for_zs[-1]
|
||||
if bi.is_sure:
|
||||
zs.set_end_bi(bi, bi.sure_time)
|
||||
|
||||
start_idx = start_idx + len(added_after_leave)
|
||||
else:
|
||||
zs.set_end_bi(bi3, bi3.sure_time)
|
||||
|
||||
if last_zs:
|
||||
last_zs.set_next(zs)
|
||||
zs.set_pre(last_zs)
|
||||
|
||||
bi_zs_list.append(zs)
|
||||
last_zs = zs
|
||||
|
||||
start_idx += 4
|
||||
|
||||
if last_zs:
|
||||
last_zs.is_sure = bi_list[-1].is_sure
|
||||
|
||||
if last_zs and not last_zs.is_sure:
|
||||
if last_zs.bi_list and len(last_zs.bi_list) > 0:
|
||||
last_bi_of_zs = last_zs.bi_list[-1]
|
||||
last_bi_idx = -1
|
||||
for i, bi in enumerate(bi_list):
|
||||
if bi == last_bi_of_zs:
|
||||
last_bi_idx = i
|
||||
break
|
||||
|
||||
has_leave = False
|
||||
if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list):
|
||||
for i in range(last_bi_idx + 1, len(bi_list)):
|
||||
bi = bi_list[i]
|
||||
if bi.is_sure:
|
||||
leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \
|
||||
(bi.high < last_zs.zd and bi.low < last_zs.zd)
|
||||
if leave:
|
||||
has_leave = True
|
||||
break
|
||||
|
||||
if has_leave:
|
||||
if last_bi_of_zs.is_sure:
|
||||
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
|
||||
return bi_zs_list
|
||||
|
||||
def get_bi_zs_list(self, bi_list):
|
||||
"""
|
||||
根据缠论笔中枢定义计算中枢(完全参照 get_seg_zs_list 线段中枢判断规则)
|
||||
从第4根笔开始(索引3),每3根笔为一组检查
|
||||
上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移)
|
||||
下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移)
|
||||
盘整/扩张:后中枢与前中枢整体区间有交集 → 合并扩展
|
||||
中枢可按两笔一组继续扩展到5根、7根...
|
||||
"""
|
||||
bi_zs_list = []
|
||||
if len(bi_list) < 3:
|
||||
return bi_zs_list
|
||||
|
||||
last_zs = None
|
||||
start_idx = 3
|
||||
|
||||
while start_idx < len(bi_list):
|
||||
if start_idx + 2 >= len(bi_list):
|
||||
break
|
||||
|
||||
bi1 = bi_list[start_idx]
|
||||
bi2 = bi_list[start_idx + 1]
|
||||
bi3 = bi_list[start_idx + 2]
|
||||
|
||||
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zg = min(bi1.high, bi2.high, bi3.high)
|
||||
zd = max(bi1.low, bi2.low, bi3.low)
|
||||
|
||||
if zg <= zd:
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
valid = False
|
||||
if last_zs is None:
|
||||
if bi1.dir == Chan_BI_DIR.DOWN:
|
||||
zs_dir = Chan_ZS_DIR.UP
|
||||
valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
|
||||
else:
|
||||
zs_dir = Chan_ZS_DIR.DOWN
|
||||
valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
|
||||
else:
|
||||
is_up_zs = zd > last_zs.zg
|
||||
is_down_zs = zg < last_zs.zd
|
||||
|
||||
if is_up_zs:
|
||||
zs_dir = Chan_ZS_DIR.UP
|
||||
valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
|
||||
elif is_down_zs:
|
||||
zs_dir = Chan_ZS_DIR.DOWN
|
||||
valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
|
||||
|
||||
create_new_zs = False
|
||||
if not valid:
|
||||
# 如果新中枢和前一个中枢的中枢区间有重叠,不形成新中枢,合并扩展
|
||||
if last_zs is not None:
|
||||
is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or \
|
||||
(zg < last_zs.zg and zg > last_zs.zd) or \
|
||||
(zg > last_zs.zg and zd < last_zs.zd) or \
|
||||
(zg < last_zs.zg and zd > last_zs.zd)
|
||||
if is_in_last_zs:
|
||||
# 扩展当前中枢:将 bi1-bi3 加入 last_zs
|
||||
for bi in [bi1, bi2, bi3]:
|
||||
if bi not in last_zs.bi_list:
|
||||
last_zs.add_bi(bi)
|
||||
create_new_zs = False
|
||||
else:
|
||||
start_idx += 1
|
||||
continue
|
||||
else:
|
||||
start_idx += 1
|
||||
continue
|
||||
else:
|
||||
create_new_zs = True
|
||||
|
||||
# 新中枢形成时确认前一个中枢
|
||||
if last_zs and create_new_zs:
|
||||
last_bi = last_zs.bi_list[-1]
|
||||
if last_bi and last_bi.is_sure:
|
||||
last_zs.is_sure = True
|
||||
last_zs.set_end_bi(last_bi, last_bi.sure_time)
|
||||
|
||||
zs = last_zs
|
||||
if create_new_zs:
|
||||
gg = max(bi1.high, bi2.high, bi3.high)
|
||||
dd = min(bi1.low, bi2.low, bi3.low)
|
||||
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
|
||||
zs.set_zg(zg)
|
||||
zs.set_zd(zd)
|
||||
zs.set_gg(gg)
|
||||
zs.set_dd(dd)
|
||||
zs.is_sure = False
|
||||
zs.bi_list = [bi1, bi2, bi3]
|
||||
|
||||
# 离开后回抽扩展检查
|
||||
added_after_leave = []
|
||||
leave_index = start_idx + 4
|
||||
while leave_index < len(bi_list):
|
||||
b = bi_list[leave_index]
|
||||
if not b.is_sure:
|
||||
break
|
||||
if b.high >= zs.zd and b.low <= zs.zg:
|
||||
added_after_leave.append(b.pre)
|
||||
added_after_leave.append(b)
|
||||
else:
|
||||
break
|
||||
leave_index += 2
|
||||
|
||||
if added_after_leave:
|
||||
bis_for_zs = list(zs.bi_list) + list(added_after_leave)
|
||||
bi_highs = [bi.high for bi in bis_for_zs]
|
||||
bi_lows = [bi.low for bi in bis_for_zs]
|
||||
zs.set_gg(max(bi_highs))
|
||||
zs.set_dd(min(bi_lows))
|
||||
zs.bi_list = bis_for_zs
|
||||
bi = bis_for_zs[-1]
|
||||
if bi.is_sure:
|
||||
zs.set_end_bi(bi, bi.sure_time)
|
||||
start_idx = start_idx + len(added_after_leave)
|
||||
else:
|
||||
if create_new_zs:
|
||||
zs.set_end_bi(bi3, bi3.sure_time)
|
||||
|
||||
if create_new_zs:
|
||||
if last_zs:
|
||||
last_zs.set_next(zs)
|
||||
zs.set_pre(last_zs)
|
||||
bi_zs_list.append(zs)
|
||||
last_zs = zs
|
||||
|
||||
start_idx += 4
|
||||
|
||||
# 最后一个中枢:根据 bi_list 最后一笔确认状态
|
||||
if last_zs:
|
||||
last_zs.is_sure = bi_list[-1].is_sure
|
||||
|
||||
if last_zs and not last_zs.is_sure:
|
||||
if last_zs.bi_list and len(last_zs.bi_list) > 0:
|
||||
last_bi_of_zs = last_zs.bi_list[-1]
|
||||
last_bi_idx = -1
|
||||
for i, bi in enumerate(bi_list):
|
||||
if bi == last_bi_of_zs:
|
||||
last_bi_idx = i
|
||||
break
|
||||
|
||||
has_leave = False
|
||||
if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list):
|
||||
for i in range(last_bi_idx + 1, len(bi_list)):
|
||||
bi = bi_list[i]
|
||||
if bi.is_sure:
|
||||
leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \
|
||||
(bi.high < last_zs.zd and bi.low < last_zs.zd)
|
||||
if leave:
|
||||
has_leave = True
|
||||
break
|
||||
|
||||
if has_leave:
|
||||
if last_bi_of_zs.is_sure:
|
||||
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
|
||||
|
||||
return bi_zs_list
|
||||
|
||||
|
||||
def cal_bi_zs_list_pure(self, bi_list):
|
||||
bi_zs_list = []
|
||||
if len(bi_list) < 3:
|
||||
return bi_zs_list
|
||||
|
||||
def get_zs_range(bis):
|
||||
zg = min(bi.high for bi in bis)
|
||||
zd = max(bi.low for bi in bis)
|
||||
return zg, zd
|
||||
|
||||
def is_bi_overlap_range(bi, zg, zd):
|
||||
return bi.high >= zd and bi.low <= zg
|
||||
|
||||
def check_zs_position_filter(last_zs, zg, zd, bis):
|
||||
if last_zs is None:
|
||||
return True
|
||||
if zg <= last_zs.zd:
|
||||
return bis[0].dir == Chan_BI_DIR.UP and bis[-1].dir == Chan_BI_DIR.UP
|
||||
if zd >= last_zs.zg:
|
||||
return bis[0].dir == Chan_BI_DIR.DOWN and bis[-1].dir == Chan_BI_DIR.DOWN
|
||||
return True
|
||||
|
||||
def set_zs_bi_list(zs, bis):
|
||||
zs.bi_list = list(bis)
|
||||
for bi in zs.bi_list:
|
||||
bi.set_bi_zs(zs)
|
||||
zs.set_gg(max(bi.high for bi in zs.bi_list))
|
||||
zs.set_dd(min(bi.low for bi in zs.bi_list))
|
||||
zs.classify_zs()
|
||||
|
||||
last_zs = None
|
||||
start_idx = 0
|
||||
while start_idx + 2 < len(bi_list):
|
||||
bi1 = bi_list[start_idx]
|
||||
bi2 = bi_list[start_idx + 1]
|
||||
bi3 = bi_list[start_idx + 2]
|
||||
|
||||
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
if not (bi1.dir != bi2.dir and bi1.dir == bi3.dir):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zg, zd = get_zs_range([bi1, bi2, bi3])
|
||||
if zg <= zd:
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
bis_for_zs = [bi1, bi2, bi3]
|
||||
extend_idx = start_idx + 3
|
||||
while extend_idx + 1 < len(bi_list):
|
||||
leave_bi = bi_list[extend_idx]
|
||||
back_bi = bi_list[extend_idx + 1]
|
||||
if not (leave_bi.is_sure and back_bi.is_sure):
|
||||
break
|
||||
if not is_bi_overlap_range(back_bi, zg, zd):
|
||||
break
|
||||
bis_for_zs.append(leave_bi)
|
||||
bis_for_zs.append(back_bi)
|
||||
extend_idx += 2
|
||||
|
||||
if not check_zs_position_filter(last_zs, zg, zd, bis_for_zs):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zs_dir = Chan_ZS_DIR.UP if bi1.dir == Chan_BI_DIR.DOWN else Chan_ZS_DIR.DOWN
|
||||
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
|
||||
zs.set_zg(zg)
|
||||
zs.set_zd(zd)
|
||||
|
||||
set_zs_bi_list(zs, bis_for_zs)
|
||||
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
|
||||
|
||||
if last_zs:
|
||||
last_zs.set_next(zs)
|
||||
zs.set_pre(last_zs)
|
||||
|
||||
bi_zs_list.append(zs)
|
||||
last_zs = zs
|
||||
start_idx = start_idx + len(bis_for_zs)
|
||||
|
||||
# 与 cal_bi_zs_list 一致:最后一笔未确认时末中枢标为未完成;若其后已出现确认的离开笔,仍按离开前最后一笔确认中枢结束
|
||||
if last_zs:
|
||||
last_zs.is_sure = bi_list[-1].is_sure
|
||||
|
||||
if last_zs and not last_zs.is_sure:
|
||||
if last_zs.bi_list and len(last_zs.bi_list) > 0:
|
||||
last_bi_of_zs = last_zs.bi_list[-1]
|
||||
last_bi_idx = -1
|
||||
for i, bi in enumerate(bi_list):
|
||||
if bi == last_bi_of_zs:
|
||||
last_bi_idx = i
|
||||
break
|
||||
|
||||
has_leave = False
|
||||
if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list):
|
||||
for i in range(last_bi_idx + 1, len(bi_list)):
|
||||
bi = bi_list[i]
|
||||
if bi.is_sure:
|
||||
leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \
|
||||
(bi.high < last_zs.zd and bi.low < last_zs.zd)
|
||||
if leave:
|
||||
has_leave = True
|
||||
break
|
||||
|
||||
if has_leave:
|
||||
if last_bi_of_zs.is_sure:
|
||||
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
|
||||
|
||||
return bi_zs_list
|
||||
|
||||
def get_zs_list(self, bi_list, seg_list):
|
||||
"""兼容历史 API:线段中枢列表。"""
|
||||
return self.get_seg_zs_list(seg_list)
|
||||
|
||||
def calculate_seg_zs(self, seg_list):
|
||||
return self.get_seg_zs_list(seg_list)
|
||||
|
||||
def get_seg_zs_list(self, seg_list):
|
||||
"""
|
||||
根据缠论线段中枢定义计算中枢
|
||||
从第4根线段开始(索引3),每3根线段为一组检查
|
||||
上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移)
|
||||
下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移)
|
||||
盘整/扩张:后中枢与前中枢整体区间(GG/DD)有交集
|
||||
中枢可按两段一组继续扩展到5根、7根...
|
||||
"""
|
||||
zs_list = []
|
||||
if len(seg_list) < 3:
|
||||
return zs_list
|
||||
|
||||
last_zs = None
|
||||
|
||||
# 从第4根线段开始(索引3),每3根为一组
|
||||
start_idx = 3
|
||||
|
||||
while start_idx < len(seg_list):
|
||||
# 取连续3个线段
|
||||
if start_idx + 2 >= len(seg_list):
|
||||
break
|
||||
|
||||
seg1 = seg_list[start_idx]
|
||||
seg2 = seg_list[start_idx + 1]
|
||||
seg3 = seg_list[start_idx + 2]
|
||||
|
||||
# 三个线段都必须是已确认的
|
||||
if not (seg1.is_sure and seg2.is_sure and seg3.is_sure):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
# 计算这3个线段的中枢区间
|
||||
zg = min(seg1.high, seg2.high, seg3.high)
|
||||
zd = max(seg1.low, seg2.low, seg3.low)
|
||||
|
||||
if zg <= zd:
|
||||
start_idx += 1
|
||||
#print(seg1.start_bi.start_klc.end_time, "not valid", zg, zd)
|
||||
continue
|
||||
|
||||
# 判断中枢类型(按注释定义)
|
||||
# 上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移)
|
||||
# 下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移)
|
||||
# 盘整/扩张:后中枢与前中枢区间有交集
|
||||
if last_zs is None:
|
||||
# 第一个中枢仅按线段形态判定方向
|
||||
if seg1.dir == Chan_SEG_DIR.DOWN:
|
||||
# 下跌+上涨+下跌,对应上涨中枢
|
||||
zs_dir = Chan_ZS_DIR.UP
|
||||
valid = (seg2.dir == Chan_SEG_DIR.UP and seg3.dir == Chan_SEG_DIR.DOWN)
|
||||
else:
|
||||
# 上涨+下跌+上涨,对应下跌中枢
|
||||
zs_dir = Chan_ZS_DIR.DOWN
|
||||
valid = (seg2.dir == Chan_SEG_DIR.DOWN and seg3.dir == Chan_SEG_DIR.UP)
|
||||
else:
|
||||
is_up_zs = zd > last_zs.zg
|
||||
is_down_zs = zg < last_zs.zd
|
||||
|
||||
if is_up_zs:
|
||||
# 不重叠上移
|
||||
zs_dir = Chan_ZS_DIR.UP
|
||||
valid = (seg1.dir == Chan_SEG_DIR.DOWN and seg2.dir == Chan_SEG_DIR.UP and seg3.dir == Chan_SEG_DIR.DOWN)
|
||||
elif is_down_zs:
|
||||
# 不重叠下移
|
||||
zs_dir = Chan_ZS_DIR.DOWN
|
||||
valid = (seg1.dir == Chan_SEG_DIR.UP and seg2.dir == Chan_SEG_DIR.DOWN and seg3.dir == Chan_SEG_DIR.UP)
|
||||
create_new_zs = False
|
||||
# 验证是否有效
|
||||
if not valid:
|
||||
# 如果新中枢和前一个中枢的中枢区间有重叠,不行成新中枢需要合并两个中枢
|
||||
is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or (zg < last_zs.zg and zg > last_zs.zd) or (zg > last_zs.zg and zd < last_zs.zd) or (zg < last_zs.zg and zd > last_zs.zd)
|
||||
if is_in_last_zs:
|
||||
#print(seg1.start_time, "New zs is in last zs, not valid")
|
||||
last_zs.extend_zs(seg_list[last_zs.seg_list[-1].index:(seg3.index + 1)])
|
||||
create_new_zs = False
|
||||
else:
|
||||
start_idx += 1
|
||||
continue
|
||||
else:
|
||||
create_new_zs = True
|
||||
if last_zs and create_new_zs:
|
||||
last_seg = last_zs.seg_list[-1]
|
||||
last_bi = last_seg.end_bi
|
||||
if last_bi:
|
||||
last_zs.is_sure = True
|
||||
last_zs.set_end_klc(last_bi.end_klc, last_bi.sure_time, 0, last_seg)
|
||||
last_zs.set_end_seg(last_seg)
|
||||
zs = last_zs
|
||||
if create_new_zs:
|
||||
# 创建新中枢
|
||||
gg = max(seg1.high, seg2.high, seg3.high)
|
||||
dd = min(seg1.low, seg2.low, seg3.low)
|
||||
|
||||
zs = ChanZS(seg1, len(zs_list), zs_dir)
|
||||
zs.set_zg(zg)
|
||||
zs.set_zd(zd)
|
||||
zs.set_gg(gg)
|
||||
zs.set_dd(dd)
|
||||
zs.is_sure = False
|
||||
zs.seg_list = [seg1, seg2, seg3]
|
||||
# 若第二线段与 [zd,zg] 重叠(如离开后回抽回到前中枢)则并入扩展
|
||||
added_after_leave = []
|
||||
leave_index = start_idx + 4
|
||||
is_break = False
|
||||
while leave_index < len(seg_list):
|
||||
s = seg_list[leave_index]
|
||||
if not s.is_sure:
|
||||
break
|
||||
sh = max(s.start_bi.high, s.end_bi.high) if s.end_bi else s.start_bi.high
|
||||
sl = min(s.start_bi.low, s.end_bi.low) if s.end_bi else s.start_bi.low
|
||||
if sh >= zs.zd and sl <= zs.zg:
|
||||
added_after_leave.append(s.pre)
|
||||
added_after_leave.append(s)
|
||||
leave_index += 2
|
||||
else:
|
||||
next_seg = s.next
|
||||
if next_seg and next_seg.is_sure:
|
||||
if next_seg.dir == Chan_SEG_DIR.UP:
|
||||
if next_seg.high <= zs.zg and next_seg.low >= zs.zd:
|
||||
leave_index += 2
|
||||
continue
|
||||
else:
|
||||
is_break = True
|
||||
else:
|
||||
if next_seg.low >= zs.zd and next_seg.low <= zs.zg:
|
||||
leave_index += 2
|
||||
continue
|
||||
else:
|
||||
is_break = True
|
||||
else:
|
||||
break
|
||||
if is_break:
|
||||
break
|
||||
if added_after_leave:
|
||||
#print(len(added_after_leave))
|
||||
segs_for_zs = list(zs.seg_list) + list(added_after_leave)
|
||||
seg_highs = [s.high for s in segs_for_zs]
|
||||
seg_lows = [s.low for s in segs_for_zs]
|
||||
zs.set_gg(max(seg_highs))
|
||||
zs.set_dd(min(seg_lows))
|
||||
zs.seg_list = segs_for_zs
|
||||
seg = segs_for_zs[-1]
|
||||
#if seg.end_bi:
|
||||
#zs.set_end_klc(seg.end_bi.end_klc, seg.sure_time, 0, seg)
|
||||
#zs.set_end_seg(seg)
|
||||
#zs.is_sure = True
|
||||
start_idx = start_idx + len(added_after_leave)
|
||||
if last_zs and last_zs.index != zs.index:
|
||||
last_zs.set_next(zs)
|
||||
zs.set_pre(last_zs)
|
||||
|
||||
zs_list.append(zs)
|
||||
last_zs = zs
|
||||
|
||||
# 移动到下一组
|
||||
start_idx += 4
|
||||
if last_zs:
|
||||
last_zs.is_sure = seg_list[-1].is_sure
|
||||
"""
|
||||
# 处理最后一个未确认的中枢 - 不自动扩展,保持未完成状态
|
||||
if last_zs and not last_zs.is_sure:
|
||||
# 获取中枢最后一个线段的索引
|
||||
if last_zs.seg_list and len(last_zs.seg_list) > 0:
|
||||
last_seg_of_zs = last_zs.seg_list[-1]
|
||||
# 找到这个线段在seg_list中的索引
|
||||
last_seg_idx = -1
|
||||
for i, seg in enumerate(seg_list):
|
||||
if seg == last_seg_of_zs:
|
||||
last_seg_idx = i
|
||||
break
|
||||
|
||||
# 从中枢最后一个线段之后检查是否有离开
|
||||
has_leave = False
|
||||
if last_seg_idx >= 0 and last_seg_idx + 1 < len(seg_list):
|
||||
for i in range(last_seg_idx + 1, len(seg_list)):
|
||||
seg = seg_list[i]
|
||||
if seg.is_sure:
|
||||
# 检查是否离开中枢
|
||||
leave = (seg.low > last_zs.zg and seg.high > last_zs.zg) or \
|
||||
(seg.high < last_zs.zd and seg.low < last_zs.zd)
|
||||
if leave:
|
||||
has_leave = True
|
||||
break
|
||||
|
||||
if not has_leave:
|
||||
# 没有离开,保持未完成状态
|
||||
pass
|
||||
else:
|
||||
# 有离开,确认中枢
|
||||
if last_seg_of_zs.end_bi:
|
||||
#print(last_seg_of_zs.start_time, "last_seg_of_zs.end_time", last_seg_of_zs.end_time)
|
||||
last_zs.set_end_klc(last_seg_of_zs.end_bi.end_klc, last_seg_of_zs.sure_time, 0, last_seg_of_zs)
|
||||
last_zs.set_end_seg(last_seg_of_zs)
|
||||
last_zs.is_sure = True
|
||||
"""
|
||||
return zs_list
|
||||
|
||||
|
||||
def get_big_zs_list(self, zs_list):
|
||||
"""
|
||||
中枢扩张:将区间重叠的连续中枢合并为大级别中枢,便于显示更大级别的震荡区间。
|
||||
重叠定义:两中枢 [zd,zg] 有交集,即 (zs_i.zg >= zs_j.zd and zs_i.zd <= zs_j.zg)。
|
||||
"""
|
||||
big_list = []
|
||||
if len(zs_list) < 2:
|
||||
return big_list
|
||||
i = 0
|
||||
while i < len(zs_list):
|
||||
group = [zs_list[i]]
|
||||
j = i + 1
|
||||
while j < len(zs_list):
|
||||
cur = zs_list[j]
|
||||
# 与当前组内任一中枢有重叠即算扩张(通常只需与组内最后一个比)
|
||||
last_in_group = group[-1]
|
||||
overlap = (last_in_group.zg >= cur.zd and last_in_group.zd <= cur.zg)
|
||||
if overlap:
|
||||
group.append(cur)
|
||||
j += 1
|
||||
else:
|
||||
break
|
||||
if len(group) >= 2:
|
||||
big = ChanZS_Big(group)
|
||||
big.index = len(big_list)
|
||||
big_list.append(big)
|
||||
i = j if len(group) >= 2 else i + 1
|
||||
return big_list
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import warnings
|
||||
|
||||
# 抑制 Docker 内 technical.util 的 fillna/ffill/bfill 的 pandas FutureWarning(pandas 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 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, Chan_MACD_STATE, Chan_PRICE_TREND, Chan_KLU_PATTERN
|
||||
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
|
||||
from technical.util import resample_to_interval
|
||||
from decimal import Decimal
|
||||
import numpy as np
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
from chanlun.pipeline.timeframe import TF_DF
|
||||
from chanlun.analysis.ChanZone import StructureZone, StructureZoneConfig, analyze_structure_zones
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def get_bsp_state(self, dataframe):
|
||||
return self.tf_df.get_bsp_state(dataframe)
|
||||
|
||||
def get_structure_zones(self, current_price=None, config=None):
|
||||
if config is None:
|
||||
config = StructureZoneConfig()
|
||||
return analyze_structure_zones(
|
||||
self.tf_df_dict,
|
||||
self.ema_symbols,
|
||||
current_price=current_price,
|
||||
config=config,
|
||||
)
|
||||
# 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_bi_zs_list(self, bi_list):
|
||||
return self.tf_df.get_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))
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
from chanlun.core.ChanBI import ChanBI
|
||||
from chanlun.core.ChanBIZS import ChanBIZS
|
||||
from chanlun.core.ChanBSP import ChanBSP
|
||||
from chanlun.core.ChanEnum import (
|
||||
Chan_BI_DIR,
|
||||
Chan_BSP_DIR,
|
||||
Chan_BSP_TYPE,
|
||||
Chan_FX_TYPE,
|
||||
Chan_K_DIR,
|
||||
Chan_KLC_FX,
|
||||
Chan_KLC_STATE,
|
||||
Chan_KLINE_DIR,
|
||||
Chan_KLU_PATTERN,
|
||||
Chan_PRICE_TREND,
|
||||
Chan_SEG_DIR,
|
||||
Chan_ZS_DIR,
|
||||
)
|
||||
from chanlun.core.ChanKLC import ChanKLC
|
||||
from chanlun.core.ChanKLU import ChanKLU
|
||||
from chanlun.core.ChanSBI import ChanSBI
|
||||
from chanlun.core.ChanSEG import ChanSEG
|
||||
from chanlun.core.ChanZS import ChanZS, ChanZS_Big
|
||||
from chanlun.indicators.ChanMACD import ChanMACD
|
||||
from chanlun.pipeline.builders.bi import BiBuilderMixin
|
||||
from chanlun.pipeline.builders.bsp import BspBuilderMixin
|
||||
from chanlun.pipeline.builders.indicators import IndicatorsBuilderMixin
|
||||
from chanlun.pipeline.builders.kline import KlineBuilderMixin
|
||||
from chanlun.pipeline.builders.seg import SegBuilderMixin
|
||||
from chanlun.pipeline.builders.zs import ZsBuilderMixin
|
||||
|
||||
class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilderMixin, ZsBuilderMixin, BspBuilderMixin):
|
||||
def __init__(self, df=None, interval=0, timeframe=None):
|
||||
if df is not None:
|
||||
self.init_TF_DF(df, interval, timeframe)
|
||||
def init_TF_DF(self, df, interval, timeframe):
|
||||
self.timeframe = timeframe
|
||||
self.interval = interval
|
||||
# 检查 DataFrame 是否为空或没有 date 列
|
||||
if df is None or df.empty:
|
||||
raise ValueError(f"DataFrame for {timeframe} is empty. Please download data first.")
|
||||
if 'date' not in df.columns:
|
||||
raise ValueError(f"DataFrame for {timeframe} missing 'date' column. Columns: {df.columns.tolist()}")
|
||||
# interval=1 时不需要重采样
|
||||
if interval == 1:
|
||||
self.dataframe = df.copy()
|
||||
else:
|
||||
self.dataframe = resample_to_interval(df, interval)
|
||||
#print(self.timeframe, len(self.dataframe))
|
||||
self.dataframe = self.add_indicators(self.dataframe)
|
||||
self.klu_list = []
|
||||
self.klc_list = []
|
||||
self.bi_list = []
|
||||
self.zs_list = []
|
||||
self.bsp_list = []
|
||||
self.seg_list = []
|
||||
self.klc_fx_list = []
|
||||
self.klu_list = self.cal_kl_data(self.dataframe)
|
||||
self.klc_list = self.get_klc_list(self.klu_list)
|
||||
self.bi_list = self.cal_bi_list(self.klc_list)
|
||||
self.seg_list = self.get_seg_list(self.bi_list)
|
||||
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
||||
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
||||
# get_klc_list 内已算过 ChanMACD,直接复用
|
||||
self.chanmacd = getattr(self, '_last_chan_macd', None)
|
||||
if self.chanmacd is None:
|
||||
self.chanmacd = ChanMACD(self.klu_list)
|
||||
self.klu_list = self.chanmacd.klu_list
|
||||
|
||||
|
||||
def get_current_klc(self):
|
||||
if len(self.klc_list) > 0:
|
||||
return self.klc_list[-2]
|
||||
return None
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.chanlun_btc_1m.sqlite",
|
||||
"dry_run_wallet": 1000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short" : true,
|
||||
"timeframe" : "1m",
|
||||
"process_only_new_candles" : false,
|
||||
"unfilledtimeout": {
|
||||
"entry": 1,
|
||||
"exit": 1,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
|
||||
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList",
|
||||
"number_assets": 1,
|
||||
"sort_key": "quoteVolume",
|
||||
"min_value": 0,
|
||||
"refresh_period": 1800
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "8197349375:AAH208JghCq8raFYF-IpnobYknCr6iGDH_0",
|
||||
"chat_id": "580807463"
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": true,
|
||||
"listen_ip_address": "0.0.0.0",
|
||||
"listen_port": 8814,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "14d3510740e2c39a973a8895f1aa2704d98d08b86170260085709fa5ea48251d",
|
||||
"ws_token": "dtKKDnafBrX4icq_ZCw7acJTahTK4h_yvg",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "freqtrade",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.chanlun_btc_5m.sqlite",
|
||||
"dry_run_wallet": 1000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short" : true,
|
||||
"timeframe" : "5m",
|
||||
"process_only_new_candles" : false,
|
||||
"unfilledtimeout": {
|
||||
"entry": 5,
|
||||
"exit": 5,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
|
||||
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList",
|
||||
"number_assets": 1,
|
||||
"sort_key": "quoteVolume",
|
||||
"min_value": 0,
|
||||
"refresh_period": 1800
|
||||
}
|
||||
],
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
ENV CONFIG_PATH=/app/config.json \
|
||||
UVICORN_HOST=0.0.0.0 \
|
||||
UVICORN_PORT=9009
|
||||
|
||||
EXPOSE 9009
|
||||
|
||||
CMD ["python", "-m", "main"]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"exchange": "binance",
|
||||
"symbols": [
|
||||
"BTC/USDT:USDT",
|
||||
"ETH/USDT:USDT",
|
||||
"SOL/USDT:USDT",
|
||||
"WIF/USDT:USDT",
|
||||
"AAVE/USDT:USDT",
|
||||
"SUI/USDT:USDT",
|
||||
"1INCH/USDT:USDT",
|
||||
"DOGE/USDT:USDT",
|
||||
"UNI/USDT:USDT"
|
||||
],
|
||||
"start_time": "2024-01-01T00:00:00Z",
|
||||
"timeframes": ["1m", "1h", "1d", "1w"],
|
||||
"data_dir": "./data"
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
services:
|
||||
data_provider:
|
||||
build: .
|
||||
container_name: data-provider
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONFIG_PATH: /app/config.json
|
||||
UVICORN_HOST: 0.0.0.0
|
||||
UVICORN_PORT: "9009"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "9009:9009"
|
||||
|
||||
@@ -1,947 +0,0 @@
|
||||
"""
|
||||
Chan 数据服务:用 ccxt 从交易所拉取 K 线,内存缓存 + CSV 落盘;
|
||||
后台线程定期增量刷新,断线时记录 resume_since 以免漏 K;
|
||||
配置中的基础周期(如 1m/1h)可合成 DERIVED_TIMEFRAME_PLAN 中的衍生周期。
|
||||
"""
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
import ccxt # type: ignore
|
||||
import pandas as pd # type: ignore
|
||||
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
from technical.util import resample_to_interval
|
||||
|
||||
# docker compose logs --tail=200
|
||||
# docker compose down && docker compose build --no-cache && docker compose up -d
|
||||
|
||||
# 基础周期枚举顺序(用于衍生周期展示顺序);仅允许集合内周期作为交易所直接拉取的 tf
|
||||
TIMEFRAME_ORDER = ["1m", "1h", "1d", "1w"]
|
||||
ALLOWED_TIMEFRAMES = set(TIMEFRAME_ORDER)
|
||||
# 各基础周期一根 K 线的毫秒长度(用于历史分页与断线回退)
|
||||
TIMEFRAME_TO_MS: Dict[str, int] = {
|
||||
"1m": 60_000,
|
||||
"1h": 3_600_000,
|
||||
"1d": 86_400_000,
|
||||
"1w": 604_800_000,
|
||||
}
|
||||
# 每个基础周期可派生出的合成周期列表(由该基础周期 K 线 resample 得到)
|
||||
DERIVED_TIMEFRAME_PLAN: Dict[str, List[str]] = {
|
||||
"1m": ["2m", "3m", "4m", "5m", "10m", "15m", "20m", "25m", "30m", "45m"],
|
||||
"1h": ["2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10", "11h", "12h", "16h", "20h"],
|
||||
"1d": ["2d", "3d", "4d", "5d", "6d"],
|
||||
"1w": ["2w", "3w"],
|
||||
}
|
||||
CSV_FIELDNAMES = ["timestamp", "datetime", "open", "high", "low", "close", "volume"]
|
||||
DEFAULT_LIMIT = 500
|
||||
RECENT_CANDLE_LIMIT = 10
|
||||
RECENT_FETCH_INTERVAL = 5 # 后台刷新循环休眠秒数
|
||||
PERSIST_INTERVAL = 600 # 全量落盘周期(秒)
|
||||
WS_UPDATE_CANDLE_COUNT = 2 # WebSocket 增量推送最近 K 线根数
|
||||
|
||||
|
||||
logger = logging.getLogger("data_provider")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
|
||||
def to_utc_iso(timestamp_ms: int) -> str:
|
||||
"""将毫秒时间戳格式化为 UTC ISO 字符串(末尾 Z)。"""
|
||||
dt = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_timestamp(value: Optional[object]) -> Optional[int]:
|
||||
"""解析查询参数中的时间为 UTC 毫秒时间戳;支持数字或 ISO 字符串。"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.isdigit():
|
||||
return int(text)
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError as exc: # pragma: no cover - informative logging
|
||||
raise ValueError(f"无法解析时间字符串: {value}") from exc
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
raise ValueError(f"不支持的时间格式: {value}")
|
||||
|
||||
|
||||
def candle_to_dict(candle: Iterable[float]) -> Dict[str, float]:
|
||||
"""ccxt OHLCV 单根 [ts, o, h, l, c, v] 转为内部字典结构。"""
|
||||
ts = int(candle[0])
|
||||
return {
|
||||
"timestamp": ts,
|
||||
"datetime": to_utc_iso(ts),
|
||||
"open": float(candle[1]),
|
||||
"high": float(candle[2]),
|
||||
"low": float(candle[3]),
|
||||
"close": float(candle[4]),
|
||||
"volume": float(candle[5]),
|
||||
}
|
||||
|
||||
|
||||
def timeframe_to_minutes(tf: str) -> Optional[int]:
|
||||
"""将如 15m、2h 转为「分钟数」,供 resample 与衍生周期计算。"""
|
||||
if not tf:
|
||||
return None
|
||||
unit = tf[-1]
|
||||
try:
|
||||
value = int(tf[:-1])
|
||||
except ValueError:
|
||||
return None
|
||||
multiplier = {
|
||||
"m": 1,
|
||||
"h": 60,
|
||||
"d": 1_440,
|
||||
"w": 10_080,
|
||||
}.get(unit)
|
||||
if multiplier is None:
|
||||
return None
|
||||
return value * multiplier
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""管理 WebSocket 连接及订阅,线程安全地广播 K 线更新。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscriptions: Dict[tuple, set] = {}
|
||||
self._async_lock = asyncio.Lock()
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self._loop = loop
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
logger.info("WebSocket 客户端已连接")
|
||||
|
||||
async def disconnect(self, ws: WebSocket) -> None:
|
||||
async with self._async_lock:
|
||||
for key in list(self._subscriptions):
|
||||
self._subscriptions[key].discard(ws)
|
||||
if not self._subscriptions[key]:
|
||||
del self._subscriptions[key]
|
||||
logger.info("WebSocket 客户端已断开")
|
||||
|
||||
async def subscribe(self, ws: WebSocket, symbol: str, timeframe: str) -> None:
|
||||
key = (symbol, timeframe)
|
||||
async with self._async_lock:
|
||||
self._subscriptions.setdefault(key, set()).add(ws)
|
||||
logger.info("WebSocket 订阅: %s %s", symbol, timeframe)
|
||||
|
||||
async def unsubscribe(self, ws: WebSocket, symbol: str, timeframe: str) -> None:
|
||||
key = (symbol, timeframe)
|
||||
async with self._async_lock:
|
||||
if key in self._subscriptions:
|
||||
self._subscriptions[key].discard(ws)
|
||||
if not self._subscriptions[key]:
|
||||
del self._subscriptions[key]
|
||||
|
||||
def has_subscribers(self, symbol: str, timeframe: str) -> bool:
|
||||
"""非异步快速检查(供同步线程调用)。"""
|
||||
return bool(self._subscriptions.get((symbol, timeframe)))
|
||||
|
||||
async def broadcast(
|
||||
self, symbol: str, timeframe: str, candles: List[Dict], msg_type: str = "kline",
|
||||
) -> None:
|
||||
key = (symbol, timeframe)
|
||||
async with self._async_lock:
|
||||
subscribers = list(self._subscriptions.get(key, set()))
|
||||
if not subscribers:
|
||||
return
|
||||
message = json.dumps(
|
||||
{"type": msg_type, "symbol": symbol, "timeframe": timeframe, "data": candles},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
dead: list = []
|
||||
for ws in subscribers:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
if dead:
|
||||
async with self._async_lock:
|
||||
for ws in dead:
|
||||
self._subscriptions.get(key, set()).discard(ws)
|
||||
|
||||
def broadcast_from_thread(
|
||||
self, symbol: str, timeframe: str, candles: List[Dict], msg_type: str = "kline",
|
||||
) -> None:
|
||||
"""供同步后台线程调用,将广播提交到 asyncio 事件循环。"""
|
||||
if self._loop is None or self._loop.is_closed():
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.broadcast(symbol, timeframe, candles, msg_type),
|
||||
self._loop,
|
||||
)
|
||||
|
||||
|
||||
class DataProvider:
|
||||
"""封装交易所连接、本地 CSV、内存缓存、断线恢复与衍生周期聚合。"""
|
||||
|
||||
def __init__(self, config_path: Path) -> None:
|
||||
self.config_path = config_path
|
||||
self.config = self._load_config()
|
||||
self.exchange_name: str = self.config["exchange"]
|
||||
self.symbols: List[str] = self._load_symbols(self.config)
|
||||
self.timeframes: List[str] = self._validate_timeframes(self.config.get("timeframes"))
|
||||
self.data_dir = Path(self.config.get("data_dir", "./data")).expanduser()
|
||||
start = parse_timestamp(self.config.get("start_time"))
|
||||
if start is None:
|
||||
raise ValueError("配置文件必须包含 start_time 字段")
|
||||
self.start_time_ms: int = start
|
||||
self.exchange = self._init_exchange()
|
||||
self.data: Dict[str, Dict[str, List[Dict[str, float]]]] = {
|
||||
symbol: {tf: [] for tf in self.timeframes} for symbol in self.symbols
|
||||
}
|
||||
# 衍生周期 -> 用于合成的交易所基础周期(每个衍生只对应一个 base)
|
||||
self.derived_map: Dict[str, str] = {}
|
||||
for base_tf in self.timeframes:
|
||||
for derived_tf in DERIVED_TIMEFRAME_PLAN.get(base_tf, []):
|
||||
self.derived_map.setdefault(derived_tf, base_tf)
|
||||
# 衍生周期展示顺序:按 TIMEFRAME_ORDER 中的基础周期依次展开
|
||||
derived_order: List[str] = []
|
||||
for base_tf in TIMEFRAME_ORDER:
|
||||
if base_tf not in self.timeframes:
|
||||
continue
|
||||
for derived_tf in DERIVED_TIMEFRAME_PLAN.get(base_tf, []):
|
||||
if derived_tf in self.derived_map and derived_tf not in derived_order:
|
||||
derived_order.append(derived_tf)
|
||||
self.available_timeframes: List[str] = list(self.timeframes) + derived_order
|
||||
self._lock = threading.RLock()
|
||||
self._ready = threading.Event()
|
||||
self._stop_event = threading.Event()
|
||||
self._fetch_thread: Optional[threading.Thread] = None
|
||||
self._persist_thread: Optional[threading.Thread] = None
|
||||
# 记录断线后需要从哪个 since 重新拉取(symbol -> timeframe -> since_ms)
|
||||
self._resume_since: Dict[str, Dict[str, int]] = {}
|
||||
# 恢复点持久化文件
|
||||
self._resume_file: Path = self.data_dir / "resume_since.json"
|
||||
# 尝试加载历史恢复点
|
||||
self._load_resume_since()
|
||||
self._update_callbacks: List = []
|
||||
|
||||
def _load_config(self) -> Dict[str, object]:
|
||||
"""读取 JSON 配置文件。"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(f"未找到配置文件: {self.config_path}")
|
||||
with self.config_path.open("r", encoding="utf-8") as fp:
|
||||
return json.load(fp)
|
||||
|
||||
def _load_symbols(self, config: Dict[str, object]) -> List[str]:
|
||||
"""从 symbols 列表、逗号分隔字符串或单字段 symbol 解析交易对,去重保序。"""
|
||||
raw_symbols: List[str] = []
|
||||
symbols_value = config.get("symbols")
|
||||
if isinstance(symbols_value, list):
|
||||
raw_symbols = [str(item).strip() for item in symbols_value if isinstance(item, str) and item.strip()]
|
||||
elif isinstance(symbols_value, str) and symbols_value.strip():
|
||||
raw_symbols = [item.strip() for item in symbols_value.split(",") if item.strip()]
|
||||
symbol_single = config.get("symbol")
|
||||
if not raw_symbols and isinstance(symbol_single, str) and symbol_single.strip():
|
||||
raw_symbols = [symbol_single.strip()]
|
||||
if not raw_symbols:
|
||||
raise ValueError("配置文件必须提供 symbols(列表或逗号分隔字符串)或 symbol 字段")
|
||||
unique: List[str] = []
|
||||
for item in raw_symbols:
|
||||
if item not in unique:
|
||||
unique.append(item)
|
||||
return unique
|
||||
|
||||
def _validate_timeframes(self, configured: Optional[Iterable[str]]) -> List[str]:
|
||||
"""校验周期在允许集合内;未配置则默认 TIMEFRAME_ORDER 全部;顺序优先按 TIMEFRAME_ORDER。"""
|
||||
if not configured:
|
||||
return list(TIMEFRAME_ORDER)
|
||||
invalid = [tf for tf in configured if tf not in ALLOWED_TIMEFRAMES]
|
||||
if invalid:
|
||||
raise ValueError(f"不支持的时间周期: {invalid}. 允许值: {sorted(ALLOWED_TIMEFRAMES)}")
|
||||
unique = []
|
||||
seen = set()
|
||||
for tf in TIMEFRAME_ORDER:
|
||||
if tf in configured and tf not in seen:
|
||||
unique.append(tf)
|
||||
seen.add(tf)
|
||||
for tf in configured:
|
||||
if tf not in seen:
|
||||
unique.append(tf)
|
||||
seen.add(tf)
|
||||
return unique
|
||||
|
||||
def _init_exchange(self):
|
||||
"""实例化 ccxt 交易所,币安期货默认 defaultType=future,并 load_markets。"""
|
||||
if not hasattr(ccxt, self.exchange_name):
|
||||
raise ValueError(f"不支持的交易所: {self.exchange_name}")
|
||||
exchange_class = getattr(ccxt, self.exchange_name)
|
||||
exchange = exchange_class({"enableRateLimit": True})
|
||||
if exchange.id == "binance":
|
||||
exchange.options.setdefault("defaultType", "future")
|
||||
exchange.load_markets()
|
||||
logger.info("已初始化交易所 %s", exchange.id)
|
||||
return exchange
|
||||
|
||||
def _data_file_path(self, symbol: str, timeframe: str) -> Path:
|
||||
"""单交易对单周期的 CSV 路径:data_dir/tf/exchange_symbol_tf.csv。"""
|
||||
symbol_safe = symbol.replace("/", "_").replace(":", "_")
|
||||
return self.data_dir / timeframe / f"{self.exchange.id}_{symbol_safe}_{timeframe}.csv"
|
||||
|
||||
def _load_local(self, symbol: str, timeframe: str) -> List[Dict[str, float]]:
|
||||
"""启动时从磁盘加载已有 K 线,损坏行跳过,按时间排序。"""
|
||||
path = self._data_file_path(symbol, timeframe)
|
||||
if not path.exists():
|
||||
return []
|
||||
loaded: List[Dict[str, float]] = []
|
||||
with path.open("r", encoding="utf-8", newline="") as fp:
|
||||
reader = csv.DictReader(fp)
|
||||
for row in reader:
|
||||
try:
|
||||
loaded.append(
|
||||
{
|
||||
"timestamp": int(row["timestamp"]),
|
||||
"datetime": row.get("datetime") or to_utc_iso(int(row["timestamp"])),
|
||||
"open": float(row["open"]),
|
||||
"high": float(row["high"]),
|
||||
"low": float(row["low"]),
|
||||
"close": float(row["close"]),
|
||||
"volume": float(row["volume"]),
|
||||
}
|
||||
)
|
||||
except (KeyError, ValueError):
|
||||
logger.warning("忽略损坏的行: %s", row)
|
||||
loaded.sort(key=lambda item: item["timestamp"])
|
||||
logger.info("交易对 %s 时间周期 %s 加载本地K线数量: %s", symbol, timeframe, len(loaded))
|
||||
return loaded
|
||||
|
||||
def _merge_candles(
|
||||
self,
|
||||
timeframe: str,
|
||||
base: List[Dict[str, float]],
|
||||
new_candles: Iterable[Iterable[float]],
|
||||
) -> List[Dict[str, float]]:
|
||||
"""按 timestamp 去重合并,新数据覆盖同时间戳旧数据。"""
|
||||
merged = {entry["timestamp"]: entry for entry in base}
|
||||
for candle in new_candles:
|
||||
entry = candle_to_dict(candle)
|
||||
merged[entry["timestamp"]] = entry
|
||||
ordered = list(sorted(merged.values(), key=lambda item: item["timestamp"]))
|
||||
logger.debug("时间周期 %s 合并后K线数量: %s", timeframe, len(ordered))
|
||||
return ordered
|
||||
|
||||
def _write_to_disk(self, symbol: str, timeframe: str, data: List[Dict[str, float]]) -> None:
|
||||
"""先写临时文件再 replace,避免写入中断导致 CSV 损坏。"""
|
||||
path = self._data_file_path(symbol, timeframe)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
try:
|
||||
with tmp_path.open("w", encoding="utf-8", newline="") as fp:
|
||||
writer = csv.DictWriter(fp, fieldnames=CSV_FIELDNAMES)
|
||||
writer.writeheader()
|
||||
writer.writerows(data)
|
||||
os.replace(tmp_path, path)
|
||||
finally:
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
logger.info("交易对 %s 时间周期 %s 已写入磁盘 (%s 根K线)", symbol, timeframe, len(data))
|
||||
|
||||
def _fetch_history(self, symbol: str, timeframe: str, since_ms: int) -> List[List[float]]:
|
||||
"""从 since_ms 分页拉取直到接近当前时间;遇限频则 sleep 重试。"""
|
||||
results: List[List[float]] = []
|
||||
limit = 1500
|
||||
now_ms = self.exchange.milliseconds()
|
||||
tf_ms = TIMEFRAME_TO_MS[timeframe]
|
||||
fetch_since = since_ms
|
||||
max_rounds = 5000
|
||||
rounds = 0
|
||||
while fetch_since < now_ms and rounds < max_rounds:
|
||||
rounds += 1
|
||||
try:
|
||||
candles = self.exchange.fetch_ohlcv(
|
||||
symbol,
|
||||
timeframe=timeframe,
|
||||
since=fetch_since,
|
||||
limit=limit,
|
||||
)
|
||||
except ccxt.RateLimitExceeded as exc:
|
||||
logger.warning("触发频率限制,等待: %s", exc)
|
||||
time.sleep(self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 1)
|
||||
continue
|
||||
except ccxt.BaseError as exc:
|
||||
logger.error("拉取历史K线失败 (%s, %s): %s", timeframe, fetch_since, exc)
|
||||
time.sleep(5)
|
||||
continue
|
||||
if not candles:
|
||||
break
|
||||
results.extend(candles)
|
||||
last_ts = candles[-1][0]
|
||||
fetch_since = last_ts + tf_ms
|
||||
if last_ts >= now_ms - tf_ms:
|
||||
break
|
||||
time.sleep(self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 0.2)
|
||||
logger.info("交易对 %s 时间周期 %s 拉取历史K线数量: %s", symbol, timeframe, len(results))
|
||||
return results
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""阻塞式启动:加载本地、从倒数第二根或配置起点补历史、写盘并 set _ready。"""
|
||||
logger.info("开始初始化数据提供商")
|
||||
for symbol in self.symbols:
|
||||
for timeframe in self.timeframes:
|
||||
existing = self._load_local(symbol, timeframe)
|
||||
tf_ms = TIMEFRAME_TO_MS[timeframe]
|
||||
last_ts = existing[-1]["timestamp"] if existing else None
|
||||
if last_ts is not None:
|
||||
if len(existing) >= 2:
|
||||
# 从倒数第二根起拉,避免最后一根未收盘重复/缺口
|
||||
fetch_since = existing[-2]["timestamp"]
|
||||
else:
|
||||
fetch_since = max(0, last_ts - tf_ms)
|
||||
else:
|
||||
fetch_since = self.start_time_ms
|
||||
logger.debug(
|
||||
"初始化拉取参数",
|
||||
extra={
|
||||
"symbol": symbol,
|
||||
"timeframe": timeframe,
|
||||
"existing_last": last_ts,
|
||||
"fetch_since": fetch_since,
|
||||
"tf_ms": tf_ms,
|
||||
},
|
||||
)
|
||||
history = self._fetch_history(symbol, timeframe, fetch_since)
|
||||
merged = self._merge_candles(timeframe, existing, history)
|
||||
with self._lock:
|
||||
self.data.setdefault(symbol, {})[timeframe] = merged
|
||||
self._write_to_disk(symbol, timeframe, merged)
|
||||
self._ready.set()
|
||||
logger.info("数据初始化完成")
|
||||
|
||||
def resample_df(self, df: pd.DataFrame, interval: int) -> pd.DataFrame:
|
||||
"""将基础周期 DataFrame 聚合为 interval 分钟周期(freqtrade technical.util)。"""
|
||||
return resample_to_interval(df, interval)
|
||||
|
||||
def _save_resume_since(self) -> None:
|
||||
"""将断线恢复点持久化到 resume_since.json(原子替换)。"""
|
||||
path = self._resume_file
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
with self._lock:
|
||||
snapshot = {
|
||||
symbol: {tf: int(since) for tf, since in tf_map.items()}
|
||||
for symbol, tf_map in self._resume_since.items()
|
||||
}
|
||||
try:
|
||||
with tmp_path.open("w", encoding="utf-8") as fp:
|
||||
json.dump(snapshot, fp, ensure_ascii=False, separators=(",", ":"))
|
||||
os.replace(tmp_path, path)
|
||||
finally:
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
logger.debug("恢复点已保存到磁盘: %s", path)
|
||||
|
||||
def _load_resume_since(self) -> None:
|
||||
"""启动时加载恢复点;与内存合并时取更早的 since,避免漏拉。"""
|
||||
path = self._resume_file
|
||||
if not path.exists():
|
||||
return
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fp:
|
||||
raw = json.load(fp)
|
||||
except Exception as exc:
|
||||
logger.warning("恢复点文件读取失败,忽略: %s (%s)", path, exc)
|
||||
return
|
||||
if not isinstance(raw, dict):
|
||||
logger.warning("恢复点文件格式错误,忽略: %s", path)
|
||||
return
|
||||
loaded: Dict[str, Dict[str, int]] = {}
|
||||
for symbol, tf_map in raw.items():
|
||||
if not isinstance(tf_map, dict):
|
||||
continue
|
||||
per_symbol: Dict[str, int] = {}
|
||||
for timeframe, since in tf_map.items():
|
||||
try:
|
||||
per_symbol[str(timeframe)] = int(since)
|
||||
except Exception:
|
||||
continue
|
||||
if per_symbol:
|
||||
loaded[str(symbol)] = per_symbol
|
||||
if not loaded:
|
||||
return
|
||||
with self._lock:
|
||||
# 合并为更早的 since,避免遗漏
|
||||
for symbol, tf_map in loaded.items():
|
||||
cur = self._resume_since.setdefault(symbol, {})
|
||||
for timeframe, since in tf_map.items():
|
||||
prev = cur.get(timeframe)
|
||||
if prev is None or since < prev:
|
||||
cur[timeframe] = since
|
||||
logger.info("已加载恢复点: %s", path)
|
||||
|
||||
def _get_resume_since(self, symbol: str, timeframe: str) -> Optional[int]:
|
||||
"""若曾断线,返回应从哪一毫秒起补拉该 symbol/tf。"""
|
||||
with self._lock:
|
||||
return self._resume_since.get(symbol, {}).get(timeframe)
|
||||
|
||||
def _set_resume_since(self, symbol: str, timeframe: str, since_ms: int) -> None:
|
||||
"""断线时写入恢复点(取更早的 since 以免漏数据),并持久化到磁盘。"""
|
||||
with self._lock:
|
||||
per_symbol = self._resume_since.setdefault(symbol, {})
|
||||
prev = per_symbol.get(timeframe)
|
||||
# 取更早的 since,避免跳过数据
|
||||
if prev is None or since_ms < prev:
|
||||
per_symbol[timeframe] = since_ms
|
||||
logger.warning(
|
||||
"记录断线恢复点: %s %s since=%s (%s)",
|
||||
symbol,
|
||||
timeframe,
|
||||
since_ms,
|
||||
to_utc_iso(since_ms),
|
||||
)
|
||||
# 同步写盘
|
||||
self._save_resume_since()
|
||||
|
||||
def _clear_resume_since(self, symbol: str, timeframe: str) -> None:
|
||||
"""补数成功后清除该 symbol/tf 的恢复点。"""
|
||||
with self._lock:
|
||||
if symbol in self._resume_since and timeframe in self._resume_since[symbol]:
|
||||
del self._resume_since[symbol][timeframe]
|
||||
if not self._resume_since[symbol]:
|
||||
del self._resume_since[symbol]
|
||||
logger.info("清除断线恢复点: %s %s", symbol, timeframe)
|
||||
# 同步写盘
|
||||
self._save_resume_since()
|
||||
|
||||
def on_update(self, callback) -> None:
|
||||
"""注册数据更新回调(签名: callback(symbol, timeframe))。"""
|
||||
self._update_callbacks.append(callback)
|
||||
|
||||
def _notify_update(self, symbol: str, timeframe: str) -> None:
|
||||
"""通知所有回调:某 symbol/timeframe 数据已更新。"""
|
||||
for cb in self._update_callbacks:
|
||||
try:
|
||||
cb(symbol, timeframe)
|
||||
except Exception as exc:
|
||||
logger.error("数据更新回调异常: %s", exc)
|
||||
|
||||
def start_background_workers(self) -> None:
|
||||
"""启动增量刷新线程与周期性落盘线程。"""
|
||||
if self._fetch_thread and self._fetch_thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._fetch_thread = threading.Thread(target=self._refresh_loop, name="refresh-loop", daemon=True)
|
||||
self._persist_thread = threading.Thread(target=self._persist_loop, name="persist-loop", daemon=True)
|
||||
self._fetch_thread.start()
|
||||
self._persist_thread.start()
|
||||
logger.info("后台线程已启动")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止后台线程(应用关闭时 lifespan finally 调用)。"""
|
||||
self._stop_event.set()
|
||||
if self._fetch_thread:
|
||||
self._fetch_thread.join(timeout=5)
|
||||
if self._persist_thread:
|
||||
self._persist_thread.join(timeout=5)
|
||||
logger.info("数据提供商已停止")
|
||||
|
||||
def _refresh_loop(self) -> None:
|
||||
"""轮询各 symbol/tf:有恢复点则先补历史,否则 fetch 最近 RECENT_CANDLE_LIMIT 根。"""
|
||||
while not self._stop_event.is_set():
|
||||
for symbol in self.symbols:
|
||||
for timeframe in self.timeframes:
|
||||
try:
|
||||
# 若存在断线恢复点,则优先从该 since 补齐历史数据
|
||||
resume_since = self._get_resume_since(symbol, timeframe)
|
||||
if resume_since is not None:
|
||||
logger.info(
|
||||
"开始断线后补数: %s %s since=%s (%s)",
|
||||
symbol,
|
||||
timeframe,
|
||||
resume_since,
|
||||
to_utc_iso(resume_since),
|
||||
)
|
||||
history = self._fetch_history(symbol, timeframe, resume_since)
|
||||
with self._lock:
|
||||
current = self.data.setdefault(symbol, {}).get(timeframe, [])
|
||||
merged = self._merge_candles(timeframe, current, history)
|
||||
self.data[symbol][timeframe] = merged
|
||||
self._notify_update(symbol, timeframe)
|
||||
self._clear_resume_since(symbol, timeframe)
|
||||
else:
|
||||
# 正常增量获取最近若干根K线
|
||||
candles = self.exchange.fetch_ohlcv(
|
||||
symbol,
|
||||
timeframe=timeframe,
|
||||
limit=RECENT_CANDLE_LIMIT,
|
||||
)
|
||||
if not candles:
|
||||
continue
|
||||
with self._lock:
|
||||
current = self.data.setdefault(symbol, {}).get(timeframe, [])
|
||||
merged = self._merge_candles(timeframe, current, candles)
|
||||
self.data[symbol][timeframe] = merged
|
||||
self._notify_update(symbol, timeframe)
|
||||
except ccxt.BaseError as exc:
|
||||
logger.error("更新最新K线失败 (%s %s): %s", symbol, timeframe, exc)
|
||||
# 记录应当从何时恢复拉取,避免重连后从当前时间开始导致丢K
|
||||
with self._lock:
|
||||
current = self.data.get(symbol, {}).get(timeframe, [])
|
||||
if current:
|
||||
last_ts = int(current[-1]["timestamp"])
|
||||
else:
|
||||
last_ts = self.start_time_ms
|
||||
tf_ms = TIMEFRAME_TO_MS[timeframe]
|
||||
# 回退一个周期,确保包含可能未完全收盘的K线,去重由 _merge_candles 处理
|
||||
since_ms = max(self.start_time_ms, last_ts - tf_ms)
|
||||
self._set_resume_since(symbol, timeframe, since_ms)
|
||||
time.sleep(2)
|
||||
continue
|
||||
if self._stop_event.wait(RECENT_FETCH_INTERVAL):
|
||||
break
|
||||
|
||||
def _persist_loop(self) -> None:
|
||||
"""每隔 PERSIST_INTERVAL 秒把内存快照写 CSV 并保存恢复点。"""
|
||||
while not self._stop_event.wait(PERSIST_INTERVAL):
|
||||
self._persist_all()
|
||||
|
||||
def _persist_all(self) -> None:
|
||||
"""在锁内复制 data 后落盘,避免长时间持锁。"""
|
||||
if not self._ready.is_set():
|
||||
return
|
||||
with self._lock:
|
||||
snapshot = {
|
||||
symbol: {tf: list(data) for tf, data in tf_map.items()}
|
||||
for symbol, tf_map in self.data.items()
|
||||
}
|
||||
for symbol, tf_map in snapshot.items():
|
||||
for timeframe, data in tf_map.items():
|
||||
self._write_to_disk(symbol, timeframe, data)
|
||||
# 周期性也保存一次恢复点,保证一致性
|
||||
self._save_resume_since()
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
return self._ready.is_set()
|
||||
|
||||
def wait_ready(self, timeout: Optional[float] = None) -> bool:
|
||||
return self._ready.wait(timeout)
|
||||
|
||||
def get_available_timeframes(self) -> List[str]:
|
||||
return list(self.available_timeframes)
|
||||
|
||||
def get_derived_timeframes(self) -> List[str]:
|
||||
return list(self.derived_map.keys())
|
||||
|
||||
def _get_base_klines(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_ms: Optional[int],
|
||||
end_ms: Optional[int],
|
||||
limit: Optional[int],
|
||||
) -> List[Dict[str, float]]:
|
||||
"""从内存读取已缓存的基础周期 K 线并按时间/limit 裁剪。"""
|
||||
with self._lock:
|
||||
candles = list(self.data.get(symbol, {}).get(timeframe, []))
|
||||
if start_ms is not None:
|
||||
candles = [row for row in candles if row["timestamp"] >= start_ms]
|
||||
if end_ms is not None:
|
||||
candles = [row for row in candles if row["timestamp"] <= end_ms]
|
||||
if limit:
|
||||
candles = candles[-limit:]
|
||||
return candles
|
||||
|
||||
def get_klines(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_time: Optional[object] = None,
|
||||
end_time: Optional[object] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Dict[str, float]]:
|
||||
"""对外查询:基础周期直接返回;衍生周期从 derived_map 取 base,resample 后对齐时间戳再裁剪。"""
|
||||
if symbol not in self.symbols:
|
||||
raise HTTPException(status_code=404, detail=f"symbol {symbol} 不可用")
|
||||
self.wait_ready()
|
||||
start_ms = parse_timestamp(start_time)
|
||||
end_ms = parse_timestamp(end_time)
|
||||
if timeframe in self.timeframes:
|
||||
return self._get_base_klines(symbol, timeframe, start_ms, end_ms, limit)
|
||||
base_tf = self.derived_map.get(timeframe)
|
||||
if not base_tf:
|
||||
raise HTTPException(status_code=404, detail=f"{symbol} 时间周期 {timeframe} 不可用")
|
||||
target_minutes = timeframe_to_minutes(timeframe)
|
||||
if target_minutes is None:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的时间周期: {timeframe}")
|
||||
target_ms = target_minutes * 60_000
|
||||
# 起点前移一根目标周期长度,保证首根合成 K 边界完整
|
||||
adjusted_start = None if start_ms is None else max(0, start_ms - target_ms)
|
||||
base_candles = self._get_base_klines(symbol, base_tf, adjusted_start, end_ms, None)
|
||||
if not base_candles:
|
||||
return []
|
||||
df = pd.DataFrame(base_candles)
|
||||
if df.empty:
|
||||
return []
|
||||
df = df.drop_duplicates(subset=["timestamp"], keep="last").sort_values("timestamp")
|
||||
df["date"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
# resample_to_interval 按「分钟」目标周期聚合 OHLCV
|
||||
resampled = self.resample_df(df, target_minutes)
|
||||
if resampled is None or resampled.empty:
|
||||
return []
|
||||
# 统一得到毫秒 timestamp 列(resample 可能返回 date 或 DatetimeIndex)
|
||||
if "timestamp" in resampled.columns:
|
||||
resampled_df = resampled.copy()
|
||||
else:
|
||||
resampled_df = resampled.copy()
|
||||
if "date" in resampled_df.columns:
|
||||
dates = pd.to_datetime(resampled_df["date"], utc=True, errors="coerce")
|
||||
resampled_df["timestamp"] = (dates.astype("int64", copy=False) // 1_000_000).astype("int64")
|
||||
elif isinstance(resampled_df.index, pd.DatetimeIndex):
|
||||
idx = resampled_df.index
|
||||
if idx.tz is None:
|
||||
idx = idx.tz_localize("UTC")
|
||||
else:
|
||||
idx = idx.tz_convert("UTC")
|
||||
resampled_df["timestamp"] = (idx.astype("int64", copy=False) // 1_000_000).astype("int64")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"聚合结果缺少 timestamp 列 ({timeframe})")
|
||||
resampled_df = resampled_df.dropna(subset=["timestamp"]).sort_values("timestamp")
|
||||
if start_ms is not None:
|
||||
resampled_df = resampled_df[resampled_df["timestamp"] >= start_ms]
|
||||
if end_ms is not None:
|
||||
resampled_df = resampled_df[resampled_df["timestamp"] <= end_ms]
|
||||
if resampled_df.empty:
|
||||
return []
|
||||
resampled_df["datetime"] = resampled_df["timestamp"].apply(to_utc_iso)
|
||||
for column in ["open", "high", "low", "close", "volume"]:
|
||||
if column not in resampled_df.columns:
|
||||
resampled_df[column] = 0.0
|
||||
resampled_df = resampled_df[["timestamp", "datetime", "open", "high", "low", "close", "volume"]]
|
||||
result = resampled_df.to_dict("records")
|
||||
if limit:
|
||||
result = result[-limit:]
|
||||
logger.debug(
|
||||
"衍生周期返回",
|
||||
extra={
|
||||
"symbol": symbol,
|
||||
"timeframe": timeframe,
|
||||
"base_timeframe": base_tf,
|
||||
"count": len(result),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def create_app(provider: DataProvider) -> FastAPI:
|
||||
"""构造 FastAPI 应用:lifespan 内同步 initialize 并启动后台拉数;WebSocket 实时推送。"""
|
||||
ws_manager = WebSocketManager()
|
||||
|
||||
def _on_data_update(symbol: str, base_tf: str) -> None:
|
||||
"""后台刷新线程回调:广播基础及衍生周期更新给 WebSocket 订阅者。"""
|
||||
with provider._lock:
|
||||
base_data = list(provider.data.get(symbol, {}).get(base_tf, []))
|
||||
recent = base_data[-WS_UPDATE_CANDLE_COUNT:] if base_data else []
|
||||
if recent:
|
||||
ws_manager.broadcast_from_thread(symbol, base_tf, recent)
|
||||
for derived_tf, src_base in provider.derived_map.items():
|
||||
if src_base != base_tf or not ws_manager.has_subscribers(symbol, derived_tf):
|
||||
continue
|
||||
try:
|
||||
target_min = timeframe_to_minutes(derived_tf)
|
||||
if target_min is None:
|
||||
continue
|
||||
now_ms = int(time.time() * 1000)
|
||||
window_ms = target_min * 60_000 * (WS_UPDATE_CANDLE_COUNT + 2)
|
||||
derived = provider.get_klines(
|
||||
symbol, derived_tf, start_time=now_ms - window_ms, limit=WS_UPDATE_CANDLE_COUNT,
|
||||
)
|
||||
if derived:
|
||||
ws_manager.broadcast_from_thread(symbol, derived_tf, derived)
|
||||
except Exception as exc:
|
||||
logger.debug("衍生周期广播失败 %s %s: %s", symbol, derived_tf, exc)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
loop = asyncio.get_running_loop()
|
||||
ws_manager.set_loop(loop)
|
||||
provider.on_update(_on_data_update)
|
||||
await loop.run_in_executor(None, provider.initialize)
|
||||
provider.start_background_workers()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
provider.stop()
|
||||
|
||||
app = FastAPI(title="Chan 数据提供商", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> Dict[str, object]:
|
||||
"""存活检查:交易所、交易对、基础/衍生周期、是否已完成冷启动。"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"exchange": provider.exchange_name,
|
||||
"symbols": provider.symbols,
|
||||
"base_timeframes": provider.timeframes,
|
||||
"derived_timeframes": provider.get_derived_timeframes(),
|
||||
"timeframes": provider.get_available_timeframes(),
|
||||
"ready": provider.is_ready(),
|
||||
}
|
||||
|
||||
@app.get("/timeframes")
|
||||
async def list_timeframes() -> Dict[str, List[str]]:
|
||||
"""返回配置的基础周期与可合成的衍生周期列表。"""
|
||||
provider.wait_ready()
|
||||
return {
|
||||
"base_timeframes": provider.timeframes,
|
||||
"derived_timeframes": provider.get_derived_timeframes(),
|
||||
"timeframes": provider.get_available_timeframes(),
|
||||
}
|
||||
|
||||
@app.get("/api/candles")
|
||||
async def api_candles(
|
||||
symbol: str = Query(..., description="如 BTC/USDT"),
|
||||
tf: str = Query("1m", description="时间周期"),
|
||||
start: Optional[int] = Query(None, description="开始时间戳(ms)"),
|
||||
end: Optional[int] = Query(None, description="结束时间戳(ms)"),
|
||||
limit: Optional[int] = Query(None, description="可选,限制返回数量"),
|
||||
):
|
||||
"""按交易对与时间周期返回 OHLCV;tf 支持配置的基础周期及衍生合成周期。"""
|
||||
data = provider.get_klines(symbol=symbol, timeframe=tf, start_time=start, end_time=end, limit=limit)
|
||||
return data
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> Dict[str, object]:
|
||||
"""根路径:服务名、交易所、交易对与可用周期(含 ready 标志)。"""
|
||||
return {
|
||||
"service": "Data Provider",
|
||||
"exchange": provider.exchange_name,
|
||||
"symbols": provider.symbols,
|
||||
"base_timeframes": provider.timeframes,
|
||||
"derived_timeframes": provider.get_derived_timeframes(),
|
||||
"timeframes": provider.get_available_timeframes(),
|
||||
"ready": provider.is_ready(),
|
||||
}
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket):
|
||||
"""WebSocket 实时 K 线推送。
|
||||
|
||||
客户端发送 JSON:
|
||||
{"action": "subscribe", "symbol": "BTC/USDT:USDT", "timeframe": "1m"}
|
||||
{"action": "unsubscribe", "symbol": "BTC/USDT:USDT", "timeframe": "1m"}
|
||||
{"action": "ping"}
|
||||
服务端推送:
|
||||
{"type": "subscribed", "symbol": "...", "timeframe": "..."}
|
||||
{"type": "snapshot", "symbol": "...", "timeframe": "...", "data": [...]}
|
||||
{"type": "kline", "symbol": "...", "timeframe": "...", "data": [...]}
|
||||
{"type": "pong"}
|
||||
{"type": "error", "message": "..."}
|
||||
"""
|
||||
await ws_manager.connect(ws)
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.receive_text()
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await ws.send_text(json.dumps({"type": "error", "message": "invalid JSON"}))
|
||||
continue
|
||||
|
||||
action = msg.get("action", "")
|
||||
symbol = str(msg.get("symbol", "")).strip()
|
||||
timeframe = str(msg.get("timeframe", "")).strip()
|
||||
|
||||
if action == "ping":
|
||||
await ws.send_text(json.dumps({"type": "pong"}))
|
||||
|
||||
elif action == "subscribe":
|
||||
if not symbol or not timeframe:
|
||||
await ws.send_text(json.dumps(
|
||||
{"type": "error", "message": "需要 symbol 和 timeframe 字段"}
|
||||
))
|
||||
continue
|
||||
await ws_manager.subscribe(ws, symbol, timeframe)
|
||||
await ws.send_text(json.dumps(
|
||||
{"type": "subscribed", "symbol": symbol, "timeframe": timeframe},
|
||||
ensure_ascii=False,
|
||||
))
|
||||
try:
|
||||
snapshot = provider.get_klines(symbol, timeframe, limit=DEFAULT_LIMIT)
|
||||
if snapshot:
|
||||
await ws.send_text(json.dumps(
|
||||
{"type": "snapshot", "symbol": symbol, "timeframe": timeframe, "data": snapshot},
|
||||
ensure_ascii=False,
|
||||
))
|
||||
except Exception as exc:
|
||||
await ws.send_text(json.dumps({"type": "error", "message": str(exc)}))
|
||||
|
||||
elif action == "unsubscribe":
|
||||
await ws_manager.unsubscribe(ws, symbol, timeframe)
|
||||
await ws.send_text(json.dumps(
|
||||
{"type": "unsubscribed", "symbol": symbol, "timeframe": timeframe},
|
||||
ensure_ascii=False,
|
||||
))
|
||||
|
||||
else:
|
||||
await ws.send_text(json.dumps({"type": "error", "message": f"未知 action: {action}"}))
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await ws_manager.disconnect(ws)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def build_app() -> FastAPI:
|
||||
"""默认入口:从环境变量 CONFIG_PATH(或 config.json)加载配置并创建 FastAPI app。"""
|
||||
config_path = Path(os.getenv("CONFIG_PATH", "config.json"))
|
||||
provider = DataProvider(config_path)
|
||||
return create_app(provider)
|
||||
|
||||
|
||||
app = build_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""直接运行本模块时启动 uvicorn(监听 UVICORN_HOST / UVICORN_PORT)。"""
|
||||
host = os.getenv("UVICORN_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("UVICORN_PORT", "9009"))
|
||||
|
||||
uvicorn.run(app, host=host, port=port, log_level=os.getenv("UVICORN_LOG_LEVEL", "info"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
ccxt>=4.0.0,<5.0.0
|
||||
fastapi>=0.110.0,<1.0.0
|
||||
uvicorn[standard]>=0.23.0,<1.0.0
|
||||
pandas>=2.0.0,<3.0.0
|
||||
technical==1.5.0
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-001: 包布局与兼容 shim
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-08-05
|
||||
**ECR:** ECR-001
|
||||
|
||||
## Context
|
||||
|
||||
根目录扁平模块被 strategies 与 web 通过模块名直接 import;完全改名会破坏 Freqtrade 策略。需要正式包边界,同时零改 `strategies/`。
|
||||
|
||||
## Decision
|
||||
|
||||
1. 正式包名:`chanlun`(`core` / `pipeline` / `indicators` / `analysis`)。
|
||||
2. 根目录保留同名 shim 文件,再导出公共符号。
|
||||
3. Web 使用 Flask blueprints + services;前端 JS 模块化,不引入 TS 构建。
|
||||
4. `TF_DF` 保留门面类名与公开方法,内部委托 builders。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 策略无需修改。
|
||||
- 长期可逐步引导新代码 `from chanlun import ...`。
|
||||
- shim 需保持至策略侧显式迁移(另立 ECR)。
|
||||
@@ -0,0 +1,44 @@
|
||||
# AGENT_MEMORY — chan
|
||||
|
||||
> Agent 短记忆。先读 `PROJECT_PROFILE.md`,再读本文件。不要把猜测写进这里。
|
||||
|
||||
## 双前端
|
||||
|
||||
| 入口 | 引擎 | 实时 |
|
||||
|------|------|------|
|
||||
| `/` | Lightweight Charts | HTTP 定时自动刷新(增量 + 每 6 次全量) |
|
||||
| `/chan_tv` | Charting Library 全版 | datafeed `subscribeBars` → WS |
|
||||
|
||||
勿把主站 `live_feed` 方案与 chan_tv datafeed 混为一谈;主站 WS 实时已回退。
|
||||
|
||||
## 版本
|
||||
|
||||
- `system_version`:`v1.0.0`(ECR-001)
|
||||
- `strategy_version`:与 system 解耦;默认不改 `config/` / `strategies/`
|
||||
|
||||
## 近期变更
|
||||
|
||||
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
|
||||
- ECR-002 Reviewed:拆 `web/services/runtime/`、加深 analyze 契约
|
||||
- ECR-003 Reviewed:主站威科夫叠层(`chanlun/analysis/wyckoff/` + `include_wyckoff`)→ `081a57a`
|
||||
- ECR-004 Reviewed:TR 评分硬化 + VP 少系列 + 阶段/门闩/单测(无币种参数)
|
||||
- ECR-007 Final Approval / `276481e`:Wyckoff Live Structure(`live.py`);Confirmed ≠ Live;execution 仅 confirmed
|
||||
- ECR-008 Reviewed:主站 `chart_tv.js` → `chart_tv_{lifecycle,shell,indicators,chan,overlays,finalize}.js` + 薄门面
|
||||
- 威科夫数据随主 analyze 默认返回;UI 开关仅显隐叠层
|
||||
- Live 观察:主图左下角 Cycle Summary(「形成中」= FORMING);无单独 Live 图层
|
||||
|
||||
## 硬约束提醒
|
||||
|
||||
- `/api/analyze` 字段可增不可删
|
||||
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||
- 威科夫为独立叠层(ECR-003/007);勿借机改缠论算法
|
||||
- Live candidate **不得**进入 execution;交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||
|
||||
## 已知债务
|
||||
|
||||
- analyze 契约已加深(mock HTTP + wyckoff opt-in);可再加固定 JSON 快照文件
|
||||
- 内存泄漏尚无自动化 heap/监听断言
|
||||
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
||||
- 威科夫启发式参数未做 UI 调参
|
||||
- ECR-007 待 Human 在 Gitea 开 PR 合入 `dev`
|
||||
- `chart_tv_overlays.js` 仍偏大,可后续再拆
|
||||
@@ -0,0 +1,70 @@
|
||||
# Backend Design: ECR-007 Wyckoff Live Structure
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | BD-2026-007 |
|
||||
| ECR | ECR-007 |
|
||||
| Change Level | L2 |
|
||||
| Status | Approved |
|
||||
| Author | Architect (LOOP-RUN-005 Planner) |
|
||||
| Date | 2026-08-07 |
|
||||
| Risk | High (domain / execution boundary) |
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
- 问题:Confirmed 引擎已存在;需要独立 Live 推演层供观察,且不得成为交易执行输入。
|
||||
- 非目标:改 Confirmed 门槛;自动交易;策略。
|
||||
- 依赖:ECR-003/004 威科夫;WYCKOFF-LIVE-STRUCTURE-001(FROZEN)。
|
||||
|
||||
## Architecture Change / Change Boundary
|
||||
|
||||
```text
|
||||
OHLCV
|
||||
→ detect_trading_ranges (Confirmed path)
|
||||
→ detect_bias_and_events / build_phases ← Confirmed(阈值不降)
|
||||
→ analyze_live_structure ← Live(只读 confirmed)
|
||||
→ cycles[i] = { lifecycle, confirmed, live }
|
||||
→ API analyze + Summary UI
|
||||
→ execution_signal_from_wyckoff(confirmed only)
|
||||
```
|
||||
|
||||
| Layer | May change | Must not |
|
||||
|-------|------------|----------|
|
||||
| Confirmed | assemble into `confirmed{}` | relax Spring/SOS rules |
|
||||
| Live | `live.py` heuristics | write into confirmed.events |
|
||||
| Execution helper | source=confirmed gate | consume candidates |
|
||||
| UI | Summary partition | treat Live as order |
|
||||
|
||||
## Backend Change Boundary
|
||||
|
||||
Live outputs are **observation**. Execution boundary:
|
||||
|
||||
```python
|
||||
assert execution_signal.source == "confirmed"
|
||||
# live-only payload → None
|
||||
```
|
||||
|
||||
## Data contract
|
||||
|
||||
See WYCKOFF-LIVE-STRUCTURE-001. Top-level `phases`/`events` mirror **Confirmed** only.
|
||||
|
||||
## delivery_constraints
|
||||
|
||||
- BD Status Approved
|
||||
- TEST_REPORT commands/result/date
|
||||
- CODE_REVIEW handoff
|
||||
- TRACEABILITY commit
|
||||
- out_of_scope + execution_source_confirmed_only
|
||||
|
||||
## Test Plan
|
||||
|
||||
1. Live candidates not in confirmed.events
|
||||
2. CONFIRMED lifecycle when Spring+SOS confirmed
|
||||
3. execution_signal source=confirmed; live-only → None
|
||||
4. analyze contract keys include live/lifecycle
|
||||
|
||||
## Rollback
|
||||
|
||||
Remove live assembly path; Summary falls back to confirmed-only.
|
||||
@@ -0,0 +1,88 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased — 2026-08-07
|
||||
|
||||
### ECR-008(L3,Reviewed)
|
||||
|
||||
- 主站 `chart_tv.js` 拆为 lifecycle / shell / indicators / chan / overlays / finalize + 薄门面
|
||||
- 行为冻结;`initTradingView` / `disposeTradingViewCharts` 对外不变;无 Vite/TS
|
||||
|
||||
### ECR-007(L2,LOOP-RUN-005)
|
||||
|
||||
- Wyckoff **Live Structure**:`live.py` + engine 组装 `lifecycle` / `confirmed` / `live`
|
||||
- Event candidates(Spring/SOS/LPS/UTAD)+ 可解释 confidence;Summary Confirmed/Live 分区
|
||||
- `execution_signal_from_wyckoff` **仅** `source=confirmed`;Live-only → None
|
||||
- **No** Confirmed 门槛降低;**No** strategies / 自动交易
|
||||
|
||||
## Unreleased — 2026-08-06
|
||||
|
||||
### ECR-004(L2,Reviewed)
|
||||
|
||||
- 威科夫 TR 评分选段(防吞前置趋势);阶段非重叠最小跨度
|
||||
- 主站 VP Top-8 + bins≤24;填充线减负
|
||||
- `elements_only` 时不跑威科夫;收紧单测(无币种独立参数)
|
||||
- **后续**:威科夫随主 `/api/analyze` 默认一并返回;前端开关只控制绘制(不再勾选才加载)
|
||||
|
||||
### ECR-003(L2,Reviewed)
|
||||
|
||||
- 新增 `chanlun/analysis/wyckoff/`:交易区间、阶段 A–E、Spring/SOS/LPS/UTAD 等事件、区间 VP(POC/VAH/VAL)、量能确认
|
||||
- `/api/analyze` 按需 `include_wyckoff=1` 返回顶层 `wyckoff`
|
||||
- 主站「威科夫」开关与 Lightweight 叠层(区间/阶段/事件/VP)
|
||||
- 单测与 analyze 契约 opt-in 断言
|
||||
|
||||
### ECR-002(L3,Reviewed)
|
||||
|
||||
- 拆分 `web/services/runtime.py` 为包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize)
|
||||
- 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON)
|
||||
- 新增 TF_DF 全量 init 冒烟与 runtime 门面测试
|
||||
|
||||
### IDEA-002(L1 补档)
|
||||
|
||||
对应 commit `9f1e736`。无新 system tag(仍为 `v1.0.0`)。
|
||||
|
||||
#### Fixed
|
||||
|
||||
- 主站自动刷新内存泄漏:`disposeTradingViewCharts`、去掉重复 sync 监听、默认增量刷新(每 6 次全量重建笔/段/中枢)
|
||||
- 加密货币首屏重复调用 `/api/analyze`
|
||||
- ChanMACD 同周期重复全量分析(复用 `get_klc_list` 结果)
|
||||
|
||||
#### Changed
|
||||
|
||||
- `/chan_tv`:WS/REST 可分离配置、指标布局 localStorage、未完成中枢与 datafeed 实时 tick 行为完善
|
||||
- `PROJECT_PROFILE` Realtime 条目与 chan_tv WS 对齐(文档)
|
||||
|
||||
#### Docs
|
||||
|
||||
- ESS:IDEA-002、AGENT_MEMORY、AGENTS;ECR-002 实现与报告
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 — 2026-08-05(首个正式 Release)
|
||||
|
||||
对应 ECR-001 / tag `v1.0.0`。详见 `docs/RELEASE/ECR-001-v1.0.0.md`。
|
||||
|
||||
### Added
|
||||
|
||||
- 正式包 `chanlun/`(core / pipeline / builders / indicators / analysis)
|
||||
- ESS 文档树 `docs/`(PROFILE / ECR / SPEC / ADR / HANDOFF / CODE_REVIEW / RELEASE)
|
||||
- Web `config.py`、`services/`、`api/` blueprints
|
||||
- 前端 `web/static/js/app/` 模块
|
||||
- Golden 回归 `tests/generate_golden.py` + fixtures
|
||||
|
||||
### Changed
|
||||
|
||||
- 根目录 `Chan*.py` / `TF_DF.py` 改为兼容 shim
|
||||
- `TF_DF` 实现拆至 builders,门面签名保持
|
||||
- `web/app.py` 瘦身为 `create_app()`
|
||||
- `index.html` 去掉巨型 inline 业务 JS
|
||||
- HTTP 代理改为环境变量配置
|
||||
- strategies / web / tests / examples → `from chanlun...` 导入
|
||||
|
||||
### Fixed
|
||||
|
||||
- 恢复缺失的 `TF_DF.get_zs_list`(委托 `get_seg_zs_list`)
|
||||
|
||||
### Moved
|
||||
|
||||
- 示例 → `examples/`;笔记 → `docs/notes/`
|
||||
- 未接线 TSX/TS → `static/js/_unused/`
|
||||
@@ -0,0 +1,75 @@
|
||||
# CODE_REVIEW — ECR-001
|
||||
|
||||
**Role:** REVIEWER
|
||||
**Date:** 2026-08-05
|
||||
**Commit:** `74dec4e` (`refactor: 缠论引擎包化与 Web 分层(ECR-001)`)
|
||||
**Decision:** Approve
|
||||
|
||||
## Evidence loaded
|
||||
|
||||
- `docs/ECR/ECR-001-chan-web-restructure.md`
|
||||
- `docs/ENGINEERING_SPEC/ECR-001-restructure.md`
|
||||
- `docs/IMPLEMENTATION_REPORT/ECR-001.md`
|
||||
- `docs/TEST_REPORT/ECR-001.md`
|
||||
- `docs/HANDOFF/ECR-001-engineer-to-reviewer.md`
|
||||
- Diff `e2e45bc..74dec4e`;本地复跑测试
|
||||
|
||||
## Acceptance ↔ Evidence
|
||||
|
||||
| Acceptance | Verdict | Evidence |
|
||||
|------------|---------|----------|
|
||||
| `from ChanLun import ChanLun` / `ChanEnum` 仍可用 | PASS | 复跑 shim+package 同一对象;`test_compat_shim_still_works` |
|
||||
| Golden bi/seg/zs/bsp 与基线一致 | PASS | `python tests/generate_golden.py --check` → GOLDEN OK;pytest 含 golden |
|
||||
| `/api/analyze` 关键字段兼容 | PASS | `analyze_contract_keys.json` + 路由注册冒烟(未做实盘拉行情 E2E,见 Findings) |
|
||||
| `web/app.py` 瘦身 factory | PASS | `web/app.py` 32 行;`create_app` + blueprints |
|
||||
| `index.html` 无大体量 inline 业务 JS | PASS | ~1482 行;业务在 `static/js/app/*` |
|
||||
| ESS docs / TEST / IMPL / CHANGELOG | PASS | `docs/` 齐全 |
|
||||
| `config/` 无内容变更 | PASS | `git diff e2e45bc..HEAD -- config` 空 |
|
||||
| `strategies/` 无内容变更 | **AMENDED** | 见下「范围修订」 |
|
||||
|
||||
## 范围修订(Human 后续指示)
|
||||
|
||||
原 ECR Forbidden 写「不改 strategies/」。实现后期 Human 要求「一次性做完」导入迁移:strategies 仅改 import / `sys.path`(26 files, +75/−75),**无策略交易逻辑变更**。
|
||||
审阅结论:视为 **L3 结构收尾的允许增补**,不构成交易语义 L2;建议 ECR Acceptance 改为「strategies 仅允许 import/path 迁移,禁止改买卖逻辑」。
|
||||
**不据此 Request changes。**
|
||||
|
||||
## 复跑结果(Reviewer)
|
||||
|
||||
```text
|
||||
shim+package OK
|
||||
GOLDEN OK {klu:400, klc:208, bi:14, seg:2, zs:0, bsp:3}
|
||||
pytest tests/test_golden_pipeline.py web/tests/test_analyze_contract.py → 6 passed
|
||||
```
|
||||
|
||||
## Findings
|
||||
|
||||
### Non-blocking(记入债务,需新 ECR 再动)
|
||||
|
||||
1. **`web/services/runtime.py` ~1176 行** — 已从 app 抽出但仍是大模块;facade 再导出符合计划 → **已起草 `docs/ECR/ECR-002-runtime-split.md`(Draft)**。
|
||||
2. **`web/static/js/app/chart_tv.js` ~4664 行** — `initTradingView` 单体;行为冻结下可接受;ECR-002 可选范围。
|
||||
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)→ ECR-002。
|
||||
4. **TEST_REPORT 写「5 passed」** — 现为 6(含 shim 兼容测);Release 前可改正文(L0 docs)。
|
||||
5. **L1:`TF_DF.get_zs_list` 恢复** — 合理兼容修复;golden 走 analyze 路径未覆盖 `TF_DF(df,...)` 全量 `__init__` → ECR-002 Acceptance。
|
||||
|
||||
### No blockers
|
||||
|
||||
未发现违反「算法语义冻结 / API 可增不可删 / 无 Vite-React / config 未改」的证据。
|
||||
|
||||
## Decision
|
||||
|
||||
**Approve**
|
||||
|
||||
- ECR-001 可进入 Release(本变更无交易 EXP 门禁)。
|
||||
- 非阻断项进入 backlog / 未来 ECR,不阻塞 tag。
|
||||
|
||||
## Next owner
|
||||
|
||||
`release_manager` — 写 RELEASE_REPORT、打 tag(需 Human 确认发布动作)。
|
||||
|
||||
## Traceability
|
||||
|
||||
| Item | Updated |
|
||||
|------|---------|
|
||||
| Acceptance mapping | 本文件 |
|
||||
| STATE.owner | → release_manager |
|
||||
| ECR Status | → Done (Reviewed) |
|
||||
@@ -0,0 +1,74 @@
|
||||
# CODE_REVIEW — ECR-002
|
||||
|
||||
**Role:** REVIEWER
|
||||
**Date:** 2026-08-06
|
||||
**Scope:** 工作区未提交实现(相对 `HEAD`/`9f1e736`);包 `web/services/runtime/` + 测试 + ESS 文档
|
||||
**Decision:** Approve
|
||||
|
||||
## Evidence loaded
|
||||
|
||||
- `docs/ECR/ECR-002-runtime-split.md`
|
||||
- `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||
- `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||
- `docs/TEST_REPORT/ECR-002.md`
|
||||
- `docs/HANDOFF/ECR-002-engineer-to-reviewer.md`
|
||||
- 包源码:`web/services/runtime/{__init__,state,timeframes,market_data,indicators,analyze,serialize}.py`
|
||||
- Diff:删除 `web/services/runtime.py`;新增包与测试
|
||||
|
||||
## Acceptance ↔ Evidence
|
||||
|
||||
| Acceptance | Verdict | Evidence |
|
||||
|------------|---------|----------|
|
||||
| runtime 门面公开符号兼容(含历史 `import *` 漏出) | PASS | 手工核对 api 所需符号;`timezone`/`OrderedDict`/`np`/`StructureZone*`/`ThreadPoolExecutor` 等在门面;`test_runtime_facade` |
|
||||
| Golden 通过 | PASS | 复跑 `tests/test_golden_pipeline.py` |
|
||||
| Analyze 契约加深 | PASS | `test_analyze_contract`:键清单 + analyze_chan 键集 + serialize JSON + mock HTTP |
|
||||
| TF_DF 全量 init 冒烟 | PASS | `tests/test_tf_df_init.py`(`interval=1`) |
|
||||
| config/strategies 无交易逻辑 diff | PASS | 工作区无 `config/`/`strategies/` 变更 |
|
||||
| IMPL / TEST / CHANGELOG / TRACEABILITY | PASS | docs 已落盘 |
|
||||
| CODE_REVIEW Approve | PASS | 本文件 |
|
||||
|
||||
## 复跑结果(Reviewer)
|
||||
|
||||
```text
|
||||
PYTHONPATH=.:web python -m pytest \
|
||||
tests/test_golden_pipeline.py \
|
||||
tests/test_tf_df_init.py \
|
||||
web/tests/test_runtime_facade.py \
|
||||
web/tests/test_analyze_contract.py -q
|
||||
→ 13 passed
|
||||
```
|
||||
|
||||
算法冻结抽查:`analyze.py` 仍为 `cal_bi_zs(seg_list)` + `_last_chan_macd` 复用;未改笔段中枢语义。
|
||||
|
||||
## Findings
|
||||
|
||||
### Non-blocking(不挡 Approve)
|
||||
|
||||
1. **门面标量同步只做一次** — `__init__` 在首次 `refresh` 后把 `DATA_SERVICE_AVAILABLE` / `macd_*` 写入模块 dict;之后 `refresh_data_service_metadata` 只改 `state.*`。通过 `R.DATA_SERVICE_AVAILABLE` 读取可能与 state 短期不一致;`from services.runtime import *` 的 bool 拷贝问题在 monolith 时代已存在。建议后续 L1:在 `refresh` 末尾同步写回门面模块,或让标量只经 `state`/`__getattr__` 暴露。
|
||||
2. **`__getattr__` 对已绑定名无效** — 与上条相关;属清理项。
|
||||
3. **`chart_tv.js` 拆分未做** — ECR 明确可选;继续记入 backlog。
|
||||
4. **契约测试仍无「固定 JSON 快照文件」** — 已有 mock HTTP + 键集,比 ECR-001 深;完整响应快照可另开 L1/ECR。
|
||||
5. **`web/tests/test_cn_stock_data_fetch.py` 仍因旧 `user_data.Chan...` 路径无法收集** — 既有问题,非本 ECR 引入。
|
||||
|
||||
### No blockers
|
||||
|
||||
未发现违反「算法语义冻结 / API 可增不可删 / 无 Vite-React / 未动 strategies·config / 未引主站 WS」的证据。
|
||||
|
||||
## Decision
|
||||
|
||||
**Approve**
|
||||
|
||||
- ECR-002 可标 Done(Reviewed);不强制新 system tag(仍为 `v1.0.0` Unreleased 文档变更)。
|
||||
- 非阻断项进 backlog;不阻塞合并本实现。
|
||||
|
||||
## Next owner
|
||||
|
||||
`engineer` / Human — 提交合并;若要发版再交 `release_manager`(本 ECR 未要求 bump tag)。
|
||||
|
||||
## Traceability
|
||||
|
||||
| Item | Updated |
|
||||
|------|---------|
|
||||
| Acceptance mapping | 本文件 |
|
||||
| STATE.owner | → idle / merge |
|
||||
| ECR Status | → Done (Reviewed) |
|
||||
@@ -0,0 +1,77 @@
|
||||
# CODE_REVIEW — ECR-003
|
||||
|
||||
**Role:** REVIEWER
|
||||
**Date:** 2026-08-06
|
||||
**Scope:** 工作区未提交 ECR-003(相对 `origin/dev` @ `df27b4d`)
|
||||
**Decision:** Approve(带非阻断 Findings;建议合并前勿提交 `.DS_Store`)
|
||||
|
||||
## Evidence loaded
|
||||
|
||||
- `chanlun/analysis/wyckoff/{engine,range,events,volume_profile}.py`
|
||||
- `web/api/analyze.py`(`include_wyckoff`)
|
||||
- `web/templates/index.html`、`chart_view.js`、`macd_ui.js`、`chart_tv.js` 威科夫块
|
||||
- `tests/test_wyckoff.py`、`web/tests/test_analyze_contract.py`
|
||||
- ESS:ECR/PRODUCT/ENG/IMPL/TEST/HANDOFF
|
||||
|
||||
## Acceptance ↔ Evidence
|
||||
|
||||
| Acceptance | Verdict | Evidence |
|
||||
|------------|---------|----------|
|
||||
| `include_wyckoff=1` 返回约定键;默认不强制 | PASS | 契约测试;默认无 `wyckoff` 键 |
|
||||
| 合成 TR + 事件;VP POC | PASS | `test_wyckoff.py`(12 相关套件全绿) |
|
||||
| 主站可开关绘制 | PASS | 主开关按需拉取;子项本地重绘 |
|
||||
| golden 不变 | PASS | `test_golden_pipeline` |
|
||||
| 未改缠论算法 / strategies / chan_tv | PASS | diff 范围核对 |
|
||||
| ESS 闭环 | PASS | IMPL/TEST/TRACE/CHANGELOG/本文件 |
|
||||
|
||||
## 复跑
|
||||
|
||||
```text
|
||||
PYTHONPATH=.:web python -m pytest \
|
||||
tests/test_wyckoff.py tests/test_golden_pipeline.py \
|
||||
web/tests/test_analyze_contract.py -q
|
||||
→ 12 passed
|
||||
```
|
||||
|
||||
## Findings
|
||||
|
||||
### Important(不挡 Approve,建议跟进)
|
||||
|
||||
1. **交易区间易吞并前置趋势**
|
||||
`detect_trading_range` 从最长窗口向下搜,合成夹具下 `abs_start_idx=0`,箱体前下跌段被算进 TR。单测只断言「有区间 + 有事件」,未锁定高低/起点。
|
||||
*建议:* 用「宽度/触边密度」评分取最优段,或要求近端触边;测试断言 `high≈60/low≈40` 与起点靠近箱体。
|
||||
|
||||
2. **VP 叠层系列数偏多,可能加压自动刷新内存**
|
||||
开启 VP 时约每个 bin 一条 `addLineSeries`(默认 ~50),再加区间填充/阶段。与 IDEA-002 内存修复同路径全量重建时放大。
|
||||
*建议:* 只画非零 bin 或合并为少量 series / histogram;或限制 `vp_bins` 上限到 24。
|
||||
|
||||
### Medium
|
||||
|
||||
3. **阶段 C–E 在事件扎堆时常退化重叠**
|
||||
夹具输出中 D/E 起止几乎相同;状态机按事件锚点硬切,缺少最小阶段长度。展示可用,语义偏弱。
|
||||
|
||||
4. **`elements_only=true` 仍可能跑威科夫**
|
||||
威科夫挂在路由末尾,不依赖 `not elements_only`。主站当前不这么发,但契约上奇怪;建议与主周期分析同门闩。
|
||||
|
||||
5. **单测断言偏松**
|
||||
`Spring in types or SOS`、`abs(poc-50)<2` 对回归保护不足。
|
||||
|
||||
### Low
|
||||
|
||||
6. 失败时 `wyckoff.error` 回传异常字符串(与结构区 print 风格一致,信息暴露轻微)。
|
||||
7. 事件 marker 一律 `arrowUp`(跌破类也可 `arrowDown`)。
|
||||
8. 工作区 `.DS_Store` 脏文件——**勿纳入 commit**。
|
||||
|
||||
### No blockers
|
||||
|
||||
未发现:契约删键、缠论语义改动、策略/config 改动、未鉴权危险写操作、主站误引 WS。
|
||||
|
||||
## Decision
|
||||
|
||||
**Approve**
|
||||
|
||||
可合并提交(排除 `.DS_Store`)。Important #1/#2 可开后续 L1/L2,不阻塞本 ECR 着陆。
|
||||
|
||||
## Next owner
|
||||
|
||||
`engineer` / Human — commit(勿含 `.DS_Store`);可选跟进 TR 评分与 VP 绘图优化。
|
||||
@@ -0,0 +1,53 @@
|
||||
# CODE_REVIEW — ECR-004
|
||||
|
||||
**Role:** REVIEWER
|
||||
**Date:** 2026-08-06
|
||||
**Scope:** `d3188ca`(相对 ECR-003)威科夫硬化
|
||||
**Decision:** Approve
|
||||
|
||||
## Evidence loaded
|
||||
|
||||
- Diff `d3188ca`:`range.py` / `events.py` / `analyze.py` / `chart_tv.js` / tests / ESS
|
||||
- 复跑:`tests/test_wyckoff.py` + golden + analyze contract → **14 passed**
|
||||
- 合成夹具抽查:`abs_start_idx=20`,low/high≈40.1/59.9(相对 003 的 bar0 已修好)
|
||||
|
||||
## Acceptance ↔ Evidence
|
||||
|
||||
| Acceptance | Verdict | Evidence |
|
||||
|------------|---------|----------|
|
||||
| TR 不吞明显前置趋势;边界近箱体 | PASS | 评分选段;单测 low/high 带 + `abs_start≥12` + start 时间容差 |
|
||||
| VP series 减负 | PASS | Top-8 + 填充 3 + POC/VAH/VAL;API bins≤24 |
|
||||
| 阶段最小跨度 / 不重合 | PASS | 链式 cursor;unique (start,end) 断言 |
|
||||
| elements_only 门闩 | PASS | `include_wyckoff and not elements_only` + 契约测试 |
|
||||
| golden 不变 / 无策略改动 / 无币种表 | PASS | golden 绿;diff 无 config/strategies |
|
||||
|
||||
## Findings
|
||||
|
||||
### Medium(不挡 Approve)
|
||||
|
||||
1. **同分 tie-break 偏向更长窗口**
|
||||
循环从长到短,`score <= best_score` 时保留已有(更长)。多数情况分数拉开;若实盘出现「长窗与短窗同分」,仍可能略偏长。可选:同分取更短,或加 `1/length` 微项。
|
||||
|
||||
2. **阶段常截断为 A–C**
|
||||
Spring/SOS 落在尾部时 D/E 因 `min_span` 被吃掉——与 ENG「空间不足截断」一致,但 UI 勾选「阶段」时用户可能期望总见 D/E。属产品预期,非缺陷;可在 UI/文档标明「尾部不足则省略」。
|
||||
|
||||
### Low
|
||||
|
||||
3. **`abs_start_idx >= 12` 弱于「箱体起点」** —— 主测已用时间容差;该断言可再收紧到 `>= 16` 一类。
|
||||
4. **VP Top-N 无自动化 series 计数** —— 靠代码审查 + ENG 约定。
|
||||
5. 事件 marker 仍一律 `arrowUp`(003 遗留)。
|
||||
6. 失败路径仍回传 `wyckoff.error` 字符串。
|
||||
|
||||
### No blockers
|
||||
|
||||
未发现契约删键、缠论语义改动、策略改动、或回归红灯。
|
||||
|
||||
## Decision
|
||||
|
||||
**Approve**
|
||||
|
||||
ECR-004 可维持 Done (Reviewed)。Medium 项进 backlog,不必立刻新 ECR,除非实盘 TR 仍偏长。
|
||||
|
||||
## Next owner
|
||||
|
||||
Human — 主站 BTC 勾选威科夫目测;无发版要求则保持 `v1.0.0` Unreleased 累计。
|
||||
@@ -0,0 +1,25 @@
|
||||
# CODE_REVIEW — ECR-008
|
||||
|
||||
**Role:** REVIEWER
|
||||
**Date:** 2026-08-07
|
||||
**Scope:** chart_tv 物理拆分
|
||||
**Decision:** Approve
|
||||
|
||||
## Checklist
|
||||
|
||||
| Item | Result | Notes |
|
||||
|------|--------|-------|
|
||||
| 行为冻结(仅搬移) | PASS | ctx 编排;无绘制算法改写意图 |
|
||||
| 对外 API | PASS | `initTradingView` / `disposeTradingViewCharts` 保留 |
|
||||
| Forbidden | PASS | 无 Vite/TS;无 strategies/config;无 analyze 契约改动 |
|
||||
| script 顺序 | PASS | lifecycle→shell→indicators→chan→overlays→finalize→门面→sync |
|
||||
| 测试证据 | PASS | `node --check` ALL_CHECK_OK |
|
||||
|
||||
## Findings
|
||||
|
||||
1. **Low:** 浏览器硬刷新冒烟仍建议 Human 点一次(自动刷新 + Cycle Summary)。不挡 Approve。
|
||||
2. **Low:** `chart_tv_overlays.js` 仍偏大(~2.3k 行);可后续再拆,非本 ECR 范围。
|
||||
|
||||
## Decision
|
||||
|
||||
**Approve**
|
||||
@@ -0,0 +1,70 @@
|
||||
# ECR-001
|
||||
|
||||
**Title:** 缠论引擎包化 + Web 分层重构(行为冻结)
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-05
|
||||
**Change Level:** L3
|
||||
|
||||
## Change
|
||||
|
||||
将根目录扁平 `Chan*.py` / `TF_DF.py` 包化为 `chanlun/`,拆分 `web/app.py` 与巨型 `index.html` inline JS;算法与 `/api/analyze` 契约冻结;`config/` / `strategies/` 不动。
|
||||
|
||||
## Motivation
|
||||
|
||||
根目录与 Web 单体过大、职责混杂,难以维护与测试;需在不影响 Freqtrade 策略导入的前提下重整结构。
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed
|
||||
|
||||
- 创建 `chanlun/`(core / pipeline / indicators / analysis)与根目录兼容 shim
|
||||
- 拆分 `TF_DF` 为 builders + 门面(公开方法签名不变)
|
||||
- Web:`config` + `services` + `api` blueprints;配置外置(proxy / DATA_SERVICE)
|
||||
- 前端:`index.html` 业务 JS 外置到 `static/js/app/`;隔离未接线 TS/TSX
|
||||
- 示例与笔记迁入 `examples/` / `docs/notes/`
|
||||
- Golden / 契约回归测试
|
||||
|
||||
### Forbidden
|
||||
|
||||
- 修改笔 / 线段 / 中枢 / 买卖点算法语义
|
||||
- 破坏 `/api/analyze` JSON 字段(可增不可删)
|
||||
- 修改 `config/`;修改 `strategies/` 交易逻辑或参数(import/`sys.path` 迁移除外,见 CODE_REVIEW)
|
||||
- 引入 Vite/React/TS 构建
|
||||
- 重做 UI 视觉或更换 TradingView
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| 策略 import 断裂 | 根 shim + 冒烟导入 |
|
||||
| 拆文件改算法 | 仅搬移;golden fixture |
|
||||
| 前端事件遗漏 | 按块抽取 + 手工/冒烟 |
|
||||
| API 字段漂移 | analyze 契约测试 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `from ChanLun import ChanLun` / `from ChanEnum import ...` 仍可用
|
||||
- [x] Golden:同一 fixture 下 bi/seg/zs/bsp 序列化结果与基线一致
|
||||
- [x] `/api/analyze` 关键字段集合兼容(契约冒烟)
|
||||
- [x] `web/app.py` 瘦身为 factory;业务在 services/api
|
||||
- [x] `index.html` 不再含大体量业务 inline JS
|
||||
- [x] ESS docs 齐全;TEST_REPORT / IMPLEMENTATION_REPORT / CHANGELOG
|
||||
- [x] `config/` 无内容变更;`strategies/` 仅允许 import/`sys.path` 迁移(Human 增补,无交易逻辑变更)
|
||||
|
||||
**Status:** Done (Released as `v1.0.0`)
|
||||
|
||||
## Rollback
|
||||
|
||||
单分支 / 单 PR 回滚;shim 期可整体 `git revert`。
|
||||
|
||||
## Risk Review
|
||||
|
||||
- Path: `docs/RISK_REVIEW/ECR-001.md` — N/A(不改交易语义)
|
||||
|
||||
## Linked
|
||||
|
||||
- PRD / PRODUCT_SPEC: `docs/PRODUCT_SPEC/ECR-001-restructure.md`
|
||||
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-001-restructure.md`
|
||||
- ADR: `docs/ADR/ADR-001-package-layout.md`
|
||||
- EXPERIMENT: N/A
|
||||
- TRACEABILITY: Yes
|
||||
@@ -0,0 +1,74 @@
|
||||
# ECR-002
|
||||
|
||||
**Title:** 拆分 `web/services/runtime.py` + 加深 `/api/analyze` 契约测试
|
||||
**Status:** Done (Reviewed)
|
||||
**Date:** 2026-08-06
|
||||
**Change Level:** L3(行为冻结;若 golden 漂移则升 L2)
|
||||
|
||||
## Change
|
||||
|
||||
将仍偏大的 `web/services/runtime.py` 按职责拆为可维护子模块;加深 analyze API 契约/快照测试;可选拆分主站巨型 `chart_tv.js`(本轮未做)。
|
||||
|
||||
## Motivation
|
||||
|
||||
ECR-001 CODE_REVIEW 非阻断债务:runtime 过大、契约测试偏浅、chart_tv 单体。不处理会继续抬高 Web 改动风险。
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed
|
||||
|
||||
- 物理拆分 `web/services/runtime.py` → 包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize + 门面)
|
||||
- 加深 `web/tests/`:固定 fixture / mock 行情下的关键字段快照与契约
|
||||
- 补 `TF_DF(..., interval=1)` 全量 `__init__` 冒烟
|
||||
- 更新 TECH_STACK / TRACEABILITY / CHANGELOG
|
||||
|
||||
### Forbidden
|
||||
|
||||
- 修改笔 / 线段 / 中枢 / 买卖点算法语义
|
||||
- 破坏 `/api/analyze` JSON 字段(可增不可删)
|
||||
- 修改 `config/`、`strategies/` 交易逻辑或参数
|
||||
- 引入 Vite/React/TS 构建
|
||||
- 为主站重新引入 WebSocket 实时(须另 ECR)
|
||||
- 无 Approve 即大规模改前端视觉
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| 拆文件隐式改行为 | 仅搬移;golden + analyze 契约/快照 |
|
||||
| 门面漏导出 | 保留 `runtime` re-export + 历史 import * 兼容符号 |
|
||||
| 测试依赖真实行情 | mock / fixture;不绑生产 WS |
|
||||
| chart_tv 拆分漏事件 | 本轮不做 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `runtime` 门面公开符号与拆分前兼容(含 `timezone`/`OrderedDict`/`np`/StructureZone 等历史漏出)
|
||||
- [x] Golden:`pytest tests/test_golden_pipeline.py` 通过
|
||||
- [x] Analyze 契约/快照测试通过且覆盖关键字段清单以上
|
||||
- [x] TF_DF 全量 init 冒烟通过
|
||||
- [x] `config/` / `strategies/` 无交易逻辑 diff
|
||||
- [x] IMPLEMENTATION_REPORT / TEST_REPORT / CHANGELOG / TRACEABILITY 更新
|
||||
- [x] CODE_REVIEW Approve
|
||||
|
||||
## Rollback
|
||||
|
||||
`git revert` 本 ECR 提交;门面保留期可整包回滚。
|
||||
|
||||
## Risk Review
|
||||
|
||||
- Path: `docs/RISK_REVIEW/ECR-002.md` — N/A(不改交易决策语义)
|
||||
|
||||
## Linked
|
||||
|
||||
- IDEA: `docs/IDEA/IDEA-003-runtime-split.md`
|
||||
- PRODUCT_SPEC: `docs/PRODUCT_SPEC/ECR-002-runtime-split.md`
|
||||
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||
- ADR: 引用 ADR-001(包内再拆,无新顶层布局 ADR)
|
||||
- EXPERIMENT: N/A
|
||||
- TRACEABILITY: Yes
|
||||
- IMPLEMENTATION_REPORT: `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||
- TEST_REPORT: `docs/TEST_REPORT/ECR-002.md`
|
||||
|
||||
## Origin
|
||||
|
||||
- `docs/CODE_REVIEW/ECR-001.md` Findings 1–3、5
|
||||
@@ -0,0 +1,62 @@
|
||||
# ECR-003
|
||||
|
||||
**Title:** 主站威科夫分析与图表展示
|
||||
**Status:** Done (Reviewed)
|
||||
**Date:** 2026-08-06
|
||||
**Change Level:** L2
|
||||
|
||||
## Change
|
||||
|
||||
在主站 `/` 增加威科夫交易区间、阶段(A–E)、关键事件(Spring/SOS/LPS/UTAD 等)、区间内简易 VP(POC/VAH/VAL)与量能确认;按需接入 `/api/analyze`。
|
||||
|
||||
## Motivation
|
||||
|
||||
用户需要在缠论图上叠加威科夫结构解读;与现有结构区语义分离。
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed
|
||||
|
||||
- 新建 `chanlun/analysis/wyckoff/`
|
||||
- `/api/analyze` 增加可选 `include_wyckoff` 与响应字段 `wyckoff`(可增不可删既有字段)
|
||||
- 主站 UI 开关与 Lightweight 绘图
|
||||
- 单测 + ESS 文档
|
||||
|
||||
### Forbidden
|
||||
|
||||
- 修改笔/段/中枢/买卖点算法语义
|
||||
- 改 `config/` / `strategies/`
|
||||
- `/chan_tv` Study
|
||||
- Vite/React、主站 WebSocket 实时(另 ECR)
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| 启发式误标 | 规格写明启发式;UI 可关;单测合成形态 |
|
||||
| 负载 | 默认关闭,勾选才计算 |
|
||||
| 与结构区混淆 | 独立开关与字段名 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `include_wyckoff=1` 返回约定 `wyckoff` 键;默认不强制计算
|
||||
- [x] 合成 fixture:能检出 TR + 至少一类事件;VP POC 可测
|
||||
- [x] 主站可开关绘制区间/阶段/事件/VP
|
||||
- [x] golden 缠论基线不变
|
||||
- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
|
||||
|
||||
## Rollback
|
||||
|
||||
`git revert`;关闭 UI 开关即可无图面影响。
|
||||
|
||||
## Risk Review
|
||||
|
||||
- `docs/RISK_REVIEW/ECR-003.md` — N/A(展示分析,非 Live 策略)
|
||||
|
||||
## Linked
|
||||
|
||||
- IDEA: `docs/IDEA/IDEA-004-wyckoff-main.md`
|
||||
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-003-*
|
||||
- EXPERIMENT: N/A
|
||||
- TRACEABILITY: Yes
|
||||
- CODE_REVIEW: `docs/CODE_REVIEW/ECR-003.md` — Approve
|
||||
@@ -0,0 +1,69 @@
|
||||
# ECR-004
|
||||
|
||||
**Title:** 威科夫区间评分硬化与主站 VP 绘图减负
|
||||
**Status:** Done (Reviewed)
|
||||
**Date:** 2026-08-06
|
||||
**Change Level:** L2
|
||||
|
||||
## Change
|
||||
|
||||
跟进 ECR-003 CODE_REVIEW Findings:改进交易区间选取启发式、阶段最小长度、收紧单测;主站 VP/叠层降低 Lightweight series 数量;`include_wyckoff` 与主周期分析同门闩。
|
||||
|
||||
## Motivation
|
||||
|
||||
003 已 Approve 合入;质量与内存项不得回塞已审变更,须独立可审闭环。
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed
|
||||
|
||||
- `chanlun/analysis/wyckoff/range.py` / `events.py`(阶段)启发式与单测
|
||||
- `web/static/js/app/chart_tv.js` 威科夫 VP/填充绘制路径
|
||||
- `web/api/analyze.py`:`elements_only` 时不跑威科夫;默认 `vp_bins` 上限 24
|
||||
- ESS 文档与契约测试补充断言(不删既有 `wyckoff` 键)
|
||||
|
||||
### Forbidden
|
||||
|
||||
- 改笔/段/中枢/买卖点语义
|
||||
- `config/` / `strategies/`
|
||||
- `/chan_tv`
|
||||
- 新数据源 / 订单流
|
||||
- **按币种独立参数表**(全局 ATR 相对即可;当前以 BTC 场景验证)
|
||||
|
||||
## Decisions(Approve 时锁定)
|
||||
|
||||
- VP:**A+C**(前端 Top-N 有量 bin + 服务端 bins 上限 24)
|
||||
- 不做 per-symbol 参数
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| TR 结果相对 003 漂移 | 合成夹具锁定高低与起点;文档标明启发式迭代 |
|
||||
| 前端 VP 观感变化 | 保留 POC/VAH/VAL;密度用 Top-N |
|
||||
| 回归 | 扩展 `tests/test_wyckoff.py` + 既有契约套件 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 合成箱体夹具:`trading_range` 高低接近箱体边界,起点不落入明显前置趋势段
|
||||
- [x] 开启 VP 时主图新增 series 数显著低于「每 bin 一条」(目标:填充+VP ≤ ~15 或等价合并策略)
|
||||
- [x] 阶段输出满足最小跨度或合并退化段;文档说明规则
|
||||
- [x] `elements_only=true` 即使 `include_wyckoff=1` 也不返回 `wyckoff`
|
||||
- [x] golden 缠论基线不变;相关 pytest 绿
|
||||
- [x] TEST/IMPL/CHANGELOG/TRACEABILITY + CODE_REVIEW
|
||||
|
||||
## Rollback
|
||||
|
||||
`git revert`;UI 关威科夫即可无图面影响。
|
||||
|
||||
## Risk Review
|
||||
|
||||
- `docs/RISK_REVIEW/ECR-004.md` — N/A(展示/启发式,非 Live 策略)
|
||||
|
||||
## Linked
|
||||
|
||||
- IDEA: `docs/IDEA/IDEA-005-wyckoff-harden.md`
|
||||
- 上游: `docs/CODE_REVIEW/ECR-003.md` Findings 1–5
|
||||
- PRODUCT_SPEC / ENGINEERING_SPEC: 同目录 ECR-004-*
|
||||
- TRACEABILITY: Yes
|
||||
- CODE_REVIEW: `docs/CODE_REVIEW/ECR-004.md` — Approve
|
||||
@@ -0,0 +1,60 @@
|
||||
# ECR-007
|
||||
|
||||
**Title:** Wyckoff Live Structure
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-07
|
||||
**Change Level:** L2
|
||||
**Human:** Approved (LOOP-RUN-005 Start Authorization)
|
||||
|
||||
## Change
|
||||
|
||||
Add **Live / Developing** structure layer beside **Confirmed** Wyckoff engine: lifecycle, FORMING candidates (Spring/SOS/LPS/UTAD), explainable confidence, Summary partition. Keep Confirmed thresholds unchanged; execution may only consume Confirmed.
|
||||
|
||||
## Motivation
|
||||
|
||||
LOOP-RUN-005 — domain-state complexity under Adapter v0.1 STABLE (Confirmed ≠ Live ≠ execution).
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed (IN)
|
||||
|
||||
- `chanlun/analysis/wyckoff/live.py` + engine assembly
|
||||
- lifecycle / confirmed / live payload
|
||||
- Event candidates + confidence
|
||||
- API contract + Summary UI
|
||||
- tests + docs notes (WYCKOFF-LIVE-STRUCTURE-001)
|
||||
|
||||
### Forbidden (OUT)
|
||||
|
||||
- execution signal automation / auto trading
|
||||
- strategy / maker / decide_quotes / `strategies/**`
|
||||
- lowering Confirmed thresholds
|
||||
- Live candidate replacing Confirmed
|
||||
- ESS / Loop / Adapter changes
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Live → execution | `execution_signal_from_wyckoff` source=confirmed only; live-only → None |
|
||||
| Confirmed pollution | candidates never written to confirmed.events |
|
||||
| Domain confusion in UI | Summary Confirmed vs Live partitions |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Approved BD-2026-007
|
||||
- [ ] Confirmed logic not relaxed
|
||||
- [ ] Live ≠ execution signal (tests)
|
||||
- [ ] Lifecycle verifiable
|
||||
- [ ] Artifact chain + Gate PASS
|
||||
|
||||
## Rollback
|
||||
|
||||
- Disable live assembly; remove live.py; revert Summary partition
|
||||
|
||||
## Linked
|
||||
|
||||
- Note: `docs/notes/WYCKOFF-LIVE-STRUCTURE-001.md` (FROZEN)
|
||||
- BACKEND_DESIGN: `docs/BACKEND_DESIGN/BD-2026-007-wyckoff-live-structure.md`
|
||||
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-007-wyckoff-live-structure.md`
|
||||
- Loop: LOOP-RUN-005
|
||||
@@ -0,0 +1,61 @@
|
||||
# ECR-008
|
||||
|
||||
**Title:** 拆分主站巨型 `chart_tv.js`(行为冻结)
|
||||
**Status:** Done (Reviewed)
|
||||
**Date:** 2026-08-07
|
||||
**Change Level:** L3(结构重构;行为冻结)
|
||||
|
||||
## Change
|
||||
|
||||
将 `web/static/js/app/chart_tv.js`(≈4700 行)按职责拆为多个无打包 script;薄门面保留 `initTradingView` / `disposeTradingViewCharts` 供 `ui.js` 调用。
|
||||
|
||||
## Motivation
|
||||
|
||||
ECR-001/002 CODE_REVIEW 非阻断债务;威科夫与 Live 叠层继续堆入单体,审阅与回归成本上升。
|
||||
|
||||
## Scope
|
||||
|
||||
### Allowed
|
||||
|
||||
- 新增:`chart_tv_lifecycle.js` / `chart_tv_shell.js` / `chart_tv_indicators.js` / `chart_tv_chan.js` / `chart_tv_overlays.js` / `chart_tv_finalize.js`
|
||||
- `chart_tv.js` 改为编排门面;`index.html` 调整 script 顺序与 cache bust
|
||||
- `node --check`;主站手动冒烟
|
||||
|
||||
### Forbidden
|
||||
|
||||
- Vite / React / TS 构建流水线
|
||||
- 修改笔 / 线段 / 中枢 / 买卖点算法语义或绘制语义(仅搬移)
|
||||
- 破坏 `/api/analyze` JSON 字段
|
||||
- 修改 `config/` / `strategies/`
|
||||
- 为主站重新引入 WebSocket 实时
|
||||
|
||||
## Risk
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| 拆分漏变量 / 作用域错误 | ctx 显式传参;冒烟 dispose + 三周期元素 + 威科夫 |
|
||||
| script 顺序错误 | index.html 固定 lifecycle→…→门面→sync |
|
||||
| 缓存旧单体 | bump `?v=` |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `initTradingView` / `disposeTradingViewCharts` 仍可被 `ui.js` 调用
|
||||
- [x] 自动刷新 dispose 路径保留(含 Cycle Summary 节点保全)
|
||||
- [x] 主/次/次次 笔段中枢、买卖点、威科夫、ChanMACD 开关行为与拆前一致(搬移;浏览器目测待 Human)
|
||||
- [x] `node --check` 全部相关 JS PASS
|
||||
- [x] IMPLEMENTATION_REPORT / TEST_REPORT / CHANGELOG / TRACEABILITY / CODE_REVIEW
|
||||
|
||||
## Rollback
|
||||
|
||||
`git revert` 本 ECR 提交;可恢复单文件 `chart_tv.js`。
|
||||
|
||||
## Risk Review
|
||||
|
||||
N/A(不改交易决策语义)
|
||||
|
||||
## Linked
|
||||
|
||||
- IDEA: `docs/IDEA/IDEA-006-chart-tv-split.md`
|
||||
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-008-chart-tv-split.md`
|
||||
- HANDOFF: `docs/HANDOFF/ECR-008-architect-to-engineer.md`
|
||||
- TRACEABILITY: Yes
|
||||
@@ -0,0 +1,105 @@
|
||||
# Engineering Spec: ECR-001 引擎 + Web 重构
|
||||
|
||||
## Related
|
||||
|
||||
| Doc | Link |
|
||||
|-----|------|
|
||||
| ECR | `docs/ECR/ECR-001-chan-web-restructure.md` |
|
||||
| PRODUCT_SPEC | `docs/PRODUCT_SPEC/ECR-001-restructure.md` |
|
||||
| ADR | `docs/ADR/ADR-001-package-layout.md` |
|
||||
|
||||
## Module Design
|
||||
|
||||
### `chanlun/core/*`
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Responsibility | K 线单元、合并 K、笔/段/中枢/买卖点数据结构与枚举 |
|
||||
| Can | 表达缠论基础对象 |
|
||||
| Cannot | 拉行情、写 Flask 路由 |
|
||||
| Layer | domain |
|
||||
|
||||
### `chanlun/pipeline/*`
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Responsibility | `ChanLun` 编排、`TF_DF` 门面、builders |
|
||||
| Can | 从 DataFrame 构建结构 |
|
||||
| Cannot | 改公开方法语义 |
|
||||
| Layer | application |
|
||||
|
||||
### `chanlun/indicators/*` / `chanlun/analysis/*`
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Responsibility | MACD 状态、Zone/Classifier/Trend 等分析 |
|
||||
| Layer | domain / application |
|
||||
|
||||
### Root shims (`ChanLun.py`, `ChanEnum.py`, …)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Responsibility | 转发到 `chanlun.*`,兼容 strategies |
|
||||
| Cannot | 含业务逻辑 |
|
||||
|
||||
### `web/config.py` + `web/services/*` + `web/api/*`
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Responsibility | 配置、行情、分析、序列化、HTTP |
|
||||
| Cannot | 修改缠论算法 |
|
||||
|
||||
### `web/static/js/app/*`
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Responsibility | 图表、overlay、UI、API 客户端 |
|
||||
| Cannot | 本轮引入 React 构建 |
|
||||
|
||||
## Interfaces
|
||||
|
||||
```python
|
||||
class TF_DF:
|
||||
# 公开方法签名保持与重构前一致
|
||||
def get_kl_data(self, dataframe): ...
|
||||
def get_bi_list(self, dataframe): ...
|
||||
# ... 门面委托 builders
|
||||
|
||||
class ChanLun:
|
||||
def init_dataframes(...): ...
|
||||
```
|
||||
|
||||
`/api/analyze`:查询参数与 JSON 顶层字段兼容。
|
||||
|
||||
## Algorithm Sketch
|
||||
|
||||
1. 物理迁移模块到 `chanlun/`,修正 import
|
||||
2. 根 shim 再导出
|
||||
3. `TF_DF` 方法体迁移到 builders,门面调用
|
||||
4. Web 按职责拆文件,路由注册不变
|
||||
5. HTML 抽 JS;未接线 TS 入 `_unused`
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Case | Behavior |
|
||||
|------|----------|
|
||||
| 行情失败 | 保持现有 JSON error |
|
||||
| 分析异常 | 保持现有日志与错误返回 |
|
||||
|
||||
## Config / Constants
|
||||
|
||||
| Name | Source |
|
||||
|------|--------|
|
||||
| DATA_SERVICE_URL | env / config |
|
||||
| HTTP(S)_PROXY | env / config(替代硬编码 7897) |
|
||||
| MACD periods | config |
|
||||
|
||||
## Test Plan Pointer
|
||||
|
||||
- `tests/test_golden_pipeline.py` + `tests/fixtures/golden_*`
|
||||
- `web/tests/` analyze 契约冒烟
|
||||
- `python -c "from ChanLun import ChanLun"`
|
||||
|
||||
## Migration / API Impact
|
||||
|
||||
无破坏性 API 变更;仅内部路径变化。
|
||||
@@ -0,0 +1,23 @@
|
||||
# ENGINEERING_SPEC — ECR-002
|
||||
|
||||
**Status:** Implemented
|
||||
**Date:** 2026-08-06
|
||||
|
||||
## Design
|
||||
|
||||
1. **包目录** `web/services/runtime/`(不用平铺 `runtime_*.py`)
|
||||
2. **边界**
|
||||
- `state`:可变全局与客户端
|
||||
- `timeframes`:周期工具
|
||||
- `market_data`:行情
|
||||
- `indicators`:技术指标列
|
||||
- `analyze`:缠论编排 + 趋势分类
|
||||
- `serialize`:JSON 整形
|
||||
- `__init__`:门面 + 历史 `import *` 兼容再导出
|
||||
3. **测试**:facade / analyze_chan 键 / serialize / HTTP mock 契约 / TF_DF init / golden
|
||||
4. **chart_tv 拆分**:本轮不做(仍可选后续 ECR)
|
||||
|
||||
## Open questions(已决)
|
||||
|
||||
- [x] 采用包目录 `services/runtime/`
|
||||
- [x] chart_tv 拆分不纳入本 PR
|
||||
@@ -0,0 +1,46 @@
|
||||
# ENGINEERING_SPEC — ECR-003
|
||||
|
||||
**Status:** Approved
|
||||
**Date:** 2026-08-06
|
||||
|
||||
## Package
|
||||
|
||||
`chanlun/analysis/wyckoff/`:
|
||||
|
||||
- `engine.py` — `analyze_wyckoff(df) -> dict`
|
||||
- `range.py` — 交易区间检测(ATR 容差震荡箱)
|
||||
- `phases.py` — A–E 状态机
|
||||
- `events.py` — Spring/SOS/LPS/UTAD(及 distribution 对称)
|
||||
- `volume_profile.py` — 区间内分桶 VP
|
||||
- `__init__.py` — 导出 `analyze_wyckoff`
|
||||
|
||||
## API
|
||||
|
||||
`GET /api/analyze?include_wyckoff=1` → `result["wyckoff"]`:
|
||||
|
||||
```json
|
||||
{
|
||||
"trading_range": {"start_time","end_time","high","low","mid","active"},
|
||||
"bias": "accumulation|distribution|unknown",
|
||||
"phases": [{"phase","label","start_time","end_time"}],
|
||||
"events": [{"type","time","price","note","volume_ratio","volume_ok"}],
|
||||
"volume_profile": {"bins":[{"price","volume"}],"poc","vah","val","bin_count"},
|
||||
"volume_confirm": {"avg_volume","event_checks":{}}
|
||||
}
|
||||
```
|
||||
|
||||
默认 `include_wyckoff` 假:可不返回或返回 `null`(实现选:不返回键以减负)。
|
||||
|
||||
## Detection heuristics
|
||||
|
||||
1. ATR(14) 容差;扫描最近窗口找高低点接近的连续段作为 TR。
|
||||
2. 阶段:价格在 TR 内相对位置 + 假破/真破时间序。
|
||||
3. Spring:下破 TR.low 后收回且收盘回到区间内;量能相对均量判断。
|
||||
4. SOS:收盘站上 TR.high 且放量。
|
||||
5. LPS:SOS 后回踩不破 mid/high 带且缩量。
|
||||
6. UTAD:上破后跌回区间内(派发)。
|
||||
7. VP:typical=(H+L+C)/3,volume 加权分桶,VA≈70% 围绕 POC。
|
||||
|
||||
## Frontend
|
||||
|
||||
主站 checkbox + `chart_view` 传参;`chart_tv.js` 绘制。
|
||||
@@ -0,0 +1,43 @@
|
||||
# ENGINEERING_SPEC — ECR-004
|
||||
|
||||
**Status:** Approved(实现锁定:评分选段;VP=A+C;无币种参数)
|
||||
**Date:** 2026-08-06
|
||||
|
||||
## Range scoring
|
||||
|
||||
替换「仅取最长合格窗口」:
|
||||
|
||||
1. 仍在 `lookback` + `tail_reserve` 框架内扫描候选段(步长 -4)。
|
||||
2. 硬门槛不变:near_hi/lo≥2、inside≥0.75、宽度上限等。
|
||||
3. 分数:`touch_density*50 + inside*30 - (width/ATR)*3 + min(length/40, 2)`,取最高。
|
||||
4. 单测:`low∈[38,42]`、`high∈[58,62]`,起点不早于箱体(容差 8 根);`abs_start_idx >= 12`。
|
||||
|
||||
## Phases
|
||||
|
||||
- 非重叠链式切分;每段至少 `min_bars=3`。
|
||||
- 尾部空间不足则延长上一段并停止新增(避免 D/E 完全重合双画)。
|
||||
|
||||
## API gate
|
||||
|
||||
```text
|
||||
if include_wyckoff and not elements_only:
|
||||
result["wyckoff"] = analyze_wyckoff(..., vp_bins∈[10,24])
|
||||
```
|
||||
|
||||
默认 `wyckoff_vp_bins=24`,上限 24。
|
||||
|
||||
## Frontend VP(A+C)
|
||||
|
||||
- 填充线 6→3
|
||||
- 有量 bin 按 volume Top-8 绘制 + POC/VAH/VAL
|
||||
- 目标:区间填充+边框+VP ≈ ≤15 series 量级
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_wyckoff.py` 收紧
|
||||
- `elements_only=true&include_wyckoff=1` 无 `wyckoff`
|
||||
- 不改 golden 缠论 JSON
|
||||
|
||||
## Non-goals
|
||||
|
||||
- 按币种独立参数(全局 ATR 相对;以 BTC 场景验证)
|
||||
@@ -0,0 +1,26 @@
|
||||
# ENGINEERING_SPEC — ECR-007 Wyckoff Live Structure
|
||||
|
||||
**ECR:** ECR-007
|
||||
**BD:** BD-2026-007
|
||||
**Status:** Approved
|
||||
|
||||
## Intent
|
||||
|
||||
Operators observe FORMING Wyckoff structure without feeding Live into execution.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Role |
|
||||
|--------|------|
|
||||
| `events.py` / `range.py` | Confirmed facts |
|
||||
| `live.py` | Live candidates + confidence + lifecycle hint |
|
||||
| `engine.py` | Assemble cycles[].confirmed / .live |
|
||||
| `execution_signal_from_wyckoff` | Confirmed-only gate |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
`UNKNOWN → FORMING → CONFIRMED → COMPLETED`
|
||||
|
||||
## Non-goals
|
||||
|
||||
strategies, maker, Live-as-signal, Confirmed threshold cuts.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ENGINEERING_SPEC — ECR-008 chart_tv 拆分
|
||||
|
||||
**ECR:** ECR-008
|
||||
**Level:** L3 · 行为冻结
|
||||
**Date:** 2026-08-07
|
||||
|
||||
## Goal
|
||||
|
||||
物理拆分主站 Lightweight Charts 绘制单体,不改变可见行为。
|
||||
|
||||
## Module map
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `chart_tv_lifecycle.js` | `disposeTradingViewCharts`;cleanup 数组与 chart.remove |
|
||||
| `chart_tv_shell.js` | `chartTvBuildShell(ctx)`:容器、createChart、K 线主系列 |
|
||||
| `chart_tv_indicators.js` | `chartTvRenderIndicators(ctx)`:成交量 / ATR / ChanMACD |
|
||||
| `chart_tv_chan.js` | `chartTvRenderChan(ctx)`:笔 / 线段 / 中枢(含未完成与 BI) |
|
||||
| `chart_tv_overlays.js` | `chartTvRenderOverlays(ctx)`:结构区、威科夫、BSP/分型、布林等 |
|
||||
| `chart_tv_finalize.js` | `chartTvFinalize(ctx)`:时间轴同步、bindSync、视图恢复、tooltip |
|
||||
| `chart_tv.js` | `initTradingView`:组 ctx → 顺序调用上述步骤 |
|
||||
|
||||
## Context object
|
||||
|
||||
`ctx` 至少携带:`symbol`、`timeframe`、`symbolConfig`、周期开关、`candles`、各 chart/container、`showMacd`。全局 `currentData` / `tvWidget` 仍按现网约定使用。
|
||||
|
||||
## HTML load order
|
||||
|
||||
`lifecycle → shell → indicators → chan → overlays → finalize → chart_tv.js → chart_sync.js → …`
|
||||
|
||||
## Tests
|
||||
|
||||
1. `node --check` 各新文件 + 门面
|
||||
2. 人工:首屏、自动刷新、威科夫开关、三周期笔段中枢、Cycle Summary
|
||||
|
||||
## Out of scope
|
||||
|
||||
Live 验证批跑、威科夫算法调参、analyze JSON 快照、`chart_sync` 大改。
|
||||
@@ -0,0 +1,38 @@
|
||||
# Handoff
|
||||
|
||||
**From:** ARCHITECT
|
||||
**To:** ENGINEER
|
||||
**ECR:** ECR-001
|
||||
**State:** design → coding
|
||||
**Date:** 2026-08-05
|
||||
|
||||
## Artifacts
|
||||
|
||||
- [x] ECR (Approved)
|
||||
- [x] PRODUCT_SPEC
|
||||
- [x] ENGINEERING_SPEC
|
||||
- [x] RISK_REVIEW (N/A)
|
||||
- [x] ADR-001
|
||||
- [x] TRACEABILITY
|
||||
- [ ] TEST_REPORT / CODE_REVIEW / EXP (engineer / reviewer)
|
||||
|
||||
## Restrictions — Do not modify
|
||||
|
||||
- `config/`、`strategies/` 内容
|
||||
- 缠论算法语义、`/api/analyze` 破坏性变更
|
||||
- 计划文件本身
|
||||
|
||||
## Goal for receiver
|
||||
|
||||
按 ENGINEERING_SPEC 完成包化、TF_DF 拆分、Web 分层、前端模块化、golden 测试与文档收尾。
|
||||
|
||||
## Done for this hop
|
||||
|
||||
- [x] ESS docs 落盘
|
||||
- [x] ECR Approved(计划实施授权)
|
||||
|
||||
## References
|
||||
|
||||
- `docs/ECR/ECR-001-chan-web-restructure.md`
|
||||
- `docs/ENGINEERING_SPEC/ECR-001-restructure.md`
|
||||
- Plan: ESS Chan Refactor(1B+2B)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user