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;
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
205macro_rules! qot_proto_json_command {
206    ($fn_name:ident, $label:literal, $proto_id:expr, $module:ident) => {
207        pub async fn $fn_name(gateway: &str, c2s_json: &str, output: OutputFormat) -> Result<()> {
208            let c2s = parse_c2s::<futu_proto::$module::C2s>($label, c2s_json)?;
209            let request = futu_proto::$module::Request { c2s };
210            let response: futu_proto::$module::Response =
211                send_proto(gateway, concat!("futucli-", $label), $proto_id, request).await?;
212            ensure_success(
213                $label,
214                response.ret_type,
215                response.ret_msg.as_deref(),
216                response.err_code,
217            )?;
218            print_proto_response(output, &response)
219        }
220    };
221}
222
223qot_proto_json_command!(
224    run_option_quote,
225    "option-quote",
226    futu_core::proto_id::QOT_GET_OPTION_QUOTE,
227    qot_get_option_quote
228);
229qot_proto_json_command!(
230    run_option_strategy,
231    "option-strategy",
232    futu_core::proto_id::QOT_GET_OPTION_STRATEGY,
233    qot_get_option_strategy
234);
235qot_proto_json_command!(
236    run_option_strategy_analysis,
237    "option-strategy-analysis",
238    futu_core::proto_id::QOT_GET_OPTION_STRATEGY_ANALYSIS,
239    qot_get_option_strategy_analysis
240);
241qot_proto_json_command!(
242    run_option_strategy_spread,
243    "option-strategy-spread",
244    futu_core::proto_id::QOT_GET_OPTION_STRATEGY_SPREAD,
245    qot_get_option_strategy_spread
246);
247qot_proto_json_command!(
248    run_earnings_calendar,
249    "earnings-calendar",
250    futu_core::proto_id::QOT_GET_EARNINGS_CALENDAR,
251    qot_get_earnings_calendar
252);
253qot_proto_json_command!(
254    run_macro_indicator_list,
255    "macro-indicator-list",
256    futu_core::proto_id::QOT_GET_MACRO_INDICATOR_LIST,
257    qot_get_macro_indicator_list
258);
259qot_proto_json_command!(
260    run_macro_indicator_history,
261    "macro-indicator-history",
262    futu_core::proto_id::QOT_GET_MACRO_INDICATOR_HISTORY,
263    qot_get_macro_indicator_history
264);
265qot_proto_json_command!(
266    run_fed_watch_target_rate,
267    "fed-watch-target-rate",
268    futu_core::proto_id::QOT_GET_FED_WATCH_TARGET_RATE,
269    qot_get_fed_watch_target_rate
270);
271qot_proto_json_command!(
272    run_fed_watch_dot_plot,
273    "fed-watch-dot-plot",
274    futu_core::proto_id::QOT_GET_FED_WATCH_DOT_PLOT,
275    qot_get_fed_watch_dot_plot
276);
277qot_proto_json_command!(
278    run_earnings_beat_rank,
279    "earnings-beat-rank",
280    futu_core::proto_id::QOT_GET_EARNINGS_BEAT_RANK,
281    qot_get_earnings_beat_rank
282);
283qot_proto_json_command!(
284    run_dividend_rank,
285    "dividend-rank",
286    futu_core::proto_id::QOT_GET_DIVIDEND_RANK,
287    qot_get_dividend_rank
288);
289qot_proto_json_command!(
290    run_dividend_calendar,
291    "dividend-calendar",
292    futu_core::proto_id::QOT_GET_DIVIDEND_CALENDAR,
293    qot_get_dividend_calendar
294);
295qot_proto_json_command!(
296    run_economic_calendar,
297    "economic-calendar",
298    futu_core::proto_id::QOT_GET_ECONOMIC_CALENDAR,
299    qot_get_economic_calendar
300);
301qot_proto_json_command!(
302    run_us_pre_market_rank,
303    "us-pre-market-rank",
304    futu_core::proto_id::QOT_GET_US_PRE_MARKET_RANK,
305    qot_get_us_pre_market_rank
306);
307qot_proto_json_command!(
308    run_us_after_hours_rank,
309    "us-after-hours-rank",
310    futu_core::proto_id::QOT_GET_US_AFTER_HOURS_RANK,
311    qot_get_us_after_hours_rank
312);
313qot_proto_json_command!(
314    run_us_overnight_rank,
315    "us-overnight-rank",
316    futu_core::proto_id::QOT_GET_US_OVERNIGHT_RANK,
317    qot_get_us_overnight_rank
318);
319qot_proto_json_command!(
320    run_top_movers_rank,
321    "top-movers-rank",
322    futu_core::proto_id::QOT_GET_TOP_MOVERS_RANK,
323    qot_get_top_movers_rank
324);
325qot_proto_json_command!(
326    run_hot_list,
327    "hot-list",
328    futu_core::proto_id::QOT_GET_HOT_LIST,
329    qot_get_hot_list
330);
331qot_proto_json_command!(
332    run_short_selling_rank,
333    "short-selling-rank",
334    futu_core::proto_id::QOT_GET_SHORT_SELLING_RANK,
335    qot_get_short_selling_rank
336);
337qot_proto_json_command!(
338    run_period_change_rank,
339    "period-change-rank",
340    futu_core::proto_id::QOT_GET_PERIOD_CHANGE_RANK,
341    qot_get_period_change_rank
342);
343qot_proto_json_command!(
344    run_high_dividend_soe_rank,
345    "high-dividend-soe-rank",
346    futu_core::proto_id::QOT_GET_HIGH_DIVIDEND_SOE_RANK,
347    qot_get_high_dividend_soe_rank
348);
349qot_proto_json_command!(
350    run_institution_list,
351    "institution-list",
352    futu_core::proto_id::QOT_GET_INSTITUTION_LIST,
353    qot_get_institution_list
354);
355qot_proto_json_command!(
356    run_institution_profile,
357    "institution-profile",
358    futu_core::proto_id::QOT_GET_INSTITUTION_PROFILE,
359    qot_get_institution_profile
360);
361qot_proto_json_command!(
362    run_institution_distribution,
363    "institution-distribution",
364    futu_core::proto_id::QOT_GET_INSTITUTION_DISTRIBUTION,
365    qot_get_institution_distribution
366);
367qot_proto_json_command!(
368    run_institution_holding_change,
369    "institution-holding-change",
370    futu_core::proto_id::QOT_GET_INSTITUTION_HOLDING_CHANGE,
371    qot_get_institution_holding_change
372);
373qot_proto_json_command!(
374    run_institution_holding_list,
375    "institution-holding-list",
376    futu_core::proto_id::QOT_GET_INSTITUTION_HOLDING_LIST,
377    qot_get_institution_holding_list
378);
379qot_proto_json_command!(
380    run_ark_fund_holding,
381    "ark-fund-holding",
382    futu_core::proto_id::QOT_GET_ARK_FUND_HOLDING,
383    qot_get_ark_fund_holding
384);
385qot_proto_json_command!(
386    run_ark_stock_dynamic,
387    "ark-stock-dynamic",
388    futu_core::proto_id::QOT_GET_ARK_STOCK_DYNAMIC,
389    qot_get_ark_stock_dynamic
390);
391qot_proto_json_command!(
392    run_ark_active_transaction,
393    "ark-active-transaction",
394    futu_core::proto_id::QOT_GET_ARK_ACTIVE_TRANSACTION,
395    qot_get_ark_active_transaction
396);
397qot_proto_json_command!(
398    run_rating_change,
399    "rating-change",
400    futu_core::proto_id::QOT_GET_RATING_CHANGE,
401    qot_get_rating_change
402);
403qot_proto_json_command!(
404    run_industrial_chain_list,
405    "industrial-chain-list",
406    futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_LIST,
407    qot_get_industrial_chain_list
408);
409qot_proto_json_command!(
410    run_industrial_chain_detail,
411    "industrial-chain-detail",
412    futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_DETAIL,
413    qot_get_industrial_chain_detail
414);
415qot_proto_json_command!(
416    run_industrial_chain_by_plate,
417    "industrial-chain-by-plate",
418    futu_core::proto_id::QOT_GET_INDUSTRIAL_CHAIN_BY_PLATE,
419    qot_get_industrial_chain_by_plate
420);
421qot_proto_json_command!(
422    run_industrial_plate_info,
423    "industrial-plate-info",
424    futu_core::proto_id::QOT_GET_INDUSTRIAL_PLATE_INFO,
425    qot_get_industrial_plate_info
426);
427qot_proto_json_command!(
428    run_industrial_plate_stock,
429    "industrial-plate-stock",
430    futu_core::proto_id::QOT_GET_INDUSTRIAL_PLATE_STOCK,
431    qot_get_industrial_plate_stock
432);
433qot_proto_json_command!(
434    run_heat_map_data,
435    "heat-map-data",
436    futu_core::proto_id::QOT_GET_HEAT_MAP_DATA,
437    qot_get_heat_map_data
438);
439qot_proto_json_command!(
440    run_rise_fall_distribution,
441    "rise-fall-distribution",
442    futu_core::proto_id::QOT_GET_RISE_FALL_DISTRIBUTION,
443    qot_get_rise_fall_distribution
444);
445
446pub async fn run_combo_max_trd_qtys(
447    gateway: &str,
448    c2s_json: &str,
449    output: OutputFormat,
450) -> Result<()> {
451    let c2s = parse_combo_max_c2s_json(c2s_json)?;
452    let request = futu_proto::trd_get_combo_max_trd_qtys::Request { c2s };
453    let response: futu_proto::trd_get_combo_max_trd_qtys::Response = send_proto(
454        gateway,
455        "futucli-combo-max-trd-qtys",
456        futu_core::proto_id::TRD_GET_COMBO_MAX_TRD_QTYS,
457        request,
458    )
459    .await?;
460    ensure_success(
461        "combo-max-trd-qtys",
462        response.ret_type,
463        response.ret_msg.as_deref(),
464        response.err_code,
465    )?;
466    print_proto_response(output, &response)
467}
468
469pub async fn run_place_combo_order(
470    gateway: &str,
471    c2s_json: &str,
472    confirm: bool,
473    idempotency_key: Option<String>,
474    output: OutputFormat,
475) -> Result<()> {
476    let mut c2s = parse_place_combo_c2s_json(c2s_json)?;
477
478    ensure_combo_order_confirmed(c2s.header.trd_env, confirm)?;
479
480    let (client, _rx) = connect_gateway(gateway, "futucli-combo-order").await?;
481    c2s.packet_id = match idempotency_key.as_deref() {
482        Some(key) => packet_id_for_idempotency_key(key),
483        None => {
484            let conn_id = client
485                .conn_id()
486                .ok_or_else(|| anyhow!("combo-order missing InitConnect conn_id"))?;
487            next_packet_id(conn_id)
488        }
489    };
490
491    let request = futu_proto::trd_place_combo_order::Request { c2s };
492    let frame = client
493        .request(
494            futu_core::proto_id::TRD_PLACE_COMBO_ORDER,
495            request.encode_to_vec(),
496        )
497        .await?;
498    let response = futu_proto::trd_place_combo_order::Response::decode(frame.body.as_ref())
499        .map_err(|err| anyhow!("decode combo-order response: {err}"))?;
500    ensure_success(
501        "combo-order",
502        response.ret_type,
503        response.ret_msg.as_deref(),
504        response.err_code,
505    )?;
506    print_proto_response(output, &response)
507}
508
509fn next_packet_id(conn_id: u64) -> futu_proto::common::PacketId {
510    let serial_no = PACKET_SERIAL.fetch_add(1, Ordering::Relaxed);
511    futu_proto::common::PacketId { conn_id, serial_no }
512}
513
514fn ensure_combo_order_confirmed(trd_env: i32, confirm: bool) -> Result<()> {
515    if trd_env == 1 && !confirm {
516        bail!("combo-order real env requires --confirm");
517    }
518    Ok(())
519}
520
521fn ensure_write_trd_market(label: &str, trd_market: i32) -> Result<()> {
522    if let Some(market) = futu_trd::market::trd_market_from_i32(trd_market)
523        && let Some(fund_label) = futu_trd::market::canonical_fund_trd_market_label(market)
524    {
525        bail!(
526            "{label} header.trd_market={trd_market} ({fund_label}) is view-only; \
527             use a write-capable main market for combo trade paths"
528        );
529    }
530    if futu_trd::market::trd_market_label(trd_market).is_none() {
531        bail!("{label} unsupported header.trd_market={trd_market}");
532    }
533    Ok(())
534}
535
536// Same design as `futu-trd::order`: deterministic key-derived PacketID lets
537// the daemon replay guard identify an explicit retry without turning every
538// identical-looking combo order into an accidental duplicate.
539fn packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
540    let mut hasher = DefaultHasher::new();
541    key.hash(&mut hasher);
542    futu_proto::common::PacketId {
543        conn_id: hasher.finish(),
544        serial_no: 0,
545    }
546}
547
548#[cfg(test)]
549mod tests;