Skip to main content

px_core/models/
market.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Shape of a market's outcomes. Options: `binary`, `categorical`, `scalar`.
5#[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/// Market lifecycle state. Options: `active`, `closed`, `resolved`.
25#[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/// One outcome of a prediction market.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61pub struct Outcome {
62    /// Outcome label (e.g. `"Yes"`, `"No"`, or a categorical option name).
63    pub label: String,
64    /// Current price as YES probability in `[0, 1]` (e.g. `0.62`); `null` when not yet quoted.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub price: Option<f64>,
67    /// Polymarket CTF token id used for per-outcome orderbook subscriptions; `null` on Kalshi.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub token_id: Option<String>,
70}
71
72/// A prediction market on the unified surface. Prices are YES probabilities in `[0, 1]`.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75pub struct Market {
76    /// OpenPX primary key in `<exchange>:<ticker>` form (e.g. `"kalshi:KXBTCD-25APR1517"`).
77    pub openpx_id: String,
78    /// Source exchange. Options: `kalshi`, `polymarket`.
79    pub exchange: String,
80    /// Native ticker — Kalshi market ticker or Polymarket slug (e.g. `"KXBTCD-25APR1517"`).
81    pub ticker: String,
82    /// Parent event ticker — Kalshi event_ticker or Polymarket event slug (e.g. `"KXBTC-25MAR14"`).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub event_ticker: Option<String>,
85    /// Polymarket numeric DB id used for REST deep-links (e.g. `"1031769"`); `null` on Kalshi.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub numeric_id: Option<String>,
88
89    /// Human-readable market title (e.g. `"Will BTC close above $100k on Apr 15?"`).
90    pub title: String,
91    /// Resolution rules in plain text; `null` when upstream omits them.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub rules: Option<String>,
94
95    /// Lifecycle state. Options: `active`, `closed`, `resolved`.
96    pub status: MarketStatus,
97    /// Outcome shape. Options: `binary`, `categorical`, `scalar`.
98    pub market_type: MarketType,
99
100    /// Ordered outcomes; binary markets have two (`"Yes"`, `"No"`), categorical have N.
101    #[serde(default)]
102    pub outcomes: Vec<Outcome>,
103
104    /// Polymarket CTF condition id (e.g. `"0xabc..."`); `null` on Kalshi.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub condition_id: Option<String>,
107
108    /// Lifetime trading volume in USD (e.g. `12345.67`).
109    pub volume: f64,
110    /// 24-hour trading volume in USD; `null` when upstream omits it.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub volume_24h: Option<f64>,
113
114    /// Last trade price as YES probability in `[0, 1]` (e.g. `0.62`).
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub last_trade_price: Option<f64>,
117    /// Best bid as YES probability in `[0, 1]` (e.g. `0.61`).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub best_bid: Option<f64>,
120    /// Best ask as YES probability in `[0, 1]` (e.g. `0.63`).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub best_ask: Option<f64>,
123
124    /// Minimum price increment in dollars (e.g. `0.01`).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub tick_size: Option<f64>,
127    /// Minimum order size in contracts (e.g. `1.0`).
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub min_order_size: Option<f64>,
130
131    /// Market close time in UTC (e.g. `"2026-04-25T20:00:00Z"`).
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub close_time: Option<DateTime<Utc>>,
134    /// Market open time in UTC.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub open_time: Option<DateTime<Utc>>,
137    /// Market creation time in UTC.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub created_at: Option<DateTime<Utc>>,
140    /// Settlement time in UTC; `null` until the market resolves on-chain.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub settlement_time: Option<DateTime<Utc>>,
143
144    /// Polymarket neg-risk flag; `null` on Kalshi.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub neg_risk: Option<bool>,
147    /// Polymarket neg-risk market id; `null` on Kalshi.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub neg_risk_market_id: Option<String>,
150
151    /// Winning outcome label after settlement (e.g. `"Yes"`); `null` until resolved.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub result: Option<String>,
154}
155
156impl Market {
157    /// Create openpx_id from exchange and ticker
158    #[inline]
159    pub fn make_openpx_id(exchange: &str, ticker: &str) -> String {
160        format!("{}:{}", exchange, ticker)
161    }
162
163    /// Parse openpx_id into (exchange, ticker)
164    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    /// Check if market matches search query (case-insensitive)
173    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    /// Find an outcome by label (case-insensitive).
199    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    /// Yes-side token id, when exposed (Polymarket binary markets).
206    pub fn token_id_yes(&self) -> Option<&str> {
207        self.outcome("Yes").and_then(|o| o.token_id.as_deref())
208    }
209
210    /// No-side token id, when exposed (Polymarket binary markets).
211    pub fn token_id_no(&self) -> Option<&str> {
212        self.outcome("No").and_then(|o| o.token_id.as_deref())
213    }
214
215    /// All exposed token ids in outcome order (skips outcomes with no token id).
216    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}