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