polymarket_api/
gamma.rs

1use {
2    crate::{cache::FileCache, error::Result},
3    serde::{Deserialize, Deserializer, Serialize},
4};
5
6/// Macro for conditional info logging based on tracing feature
7#[cfg(feature = "tracing")]
8macro_rules! log_info {
9    ($($arg:tt)*) => { tracing::info!($($arg)*) };
10}
11
12#[cfg(not(feature = "tracing"))]
13macro_rules! log_info {
14    ($($arg:tt)*) => {};
15}
16
17/// Macro for conditional debug logging based on tracing feature
18#[cfg(feature = "tracing")]
19macro_rules! log_debug {
20    ($($arg:tt)*) => { tracing::debug!($($arg)*) };
21}
22
23#[cfg(not(feature = "tracing"))]
24macro_rules! log_debug {
25    ($($arg:tt)*) => {};
26}
27
28/// Macro for conditional warn logging based on tracing feature
29#[cfg(feature = "tracing")]
30macro_rules! log_warn {
31    ($($arg:tt)*) => { tracing::warn!($($arg)*) };
32}
33
34#[cfg(not(feature = "tracing"))]
35macro_rules! log_warn {
36    ($($arg:tt)*) => {};
37}
38
39/// Macro for conditional error logging based on tracing feature
40#[cfg(feature = "tracing")]
41macro_rules! log_error {
42    ($($arg:tt)*) => { tracing::error!($($arg)*) };
43}
44
45#[cfg(not(feature = "tracing"))]
46macro_rules! log_error {
47    ($($arg:tt)*) => {};
48}
49
50const GAMMA_API_BASE: &str = "https://gamma-api.polymarket.com";
51
52// Helper function to deserialize clobTokenIds which can be either a JSON string or an array
53fn deserialize_clob_token_ids<'de, D>(
54    deserializer: D,
55) -> std::result::Result<Option<Vec<String>>, D::Error>
56where
57    D: Deserializer<'de>,
58{
59    use serde::de::Error;
60
61    // First try to deserialize as Option
62    let opt: Option<serde_json::Value> = Option::deserialize(deserializer)?;
63
64    let value = match opt {
65        Some(v) => v,
66        None => return Ok(None),
67    };
68
69    if value.is_null() {
70        return Ok(None);
71    }
72
73    match value {
74        serde_json::Value::String(s) => {
75            // It's a JSON string, parse it
76            serde_json::from_str(&s).map(Some).map_err(Error::custom)
77        },
78        serde_json::Value::Array(arr) => {
79            // It's already an array, convert it
80            Ok(Some(
81                arr.into_iter()
82                    .map(|v| {
83                        if let serde_json::Value::String(s) = v {
84                            s
85                        } else {
86                            v.to_string()
87                        }
88                    })
89                    .collect(),
90            ))
91        },
92        _ => Ok(None),
93    }
94}
95
96// Helper function to deserialize outcomes/outcomePrices which can be either a JSON string or an array
97fn deserialize_string_array<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
98where
99    D: Deserializer<'de>,
100{
101    use serde::de::Error;
102
103    let value: serde_json::Value = serde_json::Value::deserialize(deserializer)?;
104
105    match value {
106        serde_json::Value::String(s) => {
107            // It's a JSON string, parse it
108            serde_json::from_str(&s).map_err(Error::custom)
109        },
110        serde_json::Value::Array(arr) => {
111            // It's already an array, convert it
112            Ok(arr
113                .into_iter()
114                .map(|v| {
115                    if let serde_json::Value::String(s) = v {
116                        s
117                    } else {
118                        v.to_string()
119                    }
120                })
121                .collect())
122        },
123        _ => Ok(vec![]),
124    }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct Event {
129    pub id: String,
130    pub slug: String,
131    pub title: String,
132    pub active: bool,
133    pub closed: bool,
134    #[serde(default)]
135    pub tags: Vec<Tag>,
136    pub markets: Vec<Market>,
137    #[serde(rename = "endDate", default)]
138    pub end_date: Option<String>, // ISO 8601 date string
139    #[serde(default)]
140    pub image: Option<String>, // URL to event image/thumbnail
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct Tag {
145    pub id: String,
146    pub label: String,
147    pub slug: String,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct Market {
152    #[serde(default)]
153    pub id: Option<String>,
154    pub question: String,
155    /// Short display name for grouped markets (e.g., "400-419" for tweet count ranges)
156    #[serde(rename = "groupItemTitle", default)]
157    pub group_item_title: Option<String>,
158    #[serde(
159        rename = "clobTokenIds",
160        deserialize_with = "deserialize_clob_token_ids",
161        default
162    )]
163    pub clob_token_ids: Option<Vec<String>>,
164    #[serde(deserialize_with = "deserialize_string_array", default)]
165    pub outcomes: Vec<String>,
166    #[serde(
167        rename = "outcomePrices",
168        deserialize_with = "deserialize_string_array",
169        default
170    )]
171    pub outcome_prices: Vec<String>,
172    #[serde(rename = "volume24hr", default)]
173    pub volume_24hr: Option<f64>,
174    #[serde(rename = "volumeTotal", default)]
175    pub volume_total: Option<f64>,
176    /// Whether the market is active (accepting new trades)
177    #[serde(default)]
178    pub active: bool,
179    /// Whether the market has been closed/resolved
180    #[serde(default)]
181    pub closed: bool,
182    /// Market slug for URL construction
183    #[serde(default)]
184    pub slug: Option<String>,
185    /// Whether the market is accepting orders
186    #[serde(rename = "acceptingOrders", default)]
187    pub accepting_orders: bool,
188    /// UMA oracle resolution statuses (JSON string like "[\"proposed\", \"disputed\"]")
189    #[serde(rename = "umaResolutionStatuses", default)]
190    pub uma_resolution_statuses: Option<String>,
191    /// Events this market belongs to (always 0 or 1 element)
192    #[serde(default)]
193    pub events: Vec<MarketEventRef>,
194}
195
196impl Market {
197    /// Get the event this market belongs to (markets have at most one event)
198    pub fn event(&self) -> Option<&MarketEventRef> {
199        self.events.first()
200    }
201
202    /// Check if market is in resolution/review process
203    pub fn is_in_review(&self) -> bool {
204        if let Some(ref statuses) = self.uma_resolution_statuses {
205            statuses.contains("proposed") || statuses.contains("disputed")
206        } else {
207            false
208        }
209    }
210
211    /// Get human-readable status string
212    pub fn status(&self) -> &'static str {
213        if self.closed {
214            "closed"
215        } else if self.is_in_review() {
216            "in-review"
217        } else if self.active {
218            "open"
219        } else {
220            "paused"
221        }
222    }
223}
224
225/// Lightweight event reference embedded in market responses
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct MarketEventRef {
228    pub id: String,
229    pub slug: String,
230    pub title: String,
231    #[serde(rename = "endDate")]
232    pub end_date: Option<String>,
233    #[serde(default)]
234    pub active: bool,
235    #[serde(default)]
236    pub closed: bool,
237}
238
239impl MarketEventRef {
240    /// Get human-readable status string
241    pub fn status(&self) -> &'static str {
242        if self.closed {
243            "closed"
244        } else if self.active {
245            "active"
246        } else {
247            "inactive"
248        }
249    }
250}
251
252pub struct GammaClient {
253    client: reqwest::Client,
254    cache: Option<FileCache>,
255}
256
257impl GammaClient {
258    pub fn new() -> Self {
259        Self {
260            client: reqwest::Client::new(),
261            cache: None,
262        }
263    }
264
265    /// Create a new GammaClient with file-based caching
266    pub fn with_cache<P: AsRef<std::path::Path>>(cache_dir: P) -> Result<Self> {
267        let cache = FileCache::new(cache_dir)?;
268        Ok(Self {
269            client: reqwest::Client::new(),
270            cache: Some(cache),
271        })
272    }
273
274    /// Set cache TTL (time to live) in seconds
275    pub fn set_cache_ttl(&mut self, ttl_seconds: u64) -> Result<()> {
276        if let Some(ref mut cache) = self.cache {
277            *cache = cache.clone().with_default_ttl(ttl_seconds);
278        }
279        Ok(())
280    }
281
282    /// Set cache for this client
283    pub fn set_cache(&mut self, cache: FileCache) {
284        self.cache = Some(cache);
285    }
286
287    pub async fn get_active_events(&self, limit: Option<usize>) -> Result<Vec<Event>> {
288        let limit = limit.unwrap_or(100);
289        let url = format!(
290            "{}/events?active=true&closed=false&limit={}",
291            GAMMA_API_BASE, limit
292        );
293        let events: Vec<Event> = self.client.get(&url).send().await?.json().await?;
294        Ok(events)
295    }
296
297    /// Get trending events ordered by trading volume
298    ///
299    /// # Arguments
300    /// * `order_by` - Field to order by (e.g., "volume24hr", "volume7d", "volume30d")
301    /// * `ascending` - If true, sort ascending; if false, sort descending
302    /// * `limit` - Maximum number of events to return
303    pub async fn get_trending_events(
304        &self,
305        order_by: Option<&str>,
306        ascending: Option<bool>,
307        limit: Option<usize>,
308    ) -> Result<Vec<Event>> {
309        let limit = limit.unwrap_or(50);
310        let order_by = order_by.unwrap_or("volume24hr");
311        let ascending = ascending.unwrap_or(false);
312
313        let url = format!(
314            "{}/events?active=true&closed=false&order={}&ascending={}&limit={}",
315            GAMMA_API_BASE, order_by, ascending, limit
316        );
317
318        log_info!("GET {}", url);
319
320        let response = self.client.get(&url).send().await?;
321        let _status = response.status();
322
323        log_info!("GET {} -> status: {}", url, _status);
324
325        let events: Vec<Event> = response.json().await?;
326        Ok(events)
327    }
328
329    pub async fn get_market_by_slug(&self, slug: &str) -> Result<Vec<Market>> {
330        let url = format!("{}/markets?slug={}", GAMMA_API_BASE, slug);
331        let response: serde_json::Value = self.client.get(&url).send().await?.json().await?;
332
333        // The API might return a single market or an array
334        let markets = if response.is_array() {
335            serde_json::from_value(response)?
336        } else {
337            vec![serde_json::from_value(response)?]
338        };
339
340        Ok(markets)
341    }
342
343    pub async fn get_all_active_asset_ids(&self) -> Result<Vec<String>> {
344        let events = self.get_active_events(None).await?;
345        let mut asset_ids = Vec::new();
346
347        for event in events {
348            for market in event.markets {
349                if let Some(token_ids) = market.clob_token_ids {
350                    asset_ids.extend(token_ids);
351                }
352            }
353        }
354
355        Ok(asset_ids)
356    }
357
358    /// Get event by ID
359    pub async fn get_event_by_id(&self, event_id: &str) -> Result<Option<Event>> {
360        let url = format!("{}/events/{}", GAMMA_API_BASE, event_id);
361        let response = self.client.get(&url).send().await?;
362
363        if response.status() == 404 {
364            return Ok(None);
365        }
366
367        let event: Event = response.json().await?;
368        Ok(Some(event))
369    }
370
371    /// Get event by slug
372    pub async fn get_event_by_slug(&self, slug: &str) -> Result<Option<Event>> {
373        let url = format!("{}/events?slug={}", GAMMA_API_BASE, slug);
374        let events: Vec<Event> = self.client.get(&url).send().await?.json().await?;
375        Ok(events.into_iter().next())
376    }
377
378    /// Get market by ID
379    pub async fn get_market_by_id(&self, market_id: &str) -> Result<Option<Market>> {
380        let url = format!("{}/markets/{}", GAMMA_API_BASE, market_id);
381        let response = self.client.get(&url).send().await?;
382
383        if response.status() == 404 {
384            return Ok(None);
385        }
386
387        let market: Market = response.json().await?;
388        Ok(Some(market))
389    }
390
391    /// Get all markets (with optional filters)
392    pub async fn get_markets(
393        &self,
394        active: Option<bool>,
395        closed: Option<bool>,
396        limit: Option<usize>,
397    ) -> Result<Vec<Market>> {
398        let url = format!("{}/markets", GAMMA_API_BASE);
399        let mut params = Vec::new();
400
401        if let Some(active) = active {
402            params.push(("active", active.to_string()));
403        }
404        if let Some(closed) = closed {
405            params.push(("closed", closed.to_string()));
406        }
407        if let Some(limit) = limit {
408            params.push(("limit", limit.to_string()));
409        }
410
411        let markets: Vec<Market> = self
412            .client
413            .get(&url)
414            .query(&params)
415            .send()
416            .await?
417            .json()
418            .await?;
419        Ok(markets)
420    }
421
422    /// Get categories/tags
423    pub async fn get_categories(&self) -> Result<Vec<Tag>> {
424        let url = format!("{}/categories", GAMMA_API_BASE);
425        let categories: Vec<Tag> = self.client.get(&url).send().await?.json().await?;
426        Ok(categories)
427    }
428
429    /// Get events by category/tag
430    pub async fn get_events_by_category(
431        &self,
432        category_slug: &str,
433        limit: Option<usize>,
434    ) -> Result<Vec<Event>> {
435        let limit = limit.unwrap_or(100);
436        let url = format!(
437            "{}/events?category={}&limit={}",
438            GAMMA_API_BASE, category_slug, limit
439        );
440        let events: Vec<Event> = self.client.get(&url).send().await?.json().await?;
441        Ok(events)
442    }
443
444    /// Search events by query string using the public-search endpoint
445    pub async fn search_events(&self, query: &str, limit: Option<usize>) -> Result<Vec<Event>> {
446        let limit_per_type = limit.unwrap_or(50);
447        let url = format!(
448            "{}/public-search?q={}&optimized=true&limit_per_type={}&type=events&search_tags=true&search_profiles=true&cache=true",
449            GAMMA_API_BASE,
450            urlencoding::encode(query),
451            limit_per_type
452        );
453
454        // Log the API call
455        log_info!("GET {}", url);
456
457        let response = self.client.get(&url).send().await.inspect_err(|_e| {
458            log_error!("Failed to send search request: {}", _e);
459        })?;
460
461        let status = response.status();
462        log_info!("GET {} -> status: {}", url, status);
463
464        let response_text = response.text().await.inspect_err(|_e| {
465            log_error!("Failed to read search response body: {}", _e);
466        })?;
467
468        // Only log response body on error or in debug mode
469        if !status.is_success() {
470            log_debug!(
471                "Search API response body (first 500 chars): {}",
472                if response_text.len() > 500 {
473                    &response_text[..500]
474                } else {
475                    &response_text
476                }
477            );
478        }
479
480        if !status.is_success() {
481            log_warn!(
482                "Search API error: status={}, body={}",
483                status,
484                response_text
485            );
486            return Err(crate::error::PolymarketError::InvalidData(format!(
487                "Search API returned status {}: {}",
488                status, response_text
489            )));
490        }
491
492        #[derive(Deserialize)]
493        struct SearchResponse {
494            events: Vec<Event>,
495            #[allow(dead_code)]
496            profiles: Option<serde_json::Value>,
497            #[allow(dead_code)]
498            tags: Option<serde_json::Value>,
499            #[allow(dead_code)]
500            has_more: Option<bool>,
501        }
502
503        let search_response: SearchResponse =
504            serde_json::from_str(&response_text).map_err(|e| {
505                log_error!(
506                    "Failed to parse search response: {}, body (first 1000 chars): {}",
507                    e,
508                    if response_text.len() > 1000 {
509                        &response_text[..1000]
510                    } else {
511                        &response_text
512                    }
513                );
514                crate::error::PolymarketError::Serialization(e)
515            })?;
516
517        log_info!("Search returned {} events", search_response.events.len());
518
519        // The search endpoint doesn't return volume data, so we need to fetch
520        // full event details for each result to get market volumes
521        let mut full_events = Vec::with_capacity(search_response.events.len());
522        for event in &search_response.events {
523            match self.get_event_by_slug(&event.slug).await {
524                Ok(Some(full_event)) => full_events.push(full_event),
525                Ok(None) => {
526                    // Event not found, use the search result as-is
527                    log_debug!("Event not found by slug: {}", event.slug);
528                    full_events.push(event.clone());
529                },
530                Err(_e) => {
531                    // Failed to fetch, use the search result as-is
532                    log_debug!("Failed to fetch event {}: {}", event.slug, _e);
533                    full_events.push(event.clone());
534                },
535            }
536        }
537
538        log_info!(
539            "Enriched {} search results with full event data",
540            full_events.len()
541        );
542
543        Ok(full_events)
544    }
545
546    pub async fn get_market_info_by_asset_id(&self, asset_id: &str) -> Result<Option<MarketInfo>> {
547        // Check cache first
548        if let Some(ref cache) = self.cache {
549            let cache_key = format!("market_info_{}", asset_id);
550            if let Some(cached_info) = cache.get::<MarketInfo>(&cache_key)? {
551                return Ok(Some(cached_info));
552            }
553        }
554
555        let events = self.get_active_events(Some(1000)).await?;
556
557        for event in events {
558            for market in event.markets {
559                if let Some(ref token_ids) = market.clob_token_ids
560                    && token_ids.contains(&asset_id.to_string())
561                {
562                    let outcomes = market.outcomes.clone();
563                    let prices = market.outcome_prices.clone();
564
565                    let market_info = MarketInfo {
566                        event_title: event.title,
567                        event_slug: event.slug,
568                        market_question: market.question,
569                        market_id: market.id.clone().unwrap_or_default(),
570                        asset_id: asset_id.to_string(),
571                        outcomes,
572                        prices,
573                    };
574
575                    // Cache the result
576                    if let Some(ref cache) = self.cache {
577                        let cache_key = format!("market_info_{}", asset_id);
578                        let _ = cache.set(&cache_key, &market_info);
579                    }
580
581                    return Ok(Some(market_info));
582                }
583            }
584        }
585
586        Ok(None)
587    }
588
589    /// Check API health status
590    pub async fn get_status(&self) -> Result<StatusResponse> {
591        let url = format!("{}/status", GAMMA_API_BASE);
592        let status: StatusResponse = self.client.get(&url).send().await?.json().await?;
593        Ok(status)
594    }
595
596    /// Get tag by ID
597    pub async fn get_tag_by_id(&self, tag_id: &str) -> Result<Option<Tag>> {
598        let url = format!("{}/tags/{}", GAMMA_API_BASE, tag_id);
599        let response = self.client.get(&url).send().await?;
600
601        if response.status() == 404 {
602            return Ok(None);
603        }
604
605        let tag: Tag = response.json().await?;
606        Ok(Some(tag))
607    }
608
609    /// Get tag by slug
610    pub async fn get_tag_by_slug(&self, slug: &str) -> Result<Option<Tag>> {
611        let url = format!("{}/tags/slug/{}", GAMMA_API_BASE, slug);
612        let response = self.client.get(&url).send().await?;
613
614        if response.status() == 404 {
615            return Ok(None);
616        }
617
618        let tag: Tag = response.json().await?;
619        Ok(Some(tag))
620    }
621
622    /// Get related tags for a tag ID
623    pub async fn get_related_tags(&self, tag_id: &str) -> Result<Vec<Tag>> {
624        let url = format!("{}/tags/{}/related-tags", GAMMA_API_BASE, tag_id);
625        let tags: Vec<Tag> = self.client.get(&url).send().await?.json().await?;
626        Ok(tags)
627    }
628
629    /// Get all series
630    pub async fn get_series(&self, limit: Option<usize>) -> Result<Vec<Series>> {
631        let limit = limit.unwrap_or(100);
632        let url = format!("{}/series?limit={}", GAMMA_API_BASE, limit);
633        let series: Vec<Series> = self.client.get(&url).send().await?.json().await?;
634        Ok(series)
635    }
636
637    /// Get series by ID
638    pub async fn get_series_by_id(&self, series_id: &str) -> Result<Option<Series>> {
639        let url = format!("{}/series/{}", GAMMA_API_BASE, series_id);
640        let response = self.client.get(&url).send().await?;
641
642        if response.status() == 404 {
643            return Ok(None);
644        }
645
646        let series: Series = response.json().await?;
647        Ok(Some(series))
648    }
649
650    /// Get public profile by wallet address
651    pub async fn get_public_profile(&self, address: &str) -> Result<Option<PublicProfile>> {
652        let url = format!("{}/public-profile", GAMMA_API_BASE);
653        let params = [("address", address)];
654        let response = self.client.get(&url).query(&params).send().await?;
655
656        if response.status() == 404 {
657            return Ok(None);
658        }
659
660        let profile: PublicProfile = response.json().await?;
661        Ok(Some(profile))
662    }
663
664    /// Get tags for a specific event
665    pub async fn get_event_tags(&self, event_id: &str) -> Result<Vec<Tag>> {
666        let url = format!("{}/events/{}/tags", GAMMA_API_BASE, event_id);
667        let tags: Vec<Tag> = self.client.get(&url).send().await?.json().await?;
668        Ok(tags)
669    }
670
671    /// Get tags for a specific market
672    pub async fn get_market_tags(&self, market_id: &str) -> Result<Vec<Tag>> {
673        let url = format!("{}/markets/{}/tags", GAMMA_API_BASE, market_id);
674        let tags: Vec<Tag> = self.client.get(&url).send().await?.json().await?;
675        Ok(tags)
676    }
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize)]
680pub struct MarketInfo {
681    pub event_title: String,
682    pub event_slug: String,
683    pub market_question: String,
684    pub market_id: String,
685    pub asset_id: String,
686    pub outcomes: Vec<String>,
687    pub prices: Vec<String>,
688}
689
690/// API status response
691#[derive(Debug, Clone, Serialize, Deserialize)]
692pub struct StatusResponse {
693    pub status: String,
694}
695
696/// Series information
697#[derive(Debug, Clone, Serialize, Deserialize)]
698pub struct Series {
699    pub id: String,
700    #[serde(default)]
701    pub title: Option<String>,
702    #[serde(default)]
703    pub slug: Option<String>,
704    #[serde(default)]
705    pub description: Option<String>,
706}
707
708/// Public profile information
709#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct PublicProfile {
711    #[serde(default)]
712    pub address: Option<String>,
713    #[serde(default)]
714    pub name: Option<String>,
715    #[serde(default)]
716    pub pseudonym: Option<String>,
717    #[serde(default)]
718    pub bio: Option<String>,
719    #[serde(rename = "profileImage", default)]
720    pub profile_image: Option<String>,
721    #[serde(rename = "profileImageOptimized", default)]
722    pub profile_image_optimized: Option<String>,
723}
724
725impl Default for GammaClient {
726    fn default() -> Self {
727        Self::new()
728    }
729}