1use 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
41pub 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 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 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 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 pub fn add_filter(&mut self, filter: Arc<dyn InstrumentFilter>) {
118 self.filters.push(filter);
119 }
120
121 pub fn clear_filters(&mut self) {
123 self.filters.clear();
124 }
125
126 #[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 #[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 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 #[must_use]
175 pub fn filters(&self) -> Vec<Arc<dyn InstrumentFilter>> {
176 self.filters.clone()
177 }
178
179 #[must_use]
181 pub fn http_client(&self) -> &PolymarketGammaHttpClient {
182 &self.http_client
183 }
184
185 #[must_use]
187 pub fn config(&self) -> &PolymarketInstrumentProviderConfig {
188 &self.config
189 }
190
191 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 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 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 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 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 let load_ids = self.config.load_ids.clone().unwrap_or_default();
271 self.load_ids(&load_ids, None).await?;
272 }
273
274 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 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 async fn load_filtered(&self) -> anyhow::Result<Vec<InstrumentAny>> {
354 fetch_instruments(&self.http_client, &self.filters).await
355 }
356
357 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 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
390pub 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
452pub 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 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 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 let mut seen = AHashSet::new();
557 instruments.retain(|inst| seen.insert(inst.id()));
558 Ok(instruments)
559}
560
561fn has_bootstrap_scope(
565 config: &PolymarketInstrumentProviderConfig,
566 filters: &[Arc<dyn InstrumentFilter>],
567) -> bool {
568 config.should_load_all() || !filters.is_empty()
569}
570
571fn 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
582pub 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
597pub(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
611pub 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
825pub 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
1036pub 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 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 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 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}