Skip to main content

px_core/exchange/
config.rs

1use std::time::Duration;
2
3/// Filter for market status in fetch queries.
4///
5/// Unlike `MarketStatus` (which represents a market's actual status), this enum
6/// includes an `All` variant for fetching markets regardless of status.
7#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
9#[serde(rename_all = "lowercase")]
10pub enum MarketStatusFilter {
11    Active,
12    Closed,
13    Resolved,
14    All,
15}
16
17impl std::fmt::Display for MarketStatusFilter {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            MarketStatusFilter::Active => write!(f, "active"),
21            MarketStatusFilter::Closed => write!(f, "closed"),
22            MarketStatusFilter::Resolved => write!(f, "resolved"),
23            MarketStatusFilter::All => write!(f, "all"),
24        }
25    }
26}
27
28impl std::str::FromStr for MarketStatusFilter {
29    type Err = String;
30
31    fn from_str(s: &str) -> Result<Self, Self::Err> {
32        match s.to_lowercase().as_str() {
33            "active" | "open" => Ok(MarketStatusFilter::Active),
34            "closed" | "inactive" | "paused" => Ok(MarketStatusFilter::Closed),
35            "resolved" | "settled" | "determined" | "finalized" => Ok(MarketStatusFilter::Resolved),
36            "all" => Ok(MarketStatusFilter::All),
37            _ => Err(format!("Unknown market status filter: {}", s)),
38        }
39    }
40}
41
42impl From<crate::models::MarketStatus> for MarketStatusFilter {
43    fn from(s: crate::models::MarketStatus) -> Self {
44        match s {
45            crate::models::MarketStatus::Active => MarketStatusFilter::Active,
46            crate::models::MarketStatus::Closed => MarketStatusFilter::Closed,
47            crate::models::MarketStatus::Resolved => MarketStatusFilter::Resolved,
48        }
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct ExchangeConfig {
54    pub timeout: Duration,
55    pub rate_limit_per_second: u32,
56    pub max_retries: u32,
57    pub retry_delay: Duration,
58    pub verbose: bool,
59}
60
61impl Default for ExchangeConfig {
62    fn default() -> Self {
63        Self {
64            timeout: Duration::from_secs(30),
65            rate_limit_per_second: 10,
66            max_retries: 3,
67            retry_delay: Duration::from_secs(1),
68            verbose: false,
69        }
70    }
71}
72
73impl ExchangeConfig {
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    pub fn with_timeout(mut self, timeout: Duration) -> Self {
79        self.timeout = timeout;
80        self
81    }
82
83    pub fn with_rate_limit(mut self, requests_per_second: u32) -> Self {
84        self.rate_limit_per_second = requests_per_second;
85        self
86    }
87
88    pub fn with_retries(mut self, max_retries: u32, delay: Duration) -> Self {
89        self.max_retries = max_retries;
90        self.retry_delay = delay;
91        self
92    }
93
94    pub fn with_verbose(mut self, verbose: bool) -> Self {
95        self.verbose = verbose;
96        self
97    }
98}
99
100#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
102pub struct FetchMarketsParams {
103    /// Per-page limit. Each exchange applies its own server-side cap:
104    /// Kalshi tops out at 1000, Polymarket at ~500, Opinion is hard-capped
105    /// at 20. Values above the cap are silently clamped to the cap.
106    pub limit: Option<usize>,
107    /// Exchange-specific cursor (offset, page number, or cursor string)
108    #[serde(default)]
109    pub cursor: Option<String>,
110    /// Filter by market status. Defaults to Active at the exchange level when None.
111    /// Use `MarketStatusFilter::All` to fetch markets of any status.
112    #[serde(default)]
113    pub status: Option<MarketStatusFilter>,
114    /// Filter by series (Kalshi and Polymarket). Both exchanges organize markets
115    /// as Series → Events → Markets. Pass a Kalshi series ticker (e.g., `"KXBTC"`)
116    /// or a Polymarket series ID (e.g., `"10345"`) to fetch only markets in that series.
117    #[serde(default)]
118    pub series_id: Option<String>,
119    /// Fetch all markets within a specific event. Pass a Kalshi event ticker
120    /// (e.g., `"KXBTC-25MAR14"`), a Polymarket event ID or slug
121    /// (e.g., `"903"` or `"will-trump-win-2024"`), or an Opinion market slug
122    /// (e.g., `"btc-price-daily"`) to get its child markets.
123    /// When set, `series_id`, `cursor`, and `limit` are ignored (not paginated).
124    /// `status` filtering is still applied client-side.
125    #[serde(default)]
126    pub event_id: Option<String>,
127}
128
129#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
131pub struct FetchOrdersParams {
132    pub market_id: Option<String>,
133}
134
135#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
137pub struct FetchUserActivityParams {
138    pub address: String,
139    pub limit: Option<usize>,
140}
141
142// ============================================================================
143// Customer Credentials (for per-customer exchange authentication)
144//
145// These credential structs hold per-exchange authentication data.
146// Users provide their own exchange credentials to trade directly.
147// ============================================================================
148
149// TODO(wallet-support): Current wallet support and planned improvements.
150//
151// **Supported today:**
152// - Raw private key + optional funder: covers EOA (sig_type=0), Proxy (sig_type=1),
153//   GnosisSafe (sig_type=2). Server signs orders with the private key.
154// - CLOB API credentials (api_key, api_secret, api_passphrase): if provided alongside
155//   the private key, skips the expensive init_trading() derivation step.
156//
157// **SDK-side helpers to add (no server changes needed):**
158// 1. CLOB credential derivation helper — SDK method that takes a wallet signer, signs a
159//    ClobAuth EIP-712 message, calls Polymarket's /auth/derive-api-key, and returns
160//    {apiKey, apiSecret, apiPassphrase}. Runs client-side.
161//    Useful for both direct traders (automate credential setup) and platform builders
162//    (onboard end-users without manual Polymarket UI steps).
163//
164// 2. Approval/allowance helpers — SDK methods to check and set the 6 Polymarket token
165//    approvals (USDC + CTF for CTF Exchange, NegRisk CTF Exchange, NegRisk Adapter).
166//    Expose via client.approvals.check() and client.approvals.setAll(). Should also
167//    surface clear errors when orders fail due to missing approvals.
168//
169// **Future server-side additions (lower priority):**
170// 3. Pre-signed order endpoint — POST /orders/signed that accepts orders already signed
171//    client-side (EIP-712). Enables browser wallets (MetaMask, WalletConnect), hardware
172//    wallets (Ledger, Trezor), and Privy embedded wallets to trade without exposing
173//    private keys to any server. The server just forwards the pre-signed order to the
174//    exchange CLOB. Useful for both Mode A and Mode B.
175#[derive(Debug, Clone)]
176pub struct PolymarketCredentials {
177    pub private_key: Option<String>,
178    pub funder: Option<String>,
179    pub api_key: Option<String>,
180    pub api_secret: Option<String>,
181    pub api_passphrase: Option<String>,
182    pub signature_type: String,
183}
184
185impl PolymarketCredentials {
186    /// Create credentials from individual field values (e.g., from DynamoDB).
187    ///
188    /// Auto-detection: If signature_type is not provided:
189    /// - funder present → GnosisSafe (type 2)
190    /// - funder absent → EOA (type 0)
191    pub fn from_fields(
192        private_key: Option<String>,
193        funder: Option<String>,
194        api_key: Option<String>,
195        api_secret: Option<String>,
196        api_passphrase: Option<String>,
197        signature_type: Option<String>,
198    ) -> Self {
199        // Auto-detect: funder present without explicit type → GnosisSafe
200        let resolved_signature_type = signature_type.unwrap_or_else(|| {
201            if funder.is_some() {
202                "GnosisSafe".to_string()
203            } else {
204                "EOA".to_string()
205            }
206        });
207
208        Self {
209            private_key,
210            funder,
211            api_key,
212            api_secret,
213            api_passphrase,
214            signature_type: resolved_signature_type,
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::models::MarketStatus;
223
224    #[test]
225    fn market_status_filter_from_str() {
226        assert_eq!(
227            "active".parse::<MarketStatusFilter>().unwrap(),
228            MarketStatusFilter::Active
229        );
230        assert_eq!(
231            "open".parse::<MarketStatusFilter>().unwrap(),
232            MarketStatusFilter::Active
233        );
234        assert_eq!(
235            "closed".parse::<MarketStatusFilter>().unwrap(),
236            MarketStatusFilter::Closed
237        );
238        assert_eq!(
239            "resolved".parse::<MarketStatusFilter>().unwrap(),
240            MarketStatusFilter::Resolved
241        );
242        assert_eq!(
243            "settled".parse::<MarketStatusFilter>().unwrap(),
244            MarketStatusFilter::Resolved
245        );
246        assert_eq!(
247            "all".parse::<MarketStatusFilter>().unwrap(),
248            MarketStatusFilter::All
249        );
250        assert_eq!(
251            "ALL".parse::<MarketStatusFilter>().unwrap(),
252            MarketStatusFilter::All
253        );
254        assert!("invalid".parse::<MarketStatusFilter>().is_err());
255    }
256
257    #[test]
258    fn market_status_filter_display() {
259        assert_eq!(MarketStatusFilter::Active.to_string(), "active");
260        assert_eq!(MarketStatusFilter::Closed.to_string(), "closed");
261        assert_eq!(MarketStatusFilter::Resolved.to_string(), "resolved");
262        assert_eq!(MarketStatusFilter::All.to_string(), "all");
263    }
264
265    #[test]
266    fn market_status_filter_from_market_status() {
267        assert_eq!(
268            MarketStatusFilter::from(MarketStatus::Active),
269            MarketStatusFilter::Active
270        );
271        assert_eq!(
272            MarketStatusFilter::from(MarketStatus::Closed),
273            MarketStatusFilter::Closed
274        );
275        assert_eq!(
276            MarketStatusFilter::from(MarketStatus::Resolved),
277            MarketStatusFilter::Resolved
278        );
279    }
280
281    #[test]
282    fn market_status_filter_serde_roundtrip() {
283        let filter = MarketStatusFilter::All;
284        let json = serde_json::to_string(&filter).unwrap();
285        assert_eq!(json, "\"all\"");
286        let parsed: MarketStatusFilter = serde_json::from_str(&json).unwrap();
287        assert_eq!(parsed, MarketStatusFilter::All);
288    }
289
290    #[test]
291    fn fetch_markets_params_default_status_is_none() {
292        let params = FetchMarketsParams::default();
293        assert!(params.status.is_none());
294    }
295
296    #[test]
297    fn fetch_markets_params_serde_with_all_status() {
298        let params = FetchMarketsParams {
299            status: Some(MarketStatusFilter::All),
300            ..Default::default()
301        };
302        let json = serde_json::to_value(&params).unwrap();
303        assert_eq!(json["status"], "all");
304
305        let parsed: FetchMarketsParams = serde_json::from_value(json).unwrap();
306        assert_eq!(parsed.status, Some(MarketStatusFilter::All));
307    }
308}
309
310#[derive(Debug, Clone)]
311pub struct KalshiCredentials {
312    pub api_key_id: String,
313    pub private_key: String,
314}
315
316#[derive(Debug, Clone)]
317pub struct OpinionCredentials {
318    pub api_key: String,
319    pub private_key: String,
320    pub multi_sig_addr: String,
321}