Skip to main content

nautilus_polymarket/
providers.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Instrument provider for the Polymarket adapter.
17
18use std::{collections::HashMap, fmt::Debug, sync::Arc};
19
20use ahash::{AHashMap, AHashSet};
21use async_trait::async_trait;
22use nautilus_common::providers::{InstrumentProvider, InstrumentStore};
23use nautilus_model::{
24    identifiers::InstrumentId,
25    instruments::{Instrument, InstrumentAny},
26};
27use ustr::Ustr;
28
29use crate::{
30    common::consts::GAMMA_CONDITION_IDS_BATCH_SIZE,
31    config::PolymarketInstrumentProviderConfig,
32    filters::InstrumentFilter,
33    http::{gamma::PolymarketGammaHttpClient, models::GammaTag, query::GetGammaMarketsParams},
34};
35
36/// Provides Polymarket instruments via the Gamma API.
37///
38/// Wraps [`PolymarketGammaHttpClient`] with an [`InstrumentStore`] and a
39/// token_id index for resolving WebSocket asset IDs to instruments.
40///
41/// Optional [`InstrumentFilter`]s control which instruments are loaded
42/// during `load_all()`. Without filters, all active markets are fetched.
43pub struct PolymarketInstrumentProvider {
44    store: InstrumentStore,
45    http_client: PolymarketGammaHttpClient,
46    token_index: AHashMap<Ustr, InstrumentId>,
47    filters: Vec<Arc<dyn InstrumentFilter>>,
48    config: PolymarketInstrumentProviderConfig,
49}
50
51impl Debug for PolymarketInstrumentProvider {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct(stringify!(PolymarketInstrumentProvider))
54            .field("store", &self.store)
55            .field("http_client", &self.http_client)
56            .field("token_index_len", &self.token_index.len())
57            .field("filters", &self.filters)
58            .field("config", &self.config)
59            .finish()
60    }
61}
62
63impl PolymarketInstrumentProvider {
64    /// Creates a new [`PolymarketInstrumentProvider`] with an empty store and no filters.
65    #[must_use]
66    pub fn new(
67        http_client: PolymarketGammaHttpClient,
68        config: Option<PolymarketInstrumentProviderConfig>,
69    ) -> Self {
70        Self {
71            store: InstrumentStore::new(),
72            http_client,
73            token_index: AHashMap::new(),
74            filters: Vec::new(),
75            config: config.unwrap_or_default(),
76        }
77    }
78
79    /// Creates a new [`PolymarketInstrumentProvider`] with multiple filters.
80    #[must_use]
81    pub fn with_filters(
82        http_client: PolymarketGammaHttpClient,
83        config: Option<PolymarketInstrumentProviderConfig>,
84        filters: Vec<Arc<dyn InstrumentFilter>>,
85    ) -> Self {
86        Self {
87            store: InstrumentStore::new(),
88            http_client,
89            token_index: AHashMap::new(),
90            filters,
91            config: config.unwrap_or_default(),
92        }
93    }
94
95    /// Creates a new [`PolymarketInstrumentProvider`] with a single filter.
96    #[must_use]
97    pub fn with_filter(
98        http_client: PolymarketGammaHttpClient,
99        config: Option<PolymarketInstrumentProviderConfig>,
100        filter: Arc<dyn InstrumentFilter>,
101    ) -> Self {
102        Self {
103            store: InstrumentStore::new(),
104            http_client,
105            token_index: AHashMap::new(),
106            filters: vec![filter],
107            config: config.unwrap_or_default(),
108        }
109    }
110
111    /// Adds an instrument filter for subsequent `load_all()` calls.
112    pub fn add_filter(&mut self, filter: Arc<dyn InstrumentFilter>) {
113        self.filters.push(filter);
114    }
115
116    /// Clears all instrument filters, reverting to bulk load behavior.
117    pub fn clear_filters(&mut self) {
118        self.filters.clear();
119    }
120
121    /// Returns the instrument for the given token ID, if found.
122    #[must_use]
123    pub fn get_by_token_id(&self, token_id: &Ustr) -> Option<&InstrumentAny> {
124        let instrument_id = self.token_index.get(token_id)?;
125        self.store.find(instrument_id)
126    }
127
128    /// Builds a frozen snapshot mapping token IDs to instruments.
129    ///
130    /// Used to provide the WS handler task with a read-only lookup
131    /// table after instruments have been loaded.
132    #[must_use]
133    pub fn build_token_map(&self) -> AHashMap<Ustr, InstrumentAny> {
134        self.token_index
135            .iter()
136            .filter_map(|(token_id, instrument_id)| {
137                self.store
138                    .find(instrument_id)
139                    .map(|inst| (*token_id, inst.clone()))
140            })
141            .collect()
142    }
143
144    /// Loads instruments for the given slugs additively into the store.
145    ///
146    /// Unlike [`Self::load_all`], this does **not** clear existing instruments or
147    /// mark the store as initialized, allowing incremental loading of
148    /// slug-based markets alongside bulk data.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the HTTP request or parsing fails.
153    pub async fn load_by_slugs(&mut self, slugs: Vec<String>) -> anyhow::Result<()> {
154        let instruments = self.http_client.request_instruments_by_slugs(slugs).await?;
155
156        for instrument in &instruments {
157            self.token_index.insert(
158                Ustr::from(instrument.raw_symbol().as_str()),
159                instrument.id(),
160            );
161        }
162
163        self.store.add_bulk(instruments);
164
165        Ok(())
166    }
167
168    /// Returns a clone of the configured instrument filters.
169    #[must_use]
170    pub fn filters(&self) -> Vec<Arc<dyn InstrumentFilter>> {
171        self.filters.clone()
172    }
173
174    /// Returns a reference to the underlying HTTP client.
175    #[must_use]
176    pub fn http_client(&self) -> &PolymarketGammaHttpClient {
177        &self.http_client
178    }
179
180    /// Returns the configured provider config.
181    #[must_use]
182    pub fn config(&self) -> &PolymarketInstrumentProviderConfig {
183        &self.config
184    }
185
186    /// Fetches available tags from the Gamma API.
187    pub async fn list_tags(&self) -> anyhow::Result<Vec<GammaTag>> {
188        self.http_client.request_tags().await
189    }
190
191    pub fn add_instruments(&mut self, instruments: Vec<InstrumentAny>) {
192        for inst in &instruments {
193            self.token_index
194                .insert(Ustr::from(inst.raw_symbol().as_str()), inst.id());
195        }
196        self.store.add_bulk(instruments);
197    }
198
199    /// Loads instruments for the given event slugs additively into the store.
200    ///
201    /// Unlike [`Self::load_all`], this does **not** clear existing instruments or
202    /// mark the store as initialized, allowing incremental loading of
203    /// event-scoped markets alongside bulk data.
204    pub async fn load_by_event_slugs(&mut self, slugs: Vec<String>) -> anyhow::Result<()> {
205        let instruments = self
206            .http_client
207            .request_instruments_by_event_slugs(slugs)
208            .await?;
209        self.add_instruments(instruments);
210        Ok(())
211    }
212
213    /// Initializes the provider using its configured bootstrap scope.
214    pub async fn initialize(&mut self, reload: bool) -> anyhow::Result<()> {
215        if self.store.is_initialized() && !reload {
216            return Ok(());
217        }
218
219        if self.config.should_load_all() {
220            self.load_scoped_all().await?;
221            self.store.set_initialized();
222            return Ok(());
223        }
224
225        if self.config.has_load_ids() {
226            let load_ids = self.config.load_ids.clone().unwrap_or_default();
227            let filters = self.config.filters.clone();
228            self.load_ids(&load_ids, filters.as_ref()).await?;
229            self.store.set_initialized();
230            return Ok(());
231        }
232
233        if self.config.log_warnings {
234            log::warn!(
235                "No Polymarket instrument bootstrap configured: set instrument_config.load_all, instrument_config.load_ids, instrument_config.event_slugs, instrument_config.market_slugs, or instrument_config.event_slug_builder"
236            );
237        }
238        Ok(())
239    }
240
241    async fn load_scoped_all(&mut self) -> anyhow::Result<()> {
242        let has_explicit_slug_scope = self.config.event_slug_builder.is_some()
243            || self
244                .config
245                .event_slugs
246                .as_ref()
247                .is_some_and(|slugs| !slugs.is_empty())
248            || self
249                .config
250                .market_slugs
251                .as_ref()
252                .is_some_and(|slugs| !slugs.is_empty());
253        let event_slugs = self.resolve_event_slugs()?;
254        let market_slugs = self
255            .config
256            .market_slugs
257            .clone()
258            .unwrap_or_default()
259            .into_iter()
260            .filter(|slug| !slug.trim().is_empty())
261            .collect::<Vec<_>>();
262
263        if !event_slugs.is_empty() {
264            self.load_by_event_slugs(event_slugs).await?;
265        }
266
267        if !market_slugs.is_empty() {
268            self.load_by_slugs(market_slugs).await?;
269        }
270
271        if has_explicit_slug_scope {
272            return Ok(());
273        }
274
275        let filters = self.config.filters.clone();
276        self.load_all(filters.as_ref()).await
277    }
278
279    fn resolve_event_slugs(&self) -> anyhow::Result<Vec<String>> {
280        if let Some(builder) = self.config.event_slug_builder.as_ref() {
281            return builder.build_event_slugs();
282        }
283
284        Ok(self
285            .config
286            .event_slugs
287            .clone()
288            .unwrap_or_default()
289            .into_iter()
290            .filter(|slug| !slug.trim().is_empty())
291            .collect())
292    }
293
294    /// Loads instruments using all configured filters, combining results from
295    /// each filter's methods that return `Some`.
296    async fn load_filtered(&self) -> anyhow::Result<Vec<InstrumentAny>> {
297        fetch_instruments(&self.http_client, &self.filters).await
298    }
299}
300
301/// Fetches instruments from the Gamma API, respecting any configured filters.
302pub async fn fetch_instruments(
303    http_client: &PolymarketGammaHttpClient,
304    filters: &[Arc<dyn InstrumentFilter>],
305) -> anyhow::Result<Vec<InstrumentAny>> {
306    if filters.is_empty() {
307        return http_client.request_instruments().await;
308    }
309
310    let mut instruments = Vec::new();
311
312    for filter in filters {
313        if let Some(slugs) = filter.market_slugs()
314            && !slugs.is_empty()
315        {
316            let result = http_client.request_instruments_by_slugs(slugs).await?;
317            instruments.extend(result);
318        }
319
320        if let Some(event_slugs) = filter.event_slugs()
321            && !event_slugs.is_empty()
322        {
323            let result = http_client
324                .request_instruments_by_event_slugs(event_slugs)
325                .await?;
326            instruments.extend(result);
327        }
328
329        if let Some(params) = filter.query_params() {
330            let result = http_client.request_instruments_by_params(params).await?;
331            instruments.extend(result);
332        }
333
334        if let Some(event_queries) = filter.event_queries() {
335            for (event_slug, params) in event_queries {
336                let result = http_client
337                    .request_instruments_by_event_query(&event_slug, params)
338                    .await?;
339                instruments.extend(result);
340            }
341        }
342
343        if let Some(params) = filter.event_params() {
344            let result = http_client
345                .request_instruments_by_event_params(params)
346                .await?;
347            instruments.extend(result);
348        }
349
350        if let Some(params) = filter.search_params() {
351            let result = http_client.request_instruments_by_search(params).await?;
352            instruments.extend(result);
353        }
354    }
355
356    let mut seen = AHashSet::new();
357    instruments.retain(|inst| seen.insert(inst.id()));
358    instruments.retain(|inst| filters.iter().all(|f| f.accept(inst)));
359
360    Ok(instruments)
361}
362
363/// Fetches instruments using the configured provider bootstrap scope without
364/// mutating any provider state.
365pub async fn fetch_configured_instruments(
366    http_client: &PolymarketGammaHttpClient,
367    config: &PolymarketInstrumentProviderConfig,
368    filters: &[Arc<dyn InstrumentFilter>],
369) -> anyhow::Result<Vec<InstrumentAny>> {
370    let mut instruments = Vec::new();
371
372    if config.should_load_all() {
373        let has_explicit_slug_scope = config.event_slug_builder.is_some()
374            || config
375                .event_slugs
376                .as_ref()
377                .is_some_and(|slugs| !slugs.is_empty())
378            || config
379                .market_slugs
380                .as_ref()
381                .is_some_and(|slugs| !slugs.is_empty());
382        let event_slugs = if let Some(builder) = config.event_slug_builder.as_ref() {
383            builder.build_event_slugs()?
384        } else {
385            config
386                .event_slugs
387                .clone()
388                .unwrap_or_default()
389                .into_iter()
390                .filter(|slug| !slug.trim().is_empty())
391                .collect::<Vec<_>>()
392        };
393
394        let market_slugs = config
395            .market_slugs
396            .clone()
397            .unwrap_or_default()
398            .into_iter()
399            .filter(|slug| !slug.trim().is_empty())
400            .collect::<Vec<_>>();
401
402        if !event_slugs.is_empty() {
403            instruments.extend(
404                http_client
405                    .request_instruments_by_event_slugs(event_slugs)
406                    .await?,
407            );
408        }
409
410        if !market_slugs.is_empty() {
411            instruments.extend(
412                http_client
413                    .request_instruments_by_slugs(market_slugs)
414                    .await?,
415            );
416        }
417
418        if has_explicit_slug_scope {
419            // Explicit slug scoping should never broaden into a full-universe fetch.
420        } else if filters.is_empty() {
421            if let Some(map) = config.filters.as_ref() {
422                if map.is_empty() {
423                    instruments.extend(http_client.request_instruments().await?);
424                } else {
425                    let params = build_gamma_params_from_hashmap(map);
426                    instruments.extend(http_client.request_instruments_by_params(params).await?);
427                }
428            } else {
429                instruments.extend(http_client.request_instruments().await?);
430            }
431        } else {
432            instruments.extend(fetch_instruments(http_client, filters).await?);
433        }
434    } else if config.has_load_ids() {
435        let base_params = config
436            .filters
437            .as_ref()
438            .map(build_gamma_params_from_hashmap)
439            .unwrap_or_default();
440
441        let condition_ids = config
442            .load_ids
443            .clone()
444            .unwrap_or_default()
445            .into_iter()
446            .filter_map(|id| extract_condition_id(&id).ok())
447            .collect::<AHashSet<_>>()
448            .into_iter()
449            .collect::<Vec<_>>();
450
451        for chunk in condition_ids.chunks(GAMMA_CONDITION_IDS_BATCH_SIZE) {
452            let params = GetGammaMarketsParams {
453                condition_ids: Some(chunk.join(",")),
454                ..base_params.clone()
455            };
456            instruments.extend(http_client.request_instruments_by_params(params).await?);
457        }
458    }
459
460    let mut seen = AHashSet::new();
461    instruments.retain(|inst| seen.insert(inst.id()));
462    instruments.retain(|inst| filters.iter().all(|f| f.accept(inst)));
463    Ok(instruments)
464}
465
466/// Extracts the condition ID from an instrument symbol.
467///
468/// Polymarket instrument symbols follow the pattern `{condition_id}-{token_id}`.
469/// The condition_id is a hex string (e.g. `0xabc123...`) and the token_id is a
470/// large decimal number. This extracts the condition_id by splitting at the last `-`.
471pub fn extract_condition_id(instrument_id: &InstrumentId) -> anyhow::Result<String> {
472    let symbol = instrument_id.symbol.as_str();
473    symbol
474        .rfind('-')
475        .map(|idx| symbol[..idx].to_string())
476        .ok_or_else(|| {
477            anyhow::anyhow!("Cannot extract condition_id from symbol '{symbol}': no '-' separator")
478        })
479}
480
481/// Builds `GetGammaMarketsParams` from a `HashMap<String, String>`.
482pub fn build_gamma_params_from_hashmap(map: &HashMap<String, String>) -> GetGammaMarketsParams {
483    let mut params = GetGammaMarketsParams::default();
484
485    if let Some(v) = map.get("active") {
486        params.active = v.parse().ok();
487    }
488
489    if let Some(v) = map.get("closed") {
490        params.closed = v.parse().ok();
491    }
492
493    if let Some(v) = map.get("archived") {
494        params.archived = v.parse().ok();
495    }
496
497    if let Some(v) = map.get("slug") {
498        params.slug = Some(v.clone());
499    }
500
501    if let Some(v) = map.get("tag_id") {
502        params.tag_id = Some(v.clone());
503    }
504
505    if let Some(v) = map.get("condition_ids") {
506        params.condition_ids = Some(v.clone());
507    }
508
509    if let Some(v) = map.get("clob_token_ids") {
510        params.clob_token_ids = Some(v.clone());
511    }
512
513    if let Some(v) = map.get("liquidity_num_min") {
514        params.liquidity_num_min = v.parse().ok();
515    }
516
517    if let Some(v) = map.get("liquidity_num_max") {
518        params.liquidity_num_max = v.parse().ok();
519    }
520
521    if let Some(v) = map.get("volume_num_min") {
522        params.volume_num_min = v.parse().ok();
523    }
524
525    if let Some(v) = map.get("volume_num_max") {
526        params.volume_num_max = v.parse().ok();
527    }
528
529    if let Some(v) = map.get("order") {
530        params.order = Some(v.clone());
531    }
532
533    if let Some(v) = map.get("ascending") {
534        params.ascending = v.parse().ok();
535    }
536
537    if let Some(v) = map.get("limit") {
538        params.limit = v.parse().ok();
539    }
540
541    if let Some(v) = map.get("max_markets") {
542        params.max_markets = v.parse().ok();
543    }
544
545    params
546}
547
548/// Resolves a tag slug to a tag ID by querying the Gamma tags endpoint.
549pub async fn resolve_tag_slug(
550    client: &PolymarketGammaHttpClient,
551    slug: &str,
552) -> anyhow::Result<String> {
553    let tags = client.request_tags().await?;
554    tags.iter()
555        .find(|t| t.slug.as_deref() == Some(slug))
556        .map(|t| t.id.clone())
557        .ok_or_else(|| anyhow::anyhow!("Tag slug '{slug}' not found"))
558}
559
560#[async_trait(?Send)]
561impl InstrumentProvider for PolymarketInstrumentProvider {
562    fn store(&self) -> &InstrumentStore {
563        &self.store
564    }
565
566    fn store_mut(&mut self) -> &mut InstrumentStore {
567        &mut self.store
568    }
569
570    async fn load_all(&mut self, filters: Option<&HashMap<String, String>>) -> anyhow::Result<()> {
571        let instruments = if self.filters.is_empty() {
572            // If HashMap filters are provided, convert to Gamma params
573            if let Some(map) = filters {
574                if map.is_empty() {
575                    self.http_client.request_instruments().await?
576                } else {
577                    let params = build_gamma_params_from_hashmap(map);
578                    self.http_client
579                        .request_instruments_by_params(params)
580                        .await?
581                }
582            } else {
583                self.http_client.request_instruments().await?
584            }
585        } else {
586            self.load_filtered().await?
587        };
588
589        self.store.clear();
590        self.token_index.clear();
591        self.add_instruments(instruments);
592        self.store.set_initialized();
593
594        Ok(())
595    }
596
597    async fn load_ids(
598        &mut self,
599        instrument_ids: &[InstrumentId],
600        filters: Option<&HashMap<String, String>>,
601    ) -> anyhow::Result<()> {
602        let missing: Vec<_> = instrument_ids
603            .iter()
604            .filter(|id| !self.store.contains(id))
605            .collect();
606
607        if missing.is_empty() {
608            return Ok(());
609        }
610
611        // Extract unique condition IDs from instrument symbols
612        // Symbol format: "{condition_id}-{token_id}"
613        let mut condition_ids: Vec<String> = missing
614            .iter()
615            .filter_map(|id| extract_condition_id(id).ok())
616            .collect();
617        condition_ids.sort();
618        condition_ids.dedup();
619
620        if condition_ids.is_empty() {
621            return Ok(());
622        }
623
624        let base_params = filters
625            .map(build_gamma_params_from_hashmap)
626            .unwrap_or_default();
627
628        for chunk in condition_ids.chunks(GAMMA_CONDITION_IDS_BATCH_SIZE) {
629            let params = GetGammaMarketsParams {
630                condition_ids: Some(chunk.join(",")),
631                ..base_params.clone()
632            };
633            let instruments = self
634                .http_client
635                .request_instruments_by_params(params)
636                .await?;
637            self.add_instruments(instruments);
638        }
639
640        Ok(())
641    }
642
643    async fn load(
644        &mut self,
645        instrument_id: &InstrumentId,
646        filters: Option<&HashMap<String, String>>,
647    ) -> anyhow::Result<()> {
648        if self.store.contains(instrument_id) {
649            return Ok(());
650        }
651
652        // Try direct fetch via condition_id extracted from symbol
653        if let Ok(cid) = extract_condition_id(instrument_id) {
654            let params = GetGammaMarketsParams {
655                condition_ids: Some(cid),
656                ..Default::default()
657            };
658
659            if let Ok(instruments) = self.http_client.request_instruments_by_params(params).await {
660                self.add_instruments(instruments);
661
662                if self.store.contains(instrument_id) {
663                    return Ok(());
664                }
665            }
666        }
667
668        // Fallback: full load_all if not initialized
669        if !self.store.is_initialized() {
670            self.load_all(filters).await?;
671        }
672
673        if self.store.contains(instrument_id) {
674            Ok(())
675        } else {
676            anyhow::bail!("Instrument {instrument_id} not found on Polymarket")
677        }
678    }
679}