From 8ff1515f8ba0b95b83f52537cfb4ea7108ebf87a Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 24 Apr 2026 10:13:30 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E6=94=B9=E4=BA=86ema=E7=9A=84=E9=A2=9C?= =?UTF-8?q?=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 14340 -> 14340 bytes .gitignore | 1 + web/templates/index.html | 6 +++--- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.DS_Store b/.DS_Store index 303b4b0ac403ce83b20aea68b9174c313de195d6..4a146b0219b580d1cd6390fce7dd14e04e7b00ae 100644 GIT binary patch delta 156 zcmZoEXepTRK)Fw*;?TSk#~m3M7+4te7}6Os8A=K=41<&Na|?iC42+%=HVZO-W1FnN q$hVoDLrx;@`$=VF#ku(|E=f80NkB0UY=-6Lr{I-Gx2##AQ5*mWh%58} delta 156 zcmZoEXepTRK)HD9C5GM&#~m3M7+4te7}6Os8A=K=41<&Na|?iC42;GBn*|xau}xNB q Date: Thu, 30 Apr 2026 15:20:37 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=AC=94=E4=B8=AD?= =?UTF-8?q?=E6=9E=A2=E7=AC=AC=E4=B8=89=E7=B1=BB=E5=8D=96=E5=8D=96=E7=82=B9?= =?UTF-8?q?=E8=AF=86=E5=88=AB=EF=BC=8C=E8=AF=86=E5=88=AB=E5=A5=BD=E5=90=8E?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=AE=8C=E6=88=90=E7=AC=94=E4=B8=AD=E6=9E=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ChanSEG.py | 8 +++++++- web/app.py | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ChanSEG.py b/ChanSEG.py index ff775ed..86165ba 100644 --- a/ChanSEG.py +++ b/ChanSEG.py @@ -96,7 +96,10 @@ class ChanSEG(): 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 @@ -118,6 +121,7 @@ class ChanSEG(): 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) @@ -137,12 +141,14 @@ class ChanSEG(): 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) + 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 diff --git a/web/app.py b/web/app.py index c94ffff..c2a3c70 100644 --- a/web/app.py +++ b/web/app.py @@ -50,6 +50,8 @@ logger = logging.getLogger(__name__) DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://127.0.0.1:9009")) #DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://192.168.1.9:9009")) +#DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166")) + DEFAULT_TIMEFRAME_LABELS = OrderedDict([ ("1m", "1分钟"), ("3m", "3分钟"), From 5ab69c2a64af2ed9001c3da585e7cc52deff1530 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Fri, 1 May 2026 17:13:24 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E6=9B=B4=E6=96=B0data=5Fprovider=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E8=83=BD=E5=A4=9F=E6=9B=B4=E5=BF=AB=E5=BC=80?= =?UTF-8?q?=E5=A7=8B=E6=8F=90=E4=BE=9B=E6=9C=8D=E5=8A=A1=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data_provider/README.md | 82 ++++++ data_provider/api_docs.html | 517 ++++++++++++++++++++++++++++++++++++ data_provider/homepage.html | 417 +++++++++++++++++++++++++++++ data_provider/main.py | 58 ++-- 4 files changed, 1057 insertions(+), 17 deletions(-) create mode 100644 data_provider/README.md create mode 100644 data_provider/api_docs.html create mode 100644 data_provider/homepage.html diff --git a/data_provider/README.md b/data_provider/README.md new file mode 100644 index 0000000..f8ce8b2 --- /dev/null +++ b/data_provider/README.md @@ -0,0 +1,82 @@ +# Chan 数据提供商 (Chan Data Provider) + +从 **Binance 期货** 交易所拉取加密货币 K 线数据,提供 HTTP + WebSocket 数据服务。 + +## 功能 + +- **多交易对**:支持 BTC, ETH, SOL, DOGE 等 9 个交易对 +- **多时间周期**:基础周期 1m/1h/1d/1w,可合成 30+ 种衍生周期(如 5m, 15m, 4h 等) +- **本地缓存**:CSV 持久化到磁盘,重启快速加载 +- **断线恢复**:交易所连接中断时记录断点,自动补拉缺失数据 +- **实时推送**:WebSocket 订阅最新 K 线更新 +- **内存服务**:启动即加载本地数据,不阻塞服务 + +## 启动 + +```bash +# Docker +docker compose up -d + +# 直接运行 +python main.py + +# 或指定配置 +CONFIG_PATH=./config.json python main.py +``` + +服务默认监听 `http://0.0.0.0:9009`。 + +## 配置 + +编辑 `config.json`: + +```json +{ + "exchange": "binance", + "symbols": ["BTC/USDT:USDT", "ETH/USDT:USDT"], + "start_time": "2024-01-01T00:00:00Z", + "timeframes": ["1m", "1h", "1d", "1w"], + "data_dir": "./data" +} +``` + +| 字段 | 说明 | +|------|------| +| `exchange` | 交易所名称(ccxt 支持即可) | +| `symbols` | 交易对列表 | +| `start_time` | 历史数据起始时间 | +| `timeframes` | 基础周期(从交易所直接拉取) | +| `data_dir` | CSV 数据存储目录 | + +## 可用周期 + +### 基础周期(交易所直接拉取) +`1m`, `1h`, `1d`, `1w` + +### 衍生周期(内存中合成) +| 基础周期 | 可合成的衍生周期 | +|----------|----------------| +| 1m | 2m, 3m, 4m, 5m, 10m, 15m, 20m, 25m, 30m, 45m | +| 1h | 2h, 3h, 4h, 5h, 6h, 7h, 8h, 9h, 10h, 11h, 12h, 16h, 20h | +| 1d | 2d, 3d, 4d, 5d, 6d | +| 1w | 2w, 3w | + +## 数据存储 + +数据以 CSV 格式存储,按时间周期分目录: + +``` +./data/ + 1m/ + binance_BTC_USDT_USDT_1m.csv + binance_ETH_USDT_USDT_1m.csv + ... + 1h/ + ... +``` + +每根 K 线包含:`timestamp`, `datetime`, `open`, `high`, `low`, `close`, `volume`。 + +--- + +API 文档请访问 `http://:9009/api/docs`。 diff --git a/data_provider/api_docs.html b/data_provider/api_docs.html new file mode 100644 index 0000000..2c1c33a --- /dev/null +++ b/data_provider/api_docs.html @@ -0,0 +1,517 @@ + + + + + +Chan 数据提供商 - API 文档 + + + +
+ +
+

