1use std::{fmt::Debug, sync::Arc};
19
20use nautilus_core::{Params, UnixNanos};
21use nautilus_model::instruments::{BinaryOption, Instrument, InstrumentAny};
22
23use crate::{
24 http::query::{GetGammaEventsParams, GetGammaMarketsParams, GetSearchParams},
25 websocket::messages::PolymarketNewMarket,
26};
27
28pub trait InstrumentFilter: Debug + Send + Sync {
34 fn market_slugs(&self) -> Option<Vec<String>> {
36 None
37 }
38
39 fn event_slugs(&self) -> Option<Vec<String>> {
42 None
43 }
44
45 fn query_params(&self) -> Option<GetGammaMarketsParams> {
47 None
48 }
49
50 fn event_queries(&self) -> Option<Vec<(String, GetGammaMarketsParams)>> {
57 None
58 }
59
60 fn event_params(&self) -> Option<GetGammaEventsParams> {
62 None
63 }
64
65 fn search_params(&self) -> Option<GetSearchParams> {
67 None
68 }
69
70 fn accept(&self, instrument: &InstrumentAny) -> bool {
73 let _ = instrument;
74 true
75 }
76
77 fn accept_new_market(&self, new_market: &PolymarketNewMarket) -> bool {
82 let _ = new_market;
83 true
84 }
85}
86
87pub struct MarketSlugFilter {
89 slug_fn: Box<dyn Fn() -> Vec<String> + Send + Sync>,
90}
91
92impl MarketSlugFilter {
93 pub fn new<F: Fn() -> Vec<String> + Send + Sync + 'static>(slug_fn: F) -> Self {
98 Self {
99 slug_fn: Box::new(slug_fn),
100 }
101 }
102
103 pub fn from_slugs(slugs: Vec<String>) -> Self {
105 Self {
106 slug_fn: Box::new(move || slugs.clone()),
107 }
108 }
109}
110
111impl Debug for MarketSlugFilter {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.debug_struct(stringify!(MarketSlugFilter))
114 .field("slug_fn", &"<closure>")
115 .finish()
116 }
117}
118
119impl InstrumentFilter for MarketSlugFilter {
120 fn market_slugs(&self) -> Option<Vec<String>> {
121 Some((self.slug_fn)())
122 }
123}
124
125pub struct EventSlugFilter {
127 slug_fn: Box<dyn Fn() -> Vec<String> + Send + Sync>,
128}
129
130impl EventSlugFilter {
131 pub fn new<F: Fn() -> Vec<String> + Send + Sync + 'static>(slug_fn: F) -> Self {
136 Self {
137 slug_fn: Box::new(slug_fn),
138 }
139 }
140
141 pub fn from_slugs(slugs: Vec<String>) -> Self {
143 Self {
144 slug_fn: Box::new(move || slugs.clone()),
145 }
146 }
147}
148
149impl Debug for EventSlugFilter {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 f.debug_struct(stringify!(EventSlugFilter))
152 .field("slug_fn", &"<closure>")
153 .finish()
154 }
155}
156
157impl InstrumentFilter for EventSlugFilter {
158 fn event_slugs(&self) -> Option<Vec<String>> {
159 Some((self.slug_fn)())
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct GammaQueryFilter {
166 params: GetGammaMarketsParams,
167}
168
169impl GammaQueryFilter {
170 pub fn new(params: GetGammaMarketsParams) -> Self {
172 Self { params }
173 }
174}
175
176impl InstrumentFilter for GammaQueryFilter {
177 fn query_params(&self) -> Option<GetGammaMarketsParams> {
178 Some(self.params.clone())
179 }
180}
181
182#[derive(Debug, Clone)]
190pub struct EventQueryFilter {
191 queries: Vec<(String, GetGammaMarketsParams)>,
192}
193
194impl EventQueryFilter {
195 pub fn new(event_slug: impl Into<String>, params: GetGammaMarketsParams) -> Self {
197 Self {
198 queries: vec![(event_slug.into(), params)],
199 }
200 }
201
202 pub fn from_queries(queries: Vec<(String, GetGammaMarketsParams)>) -> Self {
204 Self { queries }
205 }
206}
207
208impl InstrumentFilter for EventQueryFilter {
209 fn event_queries(&self) -> Option<Vec<(String, GetGammaMarketsParams)>> {
210 Some(self.queries.clone())
211 }
212}
213
214pub struct PredicateFilter {
219 predicate: Box<dyn Fn(&InstrumentAny) -> bool + Send + Sync>,
220 label: String,
221}
222
223impl PredicateFilter {
224 pub fn new<F: Fn(&InstrumentAny) -> bool + Send + Sync + 'static>(
226 label: impl Into<String>,
227 predicate: F,
228 ) -> Self {
229 Self {
230 predicate: Box::new(predicate),
231 label: label.into(),
232 }
233 }
234
235 pub fn outcome(value: impl Into<String>) -> Self {
239 let value: String = value.into();
240 let label = format!("outcome={value}");
241 Self::new(label, move |instrument| {
242 if let InstrumentAny::BinaryOption(opt) = instrument {
243 opt.outcome.as_deref() == Some(value.as_str())
244 } else {
245 false
246 }
247 })
248 }
249
250 pub fn not_expired(now_ns: UnixNanos) -> Self {
256 Self::new("not_expired", move |instrument| {
257 !is_expired(instrument, now_ns)
258 })
259 }
260}
261
262pub(crate) fn is_expired(instrument: &InstrumentAny, now_ns: UnixNanos) -> bool {
267 Instrument::expiration_ns(instrument)
268 .is_some_and(|expiration_ns| expiration_ns.as_u64() != 0 && expiration_ns <= now_ns)
269}
270
271pub(crate) const MARKET_CLOSED_KEY: &str = "closed";
272
273pub(crate) fn market_closed(instrument: &InstrumentAny) -> Option<bool> {
274 match instrument {
275 InstrumentAny::BinaryOption(binary) => binary_market_closed(binary),
276 _ => None,
277 }
278}
279
280pub(crate) fn binary_market_closed(instrument: &BinaryOption) -> Option<bool> {
281 instrument
282 .info
283 .as_ref()
284 .and_then(|info| info.get_bool(MARKET_CLOSED_KEY))
285}
286
287pub(crate) fn set_market_closed(instrument: &mut BinaryOption, closed: bool) {
288 let mut info = instrument
289 .info
290 .as_ref()
291 .map(Params::to_shared_map)
292 .unwrap_or_default();
293 info.insert(MARKET_CLOSED_KEY.to_string(), Arc::new(closed.into()));
294 instrument.info = Some(Params::from_shared(info));
295}
296
297pub(crate) fn is_expired_and_not_reported_open(
303 instrument: &InstrumentAny,
304 now_ns: UnixNanos,
305) -> bool {
306 is_expired(instrument, now_ns) && market_closed(instrument) != Some(false)
307}
308
309impl Debug for PredicateFilter {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 f.debug_struct(stringify!(PredicateFilter))
312 .field("label", &self.label)
313 .finish()
314 }
315}
316
317impl InstrumentFilter for PredicateFilter {
318 fn accept(&self, instrument: &InstrumentAny) -> bool {
319 (self.predicate)(instrument)
320 }
321}
322
323#[derive(Debug, Clone)]
325pub struct EventParamsFilter {
326 params: GetGammaEventsParams,
327}
328
329impl EventParamsFilter {
330 pub fn new(params: GetGammaEventsParams) -> Self {
332 Self { params }
333 }
334}
335
336impl InstrumentFilter for EventParamsFilter {
337 fn event_params(&self) -> Option<GetGammaEventsParams> {
338 Some(self.params.clone())
339 }
340}
341
342#[derive(Debug, Clone)]
344pub struct SearchFilter {
345 params: GetSearchParams,
346}
347
348impl SearchFilter {
349 pub fn new(params: GetSearchParams) -> Self {
351 Self { params }
352 }
353
354 pub fn from_query(query: impl Into<String>) -> Self {
356 Self {
357 params: GetSearchParams {
358 q: Some(query.into()),
359 ..Default::default()
360 },
361 }
362 }
363}
364
365impl InstrumentFilter for SearchFilter {
366 fn search_params(&self) -> Option<GetSearchParams> {
367 Some(self.params.clone())
368 }
369}
370
371#[derive(Debug, Clone)]
373pub struct TagFilter {
374 inner: GammaQueryFilter,
375}
376
377impl TagFilter {
378 pub fn from_tag_id(tag_id: u64) -> Self {
380 Self {
381 inner: GammaQueryFilter::new(GetGammaMarketsParams {
382 tag_id: Some(vec![tag_id]),
383 ..Default::default()
384 }),
385 }
386 }
387}
388
389impl InstrumentFilter for TagFilter {
390 fn query_params(&self) -> Option<GetGammaMarketsParams> {
391 self.inner.query_params()
392 }
393}
394
395pub struct NewMarketPredicateFilter {
400 predicate: Box<dyn Fn(&PolymarketNewMarket) -> bool + Send + Sync>,
401 label: String,
402}
403
404impl NewMarketPredicateFilter {
405 pub fn new<F: Fn(&PolymarketNewMarket) -> bool + Send + Sync + 'static>(
406 label: impl Into<String>,
407 predicate: F,
408 ) -> Self {
409 Self {
410 predicate: Box::new(predicate),
411 label: label.into(),
412 }
413 }
414
415 pub fn keyword(keyword: impl Into<String>) -> Self {
418 let kw = keyword.into().to_lowercase();
419 let label = format!("keyword={kw}");
420 Self::new(label, move |nm| {
421 nm.question.to_lowercase().contains(&kw) || nm.description.to_lowercase().contains(&kw)
422 })
423 }
424}
425
426impl Debug for NewMarketPredicateFilter {
427 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428 f.debug_struct(stringify!(NewMarketPredicateFilter))
429 .field("label", &self.label)
430 .finish()
431 }
432}
433
434impl InstrumentFilter for NewMarketPredicateFilter {
435 fn accept_new_market(&self, new_market: &PolymarketNewMarket) -> bool {
436 (self.predicate)(new_market)
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use nautilus_core::UnixNanos;
443 use nautilus_model::{
444 enums::AssetClass,
445 identifiers::{InstrumentId, Symbol},
446 instruments::{BinaryOption, InstrumentAny},
447 types::{Currency, Price, Quantity},
448 };
449 use rstest::*;
450 use ustr::Ustr;
451
452 use super::*;
453 use crate::websocket::messages::PolymarketNewMarketEvent;
454
455 fn stub_binary_option_with_expiration(
456 outcome: Option<&str>,
457 expiration: UnixNanos,
458 ) -> InstrumentAny {
459 let raw_symbol = Symbol::new("test-token-id");
460 InstrumentAny::BinaryOption(
461 BinaryOption::builder()
462 .instrument_id(InstrumentId::from("test-token-id.POLYMARKET"))
463 .raw_symbol(raw_symbol)
464 .asset_class(AssetClass::Alternative)
465 .currency(Currency::pUSD())
466 .activation_ns(UnixNanos::default())
467 .expiration_ns(expiration)
468 .price_precision(3)
469 .size_precision(2)
470 .price_increment(Price::from("0.001"))
471 .size_increment(Quantity::from("0.01"))
472 .maybe_outcome(outcome.map(Ustr::from))
473 .ts_event(UnixNanos::default())
474 .ts_init(UnixNanos::default())
475 .build()
476 .unwrap(),
477 )
478 }
479
480 fn stub_binary_option(outcome: Option<&str>) -> InstrumentAny {
481 stub_binary_option_with_expiration(outcome, UnixNanos::from(u64::MAX))
482 }
483
484 #[rstest]
485 fn test_not_expired_accepts_future_expiration() {
486 let now = UnixNanos::from(1_000_000u64);
487 let instrument =
488 stub_binary_option_with_expiration(Some("Yes"), UnixNanos::from(2_000_000u64));
489 let filter = PredicateFilter::not_expired(now);
490 assert!(filter.accept(&instrument));
491 }
492
493 #[rstest]
494 fn test_not_expired_rejects_past_expiration() {
495 let now = UnixNanos::from(2_000_000u64);
496 let instrument =
497 stub_binary_option_with_expiration(Some("Yes"), UnixNanos::from(1_000_000u64));
498 let filter = PredicateFilter::not_expired(now);
499 assert!(!filter.accept(&instrument));
500 }
501
502 #[rstest]
503 fn test_not_expired_rejects_equal_expiration() {
504 let now = UnixNanos::from(1_000_000u64);
505 let instrument =
506 stub_binary_option_with_expiration(Some("Yes"), UnixNanos::from(1_000_000u64));
507 let filter = PredicateFilter::not_expired(now);
508 assert!(!filter.accept(&instrument));
509 }
510
511 #[rstest]
512 fn test_not_expired_works_with_simulated_clock() {
513 let simulated_now = UnixNanos::from(1_000_000_000_000_000_000u64); let expiration = UnixNanos::from(1_100_000_000_000_000_000u64); let instrument = stub_binary_option_with_expiration(Some("Yes"), expiration);
518 let filter = PredicateFilter::not_expired(simulated_now);
519 assert!(filter.accept(&instrument));
520 }
521
522 #[rstest]
523 fn test_not_expired_accepts_zero_sentinel_expiration() {
524 let now = UnixNanos::from(1_000_000u64);
526 let instrument = stub_binary_option_with_expiration(Some("Yes"), UnixNanos::from(0u64));
527 let filter = PredicateFilter::not_expired(now);
528 assert!(filter.accept(&instrument));
529 }
530
531 #[rstest]
532 #[case(2_000_000, Some(false), false)]
533 #[case(2_000_000, Some(true), true)]
534 #[case(2_000_000, None, true)]
535 #[case(500_000, Some(true), false)]
536 fn test_expired_and_not_reported_open_requires_expiration_and_no_open_market_state(
537 #[case] now: u64,
538 #[case] closed: Option<bool>,
539 #[case] expected: bool,
540 ) {
541 let mut instrument =
542 stub_binary_option_with_expiration(Some("Yes"), UnixNanos::from(1_000_000));
543 if let (Some(closed), InstrumentAny::BinaryOption(binary)) = (closed, &mut instrument) {
544 set_market_closed(binary, closed);
545 }
546
547 assert_eq!(
548 is_expired_and_not_reported_open(&instrument, UnixNanos::from(now)),
549 expected
550 );
551 }
552
553 #[fixture]
554 fn yes_instrument() -> InstrumentAny {
555 stub_binary_option(Some("Yes"))
556 }
557
558 #[fixture]
559 fn no_instrument() -> InstrumentAny {
560 stub_binary_option(Some("No"))
561 }
562
563 #[fixture]
564 fn no_outcome_instrument() -> InstrumentAny {
565 stub_binary_option(None)
566 }
567
568 #[rstest]
569 fn test_predicate_filter_accepts_matching(yes_instrument: InstrumentAny) {
570 let filter = PredicateFilter::new("test", |_| true);
571 assert!(filter.accept(&yes_instrument));
572 }
573
574 #[rstest]
575 fn test_predicate_filter_rejects_non_matching(yes_instrument: InstrumentAny) {
576 let filter = PredicateFilter::new("test", |_| false);
577 assert!(!filter.accept(&yes_instrument));
578 }
579
580 #[rstest]
581 fn test_predicate_filter_outcome_constructor(
582 yes_instrument: InstrumentAny,
583 no_instrument: InstrumentAny,
584 no_outcome_instrument: InstrumentAny,
585 ) {
586 let filter = PredicateFilter::outcome("Yes");
587 assert!(filter.accept(&yes_instrument));
588 assert!(!filter.accept(&no_instrument));
589 assert!(!filter.accept(&no_outcome_instrument));
590 }
591
592 #[rstest]
593 fn test_default_accept_returns_true(yes_instrument: InstrumentAny) {
594 let filter = MarketSlugFilter::from_slugs(vec!["test".to_string()]);
595 assert!(filter.accept(&yes_instrument)); }
597
598 fn stub_new_market(
599 slug: &str,
600 tags: Vec<String>,
601 event_slug: Option<&str>,
602 ) -> PolymarketNewMarket {
603 PolymarketNewMarket {
604 id: "1".to_string(),
605 question: "Test?".to_string(),
606 market: Ustr::from("0xabc"),
607 slug: slug.to_string(),
608 description: String::new(),
609 assets_ids: vec![],
610 outcomes: vec!["Yes".to_string(), "No".to_string()],
611 timestamp: "0".to_string(),
612 tags,
613 condition_id: "0xabc".to_string(),
614 active: true,
615 clob_token_ids: vec![],
616 order_price_min_tick_size: None,
617 group_item_title: None,
618 event_message: event_slug.map(|s| PolymarketNewMarketEvent {
619 id: "1".to_string(),
620 ticker: s.to_string(),
621 slug: s.to_string(),
622 title: "Test event".to_string(),
623 description: String::new(),
624 }),
625 sports_market_type: None,
626 line: None,
627 game_start_time: None,
628 taker_base_fee: None,
629 fees_enabled: None,
630 fee_schedule: None,
631 }
632 }
633
634 #[rstest]
635 fn test_default_accept_new_market_returns_true() {
636 let filter = GammaQueryFilter::new(GetGammaMarketsParams::default());
637 let nm = stub_new_market("any-market", vec![], None);
638 assert!(filter.accept_new_market(&nm));
639 }
640
641 #[rstest]
642 fn test_market_slug_filter_default_accept_new_market() {
643 let filter = MarketSlugFilter::from_slugs(vec!["trump-win".to_string()]);
644 let nm = stub_new_market("biden-win", vec![], None);
645 assert!(filter.accept_new_market(&nm)); }
647
648 #[rstest]
649 fn test_event_slug_filter_default_accept_new_market() {
650 let filter = EventSlugFilter::from_slugs(vec!["us-election-2024".to_string()]);
651 let nm = stub_new_market("some-market", vec![], Some("uk-election-2024"));
652 assert!(filter.accept_new_market(&nm)); }
654
655 #[rstest]
656 fn test_tag_filter_default_accept_new_market() {
657 let filter = TagFilter::from_tag_id(123);
658 let nm = stub_new_market("nvda-market", vec!["stocks".to_string()], None);
659 assert!(filter.accept_new_market(&nm)); }
661
662 #[rstest]
663 fn test_new_market_predicate_keyword_matches_question() {
664 let filter = NewMarketPredicateFilter::keyword("BTC");
665 let mut nm = stub_new_market("btc-market", vec![], None);
666 nm.question = "Will BTC reach 100k?".to_string();
667 assert!(filter.accept_new_market(&nm));
668 }
669
670 #[rstest]
671 fn test_new_market_predicate_keyword_matches_description() {
672 let filter = NewMarketPredicateFilter::keyword("bitcoin");
673 let mut nm = stub_new_market("some-market", vec![], None);
674 nm.question = "Some question".to_string();
675 nm.description = "This market is about Bitcoin price".to_string();
676 assert!(filter.accept_new_market(&nm));
677 }
678
679 #[rstest]
680 fn test_new_market_predicate_keyword_rejects_no_match() {
681 let filter = NewMarketPredicateFilter::keyword("BTC");
682 let mut nm = stub_new_market("eth-market", vec![], None);
683 nm.question = "Will ETH reach 10k?".to_string();
684 nm.description = "Ethereum price market".to_string();
685 assert!(!filter.accept_new_market(&nm));
686 }
687}