Skip to content

fix(serde): accept JSON numbers as Decimal in CLOB/data/gamma responses#328

Open
moeuu wants to merge 2 commits intoPolymarket:mainfrom
moeuu:flexible-decimal-deserializer
Open

fix(serde): accept JSON numbers as Decimal in CLOB/data/gamma responses#328
moeuu wants to merge 2 commits intoPolymarket:mainfrom
moeuu:flexible-decimal-deserializer

Conversation

@moeuu
Copy link
Copy Markdown

@moeuu moeuu commented Apr 10, 2026

Accept JSON numbers as Decimal across CLOB, data, and Gamma response types

Summary

Several Polymarket CLOB / data-api / Gamma responses return numeric fields as JSON
numbers (e.g. {"minimum_tick_size": 0.01}, {"avgPrice": 0.36},
{"lastTradePrice": 0.54}) rather than strings. The default
rust_decimal::Decimal deserializer in serde rejects these with errors like:

invalid type: floating point `0.01`, expected a Decimal type representing a fixed-point number

This blocks every consumer of these structs at deserialization time. In particular,
Client::tick_size is called transitively by the order-builder
(limit_order().build() / market_order().build()), so any attempt to place a
live order currently fails before the order is signed
, and sync_markets-style
workflows that iterate the Gamma catalog fail on the first sports / prediction
market with numeric bestBid / bestAsk / etc.

Reproduction

Against production endpoints (as of the date of this PR):

let client = ClobClient::new("https://clob.polymarket.com", ClobConfig::default())?;
client.tick_size(U256::from_str_radix("49104850...145930", 10)?).await?;
// Err: invalid type: floating point `0.01`, expected a Decimal type representing a fixed-point number
let data = data::Client::new("https://data-api.polymarket.com")?;
data.positions(&PositionsRequest::builder().user(addr).build()).await?;
// Err: invalid type: floating point `0.36`, expected a Decimal type representing a fixed-point number
let gamma = gamma::Client::new("https://gamma-api.polymarket.com")?;
gamma.markets(&MarketsRequest::builder().limit(500).build()).await?;
// Err: invalid type: floating point `0.54`, expected a Decimal type representing a fixed-point number

Live JSON samples observed:

GET /tick-size            → {"minimum_tick_size":0.01}
GET /positions            → [{"proxyWallet":"0x...","avgPrice":0.36,"size":19.79,...}]
GET /markets              → [{"lastTradePrice":0.54,"bestBid":0.037,"bestAsk":0.04,...}]

Change

Introduce a reusable serde_with::DeserializeAs / SerializeAs adapter
serde_helpers::DecimalFromAny that accepts strings, integers, or floats, and
apply it to every affected field across CLOB, data, and Gamma response types.

src/serde_helpers.rs

New DecimalFromAny adapter. Visits:

  • visit_str / visit_stringDecimal::from_str
  • visit_i64 / visit_u64Decimal::from
  • visit_f64Decimal::from_str(&v.to_string()) (round-trips
    through the shortest-round-trip f64 → string conversion to avoid the binary
    float precision trap, e.g. 0.10.1000000000000000055...)

On the serialize side it delegates to the existing Decimal: Serialize impl so
output format is unchanged when these types are re-serialized in tests or mock
helpers.

Gated on the same feature set as the existing StringFromAny helper
(bridge | clob | data | gamma).

src/clob/types/mod.rs

TickSize::Deserialize now delegates to DecimalFromAny and then passes the
Decimal through the existing TryFrom<Decimal> for TickSize conversion. The
validation that the value must be one of {0.1, 0.01, 0.001, 0.0001} is
preserved.

src/data/types/response.rs

  • Position#[serde_as(as = "DecimalFromAny")] on all ten Decimal
    fields (size, avg_price, initial_value, current_value, cash_pnl,
    percent_pnl, total_bought, realized_pnl, percent_realized_pnl,
    cur_price).
  • ClosedPosition — adds #[serde_as] on the struct (was missing) and
    DecimalFromAny on its four Decimal fields.
  • TradeDecimalFromAny on size and price.

src/gamma/types/response.rs

Market, Event, and related structs: #[serde_as(as = "Option<DecimalFromAny>")]
applied to every Option<Decimal> field (56 fields across the module). Structs
that didn't previously carry the #[serde_as] proc-macro marker get it added.

Covered fields include price / book fields (best_bid, best_ask,
last_trade_price, spread), volume fields (volume, volume_num,
volume_24hr, volume_1wk, volume_1mo, volume_1yr, and their _amm/_clob
variants), liquidity fields (liquidity, liquidity_num, liquidity_amm,
liquidity_clob), price-change fields (one_day_price_change,
one_hour_price_change, one_week_price_change, one_month_price_change,
one_year_price_change), and misc numeric fields (fee, rewards_min_size,
rewards_max_spread, competitive, line, uma_reward,
order_price_min_tick_size, order_min_size, spreads_main_line,
totals_main_line).

