mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
release: v1.31.3 — 紧急修复盘中分时数据错乱(实时命令解析错位,全程改走历史分时)
This commit is contained in:
+13
-5
@@ -2,11 +2,18 @@
|
||||
|
||||
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
|
||||
|
||||
## [未发布]
|
||||
## [1.31.3] — 2026-09-04
|
||||
|
||||
**CLI 对齐 WebUI/Python SDK 的分析能力**——补齐 CLI 此前缺失的三块:一键参数寻优、内置策略列表、组合级 WF/一条龙。引擎层全部复用现成实现(`ParamGridOptimizer`/`STRATEGY_PRESETS`/`evaluate_portfolio`/`PortfolioWalkForwardEngine`),CLI、Web API(`/backtest/optimize`、`/backtest/evaluate` 等)与 Python SDK 三条通路能力对等。
|
||||
**紧急修复盘中分时数据错乱(v1.31.2 回归),并包含 CLI 分析能力对齐**——v1.31.2 引入的"盘中走实时分时命令"路径存在解析错位:实时分时命令(0x0c1b)响应每条记录实际 2 个字段,解析器按 3 字段读取,从第二条起全部错位——个股与指数**盘中**分时出现天文价格(如科创50 出现 67008)与负成交量(如 -2296)。实测确认历史分时接口盘中查"当日"即返回已成交分钟(午休 12:33 时返回上午 120 条,尾价与实时行情现价吻合),实时命令本无必要。本版同时包含此前合入 main 的 CLI 分析能力对齐与寻优指标缓存修复。
|
||||
|
||||
### 新增
|
||||
### 紧急修复(分时)
|
||||
|
||||
- **分时全程改走历史分时接口**([client.py](src/easy_tdx/client.py) `get_minute_time_data`,同步+异步):先查今天——盘中=已成交部分、收盘后=全天 240 条;空(盘前/周末/节假日)回退最近交易日。实时分时命令从 client 移除,docstring 记录弃用原因(响应布局与解析器不匹配)。
|
||||
- v1.31.2 的**盘前/休市回退**与**指数K线命令适配**(乱码日期检测自动换 `GetIndexBarsCmd`)经验证正确,保持不变。
|
||||
|
||||
### 新增(CLI 对齐 WebUI/Python SDK 的分析能力)
|
||||
|
||||
补齐 CLI 此前缺失的三块:一键参数寻优、内置策略列表、组合级 WF/一条龙。引擎层全部复用现成实现(`ParamGridOptimizer`/`STRATEGY_PRESETS`/`evaluate_portfolio`/`PortfolioWalkForwardEngine`),CLI、Web API(`/backtest/optimize`、`/backtest/evaluate` 等)与 Python SDK 三条通路能力对等。
|
||||
|
||||
- **`easy-tdx optimize` 参数网格寻优命令**([backtest/cli.py](src/easy_tdx/backtest/cli.py)):单策略网格搜索(`--strategy ma_cross` 用该策略预设网格,或 `--param fast=5,10,15 --param slow=20,60` 自定义),以及 `--all` 一键寻优所有内置策略——逐策略按 `STRATEGY_PRESETS` 预设网格寻优后按总收益率全局排名(对齐 Web UI /optimize 页与 `/backtest/optimize-all/run/async`)。支持 `--workers N` 进程级并行、`--table` 排名表 / JSON 全量输出(含热力图矩阵)。`--strategy` 与 `--param` 在联网取数前前置校验(未知策略/未知参数/网格超限快速失败)。
|
||||
- **`easy_tdx.backtest.optimizer.optimize_all_strategies` Python API**([optimizer.py](src/easy_tdx/backtest/optimizer.py)):一键全策略寻优的规范实现(`_optimize_strategy_best` 模块级 worker 可 pickle,主进程解析 label、跨策略 ProcessPool 并行),CLI 与后续 Web 端可共用;`presets` 参数支持注入子集网格(测试用)。
|
||||
@@ -19,9 +26,10 @@
|
||||
|
||||
### 测试
|
||||
|
||||
- 新增 13 例:`optimize` 命令互斥/未知策略/未知参数/畸形参数校验(联网前快速失败)、`strategies` 表格与 JSON 输出、`portfolio --help` 新旗标、`optimize_all_strategies` 排名序/label/skipped/JSON 原生类型、指标缓存「同内容不同对象必须命中」回归。
|
||||
- **分时单测重写为 7 例**(同步/异步双口径):盘中只查今日历史分时(断言单次请求)、盘前回退最近交易日、指数锚点换指数K线命令、无日 K 兜底;盘中实测四类标的(个股 301008 / 上证指数 / 科创50 000688 / 880 板块指数)全部返回当日已成交分钟、尾价与实时现价吻合、零异常行(价格 ≤0 或成交量 <0 的行数为 0)。
|
||||
- CLI 侧新增 13 例:`optimize` 命令互斥/未知策略/未知参数/畸形参数校验(联网前快速失败)、`strategies` 表格与 JSON 输出、`portfolio --help` 新旗标、`optimize_all_strategies` 排名序/label/skipped/JSON 原生类型、指标缓存「同内容不同对象必须命中」回归。
|
||||
- 实测验证:`strategies` 列出 54 策略;`optimize --strategy`(3×3 网格)与 `optimize --all --workers 4`(54 策略 316 网格点)真实行情跑通;`portfolio --evaluate`(完整报告含评分/评级/WF/基准)与 `--wf` 真实跑通。
|
||||
- 全量回归:pytest 1613 全部通过、ruff/ruff format/mypy 全绿(原 v1.25 既有失败 `test_optimizer_cache_reuse_across_grid_points` 随缓存键修复转绿)。
|
||||
- 全量回归:pytest 1629 全部通过、ruff/ruff format/mypy 全绿(v1.25 既有失败 `test_optimizer_cache_reuse_across_grid_points` 随缓存键修复转绿)。
|
||||
|
||||
## [1.31.2] — 2026-09-04
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "easy-tdx"
|
||||
version = "1.31.2"
|
||||
version = "1.31.3"
|
||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
+19
-26
@@ -39,7 +39,7 @@ from .commands.base import BaseCommand
|
||||
from .commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd
|
||||
from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd
|
||||
from .commands.finance_info import GetFinanceInfoCmd
|
||||
from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd
|
||||
from .commands.minute_time import GetHistoryMinuteTimeDataCmd
|
||||
from .commands.report_file import GetReportFileCmd
|
||||
from .commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd
|
||||
from .commands.security_count import GetSecurityCountCmd
|
||||
@@ -608,27 +608,23 @@ class TdxClient:
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_minute_time_data(self, market: Market, code: str) -> pd.DataFrame:
|
||||
"""获取最近交易日分时数据(盘中=今日实时分时;盘前/休市=最近交易日历史分时)。
|
||||
"""获取最近交易日分时数据(个股与指数通用)。
|
||||
|
||||
历史分时接口对当日返回空(数据收盘后才生成),实时分时接口在盘前
|
||||
返回价格自 0 累加的占位数据——单纯用"今天"查任一接口在盘前/周末/
|
||||
节假日都会拿到空/脏数据。这里以最新一根日 K 的日期锚定最近交易日:
|
||||
等于今天(盘中/收盘后)走实时分时命令;早于今天则查该日的历史分时。
|
||||
历史分时接口盘中对"当日"即返回已成交分钟(实测 12:33 午休时返回
|
||||
上午 120 条),收盘后为全天 240 条——先查今天,无数据(盘前/周末/
|
||||
节假日)回退最近交易日(最新日 K 锚定,指数自动走指数 K 线命令)。
|
||||
实时分时命令(0x0c1b)响应每条 2 字段而解析器按 3 字段读,必然
|
||||
错位出负量/天价乱码,不使用。
|
||||
"""
|
||||
today = _today_in_shanghai()
|
||||
latest = self._latest_trade_date(market, code)
|
||||
if latest == today:
|
||||
bars = self._execute(GetMinuteTimeDataCmd(market, code))
|
||||
# 盘前占位数据首条价格恒为 0(价格差自 0 累加),视为无效
|
||||
if bars and bars[0].price > 0:
|
||||
return _add_minute_datetime(_to_df(bars), today)
|
||||
if latest is not None and latest != today:
|
||||
bars = self._execute(GetHistoryMinuteTimeDataCmd(market, code, latest))
|
||||
for date in dict.fromkeys((today, latest)):
|
||||
if date is None:
|
||||
continue
|
||||
bars = self._execute(GetHistoryMinuteTimeDataCmd(market, code, date))
|
||||
if bars:
|
||||
return _add_minute_datetime(_to_df(bars), latest)
|
||||
# 兜底(无日 K 数据等场景):维持旧契约,查今日历史分时(可能为空)
|
||||
bars = self._execute(GetHistoryMinuteTimeDataCmd(market, code, today))
|
||||
return _add_minute_datetime(_to_df(bars), today)
|
||||
return _add_minute_datetime(_to_df(bars), date)
|
||||
return _add_minute_datetime(_to_df([]), today)
|
||||
|
||||
def _latest_trade_date(self, market: Market, code: str) -> int | None:
|
||||
"""最新一根日 K 的日期(YYYYMMDD),无日 K 数据(如未上市新股)返回 None。
|
||||
@@ -1382,16 +1378,13 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
|
||||
"""获取最近交易日分时数据,语义见同步版 :meth:`get_minute_time_data`。"""
|
||||
today = _today_in_shanghai()
|
||||
latest = await self._latest_trade_date(market, code)
|
||||
if latest == today:
|
||||
bars = await self._execute(GetMinuteTimeDataCmd(market, code))
|
||||
if bars and bars[0].price > 0:
|
||||
return _add_minute_datetime(_to_df(bars), today)
|
||||
if latest is not None and latest != today:
|
||||
bars = await self._execute(GetHistoryMinuteTimeDataCmd(market, code, latest))
|
||||
for date in dict.fromkeys((today, latest)):
|
||||
if date is None:
|
||||
continue
|
||||
bars = await self._execute(GetHistoryMinuteTimeDataCmd(market, code, date))
|
||||
if bars:
|
||||
return _add_minute_datetime(_to_df(bars), latest)
|
||||
bars = await self._execute(GetHistoryMinuteTimeDataCmd(market, code, today))
|
||||
return _add_minute_datetime(_to_df(bars), today)
|
||||
return _add_minute_datetime(_to_df(bars), date)
|
||||
return _add_minute_datetime(_to_df([]), today)
|
||||
|
||||
async def _latest_trade_date(self, market: Market, code: str) -> int | None:
|
||||
"""最新一根日 K 的日期(YYYYMMDD),语义见同步版 :meth:`_latest_trade_date`。"""
|
||||
|
||||
@@ -11,7 +11,6 @@ from easy_tdx import AsyncTdxClient, Market, TdxClient
|
||||
from easy_tdx.client import _classify_fund_flow
|
||||
from easy_tdx.commands.minute_time import (
|
||||
GetHistoryMinuteTimeDataCmd,
|
||||
GetMinuteTimeDataCmd,
|
||||
)
|
||||
from easy_tdx.commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd
|
||||
from easy_tdx.commands.security_list import GetSecurityListCmd
|
||||
@@ -321,17 +320,17 @@ def test_get_price_limits_uses_listing_window(_mock_conn_cls):
|
||||
|
||||
|
||||
@patch("easy_tdx.client.TdxConnection")
|
||||
def test_get_minute_time_data_preopen_falls_back_to_last_trade_day(_mock_conn_cls):
|
||||
"""盘前/周末/节假日:今日分时尚不存在,应回退查最近交易日(最新日K日期)的历史分时。"""
|
||||
def test_get_minute_time_data_intraday_uses_history_today(_mock_conn_cls):
|
||||
"""盘中(最新日K=今天):历史分时接口对当日即返回已成交分钟,只查今天一次。"""
|
||||
client = TdxClient("127.0.0.1")
|
||||
day_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 3, 15, 0)]
|
||||
expected = [MinuteBar(price=40.91, vol=3677)]
|
||||
day_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 4, 15, 0)]
|
||||
expected = [MinuteBar(price=44.2, vol=118)]
|
||||
|
||||
def mock_execute(cmd):
|
||||
if isinstance(cmd, GetSecurityBarsCmd):
|
||||
return day_bars
|
||||
if isinstance(cmd, GetHistoryMinuteTimeDataCmd):
|
||||
assert cmd.date == 20260903, "应查最近交易日 20260903 而非今天"
|
||||
assert cmd.date == 20260904, "盘中应只查今天的历史分时"
|
||||
return expected
|
||||
return []
|
||||
|
||||
@@ -342,55 +341,26 @@ def test_get_minute_time_data_preopen_falls_back_to_last_trade_day(_mock_conn_cl
|
||||
result = client.get_minute_time_data(Market.SH, "600000")
|
||||
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert result["price"].iloc[0] == 40.91
|
||||
assert str(result["datetime"].iloc[0]).startswith("2026-09-03")
|
||||
# 不应请求今日历史分时(旧实现盘前必然拿到空的来源)
|
||||
history_dates = [
|
||||
c[0][0].date
|
||||
for c in mock_exec.call_args_list
|
||||
if isinstance(c[0][0], GetHistoryMinuteTimeDataCmd)
|
||||
]
|
||||
assert history_dates == [20260903]
|
||||
|
||||
|
||||
@patch("easy_tdx.client.TdxConnection")
|
||||
def test_get_minute_time_data_intraday_uses_live_cmd(_mock_conn_cls):
|
||||
"""盘中/收盘后(最新日K=今天):走实时分时命令,返回今日数据。"""
|
||||
client = TdxClient("127.0.0.1")
|
||||
day_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 4, 15, 0)]
|
||||
live = [MinuteBar(price=41.0, vol=100)]
|
||||
|
||||
def mock_execute(cmd):
|
||||
if isinstance(cmd, GetSecurityBarsCmd):
|
||||
return day_bars
|
||||
if isinstance(cmd, GetMinuteTimeDataCmd):
|
||||
return live
|
||||
raise AssertionError(f"盘中不应请求历史分时: {cmd}")
|
||||
|
||||
with (
|
||||
patch("easy_tdx.client._today_in_shanghai", return_value=20260904),
|
||||
patch.object(TdxClient, "_execute", side_effect=mock_execute),
|
||||
):
|
||||
result = client.get_minute_time_data(Market.SH, "600000")
|
||||
|
||||
assert result["price"].iloc[0] == 41.0
|
||||
assert result["price"].iloc[0] == 44.2
|
||||
assert str(result["datetime"].iloc[0]).startswith("2026-09-04")
|
||||
history_calls = [
|
||||
c for c in mock_exec.call_args_list if isinstance(c[0][0], GetHistoryMinuteTimeDataCmd)
|
||||
]
|
||||
assert len(history_calls) == 1
|
||||
|
||||
|
||||
@patch("easy_tdx.client.TdxConnection")
|
||||
def test_get_minute_time_data_live_placeholder_falls_back(_mock_conn_cls):
|
||||
"""防御:实时分时接口返回盘前占位数据(首条价格 0)时不应采用,回退历史分时。"""
|
||||
def test_get_minute_time_data_preopen_falls_back_to_last_trade_day(_mock_conn_cls):
|
||||
"""盘前/周末/节假日:今日分时尚不存在(历史分时当日返回空),回退最近交易日。"""
|
||||
client = TdxClient("127.0.0.1")
|
||||
day_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 4, 15, 0)]
|
||||
expected = [MinuteBar(price=41.0, vol=100)]
|
||||
day_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 3, 15, 0)]
|
||||
expected = [MinuteBar(price=40.91, vol=3677)]
|
||||
|
||||
def mock_execute(cmd):
|
||||
if isinstance(cmd, GetSecurityBarsCmd):
|
||||
return day_bars
|
||||
if isinstance(cmd, GetMinuteTimeDataCmd):
|
||||
return [MinuteBar(price=0.0, vol=48)] * 240
|
||||
if isinstance(cmd, GetHistoryMinuteTimeDataCmd):
|
||||
return expected
|
||||
return expected if cmd.date == 20260903 else []
|
||||
return []
|
||||
|
||||
with (
|
||||
@@ -399,7 +369,8 @@ def test_get_minute_time_data_live_placeholder_falls_back(_mock_conn_cls):
|
||||
):
|
||||
result = client.get_minute_time_data(Market.SH, "600000")
|
||||
|
||||
assert result["price"].iloc[0] == 41.0
|
||||
assert result["price"].iloc[0] == 40.91
|
||||
assert str(result["datetime"].iloc[0]).startswith("2026-09-03")
|
||||
|
||||
|
||||
@patch("easy_tdx.client.TdxConnection")
|
||||
@@ -432,8 +403,7 @@ def test_get_minute_time_data_index_anchor_uses_index_bars(_mock_conn_cls):
|
||||
if isinstance(cmd, GetSecurityBarsCmd):
|
||||
return garbage_bars
|
||||
if isinstance(cmd, GetHistoryMinuteTimeDataCmd):
|
||||
assert cmd.date == 20260903, "应以指数K线锚定的 20260903 查历史分时"
|
||||
return expected
|
||||
return expected if cmd.date == 20260903 else []
|
||||
return []
|
||||
|
||||
with (
|
||||
@@ -462,8 +432,7 @@ def test_async_get_minute_time_data_index_anchor_uses_index_bars():
|
||||
if isinstance(cmd, GetSecurityBarsCmd):
|
||||
return garbage_bars
|
||||
if isinstance(cmd, GetHistoryMinuteTimeDataCmd):
|
||||
assert cmd.date == 20260903
|
||||
return expected
|
||||
return expected if cmd.date == 20260903 else []
|
||||
return []
|
||||
|
||||
with (
|
||||
@@ -491,8 +460,7 @@ def test_async_get_minute_time_data_preopen_falls_back_to_last_trade_day():
|
||||
if isinstance(cmd, GetSecurityBarsCmd):
|
||||
return day_bars
|
||||
if isinstance(cmd, GetHistoryMinuteTimeDataCmd):
|
||||
assert cmd.date == 20260903, "应查最近交易日 20260903 而非今天"
|
||||
return expected
|
||||
return expected if cmd.date == 20260903 else []
|
||||
return []
|
||||
|
||||
with (
|
||||
@@ -507,10 +475,10 @@ def test_async_get_minute_time_data_preopen_falls_back_to_last_trade_day():
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
def test_async_get_minute_time_data_intraday_uses_live_cmd():
|
||||
"""异步客户端:盘中走实时分时命令。"""
|
||||
def test_async_get_minute_time_data_intraday_uses_history_today():
|
||||
"""异步客户端:盘中走历史分时接口查今天。"""
|
||||
day_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 4, 15, 0)]
|
||||
live = [MinuteBar(price=41.0, vol=100)]
|
||||
expected = [MinuteBar(price=44.2, vol=118)]
|
||||
|
||||
async def run_test() -> None:
|
||||
with patch("easy_tdx.client.AsyncTdxConnection"):
|
||||
@@ -519,9 +487,10 @@ def test_async_get_minute_time_data_intraday_uses_live_cmd():
|
||||
async def mock_execute(cmd):
|
||||
if isinstance(cmd, GetSecurityBarsCmd):
|
||||
return day_bars
|
||||
if isinstance(cmd, GetMinuteTimeDataCmd):
|
||||
return live
|
||||
raise AssertionError(f"盘中不应请求历史分时: {cmd}")
|
||||
if isinstance(cmd, GetHistoryMinuteTimeDataCmd):
|
||||
assert cmd.date == 20260904, "盘中应只查今天的历史分时"
|
||||
return expected
|
||||
return []
|
||||
|
||||
with (
|
||||
patch("easy_tdx.client._today_in_shanghai", return_value=20260904),
|
||||
@@ -529,6 +498,6 @@ def test_async_get_minute_time_data_intraday_uses_live_cmd():
|
||||
):
|
||||
result = await client.get_minute_time_data(Market.SH, "600000")
|
||||
|
||||
assert result["price"].iloc[0] == 41.0
|
||||
assert result["price"].iloc[0] == 44.2
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
Reference in New Issue
Block a user