From d84a08b7f27623d233e63ef14426a01653d74936 Mon Sep 17 00:00:00 2001 From: GitHub Date: Fri, 10 Jul 2026 15:12:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(backtest):=20v1.20.3=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=9B=9E=E6=B5=8B=E7=BB=A9=E6=95=88=E7=BB=9F=E8=AE=A1=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=20bug=EF=BC=88issues=20#30=20#31=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #31: RebalanceEngine 缺失价格导致净值假崩塌 已持仓标的当日缺 K 线(停牌/日历错位)时 prices.get(code,0)=0, 持仓市值记 0 → 净值单日暴跌(159915 在 20210208 缺一天,持仓 ~93%, 净值 1.1M→91,845,全期最大回撤 -92%)。 修复:last_known_price forward-fill,缺失日沿用最近已知收盘价。 附带:_compute_performance 最大回撤改正值口径(与 BacktestEngine 一致)。 验证:真实 ETF 数据 max_drawdown 24.01%(backtrader 基准 27%), total_return 220.56% 不变。 #30: PortfolioTracker 交易静默漏单 apply_trades 用 trade.datetime 作 dict key、df["datetime"].to_numpy()[i] 查找;两端类型不一致(int vs datetime64)时永不命中,交易被静默丢弃, 净值恒定(total_return=0 但 trades 表有 PnL)。 修复:改为按"位置索引"匹配(归一化 datetime 后查位置),类型无关。 验证:int-trade+datetime64-df 修复前净值=100000(恒定),修复后=100289。 测试:新增 4 回归测试(未修复代码上均失败,修复后通过); 全套 936 passed;mypy 改动文件零错误;ruff 全绿。 --- CHANGELOG.md | 14 +++++ pyproject.toml | 2 +- src/easy_tdx/backtest/portfolio.py | 65 +++++++++++++++++++++-- src/easy_tdx/portfolio/rebalance.py | 23 +++++++- tests/unit/test_backtest_portfolio.py | 72 ++++++++++++++++++++++++++ tests/unit/test_portfolio_rebalance.py | 44 ++++++++++++++++ 6 files changed, 212 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4070eb5..ded61b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.20.3] — 2026-07-10 + +**修复回测绩效统计两个准确性 bug**(issues #30 / #31)—— 用户反馈升级到 1.20.2 后回测数据仍然不对:#31 调仓回测最大回撤荒谬(-92%),#30 单标的回测总收益恒为 0(但交易表有盈亏)。排查后定位为两处独立缺陷,逐一修复并补回归测试。 + +### 修复 + +- **RebalanceEngine 缺失价格导致净值假崩塌**(`src/easy_tdx/portfolio/rebalance.py`)—— 已持仓标的当日缺 K 线(停牌/上市晚/日历错位)时,`prices.get(code, 0)` 返回 0,该标的持仓市值被记为 0,净值单日暴跌(issue #31:159915 在 20210208 缺一天数据,持仓占 ~93%,净值从 1.1M 瞬跌至 91,845,全期最大回撤 -92%)。新增 `last_known_price` forward-fill:缺失日沿用最近已知收盘价估值(停牌标的的标准做法)。修复后真实 ETF 数据最大回撤 24.01%(用户 backtrader 基准 27%),总收益 220.56% 不变。 +- **RebalanceEngine 最大回撤符号口径**(`src/easy_tdx/portfolio/rebalance.py`)—— `_compute_performance` 此前用 `(total-peak)/peak + np.min` 返回**负**最大回撤,与 `BacktestEngine.PerformanceAnalyzer`(正值 `[0,1]`)、CLI/文档约定不一致。改为 `(peak-total)/peak + np.max` 正值口径。 +- **PortfolioTracker 交易静默漏单**(`src/easy_tdx/backtest/portfolio.py`)—— `apply_trades` 用 `trade.datetime` 作 dict key、用 `df["datetime"].to_numpy()[i]` 查找;两端类型不一致(int YYYYMMDD vs datetime64)时 `trade_map.get(dt)` 永不命中,全部交易被静默丢弃,净值恒等于初始资金(issue #30:`total_return=0, volatility=0, end_value=100000`,但 trades 表有 PnL,因 `_compute_pnls` 不依赖 df 查找)。改为按"位置索引"匹配:预构建归一化 datetime→位置映射,trade.datetime 无论 Timestamp/int/datetime64 都能正确命中,彻底消除该静默失败。 + +### 测试 + +- 新增 4 个回归测试:`test_apply_trades_int_datetime_vs_datetime64_df` / `test_apply_trades_timestamp_vs_int_df`(#30,int↔datetime64 类型不一致仍正确撮合);`test_missing_price_does_not_collapse_equity` / `test_max_drawdown_sign_positive`(#31,缺数据不假崩塌 + 回撤正值)。四测试在未修复代码上**均失败**,修复后通过。全套 936 passed;mypy 改动文件零错误;ruff 全绿。 + ## [1.20.2] — 2026-07-09 **修复 v1.20.1 引入的 CI mypy 失败** —— v1.20.1 把 `BacktestResult.performance` 类型扩大为 `dict[str, float | str]`(为塞进 `diagnostic_warning` 字符串),破坏了 6 处下游消费方(portfolio/combo/optimizer/ranker 假设 `dict[str, float]` 做算术比较),CI mypy job 转红。本次重构为更干净的设计:诊断信息走独立的 `BacktestResult.diagnostic` 字段,performance 字典恢复 `dict[str, float]` 类型契约。顺手修复 `optimizer.py` 的 3 个既有 ndarray type-arg 错误。 diff --git a/pyproject.toml b/pyproject.toml index c0ffa7d..0f04c48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.20.2" +version = "1.20.3" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/backtest/portfolio.py b/src/easy_tdx/backtest/portfolio.py index 01c13a4..628ac4f 100644 --- a/src/easy_tdx/backtest/portfolio.py +++ b/src/easy_tdx/backtest/portfolio.py @@ -39,6 +39,58 @@ class PortfolioTracker: self._position = np.zeros(self._n) self._avg_price = np.zeros(self._n) self._initial_cash = initial_cash + # 预构建 datetime → 位置索引 映射,供 apply_trades 按"位置"匹配交易。 + # 关键:trade.datetime 与 df["datetime"] 的具体类型(Timestamp / int / + # datetime64)必须一致才能作 dict key 命中;过去直接用原始值匹配, + # 一旦两端类型不一致(如 datetime 列被预处理成 int、混合数据源、或 + # pandas 版本哈希差异)就静默漏掉全部交易 → 净值恒定(issue #30)。 + # 这里把匹配收敛到归一化后的位置索引,与类型无关。 + self._datetime_to_pos = self._build_datetime_index(df["datetime"]) + + @staticmethod + def _build_datetime_index(dt_col: pd.Series) -> dict[object, int]: + """构建 datetime 值 → 位置索引 的映射。 + + 对 datetime 列做归一化(datetime64 → int YYYYMMDD,与 OrderSimulator + 的 _find_bar_index 同款逻辑),保证无论上游 datetime 是哪种类型, + 查询键都能命中同一张表。 + + Args: + dt_col: DataFrame 的 datetime 列 + + Returns: + {归一化后的 datetime 值: 位置索引},重复值取首次出现位置。 + """ + if pd.api.types.is_datetime64_any_dtype(dt_col): + keys = dt_col.dt.strftime("%Y%m%d").astype("int64").to_numpy() + else: + keys = dt_col.to_numpy() + mapping: dict[object, int] = {} + for i, k in enumerate(keys): + if k not in mapping: + mapping[k] = i + return mapping + + def _find_pos(self, trade_dt: object) -> int | None: + """把 trade.datetime 归一化后查 df 中的位置索引。 + + 与 _build_datetime_index 使用同一套归一化规则,保证类型无关命中。 + + Args: + trade_dt: 交易时间(int YYYYMMDD / Timestamp / datetime64) + + Returns: + df 中的位置索引,未命中返回 None + """ + # datetime-like(Timestamp / datetime64)→ int YYYYMMDD 再查 + if hasattr(trade_dt, "strftime"): + try: + key: object = int(trade_dt.strftime("%Y%m%d")) + except (ValueError, AttributeError): + return None + else: + key = trade_dt + return self._datetime_to_pos.get(key) def apply_trades(self, trades: list[Trade]) -> None: """应用交易记录,更新内部状态数组。 @@ -46,17 +98,20 @@ class PortfolioTracker: Args: trades: 交易列表 """ - # 构建 datetime → [Trade] 映射(支持同 bar 多笔交易) + # 构建 位置索引 → [Trade] 映射(支持同 bar 多笔交易)。 + # 用位置索引而非原始 datetime 值作 key,与 trade.datetime 的具体类型 + # 无关,杜绝 int vs datetime64 类型不一致导致的静默漏单(issue #30)。 trade_map: dict[int, list[Trade]] = {} for trade in trades: if trade.rejected: continue - trade_map.setdefault(trade.datetime, []).append(trade) + pos = self._find_pos(trade.datetime) + if pos is None: + continue + trade_map.setdefault(pos, []).append(trade) # 遍历每个 bar for i in range(self._n): - dt = self._datetime[i] - # 继承前一个 bar 的状态(除了第一个 bar) if i > 0: self._cash[i] = self._cash[i - 1] @@ -64,7 +119,7 @@ class PortfolioTracker: self._avg_price[i] = self._avg_price[i - 1] # 处理该 bar 的所有交易 - for trade in trade_map.get(dt, []): + for trade in trade_map.get(i, []): if trade.direction == "BUY": cost = trade.size * trade.price + trade.commission + trade.slippage self._cash[i] -= cost diff --git a/src/easy_tdx/portfolio/rebalance.py b/src/easy_tdx/portfolio/rebalance.py index e2fa71c..7b89cd0 100644 --- a/src/easy_tdx/portfolio/rebalance.py +++ b/src/easy_tdx/portfolio/rebalance.py @@ -73,6 +73,10 @@ class RebalanceEngine: states: list[PortfolioState] = [] trades_list: list[dict[str, object]] = [] equity_records: list[dict[str, object]] = [] + # 已持仓标的的最近已知收盘价。当某标的当日缺 K 线(停牌/上市晚/日历 + # 错位)时,用上一已知价估值,避免持仓市值被错误记为 0 而造成净值单日 + # 假崩塌(issue #31:159915 在 20210208 缺一天数据 → -92% 假回撤)。 + last_known_price: dict[str, float] = {} for dt in all_dates: date_int = int(dt.strftime("%Y%m%d")) @@ -85,6 +89,13 @@ class RebalanceEngine: if not row.empty: prices[code] = float(row["close"].iloc[0]) + # forward-fill:当日无数据的标的沿用最近已知价(仅对已持仓标的有影响) + for code in holdings: + if code not in prices and code in last_known_price: + prices[code] = last_known_price[code] + # 记录本日出现的最新价,供后续缺失日沿用 + last_known_price.update(prices) + position_value = sum(holdings.get(c, 0) * prices.get(c, 0) for c in holdings) total_value = cash + position_value @@ -212,8 +223,16 @@ class RebalanceEngine: n_days = len(total) annual_return = (1 + total_return) ** (252 / max(n_days, 1)) - 1 peak = np.maximum.accumulate(total) - drawdown = (total - peak) / peak - max_drawdown = float(np.min(drawdown)) + # 与 BacktestEngine.PerformanceAnalyzer 一致的正值口径: + # drawdown_pct = (peak - total) / peak ∈ [0,1],max_drawdown 取最大值。 + # 此前用 (total-peak)/peak + np.min 返回负值,与 CLI/文档约定不符。 + drawdown_pct = np.divide( + peak - total, + peak, + out=np.zeros_like(peak), + where=(peak != 0), + ) + max_drawdown = float(np.max(drawdown_pct)) daily_ret = np.diff(total) / total[:-1] daily_ret = daily_ret[~np.isnan(daily_ret)] sharpe = ( diff --git a/tests/unit/test_backtest_portfolio.py b/tests/unit/test_backtest_portfolio.py index e558bf3..dcfff86 100644 --- a/tests/unit/test_backtest_portfolio.py +++ b/tests/unit/test_backtest_portfolio.py @@ -287,3 +287,75 @@ def test_empty_trades() -> None: assert (equity["cash"] == 100000).all() # 所有 bar 持仓应为 0 assert (positions["size"] == 0).all() + + +def test_apply_trades_int_datetime_vs_datetime64_df() -> None: + """issue #30:trade.datetime(int) 与 df datetime(datetime64) 类型不一致时, + 交易仍应被正确应用,而非静默漏单导致净值恒定。 + + 复现:过去 apply_trades 用 trade.datetime 作 dict key、用 + df["datetime"].to_numpy()[i] 查找;两端类型不一致(int vs datetime64) + 时 trade_map.get(dt) 永不命中,全部交易被丢弃 → 净值恒等于初始资金, + 但 trades 表里仍有 PnL(_compute_pnls 不依赖 df 查找)。 + """ + # df 的 datetime 列为 datetime64(真实 get_stock_kline 路径) + df = pd.DataFrame( + { + "datetime": pd.date_range("2024-01-01", periods=10, freq="D"), + "close": [10, 11, 12, 11, 10, 13, 14, 13, 15, 16], + } + ) + # trade.datetime 为 int YYYYMMDD(类型与 df 不一致) + buy = Trade( + datetime=20240101, + direction="BUY", + size=100, + price=10.0, + commission=5.0, + slippage=0.0, + ) + sell = Trade( + datetime=20240106, + direction="SELL", + size=100, + price=13.0, + commission=6.0, + slippage=0.0, + ) + + tracker = PortfolioTracker(df, initial_cash=100000) + tracker.apply_trades([buy, sell]) + + equity = tracker.equity_curve + # 卖出后现金 = 100000 - 100*10 - 5 + 100*13 - 6 = 100289 + # 修复前此处为 100000(交易被静默丢弃) + assert equity["cash"].iloc[-1] == 100289.0 + # 净值不应恒等于初始资金(交易生效) + assert equity["total"].iloc[-1] != 100000.0 + + +def test_apply_trades_timestamp_vs_int_df() -> None: + """issue #30 反向:trade.datetime(Timestamp) 与 df datetime(int) 不一致。""" + df = pd.DataFrame( + { + "datetime": [ + int(d.strftime("%Y%m%d")) for d in pd.date_range("2024-01-01", periods=10, freq="D") + ], + "close": [10, 11, 12, 11, 10, 13, 14, 13, 15, 16], + } + ) + buy = Trade( + datetime=pd.Timestamp("2024-01-01"), + direction="BUY", + size=100, + price=10.0, + commission=5.0, + slippage=0.0, + ) + + tracker = PortfolioTracker(df, initial_cash=100000) + tracker.apply_trades([buy]) + + # 修复前交易被丢弃、持仓为 0 + assert tracker.positions["size"].iloc[0] == 100 + diff --git a/tests/unit/test_portfolio_rebalance.py b/tests/unit/test_portfolio_rebalance.py index b88c986..728db26 100644 --- a/tests/unit/test_portfolio_rebalance.py +++ b/tests/unit/test_portfolio_rebalance.py @@ -86,3 +86,47 @@ class TestRebalanceEngine: assert result.performance["total_trades"] == len(result.trades) # 修复前 total_trades == len(equity_curve)(天数),明显大于交易笔数 assert result.performance["total_trades"] != len(result.equity_curve) + + def test_missing_price_does_not_collapse_equity(self): + """issue #31:已持仓标的当日缺 K 线时,市值不应被记为 0 导致净值假崩塌。 + + 复现:一只标的在中段缺若干交易日数据,且被持有;修复前该标的缺数据 + 的日子市值按 0 计,净值单日暴跌,max_drawdown 荒谬(如 -92%)。 + forward-fill 后用最近已知价估值,净值曲线平滑、max_drawdown 合理。 + """ + data = _make_market(n_stocks=3, n_days=120, seed=7) + # 让第一只标的中段缺 5 天数据(模拟停牌/日历错位) + target = "000000" + df0 = data[target] + keep_mask = ~df0["datetime"].isin(df0["datetime"].iloc[55:60]) + data[target] = df0[keep_mask].reset_index(drop=True) + + engine = RebalanceEngine( + optimizer=EqualWeightOptimizer(), + n_stocks=3, + rebalance_freq="M", + cash=1_000_000, + ) + result = engine.run(data, start_date=20240101, end_date=20240430) + + ec = result.equity_curve.sort_values("datetime").reset_index(drop=True) + prev = ec["total"].shift(1) + pct_chg = (ec["total"] - prev) / prev + # 无单日 >50% 假崩塌(修复前会出现接近 -100% 的尖刺) + assert pct_chg.min() > -0.5, f"单日跌幅 {pct_chg.min():.2%} 异常,疑似缺数据假崩塌" + # 最大回撤合理(< 90%)且为正值 + md = result.performance["max_drawdown"] + assert 0.0 <= md < 0.9, f"max_drawdown={md:.4f} 不合理" + + def test_max_drawdown_sign_positive(self): + """issue #31 附带:max_drawdown 应为正值 [0,1],与 BacktestEngine 约定一致。""" + engine = RebalanceEngine( + optimizer=EqualWeightOptimizer(), + n_stocks=3, + rebalance_freq="M", + cash=1_000_000, + ) + result = engine.run(_make_market(), start_date=20240101, end_date=20240430) + md = result.performance["max_drawdown"] + assert 0.0 <= md <= 1.0, f"max_drawdown={md} 应落在 [0,1]" +