1use anyhow::Context;
19use nautilus_execution::models::fee::FeeModel;
20#[cfg(feature = "python")]
21use nautilus_execution::python::fee::PyFeeModel;
22use nautilus_model::{
23 enums::LiquiditySide,
24 instruments::{Instrument, InstrumentAny},
25 orders::{Order, OrderAny},
26 types::{Money, Price, Quantity},
27};
28use rust_decimal::Decimal;
29
30use crate::http::models::FeeSchedule;
31
32#[derive(Debug, Clone, Copy, Default)]
38#[cfg_attr(
39 feature = "python",
40 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
41)]
42#[cfg_attr(
43 feature = "python",
44 pyo3::pyclass(
45 module = "nautilus_trader.adapters.polymarket",
46 extends = PyFeeModel,
47 skip_from_py_object
48 )
49)]
50pub struct PolymarketFeeModel;
51
52impl FeeModel for PolymarketFeeModel {
53 fn get_commission(
54 &self,
55 order: &OrderAny,
56 fill_quantity: Quantity,
57 fill_px: Price,
58 instrument: &InstrumentAny,
59 ) -> anyhow::Result<Money> {
60 let InstrumentAny::BinaryOption(binary) = instrument else {
61 anyhow::bail!("PolymarketFeeModel requires a binary option instrument");
62 };
63
64 let liquidity_side = match order.liquidity_side() {
65 Some(LiquiditySide::Maker) => LiquiditySide::Maker,
66 Some(LiquiditySide::Taker) => LiquiditySide::Taker,
67 Some(LiquiditySide::NoLiquiditySide) | None => {
68 anyhow::bail!("Liquidity side not set")
69 }
70 };
71
72 let Some(schedule) = binary
73 .info
74 .as_ref()
75 .and_then(|info| info.get("fee_schedule"))
76 .map(|value| serde_json::from_value::<FeeSchedule>(value.clone()))
77 .transpose()
78 .context("invalid Polymarket fee schedule")?
79 else {
80 return Ok(Money::zero(instrument.quote_currency()));
81 };
82
83 validate_schedule(&schedule)?;
84
85 let fill_price = fill_px.as_decimal();
86 if !(Decimal::ZERO..=Decimal::ONE).contains(&fill_price) {
87 anyhow::bail!("PolymarketFeeModel requires a fill price in [0, 1]");
88 }
89
90 let fee_equivalent = fill_quantity
91 .as_decimal()
92 .checked_mul(schedule.rate)
93 .and_then(|value| value.checked_mul(fill_price))
94 .and_then(|value| value.checked_mul(Decimal::ONE - fill_price))
95 .context("commission calculation overflow")?;
96 let commission = match liquidity_side {
97 LiquiditySide::Maker => -fee_equivalent
98 .checked_mul(schedule.rebate_rate)
99 .context("commission calculation overflow")?
100 .round_dp(5),
101 LiquiditySide::Taker => fee_equivalent.round_dp(5),
102 LiquiditySide::NoLiquiditySide => unreachable!(),
103 };
104
105 Money::from_decimal(commission, instrument.quote_currency()).map_err(Into::into)
106 }
107}
108
109fn validate_schedule(schedule: &FeeSchedule) -> anyhow::Result<()> {
110 if schedule.exponent != Decimal::ONE {
111 anyhow::bail!(
112 "PolymarketFeeModel requires fee schedule exponent 1, was {}",
113 schedule.exponent
114 );
115 }
116
117 if schedule.rate < Decimal::ZERO {
118 anyhow::bail!("Polymarket fee rate must be greater than or equal to zero");
119 }
120
121 if !(Decimal::ZERO..=Decimal::ONE).contains(&schedule.rebate_rate) {
122 anyhow::bail!("Polymarket rebate rate must be in [0, 1]");
123 }
124
125 if !schedule.taker_only {
126 anyhow::bail!("PolymarketFeeModel requires a taker-only fee schedule");
127 }
128 Ok(())
129}
130
131#[cfg(test)]
132mod tests {
133 use nautilus_core::UnixNanos;
134 use nautilus_execution::models::fee::{FeeModel, FeeModelHandle};
135 use nautilus_model::{
136 enums::{LiquiditySide, OrderSide, OrderType},
137 instruments::{Instrument, InstrumentAny, stubs::audusd_sim},
138 orders::{OrderAny, builder::OrderTestBuilder, stubs::TestOrderStubs},
139 types::{Price, Quantity},
140 };
141 use rstest::rstest;
142 use rust_decimal::Decimal;
143 use rust_decimal_macros::dec;
144
145 use super::PolymarketFeeModel;
146 use crate::http::{
147 models::{FeeSchedule, GammaMarket},
148 parse::{create_instrument_from_def, parse_gamma_market},
149 };
150
151 #[rstest]
152 #[case(dec!(0.07), dec!(0.20), dec!(-0.35))]
153 #[case(dec!(0.05), dec!(0.15), dec!(-0.1875))]
154 #[case(dec!(0.04), dec!(0.25), dec!(-0.25))]
155 #[case(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO)]
156 fn test_maker_rebate_rates(
157 #[case] fee_rate: Decimal,
158 #[case] rebate_rate: Decimal,
159 #[case] expected: Decimal,
160 ) {
161 let instrument = instrument_with_schedule(Some(fee_schedule(fee_rate, rebate_rate)));
162 let order = fill_order(&instrument, LiquiditySide::Maker);
163
164 let commission = PolymarketFeeModel
165 .get_commission(
166 &order,
167 Quantity::from("100"),
168 Price::from("0.50"),
169 &instrument,
170 )
171 .unwrap();
172
173 assert_eq!(commission.as_decimal(), expected);
174 assert_eq!(commission.currency, instrument.quote_currency());
175 }
176
177 #[rstest]
178 fn test_taker_fee() {
179 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.05), dec!(0.15))));
180 let order = fill_order(&instrument, LiquiditySide::Taker);
181
182 let commission = PolymarketFeeModel
183 .get_commission(
184 &order,
185 Quantity::from("100"),
186 Price::from("0.50"),
187 &instrument,
188 )
189 .unwrap();
190
191 assert_eq!(commission.as_decimal(), dec!(1.25));
192 assert_eq!(commission.currency, instrument.quote_currency());
193 }
194
195 #[rstest]
196 #[case("0.01", dec!(0.00000))]
197 #[case("0.02", dec!(0.00001))]
198 fn test_taker_fee_rounds_to_five_decimal_places(
199 #[case] fill_quantity: &str,
200 #[case] expected: Decimal,
201 ) {
202 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.05), dec!(0.15))));
203 let order = fill_order(&instrument, LiquiditySide::Taker);
204
205 let commission = PolymarketFeeModel
206 .get_commission(
207 &order,
208 Quantity::from(fill_quantity),
209 Price::from("0.01"),
210 &instrument,
211 )
212 .unwrap();
213
214 assert_eq!(commission.as_decimal(), expected);
215 }
216
217 #[rstest]
218 #[case("0.06", dec!(0.00000))]
219 #[case("0.07", dec!(-0.00001))]
220 fn test_maker_rebate_rounds_to_five_decimal_places(
221 #[case] fill_quantity: &str,
222 #[case] expected: Decimal,
223 ) {
224 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.05), dec!(0.15))));
225 let order = fill_order(&instrument, LiquiditySide::Maker);
226
227 let commission = PolymarketFeeModel
228 .get_commission(
229 &order,
230 Quantity::from(fill_quantity),
231 Price::from("0.01"),
232 &instrument,
233 )
234 .unwrap();
235
236 assert_eq!(commission.as_decimal(), expected);
237 }
238
239 #[rstest]
240 fn test_missing_fee_schedule_returns_zero() {
241 let instrument = instrument_with_schedule(None);
242 let order = fill_order(&instrument, LiquiditySide::Maker);
243
244 let commission = PolymarketFeeModel
245 .get_commission(
246 &order,
247 Quantity::from("100"),
248 Price::from("0.50"),
249 &instrument,
250 )
251 .unwrap();
252
253 assert_eq!(commission.as_decimal(), Decimal::ZERO);
254 assert_eq!(commission.currency, instrument.quote_currency());
255 }
256
257 #[rstest]
258 fn test_runtime_handle_dispatches_polymarket_model() {
259 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.07), dec!(0.20))));
260 let order = fill_order(&instrument, LiquiditySide::Maker);
261 let model = FeeModelHandle::new(PolymarketFeeModel);
262
263 let commission = model
264 .get_commission(
265 &order,
266 Quantity::from("100"),
267 Price::from("0.50"),
268 &instrument,
269 )
270 .unwrap();
271
272 assert_eq!(commission.as_decimal(), dec!(-0.35));
273 }
274
275 #[rstest]
276 fn test_requires_binary_option() {
277 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
278 let order = fill_order(&instrument, LiquiditySide::Taker);
279
280 let result = PolymarketFeeModel.get_commission(
281 &order,
282 Quantity::from("100"),
283 Price::from("0.50"),
284 &instrument,
285 );
286
287 assert_eq!(
288 result.unwrap_err().to_string(),
289 "PolymarketFeeModel requires a binary option instrument"
290 );
291 }
292
293 #[rstest]
294 fn test_requires_liquidity_side() {
295 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.05), dec!(0.15))));
296 let order = OrderTestBuilder::new(OrderType::Limit)
297 .instrument_id(instrument.id())
298 .side(OrderSide::Buy)
299 .price(Price::from("0.50"))
300 .quantity(Quantity::from("100"))
301 .build();
302 let order = TestOrderStubs::make_accepted_order(&order);
303
304 let result = PolymarketFeeModel.get_commission(
305 &order,
306 Quantity::from("100"),
307 Price::from("0.50"),
308 &instrument,
309 );
310
311 assert_eq!(result.unwrap_err().to_string(), "Liquidity side not set");
312 }
313
314 #[rstest]
315 #[case(
316 dec!(2),
317 dec!(0.05),
318 dec!(0.15),
319 true,
320 "PolymarketFeeModel requires fee schedule exponent 1, was 2"
321 )]
322 #[case(
323 dec!(1),
324 dec!(-0.01),
325 dec!(0.15),
326 true,
327 "Polymarket fee rate must be greater than or equal to zero"
328 )]
329 #[case(
330 dec!(1),
331 dec!(0.05),
332 dec!(-0.01),
333 true,
334 "Polymarket rebate rate must be in [0, 1]"
335 )]
336 #[case(
337 dec!(1),
338 dec!(0.05),
339 dec!(1.01),
340 true,
341 "Polymarket rebate rate must be in [0, 1]"
342 )]
343 #[case(
344 dec!(1),
345 dec!(0.05),
346 dec!(0.15),
347 false,
348 "PolymarketFeeModel requires a taker-only fee schedule"
349 )]
350 fn test_requires_supported_schedule(
351 #[case] exponent: Decimal,
352 #[case] rate: Decimal,
353 #[case] rebate_rate: Decimal,
354 #[case] taker_only: bool,
355 #[case] expected: &str,
356 ) {
357 let schedule = FeeSchedule {
358 exponent,
359 rate,
360 taker_only,
361 rebate_rate,
362 };
363 let instrument = instrument_with_schedule(Some(schedule));
364 let order = fill_order(&instrument, LiquiditySide::Taker);
365
366 let result = PolymarketFeeModel.get_commission(
367 &order,
368 Quantity::from("100"),
369 Price::from("0.50"),
370 &instrument,
371 );
372
373 assert_eq!(result.unwrap_err().to_string(), expected);
374 }
375
376 fn fee_schedule(rate: Decimal, rebate_rate: Decimal) -> FeeSchedule {
377 FeeSchedule {
378 exponent: Decimal::ONE,
379 rate,
380 taker_only: true,
381 rebate_rate,
382 }
383 }
384
385 fn instrument_with_schedule(schedule: Option<FeeSchedule>) -> InstrumentAny {
386 let mut market: GammaMarket = serde_json::from_str(include_str!(
387 "../test_data/gamma_market_sports_market_money_line.json"
388 ))
389 .unwrap();
390 market.fee_schedule = schedule;
391 let def = parse_gamma_market(&market).unwrap().remove(0);
392 create_instrument_from_def(&def, UnixNanos::default()).unwrap()
393 }
394
395 fn fill_order(instrument: &InstrumentAny, liquidity_side: LiquiditySide) -> OrderAny {
396 let order = OrderTestBuilder::new(OrderType::Limit)
397 .instrument_id(instrument.id())
398 .side(OrderSide::Buy)
399 .price(Price::from("0.50"))
400 .quantity(Quantity::from("100"))
401 .build();
402 TestOrderStubs::make_filled_order(&order, instrument, liquidity_side)
403 }
404}