Skip to main content

px_core/models/
market.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Market type classification.
6#[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/// Normalized market status across all exchanges.
26#[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/// Unified prediction market model.
67///
68/// All exchanges produce this single type directly — no intermediate conversion.
69///
70/// # Price Format
71///
72/// All prices are normalized to decimal format (0.0 to 1.0).
73/// Exchange-specific conversions are handled during parsing:
74///
75/// - **Kalshi**: Fixed-point dollar strings parsed directly (post March 2026 migration).
76/// - **Polymarket, Opinion**: Native prices already in decimal (0.0-1.0).
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
79pub struct Market {
80    // ── Identity ──────────────────────────────────────────────────────────
81    /// Primary key: {exchange}:{native_id}
82    pub openpx_id: String,
83    /// Exchange identifier (kalshi, polymarket, opinion)
84    pub exchange: String,
85    /// Native exchange market ID
86    pub id: String,
87    /// Source-native event/group ID from the exchange.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub group_id: Option<String>,
90    /// Canonical OpenPX event ID for cross-exchange event grouping.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub event_id: Option<String>,
93
94    // ── Display ───────────────────────────────────────────────────────────
95    /// Market title
96    pub title: String,
97    /// Market question (may differ from title)
98    pub question: Option<String>,
99    /// Full description
100    pub description: String,
101    /// URL-friendly identifier
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub slug: Option<String>,
104    /// Resolution rules
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub rules: Option<String>,
107
108    // ── Status ─────────────────────────────────────────────────────────────
109    /// Normalized status: Active, Closed, Resolved
110    pub status: MarketStatus,
111    /// Market type classification
112    pub market_type: MarketType,
113    /// Whether the market is currently accepting orders
114    #[serde(default)]
115    pub accepting_orders: bool,
116
117    // ── Outcomes ───────────────────────────────────────────────────────────
118    /// Outcome labels (e.g., ["Yes", "No"] for binary markets)
119    #[serde(default)]
120    pub outcomes: Vec<String>,
121    /// Outcome-to-token mapping for orderbook subscriptions
122    #[serde(default)]
123    pub outcome_tokens: Vec<OutcomeToken>,
124    /// Outcome prices from the REST API (e.g., {"Yes": 0.65, "No": 0.35})
125    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
126    pub outcome_prices: HashMap<String, f64>,
127
128    // ── Token / CTF ───────────────────────────────────────────────────────
129    /// Yes outcome token ID
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub token_id_yes: Option<String>,
132    /// No outcome token ID
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub token_id_no: Option<String>,
135    /// Condition ID for CTF
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub condition_id: Option<String>,
138    /// Question ID (Opinion, Polymarket)
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub question_id: Option<String>,
141
142    // ── Volume ─────────────────────────────────────────────────────────────
143    /// Total volume (USD)
144    pub volume: f64,
145    /// 24-hour trading volume (USD)
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub volume_24h: Option<f64>,
148    /// 7-day rolling trading volume (USD)
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub volume_1wk: Option<f64>,
151    /// 30-day rolling trading volume (USD)
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub volume_1mo: Option<f64>,
154
155    // ── Pricing / Liquidity ────────────────────────────────────────────────
156    /// Current liquidity
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub liquidity: Option<f64>,
159    /// Current open interest
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub open_interest: Option<f64>,
162    /// Last trade price (normalized 0-1)
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub last_trade_price: Option<f64>,
165    /// Best bid price (normalized 0-1)
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub best_bid: Option<f64>,
168    /// Best ask price (normalized 0-1)
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub best_ask: Option<f64>,
171    /// Bid-ask spread (decimal)
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub spread: Option<f64>,
174
175    // ── Price Changes ──────────────────────────────────────────────────────
176    /// 24-hour YES price change (decimal, e.g. 0.05 = +5%)
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub price_change_1d: Option<f64>,
179    /// 1-hour YES price change
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub price_change_1h: Option<f64>,
182    /// 7-day YES price change
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub price_change_1wk: Option<f64>,
185    /// 30-day YES price change
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub price_change_1mo: Option<f64>,
188
189    // ── Trading Params ─────────────────────────────────────────────────────
190    /// Tick size (minimum price increment, normalized decimal e.g. 0.01)
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub tick_size: Option<f64>,
193    /// Minimum order size (contracts)
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub min_order_size: Option<f64>,
196
197    // ── Time ───────────────────────────────────────────────────────────────
198    /// Market close time
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub close_time: Option<DateTime<Utc>>,
201    /// Market open time
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub open_time: Option<DateTime<Utc>>,
204    /// Market creation time
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub created_at: Option<DateTime<Utc>>,
207    /// Settlement / resolution time
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub settlement_time: Option<DateTime<Utc>>,
210
211    // ── Media ──────────────────────────────────────────────────────────────
212    /// Market image URL
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub image_url: Option<String>,
215    /// Market icon URL
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub icon_url: Option<String>,
218
219    // ── Exchange-Specific ──────────────────────────────────────────────────
220    /// Polymarket: neg-risk flag
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub neg_risk: Option<bool>,
223    /// Polymarket: neg-risk market ID
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub neg_risk_market_id: Option<String>,
226    /// Maker fee rate (basis points)
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub maker_fee_bps: Option<f64>,
229    /// Taker fee rate (basis points)
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub taker_fee_bps: Option<f64>,
232    /// Denomination token (e.g. USDC address)
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub denomination_token: Option<String>,
235    /// Chain ID for on-chain markets
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub chain_id: Option<String>,
238    /// Notional value per contract (Kalshi)
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub notional_value: Option<f64>,
241    /// Kalshi sub-penny pricing structure
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub price_level_structure: Option<String>,
244    /// Kalshi: settlement value
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub settlement_value: Option<f64>,
247    /// Kalshi: previous price
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub previous_price: Option<f64>,
250    /// Kalshi: can close early
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub can_close_early: Option<bool>,
253    /// Resolution result
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub result: Option<String>,
256}
257
258impl Market {
259    /// Create openpx_id from exchange and native id
260    #[inline]
261    pub fn make_openpx_id(exchange: &str, id: &str) -> String {
262        format!("{}:{}", exchange, id)
263    }
264
265    /// Parse openpx_id into (exchange, native_id)
266    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    /// Check if market matches search query (case-insensitive)
275    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    /// Compute bid-ask spread from outcome prices for binary markets.
302    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    // TODO(fee-rate): Add fee_rate (basis points) to market data responses. Pro traders need
445    // fee rates for accurate PnL calculations and cost-optimal routing between exchanges.
446    // polyfill-rs has get_fee_rate_bps(token_id) returning the maker fee rate.
447    // Implementation: add fee_rate_bps field alongside tick_size in the market data pipeline.
448    // Note: fee rates may vary per user tier on some exchanges, so document as "base fee rate."
449
450    #[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}