Skip to main content

nautilus_polymarket/common/
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//! Shared model types for the Polymarket adapter.
17
18use std::fmt::Display;
19
20use nautilus_common::cache::Cache;
21use nautilus_model::{
22    identifiers::InstrumentId,
23    instruments::{Instrument, InstrumentAny},
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27use ustr::Ustr;
28
29use crate::common::{
30    enums::{PolymarketOrderSide, PolymarketOutcome},
31    parse::{deserialize_decimal_from_str, serialize_decimal_as_str},
32};
33
34/// A maker order included in trade messages.
35///
36/// Used by both REST trade reports and WebSocket user trade updates
37/// to describe each maker-side fill in a match. The `side` field is
38/// optional because some trade-event payloads (notably user-channel WS
39/// fills) may omit it; CLOB V2 REST trade responses always include it.
40///
41/// `fee_rate_bps` is intentionally not modeled. The wire payload is unstable
42/// (the user-channel WS sometimes sends `""`) and the field is unused: maker
43/// fills always pay zero commission per Polymarket's fee policy. The official
44/// `rs-clob-client-v2` `MakerOrder` shape also omits it.
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub struct PolymarketMakerOrder {
47    pub asset_id: Ustr,
48    pub maker_address: String,
49    #[serde(
50        serialize_with = "serialize_decimal_as_str",
51        deserialize_with = "deserialize_decimal_from_str"
52    )]
53    pub matched_amount: Decimal,
54    pub order_id: String,
55    pub outcome: PolymarketOutcome,
56    pub owner: String,
57    #[serde(
58        serialize_with = "serialize_decimal_as_str",
59        deserialize_with = "deserialize_decimal_from_str"
60    )]
61    pub price: Decimal,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub side: Option<PolymarketOrderSide>,
64}
65
66/// Human-readable label for a Polymarket instrument.
67#[derive(Debug, Clone)]
68pub struct PolymarketLabel {
69    pub description: String,
70    pub outcome: String,
71}
72
73impl Display for PolymarketLabel {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        write!(f, "{} [{}]", self.description, self.outcome)
76    }
77}
78
79impl PolymarketLabel {
80    /// Build a label from an instrument reference.
81    pub fn from_instrument(instrument: &InstrumentAny) -> Self {
82        if let InstrumentAny::BinaryOption(opt) = instrument {
83            Self {
84                description: opt
85                    .description
86                    .map_or_else(|| instrument.id().to_string(), |d| d.to_string()),
87                outcome: opt
88                    .outcome
89                    .map_or_else(|| "?".to_string(), |o| o.to_string()),
90            }
91        } else {
92            Self {
93                description: instrument.id().to_string(),
94                outcome: "?".to_string(),
95            }
96        }
97    }
98
99    /// Look up an instrument by ID in the cache and build a label.
100    /// Returns `None` if the instrument is not in the cache.
101    pub fn from_cache(instrument_id: &InstrumentId, cache: &Cache) -> Option<Self> {
102        cache.instrument(instrument_id).map(Self::from_instrument)
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use rstest::rstest;
109    use rust_decimal_macros::dec;
110
111    use super::*;
112    use crate::{common::enums::PolymarketOutcome, http::models::PolymarketTradeReport};
113
114    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
115        let path = format!("test_data/{filename}");
116        let content = std::fs::read_to_string(path).expect("Failed to read test data");
117        serde_json::from_str(&content).expect("Failed to parse test data")
118    }
119
120    fn sample_maker_order_json() -> &'static str {
121        r#"{
122            "asset_id": "71321045679252212594626385532706912750332728571942532289631379312455583992563",
123            "fee_rate_bps": "10",
124            "maker_address": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
125            "matched_amount": "50.0000",
126            "order_id": "0xorder001",
127            "outcome": "Yes",
128            "owner": "00000000-0000-0000-0000-000000000002",
129            "price": "0.6000"
130        }"#
131    }
132
133    #[rstest]
134    fn test_maker_order_deserialization() {
135        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
136
137        assert_eq!(
138            order.asset_id.as_str(),
139            "71321045679252212594626385532706912750332728571942532289631379312455583992563"
140        );
141        assert_eq!(
142            order.maker_address.as_str(),
143            "0x70997970c51812dc3a010c7d01b50e0d17dc79c8"
144        );
145        assert_eq!(order.matched_amount, dec!(50.0000));
146        assert_eq!(order.order_id, "0xorder001");
147        assert_eq!(order.outcome, PolymarketOutcome::yes());
148        assert_eq!(order.price, dec!(0.6000));
149    }
150
151    #[rstest]
152    fn test_maker_order_roundtrip() {
153        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
154        let json = serde_json::to_string(&order).unwrap();
155        let order2: PolymarketMakerOrder = serde_json::from_str(&json).unwrap();
156        assert_eq!(order, order2);
157    }
158
159    #[rstest]
160    fn test_maker_order_outcome_no() {
161        let json = r#"{
162            "asset_id": "12345",
163            "fee_rate_bps": "0",
164            "maker_address": "0xaddr",
165            "matched_amount": "10.0",
166            "order_id": "order-1",
167            "outcome": "No",
168            "owner": "owner-1",
169            "price": "0.4"
170        }"#;
171        let order: PolymarketMakerOrder = serde_json::from_str(json).unwrap();
172        assert_eq!(order.outcome, PolymarketOutcome::no());
173    }
174
175    #[rstest]
176    fn test_maker_order_decimal_precision() {
177        // Verifies Decimal fields are serialized as strings (not floats)
178        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
179        let json = serde_json::to_string(&order).unwrap();
180        // Decimals must appear as quoted strings, not bare numbers
181        assert!(
182            json.contains("\"matched_amount\":\"50.0000\"")
183                || json.contains("\"matched_amount\": \"50.0000\"")
184        );
185    }
186
187    // Tests for embedded maker orders from the trade report fixture
188    #[rstest]
189    fn test_maker_orders_from_trade_report() {
190        let trade: PolymarketTradeReport = load("http_trade_report.json");
191
192        assert_eq!(trade.maker_orders.len(), 2);
193        let m0 = &trade.maker_orders[0];
194        assert_eq!(m0.matched_amount, dec!(25.0000));
195        assert_eq!(m0.outcome, PolymarketOutcome::yes());
196        assert_eq!(m0.side, Some(PolymarketOrderSide::Sell));
197
198        let m1 = &trade.maker_orders[1];
199        assert_eq!(m1.matched_amount, dec!(5.0000));
200        assert_eq!(m1.side, Some(PolymarketOrderSide::Sell));
201    }
202
203    #[rstest]
204    fn test_maker_order_without_side_is_accepted() {
205        // The legacy/WS payload shape that omits `side` must still parse,
206        // since the field is optional on `PolymarketMakerOrder`.
207        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
208        assert!(order.side.is_none());
209    }
210
211    #[rstest]
212    fn test_maker_order_with_side_is_parsed() {
213        let json = r#"{
214            "asset_id": "12345",
215            "fee_rate_bps": "0",
216            "maker_address": "0xaddr",
217            "matched_amount": "10.0",
218            "order_id": "order-1",
219            "outcome": "Yes",
220            "owner": "owner-1",
221            "price": "0.4",
222            "side": "BUY"
223        }"#;
224        let order: PolymarketMakerOrder = serde_json::from_str(json).unwrap();
225        assert_eq!(order.side, Some(PolymarketOrderSide::Buy));
226    }
227
228    #[rstest]
229    fn test_maker_order_with_empty_fee_rate_bps_is_accepted() {
230        // Production user-channel WS sometimes emits `"fee_rate_bps": ""` on
231        // maker orders. The field is unmodeled, so the empty string must not
232        // break parsing. Mirrors the official `rs-clob-client-v2` shape.
233        let json = r#"{
234            "asset_id": "12345",
235            "fee_rate_bps": "",
236            "maker_address": "0xaddr",
237            "matched_amount": "10.0",
238            "order_id": "order-1",
239            "outcome": "Yes",
240            "owner": "owner-1",
241            "price": "0.4"
242        }"#;
243        let order: PolymarketMakerOrder = serde_json::from_str(json).unwrap();
244        assert_eq!(order.matched_amount, dec!(10.0));
245    }
246}