Skip to main content

nautilus_polymarket/common/
parse.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//! Parsing utilities for the Polymarket adapter.
17
18pub use nautilus_core::serialization::{
19    deserialize_decimal_from_str, deserialize_optional_decimal_from_str, serialize_decimal_as_str,
20    serialize_optional_decimal_as_str,
21};
22use nautilus_model::identifiers::TradeId;
23use serde::{Deserialize, Deserializer, de::Error};
24
25use crate::common::enums::PolymarketOrderSide;
26
27/// Deserializes a Polymarket game ID. The Gamma API returns the field in two
28/// shapes (string on `GammaMarket`, integer on `GammaEvent`) and uses both
29/// `null` and `-1` (or `"-1"`) as the "no game" sentinel for non-sport
30/// markets. Either sentinel is mapped to `None`; valid values must be
31/// non-negative.
32pub fn deserialize_optional_polymarket_game_id<'de, D>(
33    deserializer: D,
34) -> Result<Option<u64>, D::Error>
35where
36    D: Deserializer<'de>,
37{
38    #[derive(Deserialize)]
39    #[serde(untagged)]
40    enum Raw {
41        Str(String),
42        Int(i64),
43    }
44
45    let raw: Option<Raw> = Option::deserialize(deserializer)?;
46    match raw {
47        None => Ok(None),
48        Some(Raw::Str(s)) if s.is_empty() || s == "-1" => Ok(None),
49        Some(Raw::Str(s)) => s.parse::<u64>().map(Some).map_err(D::Error::custom),
50        Some(Raw::Int(-1)) => Ok(None),
51        Some(Raw::Int(i)) if i < 0 => Err(D::Error::custom(format!(
52            "negative game_id {i}: only -1 is recognized as the no-game sentinel"
53        ))),
54        Some(Raw::Int(i)) => Ok(Some(i as u64)),
55    }
56}
57
58// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
59const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
60const FNV_PRIME: u64 = 0x0100_0000_01b3;
61
62/// Derives a deterministic [`TradeId`] for a Polymarket market data trade.
63///
64/// Polymarket does not publish a trade ID with `last_trade_price` events, so
65/// one is derived from the trade's identifying fields. FNV-1a is stable across
66/// architectures and crate versions, and the 0x1f delimiter prevents
67/// variable-length fields from colliding (e.g. `"0.12"` + `"34"` vs `"0.1"` +
68/// `"234"`).
69#[must_use]
70pub fn determine_trade_id(
71    asset_id: &str,
72    side: PolymarketOrderSide,
73    price: &str,
74    size: &str,
75    timestamp: &str,
76) -> TradeId {
77    let side_byte: &[u8] = match side {
78        PolymarketOrderSide::Buy => b"B",
79        PolymarketOrderSide::Sell => b"S",
80    };
81    let mut h: u64 = FNV_OFFSET_BASIS;
82
83    for bytes in [
84        asset_id.as_bytes(),
85        b"\x1f",
86        side_byte,
87        b"\x1f",
88        price.as_bytes(),
89        b"\x1f",
90        size.as_bytes(),
91        b"\x1f",
92        timestamp.as_bytes(),
93    ] {
94        for &b in bytes {
95            h ^= u64::from(b);
96            h = h.wrapping_mul(FNV_PRIME);
97        }
98    }
99    TradeId::new(format!("{h:016x}"))
100}
101
102#[cfg(test)]
103mod tests {
104    use rstest::rstest;
105    use serde::Deserialize;
106
107    use super::*;
108
109    #[derive(Debug, Deserialize)]
110    struct GameIdHolder {
111        #[serde(default, deserialize_with = "deserialize_optional_polymarket_game_id")]
112        game_id: Option<u64>,
113    }
114
115    #[rstest]
116    #[case::null(r#"{"game_id": null}"#, None)]
117    #[case::missing("{}", None)]
118    #[case::empty_string(r#"{"game_id": ""}"#, None)]
119    #[case::int_neg_one(r#"{"game_id": -1}"#, None)]
120    #[case::str_neg_one(r#"{"game_id": "-1"}"#, None)]
121    #[case::int_zero(r#"{"game_id": 0}"#, Some(0))]
122    #[case::str_zero(r#"{"game_id": "0"}"#, Some(0))]
123    #[case::int_value(r#"{"game_id": 1427074}"#, Some(1_427_074))]
124    #[case::str_value(r#"{"game_id": "1427074"}"#, Some(1_427_074))]
125    fn test_deserialize_optional_polymarket_game_id(
126        #[case] payload: &str,
127        #[case] expected: Option<u64>,
128    ) {
129        let holder: GameIdHolder = serde_json::from_str(payload).unwrap();
130        assert_eq!(holder.game_id, expected);
131    }
132
133    #[rstest]
134    fn test_deserialize_optional_polymarket_game_id_rejects_garbage_string() {
135        let err = serde_json::from_str::<GameIdHolder>(r#"{"game_id": "not-a-number"}"#);
136        assert!(err.is_err());
137    }
138
139    #[rstest]
140    fn test_deserialize_optional_polymarket_game_id_rejects_negative_other_than_minus_one() {
141        // Only -1 is the documented no-game sentinel; other negatives must
142        // surface as errors so unexpected wire shapes do not collapse to
143        // "no game" silently.
144        let err = serde_json::from_str::<GameIdHolder>(r#"{"game_id": -2}"#).unwrap_err();
145        assert!(err.to_string().contains("only -1"));
146    }
147
148    #[rstest]
149    fn test_deserialize_optional_polymarket_game_id_rejects_negative_string_other_than_minus_one() {
150        // Mirrors the integer behaviour: only "-1" is a sentinel; "-2" must
151        // bubble up as a parse error rather than silent None.
152        let err = serde_json::from_str::<GameIdHolder>(r#"{"game_id": "-2"}"#);
153        assert!(err.is_err());
154    }
155
156    #[rstest]
157    fn test_determine_trade_id_is_deterministic() {
158        let id1 = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
159        let id2 = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
160        assert_eq!(id1, id2);
161    }
162
163    #[rstest]
164    fn test_determine_trade_id_differentiates_sides() {
165        let buy = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
166        let sell = determine_trade_id("asset-1", PolymarketOrderSide::Sell, "0.5", "10", "1700000");
167        assert_ne!(buy, sell);
168    }
169
170    #[rstest]
171    fn test_determine_trade_id_field_delimiter_prevents_collision() {
172        // "0.12" + "34" would collide with "0.1" + "234" if fields were concatenated
173        let a = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.12", "34", "1700000");
174        let b = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.1", "234", "1700000");
175        assert_ne!(a, b);
176    }
177
178    #[rstest]
179    fn test_determine_trade_id_format() {
180        let id = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
181        let s = id.to_string();
182        assert_eq!(s.len(), 16);
183        // Pin lowercase hex so downstream consumers can rely on the format
184        assert!(
185            s.chars()
186                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
187        );
188    }
189}