longbridge/utils/
counter.rs1use std::{
14 collections::HashSet,
15 path::PathBuf,
16 sync::{OnceLock, RwLock},
17};
18
19static SPECIAL_COUNTER_IDS: OnceLock<HashSet<&'static str>> = OnceLock::new();
20
21fn special_counter_ids() -> &'static HashSet<&'static str> {
22 SPECIAL_COUNTER_IDS.get_or_init(|| {
23 [
24 include_str!("US-ETF.csv"),
25 include_str!("US-IX.csv"),
26 include_str!("US-WT.csv"),
27 ]
28 .iter()
29 .flat_map(|s| s.lines())
30 .map(str::trim)
31 .filter(|s| !s.is_empty())
32 .collect()
33 })
34}
35
36static CACHED_COUNTER_IDS: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
39
40#[cfg(test)]
41static TEST_CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
42
43fn cache_file_path() -> Option<PathBuf> {
47 #[cfg(test)]
48 if let Some(dir) = TEST_CACHE_DIR.get() {
49 return Some(dir.join("counter-ids.csv"));
50 }
51 let dir = match std::env::var_os("LONGBRIDGE_CACHE_DIR") {
52 Some(dir) => PathBuf::from(dir),
53 None => {
54 #[cfg(windows)]
55 let home = std::env::var_os("USERPROFILE")?;
56 #[cfg(not(windows))]
57 let home = std::env::var_os("HOME")?;
58 PathBuf::from(home).join(".longbridge").join("cache")
59 }
60 };
61 Some(dir.join("counter-ids.csv"))
62}
63
64fn cached_counter_ids() -> &'static RwLock<HashSet<String>> {
65 CACHED_COUNTER_IDS.get_or_init(|| {
66 let set = cache_file_path()
67 .and_then(|path| std::fs::read_to_string(path).ok())
68 .map(|s| {
69 s.lines()
70 .map(str::trim)
71 .filter(|line| !line.is_empty())
72 .map(ToString::to_string)
73 .collect()
74 })
75 .unwrap_or_default();
76 RwLock::new(set)
77 })
78}
79
80pub fn cache_counter_ids<'a>(counter_ids: impl IntoIterator<Item = &'a str>) {
84 let mut set = match cached_counter_ids().write() {
85 Ok(guard) => guard,
86 Err(poisoned) => poisoned.into_inner(),
87 };
88 let before = set.len();
89 set.extend(
90 counter_ids
91 .into_iter()
92 .map(str::trim)
93 .filter(|id| !id.is_empty())
94 .map(ToString::to_string),
95 );
96 if set.len() == before {
97 return;
98 }
99 if let Some(path) = cache_file_path() {
100 if let Some(parent) = path.parent() {
101 let _ = std::fs::create_dir_all(parent);
102 }
103 let mut lines: Vec<&str> = set.iter().map(String::as_str).collect();
104 lines.sort_unstable();
105 let _ = std::fs::write(path, lines.join("\n") + "\n");
106 }
107}
108
109pub fn lookup_counter_id(symbol: &str) -> Option<String> {
115 let (code, market) = symbol.rsplit_once('.')?;
116 let market = market.to_uppercase();
117 if code.starts_with('.') {
118 return Some(format!("IX/{market}/{code}"));
119 }
120 let code = if market == "HK" && code.chars().all(|c| c.is_ascii_digit()) {
121 code.trim_start_matches('0')
122 } else {
123 code
124 };
125 for prefix in &["ETF", "IX", "WT"] {
126 let candidate = format!("{prefix}/{market}/{code}");
127 if special_counter_ids().contains(candidate.as_str()) {
128 return Some(candidate);
129 }
130 }
131 let cached = match cached_counter_ids().read() {
132 Ok(guard) => guard,
133 Err(poisoned) => poisoned.into_inner(),
134 };
135 for prefix in &["ETF", "IX", "WT", "ST"] {
136 let candidate = format!("{prefix}/{market}/{code}");
137 if cached.contains(candidate.as_str()) {
138 return Some(candidate);
139 }
140 }
141 None
142}
143
144const CRYPTO_EXCHANGES: &[&str] = &["BKKT", "HAS", "OSL"];
159
160pub fn symbol_to_counter_id(symbol: &str) -> String {
165 if let Some((code, market)) = symbol.rsplit_once('.') {
166 if let Some(counter_id) = lookup_counter_id(symbol) {
167 return counter_id;
168 }
169 let market_upper = market.to_uppercase();
170 if CRYPTO_EXCHANGES
172 .iter()
173 .any(|e| e.eq_ignore_ascii_case(&market_upper))
174 {
175 return format!("VA/{market_upper}/{code}");
176 }
177 let code = if market_upper == "HK" && code.chars().all(|c| c.is_ascii_digit()) {
179 code.trim_start_matches('0')
180 } else {
181 code
182 };
183 format!("ST/{market_upper}/{code}")
184 } else {
185 symbol.to_string()
186 }
187}
188
189pub fn index_symbol_to_counter_id(symbol: &str) -> String {
192 if let Some((code, market)) = symbol.rsplit_once('.') {
193 format!("IX/{}/{code}", market.to_uppercase())
194 } else {
195 symbol.to_string()
196 }
197}
198
199pub fn counter_id_to_symbol(counter_id: &str) -> String {
206 let parts: Vec<&str> = counter_id.splitn(3, '/').collect();
207 if parts.len() == 3 {
208 let (prefix, market, code) = (parts[0], parts[1], parts[2]);
209 if prefix.eq_ignore_ascii_case("VA") {
210 return format!("{code}.{market}");
212 }
213 format!("{code}.{market}")
214 } else {
215 counter_id.to_string()
216 }
217}
218
219pub fn is_etf(symbol: &str) -> bool {
225 symbol_to_counter_id(symbol).starts_with("ETF/")
226}
227
228pub(crate) fn deserialize_counter_id_as_symbol<'de, D>(d: D) -> Result<String, D::Error>
230where
231 D: serde::Deserializer<'de>,
232{
233 use serde::Deserialize;
234 let counter_id = String::deserialize(d)?;
235 Ok(counter_id_to_symbol(&counter_id))
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn stock_us() {
244 assert_eq!(symbol_to_counter_id("TSLA.US"), "ST/US/TSLA");
245 }
246
247 #[test]
248 fn stock_hk() {
249 assert_eq!(symbol_to_counter_id("700.HK"), "ST/HK/700");
250 }
251
252 #[test]
253 fn stock_hk_leading_zeros() {
254 assert_eq!(symbol_to_counter_id("00700.HK"), "ST/HK/700");
255 }
256
257 #[test]
258 fn stock_hk_leading_zeros_short() {
259 assert_eq!(symbol_to_counter_id("09988.HK"), "ST/HK/9988");
260 }
261
262 #[test]
263 fn stock_sz_keeps_leading_zeros() {
264 assert_eq!(symbol_to_counter_id("000001.SZ"), "ST/SZ/000001");
265 }
266
267 #[test]
268 fn etf_us_spy() {
269 assert_eq!(symbol_to_counter_id("SPY.US"), "ETF/US/SPY");
270 }
271
272 #[test]
273 fn etf_us_qqq() {
274 assert_eq!(symbol_to_counter_id("QQQ.US"), "ETF/US/QQQ");
275 }
276
277 #[test]
278 fn etf_us_dram() {
279 assert_eq!(symbol_to_counter_id("DRAM.US"), "ETF/US/DRAM");
280 }
281
282 #[test]
283 fn market_suffix_lowercase_normalised() {
284 assert_eq!(symbol_to_counter_id("SPY.us"), "ETF/US/SPY");
285 }
286
287 #[test]
288 fn no_dot_passthrough() {
289 assert_eq!(symbol_to_counter_id("NODOT"), "NODOT");
290 }
291
292 #[test]
293 fn ix_us_dji() {
294 assert_eq!(symbol_to_counter_id(".DJI.US"), "IX/US/.DJI");
295 }
296
297 #[test]
298 fn ix_us_vix() {
299 assert_eq!(symbol_to_counter_id(".VIX.US"), "IX/US/.VIX");
300 }
301
302 #[test]
303 fn ix_us_ixic() {
304 assert_eq!(symbol_to_counter_id(".IXIC.US"), "IX/US/.IXIC");
305 }
306
307 #[test]
308 fn ix_us_spx() {
309 assert_eq!(symbol_to_counter_id(".SPX.US"), "IX/US/.SPX");
310 }
311
312 #[test]
313 fn ix_hk_hsi_via_set() {
314 assert_eq!(symbol_to_counter_id("HSI.HK"), "IX/HK/HSI");
315 }
316
317 #[test]
318 fn wt_hk_via_set() {
319 assert_eq!(symbol_to_counter_id("10005.HK"), "WT/HK/10005");
320 }
321
322 #[test]
323 fn is_etf_us() {
324 assert!(is_etf("QQQ.US"));
325 assert!(is_etf("SPY.US"));
326 assert!(is_etf("DRAM.US"));
327 }
328
329 #[test]
330 fn is_etf_non_etf() {
331 assert!(!is_etf("TSLA.US"));
332 assert!(!is_etf("HSI.HK"));
333 assert!(!is_etf("700.HK"));
334 }
335
336 #[test]
337 fn index() {
338 assert_eq!(index_symbol_to_counter_id("HSI.HK"), "IX/HK/HSI");
339 }
340
341 #[test]
342 fn counter_id_ix_us_to_symbol() {
343 assert_eq!(counter_id_to_symbol("IX/US/.DJI"), ".DJI.US");
344 }
345
346 #[test]
347 fn counter_id_ix_hk_to_symbol() {
348 assert_eq!(counter_id_to_symbol("IX/HK/HSI"), "HSI.HK");
349 }
350
351 #[test]
352 fn roundtrip() {
353 let cid = symbol_to_counter_id("TSLA.US");
354 assert_eq!(counter_id_to_symbol(&cid), "TSLA.US");
355 }
356
357 #[test]
358 fn cached_counter_ids_roundtrip() {
359 let dir = std::env::temp_dir().join("lb-counter-cache-test");
360 let dir = TEST_CACHE_DIR.get_or_init(|| dir).clone();
362
363 assert_eq!(lookup_counter_id("FAKE9.US"), None);
365 assert_eq!(symbol_to_counter_id("FAKE9.US"), "ST/US/FAKE9");
366
367 cache_counter_ids(["ETF/US/FAKE9", "ST/US/FAKE8"]);
370 assert_eq!(
371 lookup_counter_id("FAKE9.US").as_deref(),
372 Some("ETF/US/FAKE9")
373 );
374 assert_eq!(symbol_to_counter_id("FAKE9.US"), "ETF/US/FAKE9");
375 assert_eq!(
376 lookup_counter_id("FAKE8.US").as_deref(),
377 Some("ST/US/FAKE8")
378 );
379
380 let saved = std::fs::read_to_string(dir.join("counter-ids.csv")).unwrap();
382 assert_eq!(saved, "ETF/US/FAKE9\nST/US/FAKE8\n");
383 let _ = std::fs::remove_dir_all(&dir);
384 }
385
386 #[test]
387 fn lookup_known_special() {
388 assert_eq!(lookup_counter_id("QQQ.US").as_deref(), Some("ETF/US/QQQ"));
389 assert_eq!(lookup_counter_id("HSI.HK").as_deref(), Some("IX/HK/HSI"));
390 assert_eq!(lookup_counter_id(".DJI.US").as_deref(), Some("IX/US/.DJI"));
391 assert_eq!(lookup_counter_id("TSLA.US"), None);
392 assert_eq!(lookup_counter_id("NODOT"), None);
393 }
394}