Skip to main content

futu_trd/
market.rs

1//! Shared trade market projection helpers.
2//!
3//! These are pure helpers used by gateway response projection and CLI/domain
4//! adapters. Keep them here instead of in one surface handler so market prefix
5//! and futures-ticker fallback rules have a single callable source.
6
7/// Strip a known FTAPI market prefix from a user/security code.
8///
9/// Unknown dotted symbols such as `BRK.B` are preserved.
10pub fn strip_market_prefix(code: &str) -> String {
11    const MARKET_PREFIXES: &[&str] = &[
12        "HK.", "US.", "SH.", "SZ.", "SG.", "JP.", "AU.", "CA.", "MY.", "BJ.", "CN.",
13    ];
14    for prefix in MARKET_PREFIXES {
15        if let Some(stripped) = code.strip_prefix(prefix) {
16            return stripped.to_string();
17        }
18    }
19    code.to_string()
20}
21
22/// Derive FTAPI `TrdSecMarket` from explicit value, account market, and code.
23///
24/// Prefix and futures ticker fallback intentionally win over SDK supplied
25/// market, matching the established v1.4.56 behavior for futures symbols where
26/// client SDK market metadata can be stale or too generic.
27pub fn derive_sec_market(ftapi_sec_market: i32, trd_market: i32, code: &str) -> i32 {
28    if let Some(from_prefix) = sec_market_from_code_prefix(code) {
29        return from_prefix;
30    }
31
32    if let Some(from_ticker) = futures_ticker_to_sec_market(code) {
33        return from_ticker;
34    }
35
36    if ftapi_sec_market != 0 {
37        return ftapi_sec_market;
38    }
39
40    match trd_market {
41        1 | 4 | 113 => 1,
42        2 | 11 | 123 => 2,
43        3 => {
44            let bare = code
45                .trim_start_matches("SH.")
46                .trim_start_matches("SZ.")
47                .trim_start_matches("CN.");
48            match bare.chars().next() {
49                Some('6') | Some('9') => 31,
50                Some('0') | Some('2') | Some('3') => 32,
51                _ => 31,
52            }
53        }
54        6 | 12 | 124 => 41,
55        8 => 61,
56        15 | 13 | 126 => 51,
57        111 | 125 => 71,
58        112 => 81,
59        _ => 0,
60    }
61}
62
63/// Derive `TrdSecMarket` from an explicit code prefix such as `HK.` / `US.`.
64pub fn sec_market_from_code_prefix(code: &str) -> Option<i32> {
65    futu_core::market::entry_by_code_prefix(code).map(|e| e.sec_market)
66}
67
68/// Canonical `Trd_Common.TrdMarket` label used by user-facing filters and
69/// surface adapters.
70///
71/// Keep this table in the trade domain so CLI / MCP / REST do not drift on
72/// newer view-only markets such as HKFUND / USFUND.
73#[must_use]
74pub fn trd_market_label(i: i32) -> Option<&'static str> {
75    match i {
76        1 => Some("HK"),
77        2 => Some("US"),
78        3 => Some("CN"),
79        4 => Some("HKCC"),
80        5 => Some("FUTURES"),
81        6 => Some("SG"),
82        7 => Some("CRYPTO"),
83        8 => Some("AU"),
84        10 => Some("FUTURES_SIMULATE_HK"),
85        11 => Some("FUTURES_SIMULATE_US"),
86        12 => Some("FUTURES_SIMULATE_SG"),
87        13 => Some("FUTURES_SIMULATE_JP"),
88        15 => Some("JP"),
89        111 => Some("MY"),
90        112 => Some("CA"),
91        113 => Some("HKFUND"),
92        123 => Some("USFUND"),
93        124 => Some("SGFUND"),
94        125 => Some("MYFUND"),
95        126 => Some("JPFUND"),
96        _ => None,
97    }
98}
99
100/// Label for fund markets that are view-only on active write/calculation paths.
101///
102/// This intentionally covers two namespaces:
103/// - backend raw `Account.market` values cached in `CachedTrdAcc.trd_market`;
104/// - canonical OpenAPI `Trd_Common.TrdMarket` fund values.
105///
106/// `None` means the market is not a fund/view-only market. Do not use this as a
107/// generic display label; use `trd_market_label` for ordinary surface labels.
108#[must_use]
109pub fn view_only_fund_market_label(trd_market: i32) -> Option<&'static str> {
110    use crate::currency::{legacy_backend_fund_market_id, trd_market_id};
111
112    match trd_market {
113        // backend raw `Account.market` values
114        legacy_backend_fund_market_id::HK_FUND => Some("HKFund(raw)"),
115        legacy_backend_fund_market_id::US_FUND_OLD => Some("USFund(raw,old)"),
116        legacy_backend_fund_market_id::US_FUND => Some("USFund(raw)"),
117        legacy_backend_fund_market_id::SG_FUND => Some("SGFund(raw)"),
118        // OpenAPI canonical `NN_TrdMarket` values
119        trd_market_id::HK_FUND => Some("HKFund"),
120        trd_market_id::US_FUND => Some("USFund"),
121        trd_market_id::SG_FUND => Some("SGFund"),
122        trd_market_id::MY_FUND => Some("MYFund"),
123        trd_market_id::JP_FUND => Some("JPFund"),
124        _ => None,
125    }
126}
127
128/// Return whether the code looks like a futures symbol.
129///
130/// This is a cache-miss pattern fallback; cache-backed security type remains
131/// the more authoritative source when available.
132pub fn is_futures_code(code: &str) -> bool {
133    let code = strip_market_prefix(code);
134    let code = code.as_str();
135
136    if let Some(stem) = code
137        .strip_suffix("main")
138        .or_else(|| code.strip_suffix(".main"))
139    {
140        let stem = stem.trim_end_matches('.');
141        if (1..=4).contains(&stem.len()) && stem.chars().all(|c| c.is_ascii_alphabetic()) {
142            return true;
143        }
144    }
145
146    if (5..=8).contains(&code.len()) {
147        let len = code.len();
148        let bytes = code.as_bytes();
149        if !bytes[len - 4..].iter().all(|b| b.is_ascii_digit()) {
150            return false;
151        }
152        let ticker = &bytes[..len - 4];
153        return (1..=4).contains(&ticker.len())
154            && ticker.iter().all(|b| b.is_ascii_alphanumeric())
155            && ticker.iter().any(|b| b.is_ascii_alphabetic());
156    }
157
158    false
159}
160
161/// Derive `TrdSecMarket` from known futures ticker prefixes.
162pub fn futures_ticker_to_sec_market(code: &str) -> Option<i32> {
163    if !is_futures_code(code) {
164        return None;
165    }
166    let ticker = extract_futures_ticker_prefix(code);
167    if matches!(
168        ticker.as_str(),
169        "NQ" | "MNQ"
170            | "ES"
171            | "MES"
172            | "RTY"
173            | "M2K"
174            | "NKD"
175            | "YM"
176            | "MYM"
177            | "6E"
178            | "6J"
179            | "6B"
180            | "6A"
181            | "6C"
182            | "6S"
183            | "6M"
184            | "6N"
185            | "GE"
186            | "SR3"
187            | "BTC"
188            | "MBT"
189            | "ETH"
190            | "MET"
191            | "CL"
192            | "MCL"
193            | "NG"
194            | "MNG"
195            | "HO"
196            | "RB"
197            | "BZ"
198            | "WBS"
199            | "GC"
200            | "MGC"
201            | "SI"
202            | "SIL"
203            | "HG"
204            | "MHG"
205            | "PL"
206            | "PA"
207            | "ZS"
208            | "ZC"
209            | "ZW"
210            | "ZO"
211            | "ZR"
212            | "ZT"
213            | "ZF"
214            | "ZN"
215            | "ZB"
216            | "UB"
217            | "TN"
218            | "VX"
219            | "VXM"
220    ) {
221        return Some(2);
222    }
223    if matches!(
224        ticker.as_str(),
225        "HSI" | "HHI" | "HTI" | "MHI" | "MCH" | "VHSI" | "CSI300" | "CSI500" | "CSI800"
226    ) {
227        return Some(1);
228    }
229    None
230}
231
232/// Extract the ticker prefix from a futures code.
233pub fn extract_futures_ticker_prefix(code: &str) -> String {
234    let bare = strip_market_prefix(code);
235    let bare = bare.strip_suffix("main").unwrap_or(&bare);
236    let chars: Vec<char> = bare.chars().collect();
237    let len = chars.len();
238    if len >= 4 && chars[len - 4..].iter().all(|c| c.is_ascii_digit()) {
239        return chars[..len - 4].iter().collect::<String>().to_uppercase();
240    }
241    bare.to_uppercase()
242}
243
244#[cfg(test)]
245mod tests;