Skip to main content

futu_core/
diagnostic_text.rs

1//! Stable user-facing diagnostic text keys and shared hint builders.
2//!
3//! Logs should keep stable machine-readable English markers. These helpers are
4//! for CLI / REST / MCP / gateway human hints, where each message also carries
5//! a stable diagnostic code so automation and docs can refer to the same row.
6
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum DiagnosticTextKey {
11    ProtoJsonQotMarketNumeric,
12    QuotePermissionOpenMarket,
13    StaticCacheMissing,
14    OrderbookDetailPermission,
15    JpStockQuotePermission,
16    JpTradePermission,
17    AllDayRiskDisclosure,
18    UpdateCheckUnavailable,
19    Qot3401UnsupportedMarket,
20    Qot3401BackendError,
21    Qot3401EntityIdRequired,
22    Qot3401DiscoveryEmpty,
23    Qot3401SampleUnavailable,
24}
25
26impl DiagnosticTextKey {
27    pub const fn code(self) -> &'static str {
28        match self {
29            Self::ProtoJsonQotMarketNumeric => "proto_json.qot_market_numeric",
30            Self::QuotePermissionOpenMarket => "quote.permission.open_market",
31            Self::StaticCacheMissing => "static.cache_missing",
32            Self::OrderbookDetailPermission => "quote.permission.orderbook_detail",
33            Self::JpStockQuotePermission => "quote.permission.jp_stock",
34            Self::JpTradePermission => "trade.permission.jp_market",
35            Self::AllDayRiskDisclosure => "trade.risk_disclosure.all_day",
36            Self::UpdateCheckUnavailable => "update.check_unavailable",
37            Self::Qot3401UnsupportedMarket => "qot3401.unsupported_market",
38            Self::Qot3401BackendError => "qot3401.backend_error",
39            Self::Qot3401EntityIdRequired => "qot3401.entity_id_required",
40            Self::Qot3401DiscoveryEmpty => "qot3401.discovery_empty",
41            Self::Qot3401SampleUnavailable => "qot3401.sample_unavailable",
42        }
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub enum MessageLocale {
48    En,
49    ZhCn,
50    ZhHk,
51}
52
53impl MessageLocale {
54    pub fn from_normalized_lang(lang: &str) -> Self {
55        match lang {
56            "chs" | "zh_CN" | "zh-cn" | "zh-Hans" | "zh_Hans" => Self::ZhCn,
57            "cht" | "zh_HK" | "zh_TW" | "zh-hk" | "zh-tw" | "zh-Hant" | "zh_Hant" => Self::ZhHk,
58            _ => Self::En,
59        }
60    }
61
62    pub fn from_app_lang(app_lang: i32) -> Self {
63        match app_lang {
64            0 => Self::ZhCn,
65            1 => Self::ZhHk,
66            2 => Self::En,
67            _ => Self::En,
68        }
69    }
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct MessageArgs {
74    values: BTreeMap<&'static str, String>,
75}
76
77impl MessageArgs {
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    pub fn with(mut self, key: &'static str, value: impl Into<String>) -> Self {
83        self.values.insert(key, value.into());
84        self
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct CatalogMessage {
90    pub key: DiagnosticTextKey,
91    pub locale: MessageLocale,
92    pub template: &'static str,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct DiagnosticText {
97    pub key: DiagnosticTextKey,
98    pub text: String,
99}
100
101impl DiagnosticText {
102    pub fn new(key: DiagnosticTextKey, text: impl Into<String>) -> Self {
103        Self {
104            key,
105            text: text.into(),
106        }
107    }
108
109    pub fn render(key: DiagnosticTextKey, locale: MessageLocale, args: MessageArgs) -> Self {
110        let row = catalog_message(key, locale);
111        Self::new(key, render_template(row.template, &args))
112    }
113
114    pub fn text_with_code(&self) -> String {
115        format!("{} [diag:{}]", self.text, self.key.code())
116    }
117}
118
119pub fn render_with_lang(
120    key: DiagnosticTextKey,
121    normalized_lang: &str,
122    args: MessageArgs,
123) -> DiagnosticText {
124    DiagnosticText::render(
125        key,
126        MessageLocale::from_normalized_lang(normalized_lang),
127        args,
128    )
129}
130
131pub fn render_with_app_lang(
132    key: DiagnosticTextKey,
133    app_lang: i32,
134    args: MessageArgs,
135) -> DiagnosticText {
136    DiagnosticText::render(key, MessageLocale::from_app_lang(app_lang), args)
137}
138
139pub fn proto_json_numeric_qot_market_hint() -> DiagnosticText {
140    DiagnosticText::render(
141        DiagnosticTextKey::ProtoJsonQotMarketNumeric,
142        MessageLocale::En,
143        MessageArgs::new(),
144    )
145}
146
147pub fn quote_permission_action_for_market(
148    market_suffix: Option<&str>,
149    symbol: &str,
150) -> DiagnosticText {
151    let market = market_suffix.unwrap_or("market");
152    let market_label = match market_suffix {
153        Some("JP") => "JP stock",
154        Some("US") => "US stock",
155        Some("HK") => "HK stock",
156        Some(market) => market,
157        None => "the market",
158    };
159    DiagnosticText::render(
160        DiagnosticTextKey::QuotePermissionOpenMarket,
161        MessageLocale::En,
162        MessageArgs::new()
163            .with("market", market)
164            .with("market_label", market_label)
165            .with("symbol", symbol),
166    )
167}
168
169pub fn static_cache_missing_message(normalized_symbol: &str, sec_key: &str) -> DiagnosticText {
170    DiagnosticText::render(
171        DiagnosticTextKey::StaticCacheMissing,
172        MessageLocale::En,
173        MessageArgs::new()
174            .with("symbol", normalized_symbol)
175            .with("sec_key", sec_key),
176    )
177}
178
179pub fn orderbook_detail_permission_action() -> DiagnosticText {
180    DiagnosticText::render(
181        DiagnosticTextKey::OrderbookDetailPermission,
182        MessageLocale::En,
183        MessageArgs::new(),
184    )
185}
186
187pub fn jp_stock_quote_permission_hint() -> DiagnosticText {
188    DiagnosticText::render(
189        DiagnosticTextKey::JpStockQuotePermission,
190        MessageLocale::ZhCn,
191        MessageArgs::new(),
192    )
193}
194
195pub fn jp_trade_permission_hint() -> DiagnosticText {
196    DiagnosticText::render(
197        DiagnosticTextKey::JpTradePermission,
198        MessageLocale::ZhCn,
199        MessageArgs::new(),
200    )
201}
202
203pub fn all_day_risk_disclosure_hint(help_url: &str) -> DiagnosticText {
204    DiagnosticText::render(
205        DiagnosticTextKey::AllDayRiskDisclosure,
206        MessageLocale::ZhCn,
207        MessageArgs::new().with("help_url", help_url),
208    )
209}
210
211pub fn update_check_unavailable_action() -> DiagnosticText {
212    DiagnosticText::render(
213        DiagnosticTextKey::UpdateCheckUnavailable,
214        MessageLocale::En,
215        MessageArgs::new(),
216    )
217}
218
219pub fn qot3401_unsupported_market_action() -> DiagnosticText {
220    DiagnosticText::render(
221        DiagnosticTextKey::Qot3401UnsupportedMarket,
222        MessageLocale::En,
223        MessageArgs::new(),
224    )
225}
226
227pub fn qot3401_backend_error_action(api_name: &str, ret_code: i32) -> DiagnosticText {
228    DiagnosticText::render(
229        DiagnosticTextKey::Qot3401BackendError,
230        MessageLocale::En,
231        MessageArgs::new()
232            .with("api_name", api_name)
233            .with("ret_code", ret_code.to_string()),
234    )
235}
236
237pub fn qot3401_entity_id_required_action() -> DiagnosticText {
238    DiagnosticText::render(
239        DiagnosticTextKey::Qot3401EntityIdRequired,
240        MessageLocale::En,
241        MessageArgs::new(),
242    )
243}
244
245pub fn qot3401_discovery_empty_action() -> DiagnosticText {
246    DiagnosticText::render(
247        DiagnosticTextKey::Qot3401DiscoveryEmpty,
248        MessageLocale::En,
249        MessageArgs::new(),
250    )
251}
252
253pub fn qot3401_sample_unavailable_action() -> DiagnosticText {
254    DiagnosticText::render(
255        DiagnosticTextKey::Qot3401SampleUnavailable,
256        MessageLocale::En,
257        MessageArgs::new(),
258    )
259}
260
261fn catalog_message(key: DiagnosticTextKey, locale: MessageLocale) -> CatalogMessage {
262    let template = match (key, locale) {
263        (DiagnosticTextKey::ProtoJsonQotMarketNumeric, MessageLocale::ZhCn) => {
264            "`market` 是生成的 proto 数字枚举,不是字符串。请使用 Qot_Common.QotMarket 数值,例如 HK=1, US=11, SH=21, SZ=22, SG=31, JP=41, AU=51, MY=61。示例:{\"market\":11}"
265        }
266        (DiagnosticTextKey::ProtoJsonQotMarketNumeric, MessageLocale::ZhHk) => {
267            "`market` 是生成的 proto 數字枚舉,不是字串。請使用 Qot_Common.QotMarket 數值,例如 HK=1, US=11, SH=21, SZ=22, SG=31, JP=41, AU=51, MY=61。示例:{\"market\":11}"
268        }
269        (DiagnosticTextKey::ProtoJsonQotMarketNumeric, MessageLocale::En) => {
270            "`market` is a numeric generated proto enum, not a string. Use Qot_Common.QotMarket values such as HK=1, US=11, SH=21, SZ=22, SG=31, JP=41, AU=51, MY=61. Example: {\"market\":11}"
271        }
272
273        (DiagnosticTextKey::QuotePermissionOpenMarket, MessageLocale::ZhCn) => {
274            "先运行 futucli quote-rights;请在官方 Futu/moomoo App 开通{market_label}行情权限,然后用 futucli quote-rights 和 futucli quote-capability {symbol} 复查。"
275        }
276        (DiagnosticTextKey::QuotePermissionOpenMarket, MessageLocale::ZhHk) => {
277            "先執行 futucli quote-rights;請在官方 Futu/moomoo App 開通{market_label}行情權限,然後用 futucli quote-rights 和 futucli quote-capability {symbol} 複查。"
278        }
279        (DiagnosticTextKey::QuotePermissionOpenMarket, MessageLocale::En) => {
280            "run futucli quote-rights; open {market_label} quote permission in the official Futu/moomoo App if it remains zero, then recheck with futucli quote-rights and futucli quote-capability {symbol}"
281        }
282
283        (DiagnosticTextKey::StaticCacheMissing, MessageLocale::ZhCn) => {
284            "静态缓存缺少 {symbol} ({sec_key});请等待 stock-list sync 收敛,或先调用 static/static-warmup。"
285        }
286        (DiagnosticTextKey::StaticCacheMissing, MessageLocale::ZhHk) => {
287            "靜態快取缺少 {symbol} ({sec_key});請等待 stock-list sync 收斂,或先呼叫 static/static-warmup。"
288        }
289        (DiagnosticTextKey::StaticCacheMissing, MessageLocale::En) => {
290            "static cache missing {symbol} ({sec_key}); wait stock-list sync or call static/static-warmup first"
291        }
292
293        (DiagnosticTextKey::OrderbookDetailPermission, MessageLocale::ZhCn) => {
294            "请检查 quote-rights,并确认订阅账户具备所需 orderbook detail/depth 行情权限。"
295        }
296        (DiagnosticTextKey::OrderbookDetailPermission, MessageLocale::ZhHk) => {
297            "請檢查 quote-rights,並確認訂閱帳戶具備所需 orderbook detail/depth 行情權限。"
298        }
299        (DiagnosticTextKey::OrderbookDetailPermission, MessageLocale::En) => {
300            "check quote-rights and subscribe with the required orderbook detail permission"
301        }
302
303        (DiagnosticTextKey::JpStockQuotePermission, MessageLocale::ZhCn) => {
304            "App 延时行情不等于 OpenD API 实时行情权限;请在官方 Futu/moomoo App 开通日股实时行情 / Premium 权限,然后用 futucli quote-rights 确认 JP stock 权限。"
305        }
306        (DiagnosticTextKey::JpStockQuotePermission, MessageLocale::ZhHk) => {
307            "App 延時行情不等於 OpenD API 即時行情權限;請在官方 Futu/moomoo App 開通日股即時行情 / Premium 權限,然後用 futucli quote-rights 確認 JP stock 權限。"
308        }
309        (DiagnosticTextKey::JpStockQuotePermission, MessageLocale::En) => {
310            "App delayed JP quotes do not imply OpenD API realtime quote permission; open JP realtime / Premium quote permission in the official Futu/moomoo App, then confirm JP stock permission with futucli quote-rights."
311        }
312
313        (DiagnosticTextKey::JpTradePermission, MessageLocale::ZhCn) => {
314            "当前账户未开通日股交易权限(JP / trd_market=15)。请在 Futu/moomoo App 中开通日股交易权限,并按提示完成相关协议、风险披露、税务/市场权限流程。开通后重新查询账户,确认 trdmarket_auth 包含 JP/15,再重试下单。App 路径建议:账户 > 更多/交易权限 > 日股交易权限(以当前 App 实际入口为准)"
315        }
316        (DiagnosticTextKey::JpTradePermission, MessageLocale::ZhHk) => {
317            "當前帳戶未開通日股交易權限(JP / trd_market=15)。請在 Futu/moomoo App 中開通日股交易權限,並按提示完成相關協議、風險披露、稅務/市場權限流程。開通後重新查詢帳戶,確認 trdmarket_auth 包含 JP/15,再重試下單。App 路徑建議:帳戶 > 更多/交易權限 > 日股交易權限(以當前 App 實際入口為準)"
318        }
319        (DiagnosticTextKey::JpTradePermission, MessageLocale::En) => {
320            "The account does not have JP trading permission (JP / trd_market=15). Open JP trading permission in the Futu/moomoo App, finish the required agreements, risk disclosure, tax and market permission steps, then query accounts again and confirm trdmarket_auth contains JP/15 before retrying the order."
321        }
322
323        (DiagnosticTextKey::AllDayRiskDisclosure, MessageLocale::ZhCn) => {
324            "后端要求先完成全时段交易风险披露。请在官方 Futu/moomoo App 内完成全时段交易相关风险披露后重试;daemon 只透传下单协议,无法代用户确认该披露。官方帮助入口:{help_url}"
325        }
326        (DiagnosticTextKey::AllDayRiskDisclosure, MessageLocale::ZhHk) => {
327            "後端要求先完成全時段交易風險披露。請在官方 Futu/moomoo App 內完成全時段交易相關風險披露後重試;daemon 只透傳下單協議,無法代用戶確認該披露。官方幫助入口:{help_url}"
328        }
329        (DiagnosticTextKey::AllDayRiskDisclosure, MessageLocale::En) => {
330            "The backend requires all-day trading risk disclosure before this order. Complete the all-day trading risk disclosure in the official Futu/moomoo App, then retry. The daemon only forwards order protocols and cannot confirm this disclosure for the user. Help: {help_url}"
331        }
332
333        (DiagnosticTextKey::UpdateCheckUnavailable, MessageLocale::ZhCn) => {
334            "更新检查不可用;稍后重跑 `futucli version --check`,或用 `--url` / FUTU_UPDATE_CHECK_URL 指定 manifest,也可以用 `futucli doctor --no-update-check` 跳过 doctor 的更新检查。"
335        }
336        (DiagnosticTextKey::UpdateCheckUnavailable, MessageLocale::ZhHk) => {
337            "更新檢查不可用;稍後重跑 `futucli version --check`,或用 `--url` / FUTU_UPDATE_CHECK_URL 指定 manifest,也可以用 `futucli doctor --no-update-check` 跳過 doctor 的更新檢查。"
338        }
339        (DiagnosticTextKey::UpdateCheckUnavailable, MessageLocale::En) => {
340            "update check unavailable; rerun `futucli version --check` later, override with `--url` / FUTU_UPDATE_CHECK_URL, or skip doctor with `futucli doctor --no-update-check`"
341        }
342
343        (DiagnosticTextKey::Qot3401UnsupportedMarket, MessageLocale::ZhCn) => {
344            "该 3401+ 接口当前不支持传入的 market。请使用数字 Qot_Common.QotMarket,并先用包内 examples/qot-3401-plus-proto-json-examples.json 查看该接口已登记的市场样例。常用值:HK=1, US=11, SH=21, SZ=22, SG=31, JP=41, AU=51, MY=61, CA=71。"
345        }
346        (DiagnosticTextKey::Qot3401UnsupportedMarket, MessageLocale::ZhHk) => {
347            "該 3401+ 介面目前不支援傳入的 market。請使用數字 Qot_Common.QotMarket,並先用包內 examples/qot-3401-plus-proto-json-examples.json 查看該介面已登記的市場示例。常用值:HK=1, US=11, SH=21, SZ=22, SG=31, JP=41, AU=51, MY=61, CA=71。"
348        }
349        (DiagnosticTextKey::Qot3401UnsupportedMarket, MessageLocale::En) => {
350            "this 3401+ endpoint does not support the supplied market; use numeric Qot_Common.QotMarket values and check examples/qot-3401-plus-proto-json-examples.json for the endpoint sample. Common values: HK=1, US=11, SH=21, SZ=22, SG=31, JP=41, AU=51, MY=61, CA=71"
351        }
352
353        (DiagnosticTextKey::Qot3401BackendError, MessageLocale::ZhCn) => {
354            "{api_name} 后端返回 ret={ret_code}。请先运行对应发现入口并使用返回 ID;如果发现入口也返回空或 ret=-1,请把 market、C2S JSON、daemon 日志和 qot_3401_plus_discovery_chain_smoke.py 输出一起回传。"
355        }
356        (DiagnosticTextKey::Qot3401BackendError, MessageLocale::ZhHk) => {
357            "{api_name} 後端返回 ret={ret_code}。請先執行對應發現入口並使用返回 ID;如果發現入口也返回空或 ret=-1,請把 market、C2S JSON、daemon 日誌和 qot_3401_plus_discovery_chain_smoke.py 輸出一起回傳。"
358        }
359        (DiagnosticTextKey::Qot3401BackendError, MessageLocale::En) => {
360            "{api_name} backend returned ret={ret_code}; run the discovery list endpoint first and use the returned id. If discovery is empty or also returns ret=-1, attach market, C2S JSON, daemon logs, and qot_3401_plus_discovery_chain_smoke.py output to the bug report"
361        }
362
363        (DiagnosticTextKey::Qot3401EntityIdRequired, MessageLocale::ZhCn) => {
364            "该详情接口需要有效 ID。机构详情请先跑 institution-list 取 institution_id;产业链详情请先跑 industrial-chain-list 取 chain_id;板块详情请从 heat-map-data / plate-list / chain-detail 返回的 plate_id 继续查询。"
365        }
366        (DiagnosticTextKey::Qot3401EntityIdRequired, MessageLocale::ZhHk) => {
367            "該詳情介面需要有效 ID。機構詳情請先跑 institution-list 取 institution_id;產業鏈詳情請先跑 industrial-chain-list 取 chain_id;板塊詳情請從 heat-map-data / plate-list / chain-detail 返回的 plate_id 繼續查詢。"
368        }
369        (DiagnosticTextKey::Qot3401EntityIdRequired, MessageLocale::En) => {
370            "this detail endpoint needs a valid id. Run institution-list for institution_id, industrial-chain-list for chain_id, and heat-map-data / plate-list / chain-detail for plate_id before querying detail endpoints"
371        }
372
373        (DiagnosticTextKey::Qot3401DiscoveryEmpty, MessageLocale::ZhCn) => {
374            "发现入口没有返回可用 ID。请换 HK/US 等已登记市场样例重试;若仍为空,把请求 JSON 和 daemon 日志回传,先不要把详情接口当作可用路径。"
375        }
376        (DiagnosticTextKey::Qot3401DiscoveryEmpty, MessageLocale::ZhHk) => {
377            "發現入口沒有返回可用 ID。請換 HK/US 等已登記市場示例重試;若仍為空,把請求 JSON 和 daemon 日誌回傳,先不要把詳情介面當作可用路徑。"
378        }
379        (DiagnosticTextKey::Qot3401DiscoveryEmpty, MessageLocale::En) => {
380            "the discovery endpoint returned no usable id; retry with a registered HK/US sample and attach request JSON plus daemon logs before treating detail endpoints as usable"
381        }
382
383        (DiagnosticTextKey::Qot3401SampleUnavailable, MessageLocale::ZhCn) => {
384            "当前请求缺少已验证生产样例。请参考包内 examples/qot-3401-plus-proto-json-examples.json,用真实后端成功样例补齐后再对外宣称可用。"
385        }
386        (DiagnosticTextKey::Qot3401SampleUnavailable, MessageLocale::ZhHk) => {
387            "當前請求缺少已驗證生產示例。請參考包內 examples/qot-3401-plus-proto-json-examples.json,用真實後端成功示例補齊後再對外宣稱可用。"
388        }
389        (DiagnosticTextKey::Qot3401SampleUnavailable, MessageLocale::En) => {
390            "this request has no verified production sample yet; use examples/qot-3401-plus-proto-json-examples.json and add a real backend success sample before claiming the path is usable"
391        }
392    };
393    CatalogMessage {
394        key,
395        locale,
396        template,
397    }
398}
399
400fn render_template(template: &str, args: &MessageArgs) -> String {
401    let mut rendered = template.to_string();
402    for (key, value) in &args.values {
403        rendered = rendered.replace(&format!("{{{key}}}"), value);
404        rendered = rendered.replace(&format!("{{{{{key}}}}}"), value);
405    }
406    rendered
407}