Skip to main content

nautilus_binance/common/
instruments.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//! Binance instrument loading filters.
17
18use std::str::FromStr;
19
20use ahash::AHashSet;
21use nautilus_common::config::ConfigError;
22use nautilus_model::identifiers::InstrumentId;
23
24use crate::{common::enums::BinanceProductType, config::BinanceInstrumentProviderConfig};
25
26/// Normalized Binance instrument selector.
27#[derive(Debug, Clone)]
28pub struct BinanceInstrumentSelector {
29    load_all: bool,
30    load_ids: AHashSet<InstrumentId>,
31    symbols: Option<AHashSet<String>>,
32    bases: Option<AHashSet<String>>,
33    quotes: Option<AHashSet<String>>,
34    contract_types: Option<AHashSet<String>>,
35}
36
37impl BinanceInstrumentSelector {
38    /// Creates a selector from provider configuration.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if instrument loading configuration is invalid.
43    pub fn new(
44        config: &BinanceInstrumentProviderConfig,
45        product_type: BinanceProductType,
46    ) -> anyhow::Result<Self> {
47        config.validate(product_type)?;
48
49        let load_ids = config
50            .load_ids
51            .as_deref()
52            .unwrap_or_default()
53            .iter()
54            .map(|value| InstrumentId::from_str(value))
55            .collect::<Result<AHashSet<_>, _>>()?;
56
57        Ok(Self {
58            load_all: config.load_all,
59            load_ids,
60            symbols: filter_values(config, "symbols")?,
61            bases: filter_values(config, "bases")?,
62            quotes: filter_values(config, "quotes")?,
63            contract_types: filter_values(config, "contract_types")?,
64        })
65    }
66
67    /// Returns whether the symbol was explicitly selected via `load_ids` or the `symbols` filter.
68    ///
69    /// Broad category filters (`bases`, `quotes`, `contract_types`) do not count:
70    /// they still match bulk loads where non-trading skips are routine.
71    #[must_use]
72    pub fn is_explicit(&self, instrument_id: InstrumentId, symbol: &str) -> bool {
73        self.load_ids.contains(&instrument_id)
74            || self
75                .symbols
76                .as_ref()
77                .is_some_and(|values| contains(values, symbol))
78    }
79
80    /// Returns whether the definition passes startup selection and venue filters.
81    #[must_use]
82    pub fn includes(
83        &self,
84        instrument_id: InstrumentId,
85        symbol: &str,
86        base: &str,
87        quote: &str,
88        contract_type: Option<&str>,
89    ) -> bool {
90        if !self.load_all && !self.load_ids.contains(&instrument_id) {
91            return false;
92        }
93
94        matches_filter(&self.symbols, symbol)
95            && matches_filter(&self.bases, base)
96            && matches_filter(&self.quotes, quote)
97            && self
98                .contract_types
99                .as_ref()
100                .is_none_or(|values| contract_type.is_some_and(|value| contains(values, value)))
101    }
102}
103
104pub(crate) fn filter_values(
105    config: &BinanceInstrumentProviderConfig,
106    key: &str,
107) -> anyhow::Result<Option<AHashSet<String>>> {
108    let Some(value) = config.filters.get(key) else {
109        return Ok(None);
110    };
111
112    let values = match value {
113        serde_json::Value::String(value) => vec![value.as_str()],
114        serde_json::Value::Array(values) => values
115            .iter()
116            .map(|value| {
117                value
118                    .as_str()
119                    .ok_or_else(|| anyhow::anyhow!("filter {key:?} contains a non-string value"))
120            })
121            .collect::<anyhow::Result<Vec<_>>>()?,
122        _ => anyhow::bail!("filter {key:?} is not a string or array"),
123    };
124
125    let mut normalized = AHashSet::with_capacity(values.len());
126    for value in values {
127        if !normalized.insert(value.trim().to_ascii_uppercase()) {
128            anyhow::bail!(ConfigError::duplicate(
129                format!("instrument_provider.filters.{key}"),
130                None
131            ));
132        }
133    }
134
135    Ok(Some(normalized))
136}
137
138fn matches_filter(values: &Option<AHashSet<String>>, value: &str) -> bool {
139    values.as_ref().is_none_or(|values| contains(values, value))
140}
141
142fn contains(values: &AHashSet<String>, value: &str) -> bool {
143    values.contains(&value.to_ascii_uppercase())
144}
145
146#[cfg(test)]
147mod tests {
148    use std::collections::HashMap;
149
150    use nautilus_model::identifiers::InstrumentId;
151    use rstest::rstest;
152
153    use super::*;
154
155    #[rstest]
156    fn test_selector_load_ids_and_filters_are_both_discriminating() {
157        let config = BinanceInstrumentProviderConfig {
158            load_all: false,
159            load_ids: Some(vec!["ETHUSDT-PERP.BINANCE".to_string()]),
160            filters: HashMap::from([
161                ("bases".to_string(), serde_json::json!(["eth"])),
162                ("quotes".to_string(), serde_json::json!("usdt")),
163                (
164                    "contract_types".to_string(),
165                    serde_json::json!(["PERPETUAL"]),
166                ),
167            ]),
168            ..Default::default()
169        };
170
171        let selector = BinanceInstrumentSelector::new(&config, BinanceProductType::UsdM).unwrap();
172
173        assert!(selector.includes(
174            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
175            "ETHUSDT",
176            "ETH",
177            "USDT",
178            Some("PERPETUAL"),
179        ));
180        assert!(!selector.includes(
181            InstrumentId::from("BTCUSDT-PERP.BINANCE"),
182            "BTCUSDT",
183            "BTC",
184            "USDT",
185            Some("PERPETUAL"),
186        ));
187        assert!(!selector.includes(
188            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
189            "ETHUSDT",
190            "ETH",
191            "USDT",
192            Some("CURRENT_QUARTER"),
193        ));
194    }
195
196    #[rstest]
197    fn test_selector_load_all_still_applies_venue_filters() {
198        let config = BinanceInstrumentProviderConfig {
199            filters: HashMap::from([("symbols".to_string(), serde_json::json!(["btcusdt"]))]),
200            ..Default::default()
201        };
202
203        let selector = BinanceInstrumentSelector::new(&config, BinanceProductType::Spot).unwrap();
204
205        assert!(selector.includes(
206            InstrumentId::from("BTCUSDT.BINANCE"),
207            "BTCUSDT",
208            "BTC",
209            "USDT",
210            None,
211        ));
212        assert!(!selector.includes(
213            InstrumentId::from("ETHUSDT.BINANCE"),
214            "ETHUSDT",
215            "ETH",
216            "USDT",
217            None,
218        ));
219    }
220
221    #[rstest]
222    fn test_is_explicit_matches_load_ids_and_symbol_filters_only() {
223        let config = BinanceInstrumentProviderConfig {
224            load_all: false,
225            load_ids: Some(vec!["BTCUSDT.BINANCE".to_string()]),
226            filters: HashMap::from([
227                ("symbols".to_string(), serde_json::json!(["ethusdt"])),
228                ("quotes".to_string(), serde_json::json!("USDT")),
229            ]),
230            ..Default::default()
231        };
232
233        let selector = BinanceInstrumentSelector::new(&config, BinanceProductType::Spot).unwrap();
234
235        assert!(selector.is_explicit(InstrumentId::from("BTCUSDT.BINANCE"), "BTCUSDT"));
236        assert!(selector.is_explicit(InstrumentId::from("ETHUSDT.BINANCE"), "ETHUSDT"));
237        assert!(!selector.is_explicit(InstrumentId::from("SOLUSDT.BINANCE"), "SOLUSDT"));
238    }
239
240    #[rstest]
241    fn test_is_explicit_is_false_for_bulk_loads() {
242        let selector = BinanceInstrumentSelector::new(
243            &BinanceInstrumentProviderConfig::default(),
244            BinanceProductType::Spot,
245        )
246        .unwrap();
247
248        assert!(!selector.is_explicit(InstrumentId::from("BTCUSDT.BINANCE"), "BTCUSDT"));
249    }
250
251    #[rstest]
252    fn test_constructor_rejects_duplicate_ids() {
253        let config = BinanceInstrumentProviderConfig {
254            load_ids: Some(vec![
255                "BTCUSDT.BINANCE".to_string(),
256                "BTCUSDT.BINANCE".to_string(),
257            ]),
258            ..Default::default()
259        };
260
261        let error = BinanceInstrumentSelector::new(&config, BinanceProductType::Spot).unwrap_err();
262
263        assert_eq!(
264            error.downcast_ref::<ConfigError>(),
265            Some(&ConfigError::duplicate(
266                "instrument_provider.load_ids",
267                None
268            )),
269        );
270    }
271
272    #[rstest]
273    fn test_constructor_rejects_spot_contract_filter() {
274        let config = BinanceInstrumentProviderConfig {
275            filters: HashMap::from([(
276                "contract_types".to_string(),
277                serde_json::json!(["PERPETUAL"]),
278            )]),
279            ..Default::default()
280        };
281
282        let error = BinanceInstrumentSelector::new(&config, BinanceProductType::Spot).unwrap_err();
283
284        assert_eq!(
285            error.to_string(),
286            "unsupported Binance instrument filter \"contract_types\" for Spot"
287        );
288    }
289}