Skip to main content

futu_mcp/handlers/
proto_json.rs

1//! Official proto-JSON passthrough handlers for v10.7 combo-option endpoints.
2
3use std::collections::hash_map::DefaultHasher;
4use std::hash::{Hash, Hasher};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU32, Ordering};
7
8use anyhow::{Result, anyhow, bail};
9use futu_core::trade_market;
10use futu_net::client::FutuClient;
11use prost::Message;
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14
15use crate::tool_enums::{ToolEnum, TrdMarketEnum};
16
17mod kline_pattern;
18pub use kline_pattern::{
19    kline_pattern, kline_pattern_catalog, kline_pattern_performance, kline_pattern_statistics,
20    kline_pattern_stocks,
21};
22
23static PACKET_SERIAL: AtomicU32 = AtomicU32::new(1);
24
25#[derive(Debug, Clone)]
26pub struct ComboTradeContext {
27    pub env: &'static str,
28    pub acc_id: u64,
29    pub market: String,
30    pub order_value: Option<f64>,
31}
32
33pub fn parse_c2s_json<T>(label: &str, json: &str) -> Result<T>
34where
35    T: DeserializeOwned,
36{
37    let mut value: serde_json::Value =
38        serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s_json: {err}"))?;
39    validate_proto_json_contract(label, &mut value)?;
40    serde_json::from_value(value).map_err(|err| anyhow!("{label} c2s_json: {err}"))
41}
42
43fn validate_proto_json_contract(label: &str, value: &mut serde_json::Value) -> Result<()> {
44    let Some(spec) = futu_surface_spec::lookup_endpoint_by_cli_subcommand(label) else {
45        return Ok(());
46    };
47    futu_surface_spec::validate_and_normalize(spec, value)
48        .map_err(|err| anyhow!("{label} c2s_json: {err}"))
49}
50
51pub fn parse_combo_max_c2s_json(json: &str) -> Result<futu_proto::trd_get_combo_max_trd_qtys::C2s> {
52    parse_combo_c2s_json("combo-max-trd-qtys", json)
53}
54
55pub fn parse_place_combo_c2s_json(json: &str) -> Result<futu_proto::trd_place_combo_order::C2s> {
56    parse_combo_c2s_json("combo-order", json)
57}
58
59fn parse_combo_c2s_json<T>(label: &str, json: &str) -> Result<T>
60where
61    T: DeserializeOwned,
62{
63    parse_c2s_json_with_required_paths(
64        label,
65        json,
66        &[
67            (["header", "trd_env"].as_slice(), "header.trd_env"),
68            (["header", "acc_id"].as_slice(), "header.acc_id"),
69            (["header", "trd_market"].as_slice(), "header.trd_market"),
70            (["combo_legs"].as_slice(), "combo_legs"),
71            (["qty"].as_slice(), "qty"),
72            (["order_type"].as_slice(), "order_type"),
73        ],
74        Some(validate_combo_trade_contract),
75    )
76}
77
78fn parse_c2s_json_with_required_paths<T>(
79    label: &str,
80    json: &str,
81    required_paths: &[(&[&str], &'static str)],
82    extra_validator: Option<fn(&str, &serde_json::Value) -> Result<()>>,
83) -> Result<T>
84where
85    T: DeserializeOwned,
86{
87    let value: serde_json::Value =
88        serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s_json: {err}"))?;
89    for (path, name) in required_paths {
90        if json_path(&value, path).is_none() {
91            bail!("{label} c2s_json missing required field {name}");
92        }
93    }
94    if let Some(validate) = extra_validator {
95        validate(label, &value)?;
96    }
97    serde_json::from_value(value).map_err(|err| anyhow!("{label} c2s_json: {err}"))
98}
99
100fn json_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> {
101    let mut current = value;
102    for segment in path {
103        current = current.get(*segment)?;
104    }
105    Some(current)
106}
107
108fn validate_combo_trade_contract(label: &str, value: &serde_json::Value) -> Result<()> {
109    match json_path(value, &["header", "acc_id"]).and_then(serde_json::Value::as_u64) {
110        Some(acc_id) if acc_id > 0 => {}
111        _ => bail!("{label} c2s_json header.acc_id must be a positive integer"),
112    }
113
114    let legs = json_path(value, &["combo_legs"])
115        .and_then(serde_json::Value::as_array)
116        .ok_or_else(|| anyhow!("{label} c2s_json combo_legs must be an array"))?;
117    if legs.len() < 2 {
118        bail!("{label} c2s_json combo_legs must contain at least two legs");
119    }
120
121    match json_path(value, &["qty"]).and_then(serde_json::Value::as_f64) {
122        Some(qty) if qty > 0.0 => {}
123        _ => bail!("{label} c2s_json qty must be positive"),
124    }
125
126    let order_type = json_path(value, &["order_type"])
127        .and_then(serde_json::Value::as_i64)
128        .ok_or_else(|| anyhow!("{label} c2s_json order_type must be an integer"))?;
129    if i32::try_from(order_type).is_err() {
130        bail!("{label} c2s_json order_type={order_type} is out of range");
131    }
132
133    Ok(())
134}
135
136pub fn combo_max_context(c2s: &futu_proto::trd_get_combo_max_trd_qtys::C2s) -> Result<u64> {
137    if c2s.header.acc_id == 0 {
138        bail!("combo-max-trd-qtys header.acc_id is required");
139    }
140    trd_env_label(c2s.header.trd_env)?;
141    trd_write_market_label("combo-max-trd-qtys", c2s.header.trd_market)?;
142    Ok(c2s.header.acc_id)
143}
144
145pub fn place_combo_context(
146    c2s: &futu_proto::trd_place_combo_order::C2s,
147) -> Result<ComboTradeContext> {
148    if c2s.header.acc_id == 0 {
149        bail!("combo-order header.acc_id is required");
150    }
151    Ok(ComboTradeContext {
152        env: trd_env_label(c2s.header.trd_env)?,
153        acc_id: c2s.header.acc_id,
154        market: trd_write_market_label("combo-order", c2s.header.trd_market)?,
155        order_value: c2s.price.map(|price| price * c2s.qty),
156    })
157}
158
159pub async fn option_quote(
160    client: &Arc<FutuClient>,
161    c2s: futu_proto::qot_get_option_quote::C2s,
162) -> Result<String> {
163    let response: futu_proto::qot_get_option_quote::Response = send_proto(
164        client,
165        futu_core::proto_id::QOT_GET_OPTION_QUOTE,
166        futu_proto::qot_get_option_quote::Request { c2s },
167    )
168    .await?;
169    finish_response(
170        "option-quote",
171        response.ret_type,
172        response.ret_msg.as_deref(),
173        response.err_code,
174        &response,
175    )
176}
177
178pub async fn option_strategy(
179    client: &Arc<FutuClient>,
180    c2s: futu_proto::qot_get_option_strategy::C2s,
181) -> Result<String> {
182    let response: futu_proto::qot_get_option_strategy::Response = send_proto(
183        client,
184        futu_core::proto_id::QOT_GET_OPTION_STRATEGY,
185        futu_proto::qot_get_option_strategy::Request { c2s },
186    )
187    .await?;
188    finish_response(
189        "option-strategy",
190        response.ret_type,
191        response.ret_msg.as_deref(),
192        response.err_code,
193        &response,
194    )
195}
196
197pub async fn option_strategy_analysis(
198    client: &Arc<FutuClient>,
199    c2s: futu_proto::qot_get_option_strategy_analysis::C2s,
200) -> Result<String> {
201    let response: futu_proto::qot_get_option_strategy_analysis::Response = send_proto(
202        client,
203        futu_core::proto_id::QOT_GET_OPTION_STRATEGY_ANALYSIS,
204        futu_proto::qot_get_option_strategy_analysis::Request { c2s },
205    )
206    .await?;
207    finish_response(
208        "option-strategy-analysis",
209        response.ret_type,
210        response.ret_msg.as_deref(),
211        response.err_code,
212        &response,
213    )
214}
215
216pub async fn option_strategy_spread(
217    client: &Arc<FutuClient>,
218    c2s: futu_proto::qot_get_option_strategy_spread::C2s,
219) -> Result<String> {
220    let response: futu_proto::qot_get_option_strategy_spread::Response = send_proto(
221        client,
222        futu_core::proto_id::QOT_GET_OPTION_STRATEGY_SPREAD,
223        futu_proto::qot_get_option_strategy_spread::Request { c2s },
224    )
225    .await?;
226    finish_response(
227        "option-strategy-spread",
228        response.ret_type,
229        response.ret_msg.as_deref(),
230        response.err_code,
231        &response,
232    )
233}
234
235macro_rules! qot_proto_json_handler {
236    ($fn_name:ident, $label:literal, $proto_const:ident, $module:ident) => {
237        pub async fn $fn_name(
238            client: &Arc<FutuClient>,
239            c2s: futu_proto::$module::C2s,
240        ) -> Result<String> {
241            let response: futu_proto::$module::Response = send_proto(
242                client,
243                futu_core::proto_id::$proto_const,
244                futu_proto::$module::Request { c2s },
245            )
246            .await?;
247            finish_response(
248                $label,
249                response.ret_type,
250                response.ret_msg.as_deref(),
251                response.err_code,
252                &response,
253            )
254        }
255    };
256}
257
258qot_proto_json_handler!(
259    earnings_calendar,
260    "earnings-calendar",
261    QOT_GET_EARNINGS_CALENDAR,
262    qot_get_earnings_calendar
263);
264qot_proto_json_handler!(
265    macro_indicator_list,
266    "macro-indicator-list",
267    QOT_GET_MACRO_INDICATOR_LIST,
268    qot_get_macro_indicator_list
269);
270qot_proto_json_handler!(
271    security_trading_sessions,
272    "security-trading-sessions",
273    QOT_GET_SECURITY_TRADING_SESSIONS,
274    qot_get_security_trading_sessions
275);
276qot_proto_json_handler!(
277    market_trading_sessions,
278    "market-trading-sessions",
279    QOT_GET_MARKET_TRADING_SESSIONS,
280    qot_get_market_trading_sessions
281);
282qot_proto_json_handler!(hot_news, "hot-news", QOT_GET_HOT_NEWS, qot_get_hot_news);
283qot_proto_json_handler!(
284    latest_news,
285    "latest-news",
286    QOT_GET_LATEST_NEWS,
287    qot_get_latest_news
288);
289qot_proto_json_handler!(
290    watchlist_news,
291    "watchlist-news",
292    QOT_GET_WATCHLIST_NEWS,
293    qot_get_watchlist_news
294);
295qot_proto_json_handler!(
296    watchlist_announcement,
297    "watchlist-announcement",
298    QOT_GET_WATCHLIST_ANNOUNCEMENT,
299    qot_get_watchlist_announcement
300);
301qot_proto_json_handler!(
302    watchlist_rating,
303    "watchlist-rating",
304    QOT_GET_WATCHLIST_RATING,
305    qot_get_watchlist_rating
306);
307qot_proto_json_handler!(
308    stock_news,
309    "stock-news",
310    QOT_GET_STOCK_NEWS,
311    qot_get_stock_news
312);
313qot_proto_json_handler!(etf_screen, "etf-screen", QOT_ETF_SCREEN, qot_get_etf_screen);
314qot_proto_json_handler!(
315    fund_screen,
316    "fund-screen",
317    QOT_FUND_SCREEN,
318    qot_get_fund_screen
319);
320qot_proto_json_handler!(
321    future_screen,
322    "future-screen",
323    QOT_FUTURE_SCREEN,
324    qot_get_future_screen
325);
326qot_proto_json_handler!(
327    bond_screen,
328    "bond-screen",
329    QOT_BOND_SCREEN,
330    qot_get_bond_screen
331);
332qot_proto_json_handler!(
333    preview_order_impact,
334    "preview-order-impact",
335    TRD_PREVIEW_ORDER_IMPACT,
336    trd_preview_order_impact
337);
338qot_proto_json_handler!(
339    asset_trend,
340    "get-asset-trend",
341    TRD_GET_ASSET_TREND,
342    trd_get_asset_trend
343);
344qot_proto_json_handler!(
345    yield_trend,
346    "get-yield-trend",
347    TRD_GET_YIELD_TREND,
348    trd_get_yield_trend
349);
350qot_proto_json_handler!(
351    return_calendar,
352    "get-return-calendar",
353    TRD_GET_RETURN_CALENDAR,
354    trd_get_return_calendar
355);
356qot_proto_json_handler!(
357    order_relations,
358    "get-order-relations",
359    TRD_GET_ORDER_RELATIONS,
360    trd_get_order_relations
361);
362qot_proto_json_handler!(
363    position_corporate_actions,
364    "get-position-corporate-actions",
365    TRD_GET_POSITION_CORPORATE_ACTIONS,
366    trd_get_position_corporate_actions
367);
368qot_proto_json_handler!(
369    place_order_group,
370    "place-order-group",
371    TRD_PLACE_ORDER_GROUP,
372    trd_place_order_group
373);
374qot_proto_json_handler!(
375    modify_order_group,
376    "modify-order-group",
377    TRD_MODIFY_ORDER_GROUP,
378    trd_modify_order_group
379);
380qot_proto_json_handler!(
381    cancel_order_group,
382    "cancel-order-group",
383    TRD_CANCEL_ORDER_GROUP,
384    trd_cancel_order_group
385);
386qot_proto_json_handler!(
387    delete_order_group,
388    "delete-order-group",
389    TRD_DELETE_ORDER_GROUP,
390    trd_delete_order_group
391);
392qot_proto_json_handler!(
393    place_algo_order,
394    "place-algo-order",
395    TRD_PLACE_ALGO_ORDER,
396    trd_place_algo_order
397);
398qot_proto_json_handler!(
399    modify_algo_order,
400    "modify-algo-order",
401    TRD_MODIFY_ALGO_ORDER,
402    trd_modify_algo_order
403);
404qot_proto_json_handler!(
405    algo_order_logs,
406    "algo-order-logs",
407    TRD_GET_ALGO_ORDER_LOGS,
408    trd_get_algo_order_logs
409);
410qot_proto_json_handler!(
411    reverse_position,
412    "reverse-position",
413    TRD_REVERSE_POSITION,
414    trd_reverse_position
415);
416qot_proto_json_handler!(
417    roll_position,
418    "roll-position",
419    TRD_ROLL_POSITION,
420    trd_roll_position
421);
422qot_proto_json_handler!(
423    clear_futures_positions,
424    "clear-futures-positions",
425    TRD_CLEAR_FUTURES_POSITIONS,
426    trd_clear_futures_positions
427);
428qot_proto_json_handler!(
429    batch_close_positions,
430    "batch-close-positions",
431    TRD_BATCH_CLOSE_POSITIONS,
432    trd_batch_close_positions
433);
434qot_proto_json_handler!(
435    indicator_list,
436    "indicator-list",
437    QOT_GET_INDICATOR_LIST,
438    qot_get_indicator_list
439);
440qot_proto_json_handler!(
441    macro_indicator_history,
442    "macro-indicator-history",
443    QOT_GET_MACRO_INDICATOR_HISTORY,
444    qot_get_macro_indicator_history
445);
446qot_proto_json_handler!(
447    fed_watch_target_rate,
448    "fed-watch-target-rate",
449    QOT_GET_FED_WATCH_TARGET_RATE,
450    qot_get_fed_watch_target_rate
451);
452qot_proto_json_handler!(
453    fed_watch_dot_plot,
454    "fed-watch-dot-plot",
455    QOT_GET_FED_WATCH_DOT_PLOT,
456    qot_get_fed_watch_dot_plot
457);
458qot_proto_json_handler!(
459    earnings_beat_rank,
460    "earnings-beat-rank",
461    QOT_GET_EARNINGS_BEAT_RANK,
462    qot_get_earnings_beat_rank
463);
464qot_proto_json_handler!(
465    dividend_rank,
466    "dividend-rank",
467    QOT_GET_DIVIDEND_RANK,
468    qot_get_dividend_rank
469);
470qot_proto_json_handler!(
471    dividend_calendar,
472    "dividend-calendar",
473    QOT_GET_DIVIDEND_CALENDAR,
474    qot_get_dividend_calendar
475);
476qot_proto_json_handler!(
477    economic_calendar,
478    "economic-calendar",
479    QOT_GET_ECONOMIC_CALENDAR,
480    qot_get_economic_calendar
481);
482qot_proto_json_handler!(
483    us_pre_market_rank,
484    "us-pre-market-rank",
485    QOT_GET_US_PRE_MARKET_RANK,
486    qot_get_us_pre_market_rank
487);
488qot_proto_json_handler!(
489    us_after_hours_rank,
490    "us-after-hours-rank",
491    QOT_GET_US_AFTER_HOURS_RANK,
492    qot_get_us_after_hours_rank
493);
494qot_proto_json_handler!(
495    us_overnight_rank,
496    "us-overnight-rank",
497    QOT_GET_US_OVERNIGHT_RANK,
498    qot_get_us_overnight_rank
499);
500qot_proto_json_handler!(
501    top_movers_rank,
502    "top-movers-rank",
503    QOT_GET_TOP_MOVERS_RANK,
504    qot_get_top_movers_rank
505);
506qot_proto_json_handler!(hot_list, "hot-list", QOT_GET_HOT_LIST, qot_get_hot_list);
507qot_proto_json_handler!(
508    short_selling_rank,
509    "short-selling-rank",
510    QOT_GET_SHORT_SELLING_RANK,
511    qot_get_short_selling_rank
512);
513qot_proto_json_handler!(
514    period_change_rank,
515    "period-change-rank",
516    QOT_GET_PERIOD_CHANGE_RANK,
517    qot_get_period_change_rank
518);
519qot_proto_json_handler!(
520    high_dividend_soe_rank,
521    "high-dividend-soe-rank",
522    QOT_GET_HIGH_DIVIDEND_SOE_RANK,
523    qot_get_high_dividend_soe_rank
524);
525qot_proto_json_handler!(
526    institution_list,
527    "institution-list",
528    QOT_GET_INSTITUTION_LIST,
529    qot_get_institution_list
530);
531qot_proto_json_handler!(
532    institution_profile,
533    "institution-profile",
534    QOT_GET_INSTITUTION_PROFILE,
535    qot_get_institution_profile
536);
537qot_proto_json_handler!(
538    institution_distribution,
539    "institution-distribution",
540    QOT_GET_INSTITUTION_DISTRIBUTION,
541    qot_get_institution_distribution
542);
543qot_proto_json_handler!(
544    institution_holding_change,
545    "institution-holding-change",
546    QOT_GET_INSTITUTION_HOLDING_CHANGE,
547    qot_get_institution_holding_change
548);
549qot_proto_json_handler!(
550    institution_holding_list,
551    "institution-holding-list",
552    QOT_GET_INSTITUTION_HOLDING_LIST,
553    qot_get_institution_holding_list
554);
555qot_proto_json_handler!(
556    ark_fund_holding,
557    "ark-fund-holding",
558    QOT_GET_ARK_FUND_HOLDING,
559    qot_get_ark_fund_holding
560);
561qot_proto_json_handler!(
562    ark_stock_dynamic,
563    "ark-stock-dynamic",
564    QOT_GET_ARK_STOCK_DYNAMIC,
565    qot_get_ark_stock_dynamic
566);
567qot_proto_json_handler!(
568    ark_active_transaction,
569    "ark-active-transaction",
570    QOT_GET_ARK_ACTIVE_TRANSACTION,
571    qot_get_ark_active_transaction
572);
573qot_proto_json_handler!(
574    rating_change,
575    "rating-change",
576    QOT_GET_RATING_CHANGE,
577    qot_get_rating_change
578);
579qot_proto_json_handler!(
580    search_quote,
581    "search-quote",
582    QOT_GET_SEARCH_QUOTE,
583    qot_get_search_quote
584);
585qot_proto_json_handler!(
586    search_news,
587    "search-news",
588    QOT_GET_SEARCH_NEWS,
589    qot_get_search_news
590);
591qot_proto_json_handler!(
592    option_market_statistic,
593    "option-market-statistic",
594    QOT_GET_OPTION_MARKET_STATISTIC,
595    qot_get_option_market_statistic
596);
597qot_proto_json_handler!(
598    option_underlying_his_statistic,
599    "option-underlying-his-statistic",
600    QOT_GET_OPTION_UNDERLYING_HIS_STATISTIC,
601    qot_get_option_underlying_his_statistic
602);
603qot_proto_json_handler!(
604    option_underlying_overview,
605    "option-underlying-overview",
606    QOT_GET_OPTION_UNDERLYING_OVERVIEW,
607    qot_get_option_underlying_overview
608);
609qot_proto_json_handler!(
610    option_underlying_his_volatility,
611    "option-underlying-his-volatility",
612    QOT_GET_OPTION_UNDERLYING_HIS_VOLATILITY,
613    qot_get_option_underlying_his_volatility
614);
615qot_proto_json_handler!(
616    option_underlying_rank,
617    "option-underlying-rank",
618    QOT_GET_OPTION_UNDERLYING_RANK,
619    qot_get_option_underlying_rank
620);
621qot_proto_json_handler!(
622    option_rank,
623    "option-rank",
624    QOT_GET_OPTION_RANK,
625    qot_get_option_rank
626);
627qot_proto_json_handler!(
628    option_event,
629    "option-event",
630    QOT_GET_OPTION_EVENT,
631    qot_get_option_event
632);
633qot_proto_json_handler!(
634    option_event_alert,
635    "option-event-alert",
636    QOT_GET_OPTION_EVENT_ALERT,
637    qot_get_option_event_alert
638);
639qot_proto_json_handler!(
640    set_option_event_alert,
641    "set-option-event-alert",
642    QOT_SET_OPTION_EVENT_ALERT,
643    qot_set_option_event_alert
644);
645qot_proto_json_handler!(
646    option_zero_dte_screener,
647    "option-zero-dte-screener",
648    QOT_GET_OPTION_ZERO_DTE_SCREENER,
649    qot_get_option_zero_dte_screener
650);
651qot_proto_json_handler!(
652    option_zero_dte_contract,
653    "option-zero-dte-contract",
654    QOT_GET_OPTION_ZERO_DTE_CONTRACT,
655    qot_get_option_zero_dte_contract
656);
657qot_proto_json_handler!(
658    option_earnings_screener,
659    "option-earnings-screener",
660    QOT_GET_OPTION_EARNINGS_SCREENER,
661    qot_get_option_earnings_screener
662);
663qot_proto_json_handler!(
664    option_seller_screener,
665    "option-seller-screener",
666    QOT_GET_OPTION_SELLER_SCREENER,
667    qot_get_option_seller_screener
668);
669qot_proto_json_handler!(
670    industrial_chain_list,
671    "industrial-chain-list",
672    QOT_GET_INDUSTRIAL_CHAIN_LIST,
673    qot_get_industrial_chain_list
674);
675qot_proto_json_handler!(
676    industrial_chain_detail,
677    "industrial-chain-detail",
678    QOT_GET_INDUSTRIAL_CHAIN_DETAIL,
679    qot_get_industrial_chain_detail
680);
681qot_proto_json_handler!(
682    industrial_chain_by_plate,
683    "industrial-chain-by-plate",
684    QOT_GET_INDUSTRIAL_CHAIN_BY_PLATE,
685    qot_get_industrial_chain_by_plate
686);
687qot_proto_json_handler!(
688    industrial_plate_info,
689    "industrial-plate-info",
690    QOT_GET_INDUSTRIAL_PLATE_INFO,
691    qot_get_industrial_plate_info
692);
693qot_proto_json_handler!(
694    industrial_plate_stock,
695    "industrial-plate-stock",
696    QOT_GET_INDUSTRIAL_PLATE_STOCK,
697    qot_get_industrial_plate_stock
698);
699qot_proto_json_handler!(
700    heat_map_data,
701    "heat-map-data",
702    QOT_GET_HEAT_MAP_DATA,
703    qot_get_heat_map_data
704);
705qot_proto_json_handler!(
706    rise_fall_distribution,
707    "rise-fall-distribution",
708    QOT_GET_RISE_FALL_DISTRIBUTION,
709    qot_get_rise_fall_distribution
710);
711
712pub async fn combo_max_trd_qtys(
713    client: &Arc<FutuClient>,
714    c2s: futu_proto::trd_get_combo_max_trd_qtys::C2s,
715) -> Result<String> {
716    let response: futu_proto::trd_get_combo_max_trd_qtys::Response = send_proto(
717        client,
718        futu_core::proto_id::TRD_GET_COMBO_MAX_TRD_QTYS,
719        futu_proto::trd_get_combo_max_trd_qtys::Request { c2s },
720    )
721    .await?;
722    finish_response(
723        "combo-max-trd-qtys",
724        response.ret_type,
725        response.ret_msg.as_deref(),
726        response.err_code,
727        &response,
728    )
729}
730
731pub async fn place_combo_order(
732    client: &Arc<FutuClient>,
733    mut c2s: futu_proto::trd_place_combo_order::C2s,
734    idempotency_key: Option<String>,
735) -> Result<String> {
736    c2s.packet_id = match idempotency_key.as_deref() {
737        Some(key) => packet_id_for_idempotency_key(key),
738        None => {
739            let conn_id = client
740                .conn_id()
741                .ok_or_else(|| anyhow!("combo-order missing InitConnect conn_id"))?;
742            next_packet_id(conn_id)
743        }
744    };
745
746    let response: futu_proto::trd_place_combo_order::Response = send_proto(
747        client,
748        futu_core::proto_id::TRD_PLACE_COMBO_ORDER,
749        futu_proto::trd_place_combo_order::Request { c2s },
750    )
751    .await?;
752    finish_response(
753        "combo-order",
754        response.ret_type,
755        response.ret_msg.as_deref(),
756        response.err_code,
757        &response,
758    )
759}
760
761async fn send_proto<Req, Resp>(
762    client: &Arc<FutuClient>,
763    proto_id: u32,
764    request: Req,
765) -> Result<Resp>
766where
767    Req: Message,
768    Resp: Message + Default,
769{
770    let frame = client.request(proto_id, request.encode_to_vec()).await?;
771    Resp::decode(frame.body.as_ref()).map_err(|err| anyhow!("decode response: {err}"))
772}
773
774fn finish_response<T: Serialize>(
775    label: &str,
776    ret_type: i32,
777    ret_msg: Option<&str>,
778    err_code: Option<i32>,
779    response: &T,
780) -> Result<String> {
781    if ret_type != 0 {
782        bail!("{label} ret_type={ret_type} msg={ret_msg:?} err_code={err_code:?}");
783    }
784    Ok(serde_json::to_string_pretty(response)?)
785}
786
787fn trd_env_label(trd_env: i32) -> Result<&'static str> {
788    match trd_env {
789        0 => Ok("simulate"),
790        1 => Ok("real"),
791        other => {
792            bail!("unsupported combo-order header.trd_env={other}; expected 0 simulate or 1 real")
793        }
794    }
795}
796
797fn trd_write_market_label(endpoint: &str, trd_market: i32) -> Result<String> {
798    if let Some(label) = trade_market::canonical_fund_trd_market_label(trd_market) {
799        bail!(
800            "{endpoint} header.trd_market={trd_market} ({label}) is view-only; \
801             use a write-capable main market for combo trade paths"
802        );
803    }
804    trd_market_label(endpoint, trd_market)
805}
806
807fn trd_market_label(endpoint: &str, trd_market: i32) -> Result<String> {
808    let market = TrdMarketEnum::from_i32(trd_market)
809        .ok_or_else(|| anyhow!("unsupported {endpoint} header.trd_market={trd_market}"))?;
810    let int_values = TrdMarketEnum::all_int_values();
811    let string_values = TrdMarketEnum::all_string_values();
812    let idx = int_values
813        .iter()
814        .position(|&value| value == market.as_i32())
815        .ok_or_else(|| anyhow!("{endpoint} trd_market has no canonical label"))?;
816    Ok(string_values[idx].to_string())
817}
818
819fn next_packet_id(conn_id: u64) -> futu_proto::common::PacketId {
820    let serial_no = PACKET_SERIAL.fetch_add(1, Ordering::Relaxed);
821    futu_proto::common::PacketId { conn_id, serial_no }
822}
823
824fn packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
825    let mut hasher = DefaultHasher::new();
826    key.hash(&mut hasher);
827    futu_proto::common::PacketId {
828        conn_id: hasher.finish(),
829        serial_no: 0,
830    }
831}
832
833#[cfg(test)]
834mod tests;