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#[derive(Clone)]
13pub struct Markets {
14 pub(crate) http_client: HttpClient,
15 pub(crate) chain_id: u64,
16}
17
18impl Markets {
19 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ListMarketsResponse {
317 pub data: Vec<Market>,
318 pub next_cursor: Option<String>,
319}
320
321#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct PriceResponse {
358 pub price: String,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct MidpointResponse {
364 pub mid: String,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct PriceHistoryPoint {
370 #[serde(rename = "t")]
372 pub timestamp: i64,
373 #[serde(rename = "p")]
375 pub price: f64,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct PricesHistoryResponse {
381 pub history: Vec<PriceHistoryPoint>,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct NegRiskResponse {
387 pub neg_risk: bool,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct FeeRateResponse {
393 pub base_fee: u32,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct TickSizeResponse {
399 #[serde(deserialize_with = "deserialize_tick_size")]
400 pub minimum_tick_size: String,
401}
402
403#[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#[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#[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#[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#[derive(Debug, Clone, Serialize)]
440pub struct CalculatePriceParams {
441 pub token_id: String,
442 pub side: OrderSide,
443 pub amount: String,
444}
445
446#[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(¶ms).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(¶ms).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(¶ms).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}