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
7use crate::types::TrdMarket;
8use futu_core::market::TrdSecMarketId;
9
10/// Backend raw `Account.market` value cached in `CachedTrdAcc.trd_market`.
11///
12/// Keep this distinct from canonical OpenAPI `Trd_Common.TrdMarket`: raw
13/// HKFund is `13`, while canonical `TrdMarket::FuturesSimulateJP` is also
14/// `13`. Calling raw helpers requires constructing this wrapper at the cache
15/// boundary so review can see the namespace crossing.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct RawAccountMarket(i32);
18
19impl RawAccountMarket {
20    pub const fn new(value: i32) -> Self {
21        Self(value)
22    }
23
24    pub const fn as_i32(self) -> i32 {
25        self.0
26    }
27
28    #[must_use]
29    pub fn view_only_fund_label(self) -> Option<&'static str> {
30        use crate::currency::legacy_backend_fund_market_id;
31
32        match self.0 {
33            legacy_backend_fund_market_id::HK_FUND => Some("HKFund(raw)"),
34            legacy_backend_fund_market_id::US_FUND_OLD => Some("USFund(raw,old)"),
35            legacy_backend_fund_market_id::US_FUND => Some("USFund(raw)"),
36            legacy_backend_fund_market_id::SG_FUND => Some("SGFund(raw)"),
37            _ => None,
38        }
39    }
40}
41
42/// Market namespace read from `CachedTrdAcc.trd_market`.
43///
44/// The production cache stores backend raw `Account.market`, but this boundary
45/// deliberately stays fail-closed for legacy/test fixtures that may contain
46/// canonical OpenAPI fund market values from earlier projections.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub struct CachedAccountMarket(i32);
49
50impl CachedAccountMarket {
51    pub const fn new(value: i32) -> Self {
52        Self(value)
53    }
54
55    pub const fn as_i32(self) -> i32 {
56        self.0
57    }
58
59    #[must_use]
60    pub fn view_only_fund_label(self) -> Option<&'static str> {
61        use crate::currency::trd_market_id;
62
63        RawAccountMarket::new(self.0)
64            .view_only_fund_label()
65            .or(match self.0 {
66                trd_market_id::HK_FUND => Some("HKFund"),
67                trd_market_id::US_FUND => Some("USFund"),
68                trd_market_id::SG_FUND => Some("SGFund"),
69                trd_market_id::MY_FUND => Some("MYFund"),
70                trd_market_id::JP_FUND => Some("JPFund"),
71                _ => None,
72            })
73    }
74}
75
76/// User-facing aliases accepted for `Trd_Common.TrdMarket` read paths.
77pub const TRD_MARKET_STRING_VALUES: &[&str] = &[
78    "HK",
79    "US",
80    "CN",
81    "HKCC",
82    "FUTURES",
83    "SG",
84    "CRYPTO",
85    "AU",
86    "FUTURES_SIMULATE_HK",
87    "FUTURES_SIMULATE_US",
88    "FUTURES_SIMULATE_SG",
89    "FUTURES_SIMULATE_JP",
90    "JP",
91    "MY",
92    "CA",
93    "HKFUND",
94    "USFUND",
95    "SGFUND",
96    "MYFUND",
97    "JPFUND",
98];
99
100pub const TRD_MARKET_PARSE_CHOICES: &str = "HK|US|CN|HKCC|FUTURES|SG|CRYPTO|AU|\
101FUTURES_SIMULATE_HK|FUTURES_SIMULATE_US|FUTURES_SIMULATE_SG|FUTURES_SIMULATE_JP|\
102JP|MY|CA|HKFUND|USFUND|SGFUND|MYFUND|JPFUND or official TrdMarket int";
103
104/// User-facing aliases accepted for active write/calculation paths.
105pub const TRD_MARKET_NON_FUND_STRING_VALUES: &[&str] = &[
106    "HK",
107    "US",
108    "CN",
109    "HKCC",
110    "FUTURES",
111    "SG",
112    "CRYPTO",
113    "AU",
114    "FUTURES_SIMULATE_HK",
115    "FUTURES_SIMULATE_US",
116    "FUTURES_SIMULATE_SG",
117    "FUTURES_SIMULATE_JP",
118    "JP",
119    "MY",
120    "CA",
121];
122
123pub const TRD_MARKET_NON_FUND_PARSE_CHOICES: &str = "HK|US|CN|HKCC|FUTURES|SG|\
124CRYPTO|AU|FUTURES_SIMULATE_HK|FUTURES_SIMULATE_US|FUTURES_SIMULATE_SG|\
125FUTURES_SIMULATE_JP|JP|MY|CA or official non-fund TrdMarket int";
126
127/// Official OpenAPI `Trd_Common.TrdMarket` integer values accepted by read
128/// surfaces, including view-only fund markets.
129pub const TRD_MARKET_INT_VALUES: &[i32] = &[
130    1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 15, 111, 112, 113, 123, 124, 125, 126,
131];
132
133/// Official OpenAPI `Trd_Common.TrdMarket` integer values accepted by active
134/// write/calculation surfaces. Fund markets stay read-only.
135pub const TRD_MARKET_NON_FUND_INT_VALUES: &[i32] =
136    &[1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 15, 111, 112];
137
138/// Parse a user-facing trade market string into the canonical OpenAPI
139/// `Trd_Common.TrdMarket` enum.
140///
141/// Keep this in `futu-trd` so CLI, MCP read handlers, and MCP write handlers do
142/// not drift when OpenAPI adds a market variant. The parser intentionally
143/// accepts both labels and official enum integers.
144#[must_use]
145pub fn parse_trd_market(raw: &str) -> Option<TrdMarket> {
146    let upper = raw.trim().to_ascii_uppercase();
147    let compact = upper.replace('_', "");
148    let market = match upper.as_str() {
149        "HK" => TrdMarket::HK,
150        "US" => TrdMarket::US,
151        "CN" => TrdMarket::CN,
152        "HKCC" => TrdMarket::HKCC,
153        "FUTURES" => TrdMarket::Futures,
154        "SG" => TrdMarket::SG,
155        "CRYPTO" => TrdMarket::Crypto,
156        "AU" => TrdMarket::AU,
157        "JP" => TrdMarket::JP,
158        "MY" => TrdMarket::MY,
159        "CA" => TrdMarket::CA,
160        _ => match compact.as_str() {
161            "FUTURESSIMULATEHK" => TrdMarket::FuturesSimulateHK,
162            "FUTURESSIMULATEUS" => TrdMarket::FuturesSimulateUS,
163            "FUTURESSIMULATESG" => TrdMarket::FuturesSimulateSG,
164            "FUTURESSIMULATEJP" => TrdMarket::FuturesSimulateJP,
165            "HKFUND" => TrdMarket::HKFund,
166            "USFUND" => TrdMarket::USFund,
167            "SGFUND" => TrdMarket::SGFund,
168            "MYFUND" => TrdMarket::MYFund,
169            "JPFUND" => TrdMarket::JPFund,
170            _ => return upper.parse::<i32>().ok().and_then(trd_market_from_i32),
171        },
172    };
173    Some(market)
174}
175
176/// Convert official OpenAPI `Trd_Common.TrdMarket` integer values into the
177/// canonical project enum.
178#[must_use]
179pub fn trd_market_from_i32(value: i32) -> Option<TrdMarket> {
180    match value {
181        1 => Some(TrdMarket::HK),
182        2 => Some(TrdMarket::US),
183        3 => Some(TrdMarket::CN),
184        4 => Some(TrdMarket::HKCC),
185        5 => Some(TrdMarket::Futures),
186        6 => Some(TrdMarket::SG),
187        7 => Some(TrdMarket::Crypto),
188        8 => Some(TrdMarket::AU),
189        10 => Some(TrdMarket::FuturesSimulateHK),
190        11 => Some(TrdMarket::FuturesSimulateUS),
191        12 => Some(TrdMarket::FuturesSimulateSG),
192        13 => Some(TrdMarket::FuturesSimulateJP),
193        15 => Some(TrdMarket::JP),
194        111 => Some(TrdMarket::MY),
195        112 => Some(TrdMarket::CA),
196        113 => Some(TrdMarket::HKFund),
197        123 => Some(TrdMarket::USFund),
198        124 => Some(TrdMarket::SGFund),
199        125 => Some(TrdMarket::MYFund),
200        126 => Some(TrdMarket::JPFund),
201        _ => None,
202    }
203}
204
205/// Label for canonical OpenAPI fund markets only.
206///
207/// Do not use `view_only_fund_market_label` for parsed enum values: that helper
208/// also covers backend raw cache IDs, where raw HKFund=13 overlaps canonical
209/// `TrdMarket::FuturesSimulateJP`.
210#[must_use]
211pub fn canonical_fund_trd_market_label(market: TrdMarket) -> Option<&'static str> {
212    match market {
213        TrdMarket::HKFund => Some("HKFund"),
214        TrdMarket::USFund => Some("USFund"),
215        TrdMarket::SGFund => Some("SGFund"),
216        TrdMarket::MYFund => Some("MYFund"),
217        TrdMarket::JPFund => Some("JPFund"),
218        _ => None,
219    }
220}
221
222/// Parse a trade market for active write/calculation paths.
223#[must_use]
224pub fn parse_non_fund_trd_market(raw: &str) -> Option<TrdMarket> {
225    parse_trd_market(raw).and_then(|market| {
226        if canonical_fund_trd_market_label(market).is_some() {
227            None
228        } else {
229            Some(market)
230        }
231    })
232}
233
234/// Strip a known FTAPI market prefix from a user/security code.
235///
236/// Unknown dotted symbols such as `BRK.B` are preserved.
237pub fn strip_market_prefix(code: &str) -> String {
238    const MARKET_PREFIXES: &[&str] = &[
239        "HK.", "US.", "SH.", "SZ.", "SG.", "JP.", "AU.", "CA.", "MY.", "BJ.", "CN.",
240    ];
241    for prefix in MARKET_PREFIXES {
242        if let Some(stripped) = code.strip_prefix(prefix) {
243            return stripped.to_string();
244        }
245    }
246    code.to_string()
247}
248
249/// Derive FTAPI `TrdSecMarket` from explicit value, account market, and code.
250///
251/// Prefix and futures ticker fallback intentionally win over SDK supplied
252/// market, matching the established v1.4.56 behavior for futures symbols where
253/// client SDK market metadata can be stale or too generic.
254pub fn derive_trd_sec_market(ftapi_sec_market: i32, trd_market: i32, code: &str) -> TrdSecMarketId {
255    if let Some(from_prefix) = sec_market_from_code_prefix_typed(code) {
256        return from_prefix;
257    }
258
259    if let Some(from_ticker) = futures_ticker_to_trd_sec_market(code) {
260        return from_ticker;
261    }
262
263    if ftapi_sec_market != 0 {
264        return TrdSecMarketId::new(ftapi_sec_market);
265    }
266
267    TrdSecMarketId::new(match trd_market {
268        1 | 4 | 113 => 1,
269        2 | 11 | 123 => 2,
270        3 => {
271            let bare = code
272                .trim_start_matches("SH.")
273                .trim_start_matches("SZ.")
274                .trim_start_matches("CN.");
275            match bare.chars().next() {
276                Some('6') | Some('9') => 31,
277                Some('0') | Some('2') | Some('3') => 32,
278                _ => 31,
279            }
280        }
281        6 | 12 | 124 => 41,
282        8 => 61,
283        15 | 13 | 126 => 51,
284        111 | 125 => 71,
285        112 => 81,
286        _ => 0,
287    })
288}
289
290/// Derive FTAPI `TrdSecMarket` from explicit value, account market, and code.
291///
292/// Legacy raw-value facade. New code should prefer [`derive_trd_sec_market`] at
293/// the boundary, then unwrap to `i32` only when writing the FTAPI/backend wire.
294pub fn derive_sec_market(ftapi_sec_market: i32, trd_market: i32, code: &str) -> i32 {
295    derive_trd_sec_market(ftapi_sec_market, trd_market, code).raw_i32()
296}
297
298/// Derive `TrdSecMarket` from an explicit code prefix such as `HK.` / `US.`.
299pub fn sec_market_from_code_prefix(code: &str) -> Option<i32> {
300    sec_market_from_code_prefix_typed(code).map(TrdSecMarketId::raw_i32)
301}
302
303/// Typed variant of [`sec_market_from_code_prefix`].
304pub fn sec_market_from_code_prefix_typed(code: &str) -> Option<TrdSecMarketId> {
305    futu_core::market::entry_by_code_prefix(code).map(|e| TrdSecMarketId::new(e.sec_market))
306}
307
308/// Canonical `Trd_Common.TrdMarket` label used by user-facing filters and
309/// surface adapters.
310///
311/// Keep this table in the trade domain so CLI / MCP / REST do not drift on
312/// newer view-only markets such as HKFUND / USFUND.
313#[must_use]
314pub fn trd_market_label(i: i32) -> Option<&'static str> {
315    match i {
316        1 => Some("HK"),
317        2 => Some("US"),
318        3 => Some("CN"),
319        4 => Some("HKCC"),
320        5 => Some("FUTURES"),
321        6 => Some("SG"),
322        7 => Some("CRYPTO"),
323        8 => Some("AU"),
324        10 => Some("FUTURES_SIMULATE_HK"),
325        11 => Some("FUTURES_SIMULATE_US"),
326        12 => Some("FUTURES_SIMULATE_SG"),
327        13 => Some("FUTURES_SIMULATE_JP"),
328        15 => Some("JP"),
329        111 => Some("MY"),
330        112 => Some("CA"),
331        113 => Some("HKFUND"),
332        123 => Some("USFUND"),
333        124 => Some("SGFUND"),
334        125 => Some("MYFUND"),
335        126 => Some("JPFUND"),
336        _ => None,
337    }
338}
339
340/// Label for fund markets that are view-only on active write/calculation paths.
341///
342/// This intentionally covers two namespaces:
343/// - backend raw `Account.market` values cached in `CachedTrdAcc.trd_market`;
344/// - canonical OpenAPI `Trd_Common.TrdMarket` fund values.
345///
346/// `None` means the market is not a fund/view-only market. Do not use this as a
347/// generic display label; use `trd_market_label` for ordinary surface labels.
348#[must_use]
349pub fn view_only_fund_market_label(trd_market: i32) -> Option<&'static str> {
350    CachedAccountMarket::new(trd_market).view_only_fund_label()
351}
352
353/// Return whether the code looks like a futures symbol.
354///
355/// This is a cache-miss pattern fallback; cache-backed security type remains
356/// the more authoritative source when available.
357pub fn is_futures_code(code: &str) -> bool {
358    let code = strip_market_prefix(code);
359    let code = code.as_str();
360
361    if let Some(stem) = code
362        .strip_suffix("main")
363        .or_else(|| code.strip_suffix(".main"))
364    {
365        let stem = stem.trim_end_matches('.');
366        if (1..=4).contains(&stem.len()) && stem.chars().all(|c| c.is_ascii_alphabetic()) {
367            return true;
368        }
369    }
370
371    if (5..=8).contains(&code.len()) {
372        let len = code.len();
373        let bytes = code.as_bytes();
374        if !bytes[len - 4..].iter().all(|b| b.is_ascii_digit()) {
375            return false;
376        }
377        let ticker = &bytes[..len - 4];
378        return (1..=4).contains(&ticker.len())
379            && ticker.iter().all(|b| b.is_ascii_alphanumeric())
380            && ticker.iter().any(|b| b.is_ascii_alphabetic());
381    }
382
383    false
384}
385
386/// Derive `TrdSecMarket` from known futures ticker prefixes.
387pub fn futures_ticker_to_trd_sec_market(code: &str) -> Option<TrdSecMarketId> {
388    if !is_futures_code(code) {
389        return None;
390    }
391    let ticker = extract_futures_ticker_prefix(code);
392    if matches!(
393        ticker.as_str(),
394        "NQ" | "MNQ"
395            | "ES"
396            | "MES"
397            | "RTY"
398            | "M2K"
399            | "NKD"
400            | "YM"
401            | "MYM"
402            | "6E"
403            | "6J"
404            | "6B"
405            | "6A"
406            | "6C"
407            | "6S"
408            | "6M"
409            | "6N"
410            | "GE"
411            | "SR3"
412            | "BTC"
413            | "MBT"
414            | "ETH"
415            | "MET"
416            | "CL"
417            | "MCL"
418            | "NG"
419            | "MNG"
420            | "HO"
421            | "RB"
422            | "BZ"
423            | "WBS"
424            | "GC"
425            | "MGC"
426            | "SI"
427            | "SIL"
428            | "HG"
429            | "MHG"
430            | "PL"
431            | "PA"
432            | "ZS"
433            | "ZC"
434            | "ZW"
435            | "ZO"
436            | "ZR"
437            | "ZT"
438            | "ZF"
439            | "ZN"
440            | "ZB"
441            | "UB"
442            | "TN"
443            | "VX"
444            | "VXM"
445    ) {
446        return Some(TrdSecMarketId::new(2));
447    }
448    if matches!(
449        ticker.as_str(),
450        "HSI" | "HHI" | "HTI" | "MHI" | "MCH" | "VHSI" | "CSI300" | "CSI500" | "CSI800"
451    ) {
452        return Some(TrdSecMarketId::new(1));
453    }
454    None
455}
456
457/// Legacy raw-value facade for futures ticker fallback.
458pub fn futures_ticker_to_sec_market(code: &str) -> Option<i32> {
459    futures_ticker_to_trd_sec_market(code).map(TrdSecMarketId::raw_i32)
460}
461
462/// Extract the ticker prefix from a futures code.
463pub fn extract_futures_ticker_prefix(code: &str) -> String {
464    let bare = strip_market_prefix(code);
465    let bare = bare.strip_suffix("main").unwrap_or(&bare);
466    let chars: Vec<char> = bare.chars().collect();
467    let len = chars.len();
468    if len >= 4 && chars[len - 4..].iter().all(|c| c.is_ascii_digit()) {
469        return chars[..len - 4].iter().collect::<String>().to_uppercase();
470    }
471    bare.to_uppercase()
472}
473
474#[cfg(test)]
475mod tests;