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;
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        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}