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 prost::Message;
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15
16use crate::common::connect_gateway;
17use crate::output::OutputFormat;
18
19static PACKET_SERIAL: AtomicU32 = AtomicU32::new(1);
20
21fn parse_c2s<T>(label: &str, json: &str) -> Result<T>
22where
23    T: DeserializeOwned,
24{
25    let mut value: serde_json::Value =
26        serde_json::from_str(json).map_err(|err| anyhow!("{label} c2s json: {err}"))?;
27    validate_proto_json_contract(label, &mut value)?;
28    serde_json::from_value(value).map_err(|err| anyhow!("{label} c2s json: {err}"))
29}
30
31fn validate_proto_json_contract(label: &str, value: &mut serde_json::Value) -> Result<()> {
32    let Some(spec) = futu_surface_spec::lookup_endpoint_by_cli_subcommand(label) else {
33        return Ok(());
34    };
35    futu_surface_spec::validate_and_normalize(spec, value)
36        .map_err(|err| anyhow!("{label} c2s json: {err}"))
37}
38
39fn parse_combo_max_c2s_json(json: &str) -> Result<futu_proto::trd_get_combo_max_trd_qtys::C2s> {
40    let c2s: futu_proto::trd_get_combo_max_trd_qtys::C2s =
41        parse_combo_c2s_json("combo-max-trd-qtys", json)?;
42    ensure_write_trd_market("combo-max-trd-qtys", c2s.header.trd_market)?;
43    Ok(c2s)
44}
45
46fn parse_place_combo_c2s_json(json: &str) -> Result<futu_proto::trd_place_combo_order::C2s> {
47    let c2s: futu_proto::trd_place_combo_order::C2s = parse_combo_c2s_json("combo-order", json)?;
48    ensure_write_trd_market("combo-order", c2s.header.trd_market)?;
49    Ok(c2s)
50}
51
52fn parse_combo_c2s_json<T>(label: &str, json: &str) -> Result<T>
53where
54    T: DeserializeOwned,
55{
56    parse_c2s_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_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
129fn print_proto_response<T>(format: OutputFormat, response: &T) -> Result<()>
130where
131    T: Serialize,
132{
133    match format {
134        OutputFormat::Table | OutputFormat::Json => {
135            println!("{}", serde_json::to_string_pretty(response)?);
136        }
137        OutputFormat::Jsonl => {
138            println!("{}", serde_json::to_string(response)?);
139        }
140    }
141    Ok(())
142}
143
144fn ensure_success(
145    label: &str,
146    ret_type: i32,
147    ret_msg: Option<&str>,
148    err_code: Option<i32>,
149) -> Result<()> {
150    if ret_type == 0 {
151        return Ok(());
152    }
153    bail!("{label} ret_type={ret_type} msg={ret_msg:?} err_code={err_code:?}")
154}
155
156async fn send_proto<Req, Resp>(
157    gateway: &str,
158    client_id: &str,
159    proto_id: u32,
160    request: Req,
161) -> Result<Resp>
162where
163    Req: Message,
164    Resp: Message + Default,
165{
166    let (client, _rx) = connect_gateway(gateway, client_id).await?;
167    let frame = client.request(proto_id, request.encode_to_vec()).await?;
168    Resp::decode(frame.body.as_ref()).map_err(|err| anyhow!("decode response: {err}"))
169}
170
171macro_rules! qot_proto_json_command {
172    ($fn_name:ident, $label:literal, $proto_id:expr, $module:ident) => {
173        pub async fn $fn_name(gateway: &str, c2s_json: &str, output: OutputFormat) -> Result<()> {
174            let c2s = parse_c2s::<futu_proto::$module::C2s>($label, c2s_json)?;
175            let request = futu_proto::$module::Request { c2s };
176            let response: futu_proto::$module::Response =
177                send_proto(gateway, concat!("futucli-", $label), $proto_id, request).await?;
178            ensure_success(
179                $label,
180                response.ret_type,
181                response.ret_msg.as_deref(),
182                response.err_code,
183            )?;
184            print_proto_response(output, &response)
185        }
186    };
187}
188
189qot_proto_json_command!(
190    run_option_quote,
191    "option-quote",
192    futu_core::proto_id::QOT_GET_OPTION_QUOTE,
193    qot_get_option_quote
194);
195qot_proto_json_command!(
196    run_option_strategy,
197    "option-strategy",
198    futu_core::proto_id::QOT_GET_OPTION_STRATEGY,
199    qot_get_option_strategy
200);
201qot_proto_json_command!(
202    run_option_strategy_analysis,
203    "option-strategy-analysis",
204    futu_core::proto_id::QOT_GET_OPTION_STRATEGY_ANALYSIS,
205    qot_get_option_strategy_analysis
206);
207qot_proto_json_command!(
208    run_option_strategy_spread,
209    "option-strategy-spread",
210    futu_core::proto_id::QOT_GET_OPTION_STRATEGY_SPREAD,
211    qot_get_option_strategy_spread
212);
213
214pub async fn run_combo_max_trd_qtys(
215    gateway: &str,
216    c2s_json: &str,
217    output: OutputFormat,
218) -> Result<()> {
219    let c2s = parse_combo_max_c2s_json(c2s_json)?;
220    let request = futu_proto::trd_get_combo_max_trd_qtys::Request { c2s };
221    let response: futu_proto::trd_get_combo_max_trd_qtys::Response = send_proto(
222        gateway,
223        "futucli-combo-max-trd-qtys",
224        futu_core::proto_id::TRD_GET_COMBO_MAX_TRD_QTYS,
225        request,
226    )
227    .await?;
228    ensure_success(
229        "combo-max-trd-qtys",
230        response.ret_type,
231        response.ret_msg.as_deref(),
232        response.err_code,
233    )?;
234    print_proto_response(output, &response)
235}
236
237pub async fn run_place_combo_order(
238    gateway: &str,
239    c2s_json: &str,
240    confirm: bool,
241    idempotency_key: Option<String>,
242    output: OutputFormat,
243) -> Result<()> {
244    let mut c2s = parse_place_combo_c2s_json(c2s_json)?;
245
246    ensure_combo_order_confirmed(c2s.header.trd_env, confirm)?;
247
248    let (client, _rx) = connect_gateway(gateway, "futucli-combo-order").await?;
249    c2s.packet_id = match idempotency_key.as_deref() {
250        Some(key) => packet_id_for_idempotency_key(key),
251        None => {
252            let conn_id = client
253                .conn_id()
254                .ok_or_else(|| anyhow!("combo-order missing InitConnect conn_id"))?;
255            next_packet_id(conn_id)
256        }
257    };
258
259    let request = futu_proto::trd_place_combo_order::Request { c2s };
260    let frame = client
261        .request(
262            futu_core::proto_id::TRD_PLACE_COMBO_ORDER,
263            request.encode_to_vec(),
264        )
265        .await?;
266    let response = futu_proto::trd_place_combo_order::Response::decode(frame.body.as_ref())
267        .map_err(|err| anyhow!("decode combo-order response: {err}"))?;
268    ensure_success(
269        "combo-order",
270        response.ret_type,
271        response.ret_msg.as_deref(),
272        response.err_code,
273    )?;
274    print_proto_response(output, &response)
275}
276
277fn next_packet_id(conn_id: u64) -> futu_proto::common::PacketId {
278    let serial_no = PACKET_SERIAL.fetch_add(1, Ordering::Relaxed);
279    futu_proto::common::PacketId { conn_id, serial_no }
280}
281
282fn ensure_combo_order_confirmed(trd_env: i32, confirm: bool) -> Result<()> {
283    if trd_env == 1 && !confirm {
284        bail!("combo-order real env requires --confirm");
285    }
286    Ok(())
287}
288
289fn ensure_write_trd_market(label: &str, trd_market: i32) -> Result<()> {
290    if let Some(fund_label) = futu_trd::market::view_only_fund_market_label(trd_market) {
291        bail!(
292            "{label} header.trd_market={trd_market} ({fund_label}) is view-only; \
293             use a write-capable main market for combo trade paths"
294        );
295    }
296    if futu_trd::market::trd_market_label(trd_market).is_none() {
297        bail!("{label} unsupported header.trd_market={trd_market}");
298    }
299    Ok(())
300}
301
302// Same design as `futu-trd::order`: deterministic key-derived PacketID lets
303// the daemon replay guard identify an explicit retry without turning every
304// identical-looking combo order into an accidental duplicate.
305fn packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
306    let mut hasher = DefaultHasher::new();
307    key.hash(&mut hasher);
308    futu_proto::common::PacketId {
309        conn_id: hasher.finish(),
310        serial_no: 0,
311    }
312}
313
314#[cfg(test)]
315mod tests;