1use 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}