mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
release: v1.31.2 — 修复分时接口盘前/休市/指数返回空(最近交易日锚定+指数K线适配)
This commit is contained in:
@@ -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 双端同步。
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+70
-2
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user