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/// ETF + index + warrant set and the remote-resolved cache; a matching entry
151/// is returned as-is. Unmatched symbols default to `ST/`.
152pub 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        // Strip leading zeros from numeric HK codes (e.g. `00700` → `700`).
159        // Other markets keep their codes verbatim (A-share codes such as
160        // `000001.SZ` have significant leading zeros).
161        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
172/// Convert an index symbol (e.g. `HSI.HK`) to counter_id (e.g. `IX/HK/HSI`),
173/// always using the `IX/` prefix.
174pub 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
182/// Convert a counter_id (e.g. `ST/US/TSLA`, `ETF/US/SPY`, `IX/US/.DJI`,
183/// `ST/HK/700`) back to a display symbol (e.g. `TSLA.US`, `SPY.US`,
184/// `.DJI.US`, `700.HK`).
185///
186/// US index counter IDs (`IX/US/...`) preserve the leading dot in the code
187/// part (e.g. `IX/US/.DJI` → `.DJI.US`).
188pub 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
197/// Whether a user-supplied symbol resolves to an ETF (e.g. `QQQ.US`,
198/// `SPY.US`).
199///
200/// Determined by checking the embedded special counter_id set: a symbol is an
201/// ETF when [`symbol_to_counter_id`] maps it to an `ETF/...` counter_id.
202pub fn is_etf(symbol: &str) -> bool {
203    symbol_to_counter_id(symbol).starts_with("ETF/")
204}
205
206/// serde deserializer: reads a `counter_id` string and converts it to a symbol.
207pub(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        // Redirect the cache file away from the real user cache directory.
339        let dir = TEST_CACHE_DIR.get_or_init(|| dir).clone();
340
341        // Unknown symbol falls back to ST/ before caching
342        assert_eq!(lookup_counter_id("FAKE9.US"), None);
343        assert_eq!(symbol_to_counter_id("FAKE9.US"), "ST/US/FAKE9");
344
345        // After caching remote-resolved entries, lookups return them —
346        // including backend-confirmed ST/ entries
347        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        // Persisted to disk as one counter_id per line
359        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}