mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 15:44:15 +08:00
fix(client): get_history_fund_flow 加空数据故障转移(issue #41)
用户反馈 get_history_fund_flow(SH, "600519") 返回空 DataFrame,日志显示 "K线响应为空(声称 800 条但首条即解析失败...)"。排查定位:当前 host 对 常见标的也返回 ret_count 撒谎的空 body,但资金流兼容回退路径(直连空 → 拉 K 线 + 历史逐笔重算)未接入 v1.20.4 的空数据故障转移,"服务器回包正常 但内容是假的空" 既非 TdxConnectionError 也不触发换台,用户卡在坏服务器上。 修复:get_history_fund_flow(sync+async)当前 host 直连与 K 线回退均空时, 按延迟顺序逐台实测找首台返回有效数据的服务器(与 get_security_bars/ get_index_bars 同源逻辑)。因资金流获取涉及多命令,无法用单 cmd 复用泛化版 _find_host_returning_data,故内联 _fund_flow_failover:每台候选上跑完整 _fetch_fund_flow_records(原直连+K线回退逻辑抽出成独立方法),返回首台非空 结果。全空返回空 DataFrame(不 raise,区分"真无历史数据"与"服务器缺数据")。 测试:新增 6 个(4 sync + 2 async)。全套 989 passed;ruff/mypy 改动文件零错误。
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -27,6 +28,7 @@ from easy_tdx.commands.security_count import GetSecurityCountCmd
|
||||
from easy_tdx.exceptions import TdxConnectionError
|
||||
from easy_tdx.models.bar import SecurityBar
|
||||
from easy_tdx.models.enums import KlineCategory, Market
|
||||
from easy_tdx.models.stats import HistoricalFundFlow
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -588,3 +590,192 @@ class TestBarsEmptyFailover:
|
||||
|
||||
assert mock_exec.call_count == 4
|
||||
assert len(df) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# get_history_fund_flow 空数据故障转移(Issue #41)
|
||||
# 部分服务器对常见标的(如 600519)也返回 ret_count 撒谎的空 body,此前直接返回
|
||||
# 空 DataFrame;v1.20.5 接入与 K 线同源的空数据故障转移,逐台实测找有效 host。
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestFundFlowEmptyFailover:
|
||||
"""资金流空数据故障转移——验证 get_history_fund_flow 空时逐台实测切 host。"""
|
||||
|
||||
def _make_flow(self) -> HistoricalFundFlow:
|
||||
"""构造一条字段合法的历史资金流,让 _to_df 下游处理走通。"""
|
||||
return HistoricalFundFlow(
|
||||
year=2026,
|
||||
month=7,
|
||||
day=10,
|
||||
super_in=1.0,
|
||||
super_out=0.0,
|
||||
large_in=2.0,
|
||||
large_out=0.0,
|
||||
medium_in=3.0,
|
||||
medium_out=0.0,
|
||||
small_in=4.0,
|
||||
small_out=0.0,
|
||||
)
|
||||
|
||||
def test_empty_fund_flow_finds_working_host_and_returns_data(self) -> None:
|
||||
"""当前 host 空 → 逐台实测 → hostB 返回数据,停在该 host。"""
|
||||
flow = self._make_flow()
|
||||
client = TdxClient("bad-host", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
|
||||
|
||||
# _fetch_fund_flow_records 调用序列:
|
||||
# 1. 首次(bad-host)→ 空
|
||||
# 2. 验证 hostA → 空
|
||||
# 3. 验证 hostB → 非空(命中)
|
||||
# 4. 最终再取一次(停在 hostB)→ 非空
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"_fetch_fund_flow_records",
|
||||
side_effect=[[], [], [flow], [flow]],
|
||||
) as mock_fetch,
|
||||
patch.object(client, "_reconnect") as mock_reconnect,
|
||||
patch(
|
||||
"easy_tdx.client.ping_all",
|
||||
return_value=[("hostA", 0.01), ("hostB", 0.02)],
|
||||
),
|
||||
):
|
||||
df = client.get_history_fund_flow(Market.SH, "600519", 0, 10)
|
||||
|
||||
assert mock_fetch.call_count == 4
|
||||
# _reconnect 切换到 hostA、hostB(逐台实测),最终停在 hostB
|
||||
reconnect_hosts = [c.args[0] for c in mock_reconnect.call_args_list]
|
||||
assert reconnect_hosts == ["hostA", "hostB"]
|
||||
assert len(df) == 1
|
||||
|
||||
def test_empty_fund_flow_all_candidates_empty_returns_empty_df(self) -> None:
|
||||
"""所有候选都返回空时,返回空 DataFrame(不抛异常)。
|
||||
|
||||
与 get_index_bars / get_security_bars 同源逻辑:真·无数据时换台仍为空,
|
||||
返回空而非 raise。``_reconnect`` 被 mock 不更新 ``self._host``,故不会
|
||||
再切回 bad-host(与 K 线测试断言一致)。
|
||||
"""
|
||||
client = TdxClient("bad-host", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
|
||||
|
||||
with (
|
||||
patch.object(client, "_fetch_fund_flow_records", return_value=[]),
|
||||
patch.object(client, "_reconnect") as mock_reconnect,
|
||||
patch(
|
||||
"easy_tdx.client.ping_all",
|
||||
return_value=[("hostA", 0.01), ("hostB", 0.02)],
|
||||
),
|
||||
):
|
||||
df = client.get_history_fund_flow(Market.SH, "600519", 0, 10)
|
||||
|
||||
# find_working_host 逐台实测了 hostA、hostB(_reconnect 被各调一次)
|
||||
reconnect_hosts = [c.args[0] for c in mock_reconnect.call_args_list]
|
||||
assert reconnect_hosts == ["hostA", "hostB"]
|
||||
assert df.empty
|
||||
|
||||
def test_non_empty_fund_flow_does_not_trigger_failover(self) -> None:
|
||||
"""首次即返回数据时,不触发空数据故障转移。"""
|
||||
flow = self._make_flow()
|
||||
client = TdxClient("good-host", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0)
|
||||
|
||||
with (
|
||||
patch.object(client, "_fetch_fund_flow_records", return_value=[flow]) as mock_fetch,
|
||||
patch.object(client, "_fund_flow_failover") as mock_failover,
|
||||
):
|
||||
df = client.get_history_fund_flow(Market.SH, "600519", 0, 10)
|
||||
|
||||
assert mock_fetch.call_count == 1
|
||||
mock_failover.assert_not_called()
|
||||
assert len(df) == 1
|
||||
|
||||
def test_failover_disabled_when_auto_reconnect_off(self) -> None:
|
||||
"""auto_reconnect=False 时空数据不触发故障转移。"""
|
||||
client = TdxClient("bad-host", 7709, 1.0, auto_reconnect=False, heartbeat_interval=0)
|
||||
|
||||
with (
|
||||
patch.object(client, "_fetch_fund_flow_records", return_value=[]) as mock_fetch,
|
||||
patch.object(client, "_fund_flow_failover") as mock_failover,
|
||||
):
|
||||
df = client.get_history_fund_flow(Market.SH, "600519", 0, 10)
|
||||
|
||||
assert mock_fetch.call_count == 1
|
||||
mock_failover.assert_not_called()
|
||||
assert df.empty
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# AsyncTdxClient.get_history_fund_flow 空数据故障转移(Issue #41,async 对称)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAsyncFundFlowEmptyFailover:
|
||||
"""资金流空数据故障转移——async 版与 sync 语义对称。"""
|
||||
|
||||
def _make_flow(self) -> HistoricalFundFlow:
|
||||
return HistoricalFundFlow(
|
||||
year=2026,
|
||||
month=7,
|
||||
day=10,
|
||||
super_in=1.0,
|
||||
super_out=0.0,
|
||||
large_in=2.0,
|
||||
large_out=0.0,
|
||||
medium_in=3.0,
|
||||
medium_out=0.0,
|
||||
small_in=4.0,
|
||||
small_out=0.0,
|
||||
)
|
||||
|
||||
def test_async_empty_fund_flow_finds_working_host(self) -> None:
|
||||
"""当前 host 空 → 逐台实测 → hostB 返回数据。"""
|
||||
from easy_tdx.client import AsyncTdxClient
|
||||
|
||||
flow = self._make_flow()
|
||||
|
||||
async def main() -> int:
|
||||
client = AsyncTdxClient(
|
||||
"bad-host", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"_fetch_fund_flow_records",
|
||||
side_effect=[[], [], [flow], [flow]],
|
||||
) as mock_fetch,
|
||||
patch.object(client, "_areconnect") as mock_reconnect,
|
||||
patch(
|
||||
"easy_tdx.client.ping_all",
|
||||
return_value=[("hostA", 0.01), ("hostB", 0.02)],
|
||||
),
|
||||
):
|
||||
df = await client.get_history_fund_flow(Market.SH, "600519", 0, 10)
|
||||
|
||||
assert mock_fetch.call_count == 4
|
||||
reconnect_hosts = [c.args[0] for c in mock_reconnect.call_args_list]
|
||||
assert reconnect_hosts == ["hostA", "hostB"]
|
||||
return len(df)
|
||||
|
||||
assert asyncio.run(main()) == 1
|
||||
|
||||
def test_async_non_empty_fund_flow_does_not_trigger_failover(self) -> None:
|
||||
"""首次即返回数据时,不触发空数据故障转移。"""
|
||||
from easy_tdx.client import AsyncTdxClient
|
||||
|
||||
flow = self._make_flow()
|
||||
|
||||
async def main() -> int:
|
||||
client = AsyncTdxClient(
|
||||
"good-host", 7709, 1.0, auto_reconnect=True, heartbeat_interval=0
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
client, "_fetch_fund_flow_records", return_value=[flow]
|
||||
) as mock_fetch,
|
||||
patch.object(client, "_fund_flow_failover") as mock_failover,
|
||||
):
|
||||
df = await client.get_history_fund_flow(Market.SH, "600519", 0, 10)
|
||||
|
||||
assert mock_fetch.call_count == 1
|
||||
mock_failover.assert_not_called()
|
||||
return len(df)
|
||||
|
||||
assert asyncio.run(main()) == 1
|
||||
|
||||
Reference in New Issue
Block a user