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    serialize_decimal_as_str, serialize_optional_decimal_as_str,
22};
23use nautilus_model::identifiers::TradeId;
24use rust_decimal::Decimal;
25use serde::{
26    Deserialize, Deserializer, Serialize, Serializer,
27    de::{self, Error, MapAccess, SeqAccess, Unexpected, Visitor},
28};
29use serde_json::{Number, value::RawValue};
30
31use crate::common::enums::PolymarketOrderSide;
32
33/// Deserializes a decimal directly from its JSON number token without an `f64` conversion.
34pub fn deserialize_decimal_from_json_number<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
35where
36    D: Deserializer<'de>,
37{
38    let raw = Box::<RawValue>::deserialize(deserializer)?;
39    parse_decimal_exact(raw.get()).map_err(D::Error::custom)
40}
41
42/// Deserializes an optional decimal directly from its JSON number token.
43pub fn deserialize_optional_decimal_from_json_number<'de, D>(
44    deserializer: D,
45) -> Result<Option<Decimal>, D::Error>
46where
47    D: Deserializer<'de>,
48{
49    Option::<Box<RawValue>>::deserialize(deserializer)?
50        .map(|raw| parse_decimal_exact(raw.get()).map_err(D::Error::custom))
51        .transpose()
52}
53
54/// Deserializes an exact decimal from a JSON number or numeric string.
55///
56/// # Errors
57///
58/// Returns an error for an unsupported JSON shape or a value that cannot be represented exactly.
59pub fn deserialize_decimal_from_json<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
60where
61    D: Deserializer<'de>,
62{
63    let raw = Box::<RawValue>::deserialize(deserializer)?;
64    decimal_from_json(&raw).map_err(D::Error::custom)
65}
66
67/// Deserializes an optional exact decimal from a JSON number or numeric string.
68///
69/// # Errors
70///
71/// Returns an error for an unsupported JSON shape or a value that cannot be represented exactly.
72pub fn deserialize_optional_decimal_from_json<'de, D>(
73    deserializer: D,
74) -> Result<Option<Decimal>, D::Error>
75where
76    D: Deserializer<'de>,
77{
78    Option::<Box<RawValue>>::deserialize(deserializer)?
79        .map(|raw| decimal_from_json(&raw).map_err(D::Error::custom))
80        .transpose()
81}
82
83pub(crate) fn decimal_from_json(raw: &RawValue) -> anyhow::Result<Decimal> {
84    if raw.get().starts_with('"') {
85        let value: String = serde_json::from_str(raw.get())?;
86        Ok(parse_decimal_exact(&value)?)
87    } else {
88        Ok(parse_decimal_exact(raw.get())?)
89    }
90}
91
92pub(crate) fn parse_decimal_exact(value: &str) -> Result<Decimal, rust_decimal::Error> {
93    if let Some((base, exponent)) = value.split_once(['e', 'E']) {
94        let exponent: i64 = exponent
95            .parse()
96            .map_err(|_| rust_decimal::Error::from("invalid decimal exponent"))?;
97        let negative = base.starts_with('-');
98        let base = base
99            .strip_prefix(['-', '+'])
100            .unwrap_or(base)
101            .replace('_', "");
102        let mut parts = base.split('.');
103        let whole = parts.next().unwrap_or_default();
104
105        let fraction = parts.next().unwrap_or_default();
106        if parts.next().is_some() || whole.is_empty() && fraction.is_empty() {
107            return Err(rust_decimal::Error::from("invalid decimal significand"));
108        }
109
110        let mut digits = format!("{whole}{fraction}");
111        if !digits.bytes().all(|digit| digit.is_ascii_digit()) {
112            return Err(rust_decimal::Error::from("invalid decimal significand"));
113        }
114
115        let mut scale = i64::try_from(fraction.len())
116            .ok()
117            .and_then(|scale| scale.checked_sub(exponent))
118            .ok_or_else(|| rust_decimal::Error::from("decimal scale out of range"))?;
119
120        digits = digits.trim_start_matches('0').to_string();
121        if digits.is_empty() {
122            return Ok(Decimal::ZERO);
123        }
124
125        while scale > 0 && digits.ends_with('0') {
126            digits.pop();
127            scale -= 1;
128        }
129
130        if !(0..=28).contains(&scale) {
131            if (-28..0).contains(&scale) && digits.len() as i64 - scale <= 29 {
132                digits.extend(std::iter::repeat_n('0', (-scale) as usize));
133                scale = 0;
134            } else {
135                return Err(rust_decimal::Error::from("decimal scale out of range"));
136            }
137        }
138
139        let mantissa: i128 = digits
140            .parse()
141            .map_err(|_| rust_decimal::Error::from("decimal mantissa out of range"))?;
142        Decimal::try_from_i128_with_scale(if negative { -mantissa } else { mantissa }, scale as u32)
143    } else {
144        Decimal::from_str_exact(value)
145    }
146}
147
148/// Deserializes an exact decimal from a numeric string.
149///
150/// # Errors
151///
152/// Returns an error for an unsupported JSON shape or a value that cannot be represented exactly.
153pub fn deserialize_decimal_from_str<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
154where
155    D: Deserializer<'de>,
156{
157    let value = std::borrow::Cow::<'de, str>::deserialize(deserializer)?;
158    parse_decimal_exact(&value).map_err(D::Error::custom)
159}
160
161/// Deserializes an optional exact decimal, treating empty strings as absent.
162///
163/// # Errors
164///
165/// Returns an error for an unsupported JSON shape or a value that cannot be represented exactly.
166pub fn deserialize_optional_decimal_from_str<'de, D>(
167    deserializer: D,
168) -> Result<Option<Decimal>, D::Error>
169where
170    D: Deserializer<'de>,
171{
172    Option::<String>::deserialize(deserializer)?
173        .filter(|value| !value.is_empty())
174        .map(|value| parse_decimal_exact(&value).map_err(D::Error::custom))
175        .transpose()
176}
177
178/// Deserializes the required RTDS crypto TWAP `value` as a finite decimal.
179///
180/// Accepts JSON numbers and numeric strings in plain or scientific notation. Rejects missing,
181/// null, non-finite numbers, and every non-decimal JSON shape.
182pub(crate) fn deserialize_crypto_twap_value<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
183where
184    D: Deserializer<'de>,
185{
186    struct DecimalLikeVisitor;
187
188    impl<'de> Visitor<'de> for DecimalLikeVisitor {
189        type Value = Decimal;
190
191        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192            formatter.write_str("`value` as a finite decimal JSON number or numeric string")
193        }
194
195        fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
196            Ok(Decimal::from(value))
197        }
198
199        fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
200            Ok(Decimal::from(value))
201        }
202
203        fn visit_i128<E: de::Error>(self, value: i128) -> Result<Self::Value, E> {
204            Decimal::try_from_i128_with_scale(value, 0)
205                .map_err(|e| E::custom(format!("invalid decimal-like `value`: {e}")))
206        }
207
208        fn visit_u128<E: de::Error>(self, value: u128) -> Result<Self::Value, E> {
209            Decimal::from_str(&value.to_string())
210                .map_err(|e| E::custom(format!("invalid decimal-like `value`: {e}")))
211        }
212
213        fn visit_f64<E: de::Error>(self, value: f64) -> Result<Self::Value, E> {
214            if !value.is_finite() {
215                return Err(E::invalid_value(Unexpected::Float(value), &self));
216            }
217
218            Decimal::try_from(value)
219                .map_err(|e| E::custom(format!("invalid decimal-like `value`: {e}")))
220        }
221
222        fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
223            let result = parse_decimal_exact(value);
224
225            result.map_err(|e| E::custom(format!("invalid decimal-like `value`: {e}")))
226        }
227
228        fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
229            self.visit_str(&value)
230        }
231
232        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
233            Err(E::invalid_type(Unexpected::Unit, &self))
234        }
235
236        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
237            Err(E::invalid_type(Unexpected::Option, &self))
238        }
239
240        fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
241            Err(E::invalid_type(Unexpected::Bool(value), &self))
242        }
243
244        fn visit_seq<A: SeqAccess<'de>>(self, _seq: A) -> Result<Self::Value, A::Error> {
245            Err(A::Error::invalid_type(Unexpected::Seq, &self))
246        }
247
248        fn visit_map<A: MapAccess<'de>>(self, _map: A) -> Result<Self::Value, A::Error> {
249            Err(A::Error::invalid_type(Unexpected::Map, &self))
250        }
251
252        fn visit_bytes<E: de::Error>(self, value: &[u8]) -> Result<Self::Value, E> {
253            Err(E::invalid_type(Unexpected::Bytes(value), &self))
254        }
255
256        fn visit_byte_buf<E: de::Error>(self, value: Vec<u8>) -> Result<Self::Value, E> {
257            Err(E::invalid_type(Unexpected::Bytes(&value), &self))
258        }
259    }
260
261    deserializer.deserialize_any(DecimalLikeVisitor)
262}
263
264/// Serializes a decimal as an exact JSON number token.
265pub fn serialize_decimal_as_json_number<S>(
266    value: &Decimal,
267    serializer: S,
268) -> Result<S::Ok, S::Error>
269where
270    S: Serializer,
271{
272    let raw = RawValue::from_string(value.to_string()).map_err(serde::ser::Error::custom)?;
273    raw.serialize(serializer)
274}
275
276/// Serializes an optional decimal as an exact JSON number token or `null`.
277pub fn serialize_optional_decimal_as_json_number<S>(
278    value: &Option<Decimal>,
279    serializer: S,
280) -> Result<S::Ok, S::Error>
281where
282    S: Serializer,
283{
284    match value {
285        Some(value) => serialize_decimal_as_json_number(value, serializer),
286        None => serializer.serialize_none(),
287    }
288}
289
290/// Deserializes a Polymarket game ID as an opaque identifier.
291///
292/// The Gamma API returns the field in several shapes: an integer on
293/// `GammaEvent`, a numeric string on most `GammaMarket` records, and a
294/// composite `<uuid>:<away>:<home>` string on some sports markets. The value
295/// identifies a venue-side fixture and is never used for arithmetic, so it is
296/// kept verbatim rather than parsed into a number. Both `null` and `-1` (or
297/// `"-1"`) are the "no game" sentinel and map to `None`.
298pub fn deserialize_optional_polymarket_game_id<'de, D>(
299    deserializer: D,
300) -> Result<Option<String>, D::Error>
301where
302    D: Deserializer<'de>,
303{
304    #[derive(Deserialize)]
305    #[serde(untagged)]
306    enum Raw {
307        Str(String),
308        Num(Number),
309    }
310
311    let game_id = match Option::<Raw>::deserialize(deserializer)? {
312        None => return Ok(None),
313        Some(Raw::Str(value)) => value,
314        Some(Raw::Num(value)) => value.to_string(),
315    };
316
317    if game_id.is_empty() || game_id == "-1" {
318        return Ok(None);
319    }
320
321    Ok(Some(game_id))
322}
323
324// FNV-1a 64-bit constants (see http://www.isthe.com/chongo/tech/comp/fnv/).
325const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
326const FNV_PRIME: u64 = 0x0100_0000_01b3;
327
328/// Derives a deterministic [`TradeId`] for a Polymarket market data trade.
329///
330/// Polymarket does not publish a trade ID with `last_trade_price` events, so
331/// one is derived from the trade's identifying fields. FNV-1a is stable across
332/// architectures and crate versions, and the 0x1f delimiter prevents
333/// variable-length fields from colliding (e.g. `"0.12"` + `"34"` vs `"0.1"` +
334/// `"234"`).
335#[must_use]
336pub fn determine_trade_id(
337    asset_id: &str,
338    side: PolymarketOrderSide,
339    price: &str,
340    size: &str,
341    timestamp: &str,
342) -> TradeId {
343    let side_byte: &[u8] = match side {
344        PolymarketOrderSide::Buy => b"B",
345        PolymarketOrderSide::Sell => b"S",
346    };
347    let mut h: u64 = FNV_OFFSET_BASIS;
348
349    for bytes in [
350        asset_id.as_bytes(),
351        b"\x1f",
352        side_byte,
353        b"\x1f",
354        price.as_bytes(),
355        b"\x1f",
356        size.as_bytes(),
357        b"\x1f",
358        timestamp.as_bytes(),
359    ] {
360        for &b in bytes {
361            h ^= u64::from(b);
362            h = h.wrapping_mul(FNV_PRIME);
363        }
364    }
365    TradeId::new(format!("{h:016x}"))
366}
367
368#[cfg(test)]
369mod tests {
370    use rstest::rstest;
371    use serde::{Deserialize, Serialize};
372
373    use super::*;
374
375    #[rstest]
376    #[case("1e-28", "0.0000000000000000000000000001")]
377    #[case("0.00000000000000000000000000001e1", "0.0000000000000000000000000001")]
378    #[case("792281625142643375935439503350e-1", "79228162514264337593543950335")]
379    #[case("-12345e-4", "-1.2345")]
380    #[case("1.234567890123456789012345678e-1", "0.1234567890123456789012345678")]
381    fn test_parse_decimal_exact_scientific(#[case] raw: &str, #[case] expected: &str) {
382        assert_eq!(
383            parse_decimal_exact(raw).unwrap(),
384            Decimal::from_str_exact(expected).unwrap()
385        );
386    }
387
388    #[rstest]
389    #[case("0.12345678901234567890123456789e0")]
390    #[case("0.12345678901234567890123456789")]
391    #[case("1e-29")]
392    #[case("79228162514264337593543950336")]
393    #[case("NaN")]
394    #[case("Infinity")]
395    #[case("--1e0")]
396    #[case("1e9223372036854775808")]
397    fn test_parse_decimal_exact_rejects_invalid_or_inexact(#[case] raw: &str) {
398        assert!(parse_decimal_exact(raw).is_err(), "accepted {raw}");
399    }
400
401    #[derive(Debug, Deserialize)]
402    struct GameIdHolder {
403        #[serde(default, deserialize_with = "deserialize_optional_polymarket_game_id")]
404        game_id: Option<String>,
405    }
406
407    #[derive(Debug, Deserialize, Serialize)]
408    struct JsonDecimalHolder {
409        #[serde(
410            deserialize_with = "deserialize_decimal_from_json_number",
411            serialize_with = "serialize_decimal_as_json_number"
412        )]
413        value: Decimal,
414        #[serde(
415            default,
416            deserialize_with = "deserialize_optional_decimal_from_json_number",
417            serialize_with = "serialize_optional_decimal_as_json_number"
418        )]
419        optional: Option<Decimal>,
420    }
421
422    #[rstest]
423    fn test_json_decimal_number_preserves_precision() {
424        let json =
425            r#"{"value":0.1234567890123456789012345678,"optional":123456789.1234567890123456789}"#;
426        let holder: JsonDecimalHolder = serde_json::from_str(json).unwrap();
427
428        assert_eq!(
429            holder.value,
430            Decimal::from_str_exact("0.1234567890123456789012345678").unwrap()
431        );
432        assert_eq!(
433            holder.optional,
434            Some(Decimal::from_str_exact("123456789.1234567890123456789").unwrap())
435        );
436        assert_eq!(serde_json::to_string(&holder).unwrap(), json);
437    }
438
439    #[rstest]
440    fn test_optional_json_decimal_number_accepts_null_and_missing() {
441        let null: JsonDecimalHolder =
442            serde_json::from_str(r#"{"value":1,"optional":null}"#).unwrap();
443        let missing: JsonDecimalHolder = serde_json::from_str(r#"{"value":1}"#).unwrap();
444
445        assert_eq!(null.value, Decimal::ONE);
446        assert!(null.optional.is_none());
447        assert_eq!(missing.value, Decimal::ONE);
448        assert!(missing.optional.is_none());
449    }
450
451    #[rstest]
452    #[case::null(r#"{"game_id": null}"#, None)]
453    #[case::missing("{}", None)]
454    #[case::empty_string(r#"{"game_id": ""}"#, None)]
455    #[case::int_neg_one(r#"{"game_id": -1}"#, None)]
456    #[case::str_neg_one(r#"{"game_id": "-1"}"#, None)]
457    #[case::int_zero(r#"{"game_id": 0}"#, Some("0"))]
458    #[case::str_zero(r#"{"game_id": "0"}"#, Some("0"))]
459    #[case::int_value(r#"{"game_id": 1427074}"#, Some("1427074"))]
460    #[case::str_value(r#"{"game_id": "1427074"}"#, Some("1427074"))]
461    // Some sports markets carry a composite `<uuid>:<away>:<home>` game ID.
462    #[case::composite(
463        r#"{"game_id": "dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:STL:TEX"}"#,
464        Some("dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:STL:TEX")
465    )]
466    #[case::composite_rematch(
467        r#"{"game_id": "dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:DAL:LA:m2"}"#,
468        Some("dd80aae9-52f9-4c7b-a1cf-7b4ab63cd281:DAL:LA:m2")
469    )]
470    // Only -1 is the no-game sentinel, so other negatives stay verbatim
471    // rather than collapsing to "no game".
472    #[case::int_neg_other(r#"{"game_id": -2}"#, Some("-2"))]
473    #[case::str_neg_other(r#"{"game_id": "-2"}"#, Some("-2"))]
474    // A numeric ID beyond `i64` must not fail the record it arrived on.
475    #[case::int_beyond_i64(r#"{"game_id": 18446744073709551615}"#, Some("18446744073709551615"))]
476    fn test_deserialize_optional_polymarket_game_id(
477        #[case] payload: &str,
478        #[case] expected: Option<&str>,
479    ) {
480        let holder: GameIdHolder = serde_json::from_str(payload).unwrap();
481        assert_eq!(holder.game_id.as_deref(), expected);
482    }
483
484    #[rstest]
485    fn test_determine_trade_id_is_deterministic() {
486        let id1 = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
487        let id2 = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
488        assert_eq!(id1, id2);
489    }
490
491    #[rstest]
492    fn test_determine_trade_id_differentiates_sides() {
493        let buy = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
494        let sell = determine_trade_id("asset-1", PolymarketOrderSide::Sell, "0.5", "10", "1700000");
495        assert_ne!(buy, sell);
496    }
497
498    #[rstest]
499    fn test_determine_trade_id_field_delimiter_prevents_collision() {
500        // "0.12" + "34" would collide with "0.1" + "234" if fields were concatenated
501        let a = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.12", "34", "1700000");
502        let b = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.1", "234", "1700000");
503        assert_ne!(a, b);
504    }
505
506    #[rstest]
507    fn test_determine_trade_id_format() {
508        let id = determine_trade_id("asset-1", PolymarketOrderSide::Buy, "0.5", "10", "1700000");
509        let s = id.to_string();
510        assert_eq!(s.len(), 16);
511        // Pin lowercase hex so downstream consumers can rely on the format
512        assert!(
513            s.chars()
514                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
515        );
516    }
517}