mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 18:04:16 +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:
@@ -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