Skip to main content

futucli/cmd/
proto_json.rs

1//! Proto-JSON passthrough helpers for newly added official endpoints.
2//!
3//! These commands intentionally accept generated `C2S` JSON instead of a large
4//! hand-written flag surface. That keeps v10.7 combo-option CLI coverage tied to
5//! the authoritative proto shape while the typed ergonomic CLI can evolve later.
6
7use std::collections::hash_map::DefaultHasher;
8use std::hash::{Hash, Hasher};
9use std::sync::atomic::{AtomicU32, Ordering};
10
11use anyhow::{Result, anyhow, bail};
12use futu_core::{diagnostic_text::proto_json_numeric_qot_market_hint, trade_market};
13use prost::Message;
14use serde::Serialize;
15use serde::de::DeserializeOwned;
16
17use crate::common::connect_gateway;
18use crate::output::OutputFormat;
19
20static PACKET_SERIAL: AtomicU32 = AtomicU32::new(1);
21
22fn parse_c2s<T>(label: &str, json: &str) -> Result<T>
23where
24    T: DeserializeOwned,
25{
26    let mut value: serde_json::Value =
27        serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s json: {err}"))?;
28    validate_proto_json_contract(label, &mut value)?;
29    deserialize_c2s_value(label, value)
30}
31
32fn validate_proto_json_contract(label: &str, value: &mut serde_json::Value) -> Result<()> {
33    let Some(spec) = futu_surface_spec::lookup_endpoint_by_cli_subcommand(label) else {
34        return Ok(());
35    };
36    futu_surface_spec::validate_and_normalize(spec, value)
37        .map_err(|err| anyhow!("{label} c2s json: {err}"))
38}
39
40fn parse_combo_max_c2s_json(json: &str) -> Result<futu_proto::trd_get_combo_max_trd_qtys::C2s> {
41    let c2s: futu_proto::trd_get_combo_max_trd_qtys::C2s =
42        parse_combo_c2s_json("combo-max-trd-qtys", json)?;
43    ensure_write_trd_market("combo-max-trd-qtys", c2s.header.trd_market)?;
44    Ok(c2s)
45}
46
47fn parse_place_combo_c2s_json(json: &str) -> Result<futu_proto::trd_place_combo_order::C2s> {
48    let c2s: futu_proto::trd_place_combo_order::C2s = parse_combo_c2s_json("combo-order", json)?;
49    ensure_write_trd_market("combo-order", c2s.header.trd_market)?;
50    Ok(c2s)
51}
52
53fn parse_combo_c2s_json<T>(label: &str, json: &str) -> Result<T>
54where
55    T: DeserializeOwned,
56{
57    parse_c2s_with_required_paths(
58        label,
59        json,
60        &[
61            (["header", "trd_env"].as_slice(), "header.trd_env"),
62            (["header", "acc_id"].as_slice(), "header.acc_id"),
63            (["header", "trd_market"].as_slice(), "header.trd_market"),
64            (["combo_legs"].as_slice(), "combo_legs"),
65            (["qty"].as_slice(), "qty"),
66            (["order_type"].as_slice(), "order_type"),
67        ],
68        Some(validate_combo_trade_contract),
69    )
70}
71
72fn parse_c2s_with_required_paths<T>(
73    label: &str,
74    json: &str,
75    required_paths: &[(&[&str], &'static str)],
76    extra_validator: Option<fn(&str, &serde_json::Value) -> Result<()>>,
77) -> Result<T>
78where
79    T: DeserializeOwned,
80{
81    let value: serde_json::Value =
82        serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s json: {err}"))?;
83    for (path, name) in required_paths {
84        if json_path(&value, path).is_none() {
85            bail!("{label} c2s json missing required field {name}");
86        }
87    }
88    if let Some(validate) = extra_validator {
89        validate(label, &value)?;
90    }
91    deserialize_c2s_value(label, value)
92}
93
94fn deserialize_c2s_value<T>(label: &str, value: serde_json::Value) -> Result<T>
95where
96    T: DeserializeOwned,
97{
98    serde_json::from_value(value.clone()).map_err(|err| {
99        anyhow!(
100            "{label} c2s json: {err}{}",
101            proto_json_deserialize_hint(&value)
102        )
103    })
104}
105
106fn proto_json_deserialize_hint(value: &serde_json::Value) -> String {
107    if has_string_market_field(value) {
108        format!(
109            "; hint: {}",
110            proto_json_numeric_qot_market_hint().text_with_code()
111        )
112    } else {
113        String::new()
114    }
115}
116
117fn has_string_market_field(value: &serde_json::Value) -> bool {
118    match value {
119        serde_json::Value::Object(map) => map.iter().any(|(key, nested)| {
120            (key == "market" && nested.is_string()) || has_string_market_field(nested)
121        }),
122        serde_json::Value::Array(items) => items.iter().any(has_string_market_field),
123        _ => false,
124    }
125}
126
127fn json_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> {
128    let mut current = value;
129    for segment in path {
130        current = current.get(*segment)?;
131    }
132    Some(current)
133}
134
135fn validate_combo_trade_contract(label: &str, value: &serde_json::Value) -> Result<()> {
136    match json_path(value, &["header", "acc_id"]).and_then(serde_json::Value::as_u64) {
137        Some(acc_id) if acc_id > 0 => {}
138        _ => bail!("{label} c2s json header.acc_id must be a positive integer"),
139    }
140
141    let legs = json_path(value, &["combo_legs"])
142        .and_then(serde_json::Value::as_array)
143        .ok_or_else(|| anyhow!("{label} c2s json combo_legs must be an array"))?;
144    if legs.len() < 2 {
145        bail!("{label} c2s json combo_legs must contain at least two legs");
146    }
147
148    match json_path(value, &["qty"]).and_then(serde_json::Value::as_f64) {
149        Some(qty) if qty > 0.0 => {}
150        _ => bail!("{label} c2s json qty must be positive"),
151    }
152
153    let order_type = json_path(value, &["order_type"])
154        .and_then(serde_json::Value::as_i64)
155        .ok_or_else(|| anyhow!("{label} c2s json order_type must be an integer"))?;
156    if i32::try_from(order_type).is_err() {
157        bail!("{label} c2s json order_type={order_type} is out of range");
158    }
159
160    Ok(())
161}
162
163fn print_proto_response<T>(format: OutputFormat, response: &T) -> Result<()>
164where
165    T: Serialize,
166{
167    match format {
168        OutputFormat::Table | OutputFormat::Json | OutputFormat::Markdown => {
169            println!("{}", serde_json::to_string_pretty(response)?);
170        }
171        OutputFormat::Jsonl => {
172            println!("{}", serde_json::to_string(response)?);
173        }
174    }
175    Ok(())
176}
177
178fn ensure_success(
179    label: &str,
180    ret_type: i32,
181    ret_msg: Option<&str>,
182    err_code: Option<i32>,
183) -> Result<()> {
184    if ret_type == 0 {
185        return Ok(());
186    }
187    bail!("{label} ret_type={ret_type} msg={ret_msg:?} err_code={err_code:?}")
188}
189
190async fn send_proto<Req, Resp>(
191    gateway: &str,
192    client_id: &str,
193    proto_id: u32,
194    request: Req,
195) -> Result<Resp>
196where
197    Req: Message,
198    Resp: Message + Default,
199{
200    let (client, _rx) = connect_gateway(gateway, client_id).await?;
201    let frame = client.request(proto_id, request.encode_to_vec()).await?;
202    Resp::decode(frame.body.as_ref()).map_err(|err| anyhow!("decode response: {err}"))
203}
204
205pub async fn run_verification(
206    gateway: &str,
207    verification_type: i32,
208    op: i32,
209    code: Option<String>,
210    output: OutputFormat,
211) -> Result<()> {
212    validate_verification_args(verification_type, op, code.as_deref())?;
213    let request = futu_proto::verification::Request {
214        c2s: futu_proto::verification::C2s {
215            r#type: verification_type,
216            op,
217            code,
218        },
219    };
220    let response: futu_proto::verification::Response = send_proto(
221        gateway,
222        futu_core::INTERNAL_UI_CLIENT_ID,
223        futu_core::proto_id::VERIFICATION,
224        request,
225    )
226    .await?;
227    ensure_success(
228        "verification",
229        response.ret_type,
230        response.ret_msg.as_deref(),
231        response.err_code,
232    )?;
233    print_proto_response(output, &response)
234}
235
236fn validate_verification_args(verification_type: i32, op: i32, code: Option<&str>) -> Result<()> {
237    if !matches!(verification_type, 1 | 2) {
238        bail!("verification --type must be 1 (Picture) or 2 (Phone)");
239    }
240    if !matches!(op, 1 | 2) {
241        bail!("verification --op must be 1 (Request) or 2 (InputAndLogin)");
242    }
243    if op == 2 && code.is_none() {
244        bail!("verification --code is required for --op 2");
245    }
246    Ok(())
247}
248
249macro_rules! qot_proto_json_command {
250    ($fn_name:ident, $label:literal, $proto_id:expr, $module:ident) => {
251        pub async fn $fn_name(gateway: &str, c2s_json: &str, output: OutputFormat) -> Result<()> {
252            let c2s = parse_c2s::<futu_proto::$module::C2s>($label, c2s_json)?;
253            let request = futu_proto::$module::Request { c2s };
254            let response: futu_proto::$module::Response =
255                send_proto(gateway, concat!("futucli-", $label), $proto_id, request).await?;
256            ensure_success(
257                $label,
258                response.ret_type,
259                response.ret_msg.as_deref(),
260                response.err_code,
261            )?;
262            print_proto_response(output, &response)
263        }
264    };
265}
266
267qot_proto_json_command!(
268    run_option_quote,
269    "option-quote",
270    futu_core::proto_id::QOT_GET_OPTION_QUOTE,
271    qot_get_option_quote
272);
273qot_proto_json_command!(
274    run_option_strategy,
275    "option-strategy",
276    futu_core::proto_id::QOT_GET_OPTION_STRATEGY,
277    qot_get_option_strategy
278);
279qot_proto_json_command!(
280    run_option_strategy_analysis,
281    "option-strategy-analysis",
282    futu_core::proto_id::QOT_GET_OPTION_STRATEGY_ANALYSIS,
283    qot_get_option_strategy_analysis
284);
285qot_proto_json_command!(
286    run_option_strategy_spread,
287    "option-strategy-spread",
288    futu_core::proto_id::QOT_GET_OPTION_STRATEGY_SPREAD,
289    qot_get_option_strategy_spread
290);
291qot_proto_json_command!(
292    run_earnings_calendar,
293    "earnings-calendar",
294    futu_core::proto_id::QOT_GET_EARNINGS_CALENDAR,
295    qot_get_earnings_calendar
296);
297qot_proto_json_command!(
298    run_macro_indicator_list,
299    "macro-indicator-list",
300    futu_core::proto_id::QOT_GET_MACRO_INDICATOR_LIST,
301    qot_get_macro_indicator_list
302);
303qot_proto_json_command!(
304    run_macro_indicator_history,
305    "macro-indicator-history",
306    futu_core::proto_id::QOT_GET_MACRO_INDICATOR_HISTORY,
307    qot_get_macro_indicator_history
308);
309qot_proto_json_command!(
310    run_fed_watch_target_rate,
311    "fed-watch-target-rate",
312    futu_core::proto_id::QOT_GET_FED_WATCH_TARGET_RATE,
313    qot_get_fed_watch_target_rate
314);
315qot_proto_json_command!(
316    run_fed_watch_dot_plot,
317    "fed-watch-dot-plot",
318    futu_core::proto_id::QOT_GET_FED_WATCH_DOT_PLOT,
319    qot_get_fed_watch_dot_plot
320);
321qot_proto_json_command!(
322    run_earnings_beat_rank,
323    "earnings-beat-rank",
324    futu_core::proto_id::QOT_GET_EARNINGS_BEAT_RANK,
325    qot_get_earnings_beat_rank
326);
327qot_proto_json_command!(
328    run_dividend_rank,
329    "dividend-rank",
330    futu_core::proto_id::QOT_GET_DIVIDEND_RANK,
331    qot_get_dividend_rank
332);
333qot_proto_json_command!(
334    run_dividend_calendar,
335    "dividend-calendar",
336    futu_core::proto_id::QOT_GET_DIVIDEND_CALENDAR,
337    qot_get_dividend_calendar
338);
339qot_proto_json_command!(
340    run_economic_calendar,
341    "economic-calendar",
342    futu_core::proto_id::QOT_GET_ECONOMIC_CALENDAR,
343    qot_get_economic_calendar
344);
345qot_proto_json_command!(
346    run_us_pre_market_rank,
347    "us-pre-market-rank",
348    futu_core::proto_id::QOT_GET_US_PRE_MARKET_RANK,
349    qot_get_us_pre_market_rank
350);
351qot_proto_json_command!(
352    run_us_after_hours_rank,
353    "us-after-hours-rank",
354    futu_core::proto_id::QOT_GET_US_AFTER_HOURS_RANK,
355    qot_get_us_after_hours_rank
356);
357qot_proto_json_command!(
358    run_us_overnight_rank,
359    "us-overnight-rank",
360    futu_core::proto_id::QOT_GET_US_OVERNIGHT_RANK,
361    qot_get_us_overnight_rank
362);
363qot_proto_json_command!(
364    run_top_movers_rank,
365    "top-movers-rank",
366    futu_core::proto_id::QOT_GET_TOP_MOVERS_RANK,
367    qot_get_top_movers_rank
368);
369qot_proto_json_command!(
370    run_hot_list,
371    "hot-list",
372    futu_core::proto_id::QOT_GET_HOT_LIST,
373    qot_get_hot_list
374);
375qot_proto_json_command!(
376    run_short_selling_rank,
377    "short-selling-rank",
378    futu_core::proto_id::QOT_GET_SHORT_SELLING_RANK,
379    qot_get_short_selling_rank
380);
381qot_proto_json_command!(
382    run_period_change_rank,
383    "period-change-rank",
384    futu_core::proto_id::QOT_GET_PERIOD_CHANGE_RANK,
385    qot_get_period_change_rank
386);
387qot_proto_json_command!(
388    run_high_dividend_soe_rank,
389    "high-dividend-soe-rank",
390    futu_core::proto_id::QOT_GET_HIGH_DIVIDEND_SOE_RANK,
391    qot_get_high_dividend_soe_rank
392);
393qot_proto_json_command!(
394    run_institution_list,
395    "institution-list",
396    futu_core::proto_id::QOT_GET_INSTITUTION_LIST,
397    qot_get_institution_list
398);
399qot_proto_json_command!(
400    run_institution_profile,
401    "institution-profile",
402    futu_core::proto_id::QOT_GET_INSTITUTION_PROFILE,
403    qot_get_institution_profile
404);
405qot_proto_json_command!(
406    run_institution_distribution,
407    "institution-distribution",
408    futu_core::proto_id::QOT_GET_INSTITUTION_DISTRIBUTION,
409    qot_get_institution_distribution
410);
411qot_proto_json_command!(
412    run_institution_holding_change,
413    "institution-holding-change",
414    futu_core::proto_id::QOT_GET_INSTITUTION_HOLDING_CHANGE,
415    qot_get_institution_holding_change
416);
417qot_proto_json_command!(
418    run_institution_holding_list,
419    "institution-holding-list",
420    futu_core::proto_id::QOT_GET_INSTITUTION_HOLDING_LIST,
421    qot_get_institution_holding_list
422);
423qot_proto_json_command!(
424    run_ark_fund_holding,
425    "ark-fund-holding",
426    futu_core::proto_id::QOT_GET_ARK_FUND_HOLDING,
427    qot_get_ark_fund_holding
428);
429qot_proto_json_command!(
430    run_ark_stock_dynamic,
431    "ark-stock-dynamic",
432    futu_core::proto_id::QOT_GET_ARK_STOCK_DYNAMIC,
433    qot_get_ark_stock_dynamic
434);
435qot_proto_json_command!(
436    run_ark_active_transaction,
437    "ark-active-transaction",
438    futu_core::proto_id::QOT_GET_ARK_ACTIVE_TRANSACTION,
439    qot_get_ark_active_transaction
440);
441qot_proto_json_command!(
442    run_rating_change,
443    "rating-change",
444    futu_core::proto_id::QOT_GET_RATING_CHANGE,
445    qot_get_rating_change
446);
447qot_proto_json_command!(
448    run_search_quote,
449    "search-quote",
450    futu_core::proto_id::QOT_GET_SEARCH_QUOTE,
451    qot_get_search_quote
452);
453qot_proto_json_command!(
454    run_search_news,
455    "search-news",
456    futu_core::proto_id::QOT_GET_SEARCH_NEWS,
457    qot_get_search_news
458);
459qot_proto_json_command!(
460    run_indicator_list,
461    "indicator-list",
462    futu_core::proto_id::QOT_GET_INDICATOR_LIST,
463    qot_get_indicator_list
464);
465qot_proto_json_command!(
466    run_option_market_statistic,
467    "option-market-statistic",
468    futu_core::proto_id::QOT_GET_OPTION_MARKET_STATISTIC,
469    qot_get_option_market_statistic
470);
471qot_proto_json_command!(
472    run_option_underlying_his_statistic,
473    "option-underlying-his-statistic",
474    futu_core::proto_id::QOT_GET_OPTION_UNDERLYING_HIS_STATISTIC,
475    qot_get_option_underlying_his_statistic
476);
477qot_proto_json_command!(
478    run_option_underlying_overview,
479    "option-underlying-overview",
480    futu_core::proto_id::QOT_GET_OPTION_UNDERLYING_OVERVIEW,
481    qot_get_option_underlying_overview
482);
483qot_proto_json_command!(
484    run_option_underlying_his_volatility,
485    "option-underlying-his-volatility",
486    futu_core::proto_id::QOT_GET_OPTION_UNDERLYING_HIS_VOLATILITY,
487    qot_get_option_underlying_his_volatility
488);
489qot_proto_json_command!(
490    run_option_underlying_rank,
491    "option-underlying-rank",
492    futu_core::proto_id::QOT_GET_OPTION_UNDERLYING_RANK,
493    qot_get_option_underlying_rank
494);
495qot_proto_json_command!(
496    run_option_rank,
497    "option-rank",
498    futu_core::proto_id::QOT_GET_OPTION_RANK,
499    qot_get_option_rank
500);
501qot_proto_json_command!(
502    run_option_event,
503    "option-event",
504    futu_core::proto_id::QOT_GET_OPTION_EVENT,
505    qot_get_option_event
506);
507qot_proto_json_command!(
508    run_option_event_alert,
509    "option-event-alert",
510    futu_core::proto_id::QOT_GET_OPTION_EVENT_ALERT,
511    qot_get_option_event_alert
512);
513qot_proto_json_command!(
514    run_set_option_event_alert,
515    "set-option-event-alert",
516    futu_core::proto_id::QOT_SET_OPTION_EVENT_ALERT,
517    qot_set_option_event_alert
518);
519qot_proto_json_command!(
520    run_option_zero_dte_screener,
521    "option-zero-dte-screener",
522    futu_core::proto_id::QOT_GET_OPTION_ZERO_DTE_SCREENER,
523    qot_get_option_zero_dte_screener
524);
525qot_proto_json_command!(
526    run_option_zero_dte_contract,
527    "option-zero-dte-contract",
528    futu_core::proto_id::QOT_GET_OPTION_ZERO_DTE_CONTRACT,
529    qot_get_option_zero_dte_contract
530);
531qot_proto_json_command!(
532    run_option_earnings_screener,
533    "option-earnings-screener",
534    futu_core::proto_id::QOT_GET_OPTION_EARNINGS_SCREENER,
535    qot_get_option_earnings_screener
536);
537qot_proto_json_command!(
538    run_option_seller_screener,
539    "option-seller-screener",
540    futu_core::proto_id::QOT_GET_OPTION_SELLER_SCREENER,
541    qot_get_option_seller_screener
542);
543qot_proto_json_command!(
544    run_industrial_chain_list,
545    "industrial-chain-list",
546    futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_LIST,
547    qot_get_industrial_chain_list
548);
549qot_proto_json_command!(
550    run_industrial_chain_detail,
551    "industrial-chain-detail",
552    futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_DETAIL,
553    qot_get_industrial_chain_detail
554);
555qot_proto_json_command!(
556    run_industrial_chain_by_plate,
557    "industrial-chain-by-plate",
558    futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_BY_PLATE,
559    qot_get_industrial_chain_by_plate
560);
561qot_proto_json_command!(
562    run_industrial_plate_info,
563    "industrial-plate-info",
564    futu_core::proto_id::QOT_GET_INDUSTRIAL_PLATE_INFO,
565    qot_get_industrial_plate_info
566);
567qot_proto_json_command!(
568    run_industrial_plate_stock,
569    "industrial-plate-stock",
570    futu_core::proto_id::QOT_GET_INDUSTRIAL_PLATE_STOCK,
571    qot_get_industrial_plate_stock
572);
573qot_proto_json_command!(
574    run_heat_map_data,
575    "heat-map-data",
576    futu_core::proto_id::QOT_GET_HEAT_MAP_DATA,
577    qot_get_heat_map_data
578);
579qot_proto_json_command!(
580    run_rise_fall_distribution,
581    "rise-fall-distribution",
582    futu_core::proto_id::QOT_GET_RISE_FALL_DISTRIBUTION,
583    qot_get_rise_fall_distribution
584);
585
586pub async fn run_combo_max_trd_qtys(
587    gateway: &str,
588    c2s_json: &str,
589    output: OutputFormat,
590) -> Result<()> {
591    let c2s = parse_combo_max_c2s_json(c2s_json)?;
592    let request = futu_proto::trd_get_combo_max_trd_qtys::Request { c2s };
593    let response: futu_proto::trd_get_combo_max_trd_qtys::Response = send_proto(
594        gateway,
595        "futucli-combo-max-trd-qtys",
596        futu_core::proto_id::TRD_GET_COMBO_MAX_TRD_QTYS,
597        request,
598    )
599    .await?;
600    ensure_success(
601        "combo-max-trd-qtys",
602        response.ret_type,
603        response.ret_msg.as_deref(),
604        response.err_code,
605    )?;
606    print_proto_response(output, &response)
607}
608
609pub async fn run_place_combo_order(
610    gateway: &str,
611    c2s_json: &str,
612    confirm: bool,
613    idempotency_key: Option<String>,
614    output: OutputFormat,
615) -> Result<()> {
616    let mut c2s = parse_place_combo_c2s_json(c2s_json)?;
617
618    ensure_combo_order_confirmed(c2s.header.trd_env, confirm)?;
619
620    let (client, _rx) = connect_gateway(gateway, "futucli-combo-order").await?;
621    c2s.packet_id = match idempotency_key.as_deref() {
622        Some(key) => packet_id_for_idempotency_key(key),
623        None => {
624            let conn_id = client
625                .conn_id()
626                .ok_or_else(|| anyhow!("combo-order missing InitConnect conn_id"))?;
627            next_packet_id(conn_id)
628        }
629    };
630
631    let request = futu_proto::trd_place_combo_order::Request { c2s };
632    let frame = client
633        .request(
634            futu_core::proto_id::TRD_PLACE_COMBO_ORDER,
635            request.encode_to_vec(),
636        )
637        .await?;
638    let response = futu_proto::trd_place_combo_order::Response::decode(frame.body.as_ref())
639        .map_err(|err| anyhow!("decode combo-order response: {err}"))?;
640    ensure_success(
641        "combo-order",
642        response.ret_type,
643        response.ret_msg.as_deref(),
644        response.err_code,
645    )?;
646    print_proto_response(output, &response)
647}
648
649fn next_packet_id(conn_id: u64) -> futu_proto::common::PacketId {
650    let serial_no = PACKET_SERIAL.fetch_add(1, Ordering::Relaxed);
651    futu_proto::common::PacketId { conn_id, serial_no }
652}
653
654fn ensure_combo_order_confirmed(trd_env: i32, confirm: bool) -> Result<()> {
655    if trd_env == 1 && !confirm {
656        bail!("combo-order real env requires --confirm");
657    }
658    Ok(())
659}
660
661fn ensure_write_trd_market(label: &str, trd_market: i32) -> Result<()> {
662    if let Some(fund_label) = trade_market::canonical_fund_trd_market_label(trd_market) {
663        bail!(
664            "{label} header.trd_market={trd_market} ({fund_label}) is view-only; \
665             use a write-capable main market for combo trade paths"
666        );
667    }
668    if trade_market::trd_market_label(trd_market).is_none() {
669        bail!("{label} unsupported header.trd_market={trd_market}");
670    }
671    Ok(())
672}
673
674// Same design as `futu-trd::order`: deterministic key-derived PacketID lets
675// the daemon replay guard identify an explicit retry without turning every
676// identical-looking combo order into an accidental duplicate.
677fn packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
678    let mut hasher = DefaultHasher::new();
679    key.hash(&mut hasher);
680    futu_proto::common::PacketId {
681        conn_id: hasher.finish(),
682        serial_no: 0,
683    }
684}
685
686#[cfg(test)]
687mod tests;