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 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub native_numeric_id: Option<String>,
147
148 pub volume: f64,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub volume_24h: Option<f64>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub volume_1wk: Option<f64>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub volume_1mo: Option<f64>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub liquidity: Option<f64>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub open_interest: Option<f64>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub last_trade_price: Option<f64>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub best_bid: Option<f64>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub best_ask: Option<f64>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub spread: Option<f64>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub price_change_1d: Option<f64>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub price_change_1h: Option<f64>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub price_change_1wk: Option<f64>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub price_change_1mo: Option<f64>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub tick_size: Option<f64>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub min_order_size: Option<f64>,
202
203 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub close_time: Option<DateTime<Utc>>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub open_time: Option<DateTime<Utc>>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub created_at: Option<DateTime<Utc>>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub settlement_time: Option<DateTime<Utc>>,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub image_url: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub icon_url: Option<String>,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub neg_risk: Option<bool>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub neg_risk_market_id: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub maker_fee_bps: Option<f64>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub taker_fee_bps: Option<f64>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub denomination_token: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub chain_id: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub notional_value: Option<f64>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub price_level_structure: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub settlement_value: Option<f64>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub previous_price: Option<f64>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub can_close_early: Option<bool>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub result: Option<String>,
262}
263
264impl Market {
265 #[inline]
267 pub fn make_openpx_id(exchange: &str, id: &str) -> String {
268 format!("{}:{}", exchange, id)
269 }
270
271 pub fn parse_openpx_id(openpx_id: &str) -> Option<(&str, &str)> {
273 let (exchange, id) = openpx_id.split_once(':')?;
274 if exchange.is_empty() || id.is_empty() {
275 return None;
276 }
277 Some((exchange, id))
278 }
279
280 pub fn matches_search(&self, query: &str) -> bool {
282 let query_lower = query.to_lowercase();
283 self.title.to_lowercase().contains(&query_lower)
284 || self.description.to_lowercase().contains(&query_lower)
285 || self
286 .question
287 .as_ref()
288 .is_some_and(|q| q.to_lowercase().contains(&query_lower))
289 }
290
291 #[inline]
292 pub fn is_binary(&self) -> bool {
293 self.outcomes.len() == 2
294 }
295
296 #[inline]
297 pub fn is_open(&self) -> bool {
298 if self.status != MarketStatus::Active {
299 return false;
300 }
301 match self.close_time {
302 Some(close_time) => Utc::now() < close_time,
303 None => true,
304 }
305 }
306
307 pub fn computed_spread(&self) -> Option<f64> {
309 if let Some(s) = self.spread {
310 return Some(s);
311 }
312 if let (Some(bid), Some(ask)) = (self.best_bid, self.best_ask) {
313 return Some(ask - bid);
314 }
315 if !self.is_binary() || self.outcome_prices.len() != 2 {
316 return None;
317 }
318 Some((1.0 - self.outcome_prices.values().copied().sum::<f64>()).abs())
319 }
320
321 pub fn get_token_ids(&self) -> Vec<String> {
322 if !self.outcome_tokens.is_empty() {
323 return self
324 .outcome_tokens
325 .iter()
326 .map(|t| t.token_id.clone())
327 .collect();
328 }
329 let mut ids = Vec::new();
330 if let Some(ref id) = self.token_id_yes {
331 ids.push(id.clone());
332 }
333 if let Some(ref id) = self.token_id_no {
334 ids.push(id.clone());
335 }
336 ids
337 }
338
339 pub fn get_outcome_tokens(&self) -> Vec<OutcomeToken> {
340 if !self.outcome_tokens.is_empty() {
341 return self.outcome_tokens.clone();
342 }
343 let token_ids = self.get_token_ids();
344 self.outcomes
345 .iter()
346 .enumerate()
347 .map(|(i, outcome)| OutcomeToken {
348 outcome: outcome.clone(),
349 token_id: token_ids.get(i).cloned().unwrap_or_default(),
350 })
351 .collect()
352 }
353}
354
355impl Default for Market {
356 fn default() -> Self {
357 Self {
358 openpx_id: String::new(),
359 exchange: String::new(),
360 id: String::new(),
361 group_id: None,
362 event_id: None,
363 title: String::new(),
364 question: None,
365 description: String::new(),
366 slug: None,
367 rules: None,
368 status: MarketStatus::Active,
369 market_type: MarketType::Binary,
370 accepting_orders: true,
371 outcomes: vec![],
372 outcome_tokens: vec![],
373 outcome_prices: HashMap::new(),
374 token_id_yes: None,
375 token_id_no: None,
376 condition_id: None,
377 question_id: None,
378 native_numeric_id: None,
379 volume: 0.0,
380 volume_24h: None,
381 volume_1wk: None,
382 volume_1mo: None,
383 liquidity: None,
384 open_interest: None,
385 last_trade_price: None,
386 best_bid: None,
387 best_ask: None,
388 spread: None,
389 price_change_1d: None,
390 price_change_1h: None,
391 price_change_1wk: None,
392 price_change_1mo: None,
393 tick_size: None,
394 min_order_size: None,
395 close_time: None,
396 open_time: None,
397 created_at: None,
398 settlement_time: None,
399 image_url: None,
400 icon_url: None,
401 neg_risk: None,
402 neg_risk_market_id: None,
403 maker_fee_bps: None,
404 taker_fee_bps: None,
405 denomination_token: None,
406 chain_id: None,
407 notional_value: None,
408 price_level_structure: None,
409 settlement_value: None,
410 previous_price: None,
411 can_close_early: None,
412 result: None,
413 }
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn parse_openpx_id_valid() {
423 let parsed = Market::parse_openpx_id("kalshi:TICKER-123");
424 assert_eq!(parsed, Some(("kalshi", "TICKER-123")));
425 }
426
427 #[test]
428 fn parse_openpx_id_invalid() {
429 assert_eq!(Market::parse_openpx_id("invalid"), None);
430 assert_eq!(Market::parse_openpx_id("kalshi:"), None);
431 assert_eq!(Market::parse_openpx_id(":TICKER"), None);
432 assert_eq!(Market::parse_openpx_id(""), None);
433 }
434
435 #[test]
436 fn optional_fields_omitted_when_none() {
437 let market = Market {
438 openpx_id: "test:1".into(),
439 exchange: "test".into(),
440 id: "1".into(),
441 title: "Test".into(),
442 ..Default::default()
443 };
444 let json = serde_json::to_value(&market).unwrap();
445 assert!(json.get("volume_1wk").is_none());
446 assert!(json.get("volume_24h").is_none());
447 assert!(json.get("volume_1mo").is_none());
448 assert!(json.get("min_order_size").is_none());
449 }
450
451 #[test]
458 fn optional_fields_present_when_some() {
459 let market = Market {
460 openpx_id: "test:1".into(),
461 exchange: "test".into(),
462 id: "1".into(),
463 title: "Test".into(),
464 volume_24h: Some(1000.0),
465 volume_1wk: Some(7000.0),
466 volume_1mo: Some(30000.0),
467 min_order_size: Some(15.0),
468 ..Default::default()
469 };
470 let json = serde_json::to_value(&market).unwrap();
471 assert_eq!(json["volume_24h"], 1000.0);
472 assert_eq!(json["volume_1wk"], 7000.0);
473 assert_eq!(json["volume_1mo"], 30000.0);
474 assert_eq!(json["min_order_size"], 15.0);
475 }
476
477 #[test]
478 fn matches_search_title() {
479 let market = Market {
480 title: "Will Bitcoin reach $100k?".into(),
481 ..Default::default()
482 };
483 assert!(market.matches_search("bitcoin"));
484 assert!(market.matches_search("100k"));
485 assert!(!market.matches_search("ethereum"));
486 }
487
488 #[test]
489 fn get_token_ids_from_outcome_tokens() {
490 let market = Market {
491 outcome_tokens: vec![
492 OutcomeToken {
493 outcome: "Yes".into(),
494 token_id: "tok1".into(),
495 },
496 OutcomeToken {
497 outcome: "No".into(),
498 token_id: "tok2".into(),
499 },
500 ],
501 ..Default::default()
502 };
503 assert_eq!(market.get_token_ids(), vec!["tok1", "tok2"]);
504 }
505
506 #[test]
507 fn get_token_ids_from_yes_no_fields() {
508 let market = Market {
509 token_id_yes: Some("yes_tok".into()),
510 token_id_no: Some("no_tok".into()),
511 ..Default::default()
512 };
513 assert_eq!(market.get_token_ids(), vec!["yes_tok", "no_tok"]);
514 }
515
516 #[test]
517 fn is_binary_and_is_open() {
518 let market = Market {
519 outcomes: vec!["Yes".into(), "No".into()],
520 status: MarketStatus::Active,
521 ..Default::default()
522 };
523 assert!(market.is_binary());
524 assert!(market.is_open());
525
526 let closed = Market {
527 outcomes: vec!["Yes".into(), "No".into()],
528 status: MarketStatus::Closed,
529 ..Default::default()
530 };
531 assert!(!closed.is_open());
532 }
533
534 #[test]
535 fn market_type_serialization() {
536 let market = Market {
537 openpx_id: "test:1".into(),
538 exchange: "test".into(),
539 id: "1".into(),
540 title: "Test".into(),
541 market_type: MarketType::Categorical,
542 ..Default::default()
543 };
544 let json = serde_json::to_value(&market).unwrap();
545 assert_eq!(json["market_type"], "categorical");
546 }
547}