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 rust_decimal::Decimal;
28use ustr::Ustr;
29
30use crate::{
31    common::{consts::GAMMA_CONDITION_IDS_BATCH_SIZE, parse::parse_decimal_exact},
32    config::PolymarketInstrumentProviderConfig,
33    filters::InstrumentFilter,
34    http::{
35        gamma::PolymarketGammaHttpClient,
36        models::GammaTag,
37        query::{GetGammaEventsParams, GetGammaMarketsParams},
38    },
39};
40
41/// Provides Polymarket instruments via the Gamma API.
42///
43/// Wraps [`PolymarketGammaHttpClient`] with an [`InstrumentStore`] and a
44/// token_id index for resolving WebSocket asset IDs to instruments.
45///
46/// Optional [`InstrumentFilter`]s control which instruments are loaded
47/// during `load_all()`. Without filters, all active markets are fetched.
48pub struct PolymarketInstrumentProvider {
49    store: InstrumentStore,
50    http_client: PolymarketGammaHttpClient,
51    token_index: AHashMap<Ustr, InstrumentId>,
52    filters: Vec<Arc<dyn InstrumentFilter>>,
53    config: PolymarketInstrumentProviderConfig,
54}
55
56impl Debug for PolymarketInstrumentProvider {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct(stringify!(PolymarketInstrumentProvider))
59            .field("store", &self.store)
60            .field("http_client", &self.http_client)
61            .field("token_index_len", &self.token_index.len())
62            .field("filters", &self.filters)
63            .field("config", &self.config)
64            .finish()
65    }
66}
67
68impl PolymarketInstrumentProvider {
69    /// Creates a new [`PolymarketInstrumentProvider`] with an empty store and no filters.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if selections contain duplicates or Gamma filters are invalid.
74    pub fn new(
75        http_client: PolymarketGammaHttpClient,
76        config: Option<PolymarketInstrumentProviderConfig>,
77    ) -> anyhow::Result<Self> {
78        Self::with_filters(http_client, config, Vec::new())
79    }
80
81    /// Creates a new [`PolymarketInstrumentProvider`] with multiple filters.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if selections contain duplicates or Gamma filters are invalid.
86    pub fn with_filters(
87        http_client: PolymarketGammaHttpClient,
88        config: Option<PolymarketInstrumentProviderConfig>,
89        filters: Vec<Arc<dyn InstrumentFilter>>,
90    ) -> anyhow::Result<Self> {
91        let config = config.unwrap_or_default();
92        config.validate()?;
93
94        Ok(Self {
95            store: InstrumentStore::new(),
96            http_client,
97            token_index: AHashMap::new(),
98            filters,
99            config,
100        })
101    }
102
103    /// Creates a new [`PolymarketInstrumentProvider`] with a single filter.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if selections contain duplicates or Gamma filters are invalid.
108    pub fn with_filter(
109        http_client: PolymarketGammaHttpClient,
110        config: Option<PolymarketInstrumentProviderConfig>,
111        filter: Arc<dyn InstrumentFilter>,
112    ) -> anyhow::Result<Self> {
113        Self::with_filters(http_client, config, vec![filter])
114    }
115
116    /// Adds an instrument filter for subsequent `load_all()` calls.
117    pub fn add_filter(&mut self, filter: Arc<dyn InstrumentFilter>) {
118        self.filters.push(filter);
119    }
120
121    /// Clears all instrument filters, reverting to bulk load behavior.
122    pub fn clear_filters(&mut self) {
123        self.filters.clear();
124    }
125
126    /// Returns the instrument for the given token ID, if found.
127    #[must_use]
128    pub fn get_by_token_id(&self, token_id: &Ustr) -> Option<&InstrumentAny> {
129        let instrument_id = self.token_index.get(token_id)?;
130        self.store.find(instrument_id)
131    }
132
133    /// Builds a frozen snapshot mapping token IDs to instruments.
134    ///
135    /// Used to provide the WS handler task with a read-only lookup
136    /// table after instruments have been loaded.
137    #[must_use]
138    pub fn build_token_map(&self) -> AHashMap<Ustr, InstrumentAny> {
139        self.token_index
140            .iter()
141            .filter_map(|(token_id, instrument_id)| {
142                self.store
143                    .find(instrument_id)
144                    .map(|inst| (*token_id, inst.clone()))
145            })
146            .collect()
147    }
148
149    /// Loads instruments for the given slugs additively into the store.
150    ///
151    /// Unlike [`Self::load_all`], this does **not** clear existing instruments or
152    /// mark the store as initialized, allowing incremental loading of
153    /// slug-based markets alongside bulk data.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the HTTP request or parsing fails.
158    pub async fn load_by_slugs(&mut self, slugs: Vec<String>) -> anyhow::Result<()> {
159        let instruments = self.http_client.request_instruments_by_slugs(slugs).await?;
160
161        for instrument in &instruments {
162            self.token_index.insert(
163                Ustr::from(instrument.raw_symbol().as_str()),
164                instrument.id(),
165            );
166        }
167
168        self.store.add_bulk(instruments);
169
170        Ok(())
171    }
172
173    /// Returns a clone of the configured instrument filters.
174    #[must_use]
175    pub fn filters(&self) -> Vec<Arc<dyn InstrumentFilter>> {
176        self.filters.clone()
177    }
178
179    /// Returns a reference to the underlying HTTP client.
180    #[must_use]
181    pub fn http_client(&self) -> &PolymarketGammaHttpClient {
182        &self.http_client
183    }
184
185    /// Returns the configured provider config.
186    #[must_use]
187    pub fn config(&self) -> &PolymarketInstrumentProviderConfig {
188        &self.config
189    }
190
191    /// Fetches available tags from the Gamma API.
192    pub async fn list_tags(&self) -> anyhow::Result<Vec<GammaTag>> {
193        self.http_client.request_tags().await
194    }
195
196    pub fn add_instruments(&mut self, instruments: Vec<InstrumentAny>) {
197        for inst in &instruments {
198            self.token_index
199                .insert(Ustr::from(inst.raw_symbol().as_str()), inst.id());
200        }
201        self.store.add_bulk(instruments);
202    }
203
204    /// Loads instruments for the given event slugs additively into the store.
205    ///
206    /// Unlike [`Self::load_all`], this does **not** clear existing instruments or
207    /// mark the store as initialized, allowing incremental loading of
208    /// event-scoped markets alongside bulk data.
209    pub async fn load_by_event_slugs(&mut self, slugs: Vec<String>) -> anyhow::Result<()> {
210        let instruments = self
211            .http_client
212            .request_instruments_by_event_slugs(slugs)
213            .await?;
214        self.add_instruments(instruments);
215        Ok(())
216    }
217
218    /// Loads instruments for the given Gamma series IDs additively into the store.
219    ///
220    /// Resolves each series to its active, unresolved events and loads their
221    /// markets. Unlike [`Self::load_all`], this does **not** clear existing
222    /// instruments or mark the store as initialized.
223    pub async fn load_by_series_ids(&mut self, series_ids: Vec<u64>) -> anyhow::Result<()> {
224        let instruments = self
225            .http_client
226            .request_instruments_by_event_params(series_events_params(series_ids))
227            .await?;
228        self.add_instruments(instruments);
229        Ok(())
230    }
231
232    /// Initializes the provider using its configured bootstrap scope.
233    pub async fn initialize(&mut self, reload: bool) -> anyhow::Result<()> {
234        if self.store.is_initialized() && !reload {
235            return Ok(());
236        }
237
238        let should_load_all = has_bootstrap_scope(&self.config, &self.filters);
239        let has_load_ids = self.config.has_load_ids();
240
241        if !should_load_all && !has_load_ids {
242            if self.config.log_warnings {
243                log::warn!(
244                    "No Polymarket instrument bootstrap configured: set instrument_config.load_all, instrument_config.load_ids, instrument_config.filters, instrument_config.event_slugs, instrument_config.market_slugs, instrument_config.event_slug_builder, or instrument_config.series_ids, or register an instrument filter"
245                );
246            }
247            return Ok(());
248        }
249
250        // Registered `InstrumentFilter`s take precedence over the Gamma filter map,
251        // so say when the map is being dropped rather than ignoring it silently.
252        if self.config.log_warnings
253            && !self.filters.is_empty()
254            && self.config.has_nonempty_filters()
255        {
256            log::warn!(
257                "Registered instrument filters take precedence: instrument_config.filters is ignored for this bootstrap"
258            );
259        }
260
261        if should_load_all {
262            self.load_scoped_all().await?;
263        }
264
265        if has_load_ids {
266            // Deliberately not passed `config.filters`: `load_ids` names instruments
267            // explicitly, so intersecting it with the filter map would silently drop
268            // any ID that falls outside those filters. The scopes are additive, and
269            // an explicitly requested instrument is requested unconditionally.
270            let load_ids = self.config.load_ids.clone().unwrap_or_default();
271            self.load_ids(&load_ids, None).await?;
272        }
273
274        // Registered filters take precedence over the `filters` map, so a map paired with an
275        // accept-only filter loads nothing while `should_load_all()` still reports true. Latching
276        // empty would strand the provider against a later `initialize(false)`.
277        let sourced_by_registered_filters = !has_load_ids && !self.filters.is_empty();
278
279        if sourced_by_registered_filters && self.store.count() == 0 {
280            return Ok(());
281        }
282
283        self.store.set_initialized();
284        Ok(())
285    }
286
287    async fn load_scoped_all(&mut self) -> anyhow::Result<()> {
288        let event_slugs = self.resolve_event_slugs()?;
289        let market_slugs = self
290            .config
291            .market_slugs
292            .clone()
293            .unwrap_or_default()
294            .into_iter()
295            .filter(|slug| !slug.trim().is_empty())
296            .collect::<Vec<_>>();
297        let series_ids = self.config.series_ids.clone().unwrap_or_default();
298
299        // The clearing bulk load must run before the additive scoped loads.
300        // Explicit scoping never broadens into an unfiltered full-universe fetch,
301        // but every filter-driven query is bounded, so it composes with explicit
302        // scopes instead of being silently dropped. Registered `InstrumentFilter`s
303        // count here just as the Gamma filter map does, otherwise this path would
304        // drop them while `fetch_configured_instruments` keeps them, and the
305        // bootstrap and interval-refresh universes would diverge.
306        //
307        // This deliberately does not go through `load_all`, which marks the store
308        // initialized on completion. The additive scopes below are still
309        // outstanding at this point, and a failure in one of them would otherwise
310        // leave an initialized-but-incomplete store that makes a subsequent
311        // `initialize(false)` short-circuit and skip the missing scopes for good.
312        if !self.config.has_explicit_scope()
313            || self.config.has_nonempty_filters()
314            || !self.filters.is_empty()
315        {
316            let filters = self.config.filters.clone();
317            let instruments = self.fetch_bulk_instruments(filters.as_ref()).await?;
318            self.replace_instruments(instruments);
319        }
320
321        if !series_ids.is_empty() {
322            self.load_by_series_ids(series_ids).await?;
323        }
324
325        if !event_slugs.is_empty() {
326            self.load_by_event_slugs(event_slugs).await?;
327        }
328
329        if !market_slugs.is_empty() {
330            self.load_by_slugs(market_slugs).await?;
331        }
332
333        Ok(())
334    }
335
336    fn resolve_event_slugs(&self) -> anyhow::Result<Vec<String>> {
337        if let Some(builder) = self.config.event_slug_builder.as_ref() {
338            return builder.build_event_slugs();
339        }
340
341        Ok(self
342            .config
343            .event_slugs
344            .clone()
345            .unwrap_or_default()
346            .into_iter()
347            .filter(|slug| !slug.trim().is_empty())
348            .collect())
349    }
350
351    /// Loads instruments using all configured filters, combining results from
352    /// each filter's methods that return `Some`.
353    async fn load_filtered(&self) -> anyhow::Result<Vec<InstrumentAny>> {
354        fetch_instruments(&self.http_client, &self.filters).await
355    }
356
357    /// Fetches the bulk instrument universe without mutating provider state.
358    ///
359    /// Registered [`InstrumentFilter`]s take precedence; otherwise a non-empty
360    /// Gamma filter map bounds the query, and an absent or empty map falls back
361    /// to the full universe.
362    async fn fetch_bulk_instruments(
363        &self,
364        filters: Option<&HashMap<String, String>>,
365    ) -> anyhow::Result<Vec<InstrumentAny>> {
366        if !self.filters.is_empty() {
367            return self.load_filtered().await;
368        }
369
370        match filters {
371            Some(map) if !map.is_empty() => {
372                let params = build_gamma_params_from_hashmap(map)?;
373                self.http_client.request_instruments_by_params(params).await
374            }
375            _ => self.http_client.request_instruments().await,
376        }
377    }
378
379    /// Replaces the store contents, leaving the initialized flag untouched.
380    ///
381    /// Callers that complete a full bootstrap are responsible for marking the
382    /// store initialized once every scope has succeeded.
383    fn replace_instruments(&mut self, instruments: Vec<InstrumentAny>) {
384        self.store.clear();
385        self.token_index.clear();
386        self.add_instruments(instruments);
387    }
388}
389
390/// Fetches instruments from the Gamma API, respecting any configured filters.
391pub async fn fetch_instruments(
392    http_client: &PolymarketGammaHttpClient,
393    filters: &[Arc<dyn InstrumentFilter>],
394) -> anyhow::Result<Vec<InstrumentAny>> {
395    if filters.is_empty() {
396        return http_client.request_instruments().await;
397    }
398
399    let mut instruments = Vec::new();
400
401    for filter in filters {
402        if let Some(slugs) = filter.market_slugs()
403            && !slugs.is_empty()
404        {
405            let result = http_client.request_instruments_by_slugs(slugs).await?;
406            instruments.extend(result);
407        }
408
409        if let Some(event_slugs) = filter.event_slugs()
410            && !event_slugs.is_empty()
411        {
412            let result = http_client
413                .request_instruments_by_event_slugs(event_slugs)
414                .await?;
415            instruments.extend(result);
416        }
417
418        if let Some(params) = filter.query_params() {
419            let result = http_client.request_instruments_by_params(params).await?;
420            instruments.extend(result);
421        }
422
423        if let Some(event_queries) = filter.event_queries() {
424            for (event_slug, params) in event_queries {
425                let result = http_client
426                    .request_instruments_by_event_query(&event_slug, params)
427                    .await?;
428                instruments.extend(result);
429            }
430        }
431
432        if let Some(params) = filter.event_params() {
433            let result = http_client
434                .request_instruments_by_event_params(params)
435                .await?;
436            instruments.extend(result);
437        }
438
439        if let Some(params) = filter.search_params() {
440            let result = http_client.request_instruments_by_search(params).await?;
441            instruments.extend(result);
442        }
443    }
444
445    let mut seen = AHashSet::new();
446    instruments.retain(|inst| seen.insert(inst.id()));
447    instruments.retain(|inst| filters.iter().all(|f| f.accept(inst)));
448
449    Ok(instruments)
450}
451
452/// Fetches instruments using the configured provider bootstrap scope without
453/// mutating any provider state.
454pub async fn fetch_configured_instruments(
455    http_client: &PolymarketGammaHttpClient,
456    config: &PolymarketInstrumentProviderConfig,
457    filters: &[Arc<dyn InstrumentFilter>],
458) -> anyhow::Result<Vec<InstrumentAny>> {
459    config.validate()?;
460
461    let mut instruments = Vec::new();
462
463    if has_bootstrap_scope(config, filters) {
464        let has_explicit_scope = config.has_explicit_scope();
465        let event_slugs = if let Some(builder) = config.event_slug_builder.as_ref() {
466            builder.build_event_slugs()?
467        } else {
468            config
469                .event_slugs
470                .clone()
471                .unwrap_or_default()
472                .into_iter()
473                .filter(|slug| !slug.trim().is_empty())
474                .collect::<Vec<_>>()
475        };
476
477        let market_slugs = config
478            .market_slugs
479            .clone()
480            .unwrap_or_default()
481            .into_iter()
482            .filter(|slug| !slug.trim().is_empty())
483            .collect::<Vec<_>>();
484
485        let series_ids = config.series_ids.clone().unwrap_or_default();
486
487        // Explicit scoping never broadens into an unfiltered full-universe
488        // fetch, but every filter-driven query is bounded, so it composes with
489        // explicit series and slug scopes instead of being silently dropped.
490        // This mirrors `load_scoped_all`, which the interval refresh and
491        // `request_instruments` paths must stay consistent with.
492        if !filters.is_empty() {
493            instruments.extend(fetch_instruments(http_client, filters).await?);
494        } else if let Some(map) = config.filters.as_ref().filter(|map| !map.is_empty()) {
495            let params = build_gamma_params_from_hashmap(map)?;
496            instruments.extend(http_client.request_instruments_by_params(params).await?);
497        } else if !has_explicit_scope {
498            instruments.extend(http_client.request_instruments().await?);
499        }
500
501        if !series_ids.is_empty() {
502            instruments.extend(
503                http_client
504                    .request_instruments_by_event_params(series_events_params(series_ids))
505                    .await?,
506            );
507        }
508
509        if !event_slugs.is_empty() {
510            instruments.extend(
511                http_client
512                    .request_instruments_by_event_slugs(event_slugs)
513                    .await?,
514            );
515        }
516
517        if !market_slugs.is_empty() {
518            instruments.extend(
519                http_client
520                    .request_instruments_by_slugs(market_slugs)
521                    .await?,
522            );
523        }
524    }
525
526    if config.has_load_ids() {
527        // Queried by condition ID alone. Merging `config.filters` in here would
528        // intersect an explicit scope with a filter scope, so an ID outside those
529        // filters would never load despite being named directly. Mirrors the
530        // `load_ids` call in `initialize`.
531        let condition_ids = config
532            .load_ids
533            .clone()
534            .unwrap_or_default()
535            .into_iter()
536            .filter_map(|id| extract_condition_id(&id).ok())
537            .collect::<AHashSet<_>>()
538            .into_iter()
539            .collect::<Vec<_>>();
540
541        for chunk in condition_ids.chunks(GAMMA_CONDITION_IDS_BATCH_SIZE) {
542            let params = GetGammaMarketsParams {
543                condition_ids: Some(chunk.to_vec()),
544                ..Default::default()
545            };
546            instruments.extend(http_client.request_instruments_by_params(params).await?);
547        }
548    }
549
550    // Deduplicated but NOT filtered by `accept` here. `fetch_instruments` already
551    // applies acceptance to the results of filter-driven queries, which is the only
552    // place it belongs: applying it again across the whole collection would let a
553    // registered filter reject series, slug, and ID instruments that the provider's
554    // `load_scoped_all` adds unconditionally, so a refresh would silently drop
555    // instruments the bootstrap had loaded.
556    let mut seen = AHashSet::new();
557    instruments.retain(|inst| seen.insert(inst.id()));
558    Ok(instruments)
559}
560
561// Registered filters live on the provider, not the config, so the config predicate alone cannot
562// see them. Source methods are deliberately not evaluated: they are documented as re-evaluated
563// each load cycle, so probing here would consume a batch the fetch then misses.
564fn has_bootstrap_scope(
565    config: &PolymarketInstrumentProviderConfig,
566    filters: &[Arc<dyn InstrumentFilter>],
567) -> bool {
568    config.should_load_all() || !filters.is_empty()
569}
570
571/// Builds the Gamma events query that resolves series IDs to their active,
572/// unresolved events.
573fn series_events_params(series_ids: Vec<u64>) -> GetGammaEventsParams {
574    GetGammaEventsParams {
575        series_id: Some(series_ids),
576        active: Some(true),
577        closed: Some(false),
578        ..Default::default()
579    }
580}
581
582/// Extracts the condition ID from an instrument symbol.
583///
584/// Polymarket instrument symbols follow the pattern `{condition_id}-{token_id}`.
585/// The condition_id is a hex string (e.g. `0xabc123...`) and the token_id is a
586/// large decimal number. This extracts the condition_id by splitting at the last `-`.
587pub fn extract_condition_id(instrument_id: &InstrumentId) -> anyhow::Result<String> {
588    let symbol = instrument_id.symbol.as_str();
589    symbol
590        .rfind('-')
591        .map(|idx| symbol[..idx].to_string())
592        .ok_or_else(|| {
593            anyhow::anyhow!("Cannot extract condition_id from symbol '{symbol}': no '-' separator")
594        })
595}
596
597/// Extracts the token ID from an instrument symbol.
598///
599/// Polymarket instrument symbols follow the pattern `{condition_id}-{token_id}`. This extracts the
600/// token_id by splitting at the last `-`.
601pub(crate) fn extract_token_id(instrument_id: &InstrumentId) -> anyhow::Result<String> {
602    let symbol = instrument_id.symbol.as_str();
603    symbol
604        .rsplit_once('-')
605        .map(|(_, token_id)| token_id.to_string())
606        .ok_or_else(|| {
607            anyhow::anyhow!("Cannot extract token_id from symbol '{symbol}': no '-' separator")
608        })
609}
610
611/// Builds validated market keyset parameters from string key/value filters.
612///
613/// # Errors
614///
615/// Returns an error if keys are unknown, values are malformed, or filter combinations are invalid.
616pub fn build_gamma_params_from_hashmap(
617    map: &HashMap<String, String>,
618) -> anyhow::Result<GetGammaMarketsParams> {
619    for key in map.keys() {
620        match key.as_str() {
621            "is_active"
622            | "active"
623            | "closed"
624            | "archived"
625            | "id"
626            | "limit"
627            | "offset"
628            | "order"
629            | "ascending"
630            | "slug"
631            | "clob_token_ids"
632            | "condition_ids"
633            | "question_ids"
634            | "market_maker_address"
635            | "liquidity_num_min"
636            | "liquidity_num_max"
637            | "volume_num_min"
638            | "volume_num_max"
639            | "start_date_min"
640            | "start_date_max"
641            | "end_date_min"
642            | "end_date_max"
643            | "tag_id"
644            | "related_tags"
645            | "tag_match"
646            | "decimalized"
647            | "cyom"
648            | "rfq_enabled"
649            | "uma_resolution_status"
650            | "game_id"
651            | "sports_market_types"
652            | "include_tag"
653            | "locale"
654            | "max_markets" => {}
655            _ => anyhow::bail!("Unknown Gamma market filter key '{key}'"),
656        }
657    }
658
659    let mut params = GetGammaMarketsParams::default();
660
661    if map
662        .get("is_active")
663        .map(|value| parse_gamma_filter_bool("market", "is_active", value))
664        .transpose()?
665        .unwrap_or(false)
666    {
667        params.active = Some(true);
668        params.archived = Some(false);
669        params.closed = Some(false);
670    }
671
672    if let Some(v) = map.get("active") {
673        params.active = Some(parse_gamma_filter_bool("market", "active", v)?);
674    }
675
676    if let Some(v) = map.get("closed") {
677        params.closed = Some(parse_gamma_filter_bool("market", "closed", v)?);
678    }
679
680    if let Some(v) = map.get("archived") {
681        params.archived = Some(parse_gamma_filter_bool("market", "archived", v)?);
682    }
683
684    if let Some(v) = map.get("id") {
685        params.id = Some(parse_gamma_numeric_filter_list("market", "id", v)?);
686    }
687
688    if let Some(v) = map.get("slug") {
689        params.slug = Some(parse_gamma_filter_list("market", "slug", v)?);
690    }
691
692    if let Some(v) = map.get("tag_id") {
693        params.tag_id = Some(parse_gamma_numeric_filter_list("market", "tag_id", v)?);
694    }
695
696    if let Some(v) = map.get("condition_ids") {
697        params.condition_ids = Some(parse_gamma_filter_list("market", "condition_ids", v)?);
698    }
699
700    if let Some(v) = map.get("clob_token_ids") {
701        params.clob_token_ids = Some(parse_gamma_filter_list("market", "clob_token_ids", v)?);
702    }
703
704    if let Some(v) = map.get("question_ids") {
705        params.question_ids = Some(parse_gamma_filter_list("market", "question_ids", v)?);
706    }
707
708    if let Some(v) = map.get("market_maker_address") {
709        params.market_maker_address = Some(parse_gamma_filter_list(
710            "market",
711            "market_maker_address",
712            v,
713        )?);
714    }
715
716    if let Some(v) = map.get("liquidity_num_min") {
717        params.liquidity_num_min = Some(parse_gamma_filter_decimal(
718            "market",
719            "liquidity_num_min",
720            v,
721        )?);
722    }
723
724    if let Some(v) = map.get("liquidity_num_max") {
725        params.liquidity_num_max = Some(parse_gamma_filter_decimal(
726            "market",
727            "liquidity_num_max",
728            v,
729        )?);
730    }
731
732    if let Some(v) = map.get("volume_num_min") {
733        params.volume_num_min = Some(parse_gamma_filter_decimal("market", "volume_num_min", v)?);
734    }
735
736    if let Some(v) = map.get("volume_num_max") {
737        params.volume_num_max = Some(parse_gamma_filter_decimal("market", "volume_num_max", v)?);
738    }
739
740    if let Some(v) = map.get("order") {
741        params.order = Some(parse_gamma_filter_string("market", "order", v)?);
742    }
743
744    if let Some(v) = map.get("ascending") {
745        params.ascending = Some(parse_gamma_filter_bool("market", "ascending", v)?);
746    }
747
748    if let Some(v) = map.get("limit") {
749        params.limit = Some(parse_gamma_filter_u32("market", "limit", v)?.min(100));
750    }
751
752    if let Some(v) = map.get("offset") {
753        params.offset = Some(parse_gamma_filter_u32("market", "offset", v)?);
754    }
755
756    if let Some(v) = map.get("start_date_min") {
757        params.start_date_min = Some(parse_gamma_filter_string("market", "start_date_min", v)?);
758    }
759
760    if let Some(v) = map.get("start_date_max") {
761        params.start_date_max = Some(parse_gamma_filter_string("market", "start_date_max", v)?);
762    }
763
764    if let Some(v) = map.get("end_date_min") {
765        params.end_date_min = Some(parse_gamma_filter_string("market", "end_date_min", v)?);
766    }
767
768    if let Some(v) = map.get("end_date_max") {
769        params.end_date_max = Some(parse_gamma_filter_string("market", "end_date_max", v)?);
770    }
771
772    if let Some(v) = map.get("related_tags") {
773        params.related_tags = Some(parse_gamma_filter_bool("market", "related_tags", v)?);
774    }
775
776    if let Some(v) = map.get("tag_match") {
777        params.tag_match = Some(parse_gamma_filter_string("market", "tag_match", v)?);
778    }
779
780    if let Some(v) = map.get("decimalized") {
781        params.decimalized = Some(parse_gamma_filter_bool("market", "decimalized", v)?);
782    }
783
784    if let Some(v) = map.get("cyom") {
785        params.cyom = Some(parse_gamma_filter_bool("market", "cyom", v)?);
786    }
787
788    if let Some(v) = map.get("rfq_enabled") {
789        params.rfq_enabled = Some(parse_gamma_filter_bool("market", "rfq_enabled", v)?);
790    }
791
792    if let Some(v) = map.get("uma_resolution_status") {
793        params.uma_resolution_status = Some(parse_gamma_filter_string(
794            "market",
795            "uma_resolution_status",
796            v,
797        )?);
798    }
799
800    if let Some(v) = map.get("game_id") {
801        params.game_id = Some(parse_gamma_filter_string("market", "game_id", v)?);
802    }
803
804    if let Some(v) = map.get("sports_market_types") {
805        params.sports_market_types =
806            Some(parse_gamma_filter_list("market", "sports_market_types", v)?);
807    }
808
809    if let Some(v) = map.get("include_tag") {
810        params.include_tag = Some(parse_gamma_filter_bool("market", "include_tag", v)?);
811    }
812
813    if let Some(v) = map.get("locale") {
814        params.locale = Some(parse_gamma_filter_string("market", "locale", v)?);
815    }
816
817    if let Some(v) = map.get("max_markets") {
818        params.max_markets = Some(parse_gamma_filter_u32("market", "max_markets", v)?);
819    }
820
821    params.validate_keyset().map_err(|e| anyhow::anyhow!(e))?;
822    Ok(params)
823}
824
825/// Builds validated event keyset parameters from string key/value filters.
826///
827/// # Errors
828///
829/// Returns an error if keys are unknown, values are malformed, or filter combinations are invalid.
830pub fn build_gamma_event_params_from_hashmap(
831    map: &HashMap<String, String>,
832) -> anyhow::Result<GetGammaEventsParams> {
833    for key in map.keys() {
834        match key.as_str() {
835            "is_active" | "active" | "closed" | "archived" | "id" | "slug" | "live"
836            | "featured" | "cyom" | "title_search" | "liquidity_min" | "liquidity_max"
837            | "volume_min" | "volume_max" | "start_date_min" | "start_date_max"
838            | "end_date_min" | "end_date_max" | "start_time_min" | "start_time_max" | "tag_id"
839            | "tag_slug" | "exclude_tag_id" | "related_tags" | "tag_match" | "series_id"
840            | "game_id" | "event_date" | "event_week" | "featured_order" | "recurrence"
841            | "created_by" | "parent_event_id" | "include_children" | "partner_slug"
842            | "include_chat" | "include_template" | "include_best_lines" | "locale" | "order"
843            | "ascending" | "limit" | "offset" | "max_events" => {}
844            _ => anyhow::bail!("Unknown Gamma event filter key '{key}'"),
845        }
846    }
847
848    let mut params = GetGammaEventsParams::default();
849
850    if map
851        .get("is_active")
852        .map(|value| parse_gamma_filter_bool("event", "is_active", value))
853        .transpose()?
854        .unwrap_or(false)
855    {
856        params.active = Some(true);
857        params.archived = Some(false);
858        params.closed = Some(false);
859    }
860
861    macro_rules! set_bool {
862        ($field:ident) => {
863            if let Some(value) = map.get(stringify!($field)) {
864                params.$field = Some(parse_gamma_filter_bool("event", stringify!($field), value)?);
865            }
866        };
867    }
868    macro_rules! set_string {
869        ($field:ident) => {
870            if let Some(value) = map.get(stringify!($field)) {
871                params.$field = Some(parse_gamma_filter_string(
872                    "event",
873                    stringify!($field),
874                    value,
875                )?);
876            }
877        };
878    }
879    macro_rules! set_decimal {
880        ($field:ident) => {
881            if let Some(value) = map.get(stringify!($field)) {
882                params.$field = Some(parse_gamma_filter_decimal(
883                    "event",
884                    stringify!($field),
885                    value,
886                )?);
887            }
888        };
889    }
890    macro_rules! set_u32 {
891        ($field:ident) => {
892            if let Some(value) = map.get(stringify!($field)) {
893                params.$field = Some(parse_gamma_filter_u32("event", stringify!($field), value)?);
894            }
895        };
896    }
897    macro_rules! set_u64 {
898        ($field:ident) => {
899            if let Some(value) = map.get(stringify!($field)) {
900                params.$field = Some(parse_gamma_filter_u64("event", stringify!($field), value)?);
901            }
902        };
903    }
904    macro_rules! set_strings {
905        ($field:ident) => {
906            if let Some(value) = map.get(stringify!($field)) {
907                params.$field = Some(parse_gamma_filter_list("event", stringify!($field), value)?);
908            }
909        };
910    }
911    macro_rules! set_u64s {
912        ($field:ident) => {
913            if let Some(value) = map.get(stringify!($field)) {
914                params.$field = Some(parse_gamma_numeric_filter_list(
915                    "event",
916                    stringify!($field),
917                    value,
918                )?);
919            }
920        };
921    }
922
923    set_bool!(active);
924    set_bool!(closed);
925    set_bool!(archived);
926    set_bool!(live);
927    set_bool!(featured);
928    set_bool!(cyom);
929    set_bool!(related_tags);
930    set_bool!(featured_order);
931    set_bool!(include_children);
932    set_bool!(include_chat);
933    set_bool!(include_template);
934    set_bool!(include_best_lines);
935    set_bool!(ascending);
936    set_strings!(slug);
937    set_strings!(created_by);
938    set_u64s!(id);
939    set_u64s!(tag_id);
940    set_u64s!(exclude_tag_id);
941    set_u64s!(series_id);
942    set_u64s!(game_id);
943    set_string!(title_search);
944    set_string!(start_date_min);
945    set_string!(start_date_max);
946    set_string!(end_date_min);
947    set_string!(end_date_max);
948    set_string!(start_time_min);
949    set_string!(start_time_max);
950    set_string!(tag_slug);
951    set_string!(tag_match);
952    set_string!(event_date);
953    set_string!(recurrence);
954    set_string!(partner_slug);
955    set_string!(locale);
956    set_string!(order);
957    set_decimal!(liquidity_min);
958    set_decimal!(liquidity_max);
959    set_decimal!(volume_min);
960    set_decimal!(volume_max);
961    set_u32!(event_week);
962    set_u32!(limit);
963    set_u32!(offset);
964    set_u32!(max_events);
965    set_u64!(parent_event_id);
966
967    params.validate_keyset().map_err(anyhow::Error::msg)?;
968    Ok(params)
969}
970
971fn parse_gamma_filter_bool(scope: &str, key: &str, value: &str) -> anyhow::Result<bool> {
972    if value.eq_ignore_ascii_case("true") {
973        Ok(true)
974    } else if value.eq_ignore_ascii_case("false") {
975        Ok(false)
976    } else {
977        anyhow::bail!("Gamma {scope} filter '{key}' must be true or false, was '{value}'")
978    }
979}
980
981fn parse_gamma_filter_u32(scope: &str, key: &str, value: &str) -> anyhow::Result<u32> {
982    value.parse::<u32>().map_err(|e| {
983        anyhow::anyhow!("Gamma {scope} filter '{key}' must be an unsigned integer: {e}")
984    })
985}
986
987fn parse_gamma_filter_u64(scope: &str, key: &str, value: &str) -> anyhow::Result<u64> {
988    value.parse::<u64>().map_err(|e| {
989        anyhow::anyhow!("Gamma {scope} filter '{key}' must be an unsigned integer: {e}")
990    })
991}
992
993fn parse_gamma_filter_decimal(scope: &str, key: &str, value: &str) -> anyhow::Result<Decimal> {
994    parse_decimal_exact(value)
995        .map_err(|e| anyhow::anyhow!("Gamma {scope} filter '{key}' must be a decimal number: {e}"))
996}
997
998fn parse_gamma_filter_list(scope: &str, key: &str, value: &str) -> anyhow::Result<Vec<String>> {
999    let values = value
1000        .split(',')
1001        .map(str::trim)
1002        .map(str::to_string)
1003        .collect::<Vec<_>>();
1004
1005    if values.is_empty() || values.iter().any(String::is_empty) {
1006        anyhow::bail!("Gamma {scope} filter '{key}' must contain non-empty comma-separated values")
1007    }
1008
1009    Ok(values)
1010}
1011
1012fn parse_gamma_numeric_filter_list(
1013    scope: &str,
1014    key: &str,
1015    value: &str,
1016) -> anyhow::Result<Vec<u64>> {
1017    parse_gamma_filter_list(scope, key, value)?
1018        .into_iter()
1019        .map(|item| {
1020            item.parse::<u64>().map_err(|e| {
1021                anyhow::anyhow!(
1022                    "Gamma {scope} filter '{key}' values must be unsigned integers: {e}"
1023                )
1024            })
1025        })
1026        .collect()
1027}
1028
1029fn parse_gamma_filter_string(scope: &str, key: &str, value: &str) -> anyhow::Result<String> {
1030    if value.trim().is_empty() {
1031        anyhow::bail!("Gamma {scope} filter '{key}' cannot be empty")
1032    }
1033    Ok(value.to_string())
1034}
1035
1036/// Resolves a tag slug to a tag ID by querying the Gamma tags endpoint.
1037pub async fn resolve_tag_slug(
1038    client: &PolymarketGammaHttpClient,
1039    slug: &str,
1040) -> anyhow::Result<u64> {
1041    let tags = client.request_tags().await?;
1042    let tag_id = tags
1043        .iter()
1044        .find(|t| t.slug.as_deref() == Some(slug))
1045        .map(|t| t.id.as_str())
1046        .ok_or_else(|| anyhow::anyhow!("Tag slug '{slug}' not found"))?;
1047    tag_id
1048        .parse::<u64>()
1049        .map_err(|e| anyhow::anyhow!("Tag slug '{slug}' returned invalid ID '{tag_id}': {e}"))
1050}
1051
1052#[async_trait(?Send)]
1053impl InstrumentProvider for PolymarketInstrumentProvider {
1054    fn store(&self) -> &InstrumentStore {
1055        &self.store
1056    }
1057
1058    fn store_mut(&mut self) -> &mut InstrumentStore {
1059        &mut self.store
1060    }
1061
1062    async fn load_all(&mut self, filters: Option<&HashMap<String, String>>) -> anyhow::Result<()> {
1063        let instruments = self.fetch_bulk_instruments(filters).await?;
1064        self.replace_instruments(instruments);
1065        self.store.set_initialized();
1066
1067        Ok(())
1068    }
1069
1070    async fn load_ids(
1071        &mut self,
1072        instrument_ids: &[InstrumentId],
1073        filters: Option<&HashMap<String, String>>,
1074    ) -> anyhow::Result<()> {
1075        let missing: Vec<_> = instrument_ids
1076            .iter()
1077            .filter(|id| !self.store.contains(id))
1078            .collect();
1079
1080        if missing.is_empty() {
1081            return Ok(());
1082        }
1083
1084        // Extract unique condition IDs from instrument symbols
1085        // Symbol format: "{condition_id}-{token_id}"
1086        let mut condition_ids: Vec<String> = missing
1087            .iter()
1088            .filter_map(|id| extract_condition_id(id).ok())
1089            .collect();
1090        condition_ids.sort();
1091        condition_ids.dedup();
1092
1093        if condition_ids.is_empty() {
1094            return Ok(());
1095        }
1096
1097        let base_params = filters
1098            .map(build_gamma_params_from_hashmap)
1099            .transpose()?
1100            .unwrap_or_default();
1101
1102        for chunk in condition_ids.chunks(GAMMA_CONDITION_IDS_BATCH_SIZE) {
1103            let params = GetGammaMarketsParams {
1104                condition_ids: Some(chunk.to_vec()),
1105                ..base_params.clone()
1106            };
1107            let instruments = self
1108                .http_client
1109                .request_instruments_by_params(params)
1110                .await?;
1111            self.add_instruments(instruments);
1112        }
1113
1114        Ok(())
1115    }
1116
1117    async fn load(
1118        &mut self,
1119        instrument_id: &InstrumentId,
1120        filters: Option<&HashMap<String, String>>,
1121    ) -> anyhow::Result<()> {
1122        if self.store.contains(instrument_id) {
1123            return Ok(());
1124        }
1125
1126        // Try direct fetch via condition_id extracted from symbol
1127        if let Ok(cid) = extract_condition_id(instrument_id) {
1128            let params = GetGammaMarketsParams {
1129                condition_ids: Some(vec![cid]),
1130                ..Default::default()
1131            };
1132
1133            if let Ok(instruments) = self.http_client.request_instruments_by_params(params).await {
1134                self.add_instruments(instruments);
1135
1136                if self.store.contains(instrument_id) {
1137                    return Ok(());
1138                }
1139            }
1140        }
1141
1142        // Fallback: full load_all if not initialized. A provider with an explicit
1143        // scope is excluded: `load_all` would broaden it into the full-universe
1144        // fetch that scoping exists to avoid, and would also clear the partially
1145        // loaded store and mark it initialized, making a later `initialize(false)`
1146        // skip the scopes it still owes after a failed bootstrap.
1147        if !self.store.is_initialized() && !self.config.has_explicit_scope() {
1148            self.load_all(filters).await?;
1149        }
1150
1151        if self.store.contains(instrument_id) {
1152            Ok(())
1153        } else {
1154            anyhow::bail!("Instrument {instrument_id} not found on Polymarket")
1155        }
1156    }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    use nautilus_common::config::ConfigError;
1162    use nautilus_network::retry::RetryConfig;
1163    use rstest::rstest;
1164
1165    use super::*;
1166    use crate::{config::PolymarketUpDownEventSlugConfig, filters::MarketSlugFilter};
1167
1168    #[rstest]
1169    #[case("0xcondition-0xtoken", Some("0xtoken"))]
1170    #[case("0xcondition-with-dash-0xtoken", Some("0xtoken"))]
1171    #[case("0xcondition", None)]
1172    fn extracts_token_id_from_instrument_symbol(
1173        #[case] symbol: &str,
1174        #[case] expected: Option<&str>,
1175    ) {
1176        let instrument_id = InstrumentId::from(format!("{symbol}.POLYMARKET").as_str());
1177        assert_eq!(extract_token_id(&instrument_id).ok().as_deref(), expected,);
1178    }
1179
1180    #[rstest]
1181    #[case::plain(0)]
1182    #[case::multiple_filters(1)]
1183    #[case::single_filter(2)]
1184    fn test_constructor_rejects_invalid_config(
1185        #[case] constructor: u8,
1186        #[values(false, true)] builder: bool,
1187    ) {
1188        let http_client = PolymarketGammaHttpClient::new(
1189            Some("http://127.0.0.1:1".to_string()),
1190            1,
1191            RetryConfig::default(),
1192        )
1193        .unwrap();
1194
1195        let mut config = PolymarketInstrumentProviderConfig {
1196            series_ids: Some(vec![10684, 10684]),
1197            ..Default::default()
1198        };
1199
1200        let expected = if builder {
1201            config.series_ids = None;
1202            config.event_slug_builder = Some(PolymarketUpDownEventSlugConfig {
1203                interval_mins: 0,
1204                ..Default::default()
1205            });
1206
1207            ConfigError::range(
1208                "event_slug_builder.interval_mins",
1209                "must be positive and fit in seconds",
1210            )
1211        } else {
1212            ConfigError::duplicate("series_ids", None)
1213        };
1214
1215        let filter = Arc::new(MarketSlugFilter::from_slugs(vec!["market".to_string()]));
1216
1217        let result = match constructor {
1218            0 => PolymarketInstrumentProvider::new(http_client, Some(config)),
1219            1 => {
1220                PolymarketInstrumentProvider::with_filters(http_client, Some(config), vec![filter])
1221            }
1222            2 => PolymarketInstrumentProvider::with_filter(http_client, Some(config), filter),
1223            _ => unreachable!(),
1224        };
1225
1226        let error = result.unwrap_err();
1227
1228        assert_eq!(error.downcast_ref::<ConfigError>(), Some(&expected));
1229    }
1230}