diff --git a/README.md b/README.md index 110b4cc..c0607b7 100644 --- a/README.md +++ b/README.md @@ -1618,6 +1618,18 @@ ruff format --check src/ tests/ # format check ## Changelog +### 1.14.2 (2026-06-16) + +**缠论结果可视化字段增强** — 响应 [Discussion #2](https://github.com/handsomejustin/easy-tdx/discussions/2),为缠论分析 JSON 输出(`ChanlunResult.to_dict()`)中的中枢 / 买卖点 / 背驰补上对应 K 线日期,方便前端/可视化工具直接用来标点画图。纯增量、向后兼容,不破坏任何已有 JSON 字段。 + +新增字段: + +- **中枢 `zss`**:输出起始笔与结束笔的日期 `start_date` / `end_date`(第一笔起点 → 最后一笔终点)。 +- **买卖点 `mmds`**:输出触发该买卖点的笔确认日期 `date`(买卖点确立时刻的 K 线日期)。 +- **背驰 `bcs`**:输出背驰对照两笔的日期 `curr_date`(当前背驰笔)/ `prev_date`(对照基准笔)。 + +日期统一采用 `YYYY-MM-DD` 格式(与已有 `bis` / `xds` 输出一致),全部字段对 `None` 做了兜底。三层接入(Python API / CLI `easy-tdx chanlun` / Web `/chanlun/analyze`)同步生效,Web 接口直接返回新字段无需改动。 + ### 1.14.1 (2026-06-15) **高级回测 ExecutionModel 路径 3 个真实数据兼容 Bug 修复** — 实测 `601088` 高级回测(方根滑点 + TWAP)暴露:权益曲线恒定、收益归零。根因为 ExecutionModel 路径与真实行情数据的格式/列名/类型脱节。 diff --git a/pyproject.toml b/pyproject.toml index be47af4..60cc56c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.14.1" +version = "1.14.2" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/chanlun/analyser.py b/src/easy_tdx/chanlun/analyser.py index 95d649e..17234be 100644 --- a/src/easy_tdx/chanlun/analyser.py +++ b/src/easy_tdx/chanlun/analyser.py @@ -99,6 +99,8 @@ class ChanlunResult: "gg": round(zs.gg, 2), "dd": round(zs.dd, 2), "line_count": zs.line_count, + "start_date": zs.start.k.date.strftime("%Y-%m-%d") if zs.start else None, + "end_date": zs.end.k.date.strftime("%Y-%m-%d") if zs.end else None, "done": zs.done, } for zs in self.zss @@ -117,6 +119,7 @@ class ChanlunResult: "mmds": [ { "type": mmd.mmd_type.value, + "date": mmd.bi.end.k.date.strftime("%Y-%m-%d") if mmd.bi else None, "msg": mmd.msg, } for mmd in self.mmds @@ -125,6 +128,8 @@ class ChanlunResult: { "type": bc.bc_type.value, "bc": bc.bc, + "curr_date": bc.curr.end.k.date.strftime("%Y-%m-%d") if bc.curr else None, + "prev_date": bc.prev.end.k.date.strftime("%Y-%m-%d") if bc.prev else None, "msg": bc.msg, } for bc in self.bcs diff --git a/src/easy_tdx/chanlun/beichi.py b/src/easy_tdx/chanlun/beichi.py index 2dbb759..abdfa34 100644 --- a/src/easy_tdx/chanlun/beichi.py +++ b/src/easy_tdx/chanlun/beichi.py @@ -75,6 +75,8 @@ def _check_bi_level_beichi(bis: list[BI]) -> list[BC]: bc_type=BCType.BI, bc=True, zs=None, + curr=curr, + prev=prev, msg=( f"笔背驰: 笔[{curr.index}] 力度={curr_force:.2f} " f"< 笔[{prev.index}] 力度={prev_force:.2f}" @@ -108,6 +110,8 @@ def _check_pz_beichi(bis: list[BI], zss: list[ZS]) -> list[BC]: bc_type=BCType.PZ, bc=True, zs=zs, + curr=last_bi, + prev=first_bi, msg=( f"盘整背驰: 中枢[{zs.index}] 内末笔力度={last_force:.2f} " f"< 首笔力度={first_force:.2f}" @@ -138,6 +142,8 @@ def _check_qs_beichi(bis: list[BI], zss: list[ZS]) -> list[BC]: bc_type=BCType.QS, bc=True, zs=curr_zs, + curr=curr_zs.lines[-1], + prev=prev_zs.lines[-1], msg=( f"趋势背驰(下): 中枢[{curr_zs.index}] 离开力度={curr_exit_force:.2f} " f"< 中枢[{prev_zs.index}] 离开力度={prev_exit_force:.2f}" @@ -155,6 +161,8 @@ def _check_qs_beichi(bis: list[BI], zss: list[ZS]) -> list[BC]: bc_type=BCType.QS, bc=True, zs=curr_zs, + curr=curr_zs.lines[-1], + prev=prev_zs.lines[-1], msg=( f"趋势背驰(上): 中枢[{curr_zs.index}] 离开力度={curr_exit_force:.2f} " f"< 中枢[{prev_zs.index}] 离开力度={prev_exit_force:.2f}" diff --git a/src/easy_tdx/chanlun/mmd.py b/src/easy_tdx/chanlun/mmd.py index a6e3bc5..4ebcec6 100644 --- a/src/easy_tdx/chanlun/mmd.py +++ b/src/easy_tdx/chanlun/mmd.py @@ -68,6 +68,7 @@ def _check_buy_point(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None: return MMD( mmd_type=MMDType.BUY_1, zs=zs, + bi=bi, msg=f"中枢下方力度衰减,一类买点 (l={bi.low:.2f} < zd={zs.zd:.2f})", ) @@ -82,6 +83,7 @@ def _check_buy_point(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None: return MMD( mmd_type=MMDType.BUY_2, zs=zs, + bi=bi, msg=f"回调不创新低,二类买点 (l={bi.low:.2f})", ) @@ -90,6 +92,7 @@ def _check_buy_point(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None: return MMD( mmd_type=MMDType.BUY_3, zs=zs, + bi=bi, msg=f"回调不破中枢上沿,三类买点 (l={bi.low:.2f} > zg={zs.zg:.2f})", ) @@ -104,6 +107,7 @@ def _check_sell_point(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None: return MMD( mmd_type=MMDType.SELL_1, zs=zs, + bi=bi, msg=f"中枢上方力度衰减,一类卖点 (h={bi.high:.2f} > zg={zs.zg:.2f})", ) @@ -117,6 +121,7 @@ def _check_sell_point(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None: return MMD( mmd_type=MMDType.SELL_2, zs=zs, + bi=bi, msg=f"反弹不创新高,二类卖点 (h={bi.high:.2f})", ) @@ -125,6 +130,7 @@ def _check_sell_point(bi: BI, zs: ZS, all_bis: list[BI]) -> MMD | None: return MMD( mmd_type=MMDType.SELL_3, zs=zs, + bi=bi, msg=f"反弹不破中枢下沿,三类卖点 (h={bi.high:.2f} < zd={zs.zd:.2f})", ) diff --git a/src/easy_tdx/chanlun/types.py b/src/easy_tdx/chanlun/types.py index ce5b036..9ce08d0 100644 --- a/src/easy_tdx/chanlun/types.py +++ b/src/easy_tdx/chanlun/types.py @@ -183,6 +183,7 @@ class MMD: mmd_type: MMDType zs: ZS | None = None + bi: BI | None = None # 触发该买卖点的笔(用于可视化锚定日期) msg: str = "" def __str__(self) -> str: @@ -204,6 +205,8 @@ class BC: bc_type: BCType bc: bool = False # 是否背驰 zs: ZS | None = None + curr: BI | XD | None = None # 当前背驰笔/线段(用于可视化锚定日期) + prev: BI | XD | None = None # 前一同向笔/线段(力度对照基准) msg: str = "" def __str__(self) -> str: diff --git a/tests/unit/test_chanlun.py b/tests/unit/test_chanlun.py index 05db0bc..2c98a35 100644 --- a/tests/unit/test_chanlun.py +++ b/tests/unit/test_chanlun.py @@ -549,3 +549,82 @@ class TestChanlunAnalyser: assert "zs_count" in d assert "bis" in d assert "zss" in d + + # 可视化字段:中枢/买卖点/背驰应携带对应 K 线日期(若该样本产出了它们) + import re + + date_re = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + for zs in d["zss"]: + assert "start_date" in zs + assert "end_date" in zs + if zs["start_date"] is not None: + assert date_re.match(zs["start_date"]) + if zs["end_date"] is not None: + assert date_re.match(zs["end_date"]) + + for mmd in d["mmds"]: + assert "date" in mmd + if mmd["date"] is not None: + assert date_re.match(mmd["date"]) + + for bc in d["bcs"]: + assert "curr_date" in bc + assert "prev_date" in bc + if bc["curr_date"] is not None: + assert date_re.match(bc["curr_date"]) + if bc["prev_date"] is not None: + assert date_re.match(bc["prev_date"]) + + def test_result_to_dict_with_visual_dates(self) -> None: + """可视化字段:足够数据下 zss/mmds/bcs 应携带合法 K 线日期。 + + 用一段振荡+趋势的数据,确保能确定性产出中枢/买卖点/背驰, + 从而真正覆盖 to_dict() 的日期输出分支。 + """ + import math + import re + + import pandas as pd + + from easy_tdx.chanlun.analyser import ChanlunAnalyser + + dates = pd.date_range("2025-01-02", periods=40, freq="B") + highs = [15 + 5 * math.sin(i / 2) + i * 0.2 for i in range(40)] + lows = [highs[i] - 4 for i in range(40)] + df = pd.DataFrame( + { + "datetime": dates, + "open": [h - 2 for h in highs], + "close": [h - 1 for h in highs], + "high": highs, + "low": lows, + "vol": [1000] * 40, + } + ) + analyser = ChanlunAnalyser(code="SZ000001") + d = analyser.process_klines(df).to_dict() + + date_re = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + # 中枢必须有起止日期 + assert len(d["zss"]) > 0 + for zs in d["zss"]: + assert zs["start_date"] is not None + assert zs["end_date"] is not None + assert date_re.match(zs["start_date"]) + assert date_re.match(zs["end_date"]) + + # 买卖点必须有触发日期 + assert len(d["mmds"]) > 0 + for mmd in d["mmds"]: + assert mmd["date"] is not None + assert date_re.match(mmd["date"]) + + # 背驰必须有当前笔 + 对照笔日期 + assert len(d["bcs"]) > 0 + for bc in d["bcs"]: + assert bc["curr_date"] is not None + assert bc["prev_date"] is not None + assert date_re.match(bc["curr_date"]) + assert date_re.match(bc["prev_date"])