Chan 数据提供商

+

加密货币 K 线数据 HTTP + WebSocket API

+
v1.0.0  |  binance  |  port 9009
+
+ + + + +
+

服务信息

+
+
+ GET + / + 服务基本信息 +
+
+

返回服务名称、交易所、交易对列表、可用周期及就绪状态。

+

响应

+
{
+  "service":        "Data Provider",
+  "exchange":        "binance",
+  "symbols":         ["BTC/USDT:USDT", "ETH/USDT:USDT", ...],
+  "base_timeframes":  ["1m", "1h", "1d", "1w"],
+  "derived_timeframes": ["5m", "15m", "4h", ...],
+  "timeframes":       ["1m", "1h", ..., "5m", "15m", ...],
+  "ready":           true
+}
+
+
+
+ + +
+

健康检查

+
+
+ GET + /health + 存活检查 +
+
+

返回服务健康状态,与 / 相同结构,适合负载均衡探测器。

+

响应

+
{
+  "status":  "ok",
+  "exchange": "binance",
+  "symbols":  ["BTC/USDT:USDT", ...],
+  "ready":    true,
+  ...
+}
+
+
+
+ + +
+

可用周期

+
+
+ GET + /timeframes + 列出所有时间周期 +
+
+

返回基础周期(交易所直接拉取)和衍生周期(合成生成)的完整列表。

+

响应

+
{
+  "base_timeframes":    ["1m", "1h", "1d", "1w"],
+  "derived_timeframes": ["5m", "15m", "4h", ...],
+  "timeframes":         ["1m", "1h", ..., "5m", "15m", ...]
+}
+
+
+
+ + +
+

查询 K 线

+
+
+ GET + /api/candles + 获取 OHLCV K 线数据 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
参数类型必填说明
symbolstring交易对,如 BTC/USDT:USDT
tfstring时间周期,默认 1m。支持基础及衍生周期
startint可选开始时间戳(毫秒)
endint可选结束时间戳(毫秒)
limitint可选限制返回的 K 线数量(返回最后 N 根)
+ +
若不传 start/end,返回内存中全部数据(可能很多),建议搭配 limit 使用。
+ +

请求示例

+
# 获取 BTC 最近 100 根 5 分钟 K 线
+GET /api/candles?symbol=BTC/USDT:USDT&tf=5m&limit=100
+
+# 指定时间范围
+GET /api/candles?symbol=ETH/USDT:USDT&tf=1h&start=1704067200000&end=1704153600000
+
+# 获取 4 小时周期(衍生周期)
+GET /api/candles?symbol=SOL/USDT:USDT&tf=4h&limit=50
+ +

响应

+

返回 OHLCV 对象数组:

+
[
+  {
+    "timestamp": 1704067200000,
+    "datetime":  "2024-01-01T00:00:00Z",
+    "open":      42850.12,
+    "high":      43100.00,
+    "low":       42780.50,
+    "close":     43050.80,
+    "volume":    125.34
+  },
+  ...
+]
+ +

字段说明

+ + + + + + + + + +
字段类型说明
timestampintUTC 毫秒时间戳
datetimestringISO 8601 格式(末尾 Z)
openfloat开盘价
highfloat最高价
lowfloat最低价
closefloat收盘价
volumefloat成交量
+ +
+
+
+ + +
+

WebSocket 实时推送

+
+
+ WS + /ws + 实时 K 线订阅 +
+
+ +

连接 WebSocket 后,通过 JSON 消息进行订阅管理。服务端在数据更新时主动推送最新 K 线。

+ +

客户端 → 服务端

+ +
+
订阅 K 线
+
{
+  "action":    "subscribe",
+  "symbol":    "BTC/USDT:USDT",
+  "timeframe": "1m"
+}
+
+ +
+
取消订阅
+
{
+  "action":    "unsubscribe",
+  "symbol":    "BTC/USDT:USDT",
+  "timeframe": "1m"
+}
+
+ +
+
心跳 Ping
+
{ "action": "ping" }
+
+ +

服务端 → 客户端

+ +
+
订阅确认
+
{
+  "type":      "subscribed",
+  "symbol":    "BTC/USDT:USDT",
+  "timeframe": "1m"
+}
+
+ +
+
初始快照(订阅后立即推送最近 500 根 K 线)
+
{
+  "type":      "snapshot",
+  "symbol":    "BTC/USDT:USDT",
+  "timeframe": "1m",
+  "data":      [ ... ]
+}
+
+ +
+
K 线更新(增量推送最近 2 根)
+
{
+  "type":      "kline",
+  "symbol":    "BTC/USDT:USDT",
+  "timeframe": "1m",
+  "data":      [ ... ]
+}
+
+ +
+
Pong 响应
+
{ "type": "pong" }
+
+ +
+
错误消息
+
{ "type": "error", "message": "..." }
+
+ +

JavaScript 示例

+
// 连接
+const ws = new WebSocket("ws://localhost:9009/ws");
+
+ws.onopen = () => {
+  // 订阅 BTC 1m K 线
+  ws.send(JSON.stringify({
+    action: "subscribe",
+    symbol: "BTC/USDT:USDT",
+    timeframe: "1m"
+  }));
+};
+
+ws.onmessage = (event) => {
+  const msg = JSON.parse(event.data);
+  if (msg.type === "kline") {
+    console.log(msg.data); // 最新 K 线数组
+  }
+};
+ +
+
+
+ + +
+

时间周期参考

+

以下是完整的周期对照表:

+ + + + + + + +
基础周期合成衍生周期
1m2m, 3m, 4m, 5m, 10m, 15m, 20m, 25m, 30m, 45m
1h2h, 3h, 4h, 5h, 6h, 7h, 8h, 9h, 10h, 11h, 12h, 16h, 20h
1d2d, 3d, 4d, 5d, 6d
1w2w, 3w
+ +

