diff --git a/src/clob/types/mod.rs b/src/clob/types/mod.rs index 52f6a71e..fa53cb6c 100644 --- a/src/clob/types/mod.rs +++ b/src/clob/types/mod.rs @@ -417,7 +417,14 @@ impl<'de> Deserialize<'de> for TickSize { where D: Deserializer<'de>, { - let dec = ::deserialize(deserializer)?; + // The Polymarket API returns `minimum_tick_size` as a JSON number + // (e.g. `0.01`), which `Decimal`'s default deserializer rejects + // because it expects a string. Delegate to the shared flexible + // Decimal deserializer so we handle both representations. + use serde_with::DeserializeAs; + let dec = >::deserialize_as( + deserializer, + )?; TickSize::try_from(dec).map_err(de::Error::custom) } } diff --git a/src/data/types/response.rs b/src/data/types/response.rs index 85d8cac3..d572190d 100644 --- a/src/data/types/response.rs +++ b/src/data/types/response.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Deserializer}; use serde_with::{DefaultOnNull, DisplayFromStr, NoneAsEmptyString, serde_as}; use super::{ActivityType, Side}; +use crate::serde_helpers::DecimalFromAny; use crate::types::{Address, B256, Decimal, U256}; /// Deserializes an optional Side, treating empty strings as None. @@ -74,24 +75,34 @@ pub struct Position { /// The market condition ID (unique market identifier). pub condition_id: B256, /// Number of outcome tokens held. + #[serde_as(as = "DecimalFromAny")] pub size: Decimal, /// Average entry price for the position. + #[serde_as(as = "DecimalFromAny")] pub avg_price: Decimal, /// Initial value (cost basis) of the position. + #[serde_as(as = "DecimalFromAny")] pub initial_value: Decimal, /// Current market value of the position. + #[serde_as(as = "DecimalFromAny")] pub current_value: Decimal, /// Unrealized cash profit/loss. + #[serde_as(as = "DecimalFromAny")] pub cash_pnl: Decimal, /// Unrealized percentage profit/loss. + #[serde_as(as = "DecimalFromAny")] pub percent_pnl: Decimal, /// Total amount bought (cumulative). + #[serde_as(as = "DecimalFromAny")] pub total_bought: Decimal, /// Realized profit/loss from closed portions. + #[serde_as(as = "DecimalFromAny")] pub realized_pnl: Decimal, /// Realized percentage profit/loss. + #[serde_as(as = "DecimalFromAny")] pub percent_realized_pnl: Decimal, /// Current market price of the outcome. + #[serde_as(as = "DecimalFromAny")] pub cur_price: Decimal, /// Whether the position can be redeemed (market resolved). pub redeemable: bool, @@ -129,6 +140,7 @@ pub struct Position { /// /// Returned by the `/closed-positions` endpoint. Represents positions that /// have been fully sold or redeemed, with final profit/loss figures. +#[serde_as] #[derive(Debug, Clone, Deserialize, Builder)] #[serde(rename_all = "camelCase")] #[non_exhaustive] @@ -140,12 +152,16 @@ pub struct ClosedPosition { /// The market condition ID (unique market identifier). pub condition_id: B256, /// Average entry price for the position. + #[serde_as(as = "DecimalFromAny")] pub avg_price: Decimal, /// Total amount bought (cumulative). + #[serde_as(as = "DecimalFromAny")] pub total_bought: Decimal, /// Realized profit/loss from the closed position. + #[serde_as(as = "DecimalFromAny")] pub realized_pnl: Decimal, /// Final market price when position was closed. + #[serde_as(as = "DecimalFromAny")] pub cur_price: Decimal, /// Unix timestamp when the position was closed. pub timestamp: i64, @@ -187,8 +203,10 @@ pub struct Trade { /// The market condition ID (unique market identifier). pub condition_id: B256, /// Number of tokens traded. + #[serde_as(as = "DecimalFromAny")] pub size: Decimal, /// Execution price per token. + #[serde_as(as = "DecimalFromAny")] pub price: Decimal, /// Unix timestamp when the trade occurred. pub timestamp: i64, diff --git a/src/gamma/types/response.rs b/src/gamma/types/response.rs index 4a3beec6..73291bf8 100644 --- a/src/gamma/types/response.rs +++ b/src/gamma/types/response.rs @@ -10,7 +10,7 @@ use serde_with::NoneAsEmptyString; use serde_with::json::JsonString; use serde_with::{DisplayFromStr, StringWithSeparator, formats::CommaSeparator, serde_as}; -use crate::serde_helpers::StringFromAny; +use crate::serde_helpers::{DecimalFromAny, StringFromAny}; use crate::types::{Address, B256, Decimal, U256}; /// Image optimization metadata. @@ -245,8 +245,11 @@ pub struct Event { pub new: Option, pub featured: Option, pub restricted: Option, + #[serde_as(as = "Option")] pub liquidity: Option, + #[serde_as(as = "Option")] pub volume: Option, + #[serde_as(as = "Option")] pub open_interest: Option, pub sort_by: Option, pub category: Option, @@ -260,10 +263,15 @@ pub struct Event { pub created_at: Option>, pub updated_at: Option>, pub comments_enabled: Option, + #[serde_as(as = "Option")] pub competitive: Option, + #[serde_as(as = "Option")] pub volume_24hr: Option, + #[serde_as(as = "Option")] pub volume_1wk: Option, + #[serde_as(as = "Option")] pub volume_1mo: Option, + #[serde_as(as = "Option")] pub volume_1yr: Option, pub featured_image: Option, pub disqus_thread: Option, @@ -274,7 +282,9 @@ pub struct Event { #[serde_as(as = "Option")] pub turn_provider_id: Option, pub enable_order_book: Option, + #[serde_as(as = "Option")] pub liquidity_amm: Option, + #[serde_as(as = "Option")] pub liquidity_clob: Option, pub neg_risk: Option, #[serde_as(as = "NoneAsEmptyString")] @@ -317,7 +327,9 @@ pub struct Event { pub cant_estimate: Option, pub estimated_value: Option, pub templates: Option>, + #[serde_as(as = "Option")] pub spreads_main_line: Option, + #[serde_as(as = "Option")] pub totals_main_line: Option, pub carousel_map: Option, pub pending_deployment: Option, @@ -353,6 +365,7 @@ pub struct Market { pub end_date: Option>, pub category: Option, pub amm_type: Option, + #[serde_as(as = "Option")] pub liquidity: Option, pub sponsor_name: Option, pub sponsor_image: Option, @@ -360,6 +373,7 @@ pub struct Market { pub x_axis_value: Option, pub y_axis_value: Option, pub denomination_token: Option, + #[serde_as(as = "Option")] pub fee: Option, pub image: Option, pub icon: Option, @@ -370,6 +384,7 @@ pub struct Market { pub outcomes: Option>, #[serde_as(as = "Option")] pub outcome_prices: Option>, + #[serde_as(as = "Option")] pub volume: Option, pub active: Option, pub market_type: Option, @@ -400,11 +415,15 @@ pub struct Market { pub question_id: Option, pub uma_end_date: Option, pub enable_order_book: Option, + #[serde_as(as = "Option")] pub order_price_min_tick_size: Option, + #[serde_as(as = "Option")] pub order_min_size: Option, pub uma_resolution_status: Option, pub curation_order: Option, + #[serde_as(as = "Option")] pub volume_num: Option, + #[serde_as(as = "Option")] pub liquidity_num: Option, pub end_date_iso: Option, pub start_date_iso: Option, @@ -412,9 +431,13 @@ pub struct Market { pub has_reviewed_dates: Option, pub ready_for_cron: Option, pub comments_enabled: Option, + #[serde_as(as = "Option")] pub volume_24hr: Option, + #[serde_as(as = "Option")] pub volume_1wk: Option, + #[serde_as(as = "Option")] pub volume_1mo: Option, + #[serde_as(as = "Option")] pub volume_1yr: Option, pub game_start_time: Option, pub seconds_delay: Option, @@ -427,19 +450,32 @@ pub struct Market { #[serde(rename = "teamBID")] pub team_b_id: Option, pub uma_bond: Option, + #[serde_as(as = "Option")] pub uma_reward: Option, pub fpmm_live: Option, + #[serde_as(as = "Option")] pub volume_24hr_amm: Option, + #[serde_as(as = "Option")] pub volume_1wk_amm: Option, + #[serde_as(as = "Option")] pub volume_1mo_amm: Option, + #[serde_as(as = "Option")] pub volume_1yr_amm: Option, + #[serde_as(as = "Option")] pub volume_24hr_clob: Option, + #[serde_as(as = "Option")] pub volume_1wk_clob: Option, + #[serde_as(as = "Option")] pub volume_1mo_clob: Option, + #[serde_as(as = "Option")] pub volume_1yr_clob: Option, + #[serde_as(as = "Option")] pub volume_amm: Option, + #[serde_as(as = "Option")] pub volume_clob: Option, + #[serde_as(as = "Option")] pub liquidity_amm: Option, + #[serde_as(as = "Option")] pub liquidity_clob: Option, pub maker_base_fee: Option, pub taker_base_fee: Option, @@ -460,18 +496,30 @@ pub struct Market { pub ready_timestamp: Option>, pub funded_timestamp: Option>, pub accepting_orders_timestamp: Option>, + #[serde_as(as = "Option")] pub competitive: Option, + #[serde_as(as = "Option")] pub rewards_min_size: Option, + #[serde_as(as = "Option")] pub rewards_max_spread: Option, + #[serde_as(as = "Option")] pub spread: Option, pub automatically_resolved: Option, + #[serde_as(as = "Option")] pub one_day_price_change: Option, + #[serde_as(as = "Option")] pub one_hour_price_change: Option, + #[serde_as(as = "Option")] pub one_week_price_change: Option, + #[serde_as(as = "Option")] pub one_month_price_change: Option, + #[serde_as(as = "Option")] pub one_year_price_change: Option, + #[serde_as(as = "Option")] pub last_trade_price: Option, + #[serde_as(as = "Option")] pub best_bid: Option, + #[serde_as(as = "Option")] pub best_ask: Option, pub automatically_active: Option, pub clear_book_on_start: Option, @@ -484,6 +532,7 @@ pub struct Market { pub game_id: Option, pub group_item_range: Option, pub sports_market_type: Option, + #[serde_as(as = "Option")] pub line: Option, pub uma_resolution_statuses: Option, pub pending_deployment: Option, @@ -532,11 +581,14 @@ pub struct ClobReward { pub condition_id: Option, pub start_date: Option, pub end_date: Option, + #[serde_as(as = "Option")] pub rewards_amount: Option, + #[serde_as(as = "Option")] pub rewards_daily_rate: Option, } /// A series of related events. +#[serde_as] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] #[serde(rename_all = "camelCase")] #[non_exhaustive] @@ -566,9 +618,13 @@ pub struct Series { pub created_at: Option>, pub updated_at: Option>, pub comments_enabled: Option, + #[serde_as(as = "Option")] pub competitive: Option, + #[serde_as(as = "Option")] pub volume_24hr: Option, + #[serde_as(as = "Option")] pub volume: Option, + #[serde_as(as = "Option")] pub liquidity: Option, pub start_date: Option>, #[serde(rename = "pythTokenID")] @@ -585,11 +641,13 @@ pub struct Series { } /// A comment position. +#[serde_as] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)] #[serde(rename_all = "camelCase")] #[non_exhaustive] pub struct CommentPosition { pub token_id: Option, + #[serde_as(as = "Option")] pub position_size: Option, } diff --git a/src/serde_helpers.rs b/src/serde_helpers.rs index 936035a1..aa8c4cfe 100644 --- a/src/serde_helpers.rs +++ b/src/serde_helpers.rs @@ -80,6 +80,108 @@ impl serde_with::SerializeAs for StringFromAny { } } +/// A `serde_as` type that deserializes strings, integers, or floats as `Decimal`. +/// +/// Polymarket APIs sometimes return numeric fields as JSON numbers (e.g. +/// `0.01`) rather than strings, which the default `rust_decimal` deserializer +/// rejects because it expects a string. Use this helper on any `Decimal` field +/// whose JSON representation may be either a string or a number. +/// +/// Use with `#[serde_as(as = "DecimalFromAny")]` for `Decimal` fields +/// or `#[serde_as(as = "Option")]` for `Option`. +#[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" +))] +pub struct DecimalFromAny; + +#[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" +))] +impl<'de> serde_with::DeserializeAs<'de, rust_decimal::Decimal> for DecimalFromAny { + fn deserialize_as(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + use std::fmt; + use std::str::FromStr as _; + + use serde::de::{self, Visitor}; + + struct DecimalAnyVisitor; + + impl Visitor<'_> for DecimalAnyVisitor { + type Value = rust_decimal::Decimal; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string, integer, or float convertible to Decimal") + } + + fn visit_str(self, v: &str) -> std::result::Result + where + E: de::Error, + { + rust_decimal::Decimal::from_str(v).map_err(de::Error::custom) + } + + fn visit_string(self, v: String) -> std::result::Result + where + E: de::Error, + { + self.visit_str(&v) + } + + fn visit_i64(self, v: i64) -> std::result::Result + where + E: de::Error, + { + Ok(rust_decimal::Decimal::from(v)) + } + + fn visit_u64(self, v: u64) -> std::result::Result + where + E: de::Error, + { + Ok(rust_decimal::Decimal::from(v)) + } + + fn visit_f64(self, v: f64) -> std::result::Result + where + E: de::Error, + { + // Round-trip through string to avoid the binary-float + // precision trap (e.g. 0.1 → 0.1000000000000000055...). + rust_decimal::Decimal::from_str(&v.to_string()).map_err(de::Error::custom) + } + } + + deserializer.deserialize_any(DecimalAnyVisitor) + } +} + +#[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" +))] +impl serde_with::SerializeAs for DecimalFromAny { + fn serialize_as( + source: &rust_decimal::Decimal, + serializer: S, + ) -> std::result::Result + where + S: serde::Serializer, + { + ::serialize(source, serializer) + } +} + /// Deserialize JSON with unknown field warnings. /// /// This function deserializes JSON to a target type while detecting and logging @@ -728,4 +830,108 @@ mod tests { let result = lookup_value(&json, "?.outer.?.inner"); assert_eq!(result, Some(&Value::String("value".to_owned()))); } + + // ========== DecimalFromAny tests ========== + #[cfg(any( + feature = "bridge", + feature = "clob", + feature = "data", + feature = "gamma" + ))] + mod decimal_from_any_tests { + use std::str::FromStr as _; + + use rust_decimal::Decimal; + use serde::Deserialize; + use serde_with::serde_as; + + use super::super::DecimalFromAny; + + #[serde_as] + #[derive(Debug, Deserialize, PartialEq)] + struct Holder { + #[serde_as(as = "DecimalFromAny")] + value: Decimal, + } + + #[serde_as] + #[derive(Debug, Deserialize, PartialEq)] + struct OptHolder { + #[serde_as(as = "Option")] + #[serde(default)] + value: Option, + } + + #[test] + fn accepts_json_float() { + // Mirrors /tick-size returning {"minimum_tick_size": 0.01} + let holder: Holder = serde_json::from_str(r#"{"value": 0.01}"#).unwrap(); + assert_eq!(holder.value, Decimal::from_str("0.01").unwrap()); + } + + #[test] + fn accepts_json_float_without_precision_loss() { + // 0.1 is not exactly representable in binary float; the + // shortest-string round-trip should yield the literal "0.1", + // not 0.1000000000000000055... + let holder: Holder = serde_json::from_str(r#"{"value": 0.1}"#).unwrap(); + assert_eq!(holder.value, Decimal::from_str("0.1").unwrap()); + } + + #[test] + fn accepts_json_integer_unsigned() { + let holder: Holder = serde_json::from_str(r#"{"value": 42}"#).unwrap(); + assert_eq!(holder.value, Decimal::from(42)); + } + + #[test] + fn accepts_json_integer_signed() { + let holder: Holder = serde_json::from_str(r#"{"value": -7}"#).unwrap(); + assert_eq!(holder.value, Decimal::from(-7)); + } + + #[test] + fn accepts_string_decimal() { + // Responses that already return strings must continue to work. + let holder: Holder = serde_json::from_str(r#"{"value": "0.123456"}"#).unwrap(); + assert_eq!(holder.value, Decimal::from_str("0.123456").unwrap()); + } + + #[test] + fn rejects_non_numeric_string() { + let err = serde_json::from_str::(r#"{"value": "not-a-number"}"#); + err.unwrap_err(); + } + + #[test] + fn rejects_null_on_required_field() { + let err = serde_json::from_str::(r#"{"value": null}"#); + err.unwrap_err(); + } + + #[test] + fn optional_accepts_null_and_float() { + let none: OptHolder = serde_json::from_str(r#"{"value": null}"#).unwrap(); + assert_eq!(none.value, None); + let some: OptHolder = serde_json::from_str(r#"{"value": 0.54}"#).unwrap(); + assert_eq!(some.value, Some(Decimal::from_str("0.54").unwrap())); + let missing: OptHolder = serde_json::from_str("{}").unwrap(); + assert_eq!(missing.value, None); + } + + #[test] + fn tick_size_like_response() { + // Exact reproduction of the /tick-size response that triggered + // the original bug. + #[serde_as] + #[derive(Debug, Deserialize, PartialEq)] + struct TickSizeResponseLike { + #[serde_as(as = "DecimalFromAny")] + minimum_tick_size: Decimal, + } + let resp: TickSizeResponseLike = + serde_json::from_str(r#"{"minimum_tick_size": 0.01}"#).unwrap(); + assert_eq!(resp.minimum_tick_size, Decimal::from_str("0.01").unwrap()); + } + } }