px_core/exchange/manifest.rs
1use crate::models::MarketStatus;
2
3/// Complete auditable manifest for an exchange.
4/// When opened, shows SOURCE and TRANSFORMATION side-by-side.
5#[derive(Debug, Clone)]
6pub struct ExchangeManifest {
7 // ========================================
8 // SECTION 1: CONNECTION AUDIT (Where do we go?)
9 // ========================================
10 /// Exchange identifier (e.g., "kalshi", "polymarket")
11 pub id: &'static str,
12
13 /// Human-readable exchange name
14 pub name: &'static str,
15
16 /// Base API URL
17 pub base_url: &'static str,
18
19 /// Markets list endpoint (relative to base_url)
20 pub markets_endpoint: &'static str,
21
22 /// Pagination configuration
23 pub pagination: PaginationConfig,
24
25 /// Rate limiting configuration
26 pub rate_limit: RateLimitConfig,
27
28 // ========================================
29 // SECTION 2: DATA AUDIT (How do we map it?)
30 // ========================================
31 /// Field mappings from raw exchange JSON to Market
32 pub field_mappings: &'static [FieldMapping],
33
34 /// Status value mappings (exchange status -> MarketStatus)
35 pub status_map: &'static [(&'static str, MarketStatus)],
36}
37
38impl ExchangeManifest {
39 /// Look up the MarketStatus for a given exchange status string.
40 /// Status map entries should be lowercase at definition time for O(n) without allocation.
41 pub fn map_status(&self, exchange_status: &str) -> Option<MarketStatus> {
42 self.status_map
43 .iter()
44 .find(|(s, _)| s.eq_ignore_ascii_case(exchange_status))
45 .map(|(_, status)| *status)
46 }
47
48 /// Get a field mapping by unified field name.
49 pub fn get_field_mapping(&self, unified_field: &str) -> Option<&FieldMapping> {
50 self.field_mappings
51 .iter()
52 .find(|m| m.unified_field == unified_field)
53 }
54}
55
56#[derive(Debug, Clone, Copy)]
57pub struct PaginationConfig {
58 /// Pagination style: Cursor, Offset, Page
59 pub style: PaginationStyle,
60 /// Maximum items per page
61 pub max_page_size: usize,
62 /// Query param name for limit
63 pub limit_param: &'static str,
64 /// Query param name for cursor/offset
65 pub cursor_param: &'static str,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum PaginationStyle {
70 /// Cursor-based pagination (Kalshi)
71 Cursor,
72 /// Offset-based pagination (Polymarket)
73 Offset,
74 /// Page-number pagination (Opinion, 1-indexed)
75 PageNumber,
76 /// No pagination supported - endpoint returns all data in single call
77 None,
78}
79
80/// Endpoint category for per-endpoint rate limiting.
81/// Each exchange maps these to its actual documented API rate limits.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83#[repr(u8)]
84pub enum RateLimitCategory {
85 /// Read operations: fetch markets, orderbook, positions, balance, trades, price history
86 Read = 0,
87 /// Write operations: create order, cancel order
88 Write = 1,
89 /// Bulk/data operations: market fetcher, paginated full-catalog fetches
90 Bulk = 2,
91}
92
93impl RateLimitCategory {
94 pub const COUNT: usize = 3;
95 pub const ALL: [RateLimitCategory; 3] = [
96 RateLimitCategory::Read,
97 RateLimitCategory::Write,
98 RateLimitCategory::Bulk,
99 ];
100}
101
102/// Rate limit for a single endpoint category.
103#[derive(Debug, Clone, Copy)]
104pub struct EndpointRateLimit {
105 pub category: RateLimitCategory,
106 pub requests_per_second: u32,
107 pub burst: u32,
108}
109
110/// Per-category rate limiting configuration for an exchange.
111/// Categories not listed in `limits` inherit from `default_rps`/`default_burst`.
112#[derive(Debug, Clone, Copy)]
113pub struct RateLimitConfig {
114 /// Fallback rate for any category not explicitly listed.
115 pub default_rps: u32,
116 /// Default burst for any category not explicitly listed.
117 pub default_burst: u32,
118 /// Per-category overrides (searched linearly; at most 3 entries).
119 pub limits: &'static [EndpointRateLimit],
120}
121
122impl RateLimitConfig {
123 /// Look up (rps, burst) for a given category.
124 pub const fn get(&self, category: RateLimitCategory) -> (u32, u32) {
125 let mut i = 0;
126 while i < self.limits.len() {
127 if self.limits[i].category as u8 == category as u8 {
128 return (self.limits[i].requests_per_second, self.limits[i].burst);
129 }
130 i += 1;
131 }
132 (self.default_rps, self.default_burst)
133 }
134
135 /// Convenience: get requests_per_second for a category.
136 pub const fn rps(&self, category: RateLimitCategory) -> u32 {
137 self.get(category).0
138 }
139
140 /// Backwards-compat: overall requests_per_second (uses Read category).
141 pub const fn requests_per_second(&self) -> u32 {
142 self.default_rps
143 }
144}
145
146/// Mapping from raw exchange JSON field to unified field.
147#[derive(Debug, Clone)]
148pub struct FieldMapping {
149 /// Target field in Market
150 pub unified_field: &'static str,
151 /// Source path(s) in raw JSON (fallback chain)
152 pub source_paths: &'static [&'static str],
153 /// Transformation to apply
154 pub transform: Transform,
155 /// Whether field can be null
156 pub nullable: bool,
157}
158
159/// Transformation to apply when mapping a field.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum Transform {
162 /// No transformation - use value directly
163 Direct,
164 /// Divide by 100 (Kalshi prices: cents -> decimal)
165 CentsToDollars,
166 /// Unix timestamp (seconds) to DateTime
167 UnixSecsToDateTime,
168 /// Unix timestamp (milliseconds) to DateTime
169 UnixMillisToDateTime,
170 /// ISO8601 string to DateTime
171 Iso8601ToDateTime,
172 /// String/Float -> i64
173 ParseInt,
174 /// String -> f64
175 ParseFloat,
176 /// Extract element at index from JSON array
177 JsonArrayIndex(usize),
178 /// Nested path extraction (dot-notation handled by source_paths)
179 NestedPath,
180}