衍生周期由对应基础周期的 K 线通过 OHLCV 聚合合成,查询方式与基础周期完全一致。

+
+ +
+ Chan Data Provider — Built with FastAPI + ccxt + pandas +
+ +
+ + + + diff --git a/data_provider/homepage.html b/data_provider/homepage.html new file mode 100644 index 0000000..c3814f2 --- /dev/null +++ b/data_provider/homepage.html @@ -0,0 +1,417 @@ + + + + + +Chan 数据提供商 + + + +
+ +
+
+ +
+

Chan 数据提供商

+
加密货币 K 线数据服务
+
+
+
+
+ + +
+
服务状态
加载中...
+
交易所
+
交易对
+
基础周期
+
衍生周期
+
数据就绪
+
+ + + + + +
+
+

📈 交易对

+ 0 +
+
+
加载中...
+
+
+ + +
+
+

⏱ 时间周期

+ 0 +
+
+
基础周期(交易所直拉)
+
+
衍生周期(内存合成)
+
+
+
+ + +
+
+

⚡ 快速查询

+
+
+
+
+
交易对
+ +
+
+
周期
+ +
+
+
数量
+ +
+ +
+ +
+
+ +
+ +
+ Chan Data Provider  ·  Built with FastAPI + ccxt + pandas  ·  + 更新于 +
+ + + + diff --git a/data_provider/main.py b/data_provider/main.py index 7392878..462ddd3 100644 --- a/data_provider/main.py +++ b/data_provider/main.py @@ -19,6 +19,7 @@ import ccxt # type: ignore import pandas as pd # type: ignore from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse import uvicorn from technical.util import resample_to_interval @@ -405,11 +406,23 @@ class DataProvider: return results def initialize(self) -> None: - """阻塞式启动:加载本地、从倒数第二根或配置起点补历史、写盘并 set _ready。""" - logger.info("开始初始化数据提供商") + """快速启动:仅加载本地磁盘已有数据到内存,然后立即标记就绪,不阻塞服务。""" + logger.info("开始加载本地数据") for symbol in self.symbols: for timeframe in self.timeframes: existing = self._load_local(symbol, timeframe) + with self._lock: + self.data.setdefault(symbol, {})[timeframe] = existing + self._ready.set() + logger.info("本地数据加载完成,服务已就绪") + + def run_initial_history_fetch(self) -> None: + """后台一次性拉取所有 symbol/tf 的历史数据(从本地末根或配置起点到当前),然后落盘。""" + logger.info("开始后台历史数据拉取") + for symbol in self.symbols: + for timeframe in self.timeframes: + with self._lock: + existing = list(self.data.get(symbol, {}).get(timeframe, [])) tf_ms = TIMEFRAME_TO_MS[timeframe] last_ts = existing[-1]["timestamp"] if existing else None if last_ts is not None: @@ -433,10 +446,10 @@ class DataProvider: 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.data[symbol][timeframe] = merged self._write_to_disk(symbol, timeframe, merged) - self._ready.set() - logger.info("数据初始化完成") + self._notify_update(symbol, timeframe) + logger.info("后台历史数据拉取完成") def resample_df(self, df: pd.DataFrame, interval: int) -> pd.DataFrame: """将基础周期 DataFrame 聚合为 interval 分钟周期(freqtrade technical.util)。""" @@ -788,8 +801,15 @@ def create_app(provider: DataProvider) -> 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() + # 后台拉取历史数据补齐(不阻塞 HTTP/WS 服务) + threading.Thread( + target=provider.run_initial_history_fetch, + name="initial-history-fetch", + daemon=True, + ).start() try: yield finally: @@ -840,18 +860,15 @@ def create_app(provider: DataProvider) -> FastAPI: 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(), - } + homepage_path = Path(__file__).resolve().parent / "homepage.html" + docs_path = Path(__file__).resolve().parent / "api_docs.html" + + @app.get("/", response_class=HTMLResponse) + async def root(): + """服务主页。""" + if homepage_path.exists(): + return HTMLResponse(content=homepage_path.read_text(encoding="utf-8")) + return HTMLResponse(content="

主页页面未找到

