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 definition passes startup selection and venue filters.
62    #[must_use]
63    pub fn includes(
64        &self,
65        instrument_id: InstrumentId,
66        symbol: &str,
67        base: &str,
68        quote: &str,
69        contract_type: Option<&str>,
70    ) -> bool {
71        if !self.load_all && !self.load_ids.contains(&instrument_id) {
72            return false;
73        }
74
75        matches_filter(&self.symbols, symbol)
76            && matches_filter(&self.bases, base)
77            && matches_filter(&self.quotes, quote)
78            && self
79                .contract_types
80                .as_ref()
81                .is_none_or(|values| contract_type.is_some_and(|value| contains(values, value)))
82    }
83}
84
85fn filter_values(
86    config: &BinanceInstrumentProviderConfig,
87    key: &str,
88) -> anyhow::Result<Option<AHashSet<String>>> {
89    let Some(value) = config.filters.get(key) else {
90        return Ok(None);
91    };
92
93    let values = match value {
94        serde_json::Value::String(value) => vec![value.as_str()],
95        serde_json::Value::Array(values) => values
96            .iter()
97            .map(|value| {
98                value
99                    .as_str()
100                    .ok_or_else(|| anyhow::anyhow!("filter {key:?} contains a non-string value"))
101            })
102            .collect::<anyhow::Result<Vec<_>>>()?,
103        _ => anyhow::bail!("filter {key:?} is not a string or array"),
104    };
105
106    Ok(Some(
107        values
108            .into_iter()
109            .map(|value| value.trim().to_ascii_uppercase())
110            .collect(),
111    ))
112}
113
114fn matches_filter(values: &Option<AHashSet<String>>, value: &str) -> bool {
115    values.as_ref().is_none_or(|values| contains(values, value))
116}
117
118fn contains(values: &AHashSet<String>, value: &str) -> bool {
119    values.contains(&value.to_ascii_uppercase())
120}
121
122#[cfg(test)]
123mod tests {
124    use std::collections::HashMap;
125
126    use nautilus_model::identifiers::InstrumentId;
127    use rstest::rstest;
128
129    use super::*;
130
131    #[rstest]
132    fn test_selector_load_ids_and_filters_are_both_discriminating() {
133        let config = BinanceInstrumentProviderConfig {
134            load_all: false,
135            load_ids: Some(vec!["ETHUSDT-PERP.BINANCE".to_string()]),
136            filters: HashMap::from([
137                ("bases".to_string(), serde_json::json!(["eth"])),
138                ("quotes".to_string(), serde_json::json!("usdt")),
139                (
140                    "contract_types".to_string(),
141                    serde_json::json!(["PERPETUAL"]),
142                ),
143            ]),
144            ..Default::default()
145        };
146        let selector = BinanceInstrumentSelector::new(&config).unwrap();
147
148        assert!(selector.includes(
149            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
150            "ETHUSDT",
151            "ETH",
152            "USDT",
153            Some("PERPETUAL"),
154        ));
155        assert!(!selector.includes(
156            InstrumentId::from("BTCUSDT-PERP.BINANCE"),
157            "BTCUSDT",
158            "BTC",
159            "USDT",
160            Some("PERPETUAL"),
161        ));
162        assert!(!selector.includes(
163            InstrumentId::from("ETHUSDT-PERP.BINANCE"),
164            "ETHUSDT",
165            "ETH",
166            "USDT",
167            Some("CURRENT_QUARTER"),
168        ));
169    }
170
171    #[rstest]
172    fn test_selector_load_all_still_applies_venue_filters() {
173        let config = BinanceInstrumentProviderConfig {
174            filters: HashMap::from([("symbols".to_string(), serde_json::json!(["btcusdt"]))]),
175            ..Default::default()
176        };
177        let selector = BinanceInstrumentSelector::new(&config).unwrap();
178
179        assert!(selector.includes(
180            InstrumentId::from("BTCUSDT.BINANCE"),
181            "BTCUSDT",
182            "BTC",
183            "USDT",
184            None,
185        ));
186        assert!(!selector.includes(
187            InstrumentId::from("ETHUSDT.BINANCE"),
188            "ETHUSDT",
189            "ETH",
190            "USDT",
191            None,
192        ));
193    }
194}