1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
6#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[serde(rename_all = "snake_case")]
8pub enum MarketType {
9 Binary,
10 Categorical,
11 Scalar,
12}
13
14impl std::fmt::Display for MarketType {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 MarketType::Binary => write!(f, "binary"),
18 MarketType::Categorical => write!(f, "categorical"),
19 MarketType::Scalar => write!(f, "scalar"),
20 }
21 }
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[serde(rename_all = "lowercase")]
28pub enum MarketStatus {
29 Active,
30 Closed,
31 Resolved,
32}
33
34impl std::fmt::Display for MarketStatus {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 MarketStatus::Active => write!(f, "active"),
38 MarketStatus::Closed => write!(f, "closed"),
39 MarketStatus::Resolved => write!(f, "resolved"),
40 }
41 }
42}
43
44impl std::str::FromStr for MarketStatus {
45 type Err = String;
46
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s.to_lowercase().as_str() {
49 "active" | "open" => Ok(MarketStatus::Active),
50 "closed" | "initialized" | "inactive" | "paused" | "unopened" | "disputed"
51 | "amended" => Ok(MarketStatus::Closed),
52 "resolved" | "settled" | "determined" | "finalized" => Ok(MarketStatus::Resolved),
53 _ => Err(format!("Unknown market status: {}", s)),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61pub struct Outcome {
62 pub label: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub price: Option<f64>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub token_id: Option<String>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75pub struct Market {
76 pub openpx_id: String,
78 pub exchange: String,
80 pub ticker: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub event_ticker: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub numeric_id: Option<String>,
88
89 pub title: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub rules: Option<String>,
94
95 pub status: MarketStatus,
97 pub market_type: MarketType,
99
100 #[serde(default)]
102 pub outcomes: Vec<Outcome>,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub condition_id: Option<String>,
107
108 pub volume: f64,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub volume_24h: Option<f64>,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub last_trade_price: Option<f64>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub best_bid: Option<f64>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub best_ask: Option<f64>,
123
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub tick_size: Option<f64>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub min_order_size: Option<f64>,
130
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub close_time: Option<DateTime<Utc>>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub open_time: Option<DateTime<Utc>>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub created_at: Option<DateTime<Utc>>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub settlement_time: Option<DateTime<Utc>>,
143
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub neg_risk: Option<bool>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub neg_risk_market_id: Option<String>,
150
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub result: Option<String>,
154}
155
156impl Market {
157 #[inline]
159 pub fn make_openpx_id(exchange: &str, ticker: &str) -> String {
160 format!("{}:{}", exchange, ticker)
161 }
162
163 pub fn parse_openpx_id(openpx_id: &str) -> Option<(&str, &str)> {
165 let (exchange, ticker) = openpx_id.split_once(':')?;
166 if exchange.is_empty() || ticker.is_empty() {
167 return None;
168 }
169 Some((exchange, ticker))
170 }
171
172 pub fn matches_search(&self, query: &str) -> bool {
174 let query_lower = query.to_lowercase();
175 self.title.to_lowercase().contains(&query_lower)
176 || self
177 .rules
178 .as_ref()
179 .is_some_and(|r| r.to_lowercase().contains(&query_lower))
180 }
181
182 #[inline]
183 pub fn is_binary(&self) -> bool {
184 self.outcomes.len() == 2
185 }
186
187 #[inline]
188 pub fn is_open(&self) -> bool {
189 if self.status != MarketStatus::Active {
190 return false;
191 }
192 match self.close_time {
193 Some(close_time) => Utc::now() < close_time,
194 None => true,
195 }
196 }
197
198 pub fn outcome(&self, label: &str) -> Option<&Outcome> {
200 self.outcomes
201 .iter()
202 .find(|o| o.label.eq_ignore_ascii_case(label))
203 }
204
205 pub fn token_id_yes(&self) -> Option<&str> {
207 self.outcome("Yes").and_then(|o| o.token_id.as_deref())
208 }
209
210 pub fn token_id_no(&self) -> Option<&str> {
212 self.outcome("No").and_then(|o| o.token_id.as_deref())
213 }
214
215 pub fn token_ids(&self) -> Vec<String> {
217 self.outcomes
218 .iter()
219 .filter_map(|o| o.token_id.clone())
220 .collect()
221 }
222}
223
224impl Default for Market {
225 fn default() -> Self {
226 Self {
227 openpx_id: String::new(),
228 exchange: String::new(),
229 ticker: String::new(),
230 event_ticker: None,
231 numeric_id: None,
232 title: String::new(),
233 rules: None,
234 status: MarketStatus::Active,
235 market_type: MarketType::Binary,
236 outcomes: vec![],
237 condition_id: None,
238 volume: 0.0,
239 volume_24h: None,
240 last_trade_price: None,
241 best_bid: None,
242 best_ask: None,
243 tick_size: None,
244 min_order_size: None,
245 close_time: None,
246 open_time: None,
247 created_at: None,
248 settlement_time: None,
249 neg_risk: None,
250 neg_risk_market_id: None,
251 result: None,
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn outcome(label: &str, price: Option<f64>, token: Option<&str>) -> Outcome {
261 Outcome {
262 label: label.into(),
263 price,
264 token_id: token.map(String::from),
265 }
266 }
267
268 #[test]
269 fn parse_openpx_id_valid() {
270 let parsed = Market::parse_openpx_id("kalshi:TICKER-123");
271 assert_eq!(parsed, Some(("kalshi", "TICKER-123")));
272 }
273
274 #[test]
275 fn parse_openpx_id_invalid() {
276 assert_eq!(Market::parse_openpx_id("invalid"), None);
277 assert_eq!(Market::parse_openpx_id("kalshi:"), None);
278 assert_eq!(Market::parse_openpx_id(":TICKER"), None);
279 assert_eq!(Market::parse_openpx_id(""), None);
280 }
281
282 #[test]
283 fn optional_fields_omitted_when_none() {
284 let market = Market {
285 openpx_id: "test:1".into(),
286 exchange: "test".into(),
287 ticker: "1".into(),
288 title: "Test".into(),
289 ..Default::default()
290 };
291 let json = serde_json::to_value(&market).unwrap();
292 assert!(json.get("volume_24h").is_none());
293 assert!(json.get("min_order_size").is_none());
294 assert!(json.get("event_ticker").is_none());
295 assert!(json.get("tick_size").is_none());
296 }
297
298 #[test]
299 fn optional_fields_present_when_some() {
300 let market = Market {
301 openpx_id: "test:1".into(),
302 exchange: "test".into(),
303 ticker: "1".into(),
304 title: "Test".into(),
305 volume_24h: Some(1000.0),
306 min_order_size: Some(15.0),
307 tick_size: Some(0.01),
308 event_ticker: Some("EV-1".into()),
309 ..Default::default()
310 };
311 let json = serde_json::to_value(&market).unwrap();
312 assert_eq!(json["volume_24h"], 1000.0);
313 assert_eq!(json["min_order_size"], 15.0);
314 assert_eq!(json["tick_size"], 0.01);
315 assert_eq!(json["event_ticker"], "EV-1");
316 }
317
318 #[test]
319 fn matches_search_title_and_rules() {
320 let market = Market {
321 title: "Will Bitcoin reach $100k?".into(),
322 rules: Some("Resolves yes if BTC closes above 100000 USD on Coinbase".into()),
323 ..Default::default()
324 };
325 assert!(market.matches_search("bitcoin"));
326 assert!(market.matches_search("100k"));
327 assert!(market.matches_search("coinbase"));
328 assert!(!market.matches_search("ethereum"));
329 }
330
331 #[test]
332 fn token_id_yes_no_lookup() {
333 let market = Market {
334 outcomes: vec![
335 outcome("Yes", Some(0.65), Some("yes_tok")),
336 outcome("No", Some(0.35), Some("no_tok")),
337 ],
338 ..Default::default()
339 };
340 assert_eq!(market.token_id_yes(), Some("yes_tok"));
341 assert_eq!(market.token_id_no(), Some("no_tok"));
342 assert_eq!(market.token_ids(), vec!["yes_tok", "no_tok"]);
343 }
344
345 #[test]
346 fn token_id_yes_no_absent_for_kalshi() {
347 let market = Market {
348 outcomes: vec![
349 outcome("Yes", Some(0.65), None),
350 outcome("No", Some(0.35), None),
351 ],
352 ..Default::default()
353 };
354 assert_eq!(market.token_id_yes(), None);
355 assert_eq!(market.token_id_no(), None);
356 assert!(market.token_ids().is_empty());
357 }
358
359 #[test]
360 fn is_binary_and_is_open() {
361 let market = Market {
362 outcomes: vec![outcome("Yes", None, None), outcome("No", None, None)],
363 status: MarketStatus::Active,
364 ..Default::default()
365 };
366 assert!(market.is_binary());
367 assert!(market.is_open());
368
369 let closed = Market {
370 outcomes: vec![outcome("Yes", None, None), outcome("No", None, None)],
371 status: MarketStatus::Closed,
372 ..Default::default()
373 };
374 assert!(!closed.is_open());
375 }
376
377 #[test]
378 fn market_type_serialization() {
379 let market = Market {
380 openpx_id: "test:1".into(),
381 exchange: "test".into(),
382 ticker: "1".into(),
383 title: "Test".into(),
384 market_type: MarketType::Categorical,
385 ..Default::default()
386 };
387 let json = serde_json::to_value(&market).unwrap();
388 assert_eq!(json["market_type"], "categorical");
389 }
390}