", status_code=404) @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket): @@ -921,6 +938,13 @@ def create_app(provider: DataProvider) -> FastAPI: finally: await ws_manager.disconnect(ws) + @app.get("/api/docs", response_class=HTMLResponse, include_in_schema=False) + async def api_docs(): + """返回自定义 API 文档页面。""" + if docs_path.exists(): + return HTMLResponse(content=docs_path.read_text(encoding="utf-8")) + return HTMLResponse(content="

API 文档页面未找到

", status_code=404) + return app From deeea552371e5ab6e18bdbf24d1bfbeb33f86f8a Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Sat, 2 May 2026 02:12:46 +0800 Subject: [PATCH 4/7] ignore update --- .gitignore | 1 + data_provider/._config.json | Bin 4096 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 data_provider/._config.json diff --git a/.gitignore b/.gitignore index f9631c1..f0bbc0e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ feature_meta .DS_Store .DS_Store .DS_Store +data_provider/._config.json diff --git a/data_provider/._config.json b/data_provider/._config.json deleted file mode 100644 index 9f4506fa19f0ea8261f0141a3328cea137ea8d21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFz{^v(m+1nBL)UWIUt(=a103vf+zv$ zU|0ae0uVl&WCXGn05MQpDmgz_FR`E?Csi*evACqPI5j0Du_RSFAQ-HgJ3SLb7c?zk z0rN+R(GVC7fzc2c4S~@R7!85Z5Eu=C(GVC7fzc2c4S~@Rz!3tV&L#+hfm}#NX0bw1 zYH@yPQF5w6T7FTsLS|k`YF Date: Sun, 3 May 2026 15:43:56 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ChanMACD.py | 2 +- ChanSEG.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ChanMACD.py b/ChanMACD.py index 0053e91..86d5d3c 100644 --- a/ChanMACD.py +++ b/ChanMACD.py @@ -31,7 +31,7 @@ class ChanMACD(): if self.klu_list: for klu in self.klu_list: hist = klu.macdhist - singal = False + signal = False if klu.pre and klu.next: if klu.signal > 0: signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal diff --git a/ChanSEG.py b/ChanSEG.py index 86165ba..21f52cd 100644 --- a/ChanSEG.py +++ b/ChanSEG.py @@ -141,7 +141,7 @@ class ChanSEG(): 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) + #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)): From d8069e977f162abf10b7cb94282470f9aadb6fc5 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Wed, 6 May 2026 09:52:05 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20CLAUDE.md=EF=BC=8C?= =?UTF-8?q?=E4=B8=BA=20Claude=20Code=20=E6=8F=90=E4=BE=9B=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=8C=87=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..19b210d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +缠论 (Chan Theory) technical analysis system for Freqtrade. Implements Chan Zhong Shui Chan's theory for crypto/stock trading, including fractal (分型), stroke (笔), segment (线段), pivot/center (中枢), and buy/sell point (买卖点) detection. + +## Core Architecture + +### Chan Theory Engine (`Chan*.py`) + +Data processing pipeline (each step feeds the next): + +1. **`ChanKLU.py`** — Raw K-line unit with TA indicators (EMA, MACD, RSI, Bollinger Bands) +2. **`ChanKLC.py`** — Combined K-line: inclusion processing (包含处理), fractal (分型) detection +3. **`ChanBI.py`** — Stroke (笔): basic trend unit connecting alternating fractals +4. **`ChanSEG.py`** — Segment (线段): built from strokes +5. **`ChanZS.py`** / **`ChanBIZS.py`** — Center/pivot (中枢): consolidation zones (stroke-level and segment-level) +6. **`ChanBSP.py`** — Buy/Sell points (买卖点): Type 1/2/3 signals +7. **`ChanLun.py`** — Main orchestrator: ties all steps together, entry point +8. **`TF_DF.py`** — Timeframe-aware DataFrame processor: resamples data, runs the full pipeline per timeframe, handles multi-timeframe analysis + +### Support modules + +- **`ChanEnum.py`** — All enumerations: K-line types, fractal types, MACD states, buy/sell point types, EMA position/semantic states, K-line patterns +- **`ChanMACD.py`** / **`ChanMACDHistSet.py`** / **`ChanMACDSeg.py`** / **`ChanMACDUnitTF.py`** — MACD state analysis and divergence detection +- **`ChanKLU.py`** — K-line unit with candlestick pattern recognition (`Chan_KLU_PATTERN`) +- **`ChanPY.py`** — Consolidation (盘整) analysis +- **`ChanHeng.py`** — Sideways market analysis +- **`Chan_FX_Box.py`** — Fractal box (分型箱体) detection + +### Services + +- **`data_provider/`** — FastAPI data service: fetches crypto data from Binance via CCXT, caches to CSV, serves REST API + WebSocket. Synthesizes derived timeframes (e.g. 5m/15m/4h from 1m/1h base). Port 9009. +- **`web/`** — Flask web UI for interactive chart visualization with Chan theory overlays. Port 8123. +- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (40+ strategies) +- **`config/`** — Freqtrade JSON config files per pair/timeframe + +### Data Flow + +``` +Exchange (CCXT) → data_provider (CSV cache) → Freqtrade → Strategy → ChanLun → TF_DF + → KLU → KLC → BI → SEG → ZS → BSP +``` + +## Common Commands + +### Freqtrade Trading + +```bash +# Live trade +freqtrade trade -c ./user_data/Chan/config/.json --strategy --strategy-path ./user_data/Chan/strategies + +# Backtest +freqtrade backtesting -c ./user_data/Chan/config/.json --strategy --strategy-path ./user_data/Chan/strategies --timerange=20251008- + +# Download data +freqtrade download-data -c ./user_data/Chan/config/.json -t 1m 1h 1d --pairs BTC/USDT:USDT --timerange=20240101- + +# Hyperopt +freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi --strategy --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/.json -e 200 --timerange=20250201-20250901 + +# Plot +freqtrade plot-dataframe --strategy --datadir user_data/data/binance -c ./user_data/Chan/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/.json --strategy --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 From ebcb3dce73e690acd92e69c604be8cd35a91f185 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Tue, 12 May 2026 01:31:56 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20StructureZone=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=BB=B7=E5=80=BC=E5=8C=BA=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=A4=9A=E5=91=A8=E6=9C=9F=E6=94=AF?= =?UTF-8?q?=E6=92=91/=E9=98=BB=E5=8A=9B=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ChanZone.py: 从笔中枢/线段中枢/EMA52 提取价格区,聚类评分 - ChanLun.py 新增 get_structure_zones() 方法 - web/app.py: 独立拉取多周期数据 + 缓存 + limit 传参避免全量传输 - web/index.html: 结构区勾选框 + K线数量输入 + 半透明填充区绘制 - tests/test_chan_zone.py: 24 个单元测试 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 22 +- ChanLun.py | 11 + ChanZone.py | 566 +++++++++++++++++++++++++++++++++++++++ tests/test_chan_zone.py | 300 +++++++++++++++++++++ web/app.py | 132 ++++++++- web/templates/index.html | 65 ++++- 6 files changed, 1082 insertions(+), 14 deletions(-) create mode 100644 ChanZone.py create mode 100644 tests/test_chan_zone.py diff --git a/CLAUDE.md b/CLAUDE.md index 19b210d..0bdfec7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,36 +12,38 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Data processing pipeline (each step feeds the next): -1. **`ChanKLU.py`** — Raw K-line unit with TA indicators (EMA, MACD, RSI, Bollinger Bands) -2. **`ChanKLC.py`** — Combined K-line: inclusion processing (包含处理), fractal (分型) detection +1. **`ChanKLU.py`** — Raw K-line unit with TA indicators (EMA, MACD, RSI, Bollinger Bands) and candlestick pattern recognition (`Chan_KLU_PATTERN`) +2. **`ChanKLC.py`** — Combined K-line: inclusion processing (包含处理), fractal (分型) detection. Linked-list structure with `.next`/`.pre` pointers 3. **`ChanBI.py`** — Stroke (笔): basic trend unit connecting alternating fractals -4. **`ChanSEG.py`** — Segment (线段): built from strokes -5. **`ChanZS.py`** / **`ChanBIZS.py`** — Center/pivot (中枢): consolidation zones (stroke-level and segment-level) -6. **`ChanBSP.py`** — Buy/Sell points (买卖点): Type 1/2/3 signals -7. **`ChanLun.py`** — Main orchestrator: ties all steps together, entry point -8. **`TF_DF.py`** — Timeframe-aware DataFrame processor: resamples data, runs the full pipeline per timeframe, handles multi-timeframe analysis +4. **`ChanSBI.py`** — Special Stroke: aggregates multiple BI into higher-level units with fractal detection, feeds into SEG +5. **`ChanSEG.py`** — Segment (线段): built from SBI strokes +6. **`ChanZS.py`** / **`ChanBIZS.py`** — Center/pivot (中枢): consolidation zones (segment-level and stroke-level) +7. **`ChanBSP.py`** — Buy/Sell points (买卖点): Type 1/2/3 signals +8. **`ChanLun.py`** — Main orchestrator: ties all steps together, entry point +9. **`TF_DF.py`** — Timeframe-aware DataFrame processor: resamples data, runs the full pipeline per timeframe, handles multi-timeframe analysis ### Support modules - **`ChanEnum.py`** — All enumerations: K-line types, fractal types, MACD states, buy/sell point types, EMA position/semantic states, K-line patterns +- **`ChanCTime.py`** — Chan theory time utility: auto-adaptive day understanding (e.g. crypto 24h vs stock market hours) - **`ChanMACD.py`** / **`ChanMACDHistSet.py`** / **`ChanMACDSeg.py`** / **`ChanMACDUnitTF.py`** — MACD state analysis and divergence detection -- **`ChanKLU.py`** — K-line unit with candlestick pattern recognition (`Chan_KLU_PATTERN`) - **`ChanPY.py`** — Consolidation (盘整) analysis - **`ChanHeng.py`** — Sideways market analysis - **`Chan_FX_Box.py`** — Fractal box (分型箱体) detection +- **`ChanLun_Classifier.py`** — Standalone classifier script: runs full pipeline and classifies market states ### Services - **`data_provider/`** — FastAPI data service: fetches crypto data from Binance via CCXT, caches to CSV, serves REST API + WebSocket. Synthesizes derived timeframes (e.g. 5m/15m/4h from 1m/1h base). Port 9009. - **`web/`** — Flask web UI for interactive chart visualization with Chan theory overlays. Port 8123. -- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (40+ strategies) +- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (53 strategies) - **`config/`** — Freqtrade JSON config files per pair/timeframe ### Data Flow ``` Exchange (CCXT) → data_provider (CSV cache) → Freqtrade → Strategy → ChanLun → TF_DF - → KLU → KLC → BI → SEG → ZS → BSP + → KLU → KLC → BI → SBI → SEG → ZS → BSP ``` ## Common Commands diff --git a/ChanLun.py b/ChanLun.py index 2eeee78..14ddabe 100644 --- a/ChanLun.py +++ b/ChanLun.py @@ -24,6 +24,7 @@ from decimal import Decimal import numpy as np from ChanMACD import ChanMACD from TF_DF import TF_DF +from ChanZone import StructureZone, StructureZoneConfig, analyze_structure_zones class ChanLun(): def __init__(self): @@ -126,6 +127,16 @@ class ChanLun(): + + 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) diff --git a/ChanZone.py b/ChanZone.py new file mode 100644 index 0000000..2245d76 --- /dev/null +++ b/ChanZone.py @@ -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) diff --git a/tests/test_chan_zone.py b/tests/test_chan_zone.py new file mode 100644 index 0000000..472b380 --- /dev/null +++ b/tests/test_chan_zone.py @@ -0,0 +1,300 @@ +""" +StructureZone 系统单元测试 +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest +from ChanZone import ( + RawZonePoint, StructureZone, StructureZoneConfig, + cluster_raw_points, build_structure_zones, _calc_strength, _calc_confidence, _calc_recency, + extract_raw_points_from_serialized, analyze_structure_zones_from_serialized, +) + + +class TestClusterRawPoints: + """聚类算法测试""" + + def test_empty_points(self): + config = StructureZoneConfig() + result = cluster_raw_points([], config) + assert result == [] + + def test_single_point_filtered(self): + """单点被 min_overlap 过滤""" + config = StructureZoneConfig(min_overlap_for_zone=2) + points = [RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu', + boundary_type='ZG', source_zs_id=0, is_sure=True)] + result = cluster_raw_points(points, config) + assert result == [] + + def test_two_nearby_points_merge(self): + """相邻价格点归为一类""" + config = StructureZoneConfig(cluster_radius_pct=1.0, min_overlap_for_zone=2) + points = [ + RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu', + boundary_type='ZG', source_zs_id=0, is_sure=True), + RawZonePoint(price=100.5, timeframe='15m', structure_type='xd_zhongshu', + boundary_type='ZD', source_zs_id=0, is_sure=True), + ] + result = cluster_raw_points(points, config) + assert len(result) == 1 + assert len(result[0]) == 2 + + def test_two_distant_points_separate(self): + """远离的价格点不归为一类""" + config = StructureZoneConfig(cluster_radius_pct=0.1, min_overlap_for_zone=1) # 先用 1 看聚类 + points = [ + RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu', + boundary_type='ZG', source_zs_id=0, is_sure=True), + RawZonePoint(price=110, timeframe='15m', structure_type='xd_zhongshu', + boundary_type='ZD', source_zs_id=0, is_sure=True), + ] + # 先用 min_overlap=2 确认被过滤 + config2 = StructureZoneConfig(cluster_radius_pct=0.1, min_overlap_for_zone=2) + result = cluster_raw_points(points, config2) + assert result == [] # 两个单独点,都不够 min_overlap + + def test_multi_tf_convergence(self): + """多个时间周期在相同价格区间聚合""" + config = StructureZoneConfig(cluster_radius_pct=1.0, min_overlap_for_zone=2) + points = [] + for tf in ['5m', '15m', '30m', '1h']: + for btype in ['ZG', 'ZD']: + points.append(RawZonePoint(price=100 + abs(hash(tf + btype)) % 3 * 0.1, + timeframe=tf, structure_type='bi_zhongshu', + boundary_type=btype, source_zs_id=0, is_sure=True)) + result = cluster_raw_points(points, config) + assert len(result) >= 1 + # 所有点应该聚合在一起(价差很小) + total = sum(len(c) for c in result) + assert total == len(points) + + +class TestBuildStructureZones: + """评分和构建测试""" + + def _make_cluster(self, prices, tf='5m', st='bi_zhongshu'): + return [RawZonePoint(price=p, timeframe=tf, structure_type=st, + boundary_type='ZG', source_zs_id=0, is_sure=True, + candle_time='2025-01-01T00:00:00') + for p in prices] + + def test_zone_type_support(self): + """当前价上方区间是阻力,下方是支撑""" + config = StructureZoneConfig() + cluster = self._make_cluster([90, 92]) + ema52 = {'5m': 100} + zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) == 1 + assert zones[0].zone_type == 'support' # 在价格下方 + + def test_zone_type_resistance(self): + config = StructureZoneConfig() + cluster = self._make_cluster([110, 112]) + ema52 = {'5m': 100} + zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) == 1 + assert zones[0].zone_type == 'resistance' + + def test_zone_type_neutral(self): + config = StructureZoneConfig() + cluster = self._make_cluster([95, 105]) + ema52 = {'5m': 100} + zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) == 1 + assert zones[0].zone_type == 'neutral' + + def test_strength_score_range(self): + """评分在 0-100 之间""" + config = StructureZoneConfig() + cluster = self._make_cluster([100, 102, 104], '5m', 'bi_zhongshu') + cluster += self._make_cluster([100.5, 102.5], '15m', 'xd_zhongshu') + ema52 = {'5m': 0, '15m': 0} + zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) == 1 + assert 0 <= zones[0].strength_score <= 100 + + def test_ema52_aligned_true(self): + """EMA52 落在区间内""" + config = StructureZoneConfig() + cluster = self._make_cluster([95, 105]) + ema52 = {'5m': 100} + zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert zones[0].ema52_aligned is True + + def test_ema52_aligned_false(self): + """EMA52 不在区间内""" + config = StructureZoneConfig() + cluster = self._make_cluster([95, 105]) + ema52 = {'5m': 120} + zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert zones[0].ema52_aligned is False + + def test_confidence_range(self): + """置信度在 0-1 之间""" + config = StructureZoneConfig() + cluster = self._make_cluster([100, 101, 102, 103]) + ema52 = {} + zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert 0 <= zones[0].confidence <= 1 + + def test_max_zones_cap(self): + """max_zones 限制返回数量""" + config = StructureZoneConfig(max_zones=3) + clusters = [self._make_cluster([100 + i * 10, 100 + i * 10 + 2]) for i in range(10)] + ema52 = {} + zones = build_structure_zones(clusters, current_price=150, ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert len(zones) <= 3 + + def test_sorted_by_strength(self): + """按 strength 降序排列""" + config = StructureZoneConfig(max_zones=0) + # 创建一个有更多重叠的聚类(更强)和一个较弱的聚类 + cluster_strong = self._make_cluster([100, 101, 102, 103, 104]) # 5 点 + cluster_weak = self._make_cluster([200, 201]) # 2 点 + ema52 = {'5m': 0} + zones = build_structure_zones([cluster_weak, cluster_strong], current_price=150, + ema52_values=ema52, + latest_candle_time='2025-01-01T01:00:00', config=config) + assert zones[0].strength_score >= zones[-1].strength_score + + +class TestExtractFromSerialized: + """从序列化数据提取测试""" + + def test_empty_analyses(self): + config = StructureZoneConfig() + points = extract_raw_points_from_serialized({}, {}, config) + assert points == [] + + def test_basic_extraction(self): + analyses = { + '5m': { + 'bi_zs_list': [ + {'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True, 'end_time': '2025-01-01T00:00'}, + ], + 'zs_list': [], + }, + '15m': { + 'bi_zs_list': [], + 'zs_list': [ + {'zg': 105, 'zd': 98, 'gg': 107, 'dd': 96, 'is_sure': True, 'end_time': '2025-01-01T00:00'}, + ], + }, + } + ema52 = {'5m': 101, '15m': 103} + config = StructureZoneConfig(zone_timeframes=['5m', '15m']) + points = extract_raw_points_from_serialized(analyses, ema52, config) + # bi_zs: 4 points (ZG/ZD/GG/DD) + zs_list: 4 points + 2 ema52 = 10 + assert len(points) == 10 + + def test_unsure_filtered(self): + analyses = { + '5m': { + 'bi_zs_list': [ + {'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': False}, + ], + 'zs_list': [], + }, + } + ema52 = {} + config = StructureZoneConfig(zone_timeframes=['5m']) + points = extract_raw_points_from_serialized(analyses, ema52, config) + assert len(points) == 0 # is_sure=False 被过滤 + + def test_timeframe_filtering(self): + """仅提取 config.zone_timeframes 中的周期""" + analyses = { + '5m': { + 'bi_zs_list': [ + {'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True}, + ], + 'zs_list': [], + }, + '1h': { + 'bi_zs_list': [ + {'zg': 200, 'zd': 190, 'gg': 205, 'dd': 188, 'is_sure': True}, + ], + 'zs_list': [], + }, + } + ema52 = {'5m': 101, '1h': 195} + config = StructureZoneConfig(zone_timeframes=['5m']) # 只取 5m + points = extract_raw_points_from_serialized(analyses, ema52, config) + # 只有 5m: 4 bi_zs + 1 ema52 = 5 + assert len(points) == 5 + assert all(p.timeframe == '5m' for p in points) + + +class TestScoringHelpers: + """评分辅助函数测试""" + + def test_calc_recency_same_time(self): + """同一时间的 recency = 1.0""" + score = _calc_recency('2025-01-01T00:00:00', '2025-01-01T00:00:00', 50) + assert score == 1.0 + + def test_calc_recency_invalid(self): + """无效时间的 recency = 0.5""" + score = _calc_recency(None, '2025-01-01T00:00:00', 50) + assert score == 0.5 + + def test_calc_confidence_high(self): + """高重叠数 = 高置信度""" + conf = _calc_confidence(6, 3, []) + assert conf > 0.7 + + def test_calc_confidence_low(self): + """低重叠数 = 低置信度""" + conf = _calc_confidence(2, 1, []) + assert conf < 0.7 + + +class TestAnalyzeFromSerialized: + """端到端测试(从序列化数据到 StructureZone)""" + + def test_end_to_end(self): + analyses = { + '5m': { + 'bi_zs_list': [ + {'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True, 'end_time': '2025-01-01T00:00'}, + ], + 'zs_list': [], + }, + '15m': { + 'bi_zs_list': [ + {'zg': 101, 'zd': 96, 'gg': 103, 'dd': 94, 'is_sure': True, 'end_time': '2025-01-01T00:01'}, + ], + 'zs_list': [], + }, + } + ema52_dict = {'5m': 100.5, '15m': 99.5} + config = StructureZoneConfig(zone_timeframes=['5m', '15m'], cluster_radius_pct=2.0) + zones = analyze_structure_zones_from_serialized(analyses, ema52_dict, 110, config) + # 两个 TF 的 BI_ZS 价格接近,应聚合成一个区间 + assert len(zones) >= 1 + zone = zones[0] + assert zone.zone_type == 'support' # 价格在 93-103,current_price=110 + assert '5m' in zone.timeframes + assert '15m' in zone.timeframes + assert zone.structure_types == ['bi_zhongshu'] + assert 0 <= zone.strength_score <= 100 + assert 0 <= zone.confidence <= 1 + + def test_empty_returns_empty(self): + zones = analyze_structure_zones_from_serialized({}, {}, 100) + assert zones == [] + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/web/app.py b/web/app.py index c2a3c70..b723b72 100644 --- a/web/app.py +++ b/web/app.py @@ -12,6 +12,7 @@ import io import base64 import time import traceback +from concurrent.futures import ThreadPoolExecutor, as_completed from pytz import timezone import talib.abstract as ta import numpy as np @@ -22,6 +23,7 @@ from ChanLun import ChanLun, TF_DF from ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR from cn_stock_data import ChinaStockData from ChanMACD import ChanMACD +from ChanZone import StructureZoneConfig, analyze_structure_zones_from_serialized # 添加买卖点枚举类型 class TRADE_POINT_TYPE: @@ -48,9 +50,22 @@ china_stock = ChinaStockData() logger = logging.getLogger(__name__) -DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://127.0.0.1:9009")) -#DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://192.168.1.9:9009")) -#DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166")) +# 结构价值区缓存: {tf_name: {'data': ..., 'expires': timestamp}} +_zone_cache = {} + +def _zone_cache_ttl(tf_name: str) -> int: + """根据时间周期返回缓存过期时间(秒)""" + minutes = timeframe_to_minutes(tf_name) or 5 + if minutes <= 5: + return 120 # 5m及以下: 2分钟 + elif minutes <= 15: + return 300 # 15m: 5分钟 + elif minutes <= 60: + return 600 # 1h: 10分钟 + else: + return 1800 # 4h+: 30分钟 + +DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166")) DEFAULT_TIMEFRAME_LABELS = OrderedDict([ ("1m", "1分钟"), @@ -185,6 +200,8 @@ def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit= params["start"] = int(start_ms) if end_ms is not None: params["end"] = int(end_ms) + if limit is not None: + params["limit"] = limit resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=10) resp.raise_for_status() data = resp.json() @@ -1778,6 +1795,115 @@ def analyze(): pass + # 结构价值区分析(Structure Zone)—— 独立拉取多周期数据,缓存避免重复请求 + zone_timeframes_str = request.args.get('zone_timeframes', '') + zone_kl_lines = int(request.args.get('zone_kl_lines', 1000)) + try: + zone_config = StructureZoneConfig(kl_lines_per_tf=zone_kl_lines) + if zone_timeframes_str: + zone_config.zone_timeframes = [t.strip() for t in zone_timeframes_str.split(',') if t.strip()] + analyses = {} + ema52_dict = {} + latest_close = 0.0 + now = time.time() + + def _fetch_single_tf_zone(tf_name): + """单个时间周期的结构区数据拉取(线程安全)""" + cache_key = f"{symbol}:{tf_name}:{zone_kl_lines}" + cached = _zone_cache.get(cache_key) + if cached and cached['expires'] > now: + print(f" 结构区缓存命中: {tf_name}") + return { + 'tf_name': tf_name, + 'analyses': cached['analyses'], + 'ema52': cached['ema52'], + 'close': cached.get('close', 0.0), + 'cached': True, + } + + try: + tf_df = get_kl_data(symbol, tf_name, limit=zone_kl_lines) + if tf_df is None or len(tf_df) == 0: + return None + tf_df = add_indicators(tf_df) + tf_analysis = analyze_chan(tf_df, symbol, tf_name) + zs_serialized = [{ + 'start_time': (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if zs.start_klc else None, + 'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None, + 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, + 'is_sure': zs.is_sure + } for zs in tf_analysis.get('zs_list', []) if zs.is_sure] + bi_zs_serialized = [{ + 'start_time': ((zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if getattr(zs.start_klc, 'end_time', None) else (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())), + 'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None, + 'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd, + 'is_sure': bool(getattr(zs, 'is_sure', False)) + } for zs in tf_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)] + last_ema = tf_df['ema52'].iloc[-1] if 'ema52' in tf_df.columns else 0 + ema_val = float(last_ema) if last_ema and last_ema > 0 else None + last_close = float(tf_df['close'].iloc[-1]) + tf_result = { + 'tf_name': tf_name, + 'analyses': {'zs_list': zs_serialized, 'bi_zs_list': bi_zs_serialized}, + 'ema52': ema_val, + 'close': last_close, + 'cached': False, + } + # 写入缓存 + _zone_cache[cache_key] = { + 'analyses': tf_result['analyses'], + 'ema52': ema_val, + 'close': last_close, + 'expires': now + _zone_cache_ttl(tf_name), + } + print(f" 结构区数据: {tf_name} -> zs={len(zs_serialized)}, bi_zs={len(bi_zs_serialized)}, ema52={ema_val}") + return tf_result + except Exception as e: + print(f" 结构区 {tf_name} 拉取失败: {e}") + return None + + with ThreadPoolExecutor(max_workers=len(zone_config.zone_timeframes)) as executor: + futures = {executor.submit(_fetch_single_tf_zone, tf): tf for tf in zone_config.zone_timeframes} + for future in as_completed(futures): + tf_result = future.result() + if tf_result is None: + continue + tf_name = tf_result['tf_name'] + analyses[tf_name] = tf_result['analyses'] + ema52_dict[tf_name] = tf_result['ema52'] + if tf_result['close'] and (not latest_close or latest_close == 0.0): + latest_close = tf_result['close'] + + structure_zones = analyze_structure_zones_from_serialized( + analyses, ema52_dict, latest_close, config=zone_config + ) + result['structure_zones'] = [{ + 'id': z.id, + 'lower': z.lower, + 'upper': z.upper, + 'center': z.center, + 'width_pct': z.width_pct, + 'zone_type': z.zone_type, + 'timeframes': z.timeframes, + 'structure_types': z.structure_types, + 'boundary_types': z.boundary_types, + 'overlap_count': z.overlap_count, + 'touch_count': z.touch_count, + 'recency_score': z.recency_score, + 'ema52_distance_pct': z.ema52_distance_pct, + 'ema52_aligned': z.ema52_aligned, + 'strength_score': z.strength_score, + 'confidence': z.confidence, + 'first_seen': z.first_seen, + 'last_seen': z.last_seen, + 'metadata': z.metadata, + } for z in structure_zones] + except Exception as e: + print(f"StructureZone 分析出错: {e}") + import traceback + traceback.print_exc() + result['structure_zones'] = [] + return jsonify(result) @app.route('/api/symbols') diff --git a/web/templates/index.html b/web/templates/index.html index 4985279..884d798 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -958,6 +958,11 @@ +
+ + +
+ @@ -1911,6 +1916,11 @@ console.log('主BI中枢切换为:', $('#showMainBiZs').is(':checked')); updateChartDisplay(); }); + // 结构价值区复选框变更事件 + $(document).on('change', '#showMainStructureZone', function() { + console.log('结构区切换为:', $('#showMainStructureZone').is(':checked')); + updateChartDisplay(); + }); // 添加趋势显示复选框变更事件(主/元素),变更后刷新主图 $('#showMainTrend').change(function() { @@ -2179,7 +2189,8 @@ sub_sub_timeframe: subSubTimeframe || undefined, start_time: startTimeMs, end_time: endTimeMs, - elements_only: false + elements_only: false, + zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000 }, success: function(data) { // 隐藏加载图标 @@ -4634,6 +4645,58 @@ } catch (e) { console.error('次周期BI中枢处理出错:', e); } }); } + // 结构价值区绘制(半透明填充区 + 边框) + if ($('#showMainStructureZone').is(':checked') && currentData.structure_zones && currentData.structure_zones.length > 0) { + try { + const kd = currentData.kline_data || []; + if (kd.length > 0) { + const chartStart = Math.floor(new Date(kd[0].date).getTime() / 1000); + const chartEnd = Math.floor(new Date(kd[kd.length-1].date).getTime() / 1000); + // 计算可见价格范围,过滤超出范围的区间 + let priceMin = Infinity, priceMax = -Infinity; + kd.forEach(function(k) { + const hi = parseFloat(k.high), lo = parseFloat(k.low); + if (!isNaN(hi) && hi > priceMax) priceMax = hi; + if (!isNaN(lo) && lo < priceMin) priceMin = lo; + }); + const priceMargin = (priceMax - priceMin) * 0.05; + priceMin -= priceMargin; + priceMax += priceMargin; + let drawnCount = 0; + currentData.structure_zones.forEach(function(zone) { + try { + // 跳过完全超出可视价格范围的区间 + if (zone.upper < priceMin || zone.lower > priceMax) return; + const fillColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.08)' : + zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.08)' : + 'rgba(149, 165, 166, 0.06)'; + const borderColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.7)' : + zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.7)' : + 'rgba(149, 165, 166, 0.6)'; + // 填充区:在上下边界之间画多条半透明线模拟填充 + const fillLines = 8; + const step = (zone.upper - zone.lower) / (fillLines + 1); + for (let fi = 1; fi <= fillLines; fi++) { + const fy = zone.lower + step * fi; + mainChart.addLineSeries({ color: fillColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: fy }, { time: chartEnd, value: fy }]); + } + // 上边界(粗线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.upper }, { time: chartEnd, value: zone.upper }]); + // 下边界(粗线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.lower }, { time: chartEnd, value: zone.lower }]); + // 中心线(虚线) + mainChart.addLineSeries({ color: borderColor, lineWidth: 1, lineStyle: 2, lastValueVisible: false, priceLineVisible: false }) + .setData([{ time: chartStart, value: zone.center }, { time: chartEnd, value: zone.center }]); + drawnCount++; + } catch (e) { console.error('结构区绘制出错:', e); } + }); + console.log(`结构区: 共${currentData.structure_zones.length}个, 绘制${drawnCount}个 (可见价格范围: ${priceMin.toFixed(0)}-${priceMax.toFixed(0)})`); + } + } catch (e) { console.error('结构区整体绘制出错:', e); } + } // 显示未完成中枢 - 分别处理主周期、次周期和次次周期 if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) { console.log('绘制未完成中枢 - 已启用');