diff --git a/nautilus_trader/backtest/models.pxd b/nautilus_trader/backtest/models.pxd index 1cb1ce8aafb9..c8ed73e8e8c9 100644 --- a/nautilus_trader/backtest/models.pxd +++ b/nautilus_trader/backtest/models.pxd @@ -57,3 +57,5 @@ cdef class MakerTakerFeeModel(FeeModel): cdef class FixedFeeModel(FeeModel): cdef Money _commission + cdef Money _zero_commission + cdef bint _charge_commission_once diff --git a/nautilus_trader/backtest/models.pyx b/nautilus_trader/backtest/models.pyx index 40bfcb3c0a4b..4389bfcd1784 100644 --- a/nautilus_trader/backtest/models.pyx +++ b/nautilus_trader/backtest/models.pyx @@ -244,6 +244,8 @@ cdef class FixedFeeModel(FeeModel): ---------- commission : Money The fixed commission amount for trades. + charge_commission_once : bool, default True + Whether to charge the commission once per order or per fill. Raises ------ @@ -252,10 +254,16 @@ cdef class FixedFeeModel(FeeModel): """ - def __init__(self, Money commission not None): + def __init__( + self, + Money commission not None, + bint charge_commission_once: bool = True, + ): Condition.positive(commission, "commission") self._commission = commission + self._zero_commission = Money(0, commission.currency) + self._charge_commission_once = charge_commission_once cpdef Money get_commission( self, @@ -264,4 +272,7 @@ cdef class FixedFeeModel(FeeModel): Price fill_px, Instrument instrument, ): - return self._commission + if not self._charge_commission_once or order.filled_qty == 0: + return self._commission + else: + return self._zero_commission diff --git a/tests/unit_tests/backtest/test_commission_model.py b/tests/unit_tests/backtest/test_commission_model.py index a13e59f658f8..87cc579eae40 100644 --- a/tests/unit_tests/backtest/test_commission_model.py +++ b/tests/unit_tests/backtest/test_commission_model.py @@ -23,8 +23,8 @@ from nautilus_trader.model.instruments.base import Instrument from nautilus_trader.model.objects import Money from nautilus_trader.model.objects import Price -from nautilus_trader.model.orders import Order from nautilus_trader.test_kit.providers import TestInstrumentProvider +from nautilus_trader.test_kit.stubs.events import TestEventStubs from nautilus_trader.test_kit.stubs.execution import TestExecStubs @@ -36,49 +36,89 @@ def instrument() -> Instrument: return TestInstrumentProvider.default_fx_ccy("EUR/USD") -@pytest.fixture() -def buy_order(instrument: Instrument) -> Order: - return TestExecStubs.make_filled_order( +@pytest.mark.parametrize("order_side", [OrderSide.BUY, OrderSide.SELL]) +def test_fixed_commission_single_fill(instrument, order_side): + # Arrange + expected = Money(1, USD) + fee_model = FixedFeeModel(expected) + order = TestExecStubs.make_accepted_order( instrument=instrument, - order_side=OrderSide.BUY, + order_side=order_side, ) - -@pytest.fixture() -def sell_order(instrument: Instrument) -> Order: - return TestExecStubs.make_filled_order( - instrument=instrument, - order_side=OrderSide.SELL, + # Act + commission = fee_model.get_commission( + order, + instrument.make_qty(10), + Price.from_str("1.1234"), + instrument, ) + # Assert + assert commission == expected -def test_fixed_commission(buy_order, instrument): + +@pytest.mark.parametrize( + "order_side, charge_commission_once, expected_first_fill, expected_next_fill", + [ + [OrderSide.BUY, True, Money(1, USD), Money(0, USD)], + [OrderSide.SELL, True, Money(1, USD), Money(0, USD)], + [OrderSide.BUY, False, Money(1, USD), Money(1, USD)], + [OrderSide.SELL, False, Money(1, USD), Money(1, USD)], + ], +) +def test_fixed_commission_multiple_fills( + instrument, + order_side, + charge_commission_once, + expected_first_fill, + expected_next_fill, +): # Arrange - expected = Money(1, USD) - fee_model = FixedFeeModel(expected) + fee_model = FixedFeeModel( + commission=expected_first_fill, + charge_commission_once=charge_commission_once, + ) + order = TestExecStubs.make_accepted_order( + instrument=instrument, + order_side=order_side, + ) # Act - commission = fee_model.get_commission( - buy_order, - buy_order.quantity, + commission_first_fill = fee_model.get_commission( + order, + instrument.make_qty(10), + Price.from_str("1.1234"), + instrument, + ) + fill = TestEventStubs.order_filled(order=order, instrument=instrument) + order.apply(fill) + commission_next_fill = fee_model.get_commission( + order, + instrument.make_qty(10), Price.from_str("1.1234"), instrument, ) # Assert - assert commission == expected + assert commission_first_fill == expected_first_fill + assert commission_next_fill == expected_next_fill -def test_instrument_percent_commission_maker(instrument, buy_order): +def test_instrument_percent_commission_maker(instrument): # Arrange fee_model = MakerTakerFeeModel() - expected = buy_order.quantity * buy_order.price * instrument.maker_fee + order = TestExecStubs.make_filled_order( + instrument=instrument, + order_side=OrderSide.SELL, + ) + expected = order.quantity * order.price * instrument.maker_fee # Act commission = fee_model.get_commission( - buy_order, - buy_order.quantity, - buy_order.price, + order, + order.quantity, + order.price, instrument, ) @@ -87,16 +127,20 @@ def test_instrument_percent_commission_maker(instrument, buy_order): assert commission.as_decimal() == expected -def test_instrument_percent_commission_taker(instrument, sell_order): +def test_instrument_percent_commission_taker(instrument): # Arrange fee_model = MakerTakerFeeModel() - expected = sell_order.quantity * sell_order.price * instrument.taker_fee + order = TestExecStubs.make_filled_order( + instrument=instrument, + order_side=OrderSide.SELL, + ) + expected = order.quantity * order.price * instrument.taker_fee # Act commission = fee_model.get_commission( - sell_order, - sell_order.quantity, - sell_order.price, + order, + order.quantity, + order.price, instrument, )