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/// Returns whether an execution payload belongs to the configured account.
35#[must_use]
36pub(crate) fn is_owned_by_account(
37    maker_address: &str,
38    owner: &str,
39    user_address: &str,
40    api_key: &str,
41) -> bool {
42    maker_address.eq_ignore_ascii_case(user_address) || owner == api_key
43}
44
45/// A maker order included in trade messages.
46///
47/// Used by both REST trade reports and WebSocket user trade updates
48/// to describe each maker-side fill in a match. The `side` field is
49/// optional because some trade-event payloads (notably user-channel WS
50/// fills) may omit it; CLOB V2 REST trade responses always include it.
51///
52/// `fee_rate_bps` is intentionally not modeled. The wire payload is unstable
53/// (the user-channel WS sometimes sends `""`) and the field is unused: maker
54/// fills always pay zero commission per Polymarket's fee policy. The official
55/// `rs-clob-client-v2` `MakerOrder` shape also omits it.
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57pub struct PolymarketMakerOrder {
58    pub asset_id: Ustr,
59    pub maker_address: String,
60    #[serde(
61        serialize_with = "serialize_decimal_as_str",
62        deserialize_with = "deserialize_decimal_from_str"
63    )]
64    pub matched_amount: Decimal,
65    pub order_id: String,
66    pub outcome: PolymarketOutcome,
67    pub owner: String,
68    #[serde(
69        serialize_with = "serialize_decimal_as_str",
70        deserialize_with = "deserialize_decimal_from_str"
71    )]
72    pub price: Decimal,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub side: Option<PolymarketOrderSide>,
75}
76
77impl PolymarketMakerOrder {
78    /// Returns whether this maker order belongs to the account identified by
79    /// `user_address` and `api_key`.
80    ///
81    /// The address comparison ignores ASCII case: hex letter case carries no
82    /// account identity (EIP-55 checksumming encodes only a display checksum),
83    /// and the two sides routinely disagree: recorded venue payloads carry
84    /// checksummed maker addresses while configured funder addresses are
85    /// commonly lowercase, or vice versa. The API-key comparison stays exact
86    /// because keys are opaque credentials.
87    #[must_use]
88    pub(crate) fn is_owned_by(&self, user_address: &str, api_key: &str) -> bool {
89        is_owned_by_account(&self.maker_address, &self.owner, user_address, api_key)
90    }
91}
92
93/// Human-readable label for a Polymarket instrument.
94#[derive(Debug, Clone)]
95pub struct PolymarketLabel {
96    pub description: String,
97    pub outcome: String,
98}
99
100impl Display for PolymarketLabel {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        write!(f, "{} [{}]", self.description, self.outcome)
103    }
104}
105
106impl PolymarketLabel {
107    /// Build a label from an instrument reference.
108    pub fn from_instrument(instrument: &InstrumentAny) -> Self {
109        if let InstrumentAny::BinaryOption(opt) = instrument {
110            Self {
111                description: opt
112                    .description
113                    .map_or_else(|| instrument.id().to_string(), |d| d.to_string()),
114                outcome: opt
115                    .outcome
116                    .map_or_else(|| "?".to_string(), |o| o.to_string()),
117            }
118        } else {
119            Self {
120                description: instrument.id().to_string(),
121                outcome: "?".to_string(),
122            }
123        }
124    }
125
126    /// Look up an instrument by ID in the cache and build a label.
127    /// Returns `None` if the instrument is not in the cache.
128    pub fn from_cache(instrument_id: &InstrumentId, cache: &Cache) -> Option<Self> {
129        cache.instrument(instrument_id).map(Self::from_instrument)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use rstest::rstest;
136    use rust_decimal_macros::dec;
137
138    use super::*;
139    use crate::{common::enums::PolymarketOutcome, http::models::PolymarketTradeReport};
140
141    fn load<T: serde::de::DeserializeOwned>(filename: &str) -> T {
142        let path = format!("test_data/{filename}");
143        let content = std::fs::read_to_string(path).expect("Failed to read test data");
144        serde_json::from_str(&content).expect("Failed to parse test data")
145    }
146
147    fn sample_maker_order_json() -> &'static str {
148        r#"{
149            "asset_id": "71321045679252212594626385532706912750332728571942532289631379312455583992563",
150            "fee_rate_bps": "10",
151            "maker_address": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
152            "matched_amount": "50.0000",
153            "order_id": "0xorder001",
154            "outcome": "Yes",
155            "owner": "00000000-0000-0000-0000-000000000002",
156            "price": "0.6000"
157        }"#
158    }
159
160    #[rstest]
161    fn test_maker_order_deserialization() {
162        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
163
164        assert_eq!(
165            order.asset_id.as_str(),
166            "71321045679252212594626385532706912750332728571942532289631379312455583992563"
167        );
168        assert_eq!(
169            order.maker_address.as_str(),
170            "0x70997970c51812dc3a010c7d01b50e0d17dc79c8"
171        );
172        assert_eq!(order.matched_amount, dec!(50.0000));
173        assert_eq!(order.order_id, "0xorder001");
174        assert_eq!(order.outcome, PolymarketOutcome::yes());
175        assert_eq!(order.price, dec!(0.6000));
176    }
177
178    #[rstest]
179    fn test_maker_order_roundtrip() {
180        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
181        let json = serde_json::to_string(&order).unwrap();
182        let order2: PolymarketMakerOrder = serde_json::from_str(&json).unwrap();
183        assert_eq!(order, order2);
184    }
185
186    #[rstest]
187    fn test_maker_order_ownership_ignores_address_case() {
188        let mut order: PolymarketMakerOrder =
189            serde_json::from_str(sample_maker_order_json()).unwrap();
190        let lowercase_address = order.maker_address.clone();
191        // All-uppercase hex stands in for the mixed-case checksummed form the
192        // venue sends; any case difference exercises the same comparison.
193        let uppercase_variant_address = lowercase_address
194            .to_ascii_uppercase()
195            .replacen("0X", "0x", 1);
196        assert_ne!(uppercase_variant_address, lowercase_address);
197
198        // Production direction: venue payloads carry checksummed maker
199        // addresses while configured funder addresses are commonly lowercase.
200        order.maker_address = uppercase_variant_address.clone();
201        assert!(order.is_owned_by(&lowercase_address, "no-such-key"));
202
203        // Reverse direction: lowercase payload, mixed-case configuration.
204        order.maker_address = lowercase_address;
205        assert!(order.is_owned_by(&uppercase_variant_address, "no-such-key"));
206    }
207
208    #[rstest]
209    fn test_maker_order_ownership_matches_exact_api_key() {
210        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
211        let owner = order.owner.clone();
212
213        assert!(order.is_owned_by("0xother", &owner));
214    }
215
216    #[rstest]
217    fn test_maker_order_ownership_requires_exact_api_key() {
218        let mut order: PolymarketMakerOrder =
219            serde_json::from_str(sample_maker_order_json()).unwrap();
220        order.owner = "abcdefab-0000-0000-0000-000000000002".to_string();
221        let case_variant_key = order.owner.to_ascii_uppercase();
222        assert_ne!(case_variant_key, order.owner);
223
224        assert!(!order.is_owned_by("0xother", &case_variant_key));
225    }
226
227    #[rstest]
228    fn test_maker_order_ownership_rejects_foreign_identity() {
229        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
230
231        assert!(!order.is_owned_by("0xother", "no-such-key"));
232    }
233
234    #[rstest]
235    fn test_maker_order_outcome_no() {
236        let json = r#"{
237            "asset_id": "12345",
238            "fee_rate_bps": "0",
239            "maker_address": "0xaddr",
240            "matched_amount": "10.0",
241            "order_id": "order-1",
242            "outcome": "No",
243            "owner": "owner-1",
244            "price": "0.4"
245        }"#;
246        let order: PolymarketMakerOrder = serde_json::from_str(json).unwrap();
247        assert_eq!(order.outcome, PolymarketOutcome::no());
248    }
249
250    #[rstest]
251    fn test_maker_order_decimal_precision() {
252        // Verifies Decimal fields are serialized as strings (not floats)
253        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
254        let json = serde_json::to_string(&order).unwrap();
255        // Decimals must appear as quoted strings, not bare numbers
256        assert!(
257            json.contains("\"matched_amount\":\"50.0000\"")
258                || json.contains("\"matched_amount\": \"50.0000\"")
259        );
260    }
261
262    // Tests for embedded maker orders from the trade report fixture
263    #[rstest]
264    fn test_maker_orders_from_trade_report() {
265        let trade: PolymarketTradeReport = load("http_trade_report.json");
266
267        assert_eq!(trade.maker_orders.len(), 2);
268        let m0 = &trade.maker_orders[0];
269        assert_eq!(m0.matched_amount, dec!(25.0000));
270        assert_eq!(m0.outcome, PolymarketOutcome::yes());
271        assert_eq!(m0.side, Some(PolymarketOrderSide::Sell));
272
273        let m1 = &trade.maker_orders[1];
274        assert_eq!(m1.matched_amount, dec!(5.0000));
275        assert_eq!(m1.side, Some(PolymarketOrderSide::Sell));
276    }
277
278    #[rstest]
279    fn test_maker_order_without_side_is_accepted() {
280        // The legacy/WS payload shape that omits `side` must still parse,
281        // since the field is optional on `PolymarketMakerOrder`.
282        let order: PolymarketMakerOrder = serde_json::from_str(sample_maker_order_json()).unwrap();
283        assert!(order.side.is_none());
284    }
285
286    #[rstest]
287    fn test_maker_order_with_side_is_parsed() {
288        let json = r#"{
289            "asset_id": "12345",
290            "fee_rate_bps": "0",
291            "maker_address": "0xaddr",
292            "matched_amount": "10.0",
293            "order_id": "order-1",
294            "outcome": "Yes",
295            "owner": "owner-1",
296            "price": "0.4",
297            "side": "BUY"
298        }"#;
299        let order: PolymarketMakerOrder = serde_json::from_str(json).unwrap();
300        assert_eq!(order.side, Some(PolymarketOrderSide::Buy));
301    }
302
303    #[rstest]
304    fn test_maker_order_with_empty_fee_rate_bps_is_accepted() {
305        // Production user-channel WS sometimes emits `"fee_rate_bps": ""` on
306        // maker orders. The field is unmodeled, so the empty string must not
307        // break parsing. Mirrors the official `rs-clob-client-v2` shape.
308        let json = r#"{
309            "asset_id": "12345",
310            "fee_rate_bps": "",
311            "maker_address": "0xaddr",
312            "matched_amount": "10.0",
313            "order_id": "order-1",
314            "outcome": "Yes",
315            "owner": "owner-1",
316            "price": "0.4"
317        }"#;
318        let order: PolymarketMakerOrder = serde_json::from_str(json).unwrap();
319        assert_eq!(order.matched_amount, dec!(10.0));
320    }
321}