mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 14:34:15 +08:00
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 通过
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""本地前复权(QFQ)重算纯函数的单元测试。
|
||||
|
||||
覆盖 ``easy_tdx.mac.adjust`` 的因子计算与 OHLC 缩放,重点验证:
|
||||
- 除权日前后价格连续(前复权的定义性性质);
|
||||
- 最新价锚定不动;
|
||||
- 无事件 / 非法因子时安全降级。
|
||||
|
||||
公式见 ``examples/06_finance/xdxr_info.py`` 与 ``src/easy_tdx/mac/adjust.py``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.mac.adjust import (
|
||||
apply_forward_adjust,
|
||||
compute_forward_factor,
|
||||
has_bad_prices,
|
||||
)
|
||||
|
||||
|
||||
def _kline(closes: list[float], start: str = "2024-01-01") -> pd.DataFrame:
|
||||
"""构造最小 NONE K 线:OHLC 全等于给定 close 序列,逐日递增。"""
|
||||
n = len(closes)
|
||||
dates = pd.date_range(start, periods=n, freq="D")
|
||||
arr = np.array(closes, dtype=float)
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"datetime": dates,
|
||||
"open": arr,
|
||||
"high": arr,
|
||||
"low": arr,
|
||||
"close": arr,
|
||||
"vol": [100.0] * n,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _xdxr_one(date: str, fenhong=0.0, peigujia=0.0, songzhuangu=0.0, peigu=0.0) -> pd.DataFrame:
|
||||
"""构造单条 category==1 除权除息记录。"""
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"date": date,
|
||||
"category": 1,
|
||||
"fenhong": fenhong,
|
||||
"peigujia": peigujia,
|
||||
"songzhuangu": songzhuangu,
|
||||
"peigu": peigu,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# compute_forward_factor
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_factor_pure_cash_dividend():
|
||||
"""纯现金分红:f = (P - fh) / P。"""
|
||||
# P=10, fh=2 → f=0.8(前复权把含权价缩到除权后等价)
|
||||
assert compute_forward_factor(10.0, 2.0, 0.0, 0.0, 0.0) == 0.8
|
||||
|
||||
|
||||
def test_factor_songzhuangu_split():
|
||||
"""10 送 10(songzhuangu=1.0):f = P / (P*2) = 0.5。"""
|
||||
assert compute_forward_factor(10.0, 0.0, 0.0, 1.0, 0.0) == 0.5
|
||||
|
||||
|
||||
def test_factor_zero_cum_close_is_nan():
|
||||
"""cum_close<=0 返回 NaN(不抛)。"""
|
||||
assert np.isnan(compute_forward_factor(0.0, 1.0, 0.0, 0.0, 0.0))
|
||||
assert np.isnan(compute_forward_factor(-1.0, 1.0, 0.0, 0.0, 0.0))
|
||||
|
||||
|
||||
def test_factor_zero_denominator_is_nan():
|
||||
"""分母 = P*(1+s+p) 为 0 时返回 NaN。"""
|
||||
# P=10, s=-1, p=0 → denom=0
|
||||
assert np.isnan(compute_forward_factor(10.0, 0.0, 0.0, -1.0, 0.0))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# apply_forward_adjust
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_apply_pure_cash_dividend_scales_pre_event_only():
|
||||
"""纯分红:除权日及之前价格乘 f,除权日之后不动。"""
|
||||
# 3 根:cum-div close=10(含权),ex-date close=8(跌去 2 元分红),之后 9
|
||||
# 事件在 day2(ex-date),cum-div bar 是 day1
|
||||
df = _kline([10.0, 10.0, 8.0, 9.0])
|
||||
xd = _xdxr_one("2024-01-03", fenhong=2.0) # ex-date = 第 3 天
|
||||
out = apply_forward_adjust(df, xd)
|
||||
# f = (10-2)/10 = 0.8 → day1/day2 (cum 及之前) *= 0.8
|
||||
assert out["close"].tolist() == [8.0, 8.0, 8.0, 9.0]
|
||||
|
||||
|
||||
def test_apply_latest_price_anchored():
|
||||
"""最新价(最后一根)不被缩放,保持原值。"""
|
||||
df = _kline([20.0, 10.0, 11.0])
|
||||
xd = _xdxr_one("2024-01-02", fenhong=10.0) # ex-date=day2, cum-div=day1 close=20
|
||||
out = apply_forward_adjust(df, xd)
|
||||
# f=(20-10)/20=0.5 → day1*=0.5; day2/day3 不动
|
||||
assert out["close"].iloc[-1] == 11.0
|
||||
assert out["close"].iloc[0] == 10.0
|
||||
|
||||
|
||||
def test_apply_no_events_returns_unchanged():
|
||||
"""空 XDXR 或无 category==1 → 原样返回(值相等)。"""
|
||||
df = _kline([10.0, 11.0, 12.0])
|
||||
out = apply_forward_adjust(df, pd.DataFrame(columns=["date", "category"]))
|
||||
assert out["close"].tolist() == [10.0, 11.0, 12.0]
|
||||
|
||||
|
||||
def test_apply_empty_xdxr_df():
|
||||
"""XDXR 为 None 或 empty → 原样返回。"""
|
||||
df = _kline([10.0, 11.0])
|
||||
assert apply_forward_adjust(df, pd.DataFrame()).equals(df)
|
||||
assert apply_forward_adjust(df, None).equals(df) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_apply_nan_factor_event_skipped():
|
||||
"""事件对应 cum_close<=0(因子非法)→ 跳过该事件,不抛异常。"""
|
||||
# day1 close=0(非法 cum-div),事件在 day2
|
||||
df = _kline([0.0, 5.0, 6.0])
|
||||
xd = _xdxr_one("2024-01-02", fenhong=1.0)
|
||||
out = apply_forward_adjust(df, xd) # 不应抛
|
||||
# cum-div bar (day1) close=0 → 因子 NaN → 跳过,close 不变
|
||||
assert out["close"].tolist() == [0.0, 5.0, 6.0]
|
||||
|
||||
|
||||
def test_apply_multiple_events_cumulative():
|
||||
"""两次连续分红:因子累乘。"""
|
||||
# day1=20(cum1), day2 ex-date1, day3=12(cum2), day4 ex-date2, day5=10
|
||||
df = _kline([20.0, 18.0, 12.0, 10.0, 10.0])
|
||||
xd = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"date": "2024-01-02",
|
||||
"category": 1,
|
||||
"fenhong": 2.0,
|
||||
"peigujia": 0.0,
|
||||
"songzhuangu": 0.0,
|
||||
"peigu": 0.0,
|
||||
},
|
||||
{
|
||||
"date": "2024-01-04",
|
||||
"category": 1,
|
||||
"fenhong": 2.0,
|
||||
"peigujia": 0.0,
|
||||
"songzhuangu": 0.0,
|
||||
"peigu": 0.0,
|
||||
},
|
||||
]
|
||||
)
|
||||
out = apply_forward_adjust(df, xd)
|
||||
# event1: cum1=day1=20, f1=(20-2)/20=0.9 → day1*=0.9 → 18.0
|
||||
# event2: cum2=day3=12, f2=(12-2)/12=0.8333 → day1..day3 *= 0.8333
|
||||
# day1: 18.0 * 0.8333 = 15.0 ; day3: 12.0*0.8333=10.0
|
||||
# day4/day5 不动
|
||||
assert round(out["close"].iloc[0], 4) == 15.0
|
||||
assert round(out["close"].iloc[2], 4) == 10.0
|
||||
assert out["close"].iloc[3] == 10.0
|
||||
assert out["close"].iloc[4] == 10.0
|
||||
|
||||
|
||||
def test_apply_songzhuangu_halves_pre_event_prices():
|
||||
"""10 送 10:除权日前价格减半。"""
|
||||
df = _kline([20.0, 20.0, 10.0, 11.0])
|
||||
xd = _xdxr_one("2024-01-03", songzhuangu=1.0)
|
||||
out = apply_forward_adjust(df, xd)
|
||||
# cum-div=day2=20, f=20/(20*2)=0.5 → day1/day2 *= 0.5
|
||||
assert out["close"].tolist() == [10.0, 10.0, 10.0, 11.0]
|
||||
|
||||
|
||||
def test_apply_does_not_mutate_input():
|
||||
"""apply_forward_adjust 不就地修改输入 DataFrame。"""
|
||||
df = _kline([10.0, 10.0, 8.0])
|
||||
original = df["close"].tolist()
|
||||
xd = _xdxr_one("2024-01-03", fenhong=2.0)
|
||||
apply_forward_adjust(df, xd)
|
||||
assert df["close"].tolist() == original
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# has_bad_prices
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_has_bad_prices_detects_negative():
|
||||
df = pd.DataFrame({"open": [1.0], "high": [1.0], "low": [-0.5], "close": [1.0]})
|
||||
assert has_bad_prices(df) is True
|
||||
|
||||
|
||||
def test_has_bad_prices_detects_zero():
|
||||
df = pd.DataFrame({"open": [0.0], "high": [1.0], "low": [1.0], "close": [1.0]})
|
||||
assert has_bad_prices(df) is True
|
||||
|
||||
|
||||
def test_has_bad_prices_detects_nan():
|
||||
df = pd.DataFrame({"open": [1.0], "high": [1.0], "low": [1.0], "close": [float("nan")]})
|
||||
assert has_bad_prices(df) is True
|
||||
|
||||
|
||||
def test_has_bad_prices_clean_returns_false():
|
||||
df = pd.DataFrame({"open": [1.0], "high": [2.0], "low": [0.5], "close": [1.5]})
|
||||
assert has_bad_prices(df) is False
|
||||
@@ -0,0 +1,160 @@
|
||||
"""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:
|
||||
"""构造单根 MacBar,OHLC 全等于 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.0;ex-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
|
||||
Reference in New Issue
Block a user