Skip to main content

px_core/exchange/
config.rs

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