mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 16:54:17 +08:00
feat(backtest): add TWAPExecution + VWAPExecution
This commit is contained in:
@@ -185,3 +185,339 @@ class ImmediateExecution(ExecutionModel):
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
class TWAPExecution(ExecutionModel):
|
||||
"""时间加权平均价格执行。
|
||||
|
||||
将订单均匀拆分为 n_bars 份,在连续 n_bars 根 K 线上执行。
|
||||
"""
|
||||
|
||||
def __init__(self, n_bars: int = 5) -> None:
|
||||
self._n_bars = max(1, n_bars)
|
||||
|
||||
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]:
|
||||
if signal.direction == "BUY":
|
||||
return self._execute_buy(
|
||||
signal,
|
||||
df,
|
||||
bar_idx,
|
||||
cash,
|
||||
position_mode,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
slippage_model,
|
||||
)
|
||||
return self._execute_sell(
|
||||
signal,
|
||||
df,
|
||||
bar_idx,
|
||||
position,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
slippage_model,
|
||||
)
|
||||
|
||||
def _execute_buy(
|
||||
self,
|
||||
signal: Signal,
|
||||
df: pd.DataFrame,
|
||||
bar_idx: int,
|
||||
cash: float,
|
||||
position_mode: str,
|
||||
commission: float,
|
||||
min_commission: float,
|
||||
stamp_tax: float,
|
||||
slippage_model: SlippageModel | None,
|
||||
) -> list[Trade]:
|
||||
first_price = float(df["open"].iloc[bar_idx + 1]) if bar_idx + 1 < len(df) else 0
|
||||
if first_price <= 0:
|
||||
return []
|
||||
total_size = self._calc_buy_size(
|
||||
signal.size,
|
||||
first_price,
|
||||
cash,
|
||||
position_mode,
|
||||
commission,
|
||||
)
|
||||
if total_size <= 0:
|
||||
return []
|
||||
|
||||
sub_size = int(total_size / self._n_bars / 100) * 100
|
||||
if sub_size <= 0:
|
||||
sub_size = 100
|
||||
|
||||
trades: list[Trade] = []
|
||||
for i in range(self._n_bars):
|
||||
exec_idx = bar_idx + 1 + i
|
||||
if exec_idx >= len(df):
|
||||
break
|
||||
price = float(df["close"].iloc[exec_idx])
|
||||
remaining = total_size - sum(t.size for t in trades)
|
||||
actual_size = min(sub_size, remaining)
|
||||
actual_size = int(actual_size / 100) * 100
|
||||
if actual_size <= 0:
|
||||
break
|
||||
comm = self._calc_commission(
|
||||
actual_size,
|
||||
price,
|
||||
False,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
)
|
||||
slip = self._calc_slippage(actual_size, price, False, slippage_model, df)
|
||||
trades.append(
|
||||
Trade(
|
||||
datetime=self._get_datetime_int(df, exec_idx),
|
||||
direction="BUY",
|
||||
size=float(actual_size),
|
||||
price=price,
|
||||
commission=comm,
|
||||
slippage=slip,
|
||||
)
|
||||
)
|
||||
return trades
|
||||
|
||||
def _execute_sell(
|
||||
self,
|
||||
signal: Signal,
|
||||
df: pd.DataFrame,
|
||||
bar_idx: int,
|
||||
position: float,
|
||||
commission: float,
|
||||
min_commission: float,
|
||||
stamp_tax: float,
|
||||
slippage_model: SlippageModel | None,
|
||||
) -> list[Trade]:
|
||||
total_size = signal.size if signal.size > 0 else position
|
||||
if total_size <= 0:
|
||||
return []
|
||||
|
||||
sub_size = int(total_size / self._n_bars / 100) * 100
|
||||
if sub_size <= 0:
|
||||
sub_size = 100
|
||||
|
||||
trades: list[Trade] = []
|
||||
for i in range(self._n_bars):
|
||||
exec_idx = bar_idx + 1 + i
|
||||
if exec_idx >= len(df):
|
||||
break
|
||||
price = float(df["close"].iloc[exec_idx])
|
||||
remaining = total_size - sum(t.size for t in trades)
|
||||
actual_size = min(sub_size, remaining)
|
||||
actual_size = int(actual_size / 100) * 100
|
||||
if actual_size <= 0:
|
||||
break
|
||||
comm = self._calc_commission(
|
||||
actual_size,
|
||||
price,
|
||||
True,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
)
|
||||
slip = self._calc_slippage(actual_size, price, True, slippage_model, df)
|
||||
trades.append(
|
||||
Trade(
|
||||
datetime=self._get_datetime_int(df, exec_idx),
|
||||
direction="SELL",
|
||||
size=float(actual_size),
|
||||
price=price,
|
||||
commission=comm,
|
||||
slippage=slip,
|
||||
)
|
||||
)
|
||||
return trades
|
||||
|
||||
|
||||
class VWAPExecution(ExecutionModel):
|
||||
"""成交量加权平均价格执行。
|
||||
|
||||
按历史成交量分布比例拆分订单。
|
||||
"""
|
||||
|
||||
def __init__(self, n_bars: int = 5, volume_lookback: int = 20) -> None:
|
||||
self._n_bars = max(1, n_bars)
|
||||
self._volume_lookback = max(1, volume_lookback)
|
||||
|
||||
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]:
|
||||
if signal.direction == "BUY":
|
||||
return self._execute_buy(
|
||||
signal,
|
||||
df,
|
||||
bar_idx,
|
||||
cash,
|
||||
position_mode,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
slippage_model,
|
||||
)
|
||||
return self._execute_sell(
|
||||
signal,
|
||||
df,
|
||||
bar_idx,
|
||||
position,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
slippage_model,
|
||||
)
|
||||
|
||||
def _get_volume_weights(self, df: pd.DataFrame, bar_idx: int) -> list[float]:
|
||||
"""获取成交量权重分布。"""
|
||||
start = max(0, bar_idx - self._volume_lookback + 1)
|
||||
lookback = df.iloc[start : bar_idx + 1]
|
||||
if "volume" not in lookback.columns or len(lookback) == 0:
|
||||
return [1.0 / self._n_bars] * self._n_bars
|
||||
|
||||
volumes = lookback["volume"].to_numpy()
|
||||
total_vol = float(volumes.sum())
|
||||
if total_vol <= 0:
|
||||
return [1.0 / self._n_bars] * self._n_bars
|
||||
|
||||
weights: list[float] = []
|
||||
for i in range(self._n_bars):
|
||||
idx = max(0, len(volumes) - 1 - (i % max(1, len(volumes))))
|
||||
weights.append(float(volumes[idx]) / total_vol)
|
||||
total_w = sum(weights)
|
||||
if total_w <= 0:
|
||||
return [1.0 / self._n_bars] * self._n_bars
|
||||
return [w / total_w for w in weights]
|
||||
|
||||
def _execute_buy(
|
||||
self,
|
||||
signal: Signal,
|
||||
df: pd.DataFrame,
|
||||
bar_idx: int,
|
||||
cash: float,
|
||||
position_mode: str,
|
||||
commission: float,
|
||||
min_commission: float,
|
||||
stamp_tax: float,
|
||||
slippage_model: SlippageModel | None,
|
||||
) -> list[Trade]:
|
||||
first_price = float(df["open"].iloc[bar_idx + 1]) if bar_idx + 1 < len(df) else 0
|
||||
if first_price <= 0:
|
||||
return []
|
||||
total_size = self._calc_buy_size(
|
||||
signal.size,
|
||||
first_price,
|
||||
cash,
|
||||
position_mode,
|
||||
commission,
|
||||
)
|
||||
if total_size <= 0:
|
||||
return []
|
||||
|
||||
weights = self._get_volume_weights(df, bar_idx)
|
||||
trades: list[Trade] = []
|
||||
for i in range(self._n_bars):
|
||||
exec_idx = bar_idx + 1 + i
|
||||
if exec_idx >= len(df):
|
||||
break
|
||||
price = float(df["close"].iloc[exec_idx])
|
||||
w = weights[i] if i < len(weights) else 1.0 / self._n_bars
|
||||
target = int(total_size * w / 100) * 100
|
||||
remaining = total_size - sum(t.size for t in trades)
|
||||
actual_size = min(target, remaining)
|
||||
actual_size = int(actual_size / 100) * 100
|
||||
if actual_size <= 0:
|
||||
continue
|
||||
comm = self._calc_commission(
|
||||
actual_size,
|
||||
price,
|
||||
False,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
)
|
||||
slip = self._calc_slippage(actual_size, price, False, slippage_model, df)
|
||||
trades.append(
|
||||
Trade(
|
||||
datetime=self._get_datetime_int(df, exec_idx),
|
||||
direction="BUY",
|
||||
size=float(actual_size),
|
||||
price=price,
|
||||
commission=comm,
|
||||
slippage=slip,
|
||||
)
|
||||
)
|
||||
return trades
|
||||
|
||||
def _execute_sell(
|
||||
self,
|
||||
signal: Signal,
|
||||
df: pd.DataFrame,
|
||||
bar_idx: int,
|
||||
position: float,
|
||||
commission: float,
|
||||
min_commission: float,
|
||||
stamp_tax: float,
|
||||
slippage_model: SlippageModel | None,
|
||||
) -> list[Trade]:
|
||||
total_size = signal.size if signal.size > 0 else position
|
||||
if total_size <= 0:
|
||||
return []
|
||||
|
||||
weights = self._get_volume_weights(df, bar_idx)
|
||||
trades: list[Trade] = []
|
||||
for i in range(self._n_bars):
|
||||
exec_idx = bar_idx + 1 + i
|
||||
if exec_idx >= len(df):
|
||||
break
|
||||
price = float(df["close"].iloc[exec_idx])
|
||||
w = weights[i] if i < len(weights) else 1.0 / self._n_bars
|
||||
target = int(total_size * w / 100) * 100
|
||||
remaining = total_size - sum(t.size for t in trades)
|
||||
actual_size = min(target, remaining)
|
||||
actual_size = int(actual_size / 100) * 100
|
||||
if actual_size <= 0:
|
||||
continue
|
||||
comm = self._calc_commission(
|
||||
actual_size,
|
||||
price,
|
||||
True,
|
||||
commission,
|
||||
min_commission,
|
||||
stamp_tax,
|
||||
)
|
||||
slip = self._calc_slippage(actual_size, price, True, slippage_model, df)
|
||||
trades.append(
|
||||
Trade(
|
||||
datetime=self._get_datetime_int(df, exec_idx),
|
||||
direction="SELL",
|
||||
size=float(actual_size),
|
||||
price=price,
|
||||
commission=comm,
|
||||
slippage=slip,
|
||||
)
|
||||
)
|
||||
return trades
|
||||
|
||||
@@ -5,7 +5,12 @@ from __future__ import annotations
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.backtest.execution import ExecutionModel, ImmediateExecution
|
||||
from easy_tdx.backtest.execution import (
|
||||
ExecutionModel,
|
||||
ImmediateExecution,
|
||||
TWAPExecution,
|
||||
VWAPExecution,
|
||||
)
|
||||
from easy_tdx.backtest.types import Signal
|
||||
|
||||
|
||||
@@ -167,3 +172,150 @@ class TestImmediateExecution:
|
||||
)
|
||||
assert len(trades) == 1
|
||||
assert trades[0].size == 100
|
||||
|
||||
|
||||
class TestTWAPExecution:
|
||||
"""时间加权平均价格执行。"""
|
||||
|
||||
def test_split_buy_into_3_bars(self) -> None:
|
||||
df = _make_df(20)
|
||||
model = TWAPExecution(n_bars=3)
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=300)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=100000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 3
|
||||
total_size = sum(t.size for t in trades)
|
||||
assert total_size <= 300
|
||||
prices = [t.price for t in trades]
|
||||
assert prices[0] != prices[1]
|
||||
|
||||
def test_split_sell_into_2_bars(self) -> None:
|
||||
df = _make_df(20)
|
||||
model = TWAPExecution(n_bars=2)
|
||||
signal = Signal(datetime=20240101, direction="SELL", size=200)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=0,
|
||||
position=500,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 2
|
||||
assert sum(t.size for t in trades) == 200.0
|
||||
|
||||
def test_truncates_at_data_end(self) -> None:
|
||||
df = _make_df(5)
|
||||
model = TWAPExecution(n_bars=10)
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=1000)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=100000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) <= 4
|
||||
|
||||
def test_full_position_mode(self) -> None:
|
||||
df = _make_df(20)
|
||||
model = TWAPExecution(n_bars=3)
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=0)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=60000,
|
||||
position=0,
|
||||
position_mode="full",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 3
|
||||
assert all(t.size > 0 for t in trades)
|
||||
|
||||
|
||||
class TestVWAPExecution:
|
||||
"""成交量加权平均价格执行。"""
|
||||
|
||||
def test_basic_buy(self) -> None:
|
||||
df = _make_df(20)
|
||||
model = VWAPExecution(n_bars=3, volume_lookback=10)
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=300)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=5,
|
||||
cash=100000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 3
|
||||
total_size = sum(t.size for t in trades)
|
||||
assert total_size <= 300
|
||||
|
||||
def test_volume_weighted_split(self) -> None:
|
||||
df = _make_df(20)
|
||||
df.loc[6, "volume"] = 50000
|
||||
df.loc[7, "volume"] = 50000
|
||||
df.loc[8, "volume"] = 50000
|
||||
model = VWAPExecution(n_bars=3, volume_lookback=5)
|
||||
signal = Signal(datetime=20240105, direction="BUY", size=300)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=5,
|
||||
cash=100000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) == 3
|
||||
sizes = [t.size for t in trades]
|
||||
assert sum(sizes) <= 300
|
||||
|
||||
def test_truncates_at_data_end(self) -> None:
|
||||
df = _make_df(5)
|
||||
model = VWAPExecution(n_bars=10, volume_lookback=3)
|
||||
signal = Signal(datetime=20240101, direction="BUY", size=1000)
|
||||
trades = model.execute(
|
||||
signal=signal,
|
||||
df=df,
|
||||
bar_idx=0,
|
||||
cash=100000,
|
||||
position=0,
|
||||
position_mode="fixed",
|
||||
commission=0.0003,
|
||||
min_commission=5.0,
|
||||
stamp_tax=0.001,
|
||||
slippage_model=None,
|
||||
)
|
||||
assert len(trades) <= 4
|
||||
|
||||
Reference in New Issue
Block a user