1use std::collections::{BTreeMap, BTreeSet};
18
19use prost::Message;
20
21use futu_core::error::FutuError;
22
23use crate::conn::BackendConn;
24use crate::proto_internal::ft_cmd_stock_quote_sub;
25use crate::proto_internal::ft_cmd_stock_quote_sub_data;
26
27mod sub_bits;
28mod ticker;
29
30pub use sub_bits::{
31 SubBitOptions, SubscribeBitInfo, sub_type_to_bit_infos_with_options, sub_type_to_bits,
32 sub_type_to_bits_with_options,
33};
34pub use ticker::pull_latest_ticker;
35#[cfg(test)]
36use ticker::{
37 common_session_to_nn, nn_quote_session, tick_period_type, ticker_periods_for_nn_session,
38};
39
40pub const CMD_QOT_SUB: u16 = 6211;
42pub const CMD_QOT_PUSH: u16 = 6212;
44pub const CMD_QOT_PULL_TICKER: u16 = 6128;
49
50pub mod sub_type {
52 pub const BASIC: i32 = 1;
53 pub const ORDER_BOOK: i32 = 2;
54 pub const TICKER: i32 = 4;
55 pub const RT: i32 = 5;
56 pub const KL_DAY: i32 = 6;
57 pub const KL_5MIN: i32 = 7;
58 pub const KL_15MIN: i32 = 8;
59 pub const KL_30MIN: i32 = 9;
60 pub const KL_60MIN: i32 = 10;
61 pub const KL_1MIN: i32 = 11;
62 pub const KL_WEEK: i32 = 12;
63 pub const KL_MONTH: i32 = 13;
64 pub const BROKER: i32 = 14;
65 pub const KL_QUARTER: i32 = 15;
66 pub const KL_YEAR: i32 = 16;
67 pub const KL_3MIN: i32 = 17;
68 pub const KL_10MIN: i32 = 18;
69 pub const KL_120MIN: i32 = 19;
70 pub const KL_180MIN: i32 = 20;
71 pub const KL_240MIN: i32 = 21;
72 pub const ORDER_BOOK_ODD: i32 = 22;
73}
74
75pub mod sbit {
77 pub const PRICE: u32 = 0;
78 pub const STOCK_STATE: u32 = 1;
79 pub const STOCK_TYPE_SPECIFIC: u32 = 2;
80 pub const ORDER_BOOK: u32 = 3;
81 pub const ORDER_BOOK_DETAIL: u32 = 4; pub const DEAL_STATISTICS: u32 = 5;
83 pub const HK_BROKER_QUEUE: u32 = 9;
84 pub const HK_BROKER_DETAIL: u32 = 10; pub const US_PREMARKET_AFTERHOURS: u32 = 13;
86 pub const US_LV2_ORDER: u32 = 17;
87 pub const TIME_SHARING: u32 = 20;
88 pub const KLINE_1MIN: u32 = 21;
89 pub const KLINE_3MIN: u32 = 22;
90 pub const KLINE_5MIN: u32 = 23;
91 pub const KLINE_15MIN: u32 = 24;
92 pub const KLINE_30MIN: u32 = 25;
93 pub const KLINE_60MIN: u32 = 26;
94 pub const KLINE_DAY: u32 = 27;
95 pub const KLINE_WEEK: u32 = 28;
96 pub const KLINE_MONTH: u32 = 29;
97 pub const KLINE_QUARTER: u32 = 30;
98 pub const KLINE_YEAR: u32 = 31;
99 pub const KLINE_120MIN: u32 = 36;
103 pub const KLINE_240MIN: u32 = 37;
104 pub const TICK: u32 = 35;
105 pub const MEGER_LV2_ORDER: u32 = 39;
106 pub const KLINE_10MIN: u32 = 40;
107 pub const KLINE_180MIN: u32 = 41;
108}
109
110#[derive(Debug, Clone)]
122pub struct SecurityWithOpts {
123 pub stock_id: u64,
124 pub ftapi_market: i32,
125 pub sub_types_with_opts: Vec<(i32, SubBitOptions)>,
126 pub broker_id: Option<std::num::NonZeroU32>,
131}
132
133impl SecurityWithOpts {
134 pub fn new(
136 stock_id: u64,
137 ftapi_market: i32,
138 sub_types_with_opts: Vec<(i32, SubBitOptions)>,
139 ) -> Self {
140 Self {
141 stock_id,
142 ftapi_market,
143 sub_types_with_opts,
144 broker_id: None,
147 }
148 }
149
150 pub fn with_broker(
152 stock_id: u64,
153 ftapi_market: i32,
154 sub_types_with_opts: Vec<(i32, SubBitOptions)>,
155 broker_id: u32,
156 ) -> Self {
157 Self {
158 stock_id,
159 ftapi_market,
160 sub_types_with_opts,
161 broker_id: std::num::NonZeroU32::new(broker_id),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
172pub struct EmptyDesiredMarket {
173 pub mkt_type: u8,
174 pub is_depth: bool,
175}
176
177pub fn empty_desired_market_for_sub(
178 ftapi_market: i32,
179 sub_type: i32,
180) -> Option<EmptyDesiredMarket> {
181 let mkt_type = ftapi_market_to_quote_mkt(ftapi_market);
182 if mkt_type == 0 {
183 return None;
184 }
185 Some(EmptyDesiredMarket {
186 mkt_type,
187 is_depth: is_depth_sub_type(sub_type),
188 })
189}
190
191#[derive(Debug)]
195pub enum QotSubError {
196 BackendRejected { result: i32, warning: i32 },
198 DecodeFailed(String),
200 Transport(FutuError),
202 UnsupportedMarket { offending: Vec<i32> },
207 PartialMarketFailure { succeeded: Vec<u8>, failed: Vec<u8> },
212}
213
214impl std::fmt::Display for QotSubError {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 match self {
217 QotSubError::BackendRejected { result, warning } => {
218 write!(
219 f,
220 "backend rejected CMD6211: result={result} warning={warning}"
221 )
222 }
223 QotSubError::DecodeFailed(s) => write!(f, "CMD6211 response decode failed: {s}"),
224 QotSubError::Transport(e) => write!(f, "CMD6211 transport error: {e}"),
225 QotSubError::UnsupportedMarket { offending } => write!(
226 f,
227 "CMD6211 unsupported ftapi_market(s): {offending:?} \
228 (ftapi_market_to_quote_mkt returned 0). Caller must validate \
229 ftapi_market before submit_global_desired_set."
230 ),
231 QotSubError::PartialMarketFailure { succeeded, failed } => write!(
232 f,
233 "CMD6211 partial-market failure: succeeded={succeeded:?} \
234 failed={failed:?}. State is split: succeeded markets are \
235 applied, failed markets need re-submit."
236 ),
237 }
238 }
239}
240
241impl std::error::Error for QotSubError {
242 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
243 match self {
244 QotSubError::Transport(e) => Some(e),
245 _ => None,
246 }
247 }
248}
249
250impl From<FutuError> for QotSubError {
251 fn from(e: FutuError) -> Self {
252 QotSubError::Transport(e)
253 }
254}
255
256pub type SecuritySubscribeInput = (u64, Option<std::num::NonZeroU32>, Vec<(i32, SubBitOptions)>);
262
263pub fn build_subscribe_req_with_options(
277 securities: &[SecuritySubscribeInput],
278) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
279 build_subscribe_req_with_options_inner(securities, None)
280}
281
282pub fn build_keep_subscribe_req_with_options(
283 securities: &[SecuritySubscribeInput],
284) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
285 build_subscribe_req_with_options_inner(securities, Some(1))
288}
289
290fn build_subscribe_req_with_options_inner(
291 securities: &[SecuritySubscribeInput],
292 timer_sub: Option<i32>,
293) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
294 let mut security_list = Vec::new();
295
296 for (stock_id, broker_id, sub_types_with_opts) in securities {
297 let mut bit_info_list = Vec::new();
298 for (st, opts) in sub_types_with_opts {
299 for info in sub_type_to_bit_infos_with_options(*st, opts.clone()) {
300 bit_info_list.push(ft_cmd_stock_quote_sub_data::BitInfo {
301 bit: Some(info.bit),
302 prob: Some(info.prob),
303 prob2: info.prob2,
304 prob2_v2: info.prob2_v2,
305 });
306 }
307 }
308 security_list.push(ft_cmd_stock_quote_sub_data::SecuritySubscribe {
309 security_id: Some(*stock_id),
310 bit_info_list,
311 broker_id: broker_id.map(|nz| nz.get() as i32),
314 });
315 }
316
317 ft_cmd_stock_quote_sub::SubscribeSetReq {
318 security_list,
319 reserved: None,
320 timer_sub,
321 }
322}
323
324pub fn ftapi_market_to_quote_mkt(market: i32) -> u8 {
328 match market {
329 1 => 1, 11 => 2, 21 => 3, 22 => 4, 5 => 5, 6 => 6, 9 => 9, 13 => 13, 31 => 15, 15 => 7, 14 => 8, 16 => 16, 41 => 25, 61 => 27, 23 => 10, 91 => 17, _ => 0,
356 }
357}
358
359fn is_depth_sub_type(sub_type: i32) -> bool {
366 matches!(
367 sub_type,
368 sub_type::ORDER_BOOK | sub_type::ORDER_BOOK_ODD | sub_type::BROKER
369 )
370}
371
372pub async fn submit_global_desired_set(
393 backend: &BackendConn,
394 securities: &[SecurityWithOpts],
395) -> std::result::Result<i32, QotSubError> {
396 submit_global_desired_set_inner(backend, securities, false).await
397}
398
399pub async fn submit_keep_subscribe_desired_set(
404 backend: &BackendConn,
405 securities: &[SecurityWithOpts],
406) -> std::result::Result<i32, QotSubError> {
407 submit_global_desired_set_inner(backend, securities, true).await
408}
409
410async fn submit_global_desired_set_inner(
411 backend: &BackendConn,
412 securities: &[SecurityWithOpts],
413 is_timer_sub: bool,
414) -> std::result::Result<i32, QotSubError> {
415 if securities.is_empty() {
416 return Err(QotSubError::UnsupportedMarket { offending: vec![0] });
420 }
421
422 let offending: Vec<i32> = securities
429 .iter()
430 .filter_map(|sec| {
431 if ftapi_market_to_quote_mkt(sec.ftapi_market) == 0 {
432 Some(sec.ftapi_market)
433 } else {
434 None
435 }
436 })
437 .collect();
438 if !offending.is_empty() {
439 let mut dedup: Vec<i32> = offending;
440 dedup.sort_unstable();
441 dedup.dedup();
442 tracing::warn!(
443 offending = ?dedup,
444 "v1.4.106 audit 0631 F3: submit_global_desired_set rejected — unsupported ftapi_market(s)"
445 );
446 return Err(QotSubError::UnsupportedMarket { offending: dedup });
447 }
448
449 type MktGroup = Vec<SecuritySubscribeInput>;
460 let mut by_market: BTreeMap<u8, MktGroup> = BTreeMap::new();
461 for sec in securities {
462 let mkt = ftapi_market_to_quote_mkt(sec.ftapi_market);
463 debug_assert!(mkt != 0, "F3 validate 应已拒未知 market");
465 by_market.entry(mkt).or_default().push((
466 sec.stock_id,
467 sec.broker_id,
468 sec.sub_types_with_opts.clone(),
469 ));
470 }
471
472 let mut max_sub_count = 0i32;
478 let mut succeeded_markets: Vec<u8> = Vec::new();
479 let mut failed_markets: Vec<u8> = Vec::new();
480 let mut first_transport_err: Option<FutuError> = None;
481 for (mkt_type, secs) in &by_market {
482 let contains_depth_type = secs
483 .iter()
484 .any(|(_, _, sub_types)| sub_types.iter().any(|(st, _)| is_depth_sub_type(*st)));
485 match submit_subscribe_with_market(
486 backend,
487 secs,
488 *mkt_type,
489 contains_depth_type,
490 false,
491 is_timer_sub,
492 )
493 .await
494 {
495 Ok(count) => {
496 if count > max_sub_count {
497 max_sub_count = count;
498 }
499 if !succeeded_markets.contains(mkt_type) {
500 succeeded_markets.push(*mkt_type);
501 }
502 }
503 Err(QotSubError::Transport(e)) => {
504 first_transport_err = Some(e);
507 break;
508 }
509 Err(_) => {
510 if !failed_markets.contains(mkt_type) {
511 failed_markets.push(*mkt_type);
512 }
513 }
514 }
515 }
516 if let Some(e) = first_transport_err {
517 return Err(QotSubError::Transport(e));
518 }
519 if !failed_markets.is_empty() {
520 succeeded_markets.sort_unstable();
521 failed_markets.sort_unstable();
522 tracing::warn!(
523 succeeded = ?succeeded_markets,
524 failed = ?failed_markets,
525 "v1.4.106 audit 0631 F3: submit_global_desired_set partial failure"
526 );
527 return Err(QotSubError::PartialMarketFailure {
528 succeeded: succeeded_markets,
529 failed: failed_markets,
530 });
531 }
532
533 Ok(max_sub_count)
534}
535
536pub async fn submit_empty_desired_set_for_markets(
537 backend: &BackendConn,
538 markets: &[EmptyDesiredMarket],
539) -> std::result::Result<i32, QotSubError> {
540 if markets.is_empty() {
541 return Err(QotSubError::UnsupportedMarket { offending: vec![0] });
542 }
543
544 let groups: Vec<EmptyDesiredMarket> = BTreeSet::from_iter(markets.iter().copied())
545 .into_iter()
546 .collect();
547 if groups.iter().any(|g| g.mkt_type == 0) {
548 return Err(QotSubError::UnsupportedMarket { offending: vec![0] });
549 }
550
551 let mut max_sub_count = 0i32;
552 let mut succeeded_markets: Vec<u8> = Vec::new();
553 let mut failed_markets: Vec<u8> = Vec::new();
554 let mut first_transport_err: Option<FutuError> = None;
555
556 for group in groups {
557 match submit_subscribe_with_market(
558 backend,
559 &[],
560 group.mkt_type,
561 group.is_depth,
562 true,
563 false,
564 )
565 .await
566 {
567 Ok(count) => {
568 max_sub_count = max_sub_count.max(count);
569 if !succeeded_markets.contains(&group.mkt_type) {
570 succeeded_markets.push(group.mkt_type);
571 }
572 }
573 Err(QotSubError::Transport(e)) => {
574 first_transport_err = Some(e);
575 break;
576 }
577 Err(_) => {
578 if !failed_markets.contains(&group.mkt_type) {
579 failed_markets.push(group.mkt_type);
580 }
581 }
582 }
583 }
584
585 if let Some(e) = first_transport_err {
586 return Err(QotSubError::Transport(e));
587 }
588 if !failed_markets.is_empty() {
589 succeeded_markets.sort_unstable();
590 succeeded_markets.dedup();
591 failed_markets.sort_unstable();
592 failed_markets.dedup();
593 return Err(QotSubError::PartialMarketFailure {
594 succeeded: succeeded_markets,
595 failed: failed_markets,
596 });
597 }
598
599 Ok(max_sub_count)
600}
601
602async fn submit_subscribe_with_market(
607 backend: &BackendConn,
608 secs: &[SecuritySubscribeInput],
609 mkt_type: u8,
610 is_depth: bool,
611 is_unsub_all: bool,
612 is_timer_sub: bool,
613) -> std::result::Result<i32, QotSubError> {
614 let req = if is_unsub_all {
615 ft_cmd_stock_quote_sub::SubscribeSetReq {
617 security_list: vec![],
618 reserved: Some(1),
619 timer_sub: None,
620 }
621 } else if is_timer_sub {
622 build_keep_subscribe_req_with_options(secs)
623 } else {
624 build_subscribe_req_with_options(secs)
625 };
626 let body = req.encode_to_vec();
627
628 let mut reserved = [0u8; 10];
629 reserved[0] = mkt_type;
630 let request_bits: Vec<(u64, Vec<(u32, i64)>)> = secs
633 .iter()
634 .map(|(stock_id, _broker_id, sub_types)| {
635 let bits = sub_types
636 .iter()
637 .flat_map(|(sub_type, opts)| sub_type_to_bits_with_options(*sub_type, opts.clone()))
638 .collect();
639 (*stock_id, bits)
640 })
641 .collect();
642
643 tracing::info!(
644 mkt_type,
645 is_depth,
646 is_unsub_all,
647 is_timer_sub,
648 count = secs.len(),
649 body_len = body.len(),
650 request_bits = ?request_bits,
651 "v1.4.106 audit 1131 F1: sending CMD6211 subscribe (set-state)"
652 );
653
654 let resp = backend
655 .request_with_reserved(CMD_QOT_SUB, body, reserved)
656 .await
657 .map_err(QotSubError::Transport)?;
658
659 let parsed: ft_cmd_stock_quote_sub::SubscribeSetRsp = Message::decode(resp.body.as_ref())
660 .map_err(|e| QotSubError::DecodeFailed(format!("{e}")))?;
661
662 let result = parsed.result.unwrap_or(-1);
663 let warning = parsed.warning_code.unwrap_or(0);
664 let max_sub_count = parsed.max_sub_count.unwrap_or(0);
665
666 if result != 0 {
667 tracing::warn!(
669 mkt_type,
670 is_depth,
671 is_timer_sub,
672 result,
673 warning,
674 request_bits = ?request_bits,
675 "v1.4.106 audit 1131 F1: CMD6211 backend rejected"
676 );
677 return Err(QotSubError::BackendRejected { result, warning });
678 }
679
680 tracing::info!(
681 mkt_type,
682 is_depth,
683 is_timer_sub,
684 max_sub_count,
685 "v1.4.106 audit 1131 F1: CMD6211 ok"
686 );
687 Ok(max_sub_count)
688}
689
690#[cfg(test)]
691mod tests;