1use std::sync::Arc;
18
19use prost::Message;
20
21use futu_core::error::FutuError;
22
23use crate::command_runtime::{
24 SubscriptionDispatchContext, execute_qot_subscription_set,
25 execute_qot_subscription_set_with_dispatch,
26};
27use crate::conn::BackendConn;
28use crate::proto_internal::ft_cmd_stock_quote_sub;
29use crate::proto_internal::ft_cmd_stock_quote_sub_data;
30
31mod sub_bits;
32mod ticker;
33
34pub use futu_command_spec::CMD_QOT_PULL_TICKER;
35pub use futu_command_spec::CMD_QOT_PUSH_SUB as CMD_QOT_SUB;
36pub use futu_domain_qot_subscription::{
37 EmptyDesiredMarket, SecurityWithOpts, SubBitOptions, SubscribeSetCommandMode,
38 SubscribeSetPlanError, SubscribeSetSecurityPlan, empty_desired_market_for_sub,
39 ensure_subscribe_set_backend_success, ftapi_market_to_quote_mkt, is_depth_sub_type,
40 plan_empty_subscribe_set_commands, plan_subscribe_set_commands,
41};
42pub use sub_bits::{
43 SubscribeBitInfo, sub_type_to_bit_infos_with_options, sub_type_to_bits,
44 sub_type_to_bits_with_options,
45};
46pub use ticker::{TICKER_PAGE_MAX_ITEMS, pull_latest_ticker, pull_ticker_page};
47#[cfg(test)]
48use ticker::{
49 build_ticker_page_request, common_session_to_nn, nn_quote_session, tick_period_type,
50 ticker_periods_for_nn_session,
51};
52
53pub const CMD_QOT_PUSH: u16 = 6212;
55pub mod sub_type {
57 pub const BASIC: i32 = 1;
58 pub const ORDER_BOOK: i32 = 2;
59 pub const TICKER: i32 = 4;
60 pub const RT: i32 = 5;
61 pub const KL_DAY: i32 = 6;
62 pub const KL_5MIN: i32 = 7;
63 pub const KL_15MIN: i32 = 8;
64 pub const KL_30MIN: i32 = 9;
65 pub const KL_60MIN: i32 = 10;
66 pub const KL_1MIN: i32 = 11;
67 pub const KL_WEEK: i32 = 12;
68 pub const KL_MONTH: i32 = 13;
69 pub const BROKER: i32 = 14;
70 pub const KL_QUARTER: i32 = 15;
71 pub const KL_YEAR: i32 = 16;
72 pub const KL_3MIN: i32 = 17;
73 pub const KL_10MIN: i32 = 18;
74 pub const KL_120MIN: i32 = 19;
75 pub const KL_180MIN: i32 = 20;
76 pub const KL_240MIN: i32 = 21;
77 pub const ORDER_BOOK_ODD: i32 = 22;
78}
79
80pub mod sbit {
82 pub const PRICE: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_PRICE;
83 pub const STOCK_STATE: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_STOCK_STATE;
84 pub const STOCK_TYPE_SPECIFIC: u32 =
85 futu_domain_qot_subscription::SUBSCRIBE_BIT_STOCK_TYPE_SPECIFIC;
86 pub const ORDER_BOOK: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_ORDER_BOOK;
87 pub const DEAL_STATISTICS: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_DEAL_STATISTICS;
88 pub const HK_BROKER_QUEUE: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_HK_BROKER_QUEUE;
89 pub const US_PREMARKET_AFTERHOURS: u32 =
90 futu_domain_qot_subscription::SUBSCRIBE_BIT_US_PREMARKET_AFTERHOURS;
91 pub const US_LV2_ORDER: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_US_LV2_ORDER;
92 pub const TIME_SHARING: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_TIME_SHARING;
93 pub const KLINE_1MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_1MIN;
94 pub const KLINE_3MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_3MIN;
95 pub const KLINE_5MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_5MIN;
96 pub const KLINE_15MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_15MIN;
97 pub const KLINE_30MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_30MIN;
98 pub const KLINE_60MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_60MIN;
99 pub const KLINE_DAY: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_DAY;
100 pub const KLINE_WEEK: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_WEEK;
101 pub const KLINE_MONTH: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_MONTH;
102 pub const KLINE_QUARTER: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_QUARTER;
103 pub const KLINE_YEAR: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_YEAR;
104 pub const KLINE_120MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_120MIN;
108 pub const KLINE_240MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_240MIN;
109 pub const TICK: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_TICK;
110 pub const MEGER_LV2_ORDER: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_MEGER_LV2_ORDER;
111 pub const KLINE_10MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_10MIN;
112 pub const KLINE_180MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_180MIN;
113}
114
115#[derive(Debug)]
119pub enum QotSubError {
120 BackendRejected { result: i32, warning: i32 },
122 DecodeFailed(String),
124 Transport(FutuError),
126 UnsupportedMarket { offending: Vec<i32> },
131 PartialMarketFailure { succeeded: Vec<u8>, failed: Vec<u8> },
136}
137
138impl std::fmt::Display for QotSubError {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 match self {
141 QotSubError::BackendRejected { result, warning } => {
142 write!(
143 f,
144 "backend rejected CMD6211: result={result} warning={warning}"
145 )
146 }
147 QotSubError::DecodeFailed(s) => write!(f, "CMD6211 response decode failed: {s}"),
148 QotSubError::Transport(e) => write!(f, "CMD6211 transport error: {e}"),
149 QotSubError::UnsupportedMarket { offending } => write!(
150 f,
151 "CMD6211 unsupported ftapi_market(s): {offending:?} \
152 (ftapi_market_to_quote_mkt returned 0). Caller must validate \
153 ftapi_market before submit_global_desired_set."
154 ),
155 QotSubError::PartialMarketFailure { succeeded, failed } => write!(
156 f,
157 "CMD6211 partial-market failure: succeeded={succeeded:?} \
158 failed={failed:?}. State is split: succeeded markets are \
159 applied, failed markets need re-submit."
160 ),
161 }
162 }
163}
164
165impl std::error::Error for QotSubError {
166 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
167 match self {
168 QotSubError::Transport(e) => Some(e),
169 _ => None,
170 }
171 }
172}
173
174impl From<FutuError> for QotSubError {
175 fn from(e: FutuError) -> Self {
176 QotSubError::Transport(e)
177 }
178}
179
180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub struct QotSubscriptionWriterAdmission {
182 pub connection_generation: u64,
183 pub serial_no: u32,
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187pub struct DispatchAccepted {
188 pub connection_generation: u64,
189 pub serial_no: u32,
190 pub quote_market_type: u8,
191 pub runtime_dispatch_generation: u64,
192}
193
194#[derive(Clone, Copy, Debug, PartialEq, Eq)]
195pub enum NormalDispatchOutcome {
196 Succeeded,
197 Failed,
198}
199
200#[derive(Clone)]
201pub struct NormalSubscriptionDispatchHooks {
202 pub try_admit: Arc<dyn Fn(QotSubscriptionWriterAdmission) -> bool + Send + Sync>,
203 pub on_accepted:
204 Arc<dyn Fn(QotSubscriptionWriterAdmission, u8, Vec<u8>) -> DispatchAccepted + Send + Sync>,
205 pub on_outcome: Arc<dyn Fn(DispatchAccepted, NormalDispatchOutcome) + Send + Sync>,
206}
207
208pub type SecuritySubscribeInput = SubscribeSetSecurityPlan;
214
215fn plan_error_to_qot_sub_error(err: SubscribeSetPlanError) -> QotSubError {
216 match err {
217 SubscribeSetPlanError::EmptyDesiredSetWithoutMarket => {
218 QotSubError::UnsupportedMarket { offending: vec![0] }
219 }
220 SubscribeSetPlanError::UnsupportedMarket { offending } => {
221 QotSubError::UnsupportedMarket { offending }
222 }
223 }
224}
225
226pub fn build_subscribe_req_with_options(
240 securities: &[SecuritySubscribeInput],
241) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
242 build_subscribe_req_with_options_inner(securities, None)
243}
244
245pub fn build_keep_subscribe_req_with_options(
246 securities: &[SecuritySubscribeInput],
247) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
248 build_subscribe_req_with_options_inner(securities, Some(1))
251}
252
253#[allow(deprecated)]
257fn build_subscribe_req_with_options_inner(
258 securities: &[SecuritySubscribeInput],
259 timer_sub: Option<i32>,
260) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
261 let mut security_list = Vec::new();
262
263 for (stock_id, broker_id, sub_types_with_opts) in securities {
264 let mut bit_info_list = Vec::new();
265 for (st, opts) in sub_types_with_opts {
266 for info in sub_type_to_bit_infos_with_options(*st, opts.clone()) {
267 bit_info_list.push(ft_cmd_stock_quote_sub_data::BitInfo {
268 bit: Some(info.bit),
269 prob: info.prob,
270 prob2: info.prob2,
271 prob2_v2: info.prob2_v2,
272 });
273 }
274 }
275 security_list.push(ft_cmd_stock_quote_sub_data::SecuritySubscribe {
276 security_id: Some(*stock_id),
277 bit_info_list,
278 broker_id: broker_id.map(|nz| nz.get() as i32),
281 });
282 }
283
284 ft_cmd_stock_quote_sub::SubscribeSetReq {
285 security_list,
286 reserved: None,
287 timer_sub,
288 }
289}
290
291pub async fn submit_global_desired_set(
312 backend: &BackendConn,
313 securities: &[SecurityWithOpts],
314 hooks: &NormalSubscriptionDispatchHooks,
315) -> std::result::Result<i32, QotSubError> {
316 submit_global_desired_set_inner(backend, securities, hooks).await
317}
318
319async fn submit_global_desired_set_inner(
320 backend: &BackendConn,
321 securities: &[SecurityWithOpts],
322 hooks: &NormalSubscriptionDispatchHooks,
323) -> std::result::Result<i32, QotSubError> {
324 let plan = plan_subscribe_set_commands(securities, SubscribeSetCommandMode::Normal)
325 .map_err(plan_error_to_qot_sub_error)?;
326
327 let mut max_sub_count = 0i32;
333 let mut succeeded_markets: Vec<u8> = Vec::new();
334 let mut failed_markets: Vec<u8> = Vec::new();
335 let mut first_transport_err: Option<FutuError> = None;
336 for command in &plan.commands {
337 match submit_subscribe_with_market(
338 backend,
339 &command.securities,
340 command.mkt_type,
341 command.is_depth,
342 command.is_unsub_all,
343 hooks,
344 )
345 .await
346 {
347 Ok(count) => {
348 if count > max_sub_count {
349 max_sub_count = count;
350 }
351 if !succeeded_markets.contains(&command.mkt_type) {
352 succeeded_markets.push(command.mkt_type);
353 }
354 }
355 Err(QotSubError::Transport(e)) => {
356 first_transport_err = Some(e);
359 break;
360 }
361 Err(_) => {
362 if !failed_markets.contains(&command.mkt_type) {
363 failed_markets.push(command.mkt_type);
364 }
365 }
366 }
367 }
368 if let Some(e) = first_transport_err {
369 return Err(QotSubError::Transport(e));
370 }
371 if !failed_markets.is_empty() {
372 succeeded_markets.sort_unstable();
373 failed_markets.sort_unstable();
374 tracing::warn!(
375 succeeded = ?succeeded_markets,
376 failed = ?failed_markets,
377 "v1.4.106 audit 0631 F3: submit_global_desired_set partial failure"
378 );
379 return Err(QotSubError::PartialMarketFailure {
380 succeeded: succeeded_markets,
381 failed: failed_markets,
382 });
383 }
384
385 Ok(max_sub_count)
386}
387
388pub async fn submit_empty_desired_set_for_markets(
389 backend: &BackendConn,
390 markets: &[EmptyDesiredMarket],
391 hooks: &NormalSubscriptionDispatchHooks,
392) -> std::result::Result<i32, QotSubError> {
393 let plan = plan_empty_subscribe_set_commands(markets).map_err(plan_error_to_qot_sub_error)?;
394
395 let mut max_sub_count = 0i32;
396 let mut succeeded_markets: Vec<u8> = Vec::new();
397 let mut failed_markets: Vec<u8> = Vec::new();
398 let mut first_transport_err: Option<FutuError> = None;
399
400 for command in &plan.commands {
401 match submit_subscribe_with_market(
402 backend,
403 &command.securities,
404 command.mkt_type,
405 command.is_depth,
406 command.is_unsub_all,
407 hooks,
408 )
409 .await
410 {
411 Ok(count) => {
412 max_sub_count = max_sub_count.max(count);
413 if !succeeded_markets.contains(&command.mkt_type) {
414 succeeded_markets.push(command.mkt_type);
415 }
416 }
417 Err(QotSubError::Transport(e)) => {
418 first_transport_err = Some(e);
419 break;
420 }
421 Err(_) => {
422 if !failed_markets.contains(&command.mkt_type) {
423 failed_markets.push(command.mkt_type);
424 }
425 }
426 }
427 }
428
429 if let Some(e) = first_transport_err {
430 return Err(QotSubError::Transport(e));
431 }
432 if !failed_markets.is_empty() {
433 succeeded_markets.sort_unstable();
434 succeeded_markets.dedup();
435 failed_markets.sort_unstable();
436 failed_markets.dedup();
437 return Err(QotSubError::PartialMarketFailure {
438 succeeded: succeeded_markets,
439 failed: failed_markets,
440 });
441 }
442
443 Ok(max_sub_count)
444}
445
446async fn submit_subscribe_with_market(
451 backend: &BackendConn,
452 secs: &[SecuritySubscribeInput],
453 mkt_type: u8,
454 is_depth: bool,
455 is_unsub_all: bool,
456 hooks: &NormalSubscriptionDispatchHooks,
457) -> std::result::Result<i32, QotSubError> {
458 let req = if is_unsub_all {
459 ft_cmd_stock_quote_sub::SubscribeSetReq {
461 security_list: vec![],
462 reserved: Some(1),
463 timer_sub: None,
464 }
465 } else {
466 build_subscribe_req_with_options(secs)
467 };
468 let body = req.encode_to_vec();
469
470 let mut reserved = [0u8; 10];
471 reserved[0] = mkt_type;
472 let request_bits: Vec<(u64, Vec<(u32, i64)>)> = secs
475 .iter()
476 .map(|(stock_id, _broker_id, sub_types)| {
477 let bits = sub_types
478 .iter()
479 .flat_map(|(sub_type, opts)| sub_type_to_bits_with_options(*sub_type, opts.clone()))
480 .collect();
481 (*stock_id, bits)
482 })
483 .collect();
484
485 tracing::info!(
486 mkt_type,
487 is_depth,
488 is_unsub_all,
489 count = secs.len(),
490 body_len = body.len(),
491 request_bits = ?request_bits,
492 "v1.4.106 audit 1131 F1: sending CMD6211 subscribe (set-state)"
493 );
494
495 let accepted = Arc::new(parking_lot::Mutex::new(None));
496 let context = SubscriptionDispatchContext {
497 quote_market_type: mkt_type,
498 exact_normal_wire: body.clone(),
499 hooks: hooks.clone(),
500 accepted: Arc::clone(&accepted),
501 };
502 let resp =
503 match execute_qot_subscription_set_with_dispatch(backend, body.into(), reserved, context)
504 .await
505 {
506 Ok(resp) => resp,
507 Err(error) => {
508 notify_normal_dispatch_outcome(hooks, &accepted, NormalDispatchOutcome::Failed);
509 return Err(QotSubError::Transport(error));
510 }
511 };
512
513 let parsed: ft_cmd_stock_quote_sub::SubscribeSetRsp = match Message::decode(resp.body.as_ref())
514 {
515 Ok(parsed) => parsed,
516 Err(error) => {
517 notify_normal_dispatch_outcome(hooks, &accepted, NormalDispatchOutcome::Failed);
518 return Err(QotSubError::DecodeFailed(format!("{error}")));
519 }
520 };
521
522 let status = match ensure_subscribe_set_backend_success(
523 parsed.result,
524 parsed.warning_code,
525 parsed.max_sub_count,
526 ) {
527 Ok(status) => status,
528 Err(reject) => {
529 tracing::warn!(
531 mkt_type,
532 is_depth,
533 result = reject.result,
534 warning = reject.warning_code,
535 request_bits = ?request_bits,
536 "v1.4.106 audit 1131 F1: CMD6211 backend rejected"
537 );
538 notify_normal_dispatch_outcome(hooks, &accepted, NormalDispatchOutcome::Failed);
539 return Err(QotSubError::BackendRejected {
540 result: reject.result,
541 warning: reject.warning_code,
542 });
543 }
544 };
545
546 tracing::info!(
547 mkt_type,
548 is_depth,
549 max_sub_count = status.max_sub_count,
550 "v1.4.106 audit 1131 F1: CMD6211 ok"
551 );
552 notify_normal_dispatch_outcome(hooks, &accepted, NormalDispatchOutcome::Succeeded);
553 Ok(status.max_sub_count)
554}
555
556fn notify_normal_dispatch_outcome(
557 hooks: &NormalSubscriptionDispatchHooks,
558 accepted: &parking_lot::Mutex<Option<DispatchAccepted>>,
559 outcome: NormalDispatchOutcome,
560) {
561 let receipt = accepted.lock().take();
562 if let Some(receipt) = receipt {
563 (hooks.on_outcome)(receipt, outcome);
564 }
565}
566
567pub async fn submit_cached_keep_wire(
573 backend: &BackendConn,
574 quote_market_type: u8,
575 wire_with_timer_sub_1: Vec<u8>,
576) -> std::result::Result<i32, QotSubError> {
577 let mut reserved = [0u8; 10];
578 reserved[0] = quote_market_type;
579 let resp = execute_qot_subscription_set(backend, wire_with_timer_sub_1.into(), reserved)
580 .await
581 .map_err(QotSubError::Transport)?;
582 let parsed: ft_cmd_stock_quote_sub::SubscribeSetRsp = Message::decode(resp.body.as_ref())
583 .map_err(|error| QotSubError::DecodeFailed(format!("{error}")))?;
584 let status = ensure_subscribe_set_backend_success(
585 parsed.result,
586 parsed.warning_code,
587 parsed.max_sub_count,
588 )
589 .map_err(|reject| QotSubError::BackendRejected {
590 result: reject.result,
591 warning: reject.warning_code,
592 })?;
593 Ok(status.max_sub_count)
594}
595
596#[cfg(test)]
597mod tests;