mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 16:54:17 +08:00
feat(backtest): add ExecutionModel ABC + ImmediateExecution
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""可插拔执行仿真引擎。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.backtest.types import Trade
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from easy_tdx.backtest.slippage import SlippageModel
|
||||
from easy_tdx.backtest.types import Signal
|
||||
|
||||
|
||||
class ExecutionModel(ABC):
|
||||
"""执行仿真基类。"""
|
||||
|
||||
@abstractmethod
|
||||
def execute(
|
||||
self,
|
||||
signal: Signal,
|
||||
df: pd.DataFrame,
|
||||
bar_idx: int,
|
||||
cash: float,
|
||||
position: float,
|
||||
position_mode: str,
|
||||
commission: float,
|
||||
min_commission: float,
|
||||
stamp_tax: float,
|
||||
slippage_model: SlippageModel | None,
|
||||
) -> list[Trade]:
|
||||
"""将信号转换为一笔或多笔成交。"""
|
||||
...
|
||||
|
||||
def _calc_commission(
|
||||
self,
|
||||
size: float,
|
||||
price: float,
|
||||
is_sell: bool,
|
||||
commission: float,
|
||||
min_commission: float,
|
||||
stamp_tax: float,
|
||||
) -> float:
|
||||
"""计算手续费。"""
|
||||
comm = max(size * price * commission, min_commission)
|
||||
if is_sell:
|
||||
comm += size * price * stamp_tax
|
||||
return comm
|
||||
|
||||
def _calc_slippage(
|
||||
self,
|
||||
size: float,
|
||||
price: float,
|
||||
is_sell: bool,
|
||||
slippage_model: SlippageModel | None,
|
||||
df: pd.DataFrame,
|
||||
) -> float:
|
||||
"""计算滑点。"""
|
||||
if slippage_model is None:
|
||||
return 0.0
|
||||
volume = float(df["volume"].iloc[-1]) if "volume" in df.columns else 0.0
|
||||
volatility = self._estimate_volatility(df)
|
||||
return slippage_model.compute(
|
||||
price=price,
|
||||
size=size,
|
||||
volume=volume,
|
||||
volatility=volatility,
|
||||
direction="SELL" if is_sell else "BUY",
|
||||
)
|
||||
|
||||
def _estimate_volatility(self, df: pd.DataFrame) -> float:
|
||||
"""从收盘价估计近期年化波动率。"""
|
||||
if "close" not in df.columns or len(df) < 2:
|
||||
return 0.0
|
||||
close = df["close"].to_numpy()
|
||||
returns = np.diff(close) / close[:-1]
|
||||
if len(returns) < 2:
|
||||
return 0.0
|
||||
return float(np.std(returns)) * np.sqrt(252)
|
||||
|
||||
def _calc_buy_size(
|
||||
self,
|
||||
signal_size: float,
|
||||
price: float,
|
||||
cash: float,
|
||||
position_mode: str,
|
||||
commission: float,
|
||||
) -> float:
|
||||
"""计算买入数量。"""
|
||||
if position_mode == "full" or signal_size == 0:
|
||||
max_cost = price * (1 + commission)
|
||||
max_shares = int(cash / max_cost / 100) * 100
|
||||
return float(max_shares)
|
||||
elif position_mode == "percent":
|
||||
target_value = cash * signal_size
|
||||
return float(int(target_value / price / 100) * 100)
|
||||
return signal_size
|
||||
|
||||
def _get_datetime_int(self, df: pd.DataFrame, idx: int) -> int:
|
||||
"""获取指定 index 的 datetime int。"""
|
||||
dt_raw = df["datetime"].iloc[idx]
|
||||
if hasattr(dt_raw, "strftime"):
|
||||
return int(dt_raw.strftime("%Y%m%d"))
|
||||
return int(dt_raw)
|
||||
|
||||
|
||||
class ImmediateExecution(ExecutionModel):
|
||||
"""即时成交(向后兼容,与现有 OrderSimulator 行为一致)。"""
|
||||
|
||||
def execute(
|
||||
self,
|
||||
signal: Signal,
|
||||
df: pd.DataFrame,
|
||||
bar_idx: int,
|
||||
cash: float,
|
||||
position: float,
|
||||
position_mode: str,
|
||||
commission: float,
|
||||
min_commission: float,
|
||||
stamp_tax: float,
|
||||
slippage_model: SlippageModel | None,
|
||||
) -> list[Trade]:
|
||||
exec_idx = bar_idx + 1
|
||||
if exec_idx >= len(df):
|
||||
return []
|
||||
|
||||
price = float(df["open"].iloc[exec_idx])
|
||||
|
||||
if signal.direction == "BUY":
|
||||
size = self._calc_buy_size(
|
||||
signal.size,
|
||||
price,
|
||||
cash,
|
||||
position_mode,
|
||||
commission,
|
||||
)
|
||||
if size <= 0:
|
||||
return []
|
||||
comm = self._calc_commission(
|
||||
size,
|
||||
price,
|
||||
False,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
)
|
||||
slip = self._calc_slippage(size, price, False, slippage_model, df)
|
||||
return [
|
||||
Trade(
|
||||
datetime=self._get_datetime_int(df, exec_idx),
|
||||
direction="BUY",
|
||||
size=size,
|
||||
price=price,
|
||||
commission=comm,
|
||||
slippage=slip,
|
||||
)
|
||||
]
|
||||
elif signal.direction == "SELL":
|
||||
size = signal.size if signal.size > 0 else position
|
||||
if size <= 0:
|
||||
return []
|
||||
if size > position:
|
||||
size = position
|
||||
comm = self._calc_commission(
|
||||
size,
|
||||
price,
|
||||
True,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
)
|
||||
slip = self._calc_slippage(size, price, True, slippage_model, df)
|
||||
return [
|
||||
Trade(
|
||||
datetime=self._get_datetime_int(df, exec_idx),
|
||||
direction="SELL",
|
||||
size=size,
|
||||
price=price,
|
||||
commission=comm,
|
||||
slippage=slip,
|
||||
)
|
||||
]
|
||||
|
||||
return []
|
||||
@@ -0,0 +1,169 @@
|
||||
"""执行仿真引擎单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.backtest.execution import ExecutionModel, ImmediateExecution
|
||||
from easy_tdx.backtest.types import Signal
|
||||
|
||||
|
||||
def _make_df(n: int = 20) -> pd.DataFrame:
|
||||
"""构造测试用K线数据。"""
|
||||
data = {
|
||||
"datetime": [20240101 + i for i in range(n)],
|
||||
"open": [100.0 + i for i in range(n)],
|
||||
"close": [101.0 + i for i in range(n)],
|
||||
"high": [102.0 + i for i in range(n)],
|
||||
"low": [99.0 + i for i in range(n)],
|
||||
"volume": [10000] * n,
|
||||
}
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
class TestExecutionBase:
|
||||
"""基类验证。"""
|
||||
|
||||
def test_cannot_instantiate_abc(self) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
ExecutionModel() # type: ignore[abstract]
|
||||
|
||||
|
||||
class TestImmediateExecution:
|
||||
"""即时成交。"""
|
||||
|
||||
def test_buy_signal(self) -> None:
|
||||
df = _make_df(10)
|
||||
model = ImmediateExecution()
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=100)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=20000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 1
|
||||
assert trades[0].direction == "BUY"
|
||||
assert trades[0].price == 101.0
|
||||
|
||||
def test_sell_signal(self) -> None:
|
||||
df = _make_df(10)
|
||||
model = ImmediateExecution()
|
||||
signal = Signal(datetime=20240101, direction="SELL", size=100)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=0,
|
||||
position=200,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 1
|
||||
assert trades[0].direction == "SELL"
|
||||
|
||||
def test_signal_at_last_bar(self) -> None:
|
||||
df = _make_df(10)
|
||||
model = ImmediateExecution()
|
||||
signal = Signal(datetime=20240109, direction="BUY", size=100)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=9,
|
||||
cash=20000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 0
|
||||
|
||||
def test_with_slippage_model(self) -> None:
|
||||
from easy_tdx.backtest.slippage import FixedSlippage
|
||||
|
||||
df = _make_df(10)
|
||||
model = ImmediateExecution()
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=100)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=20000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=FixedSlippage(per_share=0.01),
|
||||
)
|
||||
assert len(trades) == 1
|
||||
assert trades[0].slippage == pytest.approx(1.0)
|
||||
|
||||
def test_commission_on_buy(self) -> None:
|
||||
df = _make_df(10)
|
||||
model = ImmediateExecution()
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=100)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=20000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 1
|
||||
assert trades[0].commission >= 5.0
|
||||
|
||||
def test_stamp_tax_on_sell(self) -> None:
|
||||
df = _make_df(10)
|
||||
model = ImmediateExecution()
|
||||
signal = Signal(datetime=20240101, direction="SELL", size=100)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=0,
|
||||
position=200,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 1
|
||||
assert trades[0].commission > 10.0
|
||||
|
||||
def test_full_position_buy(self) -> None:
|
||||
df = _make_df(10)
|
||||
model = ImmediateExecution()
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=0)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=20000,
|
||||
position=0,
|
||||
position_mode="full",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 1
|
||||
assert trades[0].size == 100
|
||||
Reference in New Issue
Block a user