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    /// Polymarket's numeric DB id (e.g. "1031769"). Exposed for callers that
142    /// need to build UI deep-links or cross-reference Polymarket's REST-only
143    /// numeric surface. Not used for trading or subscription — `id` (the
144    /// condition_id on Polymarket) is the canonical identifier.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub native_numeric_id: Option<String>,
147
148    // ── Volume ─────────────────────────────────────────────────────────────
149    /// Total volume (USD)
150    pub volume: f64,
151    /// 24-hour trading volume (USD)
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub volume_24h: Option<f64>,
154    /// 7-day rolling trading volume (USD)
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub volume_1wk: Option<f64>,
157    /// 30-day rolling trading volume (USD)
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub volume_1mo: Option<f64>,
160
161    // ── Pricing / Liquidity ────────────────────────────────────────────────
162    /// Current liquidity
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub liquidity: Option<f64>,
165    /// Current open interest
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub open_interest: Option<f64>,
168    /// Last trade price (normalized 0-1)
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub last_trade_price: Option<f64>,
171    /// Best bid price (normalized 0-1)
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub best_bid: Option<f64>,
174    /// Best ask price (normalized 0-1)
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub best_ask: Option<f64>,
177    /// Bid-ask spread (decimal)
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub spread: Option<f64>,
180
181    // ── Price Changes ──────────────────────────────────────────────────────
182    /// 24-hour YES price change (decimal, e.g. 0.05 = +5%)
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub price_change_1d: Option<f64>,
185    /// 1-hour YES price change
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub price_change_1h: Option<f64>,
188    /// 7-day YES price change
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub price_change_1wk: Option<f64>,
191    /// 30-day YES price change
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub price_change_1mo: Option<f64>,
194
195    // ── Trading Params ─────────────────────────────────────────────────────
196    /// Tick size (minimum price increment, normalized decimal e.g. 0.01)
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub tick_size: Option<f64>,
199    /// Minimum order size (contracts)
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub min_order_size: Option<f64>,
202
203    // ── Time ───────────────────────────────────────────────────────────────
204    /// Market close time
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub close_time: Option<DateTime<Utc>>,
207    /// Market open time
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub open_time: Option<DateTime<Utc>>,
210    /// Market creation time
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub created_at: Option<DateTime<Utc>>,
213    /// Settlement / resolution time
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub settlement_time: Option<DateTime<Utc>>,
216
217    // ── Media ──────────────────────────────────────────────────────────────
218    /// Market image URL
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub image_url: Option<String>,
221    /// Market icon URL
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub icon_url: Option<String>,
224
225    // ── Exchange-Specific ──────────────────────────────────────────────────
226    /// Polymarket: neg-risk flag
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub neg_risk: Option<bool>,
229    /// Polymarket: neg-risk market ID
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub neg_risk_market_id: Option<String>,
232    /// Maker fee rate (basis points)
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub maker_fee_bps: Option<f64>,
235    /// Taker fee rate (basis points)
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub taker_fee_bps: Option<f64>,
238    /// Denomination token (e.g. USDC address)
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub denomination_token: Option<String>,
241    /// Chain ID for on-chain markets
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub chain_id: Option<String>,
244    /// Notional value per contract (Kalshi)
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub notional_value: Option<f64>,
247    /// Kalshi sub-penny pricing structure
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub price_level_structure: Option<String>,
250    /// Kalshi: settlement value
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub settlement_value: Option<f64>,
253    /// Kalshi: previous price
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub previous_price: Option<f64>,
256    /// Kalshi: can close early
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub can_close_early: Option<bool>,
259    /// Resolution result
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub result: Option<String>,
262}
263
264impl Market {
265    /// Create openpx_id from exchange and native id
266    #[inline]
267    pub fn make_openpx_id(exchange: &str, id: &str) -> String {
268        format!("{}:{}", exchange, id)
269    }
270
271    /// Parse openpx_id into (exchange, native_id)
272    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    /// Check if market matches search query (case-insensitive)
281    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    /// Compute bid-ask spread from outcome prices for binary markets.
308    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    // TODO(fee-rate): Add fee_rate (basis points) to market data responses. Pro traders need
452    // fee rates for accurate PnL calculations and cost-optimal routing between exchanges.
453    // Per-token `get_fee_rate_bps(token_id)` returning the maker fee rate.
454    // Implementation: add fee_rate_bps field alongside tick_size in the market data pipeline.
455    // Note: fee rates may vary per user tier on some exchanges, so document as "base fee rate."
456
457    #[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}