Skip to main content

longbridge/utils/
counter.rs

1//! Symbol ↔ counter_id conversion utilities.
2//!
3//! A `counter_id` is the internal instrument identifier used by the
4//! Longbridge backend, e.g. `ST/US/TSLA`, `ETF/US/SPY`, `IX/HK/HSI`,
5//! `WT/HK/10005`. These helpers convert between user-facing symbols
6//! (e.g. `TSLA.US`, `700.HK`, `.DJI.US`) and counter IDs, using an
7//! embedded ETF + index + warrant directory to pick the right prefix.
8//!
9//! The embedded directory may lag behind newly listed instruments. Entries
10//! resolved remotely (see `QuoteContext::resolve_counter_ids`) are persisted
11//! to a local cache file and consulted on subsequent lookups.
12
13use 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
36// ── remote-resolved counter_id cache ──────────────────────────────
37
38static CACHED_COUNTER_IDS: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
39
40#[cfg(test)]
41static TEST_CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
42
43/// Cache file path: `$LONGBRIDGE_CACHE_DIR/counter-ids.csv`, defaulting to
44/// `~/.longbridge/cache/counter-ids.csv` (one counter_id per line, same
45/// format as the embedded directory files).
46fn 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
80/// Merge remotely resolved counter IDs into the local cache (in memory and
81/// on disk), so subsequent [`symbol_to_counter_id`] / [`lookup_counter_id`]
82/// calls resolve them without another network round trip.
83pub 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
109/// Look up a symbol in the local directory only (embedded special set, the
110/// remote-resolved cache, and leading-dot index notation). Returns `None`
111/// when the symbol is unknown locally — i.e. [`symbol_to_counter_id`] would
112/// fall back to the default `ST/` prefix, which may be wrong for newly
113/// listed ETFs / indexes / warrants.
114pub 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
144/// Convert a user-supplied symbol (e.g. `TSLA.US`, `700.HK`, `.DJI.US`,
145/// `HSI.HK`) to a counter_id (e.g. `ST/US/TSLA`, `ST/HK/700`, `IX/US/.DJI`,
146/// `IX/HK/HSI`).
147///
148/// Leading-dot symbols (e.g. `.DJI.US`) are US market indexes and always map
149/// to `IX/`. All other symbols are checked against the embedded
150/// Known crypto exchange identifiers used as symbol suffixes.
151///
152/// Region mapping:
153/// - US DC: `BKKT`
154/// - HK DC: `HAS`, `OSL`
155///
156/// e.g. `"BTCUSD.BKKT"` → `"VA/BKKT/BTCUSD"`, `"BTCUSD.HAS"` →
157/// `"VA/HAS/BTCUSD"`.
158const CRYPTO_EXCHANGES: &[&str] = &["BKKT", "HAS", "OSL"];
159
160/// ETF + index + warrant set and the remote-resolved cache; a matching entry
161/// is returned as-is. Crypto symbols whose suffix matches a known exchange
162/// (e.g. `HAS`) are converted to `VA/{EXCHANGE}/{PAIR}`.
163/// Unmatched symbols default to `ST/`.
164pub 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        // Known crypto exchange suffix → VA/{EXCHANGE}/{PAIR}
171        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        // Strip leading zeros from numeric HK codes (e.g. `00700` → `700`).
178        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
189/// Convert an index symbol (e.g. `HSI.HK`) to counter_id (e.g. `IX/HK/HSI`),
190/// always using the `IX/` prefix.
191pub 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
199/// Convert a counter_id back to a display symbol.
200///
201/// - `ST/US/TSLA`    → `TSLA.US`
202/// - `ETF/US/SPY`    → `SPY.US`
203/// - `IX/US/.DJI`    → `.DJI.US`
204/// - `VA/HAS/BTCUSD` → `BTCUSD.HAS`  (crypto: `PAIR.EXCHANGE`)
205pub 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            // Crypto: return PAIR.EXCHANGE format
211            return format!("{code}.{market}");
212        }
213        format!("{code}.{market}")
214    } else {
215        counter_id.to_string()
216    }
217}
218
219/// Whether a user-supplied symbol resolves to an ETF (e.g. `QQQ.US`,
220/// `SPY.US`).
221///
222/// Determined by checking the embedded special counter_id set: a symbol is an
223/// ETF when [`symbol_to_counter_id`] maps it to an `ETF/...` counter_id.
224pub fn is_etf(symbol: &str) -> bool {
225    symbol_to_counter_id(symbol).starts_with("ETF/")
226}
227
228/// serde deserializer: reads a `counter_id` string and converts it to a symbol.
229pub(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        // Redirect the cache file away from the real user cache directory.
361        let dir = TEST_CACHE_DIR.get_or_init(|| dir).clone();
362
363        // Unknown symbol falls back to ST/ before caching
364        assert_eq!(lookup_counter_id("FAKE9.US"), None);
365        assert_eq!(symbol_to_counter_id("FAKE9.US"), "ST/US/FAKE9");
366
367        // After caching remote-resolved entries, lookups return them —
368        // including backend-confirmed ST/ entries
369        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        // Persisted to disk as one counter_id per line
381        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}