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