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