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    #[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}