1use 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#[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 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 #[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 #[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}