Skip to main content

px_core/
events.rs

1/// Canonical event identity helpers.
2///
3/// Why this exists:
4/// - Exchanges expose different event/group identifiers for the same real-world event.
5/// - OpenPX keeps source-native `group_id` for transparency.
6/// - OpenPX also exposes canonical `event_id` so SDK users can query events uniformly.
7use std::borrow::Cow;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct EventAlias {
11    pub exchange: &'static str,
12    pub group_id: &'static str,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct EventAliasOwned {
17    pub exchange: String,
18    pub group_id: String,
19}
20
21#[derive(Debug, Clone, Copy)]
22struct CanonicalEventEntry {
23    canonical_event_id: &'static str,
24    aliases: &'static [EventAlias],
25}
26
27// Curated registry for explicit cross-exchange links.
28// Keep this small and auditable; avoid fuzzy matching mistakes.
29// TODO(openpx-events): Add verified Kalshi/Polymarket aliases for high-volume
30// shared events as we complete event-by-event adjudication.
31// TODO(openpx-market-aliases): `event_id` is event-level only. To power
32// cross-exchange "same market" UX (logo stack with clickable jump + live price),
33// add a second curated mapping layer for market-level equivalence within an event:
34//   (canonical event_id, exchange, market_id|group_item) <-> canonical market key.
35const CANONICAL_EVENT_REGISTRY: &[CanonicalEventEntry] = &[CanonicalEventEntry {
36    canonical_event_id: "ev:us-pres-election-winner-2028",
37    aliases: &[EventAlias {
38        exchange: "polymarket",
39        group_id: "31552",
40    }],
41}];
42
43fn normalize_exchange(exchange: &str) -> Cow<'_, str> {
44    let trimmed = exchange.trim();
45    if trimmed.bytes().all(|b| !b.is_ascii_uppercase()) {
46        Cow::Borrowed(trimmed)
47    } else {
48        Cow::Owned(trimmed.to_ascii_lowercase())
49    }
50}
51
52/// Stable fallback when no explicit registry mapping exists.
53///
54/// This preserves a canonical OpenPX shape without inventing risky fuzzy links.
55pub fn default_event_id(exchange: &str, group_id: &str) -> Option<String> {
56    let exchange = normalize_exchange(exchange);
57    let group_id = group_id.trim();
58    if exchange.is_empty() || group_id.is_empty() {
59        return None;
60    }
61    Some(format!("ev:{exchange}:{group_id}"))
62}
63
64/// Resolve canonical `event_id` from source-native identifiers.
65pub fn canonical_event_id(exchange: &str, group_id: &str) -> Option<String> {
66    let exchange_norm = normalize_exchange(exchange);
67    let group_norm = group_id.trim();
68    if exchange_norm.is_empty() || group_norm.is_empty() {
69        return None;
70    }
71
72    for entry in CANONICAL_EVENT_REGISTRY {
73        if entry
74            .aliases
75            .iter()
76            .any(|alias| alias.exchange == exchange_norm && alias.group_id == group_norm)
77        {
78            return Some(entry.canonical_event_id.to_string());
79        }
80    }
81
82    default_event_id(&exchange_norm, group_norm)
83}
84
85/// Expand canonical `event_id` into source aliases for exchange queries.
86///
87/// Supports:
88/// - Registry-backed canonical IDs (cross-exchange).
89/// - Deterministic fallback IDs (`ev:{exchange}:{group_id}`).
90pub fn aliases_for_event_id(event_id: &str) -> Vec<EventAliasOwned> {
91    let event_id = event_id.trim();
92    if event_id.is_empty() {
93        return Vec::new();
94    }
95
96    if let Some(entry) = CANONICAL_EVENT_REGISTRY
97        .iter()
98        .find(|entry| entry.canonical_event_id == event_id)
99    {
100        return entry
101            .aliases
102            .iter()
103            .map(|alias| EventAliasOwned {
104                exchange: alias.exchange.to_string(),
105                group_id: alias.group_id.to_string(),
106            })
107            .collect();
108    }
109
110    let raw = match event_id.strip_prefix("ev:") {
111        Some(v) => v,
112        None => return Vec::new(),
113    };
114
115    if let Some((exchange, group_id)) = raw.split_once(':') {
116        let exchange = normalize_exchange(exchange);
117        let group_id = group_id.trim();
118        if !exchange.is_empty() && !group_id.is_empty() {
119            return vec![EventAliasOwned {
120                exchange: exchange.into_owned(),
121                group_id: group_id.to_string(),
122            }];
123        }
124    }
125
126    Vec::new()
127}
128
129#[cfg(test)]
130mod tests {
131    use super::{aliases_for_event_id, canonical_event_id, default_event_id};
132
133    #[test]
134    fn canonical_event_id_uses_registry() {
135        let id = canonical_event_id("polymarket", "31552");
136        assert_eq!(id.as_deref(), Some("ev:us-pres-election-winner-2028"));
137    }
138
139    #[test]
140    fn canonical_event_id_falls_back_deterministically() {
141        let id = canonical_event_id("kalshi", "KXABC-123");
142        assert_eq!(id.as_deref(), Some("ev:kalshi:KXABC-123"));
143    }
144
145    #[test]
146    fn aliases_expand_registry_and_fallback() {
147        let mapped = aliases_for_event_id("ev:us-pres-election-winner-2028");
148        assert_eq!(mapped.len(), 1);
149        assert_eq!(mapped[0].exchange, "polymarket");
150        assert_eq!(mapped[0].group_id, "31552");
151
152        let fallback = aliases_for_event_id("ev:kalshi:KXABC-123");
153        assert_eq!(fallback.len(), 1);
154        assert_eq!(fallback[0].exchange, "kalshi");
155        assert_eq!(fallback[0].group_id, "KXABC-123");
156    }
157
158    #[test]
159    fn default_event_id_validates_inputs() {
160        assert_eq!(
161            default_event_id("kalshi", "ABC").as_deref(),
162            Some("ev:kalshi:ABC")
163        );
164        assert!(default_event_id("", "ABC").is_none());
165        assert!(default_event_id("kalshi", "").is_none());
166    }
167}