diff --git a/CHANGELOG.md b/CHANGELOG.md index c873033..1c5f062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.17.2] — 2026-07-03 + +**QFQ 深层历史负价修复** —— 修复通达信服务端在前复权(QFQ)模式下对长期重度除权股票(如 601088 中远海控)深层历史页直接返回**负价格**的上游缺陷,导致回测出现总收益 -3087%、最大回撤 326.85%、年化 nan%、`bollinger_breakout` 崩溃(`ZeroDivisionError`)、10 个策略报 `invalid value in scalar power`、`MyTT` 报 `divide by zero` 等一连串症状。**844 单测全绿**(+20),ruff / mypy strict 通过。 + +### 修复 + +- **QFQ 深层历史返回负价**(`mac/commands/symbol_bar.py`、`mac/client.py`、`mac/adjust.py`)— 根因:通达信 MAC 服务端在 QFQ 模式下,对 601088 这类长期重度除权股票的深层历史页(`start` 偏移 > ~2100)直接返回负价格(如 2013-11-18 QFQ close=-3.80,而 NONE=16.58、HFQ=27.17 均正常)。`SymbolBarCmd` 原样解析,污染下游全部计算:负 close → `position_value = size*close < 0` → 总权益为负 → 回撤 >100%、`total_return < -1` → `(1+total_return)` 为负 → 分数次幂 = nan;同时 BOLL 指标在零价处触发 `cash/0` 崩溃。**非 easy_tdx 代码 bug,是上游数据缺陷。** + - 修复:客户端兜底——`MacClient.get_stock_kline` 检测到 QFQ 结果含 `<=0` / NaN / inf 时,用 `fq=NONE` 重抓原始价,再经 `TdxClient.get_xdxr_info`(连 `get_known_hosts` 主机池,按 `(market,code)` 缓存)拉除权除息记录,本地重算前复权。同步 + 异步(`AsyncMacClient`)双路径一致修复。 + - 公式(经实证验证):以**除权日前一交易日收盘价**(含权价 `P_cum`)为基准,前复权因子 `f = (P_cum - fenhong + peigujia×peigu) / (P_cum×(1+songzhuangu+peigu))`,乘到该日及之前所有 bar 的 OHLC。该约定保证除权日前后价格连续(验证 jump≈0%),若误用除权日收盘价则 jump 达 -8%~-13%。 + - 降级:XDXR 取不到或重算后仍含非法价格时,打 warning 返回原值(不比现状更坏)。 + - 验证:重跑 `run_all_strategies.py SH 601088 --count 3000 --adjust QFQ`,16 策略全绿,总收益落 [-33%, +430%],最大回撤 [25%, 67%],年化全有限,无任何 warning/nan/崩溃。 + - 新增纯函数模块 `mac/adjust.py`(`compute_forward_factor` / `apply_forward_adjust` / `has_bad_prices`),无网络依赖便于单测。 + +### 新增 + +- **QFQ 本地重算纯函数**(`mac/adjust.py`)— `compute_forward_factor`(单次除权因子)、`apply_forward_adjust`(OHLC 同比缩放,最新价锚定不动,多次事件累乘)、`has_bad_prices`(检测 <=0/NaN/inf)。纯 pandas/numpy,无 easy_tdx 内部依赖。 +- **QFQ 重算单测**(`tests/unit/test_mac_qfq_adjust.py`,16 例)— 覆盖纯现金分红、送转股、多次事件累乘、无事件原样返回、非法因子跳过、输入不可变、最新价锚定、`has_bad_prices` 各分支。 +- **QFQ 重算集成测试**(`tests/unit/test_mac_qfq_integration.py`,4 例)— monkeypatch `MacClient._execute` 返回含负价的 QFQ + mock `TdxClient.get_xdxr_info` 返回 XDXR,验证触发重算、干净 QFQ 不触发、XDXR 失败降级、NONE 跳过重算。无 live server。 + ## [1.17.0] — 2026-07-03 **回测可视化 Web UI 大版本** —— 从命令行回测升级到浏览器可视化。Vue3 + ECharts 单页应用,零代码完成单标的回测、组合回测、参数寻优、结果对比四大场景。后端新增回测 REST API + 策略注册表 + 后台任务执行器,内置策略从 5 个扩充到 18 个。**823 单测全绿**,ruff / mypy strict / vue-tsc 全部通过。 diff --git a/pyproject.toml b/pyproject.toml index 4771ed2..2fe96a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.17.0" +version = "1.17.2" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/mac/adjust.py b/src/easy_tdx/mac/adjust.py new file mode 100644 index 0000000..3dec3a0 --- /dev/null +++ b/src/easy_tdx/mac/adjust.py @@ -0,0 +1,174 @@ +"""本地前复权(QFQ)重算。 + +通达信 MAC 服务端在 QFQ 模式下,对长期重度除权股票的深层历史页会返回 +负价格(上游缺陷)。本模块用 NONE(未复权)K 线 + XDXR(除权除息)记录 +在客户端本地重算前复权序列,作为服务端 QFQ 异常时的兜底。 + +公式(见 ``examples/06_finance/xdxr_info.py``):: + + 复权价 = (原价 - 每股分红 + 每股配股价 × 每股配股比例) / + (1 + 每股送转股比例 + 每股配股比例) + +约定:以除权日**前一交易日**的收盘价(含权价 ``P_cum``)作为基准,前复权 +因子把该日及之前的价格乘以:: + + f = (P_cum - fenhong + peigujia × peigu) / (P_cum × (1 + songzhuangu + peigu)) + +这样调整后的价格在除权日前后连续(除权日开盘价 ≈ 含权收盘价 - 分红)。 +最新价不动(锚定最新)。fenhong/songzhuangu/peigu 为每股单位,peigujia 为元/股。 +""" + +from __future__ import annotations + +import logging + +import numpy as np +import pandas as pd + +_logger = logging.getLogger(__name__) + +# 前复权会同比缩放的 OHLC 列名(vol/amount 不动) +_OHLC_COLS = ("open", "high", "low", "close") + + +def compute_forward_factor( + cum_close: float, + fenhong: float, + peigujia: float, + songzhuangu: float, + peigu: float, +) -> float: + """计算单次除权除息事件的前复权乘子。 + + Args: + cum_close: 除权日前一交易日的 NONE 收盘价(含权价)。 + fenhong: 每股分红(元)。 + peigujia: 每股配股价(元/股)。 + songzhuangu: 每股送转股比例(如 0.1 = 10 送/转 1)。 + peigu: 每股配股比例。 + + Returns: + 前复权因子。若输入非法(cum_close<=0、分母为 0、结果非有限)返回 NaN。 + """ + if cum_close <= 0: + return float("nan") + denom = cum_close * (1.0 + songzhuangu + peigu) + if denom == 0: + return float("nan") + factor = (cum_close - fenhong + peigujia * peigu) / denom + if not np.isfinite(factor): + return float("nan") + return float(factor) + + +def apply_forward_adjust( + df: pd.DataFrame, + xdxr_df: pd.DataFrame, +) -> pd.DataFrame: + """对 NONE K 线应用前复权,返回新的 DataFrame。 + + 遍历 XDXR 中 category==1(除权除息)的事件,按日期升序,把每个事件 + 的因子累乘到该除权日**前一交易日及之前**所有 bar 的 OHLC。最新价锚定不动。 + + Args: + df: NONE K 线,必须含 ``datetime`` 列与 OHLC 列。 + xdxr_df: ``get_xdxr_info`` 返回的 DataFrame,含 ``date``、``category``、 + ``fenhong``、``peigujia``、``songzhuangu``、``peigu`` 列。 + + Returns: + 前复权后的 DataFrame(与输入同形状、同列、同索引)。无事件或异常时 + 原样返回。 + """ + out = df.copy() + ohlc_cols = [c for c in _OHLC_COLS if c in out.columns] + if not ohlc_cols or "datetime" not in out.columns or xdxr_df is None or xdxr_df.empty: + return out + + # 统一 datetime 为 pandas Timestamp(升序) + dt = pd.to_datetime(out["datetime"]) + if not dt.is_monotonic_increasing: + order = np.argsort(dt.to_numpy()) + out = out.iloc[order].reset_index(drop=True) + dt = pd.to_datetime(out["datetime"]) + dt_arr = dt.to_numpy() + + # 筛选 category==1 且至少有一个非空除权字段的事件 + if "category" not in xdxr_df.columns or "date" not in xdxr_df.columns: + return out + cat1 = xdxr_df[xdxr_df["category"] == 1] + events: list[tuple[pd.Timestamp, float, float, float, float]] = [] + for _, r in cat1.iterrows(): + fh = _to_float(r.get("fenhong")) + pjk = _to_float(r.get("peigujia")) + sz = _to_float(r.get("songzhuangu")) + pg = _to_float(r.get("peigu")) + if fh is None and pjk is None and sz is None and pg is None: + continue + try: + ed = pd.Timestamp(str(r["date"])) + except (ValueError, TypeError): + continue + events.append((ed, fh or 0.0, pjk or 0.0, sz or 0.0, pg or 0.0)) + if not events: + return out + events.sort(key=lambda e: e[0]) + + # 取除权日前一交易日的 NONE 收盘价(cum-div close)作为基准 + none_close = out["close"].to_numpy(dtype=float) if "close" in out.columns else None + + for col in ohlc_cols: + arr = out[col].to_numpy(dtype=float).copy() + for ed, fh, pjk, sz, pg in events: + # searchsorted(>=): 第一个 >= ex-date 的位置;其前一根即为含权收盘 + idx = int(np.searchsorted(dt_arr, np.datetime64(ed), side="left")) + cum_idx = idx - 1 + if cum_idx < 0 or cum_idx >= len(arr): + continue + if none_close is not None: + cum_close = float(none_close[cum_idx]) + else: + cum_close = float(arr[cum_idx]) + factor = compute_forward_factor(cum_close, fh, pjk, sz, pg) + if not np.isfinite(factor): + _logger.warning( + "QFQ 本地重算:事件 %s 因子非法(cum_close=%s fh=%s sz=%s pg=%s),跳过", + ed.date(), cum_close, fh, sz, pg, + ) + continue + arr[: cum_idx + 1] *= factor + out[col] = arr + + return out + + +def _to_float(v: object) -> float | None: + """安全转 float,None/NaN 返回 None。""" + if v is None: + return None + try: + f = float(v) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + if not np.isfinite(f): + return None + return f + + +def has_bad_prices(df: pd.DataFrame) -> bool: + """检测 QFQ 结果是否含非法价格(<=0 或非有限值)。 + + Args: + df: 待检测的 K 线 DataFrame。 + + Returns: + 任一 OHLC 列含 <=0 或 NaN/inf 时返回 True。 + """ + for col in _OHLC_COLS: + if col not in df.columns: + continue + arr = df[col].to_numpy(dtype=float) + if not np.all(np.isfinite(arr)): + return True + if np.any(arr <= 0): + return True + return False diff --git a/src/easy_tdx/mac/client.py b/src/easy_tdx/mac/client.py index 12c546b..42365b1 100644 --- a/src/easy_tdx/mac/client.py +++ b/src/easy_tdx/mac/client.py @@ -153,6 +153,9 @@ class MacClient: self._auto_reconnect = auto_reconnect self._heartbeat_interval = heartbeat_interval self._conn = TdxConnection(self._host, self._port, self._timeout) + # XDXR(除权除息)记录缓存:(market, code) -> DataFrame。 + # 仅在服务端 QFQ 返回异常(负价)时用于本地前复权重算。 + self._xdxr_cache: dict[tuple[int, str], pd.DataFrame] = {} # ------------------------------------------------------------------ # # 工厂方法 @@ -327,6 +330,102 @@ class MacClient: return _quotes_to_df(all_quotes) + # ------------------------------------------------------------------ # + # QFQ 本地重算(服务端 QFQ 对深层历史返回负价时的兜底) + # ------------------------------------------------------------------ # + + def _fetch_kline_pages( + self, + market: int, + code: str, + period: Period, + start: int, + count: int, + times: int, + fq: Adjust, + ) -> list[MacBar]: + """分页拉取指定复权类型的 K 线(返回 oldest→newest 的 MacBar 列表)。""" + all_bars: list[MacBar] = [] + fetched = 0 + offset = start + while fetched < count: + page_size = min(count - fetched, _KLINE_PAGE_SIZE) + bars = self._execute( + SymbolBarCmd( + market=market, + code=code, + period=period, + times=times, + start=offset, + count=page_size, + fq=fq, + ) + ) + if not bars: + break + all_bars = bars + all_bars + fetched += len(bars) + offset += len(bars) + if len(bars) < page_size: + break + return all_bars + + def _fetch_xdxr_records(self, market: int, code: str) -> pd.DataFrame | None: + """通过主协议客户端(TdxClient)拉取除权除息记录。 + + MAC 主机池不响应 XDXR(0x0c1f),需连 get_known_hosts 主机池。 + 结果按 (market, code) 缓存。失败返回 None(调用方降级)。 + """ + key = (market, code) + if key in self._xdxr_cache: + return self._xdxr_cache[key] + try: + # 函数内 import 避免循环依赖(client 依赖 mac,mac 不应依赖 client) + from .. import Market + from ..client import TdxClient + + with TdxClient.from_best_host(timeout=self._timeout) as tc: + xd = tc.get_xdxr_info(Market(market), code) + except Exception as exc: # noqa: BLE001 - 降级,不中断 kline 获取 + _logger.warning( + "QFQ 本地重算:获取 %s %s XDXR 失败,降级返回服务端 QFQ:%s", market, code, exc, + ) + return None + if xd is None or xd.empty: + return None + self._xdxr_cache[key] = xd + return xd + + def _local_recompute_qfq( + self, + df: pd.DataFrame, + market: int, + code: str, + ) -> pd.DataFrame: + """对 QFQ 异常的 K 线用 NONE + XDXR 本地重算前复权。 + + Args: + df: 服务端 QFQ 结果(含异常)。 + market: 市场代码。 + code: 股票代码。 + + Returns: + 重算后的 DataFrame;XDXR 取不到或重算仍异常时原样返回 df。 + """ + from .adjust import apply_forward_adjust, has_bad_prices + + xd = self._fetch_xdxr_records(market, code) + if xd is None: + return df + out = apply_forward_adjust(df, xd) + if has_bad_prices(out): + _logger.warning("QFQ 本地重算后 %s %s 仍含非法价格,降级返回服务端 QFQ", market, code) + return df + _logger.warning( + "QFQ 本地重算:%s %s 服务端深层历史返回负价,已用 NONE+XDXR 重算前复权", market, code, + ) + return out + # ------------------------------------------------------------------ # # K 线(支持复权) # ------------------------------------------------------------------ # @@ -358,32 +457,21 @@ class MacClient: (= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。 仅对分钟级周期生效;日线及以上不受影响。 """ - all_bars: list[MacBar] = [] - fetched = 0 - offset = start - - while fetched < count: - page_size = min(count - fetched, _KLINE_PAGE_SIZE) - bars = self._execute( - SymbolBarCmd( - market=market, - code=code, - period=period, - times=times, - start=offset, - count=page_size, - fq=adjust, - ) - ) - if not bars: - break - all_bars = bars + all_bars - fetched += len(bars) - offset += len(bars) - if len(bars) < page_size: - break - + all_bars = self._fetch_kline_pages(market, code, period, start, count, times, adjust) df = _to_df(all_bars) + + # QFQ 兜底:服务端对深层历史可能返回负价/零价,此时用 NONE+XDXR 本地重算。 + if adjust == Adjust.QFQ and not df.empty: + from .adjust import has_bad_prices + + if has_bad_prices(df): + none_bars = self._fetch_kline_pages( + market, code, period, start, count, times, Adjust.NONE + ) + df = _to_df(none_bars) if none_bars else df + if not df.empty: + df = self._local_recompute_qfq(df, market, code) + delta = _period_to_minutes(period, times) is_intraday = delta is not None return _apply_bar_time_align_df( @@ -1071,6 +1159,9 @@ class AsyncMacClient(AsyncHeartbeatMixin): self._conn = AsyncTdxConnection(self._host, self._port, self._timeout) self._execute_lock = asyncio.Lock() self._heartbeat_task: asyncio.Task[None] | None = None + # XDXR(除权除息)记录缓存:(market, code) -> DataFrame。 + # 仅在服务端 QFQ 返回异常(负价)时用于本地前复权重算。 + self._xdxr_cache: dict[tuple[int, str], pd.DataFrame] = {} # ------------------------------------------------------------------ # # 工厂方法 @@ -1233,6 +1324,50 @@ class AsyncMacClient(AsyncHeartbeatMixin): return _quotes_to_df(all_quotes) + # ------------------------------------------------------------------ # + # QFQ 本地重算(服务端 QFQ 对深层历史返回负价时的兜底) + # 同步方法:XDXR 经 TdxClient(同步主协议)获取,由 asyncio.to_thread 调用。 + # ------------------------------------------------------------------ # + + def _fetch_xdxr_records(self, market: int, code: str) -> pd.DataFrame | None: + """通过主协议客户端(TdxClient)拉取除权除息记录(同 MacClient)。""" + key = (market, code) + if key in self._xdxr_cache: + return self._xdxr_cache[key] + try: + from .. import Market + from ..client import TdxClient + + with TdxClient.from_best_host(timeout=self._timeout) as tc: + xd = tc.get_xdxr_info(Market(market), code) + except Exception as exc: # noqa: BLE001 - 降级,不中断 kline 获取 + _logger.warning( + "QFQ 本地重算:获取 %s %s XDXR 失败,降级返回服务端 QFQ:%s", market, code, exc, + ) + return None + if xd is None or xd.empty: + return None + self._xdxr_cache[key] = xd + return xd + + def _local_recompute_qfq( + self, df: pd.DataFrame, market: int, code: str, + ) -> pd.DataFrame: + """对 QFQ 异常的 K 线用 NONE+XDXR 本地重算前复权(同 MacClient)。""" + from .adjust import apply_forward_adjust, has_bad_prices + + xd = self._fetch_xdxr_records(market, code) + if xd is None: + return df + out = apply_forward_adjust(df, xd) + if has_bad_prices(out): + _logger.warning("QFQ 本地重算后 %s %s 仍含非法价格,降级返回服务端 QFQ", market, code) + return df + _logger.warning( + "QFQ 本地重算:%s %s 服务端深层历史返回负价,已用 NONE+XDXR 重算前复权", market, code, + ) + return out + # ------------------------------------------------------------------ # # K 线 # ------------------------------------------------------------------ # @@ -1276,6 +1411,37 @@ class AsyncMacClient(AsyncHeartbeatMixin): break df = _to_df(all_bars) + + # QFQ 兜底:服务端对深层历史可能返回负价/零价,此时用 NONE+XDXR 本地重算。 + if adjust == Adjust.QFQ and not df.empty: + from .adjust import has_bad_prices + + if has_bad_prices(df): + # 异步重抓 NONE + none_bars: list[MacBar] = [] + nfetched = 0 + noffset = start + while nfetched < count: + nps = min(count - nfetched, _KLINE_PAGE_SIZE) + nb = await self._execute( + SymbolBarCmd( + market=market, code=code, period=period, times=times, + start=noffset, count=nps, fq=Adjust.NONE, + ) + ) + if not nb: + break + none_bars = nb + none_bars + nfetched += len(nb) + noffset += len(nb) + if len(nb) < nps: + break + if none_bars: + # XDXR 获取涉及同步网络 IO,放线程执行 + df = await asyncio.to_thread( + self._local_recompute_qfq, _to_df(none_bars), market, code, + ) + delta = _period_to_minutes(period, times) is_intraday = delta is not None return _apply_bar_time_align_df( diff --git a/tests/unit/test_mac_qfq_adjust.py b/tests/unit/test_mac_qfq_adjust.py new file mode 100644 index 0000000..0db24ce --- /dev/null +++ b/tests/unit/test_mac_qfq_adjust.py @@ -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 diff --git a/tests/unit/test_mac_qfq_integration.py b/tests/unit/test_mac_qfq_integration.py new file mode 100644 index 0000000..ead1f71 --- /dev/null +++ b/tests/unit/test_mac_qfq_integration.py @@ -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