Skip to main content

polyoxide_clob/api/
markets.rs

1use std::collections::HashMap;
2
3use polyoxide_core::{HttpClient, QueryBuilder};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    error::ClobError,
9    request::{AuthMode, Request},
10    types::OrderSide,
11};
12
13/// Markets namespace for market-related operations
14#[derive(Clone)]
15pub struct Markets {
16    pub(crate) http_client: HttpClient,
17    pub(crate) chain_id: u64,
18}
19
20impl Markets {
21    /// Get a market by condition ID
22    pub fn get(&self, condition_id: impl Into<String>) -> Request<Market> {
23        Request::get(
24            self.http_client.clone(),
25            format!("/markets/{}", urlencoding::encode(&condition_id.into())),
26            AuthMode::None,
27            self.chain_id,
28        )
29    }
30
31    pub fn get_by_token_ids(
32        &self,
33        token_ids: impl Into<Vec<String>>,
34    ) -> Request<ListMarketsResponse> {
35        Request::get(
36            self.http_client.clone(),
37            "/markets",
38            AuthMode::None,
39            self.chain_id,
40        )
41        .query_many("clob_token_ids", token_ids.into())
42    }
43
44    /// List all markets
45    pub fn list(&self) -> Request<ListMarketsResponse> {
46        Request::get(
47            self.http_client.clone(),
48            "/markets",
49            AuthMode::None,
50            self.chain_id,
51        )
52    }
53
54    /// Get order book for a token
55    pub fn order_book(&self, token_id: impl Into<String>) -> Request<OrderBook> {
56        Request::get(
57            self.http_client.clone(),
58            "/book",
59            AuthMode::None,
60            self.chain_id,
61        )
62        .query("token_id", token_id.into())
63    }
64
65    /// Get price for a token and side
66    pub fn price(&self, token_id: impl Into<String>, side: OrderSide) -> Request<PriceResponse> {
67        Request::get(
68            self.http_client.clone(),
69            "/price",
70            AuthMode::None,
71            self.chain_id,
72        )
73        .query("token_id", token_id.into())
74        .query("side", side.as_str())
75    }
76
77    /// Get midpoint price for a token
78    pub fn midpoint(&self, token_id: impl Into<String>) -> Request<MidpointResponse> {
79        Request::get(
80            self.http_client.clone(),
81            "/midpoint",
82            AuthMode::None,
83            self.chain_id,
84        )
85        .query("token_id", token_id.into())
86    }
87
88    /// Get historical prices for a token
89    pub fn prices_history(&self, token_id: impl Into<String>) -> Request<PricesHistoryResponse> {
90        Request::get(
91            self.http_client.clone(),
92            "/prices-history",
93            AuthMode::None,
94            self.chain_id,
95        )
96        .query("market", token_id.into())
97    }
98
99    /// Get neg_risk status for a token
100    pub fn neg_risk(&self, token_id: impl Into<String>) -> Request<NegRiskResponse> {
101        Request::get(
102            self.http_client.clone(),
103            "/neg-risk".to_string(),
104            AuthMode::None,
105            self.chain_id,
106        )
107        .query("token_id", token_id.into())
108    }
109
110    /// Get the current fee rate for a token
111    pub fn fee_rate(&self, token_id: impl Into<String>) -> Request<FeeRateResponse> {
112        Request::get(
113            self.http_client.clone(),
114            "/fee-rate",
115            AuthMode::None,
116            self.chain_id,
117        )
118        .query("token_id", token_id.into())
119    }
120
121    /// Get tick size for a token
122    pub fn tick_size(&self, token_id: impl Into<String>) -> Request<TickSizeResponse> {
123        Request::get(
124            self.http_client.clone(),
125            "/tick-size".to_string(),
126            AuthMode::None,
127            self.chain_id,
128        )
129        .query("token_id", token_id.into())
130    }
131
132    /// Get neg_risk flag via path parameter (`GET /neg-risk/{token_id}`).
133    pub fn neg_risk_path(&self, token_id: impl Into<String>) -> Request<NegRiskResponse> {
134        Request::get(
135            self.http_client.clone(),
136            format!("/neg-risk/{}", urlencoding::encode(&token_id.into())),
137            AuthMode::None,
138            self.chain_id,
139        )
140    }
141
142    /// Get fee rate via path parameter (`GET /fee-rate/{token_id}`).
143    pub fn fee_rate_path(&self, token_id: impl Into<String>) -> Request<FeeRateResponse> {
144        Request::get(
145            self.http_client.clone(),
146            format!("/fee-rate/{}", urlencoding::encode(&token_id.into())),
147            AuthMode::None,
148            self.chain_id,
149        )
150    }
151
152    /// Get tick size via path parameter (`GET /tick-size/{token_id}`).
153    pub fn tick_size_path(&self, token_id: impl Into<String>) -> Request<TickSizeResponse> {
154        Request::get(
155            self.http_client.clone(),
156            format!("/tick-size/{}", urlencoding::encode(&token_id.into())),
157            AuthMode::None,
158            self.chain_id,
159        )
160    }
161
162    /// Get CLOB-level market details (`GET /clob-markets/{condition_id}`).
163    ///
164    /// Returns the full set of CLOB parameters for a market: tokens, tick size,
165    /// base fees, rewards, RFQ status, and fee-curve details.
166    pub fn clob_market_details(
167        &self,
168        condition_id: impl Into<String>,
169    ) -> Request<ClobMarketDetails> {
170        Request::get(
171            self.http_client.clone(),
172            format!(
173                "/clob-markets/{}",
174                urlencoding::encode(&condition_id.into())
175            ),
176            AuthMode::None,
177            self.chain_id,
178        )
179    }
180
181    /// Resolve a market by its token ID (`GET /markets-by-token/{token_id}`).
182    ///
183    /// Returns the condition ID and both token IDs for the market that owns
184    /// the given token ID.
185    pub fn market_by_token(&self, token_id: impl Into<String>) -> Request<MarketByTokenResponse> {
186        Request::get(
187            self.http_client.clone(),
188            format!(
189                "/markets-by-token/{}",
190                urlencoding::encode(&token_id.into())
191            ),
192            AuthMode::None,
193            self.chain_id,
194        )
195    }
196
197    /// Get minimal live-activity data for a single market
198    /// (`GET /markets/live-activity/{condition_id}`).
199    pub fn live_activity_market(
200        &self,
201        condition_id: impl Into<String>,
202    ) -> Request<LiveActivityMarket> {
203        Request::get(
204            self.http_client.clone(),
205            format!(
206                "/markets/live-activity/{}",
207                urlencoding::encode(&condition_id.into())
208            ),
209            AuthMode::None,
210            self.chain_id,
211        )
212    }
213
214    /// Get minimal live-activity data for multiple markets
215    /// (`POST /markets/live-activity`).
216    pub fn live_activity_bulk(
217        &self,
218        condition_ids: Vec<String>,
219    ) -> Result<Request<Vec<LiveActivityMarket>>, ClobError> {
220        Request::<Vec<LiveActivityMarket>>::post(
221            self.http_client.clone(),
222            "/markets/live-activity".to_string(),
223            AuthMode::None,
224            self.chain_id,
225        )
226        .body(&condition_ids)
227    }
228
229    /// Get batched historical prices for multiple markets
230    /// (`POST /batch-prices-history`).
231    pub fn batch_prices_history(
232        &self,
233        req: &BatchPricesHistoryRequest,
234    ) -> Result<Request<BatchPricesHistoryResponse>, ClobError> {
235        Request::<BatchPricesHistoryResponse>::post(
236            self.http_client.clone(),
237            "/batch-prices-history".to_string(),
238            AuthMode::None,
239            self.chain_id,
240        )
241        .body(req)
242    }
243
244    /// Get bid-ask spread for a token
245    pub fn spread(&self, token_id: impl Into<String>) -> Request<SpreadResponse> {
246        Request::get(
247            self.http_client.clone(),
248            "/spread",
249            AuthMode::None,
250            self.chain_id,
251        )
252        .query("token_id", token_id.into())
253    }
254
255    /// Get last trade price for a token
256    pub fn last_trade_price(&self, token_id: impl Into<String>) -> Request<LastTradePriceResponse> {
257        Request::get(
258            self.http_client.clone(),
259            "/last-trade-price",
260            AuthMode::None,
261            self.chain_id,
262        )
263        .query("token_id", token_id.into())
264    }
265
266    /// Get live activity events for a market
267    pub fn live_activity(
268        &self,
269        condition_id: impl Into<String>,
270    ) -> Request<Vec<LiveActivityEvent>> {
271        Request::get(
272            self.http_client.clone(),
273            format!(
274                "/live-activity/events/{}",
275                urlencoding::encode(&condition_id.into())
276            ),
277            AuthMode::None,
278            self.chain_id,
279        )
280    }
281
282    /// List simplified markets (reduced payload for performance)
283    pub fn simplified(&self) -> Request<ListMarketsResponse> {
284        Request::get(
285            self.http_client.clone(),
286            "/simplified-markets",
287            AuthMode::None,
288            self.chain_id,
289        )
290    }
291
292    /// List sampling markets
293    pub fn sampling(&self) -> Request<ListMarketsResponse> {
294        Request::get(
295            self.http_client.clone(),
296            "/sampling-markets",
297            AuthMode::None,
298            self.chain_id,
299        )
300    }
301
302    /// List sampling simplified markets
303    pub fn sampling_simplified(&self) -> Request<ListMarketsResponse> {
304        Request::get(
305            self.http_client.clone(),
306            "/sampling-simplified-markets",
307            AuthMode::None,
308            self.chain_id,
309        )
310    }
311
312    /// Calculate estimated execution price for a market order
313    pub async fn calculate_price(
314        &self,
315        token_id: impl Into<String>,
316        side: OrderSide,
317        amount: impl Into<String>,
318    ) -> Result<CalculatePriceResponse, ClobError> {
319        Request::<CalculatePriceResponse>::post(
320            self.http_client.clone(),
321            "/calculate-price".to_string(),
322            AuthMode::None,
323            self.chain_id,
324        )
325        .body(&CalculatePriceParams {
326            token_id: token_id.into(),
327            side,
328            amount: amount.into(),
329        })?
330        .send()
331        .await
332    }
333
334    /// Get order books for multiple tokens
335    pub async fn order_books(&self, params: &[BookParams]) -> Result<Vec<OrderBook>, ClobError> {
336        Request::<Vec<OrderBook>>::post(
337            self.http_client.clone(),
338            "/books".to_string(),
339            AuthMode::None,
340            self.chain_id,
341        )
342        .body(params)?
343        .send()
344        .await
345    }
346
347    /// Get prices for multiple tokens
348    pub async fn prices(&self, params: &[BookParams]) -> Result<Vec<PriceResponse>, ClobError> {
349        Request::<Vec<PriceResponse>>::post(
350            self.http_client.clone(),
351            "/prices".to_string(),
352            AuthMode::None,
353            self.chain_id,
354        )
355        .body(params)?
356        .send()
357        .await
358    }
359
360    /// Get midpoints for multiple tokens
361    pub async fn midpoints(
362        &self,
363        params: &[BookParams],
364    ) -> Result<Vec<MidpointResponse>, ClobError> {
365        Request::<Vec<MidpointResponse>>::post(
366            self.http_client.clone(),
367            "/midpoints".to_string(),
368            AuthMode::None,
369            self.chain_id,
370        )
371        .body(params)?
372        .send()
373        .await
374    }
375
376    /// Get spreads for multiple tokens
377    pub async fn spreads(&self, params: &[BookParams]) -> Result<Vec<SpreadResponse>, ClobError> {
378        Request::<Vec<SpreadResponse>>::post(
379            self.http_client.clone(),
380            "/spreads".to_string(),
381            AuthMode::None,
382            self.chain_id,
383        )
384        .body(params)?
385        .send()
386        .await
387    }
388
389    /// Get last trade prices for multiple tokens
390    pub async fn last_trade_prices(
391        &self,
392        params: &[BookParams],
393    ) -> Result<Vec<LastTradePriceResponse>, ClobError> {
394        Request::<Vec<LastTradePriceResponse>>::post(
395            self.http_client.clone(),
396            "/last-trades-prices".to_string(),
397            AuthMode::None,
398            self.chain_id,
399        )
400        .body(params)?
401        .send()
402        .await
403    }
404}
405
406/// Market information
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct Market {
409    pub condition_id: String,
410    pub question_id: Option<String>,
411    pub tokens: Vec<MarketToken>,
412    pub rewards: Option<serde_json::Value>,
413    pub minimum_order_size: Option<f64>,
414    pub minimum_tick_size: Option<f64>,
415    pub description: Option<String>,
416    pub category: Option<String>,
417    pub end_date_iso: Option<String>,
418    pub question: Option<String>,
419    pub active: bool,
420    pub closed: bool,
421    pub archived: bool,
422    pub accepting_orders: Option<bool>,
423    pub neg_risk: Option<bool>,
424    pub neg_risk_market_id: Option<String>,
425    pub enable_order_book: Option<bool>,
426}
427
428/// Markets list response
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct ListMarketsResponse {
431    pub data: Vec<Market>,
432    pub next_cursor: Option<String>,
433}
434
435/// Market token (outcome)
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct MarketToken {
438    pub token_id: Option<String>,
439    pub outcome: String,
440    pub price: Option<f64>,
441    pub winner: Option<bool>,
442}
443
444/// Order book level (price and size)
445#[derive(Debug, Clone, Serialize, Deserialize)]
446pub struct OrderLevel {
447    #[serde(with = "rust_decimal::serde::str")]
448    pub price: Decimal,
449    #[serde(with = "rust_decimal::serde::str")]
450    pub size: Decimal,
451}
452
453/// Order book data
454#[derive(Debug, Clone, Serialize, Deserialize)]
455pub struct OrderBook {
456    pub market: String,
457    pub asset_id: String,
458    pub bids: Vec<OrderLevel>,
459    pub asks: Vec<OrderLevel>,
460    pub timestamp: String,
461    pub hash: String,
462    pub min_order_size: Option<String>,
463    pub tick_size: Option<String>,
464    #[serde(default)]
465    pub neg_risk: Option<bool>,
466    pub last_trade_price: Option<String>,
467}
468
469/// Price response
470#[derive(Debug, Clone, Serialize, Deserialize)]
471pub struct PriceResponse {
472    pub price: String,
473}
474
475/// Midpoint price response
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct MidpointResponse {
478    pub mid: String,
479}
480
481/// A single point in the price history timeseries
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct PriceHistoryPoint {
484    /// Unix timestamp (seconds)
485    #[serde(rename = "t")]
486    pub timestamp: i64,
487    /// Price at this point in time
488    #[serde(rename = "p")]
489    pub price: f64,
490}
491
492/// Response from the prices-history endpoint
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct PricesHistoryResponse {
495    pub history: Vec<PriceHistoryPoint>,
496}
497
498/// Response from the neg-risk endpoint
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct NegRiskResponse {
501    pub neg_risk: bool,
502}
503
504/// Response from the fee-rate endpoint
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct FeeRateResponse {
507    pub base_fee: u32,
508}
509
510/// Response from the tick-size endpoint
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct TickSizeResponse {
513    #[serde(deserialize_with = "deserialize_tick_size")]
514    pub minimum_tick_size: String,
515}
516
517/// Parameters for batch pricing requests
518#[derive(Debug, Clone, Serialize)]
519pub struct BookParams {
520    pub token_id: String,
521    #[serde(skip_serializing_if = "Option::is_none")]
522    pub side: Option<OrderSide>,
523}
524
525/// Spread response (bid-ask spread for a token)
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct SpreadResponse {
528    pub token_id: Option<String>,
529    pub spread: String,
530    pub bid: Option<String>,
531    pub ask: Option<String>,
532}
533
534/// Last trade price response
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct LastTradePriceResponse {
537    pub token_id: Option<String>,
538    pub price: Option<String>,
539    pub last_trade_price: Option<String>,
540    pub side: Option<String>,
541    pub timestamp: Option<String>,
542}
543
544/// A live activity event for a market
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct LiveActivityEvent {
547    pub condition_id: String,
548    #[serde(flatten)]
549    pub extra: serde_json::Value,
550}
551
552/// Parameters for the calculate-price endpoint
553#[derive(Debug, Clone, Serialize)]
554pub struct CalculatePriceParams {
555    pub token_id: String,
556    pub side: OrderSide,
557    pub amount: String,
558}
559
560/// Response from the calculate-price endpoint
561#[derive(Debug, Clone, Serialize, Deserialize)]
562pub struct CalculatePriceResponse {
563    pub price: String,
564}
565
566fn deserialize_tick_size<'de, D>(deserializer: D) -> Result<String, D::Error>
567where
568    D: serde::Deserializer<'de>,
569{
570    use serde::Deserialize;
571    let v = serde_json::Value::deserialize(deserializer)?;
572    match v {
573        serde_json::Value::String(s) => Ok(s),
574        serde_json::Value::Number(n) => Ok(n.to_string()),
575        _ => Err(serde::de::Error::custom(
576            "expected string or number for tick size",
577        )),
578    }
579}
580
581/// A token in a CLOB market with its ID and outcome label.
582///
583/// Field names are abbreviated to match the wire format:
584/// `t` = token ID, `o` = outcome label.
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct ClobToken {
587    /// Token ID
588    pub t: String,
589    /// Outcome label (e.g. "Yes", "No")
590    pub o: String,
591}
592
593/// Fee curve parameters for a market.
594///
595/// Field names are abbreviated to match the wire format:
596/// `r` = rate, `e` = exponent, `to` = takers only.
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct FeeDetails {
599    /// Fee rate
600    pub r: Option<f64>,
601    /// Fee curve exponent
602    pub e: Option<f64>,
603    /// Whether fees apply to takers only
604    pub to: Option<bool>,
605}
606
607/// Rewards configuration for a market.
608///
609/// The upstream OpenAPI spec declares this object with `additionalProperties: true`
610/// and no explicit fields, so we model it as a free-form map.
611#[derive(Debug, Clone, Default, Serialize, Deserialize)]
612#[serde(transparent)]
613pub struct ClobRewards {
614    /// Arbitrary rewards payload. Structure is market-dependent.
615    pub extra: HashMap<String, serde_json::Value>,
616}
617
618/// CLOB-level parameters for a market.
619///
620/// Returned by `GET /clob-markets/{condition_id}`. Field names are intentionally
621/// abbreviated to match the wire format:
622/// `gst` = game start time, `r` = rewards, `t` = tokens, `mos` = minimum order size,
623/// `mts` = minimum tick size, `mbf` = maker base fee, `tbf` = taker base fee,
624/// `rfqe` = RFQ enabled, `itode` = taker order delay enabled,
625/// `ibce` = Blockaid check enabled, `fd` = fee details,
626/// `oas` = minimum order age (seconds).
627#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct ClobMarketDetails {
629    /// Game start time (sports markets). ISO 8601 timestamp or null.
630    pub gst: Option<String>,
631    /// Rewards configuration
632    pub r: ClobRewards,
633    /// Tokens for this market
634    pub t: Vec<ClobToken>,
635    /// Minimum order size
636    pub mos: f64,
637    /// Minimum tick size (price increment)
638    pub mts: f64,
639    /// Maker base fee (basis points)
640    pub mbf: i64,
641    /// Taker base fee (basis points)
642    pub tbf: i64,
643    /// Whether RFQ is enabled for this market
644    pub rfqe: bool,
645    /// Whether taker order delay is enabled
646    pub itode: bool,
647    /// Whether Blockaid check is enabled
648    pub ibce: bool,
649    /// Fee curve parameters
650    pub fd: FeeDetails,
651    /// Minimum order age in seconds
652    pub oas: i32,
653}
654
655/// Response for `GET /markets-by-token/{token_id}`: the condition ID and
656/// both token IDs of the market containing the given token.
657#[derive(Debug, Clone, Serialize, Deserialize)]
658pub struct MarketByTokenResponse {
659    /// The condition ID of the market containing the given token
660    pub condition_id: String,
661    /// The primary (Yes) token ID
662    pub primary_token_id: String,
663    /// The secondary (No) token ID
664    pub secondary_token_id: String,
665}
666
667/// Minimal market information for live-activity widgets
668/// (`GET /markets/live-activity/{condition_id}` and bulk variant).
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct LiveActivityMarket {
671    /// Unique identifier for the market condition
672    pub condition_id: Option<String>,
673    /// Internal market ID
674    pub id: Option<i64>,
675    /// The market question being asked
676    pub question: Option<String>,
677    /// URL-friendly slug for the market
678    pub market_slug: Option<String>,
679    /// URL-friendly slug for the parent event
680    pub event_slug: Option<String>,
681    /// URL-friendly slug for the series (if applicable)
682    pub series_slug: Option<String>,
683    /// URL to the market icon image
684    pub icon: Option<String>,
685    /// URL to the market image
686    pub image: Option<String>,
687    /// List of tag slugs associated with this market
688    #[serde(default)]
689    pub tags: Vec<String>,
690}
691
692/// A single price point in `BatchPricesHistoryResponse`.
693///
694/// Field names are abbreviated to match the wire format:
695/// `t` = unix timestamp (seconds), `p` = price.
696#[derive(Debug, Clone, Serialize, Deserialize)]
697pub struct MarketPrice {
698    /// Unix timestamp (seconds)
699    pub t: u32,
700    /// Price at this point in time
701    pub p: f64,
702}
703
704/// Request body for `POST /batch-prices-history`.
705#[derive(Debug, Clone, Default, Serialize, Deserialize)]
706pub struct BatchPricesHistoryRequest {
707    /// List of market asset ids to query (maximum 20).
708    pub markets: Vec<String>,
709    /// Filter by items after this unix timestamp (seconds).
710    #[serde(skip_serializing_if = "Option::is_none")]
711    pub start_ts: Option<f64>,
712    /// Filter by items before this unix timestamp (seconds).
713    #[serde(skip_serializing_if = "Option::is_none")]
714    pub end_ts: Option<f64>,
715    /// Time interval for data aggregation (`max`, `all`, `1m`, `1w`, `1d`, `6h`, `1h`).
716    #[serde(skip_serializing_if = "Option::is_none")]
717    pub interval: Option<String>,
718    /// Accuracy of the data expressed in minutes. Default is 1 minute.
719    #[serde(skip_serializing_if = "Option::is_none")]
720    pub fidelity: Option<i32>,
721}
722
723/// Response body for `POST /batch-prices-history`: a mapping of market asset
724/// id to its list of price points.
725#[derive(Debug, Clone, Default, Serialize, Deserialize)]
726pub struct BatchPricesHistoryResponse {
727    /// Map of market asset id to array of price data points.
728    pub history: HashMap<String, Vec<MarketPrice>>,
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    #[test]
736    fn test_fee_rate_response_deserializes() {
737        let json = r#"{"base_fee": 100}"#;
738        let resp: FeeRateResponse = serde_json::from_str(json).unwrap();
739        assert_eq!(resp.base_fee, 100);
740    }
741
742    #[test]
743    fn test_fee_rate_response_deserializes_zero() {
744        let json = r#"{"base_fee": 0}"#;
745        let resp: FeeRateResponse = serde_json::from_str(json).unwrap();
746        assert_eq!(resp.base_fee, 0);
747    }
748
749    #[test]
750    fn test_fee_rate_response_rejects_missing_field() {
751        let json = r#"{"feeRate": "100"}"#;
752        let result = serde_json::from_str::<FeeRateResponse>(json);
753        assert!(result.is_err(), "Should reject JSON missing base_fee field");
754    }
755
756    #[test]
757    fn test_fee_rate_response_rejects_empty_json() {
758        let json = r#"{}"#;
759        let result = serde_json::from_str::<FeeRateResponse>(json);
760        assert!(result.is_err(), "Should reject empty JSON object");
761    }
762
763    #[test]
764    fn book_params_serializes() {
765        let params = BookParams {
766            token_id: "token-1".into(),
767            side: Some(OrderSide::Buy),
768        };
769        let json = serde_json::to_value(&params).unwrap();
770        assert_eq!(json["token_id"], "token-1");
771        assert_eq!(json["side"], "BUY");
772    }
773
774    #[test]
775    fn book_params_omits_none_side() {
776        let params = BookParams {
777            token_id: "token-1".into(),
778            side: None,
779        };
780        let json = serde_json::to_value(&params).unwrap();
781        assert_eq!(json["token_id"], "token-1");
782        assert!(json.get("side").is_none());
783    }
784
785    #[test]
786    fn spread_response_deserializes() {
787        let json = r#"{
788            "token_id": "token-1",
789            "spread": "0.02",
790            "bid": "0.48",
791            "ask": "0.50"
792        }"#;
793        let resp: SpreadResponse = serde_json::from_str(json).unwrap();
794        assert_eq!(resp.token_id.as_deref(), Some("token-1"));
795        assert_eq!(resp.spread, "0.02");
796        assert_eq!(resp.bid.as_deref(), Some("0.48"));
797        assert_eq!(resp.ask.as_deref(), Some("0.50"));
798    }
799
800    #[test]
801    fn last_trade_price_response_deserializes() {
802        let json = r#"{
803            "token_id": "token-1",
804            "last_trade_price": "0.55",
805            "timestamp": "1700000000"
806        }"#;
807        let resp: LastTradePriceResponse = serde_json::from_str(json).unwrap();
808        assert_eq!(resp.token_id.as_deref(), Some("token-1"));
809        assert_eq!(resp.last_trade_price.as_deref(), Some("0.55"));
810        assert_eq!(resp.timestamp.as_deref(), Some("1700000000"));
811    }
812
813    #[test]
814    fn live_activity_event_deserializes_with_extra_fields() {
815        let json = r#"{
816            "condition_id": "0xabc123",
817            "event_type": "trade",
818            "amount": 100
819        }"#;
820        let event: LiveActivityEvent = serde_json::from_str(json).unwrap();
821        assert_eq!(event.condition_id, "0xabc123");
822        assert_eq!(event.extra["event_type"], "trade");
823        assert_eq!(event.extra["amount"], 100);
824    }
825
826    #[test]
827    fn calculate_price_params_serializes() {
828        let params = CalculatePriceParams {
829            token_id: "token-1".into(),
830            side: OrderSide::Buy,
831            amount: "100.0".into(),
832        };
833        let json = serde_json::to_value(&params).unwrap();
834        assert_eq!(json["token_id"], "token-1");
835        assert_eq!(json["side"], "BUY");
836        assert_eq!(json["amount"], "100.0");
837    }
838
839    #[test]
840    fn calculate_price_response_deserializes() {
841        let json = r#"{"price": "0.52"}"#;
842        let resp: CalculatePriceResponse = serde_json::from_str(json).unwrap();
843        assert_eq!(resp.price, "0.52");
844    }
845
846    #[test]
847    fn order_book_deserializes_with_new_fields() {
848        let json = r#"{
849            "market": "0xcond",
850            "asset_id": "0xtoken",
851            "bids": [{"price": "0.48", "size": "100"}],
852            "asks": [{"price": "0.52", "size": "200"}],
853            "timestamp": "1700000000",
854            "hash": "abc123",
855            "min_order_size": "5",
856            "tick_size": "0.001",
857            "neg_risk": false,
858            "last_trade_price": "0.50"
859        }"#;
860        let ob: OrderBook = serde_json::from_str(json).unwrap();
861        assert_eq!(ob.market, "0xcond");
862        assert_eq!(ob.bids.len(), 1);
863        assert_eq!(ob.asks.len(), 1);
864        assert_eq!(ob.min_order_size.as_deref(), Some("5"));
865        assert_eq!(ob.tick_size.as_deref(), Some("0.001"));
866        assert_eq!(ob.neg_risk, Some(false));
867        assert_eq!(ob.last_trade_price.as_deref(), Some("0.50"));
868    }
869
870    #[test]
871    fn order_book_deserializes_without_new_fields() {
872        let json = r#"{
873            "market": "0xcond",
874            "asset_id": "0xtoken",
875            "bids": [],
876            "asks": [],
877            "timestamp": "1700000000",
878            "hash": "abc123"
879        }"#;
880        let ob: OrderBook = serde_json::from_str(json).unwrap();
881        assert_eq!(ob.market, "0xcond");
882        assert!(ob.min_order_size.is_none());
883        assert!(ob.tick_size.is_none());
884        assert!(ob.neg_risk.is_none());
885        assert!(ob.last_trade_price.is_none());
886    }
887
888    #[test]
889    fn clob_market_details_roundtrip() {
890        // Shape lifted from docs/specs/clob/openapi.yaml ClobMarketDetails example.
891        let json = r#"{
892            "gst": null,
893            "r": {"minSize": 100, "maxSpread": 2.0},
894            "t": [
895                {"t": "71321045679252212594626385532706912750332728571942532289631379312455583992563", "o": "Yes"},
896                {"t": "52114319501245915516055106046884209969926127482827954674443846427813813222426", "o": "No"}
897            ],
898            "mos": 5.0,
899            "mts": 0.01,
900            "mbf": 0,
901            "tbf": 0,
902            "rfqe": true,
903            "itode": false,
904            "ibce": true,
905            "fd": {"r": 0.02, "e": 2.0, "to": true},
906            "oas": 0
907        }"#;
908        let parsed: ClobMarketDetails = serde_json::from_str(json).unwrap();
909        assert!(parsed.gst.is_none());
910        assert_eq!(parsed.t.len(), 2);
911        assert_eq!(parsed.t[0].o, "Yes");
912        assert!((parsed.mos - 5.0).abs() < f64::EPSILON);
913        assert!((parsed.mts - 0.01).abs() < f64::EPSILON);
914        assert_eq!(parsed.mbf, 0);
915        assert_eq!(parsed.tbf, 0);
916        assert!(parsed.rfqe);
917        assert!(!parsed.itode);
918        assert!(parsed.ibce);
919        assert_eq!(parsed.fd.r, Some(0.02));
920        assert_eq!(parsed.fd.e, Some(2.0));
921        assert_eq!(parsed.fd.to, Some(true));
922        assert_eq!(parsed.oas, 0);
923        // Free-form rewards captured via flatten
924        assert!(parsed.r.extra.contains_key("minSize"));
925
926        // Ensure roundtrip: serialize then deserialize again produces equivalent data.
927        let back = serde_json::to_value(&parsed).unwrap();
928        let again: ClobMarketDetails = serde_json::from_value(back).unwrap();
929        assert_eq!(again.t.len(), 2);
930        assert_eq!(again.fd.r, Some(0.02));
931    }
932
933    #[test]
934    fn market_by_token_response_deserializes() {
935        let json = r#"{
936            "condition_id": "0xbd31dc8a",
937            "primary_token_id": "713210456",
938            "secondary_token_id": "521143195"
939        }"#;
940        let parsed: MarketByTokenResponse = serde_json::from_str(json).unwrap();
941        assert_eq!(parsed.condition_id, "0xbd31dc8a");
942        assert_eq!(parsed.primary_token_id, "713210456");
943        assert_eq!(parsed.secondary_token_id, "521143195");
944    }
945
946    #[test]
947    fn live_activity_market_roundtrip() {
948        let json = r#"{
949            "condition_id": "0xcond",
950            "id": 42,
951            "question": "Will X happen?",
952            "market_slug": "will-x-happen",
953            "event_slug": "x-event",
954            "series_slug": null,
955            "icon": "https://icon",
956            "image": "https://image",
957            "tags": ["crypto", "sports"]
958        }"#;
959        let parsed: LiveActivityMarket = serde_json::from_str(json).unwrap();
960        assert_eq!(parsed.condition_id.as_deref(), Some("0xcond"));
961        assert_eq!(parsed.id, Some(42));
962        assert_eq!(parsed.question.as_deref(), Some("Will X happen?"));
963        assert_eq!(parsed.market_slug.as_deref(), Some("will-x-happen"));
964        assert_eq!(parsed.event_slug.as_deref(), Some("x-event"));
965        assert!(parsed.series_slug.is_none());
966        assert_eq!(parsed.tags, vec!["crypto", "sports"]);
967
968        // Roundtrip
969        let back: LiveActivityMarket =
970            serde_json::from_value(serde_json::to_value(&parsed).unwrap()).unwrap();
971        assert_eq!(back.id, Some(42));
972    }
973
974    #[test]
975    fn batch_prices_history_request_omits_none_fields() {
976        let req = BatchPricesHistoryRequest {
977            markets: vec!["0xtoken1".into(), "0xtoken2".into()],
978            start_ts: Some(1_700_000_000.0),
979            end_ts: None,
980            interval: Some("1d".into()),
981            fidelity: None,
982        };
983        let json = serde_json::to_value(&req).unwrap();
984        assert_eq!(json["markets"][0], "0xtoken1");
985        assert!((json["start_ts"].as_f64().unwrap() - 1_700_000_000.0).abs() < f64::EPSILON);
986        assert_eq!(json["interval"], "1d");
987        assert!(json.get("end_ts").is_none());
988        assert!(json.get("fidelity").is_none());
989    }
990
991    #[test]
992    fn batch_prices_history_response_roundtrip() {
993        let json = r#"{
994            "history": {
995                "0xtokenA": [
996                    {"t": 1700000000, "p": 0.55},
997                    {"t": 1700001000, "p": 0.60}
998                ],
999                "0xtokenB": [
1000                    {"t": 1700000000, "p": 0.30}
1001                ]
1002            }
1003        }"#;
1004        let parsed: BatchPricesHistoryResponse = serde_json::from_str(json).unwrap();
1005        assert_eq!(parsed.history.len(), 2);
1006        let a = parsed.history.get("0xtokenA").unwrap();
1007        assert_eq!(a.len(), 2);
1008        assert_eq!(a[0].t, 1_700_000_000);
1009        assert!((a[0].p - 0.55).abs() < f64::EPSILON);
1010        let b = parsed.history.get("0xtokenB").unwrap();
1011        assert_eq!(b.len(), 1);
1012        assert!((b[0].p - 0.30).abs() < f64::EPSILON);
1013
1014        // Roundtrip
1015        let back: BatchPricesHistoryResponse =
1016            serde_json::from_value(serde_json::to_value(&parsed).unwrap()).unwrap();
1017        assert_eq!(back.history.len(), 2);
1018    }
1019}