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: String,
297    pub tokens: Vec<MarketToken>,
298    pub rewards: Option<serde_json::Value>,
299    pub minimum_order_size: f64,
300    pub minimum_tick_size: f64,
301    pub description: String,
302    pub category: Option<String>,
303    pub end_date_iso: Option<String>,
304    pub question: String,
305    pub active: bool,
306    pub closed: bool,
307    pub archived: bool,
308    pub neg_risk: Option<bool>,
309    pub neg_risk_market_id: Option<String>,
310    pub enable_order_book: Option<bool>,
311}
312
313/// Markets list response
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ListMarketsResponse {
316    pub data: Vec<Market>,
317    pub next_cursor: Option<String>,
318}
319
320/// Market token (outcome)
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct MarketToken {
323    pub token_id: Option<String>,
324    pub outcome: String,
325    pub price: Option<f64>,
326    pub winner: Option<bool>,
327}
328
329/// Order book level (price and size)
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct OrderLevel {
332    #[serde(with = "rust_decimal::serde::str")]
333    pub price: Decimal,
334    #[serde(with = "rust_decimal::serde::str")]
335    pub size: Decimal,
336}
337
338/// Order book data
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct OrderBook {
341    pub market: String,
342    pub asset_id: String,
343    pub bids: Vec<OrderLevel>,
344    pub asks: Vec<OrderLevel>,
345    pub timestamp: String,
346    pub hash: String,
347    pub min_order_size: Option<String>,
348    pub tick_size: Option<String>,
349    #[serde(default)]
350    pub neg_risk: Option<bool>,
351    pub last_trade_price: Option<String>,
352}
353
354/// Price response
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct PriceResponse {
357    pub price: String,
358}
359
360/// Midpoint price response
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct MidpointResponse {
363    pub mid: String,
364}
365
366/// A single point in the price history timeseries
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct PriceHistoryPoint {
369    /// Unix timestamp (seconds)
370    #[serde(rename = "t")]
371    pub timestamp: i64,
372    /// Price at this point in time
373    #[serde(rename = "p")]
374    pub price: f64,
375}
376
377/// Response from the prices-history endpoint
378#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct PricesHistoryResponse {
380    pub history: Vec<PriceHistoryPoint>,
381}
382
383/// Response from the neg-risk endpoint
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct NegRiskResponse {
386    pub neg_risk: bool,
387}
388
389/// Response from the fee-rate endpoint
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct FeeRateResponse {
392    pub base_fee: u32,
393}
394
395/// Response from the tick-size endpoint
396#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct TickSizeResponse {
398    #[serde(deserialize_with = "deserialize_tick_size")]
399    pub minimum_tick_size: String,
400}
401
402/// Parameters for batch pricing requests
403#[derive(Debug, Clone, Serialize)]
404pub struct BookParams {
405    pub token_id: String,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub side: Option<OrderSide>,
408}
409
410/// Spread response (bid-ask spread for a token)
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct SpreadResponse {
413    pub token_id: String,
414    pub spread: String,
415    pub bid: String,
416    pub ask: String,
417}
418
419/// Last trade price response
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct LastTradePriceResponse {
422    pub token_id: String,
423    pub last_trade_price: String,
424    pub timestamp: String,
425}
426
427/// A live activity event for a market
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct LiveActivityEvent {
430    pub condition_id: String,
431    #[serde(flatten)]
432    pub extra: serde_json::Value,
433}
434
435/// Parameters for the calculate-price endpoint
436#[derive(Debug, Clone, Serialize)]
437pub struct CalculatePriceParams {
438    pub token_id: String,
439    pub side: OrderSide,
440    pub amount: String,
441}
442
443/// Response from the calculate-price endpoint
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct CalculatePriceResponse {
446    pub price: String,
447}
448
449fn deserialize_tick_size<'de, D>(deserializer: D) -> Result<String, D::Error>
450where
451    D: serde::Deserializer<'de>,
452{
453    use serde::Deserialize;
454    let v = serde_json::Value::deserialize(deserializer)?;
455    match v {
456        serde_json::Value::String(s) => Ok(s),
457        serde_json::Value::Number(n) => Ok(n.to_string()),
458        _ => Err(serde::de::Error::custom(
459            "expected string or number for tick size",
460        )),
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_fee_rate_response_deserializes() {
470        let json = r#"{"base_fee": 100}"#;
471        let resp: FeeRateResponse = serde_json::from_str(json).unwrap();
472        assert_eq!(resp.base_fee, 100);
473    }
474
475    #[test]
476    fn test_fee_rate_response_deserializes_zero() {
477        let json = r#"{"base_fee": 0}"#;
478        let resp: FeeRateResponse = serde_json::from_str(json).unwrap();
479        assert_eq!(resp.base_fee, 0);
480    }
481
482    #[test]
483    fn test_fee_rate_response_rejects_missing_field() {
484        let json = r#"{"feeRate": "100"}"#;
485        let result = serde_json::from_str::<FeeRateResponse>(json);
486        assert!(result.is_err(), "Should reject JSON missing base_fee field");
487    }
488
489    #[test]
490    fn test_fee_rate_response_rejects_empty_json() {
491        let json = r#"{}"#;
492        let result = serde_json::from_str::<FeeRateResponse>(json);
493        assert!(result.is_err(), "Should reject empty JSON object");
494    }
495
496    #[test]
497    fn book_params_serializes() {
498        let params = BookParams {
499            token_id: "token-1".into(),
500            side: Some(OrderSide::Buy),
501        };
502        let json = serde_json::to_value(&params).unwrap();
503        assert_eq!(json["token_id"], "token-1");
504        assert_eq!(json["side"], "BUY");
505    }
506
507    #[test]
508    fn book_params_omits_none_side() {
509        let params = BookParams {
510            token_id: "token-1".into(),
511            side: None,
512        };
513        let json = serde_json::to_value(&params).unwrap();
514        assert_eq!(json["token_id"], "token-1");
515        assert!(json.get("side").is_none());
516    }
517
518    #[test]
519    fn spread_response_deserializes() {
520        let json = r#"{
521            "token_id": "token-1",
522            "spread": "0.02",
523            "bid": "0.48",
524            "ask": "0.50"
525        }"#;
526        let resp: SpreadResponse = serde_json::from_str(json).unwrap();
527        assert_eq!(resp.token_id, "token-1");
528        assert_eq!(resp.spread, "0.02");
529        assert_eq!(resp.bid, "0.48");
530        assert_eq!(resp.ask, "0.50");
531    }
532
533    #[test]
534    fn last_trade_price_response_deserializes() {
535        let json = r#"{
536            "token_id": "token-1",
537            "last_trade_price": "0.55",
538            "timestamp": "1700000000"
539        }"#;
540        let resp: LastTradePriceResponse = serde_json::from_str(json).unwrap();
541        assert_eq!(resp.token_id, "token-1");
542        assert_eq!(resp.last_trade_price, "0.55");
543        assert_eq!(resp.timestamp, "1700000000");
544    }
545
546    #[test]
547    fn live_activity_event_deserializes_with_extra_fields() {
548        let json = r#"{
549            "condition_id": "0xabc123",
550            "event_type": "trade",
551            "amount": 100
552        }"#;
553        let event: LiveActivityEvent = serde_json::from_str(json).unwrap();
554        assert_eq!(event.condition_id, "0xabc123");
555        assert_eq!(event.extra["event_type"], "trade");
556        assert_eq!(event.extra["amount"], 100);
557    }
558
559    #[test]
560    fn calculate_price_params_serializes() {
561        let params = CalculatePriceParams {
562            token_id: "token-1".into(),
563            side: OrderSide::Buy,
564            amount: "100.0".into(),
565        };
566        let json = serde_json::to_value(&params).unwrap();
567        assert_eq!(json["token_id"], "token-1");
568        assert_eq!(json["side"], "BUY");
569        assert_eq!(json["amount"], "100.0");
570    }
571
572    #[test]
573    fn calculate_price_response_deserializes() {
574        let json = r#"{"price": "0.52"}"#;
575        let resp: CalculatePriceResponse = serde_json::from_str(json).unwrap();
576        assert_eq!(resp.price, "0.52");
577    }
578
579    #[test]
580    fn order_book_deserializes_with_new_fields() {
581        let json = r#"{
582            "market": "0xcond",
583            "asset_id": "0xtoken",
584            "bids": [{"price": "0.48", "size": "100"}],
585            "asks": [{"price": "0.52", "size": "200"}],
586            "timestamp": "1700000000",
587            "hash": "abc123",
588            "min_order_size": "5",
589            "tick_size": "0.001",
590            "neg_risk": false,
591            "last_trade_price": "0.50"
592        }"#;
593        let ob: OrderBook = serde_json::from_str(json).unwrap();
594        assert_eq!(ob.market, "0xcond");
595        assert_eq!(ob.bids.len(), 1);
596        assert_eq!(ob.asks.len(), 1);
597        assert_eq!(ob.min_order_size.as_deref(), Some("5"));
598        assert_eq!(ob.tick_size.as_deref(), Some("0.001"));
599        assert_eq!(ob.neg_risk, Some(false));
600        assert_eq!(ob.last_trade_price.as_deref(), Some("0.50"));
601    }
602
603    #[test]
604    fn order_book_deserializes_without_new_fields() {
605        let json = r#"{
606            "market": "0xcond",
607            "asset_id": "0xtoken",
608            "bids": [],
609            "asks": [],
610            "timestamp": "1700000000",
611            "hash": "abc123"
612        }"#;
613        let ob: OrderBook = serde_json::from_str(json).unwrap();
614        assert_eq!(ob.market, "0xcond");
615        assert!(ob.min_order_size.is_none());
616        assert!(ob.tick_size.is_none());
617        assert!(ob.neg_risk.is_none());
618        assert!(ob.last_trade_price.is_none());
619    }
620}