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
17static PACKET_SERIAL: AtomicU32 = AtomicU32::new(1);
18
19#[derive(Debug, Clone)]
20pub struct ComboTradeContext {
21    pub env: &'static str,
22    pub acc_id: u64,
23    pub market: String,
24    pub order_value: Option<f64>,
25}
26
27pub fn parse_c2s_json<T>(label: &str, json: &str) -> Result<T>
28where
29    T: DeserializeOwned,
30{
31    let mut value: serde_json::Value =
32        serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s_json: {err}"))?;
33    validate_proto_json_contract(label, &mut value)?;
34    serde_json::from_value(value).map_err(|err| anyhow!("{label} c2s_json: {err}"))
35}
36
37fn validate_proto_json_contract(label: &str, value: &mut serde_json::Value) -> Result<()> {
38    let Some(spec) = futu_surface_spec::lookup_endpoint_by_cli_subcommand(label) else {
39        return Ok(());
40    };
41    futu_surface_spec::validate_and_normalize(spec, value)
42        .map_err(|err| anyhow!("{label} c2s_json: {err}"))
43}
44
45pub fn parse_combo_max_c2s_json(json: &str) -> Result<futu_proto::trd_get_combo_max_trd_qtys::C2s> {
46    parse_combo_c2s_json("combo-max-trd-qtys", json)
47}
48
49pub fn parse_place_combo_c2s_json(json: &str) -> Result<futu_proto::trd_place_combo_order::C2s> {
50    parse_combo_c2s_json("combo-order", json)
51}
52
53fn parse_combo_c2s_json<T>(label: &str, json: &str) -> Result<T>
54where
55    T: DeserializeOwned,
56{
57    parse_c2s_json_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_json_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    serde_json::from_value(value).map_err(|err| anyhow!("{label} c2s_json: {err}"))
92}
93
94fn json_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> {
95    let mut current = value;
96    for segment in path {
97        current = current.get(*segment)?;
98    }
99    Some(current)
100}
101
102fn validate_combo_trade_contract(label: &str, value: &serde_json::Value) -> Result<()> {
103    match json_path(value, &["header", "acc_id"]).and_then(serde_json::Value::as_u64) {
104        Some(acc_id) if acc_id > 0 => {}
105        _ => bail!("{label} c2s_json header.acc_id must be a positive integer"),
106    }
107
108    let legs = json_path(value, &["combo_legs"])
109        .and_then(serde_json::Value::as_array)
110        .ok_or_else(|| anyhow!("{label} c2s_json combo_legs must be an array"))?;
111    if legs.len() < 2 {
112        bail!("{label} c2s_json combo_legs must contain at least two legs");
113    }
114
115    match json_path(value, &["qty"]).and_then(serde_json::Value::as_f64) {
116        Some(qty) if qty > 0.0 => {}
117        _ => bail!("{label} c2s_json qty must be positive"),
118    }
119
120    let order_type = json_path(value, &["order_type"])
121        .and_then(serde_json::Value::as_i64)
122        .ok_or_else(|| anyhow!("{label} c2s_json order_type must be an integer"))?;
123    if i32::try_from(order_type).is_err() {
124        bail!("{label} c2s_json order_type={order_type} is out of range");
125    }
126
127    Ok(())
128}
129
130pub fn combo_max_context(c2s: &futu_proto::trd_get_combo_max_trd_qtys::C2s) -> Result<u64> {
131    if c2s.header.acc_id == 0 {
132        bail!("combo-max-trd-qtys header.acc_id is required");
133    }
134    trd_env_label(c2s.header.trd_env)?;
135    trd_write_market_label("combo-max-trd-qtys", c2s.header.trd_market)?;
136    Ok(c2s.header.acc_id)
137}
138
139pub fn place_combo_context(
140    c2s: &futu_proto::trd_place_combo_order::C2s,
141) -> Result<ComboTradeContext> {
142    if c2s.header.acc_id == 0 {
143        bail!("combo-order header.acc_id is required");
144    }
145    Ok(ComboTradeContext {
146        env: trd_env_label(c2s.header.trd_env)?,
147        acc_id: c2s.header.acc_id,
148        market: trd_write_market_label("combo-order", c2s.header.trd_market)?,
149        order_value: c2s.price.map(|price| price * c2s.qty),
150    })
151}
152
153pub async fn option_quote(
154    client: &Arc<FutuClient>,
155    c2s: futu_proto::qot_get_option_quote::C2s,
156) -> Result<String> {
157    let response: futu_proto::qot_get_option_quote::Response = send_proto(
158        client,
159        futu_core::proto_id::QOT_GET_OPTION_QUOTE,
160        futu_proto::qot_get_option_quote::Request { c2s },
161    )
162    .await?;
163    finish_response(
164        "option-quote",
165        response.ret_type,
166        response.ret_msg.as_deref(),
167        response.err_code,
168        &response,
169    )
170}
171
172pub async fn option_strategy(
173    client: &Arc<FutuClient>,
174    c2s: futu_proto::qot_get_option_strategy::C2s,
175) -> Result<String> {
176    let response: futu_proto::qot_get_option_strategy::Response = send_proto(
177        client,
178        futu_core::proto_id::QOT_GET_OPTION_STRATEGY,
179        futu_proto::qot_get_option_strategy::Request { c2s },
180    )
181    .await?;
182    finish_response(
183        "option-strategy",
184        response.ret_type,
185        response.ret_msg.as_deref(),
186        response.err_code,
187        &response,
188    )
189}
190
191pub async fn option_strategy_analysis(
192    client: &Arc<FutuClient>,
193    c2s: futu_proto::qot_get_option_strategy_analysis::C2s,
194) -> Result<String> {
195    let response: futu_proto::qot_get_option_strategy_analysis::Response = send_proto(
196        client,
197        futu_core::proto_id::QOT_GET_OPTION_STRATEGY_ANALYSIS,
198        futu_proto::qot_get_option_strategy_analysis::Request { c2s },
199    )
200    .await?;
201    finish_response(
202        "option-strategy-analysis",
203        response.ret_type,
204        response.ret_msg.as_deref(),
205        response.err_code,
206        &response,
207    )
208}
209
210pub async fn option_strategy_spread(
211    client: &Arc<FutuClient>,
212    c2s: futu_proto::qot_get_option_strategy_spread::C2s,
213) -> Result<String> {
214    let response: futu_proto::qot_get_option_strategy_spread::Response = send_proto(
215        client,
216        futu_core::proto_id::QOT_GET_OPTION_STRATEGY_SPREAD,
217        futu_proto::qot_get_option_strategy_spread::Request { c2s },
218    )
219    .await?;
220    finish_response(
221        "option-strategy-spread",
222        response.ret_type,
223        response.ret_msg.as_deref(),
224        response.err_code,
225        &response,
226    )
227}
228
229macro_rules! qot_proto_json_handler {
230    ($fn_name:ident, $label:literal, $proto_const:ident, $module:ident) => {
231        pub async fn $fn_name(
232            client: &Arc<FutuClient>,
233            c2s: futu_proto::$module::C2s,
234        ) -> Result<String> {
235            let response: futu_proto::$module::Response = send_proto(
236                client,
237                futu_core::proto_id::$proto_const,
238                futu_proto::$module::Request { c2s },
239            )
240            .await?;
241            finish_response(
242                $label,
243                response.ret_type,
244                response.ret_msg.as_deref(),
245                response.err_code,
246                &response,
247            )
248        }
249    };
250}
251
252qot_proto_json_handler!(
253    earnings_calendar,
254    "earnings-calendar",
255    QOT_GET_EARNINGS_CALENDAR,
256    qot_get_earnings_calendar
257);
258qot_proto_json_handler!(
259    macro_indicator_list,
260    "macro-indicator-list",
261    QOT_GET_MACRO_INDICATOR_LIST,
262    qot_get_macro_indicator_list
263);
264qot_proto_json_handler!(
265    indicator_list,
266    "indicator-list",
267    QOT_GET_INDICATOR_LIST,
268    qot_get_indicator_list
269);
270qot_proto_json_handler!(
271    macro_indicator_history,
272    "macro-indicator-history",
273    QOT_GET_MACRO_INDICATOR_HISTORY,
274    qot_get_macro_indicator_history
275);
276qot_proto_json_handler!(
277    fed_watch_target_rate,
278    "fed-watch-target-rate",
279    QOT_GET_FED_WATCH_TARGET_RATE,
280    qot_get_fed_watch_target_rate
281);
282qot_proto_json_handler!(
283    fed_watch_dot_plot,
284    "fed-watch-dot-plot",
285    QOT_GET_FED_WATCH_DOT_PLOT,
286    qot_get_fed_watch_dot_plot
287);
288qot_proto_json_handler!(
289    earnings_beat_rank,
290    "earnings-beat-rank",
291    QOT_GET_EARNINGS_BEAT_RANK,
292    qot_get_earnings_beat_rank
293);
294qot_proto_json_handler!(
295    dividend_rank,
296    "dividend-rank",
297    QOT_GET_DIVIDEND_RANK,
298    qot_get_dividend_rank
299);
300qot_proto_json_handler!(
301    dividend_calendar,
302    "dividend-calendar",
303    QOT_GET_DIVIDEND_CALENDAR,
304    qot_get_dividend_calendar
305);
306qot_proto_json_handler!(
307    economic_calendar,
308    "economic-calendar",
309    QOT_GET_ECONOMIC_CALENDAR,
310    qot_get_economic_calendar
311);
312qot_proto_json_handler!(
313    us_pre_market_rank,
314    "us-pre-market-rank",
315    QOT_GET_US_PRE_MARKET_RANK,
316    qot_get_us_pre_market_rank
317);
318qot_proto_json_handler!(
319    us_after_hours_rank,
320    "us-after-hours-rank",
321    QOT_GET_US_AFTER_HOURS_RANK,
322    qot_get_us_after_hours_rank
323);
324qot_proto_json_handler!(
325    us_overnight_rank,
326    "us-overnight-rank",
327    QOT_GET_US_OVERNIGHT_RANK,
328    qot_get_us_overnight_rank
329);
330qot_proto_json_handler!(
331    top_movers_rank,
332    "top-movers-rank",
333    QOT_GET_TOP_MOVERS_RANK,
334    qot_get_top_movers_rank
335);
336qot_proto_json_handler!(hot_list, "hot-list", QOT_GET_HOT_LIST, qot_get_hot_list);
337qot_proto_json_handler!(
338    short_selling_rank,
339    "short-selling-rank",
340    QOT_GET_SHORT_SELLING_RANK,
341    qot_get_short_selling_rank
342);
343qot_proto_json_handler!(
344    period_change_rank,
345    "period-change-rank",
346    QOT_GET_PERIOD_CHANGE_RANK,
347    qot_get_period_change_rank
348);
349qot_proto_json_handler!(
350    high_dividend_soe_rank,
351    "high-dividend-soe-rank",
352    QOT_GET_HIGH_DIVIDEND_SOE_RANK,
353    qot_get_high_dividend_soe_rank
354);
355qot_proto_json_handler!(
356    institution_list,
357    "institution-list",
358    QOT_GET_INSTITUTION_LIST,
359    qot_get_institution_list
360);
361qot_proto_json_handler!(
362    institution_profile,
363    "institution-profile",
364    QOT_GET_INSTITUTION_PROFILE,
365    qot_get_institution_profile
366);
367qot_proto_json_handler!(
368    institution_distribution,
369    "institution-distribution",
370    QOT_GET_INSTITUTION_DISTRIBUTION,
371    qot_get_institution_distribution
372);
373qot_proto_json_handler!(
374    institution_holding_change,
375    "institution-holding-change",
376    QOT_GET_INSTITUTION_HOLDING_CHANGE,
377    qot_get_institution_holding_change
378);
379qot_proto_json_handler!(
380    institution_holding_list,
381    "institution-holding-list",
382    QOT_GET_INSTITUTION_HOLDING_LIST,
383    qot_get_institution_holding_list
384);
385qot_proto_json_handler!(
386    ark_fund_holding,
387    "ark-fund-holding",
388    QOT_GET_ARK_FUND_HOLDING,
389    qot_get_ark_fund_holding
390);
391qot_proto_json_handler!(
392    ark_stock_dynamic,
393    "ark-stock-dynamic",
394    QOT_GET_ARK_STOCK_DYNAMIC,
395    qot_get_ark_stock_dynamic
396);
397qot_proto_json_handler!(
398    ark_active_transaction,
399    "ark-active-transaction",
400    QOT_GET_ARK_ACTIVE_TRANSACTION,
401    qot_get_ark_active_transaction
402);
403qot_proto_json_handler!(
404    rating_change,
405    "rating-change",
406    QOT_GET_RATING_CHANGE,
407    qot_get_rating_change
408);
409qot_proto_json_handler!(
410    search_quote,
411    "search-quote",
412    QOT_GET_SEARCH_QUOTE,
413    qot_get_search_quote
414);
415qot_proto_json_handler!(
416    search_news,
417    "search-news",
418    QOT_GET_SEARCH_NEWS,
419    qot_get_search_news
420);
421qot_proto_json_handler!(
422    option_market_statistic,
423    "option-market-statistic",
424    QOT_GET_OPTION_MARKET_STATISTIC,
425    qot_get_option_market_statistic
426);
427qot_proto_json_handler!(
428    option_underlying_his_statistic,
429    "option-underlying-his-statistic",
430    QOT_GET_OPTION_UNDERLYING_HIS_STATISTIC,
431    qot_get_option_underlying_his_statistic
432);
433qot_proto_json_handler!(
434    option_underlying_overview,
435    "option-underlying-overview",
436    QOT_GET_OPTION_UNDERLYING_OVERVIEW,
437    qot_get_option_underlying_overview
438);
439qot_proto_json_handler!(
440    option_underlying_his_volatility,
441    "option-underlying-his-volatility",
442    QOT_GET_OPTION_UNDERLYING_HIS_VOLATILITY,
443    qot_get_option_underlying_his_volatility
444);
445qot_proto_json_handler!(
446    option_underlying_rank,
447    "option-underlying-rank",
448    QOT_GET_OPTION_UNDERLYING_RANK,
449    qot_get_option_underlying_rank
450);
451qot_proto_json_handler!(
452    option_rank,
453    "option-rank",
454    QOT_GET_OPTION_RANK,
455    qot_get_option_rank
456);
457qot_proto_json_handler!(
458    option_event,
459    "option-event",
460    QOT_GET_OPTION_EVENT,
461    qot_get_option_event
462);
463qot_proto_json_handler!(
464    option_event_alert,
465    "option-event-alert",
466    QOT_GET_OPTION_EVENT_ALERT,
467    qot_get_option_event_alert
468);
469qot_proto_json_handler!(
470    set_option_event_alert,
471    "set-option-event-alert",
472    QOT_SET_OPTION_EVENT_ALERT,
473    qot_set_option_event_alert
474);
475qot_proto_json_handler!(
476    option_zero_dte_screener,
477    "option-zero-dte-screener",
478    QOT_GET_OPTION_ZERO_DTE_SCREENER,
479    qot_get_option_zero_dte_screener
480);
481qot_proto_json_handler!(
482    option_zero_dte_contract,
483    "option-zero-dte-contract",
484    QOT_GET_OPTION_ZERO_DTE_CONTRACT,
485    qot_get_option_zero_dte_contract
486);
487qot_proto_json_handler!(
488    option_earnings_screener,
489    "option-earnings-screener",
490    QOT_GET_OPTION_EARNINGS_SCREENER,
491    qot_get_option_earnings_screener
492);
493qot_proto_json_handler!(
494    option_seller_screener,
495    "option-seller-screener",
496    QOT_GET_OPTION_SELLER_SCREENER,
497    qot_get_option_seller_screener
498);
499qot_proto_json_handler!(
500    industrial_chain_list,
501    "industrial-chain-list",
502    QOT_GET_INDUSTRIAL_CHAIN_LIST,
503    qot_get_industrial_chain_list
504);
505qot_proto_json_handler!(
506    industrial_chain_detail,
507    "industrial-chain-detail",
508    QOT_GET_INDUSTRIAL_CHAIN_DETAIL,
509    qot_get_industrial_chain_detail
510);
511qot_proto_json_handler!(
512    industrial_chain_by_plate,
513    "industrial-chain-by-plate",
514    QOT_GET_INDUSTRIAL_CHAIN_BY_PLATE,
515    qot_get_industrial_chain_by_plate
516);
517qot_proto_json_handler!(
518    industrial_plate_info,
519    "industrial-plate-info",
520    QOT_GET_INDUSTRIAL_PLATE_INFO,
521    qot_get_industrial_plate_info
522);
523qot_proto_json_handler!(
524    industrial_plate_stock,
525    "industrial-plate-stock",
526    QOT_GET_INDUSTRIAL_PLATE_STOCK,
527    qot_get_industrial_plate_stock
528);
529qot_proto_json_handler!(
530    heat_map_data,
531    "heat-map-data",
532    QOT_GET_HEAT_MAP_DATA,
533    qot_get_heat_map_data
534);
535qot_proto_json_handler!(
536    rise_fall_distribution,
537    "rise-fall-distribution",
538    QOT_GET_RISE_FALL_DISTRIBUTION,
539    qot_get_rise_fall_distribution
540);
541
542pub async fn combo_max_trd_qtys(
543    client: &Arc<FutuClient>,
544    c2s: futu_proto::trd_get_combo_max_trd_qtys::C2s,
545) -> Result<String> {
546    let response: futu_proto::trd_get_combo_max_trd_qtys::Response = send_proto(
547        client,
548        futu_core::proto_id::TRD_GET_COMBO_MAX_TRD_QTYS,
549        futu_proto::trd_get_combo_max_trd_qtys::Request { c2s },
550    )
551    .await?;
552    finish_response(
553        "combo-max-trd-qtys",
554        response.ret_type,
555        response.ret_msg.as_deref(),
556        response.err_code,
557        &response,
558    )
559}
560
561pub async fn place_combo_order(
562    client: &Arc<FutuClient>,
563    mut c2s: futu_proto::trd_place_combo_order::C2s,
564    idempotency_key: Option<String>,
565) -> Result<String> {
566    c2s.packet_id = match idempotency_key.as_deref() {
567        Some(key) => packet_id_for_idempotency_key(key),
568        None => {
569            let conn_id = client
570                .conn_id()
571                .ok_or_else(|| anyhow!("combo-order missing InitConnect conn_id"))?;
572            next_packet_id(conn_id)
573        }
574    };
575
576    let response: futu_proto::trd_place_combo_order::Response = send_proto(
577        client,
578        futu_core::proto_id::TRD_PLACE_COMBO_ORDER,
579        futu_proto::trd_place_combo_order::Request { c2s },
580    )
581    .await?;
582    finish_response(
583        "combo-order",
584        response.ret_type,
585        response.ret_msg.as_deref(),
586        response.err_code,
587        &response,
588    )
589}
590
591async fn send_proto<Req, Resp>(
592    client: &Arc<FutuClient>,
593    proto_id: u32,
594    request: Req,
595) -> Result<Resp>
596where
597    Req: Message,
598    Resp: Message + Default,
599{
600    let frame = client.request(proto_id, request.encode_to_vec()).await?;
601    Resp::decode(frame.body.as_ref()).map_err(|err| anyhow!("decode response: {err}"))
602}
603
604fn finish_response<T: Serialize>(
605    label: &str,
606    ret_type: i32,
607    ret_msg: Option<&str>,
608    err_code: Option<i32>,
609    response: &T,
610) -> Result<String> {
611    if ret_type != 0 {
612        bail!("{label} ret_type={ret_type} msg={ret_msg:?} err_code={err_code:?}");
613    }
614    Ok(serde_json::to_string_pretty(response)?)
615}
616
617fn trd_env_label(trd_env: i32) -> Result<&'static str> {
618    match trd_env {
619        0 => Ok("simulate"),
620        1 => Ok("real"),
621        other => {
622            bail!("unsupported combo-order header.trd_env={other}; expected 0 simulate or 1 real")
623        }
624    }
625}
626
627fn trd_write_market_label(endpoint: &str, trd_market: i32) -> Result<String> {
628    if let Some(label) = trade_market::canonical_fund_trd_market_label(trd_market) {
629        bail!(
630            "{endpoint} header.trd_market={trd_market} ({label}) is view-only; \
631             use a write-capable main market for combo trade paths"
632        );
633    }
634    trd_market_label(endpoint, trd_market)
635}
636
637fn trd_market_label(endpoint: &str, trd_market: i32) -> Result<String> {
638    let market = TrdMarketEnum::from_i32(trd_market)
639        .ok_or_else(|| anyhow!("unsupported {endpoint} header.trd_market={trd_market}"))?;
640    let int_values = TrdMarketEnum::all_int_values();
641    let string_values = TrdMarketEnum::all_string_values();
642    let idx = int_values
643        .iter()
644        .position(|&value| value == market.as_i32())
645        .ok_or_else(|| anyhow!("{endpoint} trd_market has no canonical label"))?;
646    Ok(string_values[idx].to_string())
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 packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
655    let mut hasher = DefaultHasher::new();
656    key.hash(&mut hasher);
657    futu_proto::common::PacketId {
658        conn_id: hasher.finish(),
659        serial_no: 0,
660    }
661}
662
663#[cfg(test)]
664mod tests;