1use 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
536fn 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;