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