diff --git a/src/easy_tdx/backtest/slippage.py b/src/easy_tdx/backtest/slippage.py new file mode 100644 index 0000000..7ac581d --- /dev/null +++ b/src/easy_tdx/backtest/slippage.py @@ -0,0 +1,69 @@ +"""可插拔滑点模型。""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class SlippageModel(ABC): + """滑点模型基类。 + + 所有滑点模型必须实现 compute() 方法,返回总滑点成本(金额)。 + """ + + @abstractmethod + def compute( + self, + price: float, + size: float, + volume: float, + volatility: float, + direction: str, + ) -> float: + """计算滑点成本。 + + Args: + price: 成交价格 + size: 订单数量(股) + volume: 当日成交量(股),0 表示无数据 + volatility: 近期年化波动率,0 表示无数据 + direction: 交易方向 BUY / SELL + + Returns: + 总滑点成本(金额,非比率) + """ + ... + + +class FixedSlippage(SlippageModel): + """固定每股滑点(向后兼容)。""" + + def __init__(self, per_share: float = 0.01) -> None: + self._per_share = per_share + + def compute( + self, + price: float, + size: float, + volume: float, + volatility: float, + direction: str, + ) -> float: + return size * self._per_share + + +class PercentSlippage(SlippageModel): + """按成交金额百分比滑点。""" + + def __init__(self, rate: float = 0.001) -> None: + self._rate = rate + + def compute( + self, + price: float, + size: float, + volume: float, + volatility: float, + direction: str, + ) -> float: + return price * size * self._rate diff --git a/tests/unit/test_backtest_slippage.py b/tests/unit/test_backtest_slippage.py new file mode 100644 index 0000000..241ef8f --- /dev/null +++ b/tests/unit/test_backtest_slippage.py @@ -0,0 +1,87 @@ +"""滑点模型单元测试。""" + +from __future__ import annotations + +import pytest + +from easy_tdx.backtest.slippage import ( + FixedSlippage, + PercentSlippage, + SlippageModel, +) + + +class TestSlippageBase: + """基类验证。""" + + def test_cannot_instantiate_abc(self) -> None: + """不能直接实例化 ABC。""" + with pytest.raises(TypeError): + SlippageModel() # type: ignore[abstract] + + def test_subclass_must_implement_compute(self) -> None: + """子类必须实现 compute。""" + + class BadModel(SlippageModel): + pass + + with pytest.raises(TypeError): + BadModel() # type: ignore[abstract] + + +class TestFixedSlippage: + """固定每股滑点。""" + + def test_zero_per_share(self) -> None: + """per_share=0 时无滑点。""" + model = FixedSlippage(per_share=0.0) + cost = model.compute(price=10.0, size=100, volume=10000, volatility=0.3, direction="BUY") + assert cost == 0.0 + + def test_basic(self) -> None: + """基本计算:100 股 × 0.01 元/股 = 1.0。""" + model = FixedSlippage(per_share=0.01) + cost = model.compute(price=10.0, size=100, volume=10000, volatility=0.3, direction="BUY") + assert cost == pytest.approx(1.0) + + def test_large_size(self) -> None: + """大单。""" + model = FixedSlippage(per_share=0.05) + cost = model.compute( + price=50.0, size=10000, volume=500000, volatility=0.2, direction="SELL" + ) + assert cost == pytest.approx(500.0) + + def test_direction_irrelevant(self) -> None: + """方向不影响固定滑点。""" + model = FixedSlippage(per_share=0.01) + buy_cost = model.compute( + price=10.0, size=100, volume=10000, volatility=0.3, direction="BUY" + ) + sell_cost = model.compute( + price=10.0, size=100, volume=10000, volatility=0.3, direction="SELL" + ) + assert buy_cost == sell_cost + + +class TestPercentSlippage: + """按成交金额百分比滑点。""" + + def test_zero_rate(self) -> None: + """rate=0 时无滑点。""" + model = PercentSlippage(rate=0.0) + cost = model.compute(price=10.0, size=100, volume=10000, volatility=0.3, direction="BUY") + assert cost == 0.0 + + def test_basic(self) -> None: + """10元 × 100股 × 0.001 = 1.0。""" + model = PercentSlippage(rate=0.001) + cost = model.compute(price=10.0, size=100, volume=10000, volatility=0.3, direction="BUY") + assert cost == pytest.approx(1.0) + + def test_high_price(self) -> None: + """高价股。""" + model = PercentSlippage(rate=0.002) + cost = model.compute(price=100.0, size=500, volume=20000, volatility=0.25, direction="BUY") + # 100 × 500 × 0.002 = 100.0 + assert cost == pytest.approx(100.0)