1use std::str::FromStr;
19
20use ahash::AHashSet;
21use nautilus_model::identifiers::InstrumentId;
22
23use crate::config::BinanceInstrumentProviderConfig;
24
25#[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 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 #[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 #[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}