Files
easy_tdx_max/tests/unit/test_mac_qfq_integration.py
T
Justin Gu c15bd8232f release: v1.17.2 — QFQ 深层历史负价修复
通达信服务端 QFQ 模式对长期重度除权股票(如 601088)深层历史页
返回负价格,导致回测总收益 -3087%、回撤 326.85%、年化 nan、
bollinger 崩溃、10 策略 invalid-value-in-scalar-power、MyTT divide-by-zero。

客户端兜底:检测 QFQ 负价时用 NONE+XDXR 本地重算前复权
(因子以除权日前一交易日含权收盘价为基准,保证除权日前后连续)。
同步+异步双路径一致修复,失败降级返回原值。

- 新增 src/easy_tdx/mac/adjust.py(纯函数 compute_forward_factor/
  apply_forward_adjust/has_bad_prices)
- MacClient/AsyncMacClient 触发本地重算,XDXR 按 (market,code) 缓存
- tests: +20 例(16 纯函数 + 4 集成),844 全绿,ruff/mypy 通过
2026-07-03 22:42:04 +08:00

161 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""QFQ 本地重算的集成测试(monkeypatch,无 live server)。
验证 ``MacClient.get_stock_kline(adjust=QFQ)`` 在服务端返回负价时:
1. 触发 NONE 重抓 + XDXR 本地重算;
2. 结果全部为正、OHLC 同比缩放;
3. XDXR 取不到时降级返回原始(含负价)数据,不抛异常。
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import patch
import pandas as pd
from easy_tdx.mac.client import MacClient
from easy_tdx.mac.commands.symbol_bar import SymbolBarCmd
from easy_tdx.mac.enums import Adjust, Period
from easy_tdx.mac.models import MacBar
def _bar(dt: str, close: float, fq: Adjust = Adjust.NONE) -> MacBar:
"""构造单根 MacBarOHLC 全等于 close。"""
d = datetime.fromisoformat(dt)
return MacBar(
datetime=d, open=close, high=close, low=close, close=close, vol=100.0, amount=1000.0
)
def _none_bars() -> list[MacBar]:
"""干净的 NONE 序列:除权日前 close=10,除权日 close=8(跌去 2 元分红),之后 9。"""
return [
_bar("2024-01-01", 10.0),
_bar("2024-01-02", 10.0), # cum-div
_bar("2024-01-03", 8.0), # ex-date
_bar("2024-01-04", 9.0),
]
def _qfq_broken_bars() -> list[MacBar]:
"""模拟服务端 QFQ 异常:除权日及之前返回负价。"""
return [
_bar("2024-01-01", -4.0, Adjust.QFQ),
_bar("2024-01-02", -4.0, Adjust.QFQ),
_bar("2024-01-03", 8.0, Adjust.QFQ),
_bar("2024-01-04", 9.0, Adjust.QFQ),
]
def _xdxr_df() -> pd.DataFrame:
"""单条除权除息记录:fenhong=2.0(除权日 2024-01-03)。"""
return pd.DataFrame(
[
{
"date": "2024-01-03",
"category": 1,
"fenhong": 2.0,
"peigujia": None,
"songzhuangu": None,
"peigu": None,
}
]
)
def _make_client() -> MacClient:
"""构造未连接的 MacClient(仅用于调用 _execute mock 路径)。"""
client = MacClient.__new__(MacClient)
client._xdxr_cache = {}
client._timeout = 10.0
return client
def test_qfq_negative_triggers_local_recompute():
"""服务端 QFQ 返回负价 → 用 NONE+XDXR 重算,结果全正。"""
client = _make_client()
def fake_execute(cmd: SymbolBarCmd) -> list[MacBar]:
return _qfq_broken_bars() if cmd._fq == Adjust.QFQ else _none_bars()
with patch.object(client, "_execute", side_effect=fake_execute), patch(
"easy_tdx.client.TdxClient"
) as MockTdx:
# 让 TdxClient 上下文返回手构 XDXR
mock_inst = MockTdx.from_best_host.return_value.__enter__.return_value
mock_inst.get_xdxr_info.return_value = _xdxr_df()
df = client.get_stock_kline(
market=1, code="601088", period=Period.DAILY, start=0, count=4, adjust=Adjust.QFQ,
)
# 重算后全部为正
assert (df["close"] > 0).all(), df["close"].tolist()
# f=(10-2)/10=0.8 → 除权日前两根 *= 0.8 = 8.0ex-date 及之后不动
assert df["close"].tolist() == [8.0, 8.0, 8.0, 9.0]
# OHLC 同比缩放(open 也应被缩放)
assert df["open"].tolist() == [8.0, 8.0, 8.0, 9.0]
def test_qfq_clean_does_not_trigger_recompute():
"""服务端 QFQ 正常(无负价)→ 不触发重算,原样返回。"""
client = _make_client()
clean_qfq = [
_bar("2024-01-01", 8.0, Adjust.QFQ),
_bar("2024-01-02", 8.0, Adjust.QFQ),
_bar("2024-01-03", 8.0, Adjust.QFQ),
_bar("2024-01-04", 9.0, Adjust.QFQ),
]
with patch.object(client, "_execute", return_value=clean_qfq) as mock_exec, patch(
"easy_tdx.client.TdxClient"
) as MockTdx:
df = client.get_stock_kline(
market=1, code="601088", period=Period.DAILY, start=0, count=4, adjust=Adjust.QFQ,
)
# QFQ 干净时不应再去拉 XDXR
MockTdx.from_best_host.assert_not_called()
assert df["close"].tolist() == [8.0, 8.0, 8.0, 9.0]
# 只拉了一次(QFQ),没有第二次拉 NONE
assert mock_exec.call_count == 1
def test_qfq_recompute_xdxr_failure_degrades_gracefully():
"""XDXR 取不到 → 降级返回 NONE 数据(不再含负价),不抛异常。"""
client = _make_client()
def fake_execute(cmd: SymbolBarCmd) -> list[MacBar]:
return _qfq_broken_bars() if cmd._fq == Adjust.QFQ else _none_bars()
with patch.object(client, "_execute", side_effect=fake_execute), patch(
"easy_tdx.client.TdxClient"
) as MockTdx:
# XDXR 抛异常 → _fetch_xdxr_records 返回 None → 降级
mock_inst = MockTdx.from_best_host.return_value.__enter__.return_value
mock_inst.get_xdxr_info.side_effect = RuntimeError("host unreachable")
df = client.get_stock_kline(
market=1, code="601088", period=Period.DAILY, start=0, count=4, adjust=Adjust.QFQ,
)
# 降级:返回 NONE 数据(apply_forward_adjust 因 xd=None 原样返回 df
# df 是 NONE 重抓结果(全正),但未做前复权
assert (df["close"] > 0).all()
assert df["close"].tolist() == [10.0, 10.0, 8.0, 9.0]
def test_none_adjust_skips_recompute():
"""adjust=NONE 时完全跳过 QFQ 重算逻辑。"""
client = _make_client()
with patch.object(client, "_execute", return_value=_none_bars()) as mock_exec, patch(
"easy_tdx.client.TdxClient"
) as MockTdx:
df = client.get_stock_kline(
market=1, code="601088", period=Period.DAILY, start=0, count=4, adjust=Adjust.NONE,
)
MockTdx.from_best_host.assert_not_called()
assert df["close"].tolist() == [10.0, 10.0, 8.0, 9.0]
assert mock_exec.call_count == 1