Files
easy-tdx/tests/unit/test_backtest_portfolio.py
T
GitHub d84a08b7f2 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 全绿。
2026-07-10 15:12:47 +08:00

362 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""回测引擎持仓追踪器单元测试。"""
from __future__ import annotations
import pandas as pd
from easy_tdx.backtest.portfolio import PortfolioTracker
from easy_tdx.backtest.types import Trade
def _make_df(n: int = 10) -> pd.DataFrame:
"""创建测试用 DataFrame。
Args:
n: bar 数量
Returns:
包含 close 和 datetime 列的 DataFrame
"""
close = [100.0] * 5 + [110.0] * 5
close = close[:n]
datetime = list(range(20240101, 20240101 + n))
return pd.DataFrame({"close": close, "datetime": datetime})
def test_initial_state() -> None:
"""测试初始状态。"""
df = _make_df(10)
tracker = PortfolioTracker(df, initial_cash=100000)
assert tracker.initial_cash == 100000
def test_buy_then_sell() -> None:
"""测试买入再卖出。"""
df = _make_df(10)
tracker = PortfolioTracker(df, initial_cash=100000)
# bar 0 买入 100 股 @100,手续费 5
buy_trade = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=100.0,
commission=5.0,
slippage=0.0,
)
# bar 5 卖出 100 股 @110,手续费 11
sell_trade = Trade(
datetime=20240106,
direction="SELL",
size=100,
price=110.0,
commission=11.0,
slippage=0.0,
)
tracker.apply_trades([buy_trade, sell_trade])
equity = tracker.equity_curve
final_cash = equity["cash"].iloc[-1]
# 最终现金 = 100000 - 10000 - 5 + 11000 - 11 = 100984
expected = 100984.0
assert abs(final_cash - expected) < 1.0, f"Expected {expected}, got {final_cash}"
def test_drawdown_calculation() -> None:
"""测试回撤计算(无交易时回撤全为 0)。"""
df = _make_df(10)
tracker = PortfolioTracker(df, initial_cash=100000)
tracker.apply_trades([])
equity = tracker.equity_curve
# 无交易时,总资产应保持不变,回撤为 0
assert (equity["drawdown"] == 0).all()
assert (equity["drawdown_pct"] == 0).all()
def test_equity_curve_columns() -> None:
"""测试资金曲线列名。"""
df = _make_df(10)
tracker = PortfolioTracker(df)
tracker.apply_trades([])
equity = tracker.equity_curve
expected_cols = {"datetime", "cash", "position_value", "total", "drawdown", "drawdown_pct"}
assert set(equity.columns) == expected_cols
def test_position_tracking() -> None:
"""测试持仓追踪。"""
df = _make_df(10)
tracker = PortfolioTracker(df)
# bar 0 买入 100 股
buy_trade = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=100.0,
commission=5.0,
slippage=0.0,
)
tracker.apply_trades([buy_trade])
positions = tracker.positions
# 买入后持仓应为 100,并持续到最后
assert positions["size"].iloc[0] == 100
assert positions["size"].iloc[-1] == 100
assert (positions["size"].iloc[1:] == 100).all()
def test_avg_price_update() -> None:
"""测试均价更新。"""
df = _make_df(10)
tracker = PortfolioTracker(df)
# bar 0 买入 100 股 @100
buy1 = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=100.0,
commission=0.0,
slippage=0.0,
)
# bar 1 再买入 50 股 @110
buy2 = Trade(
datetime=20240102,
direction="BUY",
size=50,
price=110.0,
commission=0.0,
slippage=0.0,
)
tracker.apply_trades([buy1, buy2])
positions = tracker.positions
# 均价 = (100*100 + 50*110) / 150 = 103.33
expected_avg = (100 * 100 + 50 * 110) / 150
assert abs(positions["avg_price"].iloc[1] - expected_avg) < 0.01
def test_sell_clears_position() -> None:
"""测试卖出清空持仓。"""
df = _make_df(10)
tracker = PortfolioTracker(df)
buy = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=100.0,
commission=0.0,
slippage=0.0,
)
sell = Trade(
datetime=20240102,
direction="SELL",
size=100,
price=110.0,
commission=0.0,
slippage=0.0,
)
tracker.apply_trades([buy, sell])
positions = tracker.positions
# 卖出后持仓应为 0
assert positions["size"].iloc[0] == 100
assert positions["size"].iloc[1] == 0
assert positions["avg_price"].iloc[1] == 0
def test_position_market_value() -> None:
"""测试持仓市值计算。"""
df = _make_df(10)
tracker = PortfolioTracker(df)
buy = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=100.0,
commission=0.0,
slippage=0.0,
)
tracker.apply_trades([buy])
positions = tracker.positions
# 前 5 个 bar 价格 100,后 5 个 bar 价格 110
assert positions["market_value"].iloc[0] == 100 * 100
assert positions["market_value"].iloc[5] == 100 * 110
def test_unrealized_pnl() -> None:
"""测试未实现盈亏计算。"""
df = _make_df(10)
tracker = PortfolioTracker(df)
buy = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=100.0,
commission=0.0,
slippage=0.0,
)
tracker.apply_trades([buy])
positions = tracker.positions
# 前 5 个 bar 价格 100,盈亏为 0
assert positions["unrealized_pnl"].iloc[0] == 0
# 后 5 个 bar 价格 110,盈亏为 (110-100)*100 = 1000
assert positions["unrealized_pnl"].iloc[5] == 1000
def test_rejected_trade_ignored() -> None:
"""测试被拒绝的交易不产生影响。"""
df = _make_df(10)
tracker = PortfolioTracker(df, initial_cash=100000)
rejected_buy = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=100.0,
commission=0.0,
slippage=0.0,
rejected=True,
)
tracker.apply_trades([rejected_buy])
equity = tracker.equity_curve
# 现金应保持不变
assert (equity["cash"] == 100000).all()
assert (tracker.positions["size"] == 0).all()
def test_commission_and_slippage() -> None:
"""测试手续费和滑点扣减。"""
df = _make_df(10)
tracker = PortfolioTracker(df, initial_cash=10000)
buy = Trade(
datetime=20240101,
direction="BUY",
size=100,
price=100.0,
commission=10.0,
slippage=5.0,
)
tracker.apply_trades([buy])
equity = tracker.equity_curve
# 现金 = 10000 - 100*100 - 10 - 5 = -15
expected_cash = 10000 - 10000 - 10 - 5
assert equity["cash"].iloc[0] == expected_cash
def test_empty_trades() -> None:
"""测试空交易列表不崩溃。"""
df = _make_df(10)
tracker = PortfolioTracker(df)
tracker.apply_trades([])
equity = tracker.equity_curve
positions = tracker.positions
# 所有 bar 现金应等于初始现金
assert (equity["cash"] == 100000).all()
# 所有 bar 持仓应为 0
assert (positions["size"] == 0).all()
def test_apply_trades_int_datetime_vs_datetime64_df() -> None:
"""issue #30trade.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