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
18use std::str::FromStr;
19
20pub use nautilus_core::serialization::{
21    deserialize_decimal_from_str, deserialize_optional_decimal_from_str, serialize_decimal_as_str,
22    serialize_optional_decimal_as_str,
23};
24use nautilus_model::identifiers::TradeId;
25use rust_decimal::Decimal;
26use serde::{
27    Deserialize, Deserializer, Serialize, Serializer,
28    de::{self, Error, MapAccess, SeqAccess, Unexpected, Visitor},
29};
30use serde_json::{Number, value::RawValue};
31
32use crate::common::enums::PolymarketOrderSide;
33
34/// Deserializes a decimal directly from its JSON number token without an `f64` conversion.
35pub fn deserialize_decimal_from_json_number<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
36where
37    D: Deserializer<'de>,
38{
39    let raw = Box::<RawValue>::deserialize(deserializer)?;
40    Decimal::from_str_exact(raw.get()).map_err(D::Error::custom)
41}
42
43/// Deserializes an optional decimal directly from its JSON number token.
44pub fn deserialize_optional_decimal_from_json_number<'de, D>(
45    deserializer: D,
46) -> Result<Option<Decimal>, D::Error>
47where
48    D: Deserializer<'de>,
49{
50    Option::<Box<RawValue>>::deserialize(deserializer)?
51        .map(|raw| Decimal::from_str_exact(raw.get()).map_err(D::Error::custom))
52        .transpose()
53}
54
55/// Deserializes the required RTDS crypto TWAP `value` as a finite decimal.
56///
57/// Accepts JSON numbers and numeric strings in plain or scientific notation. Rejects missing,
58/// null, non-finite numbers, and every non-decimal JSON shape.
59pub(crate) fn deserialize_crypto_twap_value<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
60where
61    D: Deserializer<'de>,
62{
63    struct DecimalLikeVisitor;
64
65    impl<'de> Visitor<'de> for DecimalLikeVisitor {
66        type Value = Decimal;
67
68        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69            formatter.write_str("`value` as a finite decimal JSON number or numeric string")
70        }
71
72        fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
73            Ok(Decimal::from(value))
74        }
75
76        fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
77            Ok(Decimal::from(value))
78        }
79
80        fn visit_i128<E: de::Error>(self, value: i128) -> Result<Self::Value, E> {
81            Decimal::try_from_i128_with_scale(value, 0)
82                .map_err(|e| E::custom(format!("invalid decimal-like `value`: {e}")))
83        }
84
85        fn visit_u128<E: de::Error>(self, value: u128) -> Result<Self::Value, E> {
86            Decimal::from_str(&value.to_string())
87                .map_err(|e| E::custom(format!("invalid decimal-like `value`: {e}")))
88        }
89
90        fn visit_f64<E: de::Error>(self, value: f64) -> Result<Self::Value, E> {
91            if !value.is_finite() {
92                return Err(E::invalid_value(Unexpected::Float(value), &self));
93            }
94
95            Decimal::try_from(value)
96                .map_err(|e| E::custom(format!("invalid decimal-like `value`: {e}")))
97        }
98
99        fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
100            let result = if value.contains('e') || value.contains('E') {
101                Decimal::from_scientific(value)
102            } else {
103                Decimal::from_str(value)
104            };
105
106            result.map_err(|e| E::custom(format!("invalid decimal-like `value`: {e}")))
107        }
108
109        fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
110            self.visit_str(&value)
111        }
112
113        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
114            Err(E::invalid_type(Unexpected::Unit, &self))
115        }
116
117        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
118            Err(E::invalid_type(Unexpected::Option, &self))
119        }
120
121        fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
122            Err(E::invalid_type(Unexpected::Bool(value), &self))
123        }
124
125        fn visit_seq<A: SeqAccess<'de>>(self, _seq: A) -> Result<Self::Value, A::Error> {
126            Err(A::Error::invalid_type(Unexpected::Seq, &self))
127        }
128
129        fn visit_map<A: MapAccess<'de>>(self, _map: A) -> Result<Self::Value, A::Error> {
130            Err(A::Error::invalid_type(Unexpected::Map, &self))
131        }
132
133        fn visit_bytes<E: de::Error>(self, value: &[u8]) -> Result<Self::Value, E> {
134            Err(E::invalid_type(Unexpected::Bytes(value), &self))
135        }
136
137        fn visit_byte_buf<E: de::Error>(self, value: Vec<u8>) -> Result<Self::Value, E> {
138            Err(E::invalid_type(Unexpected::Bytes(&value), &self))
139        }
140    }
141
142    deserializer.deserialize_any(DecimalLikeVisitor)
143}
144
145/// Serializes a decimal as an exact JSON number token.
146pub fn serialize_decimal_as_json_number<S>(
147    value: &Decimal,
148    serializer: S,
149) -> Result<S::Ok, S::Error>
150where
151    S: Serializer,
152{
153    let raw = RawValue::from_string(value.to_string()).map_err(serde::ser::Error::custom)?;
154    raw.serialize(serializer)
155}
156
157/// Serializes an optional decimal as an exact JSON number token or `null`.
158pub fn serialize_optional_decimal_as_json_number<S>(
159    value: &Option<Decimal>,
160    serializer: S,
161) -> Result<S::Ok, S::Error>
162where
163    S: Serializer,
164{
165    match value {
166        Some(value) => serialize_decimal_as_json_number(value, serializer),
167        None => serializer.serialize_none(),
168    }
169}
170
171/// Deserializes a Polymarket game ID as an opaque identifier.
172///
173/// The Gamma API returns the field in several shapes: an integer on
174/// `GammaEvent`, a numeric string on most `GammaMarket` records, and a
175/// composite `<uuid>:<away>:<home>` string on some sports markets. The value
176/// identifies a venue-side fixture and is never used for arithmetic, so it is
177/// kept verbatim rather than parsed into a number. Both `null` and `-1` (or
178/// `"-1"`) are the "no game" sentinel and map to `None`.
179pub fn deserialize_optional_polymarket_game_id<'de, D>(
180    deserializer: D,
181) -> Result<Option<String>, D::Error>
182where
183    D: Deserializer<'de>,
184{
185    #[derive(Deserialize)]
186    #[serde(untagged)]
187    enum Raw {
188        Str(String),
189        Num(Number),
190    }
191
192    let game_id = match Option::<Raw>::deserialize(deserializer)? {
193        None => return Ok(None),
194        Some(Raw::Str(value)) => value,
195        Some(Raw::Num(value)) => value.to_string(),
196    };
197
198    if game_id.is_empty() || game_id == "-1" {
199        return Ok(None);
200    }
201
202    Ok(Some(game_id))
203}
204
205// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
206const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
207const FNV_PRIME: u64 = 0x0100_0000_01b3;
208
209/// Derives a deterministic [`TradeId`] for a Polymarket market data trade.
210///
211/// Polymarket does not publish a trade ID with `last_trade_price` events, so
212/// one is derived from the trade's identifying fields. FNV-1a is stable across
213/// architectures and crate versions, and the 0x1f delimiter prevents
214/// variable-length fields from colliding (e.g. `"0.12"` + `"34"` vs `"0.1"` +
215/// `"234"`).
216#[must_use]
217pub fn determine_trade_id(
218    asset_id: &str,
219    side: PolymarketOrderSide,
220    price: &str,
221    size: &str,
222    timestamp: &str,
223) -> TradeId {
224    let side_byte: &[u8] = match side {
225        PolymarketOrderSide::Buy => b"B",
226        PolymarketOrderSide::Sell => b"S",
227    };
228    let mut h: u64 = FNV_OFFSET_BASIS;
229
230    for bytes in [
231        asset_id.as_bytes(),
232        b"\x1f",
233        side_byte,
234        b"\x1f",
235        price.as_bytes(),
236        b"\x1f",
237        size.as_bytes(),
238        b"\x1f",
239        timestamp.as_bytes(),
240    ] {
241        for &b in bytes {
242            h ^= u64::from(b);
243            h = h.wrapping_mul(FNV_PRIME);
244        }
245    }
246    TradeId::new(format!("{h:016x}"))
247}
248
249#[cfg(test)]
250mod tests {
251    use rstest::rstest;
252    use serde::{Deserialize, Serialize};
253
254    use super::*;
255
256    #[derive(Debug, Deserialize)]
257    struct GameIdHolder {
258        #[serde(default, deserialize_with = "deserialize_optional_polymarket_game_id")]
259        game_id: Option<String>,
260    }
261
262    #[derive(Debug, Deserialize, Serialize)]
263    struct JsonDecimalHolder {
264        #[serde(
265            deserialize_with = "deserialize_decimal_from_json_number",
266            serialize_with = "serialize_decimal_as_json_number"
267        )]
268        value: Decimal,
269        #[serde(
270            default,
271            deserialize_with = "deserialize_optional_decimal_from_json_number",
272            serialize_with = "serialize_optional_decimal_as_json_number"
273        )]
274        optional: Option<Decimal>,
275    }
276
277    #[rstest]
278    fn test_json_decimal_number_preserves_precision() {
279        let json =
280            r#"{"value":0.1234567890123456789012345678,"optional":123456789.1234567890123456789}"#;
281        let holder: JsonDecimalHolder = serde_json::from_str(json).unwrap();
282
283        assert_eq!(
284            holder.value,
285            Decimal::from_str_exact("0.1234567890123456789012345678").unwrap()
286        );
287        assert_eq!(
288            holder.optional,
289            Some(Decimal::from_str_exact("123456789.1234567890123456789").unwrap())
290        );
291        assert_eq!(serde_json::to_string(&holder).unwrap(), json);
292    }
293
294    #[rstest]
295    fn test_optional_json_decimal_number_accepts_null_and_missing() {
296        let null: JsonDecimalHolder =
297            serde_json::from_str(r#"{"value":1,"optional":null}"#).unwrap();
298        let missing: JsonDecimalHolder = serde_json::from_str(r#"{"value":1}"#).unwrap();
299
300        assert_eq!(null.value, Decimal::ONE);
301        assert!(null.optional.is_none());
302        assert_eq!(missing.value, Decimal::ONE);
303        assert!(missing.optional.is_none());
304    }
305
306    #[rstest]
307    #[case::null(r#"{"game_id": null}"#, None)]
308    #[case::missing("{}", None)]
309    #[case::empty_string(r#"{"game_id": ""}"#, None)]
310    #[case::int_neg_one(r#"{"game_id": -1}"#, None)]
311    #[case::str_neg_one(r#"{"game_id": "-1"}"#, None)]
312    #[case::int_zero(r#"{"game_id": 0}"#, Some("0"))]
313    #[case::str_zero(r#"{"game_id": "0"}"#, Some("0"))]
314    #[case::int_value(r#"{"game_id": 1427074}"#, Some("1427074"))]
315    #[case::str_value(r#"{"game_id": "1427074"}"#, Some("1427074"))]
316    // Some sports markets carry a composite `<uuid>:<away>:<home>` game ID.
317    #[case::composite(
318        r#"{"game_id": "dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:STL:TEX"}"#,
319        Some("dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:STL:TEX")
320    )]
321    #[case::composite_rematch(
322        r#"{"game_id": "dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:DAL:LA:m2"}"#,
323        Some("dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:DAL:LA:m2")
324    )]
325    // Only -1 is the no-game sentinel, so other negatives stay verbatim
326    // rather than collapsing to "no game".
327    #[case::int_neg_other(r#"{"game_id": -2}"#, Some("-2"))]
328    #[case::str_neg_other(r#"{"game_id": "-2"}"#, Some("-2"))]
329    // A numeric ID beyond `i64` must not fail the record it arrived on.
330    #[case::int_beyond_i64(r#"{"game_id": 18446744073709551615}"#, Some("18446744073709551615"))]
331    fn test_deserialize_optional_polymarket_game_id(
332        #[case] payload: &str,
333        #[case] expected: Option<&str>,
334    ) {
335        let holder: GameIdHolder = serde_json::from_str(payload).unwrap();
336        assert_eq!(holder.game_id.as_deref(), expected);
337    }
338
339    #[rstest]
340    fn test_determine_trade_id_is_deterministic() {
341        let id1 = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
342        let id2 = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
343        assert_eq!(id1, id2);
344    }
345
346    #[rstest]
347    fn test_determine_trade_id_differentiates_sides() {
348        let buy = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
349        let sell = determine_trade_id("asset-1", PolymarketOrderSide::Sell, "0.5", "10", "1700000");
350        assert_ne!(buy, sell);
351    }
352
353    #[rstest]
354    fn test_determine_trade_id_field_delimiter_prevents_collision() {
355        // "0.12" + "34" would collide with "0.1" + "234" if fields were concatenated
356        let a = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.12", "34", "1700000");
357        let b = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.1", "234", "1700000");
358        assert_ne!(a, b);
359    }
360
361    #[rstest]
362    fn test_determine_trade_id_format() {
363        let id = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
364        let s = id.to_string();
365        assert_eq!(s.len(), 16);
366        // Pin lowercase hex so downstream consumers can rely on the format
367        assert!(
368            s.chars()
369                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
370        );
371    }
372}