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    pub limit: Option<usize>,
104    /// Exchange-specific cursor (offset, page number, or cursor string)
105    #[serde(default)]
106    pub cursor: Option<String>,
107    /// Filter by market status. Defaults to Active at the exchange level when None.
108    /// Use `MarketStatusFilter::All` to fetch markets of any status.
109    #[serde(default)]
110    pub status: Option<MarketStatusFilter>,
111    /// Filter by series (Kalshi and Polymarket). Both exchanges organize markets
112    /// as Series → Events → Markets. Pass a Kalshi series ticker (e.g., `"KXBTC"`)
113    /// or a Polymarket series ID (e.g., `"10345"`) to fetch only markets in that series.
114    #[serde(default)]
115    pub series_id: Option<String>,
116    /// Fetch all markets within a specific event. Pass a Kalshi event ticker
117    /// (e.g., `"KXBTC-25MAR14"`), a Polymarket event ID or slug
118    /// (e.g., `"903"` or `"will-trump-win-2024"`), or an Opinion market slug
119    /// (e.g., `"btc-price-daily"`) to get its child markets.
120    /// When set, `series_id`, `cursor`, and `limit` are ignored (not paginated).
121    /// `status` filtering is still applied client-side.
122    #[serde(default)]
123    pub event_id: Option<String>,
124}
125
126#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128pub struct FetchOrdersParams {
129    pub market_id: Option<String>,
130}
131
132#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct FetchUserActivityParams {
135    pub address: String,
136    pub limit: Option<usize>,
137}
138
139// ============================================================================
140// Customer Credentials (for per-customer exchange authentication)
141//
142// These credential structs hold per-exchange authentication data.
143// Users provide their own exchange credentials to trade directly.
144// ============================================================================
145
146// TODO(wallet-support): Current wallet support and planned improvements.
147//
148// **Supported today:**
149// - Raw private key + optional funder: covers EOA (sig_type=0), Proxy (sig_type=1),
150//   GnosisSafe (sig_type=2). Server signs orders with the private key.
151// - CLOB API credentials (api_key, api_secret, api_passphrase): if provided alongside
152//   the private key, skips the expensive init_trading() derivation step.
153//
154// **SDK-side helpers to add (no server changes needed):**
155// 1. CLOB credential derivation helper — SDK method that takes a wallet signer, signs a
156//    ClobAuth EIP-712 message, calls Polymarket's /auth/derive-api-key, and returns
157//    {apiKey, apiSecret, apiPassphrase}. Runs client-side.
158//    Useful for both direct traders (automate credential setup) and platform builders
159//    (onboard end-users without manual Polymarket UI steps).
160//
161// 2. Approval/allowance helpers — SDK methods to check and set the 6 Polymarket token
162//    approvals (USDC + CTF for CTF Exchange, NegRisk CTF Exchange, NegRisk Adapter).
163//    Expose via client.approvals.check() and client.approvals.setAll(). Should also
164//    surface clear errors when orders fail due to missing approvals.
165//
166// **Future server-side additions (lower priority):**
167// 3. Pre-signed order endpoint — POST /orders/signed that accepts orders already signed
168//    client-side (EIP-712). Enables browser wallets (MetaMask, WalletConnect), hardware
169//    wallets (Ledger, Trezor), and Privy embedded wallets to trade without exposing
170//    private keys to any server. The server just forwards the pre-signed order to the
171//    exchange CLOB. Useful for both Mode A and Mode B.
172#[derive(Debug, Clone)]
173pub struct PolymarketCredentials {
174    pub private_key: Option<String>,
175    pub funder: Option<String>,
176    pub api_key: Option<String>,
177    pub api_secret: Option<String>,
178    pub api_passphrase: Option<String>,
179    pub signature_type: String,
180}
181
182impl PolymarketCredentials {
183    /// Create credentials from individual field values (e.g., from DynamoDB).
184    ///
185    /// Auto-detection: If signature_type is not provided:
186    /// - funder present → GnosisSafe (type 2)
187    /// - funder absent → EOA (type 0)
188    pub fn from_fields(
189        private_key: Option<String>,
190        funder: Option<String>,
191        api_key: Option<String>,
192        api_secret: Option<String>,
193        api_passphrase: Option<String>,
194        signature_type: Option<String>,
195    ) -> Self {
196        // Auto-detect: funder present without explicit type → GnosisSafe
197        let resolved_signature_type = signature_type.unwrap_or_else(|| {
198            if funder.is_some() {
199                "GnosisSafe".to_string()
200            } else {
201                "EOA".to_string()
202            }
203        });
204
205        Self {
206            private_key,
207            funder,
208            api_key,
209            api_secret,
210            api_passphrase,
211            signature_type: resolved_signature_type,
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::models::MarketStatus;
220
221    #[test]
222    fn market_status_filter_from_str() {
223        assert_eq!(
224            "active".parse::<MarketStatusFilter>().unwrap(),
225            MarketStatusFilter::Active
226        );
227        assert_eq!(
228            "open".parse::<MarketStatusFilter>().unwrap(),
229            MarketStatusFilter::Active
230        );
231        assert_eq!(
232            "closed".parse::<MarketStatusFilter>().unwrap(),
233            MarketStatusFilter::Closed
234        );
235        assert_eq!(
236            "resolved".parse::<MarketStatusFilter>().unwrap(),
237            MarketStatusFilter::Resolved
238        );
239        assert_eq!(
240            "settled".parse::<MarketStatusFilter>().unwrap(),
241            MarketStatusFilter::Resolved
242        );
243        assert_eq!(
244            "all".parse::<MarketStatusFilter>().unwrap(),
245            MarketStatusFilter::All
246        );
247        assert_eq!(
248            "ALL".parse::<MarketStatusFilter>().unwrap(),
249            MarketStatusFilter::All
250        );
251        assert!("invalid".parse::<MarketStatusFilter>().is_err());
252    }
253
254    #[test]
255    fn market_status_filter_display() {
256        assert_eq!(MarketStatusFilter::Active.to_string(), "active");
257        assert_eq!(MarketStatusFilter::Closed.to_string(), "closed");
258        assert_eq!(MarketStatusFilter::Resolved.to_string(), "resolved");
259        assert_eq!(MarketStatusFilter::All.to_string(), "all");
260    }
261
262    #[test]
263    fn market_status_filter_from_market_status() {
264        assert_eq!(
265            MarketStatusFilter::from(MarketStatus::Active),
266            MarketStatusFilter::Active
267        );
268        assert_eq!(
269            MarketStatusFilter::from(MarketStatus::Closed),
270            MarketStatusFilter::Closed
271        );
272        assert_eq!(
273            MarketStatusFilter::from(MarketStatus::Resolved),
274            MarketStatusFilter::Resolved
275        );
276    }
277
278    #[test]
279    fn market_status_filter_serde_roundtrip() {
280        let filter = MarketStatusFilter::All;
281        let json = serde_json::to_string(&filter).unwrap();
282        assert_eq!(json, "\"all\"");
283        let parsed: MarketStatusFilter = serde_json::from_str(&json).unwrap();
284        assert_eq!(parsed, MarketStatusFilter::All);
285    }
286
287    #[test]
288    fn fetch_markets_params_default_status_is_none() {
289        let params = FetchMarketsParams::default();
290        assert!(params.status.is_none());
291    }
292
293    #[test]
294    fn fetch_markets_params_serde_with_all_status() {
295        let params = FetchMarketsParams {
296            status: Some(MarketStatusFilter::All),
297            ..Default::default()
298        };
299        let json = serde_json::to_value(&params).unwrap();
300        assert_eq!(json["status"], "all");
301
302        let parsed: FetchMarketsParams = serde_json::from_value(json).unwrap();
303        assert_eq!(parsed.status, Some(MarketStatusFilter::All));
304    }
305}
306
307#[derive(Debug, Clone)]
308pub struct KalshiCredentials {
309    pub api_key_id: String,
310    pub private_key: String,
311}
312
313#[derive(Debug, Clone)]
314pub struct OpinionCredentials {
315    pub api_key: String,
316    pub private_key: String,
317    pub multi_sig_addr: String,
318}