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        8 => Some("AU"),
83        15 => Some("JP"),
84        111 => Some("MY"),
85        112 => Some("CA"),
86        113 => Some("HKFUND"),
87        123 => Some("USFUND"),
88        _ => None,
89    }
90}
91
92/// Label for fund markets that are view-only on active write/calculation paths.
93///
94/// This intentionally covers two namespaces:
95/// - backend raw `Account.market` values cached in `CachedTrdAcc.trd_market`;
96/// - canonical OpenAPI `Trd_Common.TrdMarket` fund values.
97///
98/// `None` means the market is not a fund/view-only market. Do not use this as a
99/// generic display label; use `trd_market_label` for ordinary surface labels.
100#[must_use]
101pub fn view_only_fund_market_label(trd_market: i32) -> Option<&'static str> {
102    use crate::currency::{legacy_backend_fund_market_id, trd_market_id};
103
104    match trd_market {
105        // backend raw `Account.market` values
106        legacy_backend_fund_market_id::HK_FUND => Some("HKFund(raw)"),
107        legacy_backend_fund_market_id::US_FUND_OLD => Some("USFund(raw,old)"),
108        legacy_backend_fund_market_id::US_FUND => Some("USFund(raw)"),
109        legacy_backend_fund_market_id::SG_FUND => Some("SGFund(raw)"),
110        // OpenAPI canonical `NN_TrdMarket` values
111        trd_market_id::HK_FUND => Some("HKFund"),
112        trd_market_id::US_FUND => Some("USFund"),
113        trd_market_id::SG_FUND => Some("SGFund"),
114        trd_market_id::MY_FUND => Some("MYFund"),
115        trd_market_id::JP_FUND => Some("JPFund"),
116        _ => None,
117    }
118}
119
120/// Return whether the code looks like a futures symbol.
121///
122/// This is a fallback heuristic; cache-backed security type remains the more
123/// authoritative source when available.
124pub fn is_futures_code(code: &str) -> bool {
125    let code = strip_market_prefix(code);
126    let code = code.as_str();
127
128    if let Some(stem) = code
129        .strip_suffix("main")
130        .or_else(|| code.strip_suffix(".main"))
131    {
132        let stem = stem.trim_end_matches('.');
133        if (1..=4).contains(&stem.len()) && stem.chars().all(|c| c.is_ascii_alphabetic()) {
134            return true;
135        }
136    }
137
138    if (5..=8).contains(&code.len()) {
139        let len = code.len();
140        let bytes = code.as_bytes();
141        if !bytes[len - 4..].iter().all(|b| b.is_ascii_digit()) {
142            return false;
143        }
144        let prefix = &code[..len - 4];
145        return !prefix.is_empty() && prefix.chars().any(|c| c.is_ascii_alphabetic());
146    }
147
148    false
149}
150
151/// Derive `TrdSecMarket` from known futures ticker prefixes.
152pub fn futures_ticker_to_sec_market(code: &str) -> Option<i32> {
153    if !is_futures_code(code) {
154        return None;
155    }
156    let ticker = extract_futures_ticker_prefix(code);
157    if matches!(
158        ticker.as_str(),
159        "NQ" | "MNQ"
160            | "ES"
161            | "MES"
162            | "RTY"
163            | "M2K"
164            | "NKD"
165            | "YM"
166            | "MYM"
167            | "6E"
168            | "6J"
169            | "6B"
170            | "6A"
171            | "6C"
172            | "6S"
173            | "6M"
174            | "6N"
175            | "GE"
176            | "SR3"
177            | "BTC"
178            | "MBT"
179            | "ETH"
180            | "MET"
181            | "CL"
182            | "MCL"
183            | "NG"
184            | "MNG"
185            | "HO"
186            | "RB"
187            | "BZ"
188            | "WBS"
189            | "GC"
190            | "MGC"
191            | "SI"
192            | "SIL"
193            | "HG"
194            | "MHG"
195            | "PL"
196            | "PA"
197            | "ZS"
198            | "ZC"
199            | "ZW"
200            | "ZO"
201            | "ZR"
202            | "ZT"
203            | "ZF"
204            | "ZN"
205            | "ZB"
206            | "UB"
207            | "TN"
208            | "VX"
209            | "VXM"
210    ) {
211        return Some(2);
212    }
213    if matches!(
214        ticker.as_str(),
215        "HSI" | "HHI" | "HTI" | "MHI" | "MCH" | "VHSI" | "CSI300" | "CSI500" | "CSI800"
216    ) {
217        return Some(1);
218    }
219    None
220}
221
222/// Extract the ticker prefix from a futures code.
223pub fn extract_futures_ticker_prefix(code: &str) -> String {
224    let bare = strip_market_prefix(code);
225    let bare = bare.strip_suffix("main").unwrap_or(&bare);
226    let chars: Vec<char> = bare.chars().collect();
227    let len = chars.len();
228    if len >= 4 && chars[len - 4..].iter().all(|c| c.is_ascii_digit()) {
229        return chars[..len - 4].iter().collect::<String>().to_uppercase();
230    }
231    bare.to_uppercase()
232}
233
234#[cfg(test)]
235mod tests;