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 fn test_fee_model_preserves_instrument_metadata_precision() {
153 let rate = dec!(0.1234567890123456789012345678);
154 let instrument = instrument_with_schedule(Some(fee_schedule(
155 rate,
156 dec!(0.2345678901234567890123456789),
157 )));
158 let order = fill_order(&instrument, LiquiditySide::Taker);
159 let commission = PolymarketFeeModel
160 .get_commission(
161 &order,
162 Quantity::from("30000000000000"),
163 Price::from("0.50"),
164 &instrument,
165 )
166 .unwrap();
167 assert_eq!(commission.as_decimal(), dec!(925925917592.59259));
168 let InstrumentAny::BinaryOption(binary) = &instrument else {
169 unreachable!()
170 };
171 let restored: FeeSchedule = serde_json::from_value(
172 binary
173 .info
174 .as_ref()
175 .unwrap()
176 .get("fee_schedule")
177 .unwrap()
178 .clone(),
179 )
180 .unwrap();
181 assert_eq!(restored.rate, rate);
182 assert_eq!(restored.rebate_rate, dec!(0.2345678901234567890123456789));
183 assert_eq!(restored.exponent, Decimal::ONE);
184 assert!(restored.taker_only);
185 }
186
187 #[rstest]
188 #[case(dec!(0.07), dec!(0.20), dec!(-0.35))]
189 #[case(dec!(0.05), dec!(0.15), dec!(-0.1875))]
190 #[case(dec!(0.04), dec!(0.25), dec!(-0.25))]
191 #[case(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO)]
192 fn test_maker_rebate_rates(
193 #[case] fee_rate: Decimal,
194 #[case] rebate_rate: Decimal,
195 #[case] expected: Decimal,
196 ) {
197 let instrument = instrument_with_schedule(Some(fee_schedule(fee_rate, rebate_rate)));
198 let order = fill_order(&instrument, LiquiditySide::Maker);
199
200 let commission = PolymarketFeeModel
201 .get_commission(
202 &order,
203 Quantity::from("100"),
204 Price::from("0.50"),
205 &instrument,
206 )
207 .unwrap();
208
209 assert_eq!(commission.as_decimal(), expected);
210 assert_eq!(commission.currency, instrument.quote_currency());
211 }
212
213 #[rstest]
214 fn test_taker_fee() {
215 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.05), dec!(0.15))));
216 let order = fill_order(&instrument, LiquiditySide::Taker);
217
218 let commission = PolymarketFeeModel
219 .get_commission(
220 &order,
221 Quantity::from("100"),
222 Price::from("0.50"),
223 &instrument,
224 )
225 .unwrap();
226
227 assert_eq!(commission.as_decimal(), dec!(1.25));
228 assert_eq!(commission.currency, instrument.quote_currency());
229 }
230
231 #[rstest]
232 #[case("0.01", dec!(0.00000))]
233 #[case("0.02", dec!(0.00001))]
234 fn test_taker_fee_rounds_to_five_decimal_places(
235 #[case] fill_quantity: &str,
236 #[case] expected: Decimal,
237 ) {
238 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.05), dec!(0.15))));
239 let order = fill_order(&instrument, LiquiditySide::Taker);
240
241 let commission = PolymarketFeeModel
242 .get_commission(
243 &order,
244 Quantity::from(fill_quantity),
245 Price::from("0.01"),
246 &instrument,
247 )
248 .unwrap();
249
250 assert_eq!(commission.as_decimal(), expected);
251 }
252
253 #[rstest]
254 #[case("0.06", dec!(0.00000))]
255 #[case("0.07", dec!(-0.00001))]
256 fn test_maker_rebate_rounds_to_five_decimal_places(
257 #[case] fill_quantity: &str,
258 #[case] expected: Decimal,
259 ) {
260 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.05), dec!(0.15))));
261 let order = fill_order(&instrument, LiquiditySide::Maker);
262
263 let commission = PolymarketFeeModel
264 .get_commission(
265 &order,
266 Quantity::from(fill_quantity),
267 Price::from("0.01"),
268 &instrument,
269 )
270 .unwrap();
271
272 assert_eq!(commission.as_decimal(), expected);
273 }
274
275 #[rstest]
276 fn test_missing_fee_schedule_returns_zero() {
277 let instrument = instrument_with_schedule(None);
278 let order = fill_order(&instrument, LiquiditySide::Maker);
279
280 let commission = PolymarketFeeModel
281 .get_commission(
282 &order,
283 Quantity::from("100"),
284 Price::from("0.50"),
285 &instrument,
286 )
287 .unwrap();
288
289 assert_eq!(commission.as_decimal(), Decimal::ZERO);
290 assert_eq!(commission.currency, instrument.quote_currency());
291 }
292
293 #[rstest]
294 fn test_runtime_handle_dispatches_polymarket_model() {
295 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.07), dec!(0.20))));
296 let order = fill_order(&instrument, LiquiditySide::Maker);
297 let model = FeeModelHandle::new(PolymarketFeeModel);
298
299 let commission = model
300 .get_commission(
301 &order,
302 Quantity::from("100"),
303 Price::from("0.50"),
304 &instrument,
305 )
306 .unwrap();
307
308 assert_eq!(commission.as_decimal(), dec!(-0.35));
309 }
310
311 #[rstest]
312 fn test_requires_binary_option() {
313 let instrument = InstrumentAny::CurrencyPair(audusd_sim());
314 let order = fill_order(&instrument, LiquiditySide::Taker);
315
316 let result = PolymarketFeeModel.get_commission(
317 &order,
318 Quantity::from("100"),
319 Price::from("0.50"),
320 &instrument,
321 );
322
323 assert_eq!(
324 result.unwrap_err().to_string(),
325 "PolymarketFeeModel requires a binary option instrument"
326 );
327 }
328
329 #[rstest]
330 fn test_requires_liquidity_side() {
331 let instrument = instrument_with_schedule(Some(fee_schedule(dec!(0.05), dec!(0.15))));
332 let order = OrderTestBuilder::new(OrderType::Limit)
333 .instrument_id(instrument.id())
334 .side(OrderSide::Buy)
335 .price(Price::from("0.50"))
336 .quantity(Quantity::from("100"))
337 .build();
338 let order = TestOrderStubs::make_accepted_order(&order);
339
340 let result = PolymarketFeeModel.get_commission(
341 &order,
342 Quantity::from("100"),
343 Price::from("0.50"),
344 &instrument,
345 );
346
347 assert_eq!(result.unwrap_err().to_string(), "Liquidity side not set");
348 }
349
350 #[rstest]
351 #[case(
352 dec!(2),
353 dec!(0.05),
354 dec!(0.15),
355 true,
356 "PolymarketFeeModel requires fee schedule exponent 1, was 2"
357 )]
358 #[case(
359 dec!(1),
360 dec!(-0.01),
361 dec!(0.15),
362 true,
363 "Polymarket fee rate must be greater than or equal to zero"
364 )]
365 #[case(
366 dec!(1),
367 dec!(0.05),
368 dec!(-0.01),
369 true,
370 "Polymarket rebate rate must be in [0, 1]"
371 )]
372 #[case(
373 dec!(1),
374 dec!(0.05),
375 dec!(1.01),
376 true,
377 "Polymarket rebate rate must be in [0, 1]"
378 )]
379 #[case(
380 dec!(1),
381 dec!(0.05),
382 dec!(0.15),
383 false,
384 "PolymarketFeeModel requires a taker-only fee schedule"
385 )]
386 fn test_requires_supported_schedule(
387 #[case] exponent: Decimal,
388 #[case] rate: Decimal,
389 #[case] rebate_rate: Decimal,
390 #[case] taker_only: bool,
391 #[case] expected: &str,
392 ) {
393 let schedule = FeeSchedule {
394 exponent,
395 rate,
396 taker_only,
397 rebate_rate,
398 };
399 let instrument = instrument_with_schedule(Some(schedule));
400 let order = fill_order(&instrument, LiquiditySide::Taker);
401
402 let result = PolymarketFeeModel.get_commission(
403 &order,
404 Quantity::from("100"),
405 Price::from("0.50"),
406 &instrument,
407 );
408
409 assert_eq!(result.unwrap_err().to_string(), expected);
410 }
411
412 fn fee_schedule(rate: Decimal, rebate_rate: Decimal) -> FeeSchedule {
413 FeeSchedule {
414 exponent: Decimal::ONE,
415 rate,
416 taker_only: true,
417 rebate_rate,
418 }
419 }
420
421 fn instrument_with_schedule(schedule: Option<FeeSchedule>) -> InstrumentAny {
422 let mut market: GammaMarket = serde_json::from_str(include_str!(
423 "../test_data/gamma_market_sports_market_money_line.json"
424 ))
425 .unwrap();
426 market.fee_schedule = schedule;
427 let def = parse_gamma_market(&market).unwrap().remove(0);
428 create_instrument_from_def(&def, UnixNanos::default()).unwrap()
429 }
430
431 fn fill_order(instrument: &InstrumentAny, liquidity_side: LiquiditySide) -> OrderAny {
432 let order = OrderTestBuilder::new(OrderType::Limit)
433 .instrument_id(instrument.id())
434 .side(OrderSide::Buy)
435 .price(Price::from("0.50"))
436 .quantity(Quantity::from("100"))
437 .build();
438 TestOrderStubs::make_filled_order(&order, instrument, liquidity_side)
439 }
440}