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
144pub fn symbol_to_counter_id(symbol: &str) -> String {
153 if let Some((code, market)) = symbol.rsplit_once('.') {
154 if let Some(counter_id) = lookup_counter_id(symbol) {
155 return counter_id;
156 }
157 let market = market.to_uppercase();
158 let code = if market == "HK" && code.chars().all(|c| c.is_ascii_digit()) {
162 code.trim_start_matches('0')
163 } else {
164 code
165 };
166 format!("ST/{market}/{code}")
167 } else {
168 symbol.to_string()
169 }
170}
171
172pub fn index_symbol_to_counter_id(symbol: &str) -> String {
175 if let Some((code, market)) = symbol.rsplit_once('.') {
176 format!("IX/{}/{code}", market.to_uppercase())
177 } else {
178 symbol.to_string()
179 }
180}
181
182pub fn counter_id_to_symbol(counter_id: &str) -> String {
189 let parts: Vec<&str> = counter_id.splitn(3, '/').collect();
190 if parts.len() == 3 {
191 format!("{}.{}", parts[2], parts[1])
192 } else {
193 counter_id.to_string()
194 }
195}
196
197pub fn is_etf(symbol: &str) -> bool {
203 symbol_to_counter_id(symbol).starts_with("ETF/")
204}
205
206pub(crate) fn deserialize_counter_id_as_symbol<'de, D>(d: D) -> Result<String, D::Error>
208where
209 D: serde::Deserializer<'de>,
210{
211 use serde::Deserialize;
212 let counter_id = String::deserialize(d)?;
213 Ok(counter_id_to_symbol(&counter_id))
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn stock_us() {
222 assert_eq!(symbol_to_counter_id("TSLA.US"), "ST/US/TSLA");
223 }
224
225 #[test]
226 fn stock_hk() {
227 assert_eq!(symbol_to_counter_id("700.HK"), "ST/HK/700");
228 }
229
230 #[test]
231 fn stock_hk_leading_zeros() {
232 assert_eq!(symbol_to_counter_id("00700.HK"), "ST/HK/700");
233 }
234
235 #[test]
236 fn stock_hk_leading_zeros_short() {
237 assert_eq!(symbol_to_counter_id("09988.HK"), "ST/HK/9988");
238 }
239
240 #[test]
241 fn stock_sz_keeps_leading_zeros() {
242 assert_eq!(symbol_to_counter_id("000001.SZ"), "ST/SZ/000001");
243 }
244
245 #[test]
246 fn etf_us_spy() {
247 assert_eq!(symbol_to_counter_id("SPY.US"), "ETF/US/SPY");
248 }
249
250 #[test]
251 fn etf_us_qqq() {
252 assert_eq!(symbol_to_counter_id("QQQ.US"), "ETF/US/QQQ");
253 }
254
255 #[test]
256 fn etf_us_dram() {
257 assert_eq!(symbol_to_counter_id("DRAM.US"), "ETF/US/DRAM");
258 }
259
260 #[test]
261 fn market_suffix_lowercase_normalised() {
262 assert_eq!(symbol_to_counter_id("SPY.us"), "ETF/US/SPY");
263 }
264
265 #[test]
266 fn no_dot_passthrough() {
267 assert_eq!(symbol_to_counter_id("NODOT"), "NODOT");
268 }
269
270 #[test]
271 fn ix_us_dji() {
272 assert_eq!(symbol_to_counter_id(".DJI.US"), "IX/US/.DJI");
273 }
274
275 #[test]
276 fn ix_us_vix() {
277 assert_eq!(symbol_to_counter_id(".VIX.US"), "IX/US/.VIX");
278 }
279
280 #[test]
281 fn ix_us_ixic() {
282 assert_eq!(symbol_to_counter_id(".IXIC.US"), "IX/US/.IXIC");
283 }
284
285 #[test]
286 fn ix_us_spx() {
287 assert_eq!(symbol_to_counter_id(".SPX.US"), "IX/US/.SPX");
288 }
289
290 #[test]
291 fn ix_hk_hsi_via_set() {
292 assert_eq!(symbol_to_counter_id("HSI.HK"), "IX/HK/HSI");
293 }
294
295 #[test]
296 fn wt_hk_via_set() {
297 assert_eq!(symbol_to_counter_id("10005.HK"), "WT/HK/10005");
298 }
299
300 #[test]
301 fn is_etf_us() {
302 assert!(is_etf("QQQ.US"));
303 assert!(is_etf("SPY.US"));
304 assert!(is_etf("DRAM.US"));
305 }
306
307 #[test]
308 fn is_etf_non_etf() {
309 assert!(!is_etf("TSLA.US"));
310 assert!(!is_etf("HSI.HK"));
311 assert!(!is_etf("700.HK"));
312 }
313
314 #[test]
315 fn index() {
316 assert_eq!(index_symbol_to_counter_id("HSI.HK"), "IX/HK/HSI");
317 }
318
319 #[test]
320 fn counter_id_ix_us_to_symbol() {
321 assert_eq!(counter_id_to_symbol("IX/US/.DJI"), ".DJI.US");
322 }
323
324 #[test]
325 fn counter_id_ix_hk_to_symbol() {
326 assert_eq!(counter_id_to_symbol("IX/HK/HSI"), "HSI.HK");
327 }
328
329 #[test]
330 fn roundtrip() {
331 let cid = symbol_to_counter_id("TSLA.US");
332 assert_eq!(counter_id_to_symbol(&cid), "TSLA.US");
333 }
334
335 #[test]
336 fn cached_counter_ids_roundtrip() {
337 let dir = std::env::temp_dir().join("lb-counter-cache-test");
338 let dir = TEST_CACHE_DIR.get_or_init(|| dir).clone();
340
341 assert_eq!(lookup_counter_id("FAKE9.US"), None);
343 assert_eq!(symbol_to_counter_id("FAKE9.US"), "ST/US/FAKE9");
344
345 cache_counter_ids(["ETF/US/FAKE9", "ST/US/FAKE8"]);
348 assert_eq!(
349 lookup_counter_id("FAKE9.US").as_deref(),
350 Some("ETF/US/FAKE9")
351 );
352 assert_eq!(symbol_to_counter_id("FAKE9.US"), "ETF/US/FAKE9");
353 assert_eq!(
354 lookup_counter_id("FAKE8.US").as_deref(),
355 Some("ST/US/FAKE8")
356 );
357
358 let saved = std::fs::read_to_string(dir.join("counter-ids.csv")).unwrap();
360 assert_eq!(saved, "ETF/US/FAKE9\nST/US/FAKE8\n");
361 let _ = std::fs::remove_dir_all(&dir);
362 }
363
364 #[test]
365 fn lookup_known_special() {
366 assert_eq!(lookup_counter_id("QQQ.US").as_deref(), Some("ETF/US/QQQ"));
367 assert_eq!(lookup_counter_id("HSI.HK").as_deref(), Some("IX/HK/HSI"));
368 assert_eq!(lookup_counter_id(".DJI.US").as_deref(), Some("IX/US/.DJI"));
369 assert_eq!(lookup_counter_id("TSLA.US"), None);
370 assert_eq!(lookup_counter_id("NODOT"), None);
371 }
372}