The outcome_prices: Option<Vec<Decimal>> field is left unchanged because it
uses #[serde_as(as = "Option<JsonString>")] and is wrapped in a stringified
JSON array whose inner Decimals are already strings in live responses.

Scope / non-goals

  • Request types and order serialization (SignedOrder, Order) are untouched;
    these are still serialized as strings when sent to the API as required.
  • The change is backwards compatible with responses that return Decimals as
    strings; DecimalFromAny handles both.
  • No behavior change for any consumer that previously deserialized successfully.
  • No dependency additions: the patch reuses the crate's existing
    rust_decimal, serde, and serde_with dependencies.

Tests

The existing httpmock-based tests continue to pass. I can add regression
coverage in a follow-up that feeds raw JSON-number responses through an
httpmock server, modeled on tests/clob.rs::tick_size_* and
tests/data.rs::positions_*.

Context

Surfaced from a downstream consumer wiring Polymarket into a live trading
runtime. The bug currently blocks end-to-end order placement and any
sync_markets-style metadata pull. A minimal local reproduction walks through
ClobClient::tick_size → /tick-size → 200 {"minimum_tick_size":0.01} and fails
in TickSize::deserialize inside Decimal::deserialize.


Note

Medium Risk
Touches many response structs and changes how numerous Decimal fields deserialize, so regressions could surface if any endpoints relied on strict string-only parsing or if float-to-string round-tripping behaves unexpectedly.

Overview
Fixes deserialization failures when Polymarket APIs return Decimal-typed fields as JSON numbers instead of strings.

Introduces a reusable serde_helpers::DecimalFromAny adapter (with tests) that accepts string/int/float JSON values, and applies it to CLOB TickSize plus affected Decimal fields across Data API (Position/ClosedPosition/Trade) and Gamma response types (many Option<Decimal> market/event metrics like liquidity, volume, prices, and spreads).

Reviewed by Cursor Bugbot for commit 350a69a. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 10, 2026

Codecov Report

❌ Patch coverage is 89.41176% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.60%. Comparing base (77264a4) to head (350a69a).

Files with missing lines Patch % Lines
src/serde_helpers.rs 90.24% 8 Missing ⚠️
src/clob/types/mod.rs 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #328      +/-   ##
==========================================
+ Coverage   85.54%   85.60%   +0.05%     
==========================================
  Files          32       32              
  Lines        5167     5251      +84     
==========================================
+ Hits         4420     4495      +75     
- Misses        747      756       +9     
Flag Coverage Δ
rust 85.60% <89.41%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Introduce a shared DecimalFromAny serde_with adapter in
src/serde_helpers.rs that accepts strings, integers, and floats when
deserializing rust_decimal::Decimal, and apply it to the response
fields where the Polymarket API returns JSON numbers rather than
strings.

Impact today: ClobClient::tick_size (called transitively by
limit_order().build() / market_order().build()) fails against the live
/tick-size endpoint because {"minimum_tick_size": 0.01} comes back as
a JSON number, so any live order placement errors out before signing.
data::Client::positions and gamma::Client::markets hit the same class
of bug on avgPrice, lastTradePrice, bestBid/bestAsk, volume fields,
liquidity fields, and price-change fields.

Covered structs:
- TickSize (clob): deserializer now goes through DecimalFromAny.
- Position, ClosedPosition, Trade (data): all Decimal fields annotated.
  ClosedPosition gains its missing #[serde_as] struct marker.
- Market and related (gamma): 56 Option<Decimal> fields annotated.

Backwards compatible: DecimalFromAny still accepts string-encoded
Decimals, so responses that already work continue to work. No new
dependencies. No feature flag changes.

Verified: cargo build / cargo test pass under
--features "clob,data,gamma,heartbeats,tracing" with no additional
failures introduced. Live reproduction of the pre-patch error:

    let client = ClobClient::new("https://clob.polymarket.com", ClobConfig::default())?;
    client.tick_size(some_real_token_id).await?;
    // Err: invalid type: floating point `0.01`, expected a Decimal type representing a fixed-point number

and after the patch the same call returns TickSize::Hundredth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@moeuu moeuu force-pushed the flexible-decimal-deserializer branch from d2ed1b1 to 96410cf Compare April 10, 2026 21:48
@moeuu moeuu changed the title serde: accept JSON numbers as Decimal in CLOB/data/gamma responses fix(serde): accept JSON numbers as Decimal in CLOB/data/gamma responses Apr 10, 2026
Adds a decimal_from_any_tests module alongside the existing
serde_helpers tests. Covers:

- JSON float (0.01) — the /tick-size regression
- JSON float without precision loss (0.1 must round-trip through
  the shortest-string representation, not 0.1000000000000000055...)
- JSON unsigned integer (42)
- JSON signed integer (-7)
- String-encoded Decimal (backwards compat path)
- Non-numeric string (must error)
- Null on a required field (must error)
- Option<DecimalFromAny> accepting null / float / absent field
- A full "minimum_tick_size" fixture matching the shape that
  triggered the original bug against the live /tick-size endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant