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