From 9d2b0397bd02054f9dbcc541aa0adb36e83a99a6 Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Fri, 4 Sep 2026 04:40:50 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.31.2=20=E2=80=94=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=88=86=E6=97=B6=E6=8E=A5=E5=8F=A3=E7=9B=98=E5=89=8D?= =?UTF-8?q?/=E4=BC=91=E5=B8=82/=E6=8C=87=E6=95=B0=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E7=A9=BA=EF=BC=88=E6=9C=80=E8=BF=91=E4=BA=A4=E6=98=93=E6=97=A5?= =?UTF-8?q?=E9=94=9A=E5=AE=9A+=E6=8C=87=E6=95=B0K=E7=BA=BF=E9=80=82?= =?UTF-8?q?=E9=85=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 ++ pyproject.toml | 2 +- src/easy_tdx/client.py | 72 +++++++++- src/easy_tdx/web/routers/bars.py | 2 +- tests/unit/test_a_share_extensions.py | 195 ++++++++++++++++++++++++-- 5 files changed, 267 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ecf06..7355dba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.31.2] — 2026-09-04 + +**分时接口全场景修复:盘前/休市不再为空,指数从无到有**——`/api/v1/minute`(今日分时)此前固定用"今天的日期"走历史分时接口,而历史分时的当日数据要收盘后才生成:盘前、周末、节假日调用必然拿到 `{"data":[],"count":0}`,WebUI 分时图一片空白;指数(上证指数 000001、创业板指 399006、880 板块指数等)则**任何时候都为空**——旧实现从未适配指数。本版以「最新一根日 K 的日期」锚定最近交易日,个股与指数全场景有数。 + +### 修复 + +- **盘前/周末/节假日分时为空**([client.py](src/easy_tdx/client.py) `get_minute_time_data`,同步+异步双口径):先取最新日 K 日期锚定最近交易日——等于今天(盘中/收盘后)走实时分时命令 `GetMinuteTimeDataCmd`(协议层早有此命令但 client 从未使用),早于今天(盘前/休市)自动回退查该日的历史分时。分时图任何时候打开都显示最近交易日的完整 240 条,9:30 开盘后自动切换为当日实时分时;无需维护节假日表(交易日由服务器日 K 事实决定)。 +- **指数分时始终为空**:指数的 K 线响应每条比个股多 4 字节,锚点查询用个股 K 线命令解析指数数据会错位出乱码日期(实测解析出 116785687)。`_latest_trade_date` 现在先按个股命令查询并校验日期落位 `[19900101, 今天]`,不合法自动换指数 K 线命令 `GetIndexBarsCmd` 重查——不依赖代码前缀规则,个股/指数/880 板块指数全部自适应。 +- **盘前占位脏数据防御**:实时分时接口在盘前会返回 240 条价格从 0 累加的占位数据,识别(首条价格 0)后不采用,避免脏数据进图。 + +### 测试 + +- 分时单测重写+扩充为 8 例(同步/异步双口径):盘前回退最近交易日、盘中走实时命令、占位数据防御、无日 K 兜底(维持旧契约)、指数乱码日期→换指数 K 线命令锚定。 +- 实测三类真实验证:个股 301008 / 指数 000001・399006・880958 盘前均返回最近交易日(2026-09-03)240 条,分时尾价与日 K 收盘价一致。 + ## [1.31.1] — 2026-09-04 **策略库「重跑到今天」补齐组合分析**——v1.31.0 把组合分析链路补到了多标的组合页(/portfolio),但策略库(/strategies)里 `kind: 'multi'` 的**多策略组合**卡片点「↻ 重跑到今天」仍只跑主回测,没有 Walk-Forward、一条龙和 AI 解读;且结果区「绩效指标」表只透传 19 项老指标,v1.28 的深度 6 项(SQN/最大连胜连亏/Ulcer/VaR/CVaR)被丢弃。本版把分析链路延伸到多策略组合(N 策略 × 各自原标的),WebUI/REST 双端同步。 diff --git a/pyproject.toml b/pyproject.toml index 6d2c1b7..5241335 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.31.1" +version = "1.31.2" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/client.py b/src/easy_tdx/client.py index a82e7de..c6f679f 100644 --- a/src/easy_tdx/client.py +++ b/src/easy_tdx/client.py @@ -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 +from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd from .commands.report_file import GetReportFileCmd from .commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd from .commands.security_count import GetSecurityCountCmd @@ -608,11 +608,52 @@ class TdxClient: # ------------------------------------------------------------------ # def get_minute_time_data(self, market: Market, code: str) -> pd.DataFrame: - """获取今日分时数据(240条,走历史分时接口)。""" + """获取最近交易日分时数据(盘中=今日实时分时;盘前/休市=最近交易日历史分时)。 + + 历史分时接口对当日返回空(数据收盘后才生成),实时分时接口在盘前 + 返回价格自 0 累加的占位数据——单纯用"今天"查任一接口在盘前/周末/ + 节假日都会拿到空/脏数据。这里以最新一根日 K 的日期锚定最近交易日: + 等于今天(盘中/收盘后)走实时分时命令;早于今天则查该日的历史分时。 + """ 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)) + 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) + def _latest_trade_date(self, market: Market, code: str) -> int | None: + """最新一根日 K 的日期(YYYYMMDD),无日 K 数据(如未上市新股)返回 None。 + + 指数/板块指数必须走指数 K 线命令(响应每条多 4 字节),用个股命令解析 + 会得到乱码日期(如 116785687)。先按个股命令查询并校验日期落位 + [19900101, 今天],不合法再换指数命令重查,避免依赖代码前缀规则。 + """ + today = _today_in_shanghai() + for cmd in ( + GetSecurityBarsCmd(market, code, KlineCategory.DAY, 0, 2), + GetIndexBarsCmd(market, code, KlineCategory.DAY, 0, 2), + ): + bars = self._execute(cmd) + # 空数据故障转移:与 get_security_bars 同源(部分服务器对 K 线返回空 body; + # 指数/880 板块指数也并非所有服务器都提供) + if not bars and self._auto_reconnect: + bars = self._find_host_returning_data(cmd) + if not bars: + continue + d = bars[-1].year * 10000 + bars[-1].month * 100 + bars[-1].day + if 19900101 <= d <= today: + return d + return None + def get_history_minute_time_data(self, market: Market, code: str, date: int) -> pd.DataFrame: """获取历史某日分时数据(date: YYYYMMDD)。""" bars = self._execute(GetHistoryMinuteTimeDataCmd(market, code, date)) @@ -1338,10 +1379,37 @@ class AsyncTdxClient(AsyncHeartbeatMixin): return _merge_bar_datetime(df, not is_intraday) async def get_minute_time_data(self, market: Market, code: str) -> pd.DataFrame: + """获取最近交易日分时数据,语义见同步版 :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)) + 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) + async def _latest_trade_date(self, market: Market, code: str) -> int | None: + """最新一根日 K 的日期(YYYYMMDD),语义见同步版 :meth:`_latest_trade_date`。""" + today = _today_in_shanghai() + for cmd in ( + GetSecurityBarsCmd(market, code, KlineCategory.DAY, 0, 2), + GetIndexBarsCmd(market, code, KlineCategory.DAY, 0, 2), + ): + bars = await self._execute(cmd) + if not bars and self._auto_reconnect: + bars = await self._find_host_returning_data(cmd) + if not bars: + continue + d = bars[-1].year * 10000 + bars[-1].month * 100 + bars[-1].day + if 19900101 <= d <= today: + return d + return None + async def get_history_minute_time_data( self, market: Market, code: str, date: int ) -> pd.DataFrame: diff --git a/src/easy_tdx/web/routers/bars.py b/src/easy_tdx/web/routers/bars.py index d484118..4fe9c94 100644 --- a/src/easy_tdx/web/routers/bars.py +++ b/src/easy_tdx/web/routers/bars.py @@ -318,7 +318,7 @@ async def minute_time( code: str = Query(..., min_length=6, max_length=6), client: Any = Depends(get_client), ) -> DataFrameResponse: - """获取今日分时数据。""" + """获取最近交易日分时数据(盘中=今日实时分时;盘前/周末/节假日=最近交易日历史分时)。""" df = await client.get_minute_time_data(market_from_str(market), code) return _df_resp(df) diff --git a/tests/unit/test_a_share_extensions.py b/tests/unit/test_a_share_extensions.py index 2affdd4..6afcdc6 100644 --- a/tests/unit/test_a_share_extensions.py +++ b/tests/unit/test_a_share_extensions.py @@ -11,8 +11,9 @@ 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 GetSecurityBarsCmd +from easy_tdx.commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd from easy_tdx.commands.security_list import GetSecurityListCmd from easy_tdx.commands.security_quotes import GetSecurityQuotesCmd from easy_tdx.commands.transaction import ( @@ -320,50 +321,214 @@ def test_get_price_limits_uses_listing_window(_mock_conn_cls): @patch("easy_tdx.client.TdxConnection") -def test_get_minute_time_data_uses_history_endpoint(_mock_conn_cls): - """今日分时走历史分时接口。""" +def test_get_minute_time_data_preopen_falls_back_to_last_trade_day(_mock_conn_cls): + """盘前/周末/节假日:今日分时尚不存在,应回退查最近交易日(最新日K日期)的历史分时。""" client = TdxClient("127.0.0.1") - expected = [MinuteBar(price=9.7, vol=13694)] + 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, GetHistoryMinuteTimeDataCmd): + assert cmd.date == 20260903, "应查最近交易日 20260903 而非今天" return expected return [] with ( - patch("easy_tdx.client._today_in_shanghai", return_value=20260422), + patch("easy_tdx.client._today_in_shanghai", return_value=20260904), patch.object(TdxClient, "_execute", side_effect=mock_execute) as mock_exec, ): result = client.get_minute_time_data(Market.SH, "600000") assert isinstance(result, pd.DataFrame) - assert result["price"].iloc[0] == 9.7 - history_calls = [ - c for c in mock_exec.call_args_list if isinstance(c[0][0], GetHistoryMinuteTimeDataCmd) + 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 len(history_calls) == 1 + assert history_dates == [20260903] -def test_async_get_minute_time_data_uses_history_endpoint(): - """异步客户端走历史分时接口。""" - expected = [MinuteBar(price=9.7, vol=13694)] +@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 str(result["datetime"].iloc[0]).startswith("2026-09-04") + + +@patch("easy_tdx.client.TdxConnection") +def test_get_minute_time_data_live_placeholder_falls_back(_mock_conn_cls): + """防御:实时分时接口返回盘前占位数据(首条价格 0)时不应采用,回退历史分时。""" + 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)] + + 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 [] + + 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 + + +@patch("easy_tdx.client.TdxConnection") +def test_get_minute_time_data_no_daily_bars_keeps_old_contract(_mock_conn_cls): + """兜底:无日 K 数据(未上市新股等)时维持旧契约——查今日历史分时。""" + client = TdxClient("127.0.0.1") + + with ( + patch("easy_tdx.client._today_in_shanghai", return_value=20260904), + patch.object(TdxClient, "_execute", return_value=[]), + patch.object(TdxClient, "_find_host_returning_data", return_value=[]), + ): + result = client.get_minute_time_data(Market.SH, "600000") + + assert isinstance(result, pd.DataFrame) + assert result.empty + + +@patch("easy_tdx.client.TdxConnection") +def test_get_minute_time_data_index_anchor_uses_index_bars(_mock_conn_cls): + """指数:个股K线命令对指数返回乱码日期,锚点应换指数K线命令取最近交易日。""" + client = TdxClient("127.0.0.1") + garbage_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 11678, 5, 68, 15, 0)] + index_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 3, 15, 0)] + expected = [MinuteBar(price=3350.5, vol=100000)] + + def mock_execute(cmd): + if isinstance(cmd, GetIndexBarsCmd): # 子类必须先判,否则会被个股分支截胡 + return index_bars + if isinstance(cmd, GetSecurityBarsCmd): + return garbage_bars + if isinstance(cmd, GetHistoryMinuteTimeDataCmd): + assert cmd.date == 20260903, "应以指数K线锚定的 20260903 查历史分时" + return expected + return [] + + 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, "000001") + + assert result["price"].iloc[0] == 3350.5 + assert str(result["datetime"].iloc[0]).startswith("2026-09-03") + + +def test_async_get_minute_time_data_index_anchor_uses_index_bars(): + """异步客户端:指数锚点走指数K线命令。""" + garbage_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 11678, 5, 68, 15, 0)] + index_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 3, 15, 0)] + expected = [MinuteBar(price=3350.5, vol=100000)] async def run_test() -> None: with patch("easy_tdx.client.AsyncTdxConnection"): client = AsyncTdxClient("127.0.0.1") async def mock_execute(cmd): + if isinstance(cmd, GetIndexBarsCmd): + return index_bars + if isinstance(cmd, GetSecurityBarsCmd): + return garbage_bars if isinstance(cmd, GetHistoryMinuteTimeDataCmd): + assert cmd.date == 20260903 return expected return [] with ( - patch("easy_tdx.client._today_in_shanghai", return_value=20260422), + patch("easy_tdx.client._today_in_shanghai", return_value=20260904), + patch.object(AsyncTdxClient, "_execute", side_effect=mock_execute), + ): + result = await client.get_minute_time_data(Market.SH, "000001") + + assert result["price"].iloc[0] == 3350.5 + assert str(result["datetime"].iloc[0]).startswith("2026-09-03") + + asyncio.run(run_test()) + + +def test_async_get_minute_time_data_preopen_falls_back_to_last_trade_day(): + """异步客户端:盘前回退最近交易日历史分时。""" + day_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 3, 15, 0)] + expected = [MinuteBar(price=40.91, vol=3677)] + + async def run_test() -> None: + with patch("easy_tdx.client.AsyncTdxConnection"): + client = AsyncTdxClient("127.0.0.1") + + async def mock_execute(cmd): + if isinstance(cmd, GetSecurityBarsCmd): + return day_bars + if isinstance(cmd, GetHistoryMinuteTimeDataCmd): + assert cmd.date == 20260903, "应查最近交易日 20260903 而非今天" + return expected + return [] + + with ( + patch("easy_tdx.client._today_in_shanghai", return_value=20260904), patch.object(AsyncTdxClient, "_execute", side_effect=mock_execute), ): result = await client.get_minute_time_data(Market.SH, "600000") - assert isinstance(result, pd.DataFrame) - assert result["price"].iloc[0] == 9.7 + assert result["price"].iloc[0] == 40.91 + assert str(result["datetime"].iloc[0]).startswith("2026-09-03") + + asyncio.run(run_test()) + + +def test_async_get_minute_time_data_intraday_uses_live_cmd(): + """异步客户端:盘中走实时分时命令。""" + day_bars = [SecurityBar(10, 10, 10, 10, 100, 1000, 2026, 9, 4, 15, 0)] + live = [MinuteBar(price=41.0, vol=100)] + + async def run_test() -> None: + with patch("easy_tdx.client.AsyncTdxConnection"): + client = AsyncTdxClient("127.0.0.1") + + async 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(AsyncTdxClient, "_execute", side_effect=mock_execute), + ): + result = await client.get_minute_time_data(Market.SH, "600000") + + assert result["price"].iloc[0] == 41.0 asyncio.run(run_test())