polymarket_api/
clob.rs

1//! CLOB (Central Limit Order Book) REST API client
2//!
3//! This module provides a client for interacting with Polymarket's CLOB REST API,
4//! which allows fetching orderbooks, trades, and managing orders.
5
6use {
7    crate::error::Result,
8    base64::{Engine, engine::general_purpose::STANDARD},
9    hmac::{Hmac, Mac},
10    reqwest::header::{HeaderMap, HeaderValue},
11    serde::{Deserialize, Serialize},
12    sha2::Sha256,
13    std::time::{SystemTime, UNIX_EPOCH},
14};
15
16/// Macro for conditional info logging based on tracing feature
17#[cfg(feature = "tracing")]
18macro_rules! log_info {
19    ($($arg:tt)*) => { tracing::info!($($arg)*) };
20}
21
22#[cfg(not(feature = "tracing"))]
23macro_rules! log_info {
24    ($($arg:tt)*) => {};
25}
26
27/// Macro for conditional debug logging based on tracing feature
28#[cfg(feature = "tracing")]
29macro_rules! log_debug {
30    ($($arg:tt)*) => { tracing::debug!($($arg)*) };
31}
32
33#[cfg(not(feature = "tracing"))]
34macro_rules! log_debug {
35    ($($arg:tt)*) => {};
36}
37
38const CLOB_API_BASE: &str = "https://clob.polymarket.com";
39
40/// Order side (buy or sell)
41#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
42#[serde(rename_all = "UPPERCASE")]
43pub enum Side {
44    Buy,
45    Sell,
46}
47
48/// Order type
49#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
50#[serde(rename_all = "UPPERCASE")]
51pub enum OrderType {
52    Limit,
53    Market,
54}
55
56/// Order status
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum OrderStatus {
60    Open,
61    Filled,
62    Cancelled,
63    Rejected,
64}
65
66/// Price level in the orderbook
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct PriceLevel {
69    pub price: String,
70    pub size: String,
71}
72
73/// Orderbook snapshot
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Orderbook {
76    pub bids: Vec<PriceLevel>,
77    pub asks: Vec<PriceLevel>,
78    /// Market identifier (condition ID)
79    #[serde(default)]
80    pub market: Option<String>,
81    /// Asset identifier (token ID)
82    #[serde(default)]
83    pub asset_id: Option<String>,
84    /// Snapshot timestamp (ISO 8601)
85    #[serde(default)]
86    pub timestamp: Option<String>,
87    /// Order book state hash
88    #[serde(default)]
89    pub hash: Option<String>,
90    /// Minimum tradeable size
91    #[serde(default)]
92    pub min_order_size: Option<String>,
93    /// Minimum price increment
94    #[serde(default)]
95    pub tick_size: Option<String>,
96    /// Whether negative risk mechanics are enabled
97    #[serde(default)]
98    pub neg_risk: Option<bool>,
99}
100
101/// Price response from GET /price endpoint
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PriceResponse {
104    pub price: String,
105}
106
107/// Midpoint response from GET /midpoint endpoint
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct MidpointResponse {
110    pub mid: String,
111}
112
113/// Historical price point
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PriceHistoryPoint {
116    /// Unix timestamp
117    pub t: i64,
118    /// Price value
119    pub p: f64,
120}
121
122/// Price history response
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct PriceHistoryResponse {
125    pub history: Vec<PriceHistoryPoint>,
126}
127
128/// Time interval for price history queries
129#[derive(Debug, Clone, Copy)]
130pub enum PriceInterval {
131    OneMinute,
132    OneHour,
133    SixHours,
134    OneDay,
135    OneWeek,
136    Max,
137}
138
139impl PriceInterval {
140    /// Get the string representation of the interval
141    pub fn as_str(&self) -> &'static str {
142        match self {
143            PriceInterval::OneMinute => "1m",
144            PriceInterval::OneHour => "1h",
145            PriceInterval::SixHours => "6h",
146            PriceInterval::OneDay => "1d",
147            PriceInterval::OneWeek => "1w",
148            PriceInterval::Max => "max",
149        }
150    }
151}
152
153/// Request for spread calculation
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SpreadRequest {
156    pub token_id: String,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub side: Option<Side>,
159}
160
161/// Request for batch price/orderbook queries
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct BatchTokenRequest {
164    pub token_id: String,
165    pub side: Side,
166}
167
168/// Price data for a single token (both sides)
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct TokenPrices {
171    #[serde(rename = "BUY", default)]
172    pub buy: Option<String>,
173    #[serde(rename = "SELL", default)]
174    pub sell: Option<String>,
175}
176
177/// Trade information
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Trade {
180    pub price: String,
181    pub size: String,
182    pub timestamp: i64,
183    pub side: String,
184    pub maker_order_id: Option<String>,
185    pub taker_order_id: Option<String>,
186}
187
188/// Order information
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct Order {
191    pub order_id: String,
192    pub market: String,
193    pub side: String,
194    #[serde(rename = "type")]
195    pub order_type: String,
196    pub price: Option<String>,
197    pub size: String,
198    pub filled: String,
199    pub status: String,
200    pub created_at: Option<i64>,
201    pub updated_at: Option<i64>,
202}
203
204/// CLOB REST API client
205pub struct ClobClient {
206    client: reqwest::Client,
207    api_key: Option<String>,
208    api_secret: Option<String>,
209    passphrase: Option<String>,
210    /// Polygon wallet address (required for L2 authentication)
211    address: Option<String>,
212}
213
214impl ClobClient {
215    /// Create a new CLOB client without authentication (for public endpoints)
216    pub fn new() -> Self {
217        Self {
218            client: reqwest::Client::new(),
219            api_key: None,
220            api_secret: None,
221            passphrase: None,
222            address: None,
223        }
224    }
225
226    /// Create a new CLOB client with authentication
227    pub fn with_auth(
228        api_key: String,
229        api_secret: String,
230        passphrase: String,
231        address: String,
232    ) -> Self {
233        Self {
234            client: reqwest::Client::new(),
235            api_key: Some(api_key),
236            api_secret: Some(api_secret),
237            passphrase: Some(passphrase),
238            address: Some(address),
239        }
240    }
241
242    /// Create a new CLOB client from environment variables
243    /// Requires: api_key, secret, passphrase, address (or poly_address)
244    pub fn from_env() -> Self {
245        let address = std::env::var("address")
246            .or_else(|_| std::env::var("poly_address"))
247            .or_else(|_| std::env::var("POLY_ADDRESS"))
248            .ok();
249
250        if let (Ok(api_key), Ok(api_secret), Ok(passphrase), Some(addr)) = (
251            std::env::var("api_key"),
252            std::env::var("secret"),
253            std::env::var("passphrase"),
254            address,
255        ) {
256            Self::with_auth(api_key, api_secret, passphrase, addr)
257        } else {
258            Self::new()
259        }
260    }
261
262    /// Check if the client has authentication credentials
263    pub fn has_auth(&self) -> bool {
264        self.api_key.is_some()
265            && self.api_secret.is_some()
266            && self.passphrase.is_some()
267            && self.address.is_some()
268    }
269
270    /// Create L2 authentication headers for a request
271    fn create_l2_headers(
272        &self,
273        method: &str,
274        request_path: &str,
275        body: Option<&str>,
276    ) -> Option<HeaderMap> {
277        if let (Some(api_key), Some(secret), Some(passphrase), Some(address)) = (
278            &self.api_key,
279            &self.api_secret,
280            &self.passphrase,
281            &self.address,
282        ) {
283            match L2Headers::new(
284                api_key,
285                secret,
286                passphrase,
287                address,
288                method,
289                request_path,
290                body,
291            ) {
292                Ok(headers) => Some(headers.to_header_map()),
293                Err(e) => {
294                    log_debug!("Failed to create L2 headers: {}", e);
295                    None
296                },
297            }
298        } else {
299            None
300        }
301    }
302
303    /// Get orderbook for a specific market (condition ID)
304    pub async fn get_orderbook(&self, condition_id: &str) -> Result<Orderbook> {
305        let url = format!("{}/book", CLOB_API_BASE);
306        let params = [("market", condition_id)];
307        let orderbook: Orderbook = self
308            .client
309            .get(&url)
310            .query(&params)
311            .send()
312            .await?
313            .json()
314            .await?;
315        Ok(orderbook)
316    }
317
318    /// Get recent trades for a specific market (condition ID)
319    pub async fn get_trades(&self, condition_id: &str, limit: Option<usize>) -> Result<Vec<Trade>> {
320        let url = format!("{}/trades", CLOB_API_BASE);
321        let mut params = vec![("market", condition_id.to_string())];
322        if let Some(limit) = limit {
323            params.push(("limit", limit.to_string()));
324        }
325        let trades: Vec<Trade> = self
326            .client
327            .get(&url)
328            .query(&params)
329            .send()
330            .await?
331            .json()
332            .await?;
333        Ok(trades)
334    }
335
336    /// Get orderbook for a specific token ID (clob_token_id from Gamma API)
337    pub async fn get_orderbook_by_asset(&self, token_id: &str) -> Result<Orderbook> {
338        let _url = format!("{}/book?token_id={}", CLOB_API_BASE, token_id);
339        log_info!("GET {}", _url);
340
341        let params = [("token_id", token_id)];
342        let response = self
343            .client
344            .get(format!("{}/book", CLOB_API_BASE))
345            .query(&params)
346            .send()
347            .await?;
348
349        let status = response.status();
350        log_info!("GET {} -> status: {}", _url, status);
351
352        // 404 means no orderbook exists for this token (market might be new or have no orders)
353        if status == reqwest::StatusCode::NOT_FOUND {
354            log_info!(
355                "GET {} -> no orderbook (market may have no orders yet)",
356                _url
357            );
358            return Ok(Orderbook {
359                bids: Vec::new(),
360                asks: Vec::new(),
361                market: None,
362                asset_id: Some(token_id.to_string()),
363                timestamp: None,
364                hash: None,
365                min_order_size: None,
366                tick_size: None,
367                neg_risk: None,
368            });
369        }
370
371        if !status.is_success() {
372            let error_text = response
373                .text()
374                .await
375                .unwrap_or_else(|_| "Unknown error".to_string());
376            return Err(crate::error::PolymarketError::InvalidData(format!(
377                "HTTP {}: {}",
378                status, error_text
379            )));
380        }
381
382        let response_text = response.text().await?;
383        log_info!(
384            "GET {} -> bids/asks preview: {}",
385            _url,
386            if response_text.len() > 200 {
387                &response_text[..200]
388            } else {
389                &response_text
390            }
391        );
392
393        // Try to deserialize as Orderbook
394        match serde_json::from_str::<Orderbook>(&response_text) {
395            Ok(orderbook) => {
396                log_info!(
397                    "GET {} -> parsed: {} bids, {} asks",
398                    _url,
399                    orderbook.bids.len(),
400                    orderbook.asks.len()
401                );
402                Ok(orderbook)
403            },
404            Err(_e) => {
405                // Log the actual response for debugging
406                log_debug!(
407                    "Failed to parse orderbook response for token {}: {}. Response: {}",
408                    token_id,
409                    _e,
410                    response_text
411                );
412                // Return an empty orderbook if deserialization fails (token might not have orders)
413                Ok(Orderbook {
414                    bids: Vec::new(),
415                    asks: Vec::new(),
416                    market: None,
417                    asset_id: Some(token_id.to_string()),
418                    timestamp: None,
419                    hash: None,
420                    min_order_size: None,
421                    tick_size: None,
422                    neg_risk: None,
423                })
424            },
425        }
426    }
427
428    /// Get recent trades for a specific asset ID
429    pub async fn get_trades_by_asset(
430        &self,
431        asset_id: &str,
432        limit: Option<usize>,
433    ) -> Result<Vec<Trade>> {
434        let url = format!("{}/trades", CLOB_API_BASE);
435        let mut params = vec![("asset_id", asset_id.to_string())];
436        if let Some(limit) = limit {
437            params.push(("limit", limit.to_string()));
438        }
439        let trades: Vec<Trade> = self
440            .client
441            .get(&url)
442            .query(&params)
443            .send()
444            .await?
445            .json()
446            .await?;
447        Ok(trades)
448    }
449
450    /// Get recent trades for a specific market with authentication
451    /// Returns trade count if authenticated, otherwise returns an error
452    pub async fn get_trades_authenticated(
453        &self,
454        market: &str,
455        limit: Option<usize>,
456    ) -> Result<Vec<Trade>> {
457        // Build the query string
458        let mut query_parts = vec![format!("market={}", market)];
459        if let Some(limit) = limit {
460            query_parts.push(format!("limit={}", limit));
461        }
462        let query_string = query_parts.join("&");
463        let request_path = format!("/trades?{}", query_string);
464
465        log_info!("GET {}{} (authenticated)", CLOB_API_BASE, request_path);
466
467        // Create L2 auth headers
468        let headers = self
469            .create_l2_headers("GET", &request_path, None)
470            .ok_or_else(|| {
471                crate::error::PolymarketError::InvalidData(
472                    "Missing authentication credentials".to_string(),
473                )
474            })?;
475
476        let url = format!("{}{}", CLOB_API_BASE, request_path);
477        let response = self.client.get(&url).headers(headers).send().await?;
478
479        let status = response.status();
480        if !status.is_success() {
481            let error_text = response
482                .text()
483                .await
484                .unwrap_or_else(|_| "Unknown error".to_string());
485            log_info!("GET {} -> error: {} - {}", request_path, status, error_text);
486            return Err(crate::error::PolymarketError::InvalidData(format!(
487                "HTTP {}: {}",
488                status, error_text
489            )));
490        }
491
492        let trades: Vec<Trade> = response.json().await?;
493        log_info!("GET {} -> {} trades", request_path, trades.len());
494        Ok(trades)
495    }
496
497    /// Get trade count for a market (uses authenticated endpoint if credentials available)
498    pub async fn get_trade_count(&self, market: &str) -> Result<usize> {
499        if self.has_auth() {
500            // Use authenticated endpoint to get all trades
501            let trades = self.get_trades_authenticated(market, Some(1000)).await?;
502            Ok(trades.len())
503        } else {
504            // Without auth, we can't get trade counts reliably
505            Err(crate::error::PolymarketError::InvalidData(
506                "Authentication required to fetch trade counts".to_string(),
507            ))
508        }
509    }
510
511    /// Get user's orders (requires authentication)
512    pub async fn get_orders(&self) -> Result<Vec<Order>> {
513        let url = format!("{}/orders", CLOB_API_BASE);
514        // TODO: Add authentication headers
515        let orders: Vec<Order> = self.client.get(&url).send().await?.json().await?;
516        Ok(orders)
517    }
518
519    /// Get a specific order by ID (requires authentication)
520    pub async fn get_order(&self, order_id: &str) -> Result<Order> {
521        let url = format!("{}/orders/{}", CLOB_API_BASE, order_id);
522        // TODO: Add authentication headers
523        let order: Order = self.client.get(&url).send().await?.json().await?;
524        Ok(order)
525    }
526
527    /// Place a new order (requires authentication)
528    pub async fn place_order(
529        &self,
530        _market: &str,
531        _side: Side,
532        _order_type: OrderType,
533        _size: &str,
534        _price: Option<&str>,
535    ) -> Result<Order> {
536        // TODO: Add authentication headers and request body
537        // This is a placeholder - actual implementation would need proper auth signing
538        let _url = format!("{}/orders", CLOB_API_BASE);
539        todo!("Order placement requires authentication signing")
540    }
541
542    /// Cancel an order (requires authentication)
543    pub async fn cancel_order(&self, order_id: &str) -> Result<()> {
544        let url = format!("{}/orders/{}", CLOB_API_BASE, order_id);
545        // TODO: Add authentication headers
546        self.client.delete(&url).send().await?;
547        Ok(())
548    }
549
550    /// Get market price for a specific token and side
551    ///
552    /// # Arguments
553    /// * `token_id` - The unique identifier for the token
554    /// * `side` - The side of the market (BUY or SELL)
555    pub async fn get_price(&self, token_id: &str, side: Side) -> Result<PriceResponse> {
556        let url = format!("{}/price", CLOB_API_BASE);
557        let side_str = match side {
558            Side::Buy => "BUY",
559            Side::Sell => "SELL",
560        };
561        let params = [("token_id", token_id), ("side", side_str)];
562
563        let response = self.client.get(&url).query(&params).send().await?;
564
565        if !response.status().is_success() {
566            let error_text = response
567                .text()
568                .await
569                .unwrap_or_else(|_| "Unknown error".to_string());
570            return Err(crate::error::PolymarketError::InvalidData(error_text));
571        }
572
573        let price: PriceResponse = response.json().await?;
574        Ok(price)
575    }
576
577    /// Get midpoint price for a specific token
578    ///
579    /// The midpoint is the middle point between the current best bid and ask prices.
580    ///
581    /// # Arguments
582    /// * `token_id` - The unique identifier for the token
583    pub async fn get_midpoint(&self, token_id: &str) -> Result<MidpointResponse> {
584        let url = format!("{}/midpoint", CLOB_API_BASE);
585        let params = [("token_id", token_id)];
586
587        let response = self.client.get(&url).query(&params).send().await?;
588
589        if !response.status().is_success() {
590            let error_text = response
591                .text()
592                .await
593                .unwrap_or_else(|_| "Unknown error".to_string());
594            return Err(crate::error::PolymarketError::InvalidData(error_text));
595        }
596
597        let midpoint: MidpointResponse = response.json().await?;
598        Ok(midpoint)
599    }
600
601    /// Get historical price data for a token
602    ///
603    /// # Arguments
604    /// * `token_id` - The CLOB token ID for which to fetch price history
605    /// * `start_ts` - Optional start time as Unix timestamp in UTC
606    /// * `end_ts` - Optional end time as Unix timestamp in UTC
607    /// * `interval` - Optional interval string (mutually exclusive with start_ts/end_ts)
608    /// * `fidelity` - Optional resolution of the data in minutes
609    pub async fn get_prices_history(
610        &self,
611        token_id: &str,
612        start_ts: Option<i64>,
613        end_ts: Option<i64>,
614        interval: Option<PriceInterval>,
615        fidelity: Option<u32>,
616    ) -> Result<PriceHistoryResponse> {
617        let url = format!("{}/prices-history", CLOB_API_BASE);
618        let mut params = vec![("market", token_id.to_string())];
619
620        if let Some(start) = start_ts {
621            params.push(("startTs", start.to_string()));
622        }
623        if let Some(end) = end_ts {
624            params.push(("endTs", end.to_string()));
625        }
626        if let Some(interval) = interval {
627            params.push(("interval", interval.as_str().to_string()));
628        }
629        if let Some(fidelity) = fidelity {
630            params.push(("fidelity", fidelity.to_string()));
631        }
632
633        let response = self.client.get(&url).query(&params).send().await?;
634
635        if !response.status().is_success() {
636            let error_text = response
637                .text()
638                .await
639                .unwrap_or_else(|_| "Unknown error".to_string());
640            return Err(crate::error::PolymarketError::InvalidData(error_text));
641        }
642
643        let history: PriceHistoryResponse = response.json().await?;
644        Ok(history)
645    }
646
647    /// Get bid-ask spreads for multiple tokens
648    ///
649    /// # Arguments
650    /// * `requests` - Array of spread requests (max 500)
651    pub async fn get_spreads(
652        &self,
653        requests: Vec<SpreadRequest>,
654    ) -> Result<std::collections::HashMap<String, String>> {
655        let url = format!("{}/spreads", CLOB_API_BASE);
656
657        let response = self.client.post(&url).json(&requests).send().await?;
658
659        if !response.status().is_success() {
660            let error_text = response
661                .text()
662                .await
663                .unwrap_or_else(|_| "Unknown error".to_string());
664            return Err(crate::error::PolymarketError::InvalidData(error_text));
665        }
666
667        let spreads: std::collections::HashMap<String, String> = response.json().await?;
668        Ok(spreads)
669    }
670
671    /// Get multiple orderbooks at once
672    ///
673    /// # Arguments
674    /// * `requests` - Array of batch token requests (max 500)
675    pub async fn get_orderbooks(&self, requests: Vec<BatchTokenRequest>) -> Result<Vec<Orderbook>> {
676        let url = format!("{}/books", CLOB_API_BASE);
677
678        let response = self.client.post(&url).json(&requests).send().await?;
679
680        if !response.status().is_success() {
681            let error_text = response
682                .text()
683                .await
684                .unwrap_or_else(|_| "Unknown error".to_string());
685            return Err(crate::error::PolymarketError::InvalidData(error_text));
686        }
687
688        let orderbooks: Vec<Orderbook> = response.json().await?;
689        Ok(orderbooks)
690    }
691
692    /// Get multiple market prices at once (batch)
693    ///
694    /// # Arguments
695    /// * `requests` - Array of batch token requests (max 500)
696    ///
697    /// # Returns
698    /// HashMap mapping token_id to TokenPrices (buy/sell prices)
699    pub async fn get_prices_batch(
700        &self,
701        requests: Vec<BatchTokenRequest>,
702    ) -> Result<std::collections::HashMap<String, TokenPrices>> {
703        let url = format!("{}/prices", CLOB_API_BASE);
704
705        let response = self.client.post(&url).json(&requests).send().await?;
706
707        if !response.status().is_success() {
708            let error_text = response
709                .text()
710                .await
711                .unwrap_or_else(|_| "Unknown error".to_string());
712            return Err(crate::error::PolymarketError::InvalidData(error_text));
713        }
714
715        let prices: std::collections::HashMap<String, TokenPrices> = response.json().await?;
716        Ok(prices)
717    }
718}
719
720impl Default for ClobClient {
721    fn default() -> Self {
722        Self::new()
723    }
724}
725
726type HmacSha256 = Hmac<Sha256>;
727
728/// Build HMAC-SHA256 signature for L2 authentication
729///
730/// The signature is created by concatenating: timestamp + method + requestPath + body (optional)
731/// Then signing with HMAC-SHA256 using the base64-decoded secret.
732fn build_hmac_signature(
733    secret: &str,
734    timestamp: i64,
735    method: &str,
736    request_path: &str,
737    body: Option<&str>,
738) -> std::result::Result<String, String> {
739    // Decode the base64 standard secret
740    let secret_bytes = STANDARD
741        .decode(secret)
742        .map_err(|e| format!("Failed to decode secret: {}", e))?;
743
744    // Build the message: timestamp + method + requestPath + body
745    let mut message = format!("{}{}{}", timestamp, method, request_path);
746    if let Some(body) = body {
747        message.push_str(body);
748    }
749
750    // Create HMAC-SHA256
751    let mut mac = HmacSha256::new_from_slice(&secret_bytes)
752        .map_err(|e| format!("Failed to create HMAC: {}", e))?;
753    mac.update(message.as_bytes());
754
755    // Get the signature and encode as base64 standard
756    let result = mac.finalize();
757    let signature = STANDARD.encode(result.into_bytes());
758
759    Ok(signature)
760}
761
762/// L2 authentication headers for CLOB API requests
763pub struct L2Headers {
764    pub api_key: String,
765    pub signature: String,
766    pub timestamp: i64,
767    pub passphrase: String,
768    pub address: String,
769}
770
771impl L2Headers {
772    /// Create L2 authentication headers
773    pub fn new(
774        api_key: &str,
775        secret: &str,
776        passphrase: &str,
777        address: &str,
778        method: &str,
779        request_path: &str,
780        body: Option<&str>,
781    ) -> std::result::Result<Self, String> {
782        let timestamp = SystemTime::now()
783            .duration_since(UNIX_EPOCH)
784            .map_err(|e| format!("Failed to get timestamp: {}", e))?
785            .as_secs() as i64;
786
787        let signature = build_hmac_signature(secret, timestamp, method, request_path, body)?;
788
789        Ok(Self {
790            api_key: api_key.to_string(),
791            signature,
792            timestamp,
793            passphrase: passphrase.to_string(),
794            address: address.to_string(),
795        })
796    }
797
798    /// Convert to reqwest HeaderMap
799    /// Uses underscore format as per Polymarket API spec: POLY_API_KEY, POLY_SIGNATURE, etc.
800    pub fn to_header_map(&self) -> HeaderMap {
801        let mut headers = HeaderMap::new();
802        headers.insert(
803            "POLY_ADDRESS",
804            HeaderValue::from_str(&self.address).unwrap_or_else(|_| HeaderValue::from_static("")),
805        );
806        headers.insert(
807            "POLY_API_KEY",
808            HeaderValue::from_str(&self.api_key).unwrap_or_else(|_| HeaderValue::from_static("")),
809        );
810        headers.insert(
811            "POLY_SIGNATURE",
812            HeaderValue::from_str(&self.signature).unwrap_or_else(|_| HeaderValue::from_static("")),
813        );
814        headers.insert(
815            "POLY_TIMESTAMP",
816            HeaderValue::from_str(&self.timestamp.to_string())
817                .unwrap_or_else(|_| HeaderValue::from_static("")),
818        );
819        headers.insert(
820            "POLY_PASSPHRASE",
821            HeaderValue::from_str(&self.passphrase)
822                .unwrap_or_else(|_| HeaderValue::from_static("")),
823        );
824        headers
825    }
826}