mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
fix(backtest): v1.20.3 修复回测绩效统计两个 bug(issues #30 #31)
#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 全绿。
This commit is contained in:
@@ -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 错误。
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user