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: 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#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ListMarketsResponse {
316 pub data: Vec<Market>,
317 pub next_cursor: Option<String>,
318}
319
320#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct PriceResponse {
357 pub price: String,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct MidpointResponse {
363 pub mid: String,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct PriceHistoryPoint {
369 #[serde(rename = "t")]
371 pub timestamp: i64,
372 #[serde(rename = "p")]
374 pub price: f64,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379pub struct PricesHistoryResponse {
380 pub history: Vec<PriceHistoryPoint>,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct NegRiskResponse {
386 pub neg_risk: bool,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct FeeRateResponse {
392 pub base_fee: u32,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct TickSizeResponse {
398 #[serde(deserialize_with = "deserialize_tick_size")]
399 pub minimum_tick_size: String,
400}
401
402#[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#[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#[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#[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#[derive(Debug, Clone, Serialize)]
437pub struct CalculatePriceParams {
438 pub token_id: String,
439 pub side: OrderSide,
440 pub amount: String,
441}
442
443#[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(¶ms).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(¶ms).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(¶ms).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}