Skip to main content

polyoxide_clob/api/
markets.rs

1use polyoxide_core::{HttpClient, QueryBuilder};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    error::ClobError,
7    request::{AuthMode, Request},
8    types::OrderSide,
9};
10
11/// Markets namespace for market-related operations
12#[derive(Clone)]
13pub struct Markets {
14    pub(crate) http_client: HttpClient,
15    pub(crate) chain_id: u64,
16}
17
18impl Markets {
19    /// Get a market by condition ID
20    pub fn get(&self, condition_id: impl Into<String>) -> Request<Market> {
21        Request::get(
22            self.http_client.clone(),
23            format!("/markets/{}", urlencoding::encode(&condition_id.into())),
24            AuthMode::None,
25            self.chain_id,
26        )
27    }
28
29    pub fn get_by_token_ids(
30        &self,
31        token_ids: impl Into<Vec<String>>,
32    ) -> Request<ListMarketsResponse> {
33        Request::get(
34            self.http_client.clone(),
35            "/markets",
36            AuthMode::None,
37            self.chain_id,
38        )
39        .query_many("clob_token_ids", token_ids.into())
40    }
41
42    /// List all markets
43    pub fn list(&self) -> Request<ListMarketsResponse> {
44        Request::get(
45            self.http_client.clone(),
46            "/markets",
47            AuthMode::None,
48            self.chain_id,
49        )
50    }
51
52    /// Get order book for a token
53    pub fn order_book(&self, token_id: impl Into<String>) -> Request<OrderBook> {
54        Request::get(
55            self.http_client.clone(),
56            "/book",
57            AuthMode::None,
58            self.chain_id,
59        )
60        .query("token_id", token_id.into())
61    }
62
63    /// Get price for a token and side
64    pub fn price(&self, token_id: impl Into<String>, side: OrderSide) -> Request<PriceResponse> {
65        Request::get(
66            self.http_client.clone(),
67            "/price",
68            AuthMode::None,
69            self.chain_id,
70        )
71        .query("token_id", token_id.into())
72        .query("side", side.as_str())
73    }
74
75    /// Get midpoint price for a token
76    pub fn midpoint(&self, token_id: impl Into<String>) -> Request<MidpointResponse> {
77        Request::get(
78            self.http_client.clone(),
79            "/midpoint",
80            AuthMode::None,
81            self.chain_id,
82        )
83        .query("token_id", token_id.into())
84    }
85
86    /// Get historical prices for a token
87    pub fn prices_history(&self, token_id: impl Into<String>) -> Request<PricesHistoryResponse> {
88        Request::get(
89            self.http_client.clone(),
90            "/prices-history",
91            AuthMode::None,
92            self.chain_id,
93        )
94        .query("market", token_id.into())
95    }
96
97    /// Get neg_risk status for a token
98    pub fn neg_risk(&self, token_id: impl Into<String>) -> Request<NegRiskResponse> {
99        Request::get(
100            self.http_client.clone(),
101            "/neg-risk".to_string(),
102            AuthMode::None,
103            self.chain_id,
104        )
105        .query("token_id", token_id.into())
106    }
107
108    /// Get the current fee rate for a token
109    pub fn fee_rate(&self, token_id: impl Into<String>) -> Request<FeeRateResponse> {
110        Request::get(
111            self.http_client.clone(),
112            "/fee-rate",
113            AuthMode::None,
114            self.chain_id,
115        )
116        .query("token_id", token_id.into())
117    }
118
119    /// Get tick size for a token
120    pub fn tick_size(&self, token_id: impl Into<String>) -> Request<TickSizeResponse> {
121        Request::get(
122            self.http_client.clone(),
123            "/tick-size".to_string(),
124            AuthMode::None,
125            self.chain_id,
126        )
127        .query("token_id", token_id.into())
128    }
129
130    /// Get bid-ask spread for a token
131    pub fn spread(&self, token_id: impl Into<String>) -> Request<SpreadResponse> {
132        Request::get(
133            self.http_client.clone(),
134            "/spread",
135            AuthMode::None,
136            self.chain_id,
137        )
138        .query("token_id", token_id.into())
139    }
140
141    /// Get last trade price for a token
142    pub fn last_trade_price(&self, token_id: impl Into<String>) -> Request<LastTradePriceResponse> {
143        Request::get(
144            self.http_client.clone(),
145            "/last-trade-price",
146            AuthMode::None,
147            self.chain_id,
148        )
149        .query("token_id", token_id.into())
150    }
151
152    /// Get live activity events for a market
153    pub fn live_activity(
154        &self,
155        condition_id: impl Into<String>,
156    ) -> Request<Vec<LiveActivityEvent>> {
157        Request::get(
158            self.http_client.clone(),
159            format!(
160                "/live-activity/events/{}",
161                urlencoding::encode(&condition_id.into())
162            ),
163            AuthMode::None,
164            self.chain_id,
165        )
166    }
167
168    /// List simplified markets (reduced payload for performance)
169    pub fn simplified(&self) -> Request<ListMarketsResponse> {
170        Request::get(
171            self.http_client.clone(),
172            "/simplified-markets",
173            AuthMode::None,
174            self.chain_id,
175        )
176    }
177
178    /// List sampling markets
179    pub fn sampling(&self) -> Request<ListMarketsResponse> {
180        Request::get(
181            self.http_client.clone(),
182            "/sampling-markets",
183            AuthMode::None,
184            self.chain_id,
185        )
186    }
187
188    /// List sampling simplified markets
189    pub fn sampling_simplified(&self) -> Request<ListMarketsResponse> {
190        Request::get(
191            self.http_client.clone(),
192            "/sampling-simplified-markets",
193            AuthMode::None,
194            self.chain_id,
195        )
196    }
197
198    /// Calculate estimated execution price for a market order
199    pub async fn calculate_price(
200        &self,
201        token_id: impl Into<String>,
202        side: OrderSide,
203        amount: impl Into<String>,
204    ) -> Result<CalculatePriceResponse, ClobError> {
205        Request::<CalculatePriceResponse>::post(
206            self.http_client.clone(),
207            "/calculate-price".to_string(),
208            AuthMode::None,
209            self.chain_id,
210        )
211        .body(&CalculatePriceParams {
212            token_id: token_id.into(),
213            side,
214            amount: amount.into(),
215        })?
216        .send()
217        .await
218    }
219
220    /// Get order books for multiple tokens
221    pub async fn order_books(&self, params: &[BookParams]) -> Result<Vec<OrderBook>, ClobError> {
222        Request::<Vec<OrderBook>>::post(
223            self.http_client.clone(),
224            "/books".to_string(),
225            AuthMode::None,
226            self.chain_id,
227        )
228        .body(params)?
229        .send()
230        .await
231    }
232
233    /// Get prices for multiple tokens
234    pub async fn prices(&self, params: &[BookParams]) -> Result<Vec<PriceResponse>, ClobError> {
235        Request::<Vec<PriceResponse>>::post(
236            self.http_client.clone(),
237            "/prices".to_string(),
238            AuthMode::None,
239            self.chain_id,
240        )
241        .body(params)?
242        .send()
243        .await
244    }
245
246    /// Get midpoints for multiple tokens
247    pub async fn midpoints(
248        &self,
249        params: &[BookParams],
250    ) -> Result<Vec<MidpointResponse>, ClobError> {
251        Request::<Vec<MidpointResponse>>::post(
252            self.http_client.clone(),
253            "/midpoints".to_string(),
254            AuthMode::None,
255            self.chain_id,
256        )
257        .body(params)?
258        .send()
259        .await
260    }
261
262    /// Get spreads for multiple tokens
263    pub async fn spreads(&self, params: &[BookParams]) -> Result<Vec<SpreadResponse>, ClobError> {
264        Request::<Vec<SpreadResponse>>::post(
265            self.http_client.clone(),
266            "/spreads".to_string(),
267            AuthMode::None,
268            self.chain_id,
269        )
270        .body(params)?
271        .send()
272        .await
273    }
274
275    /// Get last trade prices for multiple tokens
276    pub async fn last_trade_prices(
277        &self,
278        params: &[BookParams],
279    ) -> Result<Vec<LastTradePriceResponse>, ClobError> {
280        Request::<Vec<LastTradePriceResponse>>::post(
281            self.http_client.clone(),
282            "/last-trades-prices".to_string(),
283            AuthMode::None,
284            self.chain_id,
285        )
286        .body(params)?
287        .send()
288        .await
289    }
290}
291
292/// Market information
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct Market {
295    pub condition_id: String,
296    pub question_id: Option<String>,
297    pub tokens: Vec<MarketToken>,
298    pub rewards: Option<serde_json::Value>,
299    pub minimum_order_size: Option<f64>,
300    pub minimum_tick_size: Option<f64>,
301    pub description: Option<String>,
302    pub category: Option<String>,
303    pub end_date_iso: Option<String>,
304    pub question: Option<String>,
305    pub active: bool,
306    pub closed: bool,
307    pub archived: bool,
308    pub accepting_orders: Option<bool>,
309    pub neg_risk: Option<bool>,
310    pub neg_risk_market_id: Option<String>,
311    pub enable_order_book: Option<bool>,
312}
313
314/// Markets list response
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ListMarketsResponse {
317    pub data: Vec<Market>,
318    pub next_cursor: Option<String>,
319}
320
321/// Market token (outcome)
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct MarketToken {
324    pub token_id: Option<String>,
325    pub outcome: String,
326    pub price: Option<f64>,
327    pub winner: Option<bool>,
328}
329
330/// Order book level (price and size)
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct OrderLevel {
333    #[serde(with = "rust_decimal::serde::str")]
334    pub price: Decimal,
335    #[serde(with = "rust_decimal::serde::str")]
336    pub size: Decimal,
337}
338
339/// Order book data
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct OrderBook {
342    pub market: String,
343    pub asset_id: String,
344    pub bids: Vec<OrderLevel>,
345    pub asks: Vec<OrderLevel>,
346    pub timestamp: String,
347    pub hash: String,
348    pub min_order_size: Option<String>,
349    pub tick_size: Option<String>,
350    #[serde(default)]
351    pub neg_risk: Option<bool>,
352    pub last_trade_price: Option<String>,
353}
354
355/// Price response
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct PriceResponse {
358    pub price: String,
359}
360
361/// Midpoint price response
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct MidpointResponse {
364    pub mid: String,
365}
366
367/// A single point in the price history timeseries
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct PriceHistoryPoint {
370    /// Unix timestamp (seconds)
371    #[serde(rename = "t")]
372    pub timestamp: i64,
373    /// Price at this point in time
374    #[serde(rename = "p")]
375    pub price: f64,
376}
377
378/// Response from the prices-history endpoint
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct PricesHistoryResponse {
381    pub history: Vec<PriceHistoryPoint>,
382}
383
384/// Response from the neg-risk endpoint
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct NegRiskResponse {
387    pub neg_risk: bool,
388}
389
390/// Response from the fee-rate endpoint
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct FeeRateResponse {
393    pub base_fee: u32,
394}
395
396/// Response from the tick-size endpoint
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct TickSizeResponse {
399    #[serde(deserialize_with = "deserialize_tick_size")]
400    pub minimum_tick_size: String,
401}
402
403/// Parameters for batch pricing requests
404#[derive(Debug, Clone, Serialize)]
405pub struct BookParams {
406    pub token_id: String,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub side: Option<OrderSide>,
409}
410
411/// Spread response (bid-ask spread for a token)
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct SpreadResponse {
414    pub token_id: Option<String>,
415    pub spread: String,
416    pub bid: Option<String>,
417    pub ask: Option<String>,
418}
419
420/// Last trade price response
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct LastTradePriceResponse {
423    pub token_id: Option<String>,
424    pub price: Option<String>,
425    pub last_trade_price: Option<String>,
426    pub side: Option<String>,
427    pub timestamp: Option<String>,
428}
429
430/// A live activity event for a market
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct LiveActivityEvent {
433    pub condition_id: String,
434    #[serde(flatten)]
435    pub extra: serde_json::Value,
436}
437
438/// Parameters for the calculate-price endpoint
439#[derive(Debug, Clone, Serialize)]
440pub struct CalculatePriceParams {
441    pub token_id: String,
442    pub side: OrderSide,
443    pub amount: String,
444}
445
446/// Response from the calculate-price endpoint
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct CalculatePriceResponse {
449    pub price: String,
450}
451
452fn deserialize_tick_size<'de, D>(deserializer: D) -> Result<String, D::Error>
453where
454    D: serde::Deserializer<'de>,
455{
456    use serde::Deserialize;
457    let v = serde_json::Value::deserialize(deserializer)?;
458    match v {
459        serde_json::Value::String(s) => Ok(s),
460        serde_json::Value::Number(n) => Ok(n.to_string()),
461        _ => Err(serde::de::Error::custom(
462            "expected string or number for tick size",
463        )),
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn test_fee_rate_response_deserializes() {
473        let json = r#"{"base_fee": 100}"#;
474        let resp: FeeRateResponse = serde_json::from_str(json).unwrap();
475        assert_eq!(resp.base_fee, 100);
476    }
477
478    #[test]
479    fn test_fee_rate_response_deserializes_zero() {
480        let json = r#"{"base_fee": 0}"#;
481        let resp: FeeRateResponse = serde_json::from_str(json).unwrap();
482        assert_eq!(resp.base_fee, 0);
483    }
484
485    #[test]
486    fn test_fee_rate_response_rejects_missing_field() {
487        let json = r#"{"feeRate": "100"}"#;
488        let result = serde_json::from_str::<FeeRateResponse>(json);
489        assert!(result.is_err(), "Should reject JSON missing base_fee field");
490    }
491
492    #[test]
493    fn test_fee_rate_response_rejects_empty_json() {
494        let json = r#"{}"#;
495        let result = serde_json::from_str::<FeeRateResponse>(json);
496        assert!(result.is_err(), "Should reject empty JSON object");
497    }
498
499    #[test]
500    fn book_params_serializes() {
501        let params = BookParams {
502            token_id: "token-1".into(),
503            side: Some(OrderSide::Buy),
504        };
505        let json = serde_json::to_value(&params).unwrap();
506        assert_eq!(json["token_id"], "token-1");
507        assert_eq!(json["side"], "BUY");
508    }
509
510    #[test]
511    fn book_params_omits_none_side() {
512        let params = BookParams {
513            token_id: "token-1".into(),
514            side: None,
515        };
516        let json = serde_json::to_value(&params).unwrap();
517        assert_eq!(json["token_id"], "token-1");
518        assert!(json.get("side").is_none());
519    }
520
521    #[test]
522    fn spread_response_deserializes() {
523        let json = r#"{
524            "token_id": "token-1",
525            "spread": "0.02",
526            "bid": "0.48",
527            "ask": "0.50"
528        }"#;
529        let resp: SpreadResponse = serde_json::from_str(json).unwrap();
530        assert_eq!(resp.token_id.as_deref(), Some("token-1"));
531        assert_eq!(resp.spread, "0.02");
532        assert_eq!(resp.bid.as_deref(), Some("0.48"));
533        assert_eq!(resp.ask.as_deref(), Some("0.50"));
534    }
535
536    #[test]
537    fn last_trade_price_response_deserializes() {
538        let json = r#"{
539            "token_id": "token-1",
540            "last_trade_price": "0.55",
541            "timestamp": "1700000000"
542        }"#;
543        let resp: LastTradePriceResponse = serde_json::from_str(json).unwrap();
544        assert_eq!(resp.token_id.as_deref(), Some("token-1"));
545        assert_eq!(resp.last_trade_price.as_deref(), Some("0.55"));
546        assert_eq!(resp.timestamp.as_deref(), Some("1700000000"));
547    }
548
549    #[test]
550    fn live_activity_event_deserializes_with_extra_fields() {
551        let json = r#"{
552            "condition_id": "0xabc123",
553            "event_type": "trade",
554            "amount": 100
555        }"#;
556        let event: LiveActivityEvent = serde_json::from_str(json).unwrap();
557        assert_eq!(event.condition_id, "0xabc123");
558        assert_eq!(event.extra["event_type"], "trade");
559        assert_eq!(event.extra["amount"], 100);
560    }
561
562    #[test]
563    fn calculate_price_params_serializes() {
564        let params = CalculatePriceParams {
565            token_id: "token-1".into(),
566            side: OrderSide::Buy,
567            amount: "100.0".into(),
568        };
569        let json = serde_json::to_value(&params).unwrap();
570        assert_eq!(json["token_id"], "token-1");
571        assert_eq!(json["side"], "BUY");
572        assert_eq!(json["amount"], "100.0");
573    }
574
575    #[test]
576    fn calculate_price_response_deserializes() {
577        let json = r#"{"price": "0.52"}"#;
578        let resp: CalculatePriceResponse = serde_json::from_str(json).unwrap();
579        assert_eq!(resp.price, "0.52");
580    }
581
582    #[test]
583    fn order_book_deserializes_with_new_fields() {
584        let json = r#"{
585            "market": "0xcond",
586            "asset_id": "0xtoken",
587            "bids": [{"price": "0.48", "size": "100"}],
588            "asks": [{"price": "0.52", "size": "200"}],
589            "timestamp": "1700000000",
590            "hash": "abc123",
591            "min_order_size": "5",
592            "tick_size": "0.001",
593            "neg_risk": false,
594            "last_trade_price": "0.50"
595        }"#;
596        let ob: OrderBook = serde_json::from_str(json).unwrap();
597        assert_eq!(ob.market, "0xcond");
598        assert_eq!(ob.bids.len(), 1);
599        assert_eq!(ob.asks.len(), 1);
600        assert_eq!(ob.min_order_size.as_deref(), Some("5"));
601        assert_eq!(ob.tick_size.as_deref(), Some("0.001"));
602        assert_eq!(ob.neg_risk, Some(false));
603        assert_eq!(ob.last_trade_price.as_deref(), Some("0.50"));
604    }
605
606    #[test]
607    fn order_book_deserializes_without_new_fields() {
608        let json = r#"{
609            "market": "0xcond",
610            "asset_id": "0xtoken",
611            "bids": [],
612            "asks": [],
613            "timestamp": "1700000000",
614            "hash": "abc123"
615        }"#;
616        let ob: OrderBook = serde_json::from_str(json).unwrap();
617        assert_eq!(ob.market, "0xcond");
618        assert!(ob.min_order_size.is_none());
619        assert!(ob.tick_size.is_none());
620        assert!(ob.neg_risk.is_none());
621        assert!(ob.last_trade_price.is_none());
622    }
623}