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