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