fix(tdxquant): 区分除权空结果与服务端失败

在 SDK 丢失错误前检查原生除权响应,保留脱敏错误与重试语义;正常无事件返回空结果。事件或配价失败时中止同步并保留旧因子文件,避免被报告为成功的零行结果。补充 SDK 离线回放、数值与落盘回归测试。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-11-02 00:23:15 +08:00
parent 7bd290289a
commit 8796f2cd7c
4 changed files with 219 additions and 27 deletions
+38 -25
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import math
import os
import re
import shutil
@@ -282,29 +283,41 @@ class TdxQuantClient:
"""单标的除权事件, 每行 {Date 'YYYYMMDD', Type, Bonus, ShareBonus,
AllotPrice, Allotment} — 金额/比例均为**每 10 股**口径(Bonus=10派X元)。
"""
start = f"{start_d:%Y-%m-%d}" if start_d else ""
end = f"{end_d:%Y-%m-%d}" if end_d else ""
try:
out = self._tqs.get_divid_factors(code, start, end)
except Exception as exc: # SDK 内部异常类型不可枚举
raise TdxQuantError(f"get_divid_factors({code}): {exc}") from exc
if isinstance(out, list):
return out
# 环境装有 pandas 时 SDK 返回 DataFrame, 归一为 list[dict]。
# 注意 Date 会成为 DataFrame 索引(同 get_market_data 的日期索引惯例),
# reset_index 后若列名漂移, 把首列恢复为 'Date'。
to_dict = getattr(out, "to_dict", None)
reset_index = getattr(out, "reset_index", None)
if callable(to_dict) and callable(reset_index):
start = f"{start_d:%Y-%m-%d} 00:00:00" if start_d else ""
end = f"{end_d:%Y-%m-%d} 00:00:00" if end_d else ""
# SDK get_divid_factors 会把 _clean 拒绝的服务端错误和正常无事件
# 都变成空 DataFrame。与日K共用原生重试/错误检查,避免丢失原因;
# 使用同一 get_cw_data 端点及 SDK 1.2.2 的四列顺序,单位不变。
out = self._raw("get_cw_data", str(code).strip(), start, end)
if not isinstance(out, dict):
raise TdxQuantError(f"get_cw_data({code}) 返回异常结构: {type(out)!r}")
if not out:
return []
dates, values, types = out.get("Date"), out.get("Value"), out.get("Type", [])
if (not all(isinstance(v, list) for v in (dates, values, types))
or len(dates) != len(values)):
raise TdxQuantError(f"get_cw_data({code}) Date/Value/Type 列结构异常")
records = []
fields = ("Bonus", "AllotPrice", "ShareBonus", "Allotment")
for i, item in enumerate(values):
date_key = "".join(c for c in str(dates[i]).strip() if c.isdigit())[:8]
try:
records = reset_index().to_dict("records")
if isinstance(records, list) and records:
if "Date" not in records[0]:
first_key = next(iter(records[0]))
if first_key not in ("Type", "Bonus", "AllotPrice", "ShareBonus", "Allotment"):
for row in records:
row["Date"] = row.pop(first_key)
return records
except Exception: # noqa: BLE001 — 转换失败按结构异常处理
pass
raise TdxQuantError(f"get_divid_factors({code}) 返回异常结构: {type(out)!r}")
event_date = datetime.strptime(date_key, "%Y%m%d").date() if len(date_key) == 8 else None
except ValueError:
event_date = None
if event_date is None or (start_d and event_date < start_d) or (end_d and event_date > end_d):
continue # SDK 同样过滤无效日期及服务端未裁剪的窗口外事件。
if item is None:
item = []
if not isinstance(item, list):
raise TdxQuantError(f"get_cw_data({code}) Value[{i}] 结构异常")
padded = item + [0] * max(0, 4 - len(item))
try:
amounts = [float(v or 0) for v in padded[:4]]
except (TypeError, ValueError, OverflowError) as exc:
raise TdxQuantError(f"get_cw_data({code}) Value[{i}] 含无效数值") from exc
if not all(math.isfinite(v) for v in amounts):
raise TdxQuantError(f"get_cw_data({code}) Value[{i}] 含非有限数值")
records.append({"Date": date_key, "Type": types[i] if i < len(types) else "",
**dict(zip(fields, amounts, strict=True))})
return records
+4 -2
View File
@@ -586,7 +586,9 @@ class TdxQuantProvider:
events = client.divid_factors(symbol, start_d, end_d + timedelta(days=_ADJ_FUTURE_DAYS))
except TdxQuantError as e:
logger.warning("TdxQuant 除权事件 %s 拉取失败: %s", symbol, e)
events = []
# 失败不能当作「该股无除权」;由任务层标记失败,整轮不写入
# 不完整因子,也避免权限/额度异常后继续请求其余数千只。
raise
events = [ev for ev in events if _compact_date(ev.get("Date")) is not None]
if events:
events.sort(key=lambda ev: str(ev.get("Date")))
@@ -610,7 +612,7 @@ class TdxQuantProvider:
raw = client.his_dats(symbol, end_d, _bars_for_window((end_d - start_d).days))
except TdxQuantError as e:
logger.warning("TdxQuant 除权配价 %s 日K失败: %s", symbol, e)
return {}
raise
out: dict[date, float] = {}
dates = raw.get("Date") or []
closes = raw.get("Close") or []
+175
View File
@@ -0,0 +1,175 @@
"""Replay native responses through the installed SDK without loading its DLL.
The SDK turns both a valid empty result and a server error into empty pandas
frames. Exercise the actual SDK boundary, then provider math and persistence.
"""
from __future__ import annotations
import json
from datetime import date, datetime
from types import SimpleNamespace
import polars as pl
import pytest
from app.plugins.tdxquant import client as tc
from app.plugins.tdxquant import provider as tp
from app.services import kline_sync
sdk = pytest.importorskip("tdxaidata.tdxaidata")
EVENT = {"ErrorId": 0, "Date": ["20240619"], "Type": ["1"],
"Value": [["2", "5", "1", "2"]]}
AUTH_ERROR = {"ErrorId": -1, "Error": "TokenKey Insufficient Or Key Error TDX-test-secret"}
@pytest.fixture
def make_client(monkeypatch):
monkeypatch.setattr(tc.time, "sleep", lambda _: None)
monkeypatch.setattr(tp, "_HIST_INTERVAL_S", 0)
def make(responses, *, close_response=None):
responses = list(responses)
calls = []
def events(*args):
calls.append(args)
value = responses.pop(0) if len(responses) > 1 else responses[0]
return json.dumps(value)
closes = close_response if close_response is not None else {
"ErrorId": 0, "Date": ["20240618", "20240619"], "Close": ["10", "8.35"],
}
native = SimpleNamespace(get_cw_data=events, get_his_dats=lambda *a: json.dumps(closes))
class ReplaySDK(sdk.tqs):
@classmethod
def _tdx(cls):
return native
client = object.__new__(tc.TdxQuantClient)
client.api_key = "TDX-test-secret"
client._tqs = ReplaySDK
client._native_cls = SimpleNamespace(get_instance=lambda: native)
return client, calls
return make
def test_event_values_and_dates_match_sdk_dataframe(make_client):
client, calls = make_client([EVENT])
expected = client._tqs.get_divid_factors("600000.SH", "2024-01-01", "2024-12-31")
assert not expected.empty # The normal nonempty DataFrame was already supported.
actual = client.divid_factors("600000.SH", date(2024, 1, 1), date(2024, 12, 31))
row = expected.reset_index().to_dict("records")[0]
row["Date"] = tp._compact_date(row["Date"])
actual[0]["Date"] = tp._compact_date(actual[0]["Date"])
assert actual == [row]
assert calls[-1] == ("600000.SH", "2024-01-01 00:00:00", "2024-12-31 00:00:00")
@pytest.mark.parametrize("payload", [{"ErrorId": 0}, {"ErrorId": 0, "Date": [], "Type": [], "Value": []}])
def test_valid_empty_sdk_frame_means_no_events(make_client, payload):
client, _ = make_client([payload])
assert client._tqs.get_divid_factors("000002.SZ").empty
assert client.divid_factors("000002.SZ") == []
def test_auth_failure_is_preserved_without_retry_or_secret_output(make_client, capsys):
client, calls = make_client([AUTH_ERROR])
with pytest.raises(tc.TdxQuantAuthError, match=r"凭据|额度") as caught:
client.divid_factors("000002.SZ")
assert len(calls) == 1
assert "TDX-test-secret" not in str(caught.value)
assert "TDX-test-secret" not in capsys.readouterr().out
def test_transient_server_error_retries_and_recovers(make_client):
client, calls = make_client([{"ErrorId": 500, "Error": "server busy"}, EVENT])
assert len(client.divid_factors("600000.SH")) == 1
assert len(calls) == 2
@pytest.mark.parametrize("payload", [None, [], {"unexpected": 1},
{"Date": ["20240619"], "Type": ["1"], "Value": []},
{"Date": ["20240619"], "Type": ["1"], "Value": [["invalid", 0, 0, 0]]},
{"Date": ["20240619"], "Type": ["1"], "Value": [["NaN", 0, 0, 0]]},
{"Date": ["20240619"], "Type": ["1"], "Value": [["Infinity", 0, 0, 0]]},
])
def test_malformed_response_remains_a_failure(make_client, payload):
client, _ = make_client([payload])
with pytest.raises(tc.TdxQuantError):
client.divid_factors("600000.SH")
def test_date_window_is_inclusive_and_empty_window_is_valid(make_client):
client, _ = make_client([EVENT])
assert len(client.divid_factors("600000.SH", date(2024, 6, 19), date(2024, 6, 19))) == 1
assert client.divid_factors("600000.SH", date(2024, 6, 20)) == []
def test_normalization_matches_sdk_without_pandas(make_client, monkeypatch):
monkeypatch.setattr(sdk, "pd", None)
client, _ = make_client([EVENT])
assert client.divid_factors("600000.SH") == client._tqs.get_divid_factors("600000.SH")
def route_to(client, monkeypatch):
from app.data_providers import custom
provider = tp.TdxQuantProvider()
provider._client = client
monkeypatch.setattr(kline_sync.preferences, "get_adj_factor_provider", lambda: "tdxquant")
monkeypatch.setattr(custom, "provider_has_dataset", lambda name, dataset: True)
monkeypatch.setattr(custom, "get_provider", lambda name: provider)
monkeypatch.setattr(kline_sync, "get_client", lambda: pytest.fail("不得跨源请求 TickFlow"))
return provider
def test_events_flow_to_parquet_with_existing_per_share_formula(make_client, monkeypatch, tmp_path):
client, _ = make_client([EVENT])
route_to(client, monkeypatch)
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
added, affected = kline_sync.sync_adj_factor(
["600000.SH"], repo, SimpleNamespace(has=lambda _: False),
start_time=datetime(2024, 1, 1), end_time=datetime(2024, 12, 31),
)
assert added == 1 and affected == ["600000.SH"]
row = pl.read_parquet(tmp_path / "adj_factor/all.parquet").row(0, named=True)
assert row["trade_date"] == date(2024, 6, 19)
assert row["dividend"] == pytest.approx(0.2)
assert row["bonus"] == pytest.approx(0.1)
assert row["allot"] == pytest.approx(0.2)
assert row["allot_price"] == 5.0
assert row["prev_close"] == 10.0
assert row["ex_factor"] == pytest.approx(10.0 / 8.31)
def test_partial_failure_keeps_existing_factors_and_stops_remaining_symbols(make_client, monkeypatch, tmp_path):
client, calls = make_client([EVENT, AUTH_ERROR])
route_to(client, monkeypatch)
path = tmp_path / "adj_factor/all.parquet"
path.parent.mkdir()
pl.DataFrame({"symbol": ["600000.SH"], "trade_date": [date(2023, 1, 1)], "ex_factor": [1.02]}).write_parquet(path)
before = path.read_bytes()
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
with pytest.raises(tc.TdxQuantAuthError):
kline_sync.sync_adj_factor(["600000.SH", "000002.SZ", "600519.SH"], repo, None,
end_time=datetime(2024, 12, 31))
assert len(calls) == 2
assert path.read_bytes() == before
def test_adj_dataset_probe_exposes_server_failure(make_client, monkeypatch):
client, calls = make_client([AUTH_ERROR])
provider = route_to(client, monkeypatch)
result = provider.test_dataset("adj_factor", ["000002.SZ"])
assert result["rows"] == 0 and "凭据" in result["error"]
assert len(calls) == 1
def test_price_lookup_failure_is_not_reported_as_successful_zero_rows(make_client, monkeypatch):
client, _ = make_client([EVENT], close_response=AUTH_ERROR)
provider = route_to(client, monkeypatch)
with pytest.raises(tc.TdxQuantAuthError):
provider.get_adj_factors(["600000.SH"], None, datetime(2024, 12, 31))
+2
View File
@@ -334,6 +334,8 @@ uv run --extra dev python -m ruff check app/plugins/<your_plugin>/ tests/test_<y
TdxQuant 在 SDK 清理响应前检查服务端错误, 保留错误原因并脱敏; 明确的凭据/额度拒绝不重试, 实时分钟轮立即熔断。历史分钟请求失败会通过 `app.data_providers.base.ProviderRequestError` 传递到任务层并标记失败, 不转换成成功的 0 行或跨源重试; 其他插件也可用该异常显式声明请求失败。带时区的分钟请求窗口在插件边界归一为北京墙钟, 再与原生分钟列比较。正常停牌、休市或窗口内无数据的空结果保留原语义。
除权事件也在原生 `get_cw_data` 响应上执行上述检查:SDK 1.2.2 的 `get_divid_factors` 会将服务端失败与正常无事件都转换为空 DataFrame,不能用 DataFrame 是否为空判断请求成功。插件按 SDK 的 `Date/Type/Value` 契约解析事件,保留每 10 股单位及闭区间日期筛选;正常无事件返回空列表,事件或配价请求失败则中止本轮、保留原有因子文件,并向任务层及数据源试拉返回明确错误。
## 路由机制(无需关心, 仅参考)
后端启动时, `loader.py` 扫描 `plugins/` 目录: