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_model::identifiers::InstrumentId;
22
23use crate::config::BinanceInstrumentProviderConfig;
24
25/// Normalized Binance instrument selector.
26#[derive(Debug, Clone)]
27pub struct BinanceInstrumentSelector {
28    load_all: bool,
29    load_ids: AHashSet<InstrumentId>,
30    symbols: Option<AHashSet<String>>,
31    bases: Option<AHashSet<String>>,
32    quotes: Option<AHashSet<String>>,
33    contract_types: Option<AHashSet<String>>,
34}
35
36impl BinanceInstrumentSelector {
37    /// Creates a selector from validated provider configuration.
38    ///
39    /// # Errors
40    ///
41    /// Returns an error if a configured instrument ID or filter value is malformed.
42    pub fn new(config: &BinanceInstrumentProviderConfig) -> anyhow::Result<Self> {
43        let load_ids = config
44            .load_ids
45            .as_deref()
46            .unwrap_or_default()
47            .iter()
48            .map(|value| InstrumentId::from_str(value))
49            .collect::<Result<AHashSet<_>, _>>()?;
50
51        Ok(Self {
52            load_all: config.load_all,
53            load_ids,
54            symbols: filter_values(config, "symbols")?,
55            bases: filter_values(config, "bases")?,
56            quotes: filter_values(config, "quotes")?,
57            contract_types: filter_values(config, "contract_types")?,
58        })
59    }
60
61    /// Returns whether the symbol was explicitly selected via `load_ids` or the `symbols` filter.
62    ///
63    /// Broad category filters (`bases`, `quotes`, `contract_types`) do not count:
64    /// they still match bulk loads where non-trading skips are routine.
65    #[must_use]
66    pub fn is_explicit(&self, instrument_id: InstrumentId, symbol: &str) -> bool {
67        self.load_ids.contains(&instrument_id)
68            || self
69                .symbols
70                .as_ref()
71                .is_some_and(|values| contains(values, symbol))
72    }
73
74    /// Returns whether the definition passes startup selection and venue filters.
75    #[must_use]
76    pub fn includes(
77        &self,
78        instrument_id: InstrumentId,
79        symbol: &str,
80        base: &str,
81        quote: &str,
82        contract_type: Option<&str>,
83    ) -> bool {
84        if !self.load_all && !self.load_ids.contains(&instrument_id) {
85            return false;
86        }
87
88        matches_filter(&self.symbols, symbol)
89            && matches_filter(&self.bases, base)
90            && matches_filter(&self.quotes, quote)
91            && self
92                .contract_types
93                .as_ref()
94                .is_none_or(|values| contract_type.is_some_and(|value| contains(values, value)))
95    }
96}
97
98fn filter_values(
99    config: &BinanceInstrumentProviderConfig,
100    key: &str,
101) -> anyhow::Result<Option<AHashSet<String>>> {
102    let Some(value) = config.filters.get(key) else {
103        return Ok(None);
104    };
105
106    let values = match value {
107        serde_json::Value::String(value) => vec![value.as_str()],
108        serde_json::Value::Array(values) => values
109            .iter()
110            .map(|value| {
111                value
112                    .as_str()
113                    .ok_or_else(|| anyhow::anyhow!("filter {key:?} contains a non-string value"))
114            })
115            .collect::<anyhow::Result<Vec<_>>>()?,
116        _ => anyhow::bail!("filter {key:?} is not a string or array"),
117    };
118
119    Ok(Some(
120        values
121            .into_iter()
122            .map(|value| value.trim().to_ascii_uppercase())
123            .collect(),
124    ))
125}
126
127fn matches_filter(values: &Option<AHashSet<String>>, value: &str) -> bool {
128    values.as_ref().is_none_or(|values| contains(values, value))
129}
130
131fn contains(values: &AHashSet<String>, value: &str) -> bool {
132    values.contains(&value.to_ascii_uppercase())
133}
134
135#[cfg(test)]
136mod tests {
137    use std::collections::HashMap;
138
139    use nautilus_model::identifiers::InstrumentId;
140    use rstest::rstest;
141
142    use super::*;
143
144    #[rstest]
145    fn test_selector_load_ids_and_filters_are_both_discriminating() {
146        let config = BinanceInstrumentProviderConfig {
147            load_all: false,
148            load_ids: Some(vec!["ETHUSDT-PERP.BINANCE".to_string()]),
149            filters: HashMap::from([
150                ("bases".to_string(), serde_json::json!(["eth"])),
151                ("quotes".to_string(), serde_json::json!("usdt")),
152                (
153                    "contract_types".to_string(),
154                    serde_json::json!(["PERPETUAL"]),
155                ),
156            ]),
157            ..Default::default()
158        };
159        let selector = BinanceInstrumentSelector::new(&config).unwrap();
160
161        assert!(selector.includes(
162            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
163            "ETHUSDT",
164            "ETH",
165            "USDT",
166            Some("PERPETUAL"),
167        ));
168        assert!(!selector.includes(
169            InstrumentId::from("BTCUSDT-PERP.BINANCE"),
170            "BTCUSDT",
171            "BTC",
172            "USDT",
173            Some("PERPETUAL"),
174        ));
175        assert!(!selector.includes(
176            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
177            "ETHUSDT",
178            "ETH",
179            "USDT",
180            Some("CURRENT_QUARTER"),
181        ));
182    }
183
184    #[rstest]
185    fn test_selector_load_all_still_applies_venue_filters() {
186        let config = BinanceInstrumentProviderConfig {
187            filters: HashMap::from([("symbols".to_string(), serde_json::json!(["btcusdt"]))]),
188            ..Default::default()
189        };
190        let selector = BinanceInstrumentSelector::new(&config).unwrap();
191
192        assert!(selector.includes(
193            InstrumentId::from("BTCUSDT.BINANCE"),
194            "BTCUSDT",
195            "BTC",
196            "USDT",
197            None,
198        ));
199        assert!(!selector.includes(
200            InstrumentId::from("ETHUSDT.BINANCE"),
201            "ETHUSDT",
202            "ETH",
203            "USDT",
204            None,
205        ));
206    }
207
208    #[rstest]
209    fn test_is_explicit_matches_load_ids_and_symbol_filters_only() {
210        let config = BinanceInstrumentProviderConfig {
211            load_all: false,
212            load_ids: Some(vec!["BTCUSDT.BINANCE".to_string()]),
213            filters: HashMap::from([
214                ("symbols".to_string(), serde_json::json!(["ethusdt"])),
215                ("quotes".to_string(), serde_json::json!("USDT")),
216            ]),
217            ..Default::default()
218        };
219        let selector = BinanceInstrumentSelector::new(&config).unwrap();
220
221        assert!(selector.is_explicit(InstrumentId::from("BTCUSDT.BINANCE"), "BTCUSDT"));
222        assert!(selector.is_explicit(InstrumentId::from("ETHUSDT.BINANCE"), "ETHUSDT"));
223        assert!(!selector.is_explicit(InstrumentId::from("SOLUSDT.BINANCE"), "SOLUSDT"));
224    }
225
226    #[rstest]
227    fn test_is_explicit_is_false_for_bulk_loads() {
228        let selector =
229            BinanceInstrumentSelector::new(&BinanceInstrumentProviderConfig::default()).unwrap();
230
231        assert!(!selector.is_explicit(InstrumentId::from("BTCUSDT.BINANCE"), "BTCUSDT"));
232    }
233}