1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
7#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
8#[serde(rename_all = "snake_case")]
9pub enum MarketType {
10 Binary,
11 Categorical,
12 Scalar,
13}
14
15impl std::fmt::Display for MarketType {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 match self {
18 MarketType::Binary => write!(f, "binary"),
19 MarketType::Categorical => write!(f, "categorical"),
20 MarketType::Scalar => write!(f, "scalar"),
21 }
22 }
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[serde(rename_all = "lowercase")]
29pub enum MarketStatus {
30 Active,
31 Closed,
32 Resolved,
33}
34
35impl std::fmt::Display for MarketStatus {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 MarketStatus::Active => write!(f, "active"),
39 MarketStatus::Closed => write!(f, "closed"),
40 MarketStatus::Resolved => write!(f, "resolved"),
41 }
42 }
43}
44
45impl std::str::FromStr for MarketStatus {
46 type Err = String;
47
48 fn from_str(s: &str) -> Result<Self, Self::Err> {
49 match s.to_lowercase().as_str() {
50 "active" | "open" => Ok(MarketStatus::Active),
51 "closed" | "initialized" | "inactive" | "paused" | "unopened" | "disputed"
52 | "amended" => Ok(MarketStatus::Closed),
53 "resolved" | "settled" | "determined" | "finalized" => Ok(MarketStatus::Resolved),
54 _ => Err(format!("Unknown market status: {}", s)),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61pub struct OutcomeToken {
62 pub outcome: String,
63 pub token_id: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
78#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
79pub struct Market {
80 pub openpx_id: String,
83 pub exchange: String,
85 pub id: String,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub group_id: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub event_id: Option<String>,
93
94 pub title: String,
97 pub question: Option<String>,
99 pub description: String,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub slug: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub rules: Option<String>,
107
108 pub status: MarketStatus,
111 pub market_type: MarketType,
113 #[serde(default)]
115 pub accepting_orders: bool,
116
117 #[serde(default)]
120 pub outcomes: Vec<String>,
121 #[serde(default)]
123 pub outcome_tokens: Vec<OutcomeToken>,
124 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
126 pub outcome_prices: HashMap<String, f64>,
127
128 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub token_id_yes: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub token_id_no: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub condition_id: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub question_id: Option<String>,
141
142 pub volume: f64,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub volume_24h: Option<f64>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub volume_1wk: Option<f64>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub volume_1mo: Option<f64>,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub liquidity: Option<f64>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub open_interest: Option<f64>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub last_trade_price: Option<f64>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub best_bid: Option<f64>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub best_ask: Option<f64>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub spread: Option<f64>,
174
175 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub price_change_1d: Option<f64>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub price_change_1h: Option<f64>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub price_change_1wk: Option<f64>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub price_change_1mo: Option<f64>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub tick_size: Option<f64>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub min_order_size: Option<f64>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub close_time: Option<DateTime<Utc>>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub open_time: Option<DateTime<Utc>>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub created_at: Option<DateTime<Utc>>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub settlement_time: Option<DateTime<Utc>>,
210
211 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub image_url: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub icon_url: Option<String>,
218
219 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub neg_risk: Option<bool>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub neg_risk_market_id: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub maker_fee_bps: Option<f64>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub taker_fee_bps: Option<f64>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub denomination_token: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub chain_id: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub notional_value: Option<f64>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub price_level_structure: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub settlement_value: Option<f64>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub previous_price: Option<f64>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub can_close_early: Option<bool>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub result: Option<String>,
256}
257
258impl Market {
259 #[inline]
261 pub fn make_openpx_id(exchange: &str, id: &str) -> String {
262 format!("{}:{}", exchange, id)
263 }
264
265 pub fn parse_openpx_id(openpx_id: &str) -> Option<(&str, &str)> {
267 let (exchange, id) = openpx_id.split_once(':')?;
268 if exchange.is_empty() || id.is_empty() {
269 return None;
270 }
271 Some((exchange, id))
272 }
273
274 pub fn matches_search(&self, query: &str) -> bool {
276 let query_lower = query.to_lowercase();
277 self.title.to_lowercase().contains(&query_lower)
278 || self.description.to_lowercase().contains(&query_lower)
279 || self
280 .question
281 .as_ref()
282 .is_some_and(|q| q.to_lowercase().contains(&query_lower))
283 }
284
285 #[inline]
286 pub fn is_binary(&self) -> bool {
287 self.outcomes.len() == 2
288 }
289
290 #[inline]
291 pub fn is_open(&self) -> bool {
292 if self.status != MarketStatus::Active {
293 return false;
294 }
295 match self.close_time {
296 Some(close_time) => Utc::now() < close_time,
297 None => true,
298 }
299 }
300
301 pub fn computed_spread(&self) -> Option<f64> {
303 if let Some(s) = self.spread {
304 return Some(s);
305 }
306 if let (Some(bid), Some(ask)) = (self.best_bid, self.best_ask) {
307 return Some(ask - bid);
308 }
309 if !self.is_binary() || self.outcome_prices.len() != 2 {
310 return None;
311 }
312 Some((1.0 - self.outcome_prices.values().copied().sum::<f64>()).abs())
313 }
314
315 pub fn get_token_ids(&self) -> Vec<String> {
316 if !self.outcome_tokens.is_empty() {
317 return self
318 .outcome_tokens
319 .iter()
320 .map(|t| t.token_id.clone())
321 .collect();
322 }
323 let mut ids = Vec::new();
324 if let Some(ref id) = self.token_id_yes {
325 ids.push(id.clone());
326 }
327 if let Some(ref id) = self.token_id_no {
328 ids.push(id.clone());
329 }
330 ids
331 }
332
333 pub fn get_outcome_tokens(&self) -> Vec<OutcomeToken> {
334 if !self.outcome_tokens.is_empty() {
335 return self.outcome_tokens.clone();
336 }
337 let token_ids = self.get_token_ids();
338 self.outcomes
339 .iter()
340 .enumerate()
341 .map(|(i, outcome)| OutcomeToken {
342 outcome: outcome.clone(),
343 token_id: token_ids.get(i).cloned().unwrap_or_default(),
344 })
345 .collect()
346 }
347}
348
349impl Default for Market {
350 fn default() -> Self {
351 Self {
352 openpx_id: String::new(),
353 exchange: String::new(),
354 id: String::new(),
355 group_id: None,
356 event_id: None,
357 title: String::new(),
358 question: None,
359 description: String::new(),
360 slug: None,
361 rules: None,
362 status: MarketStatus::Active,
363 market_type: MarketType::Binary,
364 accepting_orders: true,
365 outcomes: vec![],
366 outcome_tokens: vec![],
367 outcome_prices: HashMap::new(),
368 token_id_yes: None,
369 token_id_no: None,
370 condition_id: None,
371 question_id: None,
372 volume: 0.0,
373 volume_24h: None,
374 volume_1wk: None,
375 volume_1mo: None,
376 liquidity: None,
377 open_interest: None,
378 last_trade_price: None,
379 best_bid: None,
380 best_ask: None,
381 spread: None,
382 price_change_1d: None,
383 price_change_1h: None,
384 price_change_1wk: None,
385 price_change_1mo: None,
386 tick_size: None,
387 min_order_size: None,
388 close_time: None,
389 open_time: None,
390 created_at: None,
391 settlement_time: None,
392 image_url: None,
393 icon_url: None,
394 neg_risk: None,
395 neg_risk_market_id: None,
396 maker_fee_bps: None,
397 taker_fee_bps: None,
398 denomination_token: None,
399 chain_id: None,
400 notional_value: None,
401 price_level_structure: None,
402 settlement_value: None,
403 previous_price: None,
404 can_close_early: None,
405 result: None,
406 }
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn parse_openpx_id_valid() {
416 let parsed = Market::parse_openpx_id("kalshi:TICKER-123");
417 assert_eq!(parsed, Some(("kalshi", "TICKER-123")));
418 }
419
420 #[test]
421 fn parse_openpx_id_invalid() {
422 assert_eq!(Market::parse_openpx_id("invalid"), None);
423 assert_eq!(Market::parse_openpx_id("kalshi:"), None);
424 assert_eq!(Market::parse_openpx_id(":TICKER"), None);
425 assert_eq!(Market::parse_openpx_id(""), None);
426 }
427
428 #[test]
429 fn optional_fields_omitted_when_none() {
430 let market = Market {
431 openpx_id: "test:1".into(),
432 exchange: "test".into(),
433 id: "1".into(),
434 title: "Test".into(),
435 ..Default::default()
436 };
437 let json = serde_json::to_value(&market).unwrap();
438 assert!(json.get("volume_1wk").is_none());
439 assert!(json.get("volume_24h").is_none());
440 assert!(json.get("volume_1mo").is_none());
441 assert!(json.get("min_order_size").is_none());
442 }
443
444 #[test]
451 fn optional_fields_present_when_some() {
452 let market = Market {
453 openpx_id: "test:1".into(),
454 exchange: "test".into(),
455 id: "1".into(),
456 title: "Test".into(),
457 volume_24h: Some(1000.0),
458 volume_1wk: Some(7000.0),
459 volume_1mo: Some(30000.0),
460 min_order_size: Some(15.0),
461 ..Default::default()
462 };
463 let json = serde_json::to_value(&market).unwrap();
464 assert_eq!(json["volume_24h"], 1000.0);
465 assert_eq!(json["volume_1wk"], 7000.0);
466 assert_eq!(json["volume_1mo"], 30000.0);
467 assert_eq!(json["min_order_size"], 15.0);
468 }
469
470 #[test]
471 fn matches_search_title() {
472 let market = Market {
473 title: "Will Bitcoin reach $100k?".into(),
474 ..Default::default()
475 };
476 assert!(market.matches_search("bitcoin"));
477 assert!(market.matches_search("100k"));
478 assert!(!market.matches_search("ethereum"));
479 }
480
481 #[test]
482 fn get_token_ids_from_outcome_tokens() {
483 let market = Market {
484 outcome_tokens: vec![
485 OutcomeToken {
486 outcome: "Yes".into(),
487 token_id: "tok1".into(),
488 },
489 OutcomeToken {
490 outcome: "No".into(),
491 token_id: "tok2".into(),
492 },
493 ],
494 ..Default::default()
495 };
496 assert_eq!(market.get_token_ids(), vec!["tok1", "tok2"]);
497 }
498
499 #[test]
500 fn get_token_ids_from_yes_no_fields() {
501 let market = Market {
502 token_id_yes: Some("yes_tok".into()),
503 token_id_no: Some("no_tok".into()),
504 ..Default::default()
505 };
506 assert_eq!(market.get_token_ids(), vec!["yes_tok", "no_tok"]);
507 }
508
509 #[test]
510 fn is_binary_and_is_open() {
511 let market = Market {
512 outcomes: vec!["Yes".into(), "No".into()],
513 status: MarketStatus::Active,
514 ..Default::default()
515 };
516 assert!(market.is_binary());
517 assert!(market.is_open());
518
519 let closed = Market {
520 outcomes: vec!["Yes".into(), "No".into()],
521 status: MarketStatus::Closed,
522 ..Default::default()
523 };
524 assert!(!closed.is_open());
525 }
526
527 #[test]
528 fn market_type_serialization() {
529 let market = Market {
530 openpx_id: "test:1".into(),
531 exchange: "test".into(),
532 id: "1".into(),
533 title: "Test".into(),
534 market_type: MarketType::Categorical,
535 ..Default::default()
536 };
537 let json = serde_json::to_value(&market).unwrap();
538 assert_eq!(json["market_type"], "categorical");
539 }
540}