Skip to main content

nautilus_polymarket/
models.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Polymarket simulation models.
17
18use 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/// Polymarket fee model for binary-option backtests.
33///
34/// Taker fills pay the market's fee-equivalent amount. Maker fills receive a
35/// per-fill approximation of the daily maker rebate by applying the market's
36/// configured rebate rate to that fee-equivalent amount.
37#[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}