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