Skip to main content

nautilus_polymarket/
filters.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 filters for the Polymarket adapter.
17
18use 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
28/// Declarative filter for controlling which instruments are loaded.
29///
30/// All three methods default to `None`. At least one must return `Some` for
31/// the filter to be useful. Methods are called on each `load_all()` cycle,
32/// enabling dynamic re-evaluation (e.g., time-based slug generation).
33pub trait InstrumentFilter: Debug + Send + Sync {
34    /// Market slugs for concurrent per-slug fetching via `GET /markets?slug=`.
35    fn market_slugs(&self) -> Option<Vec<String>> {
36        None
37    }
38
39    /// Event slugs for fetching event containers via `GET /events?slug=`.
40    /// Each event returns multiple markets.
41    fn event_slugs(&self) -> Option<Vec<String>> {
42        None
43    }
44
45    /// Gamma API query params for bulk filtered fetching.
46    fn query_params(&self) -> Option<GetGammaMarketsParams> {
47        None
48    }
49
50    /// Event queries with client-side sorting and limiting of embedded markets.
51    ///
52    /// Only `order`, `ascending`, and `max_markets` are supported. Other parameters
53    /// and unsupported order fields cause an error when loading. See
54    /// [`PolymarketGammaHttpClient::request_instruments_by_event_query`](
55    /// crate::http::gamma::PolymarketGammaHttpClient::request_instruments_by_event_query).
56    fn event_queries(&self) -> Option<Vec<(String, GetGammaMarketsParams)>> {
57        None
58    }
59
60    /// Gamma events API query params for fetching events with full filtering.
61    fn event_params(&self) -> Option<GetGammaEventsParams> {
62        None
63    }
64
65    /// Public search API params for text-based instrument discovery.
66    fn search_params(&self) -> Option<GetSearchParams> {
67        None
68    }
69
70    /// Post-fetch predicate: only instruments where this returns `true` are kept.
71    /// Default accepts all instruments. Override to refine results after fetching.
72    fn accept(&self, instrument: &InstrumentAny) -> bool {
73        let _ = instrument;
74        true
75    }
76
77    /// Pre-fetch gate for new market WS events: returns `true` if the market
78    /// should proceed to Gamma HTTP fetch. Used when this filter is set as
79    /// the dedicated `new_market_filter` in the data client config.
80    /// Default accepts all new markets.
81    fn accept_new_market(&self, new_market: &PolymarketNewMarket) -> bool {
82        let _ = new_market;
83        true
84    }
85}
86
87/// Filter that provides market slugs, optionally via a dynamic closure.
88pub struct MarketSlugFilter {
89    slug_fn: Box<dyn Fn() -> Vec<String> + Send + Sync>,
90}
91
92impl MarketSlugFilter {
93    /// Creates a new [`MarketSlugFilter`] from a closure that generates slugs.
94    ///
95    /// The closure is re-evaluated on each `load_all()` call, enabling
96    /// time-based or stateful slug generation.
97    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    /// Creates a new [`MarketSlugFilter`] from a static list of market slugs.
104    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
125/// Filter that provides event slugs, optionally via a dynamic closure.
126pub struct EventSlugFilter {
127    slug_fn: Box<dyn Fn() -> Vec<String> + Send + Sync>,
128}
129
130impl EventSlugFilter {
131    /// Creates a new [`EventSlugFilter`] from a closure that generates slugs.
132    ///
133    /// The closure is re-evaluated on each `load_all()` call, enabling
134    /// time-based or stateful slug generation.
135    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    /// Creates a new [`EventSlugFilter`] from a static list of event slugs.
142    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/// Filter that provides Gamma API query parameters for bulk filtered fetching.
164#[derive(Debug, Clone)]
165pub struct GammaQueryFilter {
166    params: GetGammaMarketsParams,
167}
168
169impl GammaQueryFilter {
170    /// Creates a new [`GammaQueryFilter`] from query parameters.
171    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/// Event filter that sorts and limits an event's embedded markets client-side.
183///
184/// Only `order`, `ascending`, and `max_markets` are supported. Other parameters
185/// and unsupported order fields cause an error when loading. See
186/// [`PolymarketGammaHttpClient::request_instruments_by_event_query`](
187/// crate::http::gamma::PolymarketGammaHttpClient::request_instruments_by_event_query)
188/// for supported order fields. [`EventSlugFilter`] returns all markets from the event.
189#[derive(Debug, Clone)]
190pub struct EventQueryFilter {
191    queries: Vec<(String, GetGammaMarketsParams)>,
192}
193
194impl EventQueryFilter {
195    /// Creates a new [`EventQueryFilter`] for a single event slug with query params.
196    pub fn new(event_slug: impl Into<String>, params: GetGammaMarketsParams) -> Self {
197        Self {
198            queries: vec![(event_slug.into(), params)],
199        }
200    }
201
202    /// Creates a new [`EventQueryFilter`] from multiple event slug + params pairs.
203    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
214/// Pure post-fetch filter that accepts/rejects instruments via a closure.
215///
216/// Does not provide any slugs or query params: combine with source filters
217/// via the provider's `with_filters()` or `add_filter()` methods.
218pub struct PredicateFilter {
219    predicate: Box<dyn Fn(&InstrumentAny) -> bool + Send + Sync>,
220    label: String,
221}
222
223impl PredicateFilter {
224    /// Creates a new [`PredicateFilter`] with a label and closure predicate.
225    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    /// Convenience: keep only instruments with a matching outcome value.
236    ///
237    /// Only [`InstrumentAny::BinaryOption`] instruments are checked; all other variants are rejected.
238    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    /// Convenience: reject instruments past expiration.
251    ///
252    /// The caller provides the current time as [`UnixNanos`] so the filter
253    /// works correctly with both real-time and simulated (backtest) clocks.
254    /// Only [`InstrumentAny::BinaryOption`] instruments are checked; non-binary variants are accepted.
255    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
262/// Returns `true` if `instrument` has a real expiration timestamp at or before `now_ns`.
263///
264/// An `expiration_ns` of `0` is treated as a "no expiration" sentinel, the same as a missing
265/// timestamp, so both report as not expired.
266pub(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
297/// Returns whether `instrument` is past its expiration without Gamma reporting it still open.
298///
299/// Gamma `endDate` is a scheduled end, not proof that trading stopped, so an expired instrument is
300/// retained while Gamma reports `closed=false`. A missing `closed` key means the instrument did not
301/// come from the live Gamma path, which keeps the time-only retirement behavior.
302pub(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/// Filter that queries the Gamma events endpoint with full params.
324#[derive(Debug, Clone)]
325pub struct EventParamsFilter {
326    params: GetGammaEventsParams,
327}
328
329impl EventParamsFilter {
330    /// Creates a new [`EventParamsFilter`] from event query parameters.
331    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/// Filter that uses the Gamma public search endpoint.
343#[derive(Debug, Clone)]
344pub struct SearchFilter {
345    params: GetSearchParams,
346}
347
348impl SearchFilter {
349    /// Creates a new [`SearchFilter`] from search parameters.
350    pub fn new(params: GetSearchParams) -> Self {
351        Self { params }
352    }
353
354    /// Convenience: search by free-text query string.
355    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/// Filter that queries markets by tag ID.
372#[derive(Debug, Clone)]
373pub struct TagFilter {
374    inner: GammaQueryFilter,
375}
376
377impl TagFilter {
378    /// Creates a new [`TagFilter`] from a known tag ID.
379    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
395/// Pre-fetch gate filter for new market WS events via closure.
396///
397/// Intended for use as `new_market_filter` in the data client config.
398/// Does not affect initial instrument loading.
399pub 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    /// Accept new markets where `question` or `description` contains the keyword
416    /// (case-insensitive).
417    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        // Simulates a backtest scenario: clock is set to a historical time,
514        // instrument expires in the "future" relative to the simulated clock
515        let simulated_now = UnixNanos::from(1_000_000_000_000_000_000u64); // ~2001
516        let expiration = UnixNanos::from(1_100_000_000_000_000_000u64); // ~2004
517        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        // A zero expiration is the "no expiration" sentinel, so it must be treated as not expired
525        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)); // default impl returns true
596    }
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)); // default: accept all
646    }
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)); // default: accept all
653    }
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)); // default: accept all
660    }
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}