mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
361 lines
9.2 KiB
Python
361 lines
9.2 KiB
Python
"""回测引擎持仓追踪器单元测试。"""
|
||
|
||
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 #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
|