Skip to content

Commit 0662a02

Browse files
authored
Fix per-contract fees for option spreads (#4363)
1 parent dc4b4b3 commit 0662a02

3 files changed

Lines changed: 114 additions & 4 deletions

File tree

crates/execution/src/models/fee.rs

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,41 @@ impl FeeModel for PerContractFeeModel {
309309
_order: &OrderAny,
310310
fill_quantity: Quantity,
311311
_fill_px: Price,
312-
_instrument: &InstrumentAny,
312+
instrument: &InstrumentAny,
313313
) -> anyhow::Result<Money> {
314-
let total = self.commission.as_decimal() * fill_quantity.as_decimal();
314+
let total = self.commission.as_decimal()
315+
* fill_quantity.as_decimal()
316+
* spread_contract_count(instrument);
315317
Money::from_decimal(total, self.commission.currency).map_err(Into::into)
316318
}
317319
}
318320

321+
fn spread_contract_count(instrument: &InstrumentAny) -> Decimal {
322+
let symbol = instrument.id().symbol.to_string();
323+
if !symbol.contains("___") {
324+
return Decimal::ONE;
325+
}
326+
327+
symbol
328+
.split("___")
329+
.filter_map(spread_leg_ratio)
330+
.sum::<i64>()
331+
.max(1)
332+
.into()
333+
}
334+
335+
fn spread_leg_ratio(component: &str) -> Option<i64> {
336+
let ratio = component
337+
.strip_prefix("((")
338+
.and_then(|rest| rest.split_once("))").map(|(ratio, _)| ratio))
339+
.or_else(|| {
340+
component
341+
.strip_prefix('(')
342+
.and_then(|rest| rest.split_once(')').map(|(ratio, _)| ratio))
343+
})?;
344+
ratio.parse::<i64>().ok()
345+
}
346+
319347
#[derive(Debug, Clone)]
320348
#[cfg_attr(
321349
feature = "python",
@@ -605,9 +633,13 @@ mod tests {
605633

606634
use nautilus_model::{
607635
enums::{LiquiditySide, OrderSide, OrderType},
636+
identifiers::InstrumentId,
608637
instruments::{
609638
BinaryOption, CryptoOption, Instrument, InstrumentAny, OptionContract,
610-
stubs::{audusd_sim, binary_option, crypto_option_btc_deribit, option_contract_appl},
639+
stubs::{
640+
audusd_sim, binary_option, crypto_option_btc_deribit, option_contract_appl,
641+
option_spread,
642+
},
611643
},
612644
orders::{
613645
Order, OrderAny,
@@ -785,6 +817,33 @@ mod tests {
785817
assert_eq!(commission, Money::new(50.0, Currency::USD()));
786818
}
787819

820+
#[rstest]
821+
fn test_per_contract_fee_model_option_spread_charges_each_contract() {
822+
let commission_per_contract = Money::from("1.25 USD");
823+
let fee_model = PerContractFeeModel::new(commission_per_contract).unwrap();
824+
let spread_id = InstrumentId::from("((2))SPY C410___(1)SPY C400.SMART");
825+
let mut option_spread = option_spread();
826+
option_spread.id = spread_id;
827+
let instrument = InstrumentAny::OptionSpread(option_spread);
828+
let market_order = OrderTestBuilder::new(OrderType::Market)
829+
.instrument_id(instrument.id())
830+
.side(OrderSide::Buy)
831+
.quantity(Quantity::from(2))
832+
.build();
833+
let accepted_order = TestOrderStubs::make_accepted_order(&market_order);
834+
835+
let commission = fee_model
836+
.get_commission(
837+
&accepted_order,
838+
Quantity::from(2),
839+
Price::from("1.0"),
840+
&instrument,
841+
)
842+
.unwrap();
843+
844+
assert_eq!(commission, Money::from("7.50 USD"));
845+
}
846+
788847
#[rstest]
789848
fn test_per_contract_fee_model_partial_fill() {
790849
let commission_per_contract = Money::new(1.25, Currency::USD());

nautilus_trader/backtest/models/fee.pyx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ from nautilus_trader.core.rust.core cimport NANOSECONDS_IN_MILLISECOND
2222
from nautilus_trader.core.rust.model cimport LiquiditySide
2323
from nautilus_trader.model.book cimport OrderBook
2424
from nautilus_trader.model.functions cimport liquidity_side_to_str
25+
from nautilus_trader.model.identifiers cimport generic_spread_id_n_legs
2526
from nautilus_trader.model.instruments.base cimport Instrument
2627
from nautilus_trader.model.objects cimport Money
2728
from nautilus_trader.model.objects cimport Price
@@ -205,4 +206,7 @@ cdef class PerContractFeeModel(FeeModel):
205206
Price fill_px,
206207
Instrument instrument,
207208
):
208-
return Money(self._commission * fill_qty, self._commission.currency)
209+
return Money(
210+
self._commission * fill_qty * generic_spread_id_n_legs(instrument.id),
211+
self._commission.currency,
212+
)

tests/unit_tests/backtest/test_commission_model.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,19 @@
1919

2020
from nautilus_trader.backtest.models import FixedFeeModel
2121
from nautilus_trader.backtest.models import MakerTakerFeeModel
22+
from nautilus_trader.backtest.models import PerContractFeeModel
2223
from nautilus_trader.model.currencies import BTC
2324
from nautilus_trader.model.currencies import USD
25+
from nautilus_trader.model.enums import AssetClass
2426
from nautilus_trader.model.enums import OptionKind
2527
from nautilus_trader.model.enums import OrderSide
2628
from nautilus_trader.model.identifiers import InstrumentId
2729
from nautilus_trader.model.identifiers import Symbol
2830
from nautilus_trader.model.identifiers import Venue
31+
from nautilus_trader.model.identifiers import new_generic_spread_id
2932
from nautilus_trader.model.instruments import CryptoOption
3033
from nautilus_trader.model.instruments import Instrument
34+
from nautilus_trader.model.instruments import OptionSpread
3135
from nautilus_trader.model.objects import Money
3236
from nautilus_trader.model.objects import Price
3337
from nautilus_trader.model.objects import Quantity
@@ -110,6 +114,49 @@ def test_fixed_commission_multiple_fills(
110114
assert commission_next_fill == expected_next_fill
111115

112116

117+
def test_per_contract_commission_option_spread_charges_each_contract():
118+
# Arrange
119+
spread_id = new_generic_spread_id(
120+
[
121+
(InstrumentId.from_str("SPY C400.SMART"), 1),
122+
(InstrumentId.from_str("SPY C410.SMART"), -2),
123+
],
124+
)
125+
instrument = OptionSpread(
126+
instrument_id=spread_id,
127+
raw_symbol=spread_id.symbol,
128+
asset_class=AssetClass.EQUITY,
129+
currency=USD,
130+
price_precision=2,
131+
price_increment=Price.from_str("0.01"),
132+
multiplier=Quantity.from_int(100),
133+
lot_size=Quantity.from_int(1),
134+
underlying="SPY",
135+
strategy_type="SPREAD",
136+
activation_ns=0,
137+
expiration_ns=0,
138+
ts_event=0,
139+
ts_init=0,
140+
)
141+
fee_model = PerContractFeeModel(Money(Decimal("1.25"), USD))
142+
order = TestExecStubs.make_accepted_order(
143+
instrument=instrument,
144+
order_side=OrderSide.BUY,
145+
quantity=Quantity.from_int(2),
146+
)
147+
148+
# Act
149+
commission = fee_model.get_commission(
150+
order,
151+
Quantity.from_int(2),
152+
Price.from_str("1.00"),
153+
instrument,
154+
)
155+
156+
# Assert
157+
assert commission == Money(Decimal("7.50"), USD)
158+
159+
113160
def test_instrument_percent_commission_maker(instrument):
114161
# Arrange
115162
fee_model = MakerTakerFeeModel()

0 commit comments

Comments
 (0)