futu_gateway/handlers/
qot.rs

1// 行情业务处理器 — 全量实现(无占位)
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6
7use futu_cache::login_cache::LoginCache;
8use futu_cache::qot_cache::{self, QotCache};
9use futu_cache::static_data::StaticDataCache;
10use futu_core::proto_id;
11use futu_server::conn::IncomingRequest;
12use futu_server::router::{RequestHandler, RequestRouter};
13use futu_server::subscription::SubscriptionManager;
14
15use crate::bridge::GatewayBridge;
16
17#[allow(dead_code)]
18fn hex_preview(data: &[u8], max: usize) -> String {
19    let n = data.len().min(max);
20    data[..n]
21        .iter()
22        .map(|b| format!("{b:02x}"))
23        .collect::<Vec<_>>()
24        .join(" ")
25}
26
27/// 注册所有行情处理器
28pub fn register_handlers(router: &Arc<RequestRouter>, bridge: &GatewayBridge) {
29    let cache = Arc::clone(&bridge.qot_cache);
30    let subs = Arc::clone(&bridge.subscriptions);
31    let static_cache = Arc::clone(&bridge.static_cache);
32
33    // GetGlobalState
34    router.register(
35        proto_id::GET_GLOBAL_STATE,
36        Arc::new(GetGlobalStateHandler {
37            login_cache: Arc::clone(&bridge.login_cache),
38            backend: bridge.backend.clone(),
39        }),
40    );
41
42    // 连接管理
43    router.register(
44        proto_id::QOT_SUB,
45        Arc::new(SubHandler {
46            subscriptions: subs.clone(),
47            backend: bridge.backend.clone(),
48            static_cache: static_cache.clone(),
49        }),
50    );
51    router.register(
52        proto_id::QOT_REG_QOT_PUSH,
53        Arc::new(RegQotPushHandler {
54            subscriptions: subs.clone(),
55        }),
56    );
57    router.register(
58        proto_id::QOT_GET_SUB_INFO,
59        Arc::new(GetSubInfoHandler {
60            subscriptions: subs.clone(),
61        }),
62    );
63
64    // 实时行情
65    router.register(
66        proto_id::QOT_GET_BASIC_QOT,
67        Arc::new(GetBasicQotHandler {
68            cache: cache.clone(),
69            static_cache: static_cache.clone(),
70        }),
71    );
72    router.register(
73        proto_id::QOT_GET_KL,
74        Arc::new(GetKLHandler {
75            cache: cache.clone(),
76        }),
77    );
78    router.register(
79        proto_id::QOT_GET_ORDER_BOOK,
80        Arc::new(GetOrderBookHandler {
81            cache: cache.clone(),
82        }),
83    );
84    router.register(
85        proto_id::QOT_GET_BROKER,
86        Arc::new(GetBrokerHandler {
87            cache: cache.clone(),
88            static_cache: static_cache.clone(),
89        }),
90    );
91    router.register(
92        proto_id::QOT_GET_TICKER,
93        Arc::new(GetTickerHandler {
94            cache: cache.clone(),
95        }),
96    );
97    router.register(
98        proto_id::QOT_GET_RT,
99        Arc::new(GetRTHandler {
100            cache: cache.clone(),
101        }),
102    );
103    router.register(
104        proto_id::QOT_GET_BROKER,
105        Arc::new(GetBrokerHandler {
106            cache: cache.clone(),
107            static_cache: static_cache.clone(),
108        }),
109    );
110    // NOTE: QOT_GET_ORDER_DETAIL proto removed; handler not registered.
111
112    // 静态/快照
113    router.register(
114        proto_id::QOT_GET_STATIC_INFO,
115        Arc::new(GetStaticInfoHandler {
116            cache: static_cache.clone(),
117        }),
118    );
119    router.register(
120        proto_id::QOT_GET_SECURITY_SNAPSHOT,
121        Arc::new(GetSecuritySnapshotHandler {
122            backend: bridge.backend.clone(),
123            static_cache: static_cache.clone(),
124        }),
125    );
126
127    // 历史/市场数据
128    router.register(
129        proto_id::QOT_GET_HISTORY_KL,
130        Arc::new(GetHistoryKLHandler {
131            backend: bridge.backend.clone(),
132            static_cache: static_cache.clone(),
133        }),
134    );
135    router.register(
136        proto_id::QOT_REQUEST_HISTORY_KL,
137        Arc::new(RequestHistoryKLHandler {
138            backend: bridge.backend.clone(),
139            static_cache: static_cache.clone(),
140            kl_quota_counter: bridge.kl_quota_counter.clone(),
141        }),
142    );
143    router.register(
144        proto_id::QOT_GET_HISTORY_KL_POINTS,
145        Arc::new(GetHistoryKLPointsHandler),
146    );
147    router.register(
148        proto_id::QOT_GET_TRADE_DATE,
149        Arc::new(GetTradeDateHandler {
150            backend: bridge.backend.clone(),
151        }),
152    );
153    router.register(
154        proto_id::QOT_GET_SUSPEND,
155        Arc::new(GetSuspendHandler {
156            suspend_cache: bridge.suspend_cache.clone(),
157            static_cache: static_cache.clone(),
158        }),
159    );
160    router.register(
161        proto_id::QOT_GET_REHAB,
162        Arc::new(GetRehabHandler {
163            backend: bridge.backend.clone(),
164            static_cache: static_cache.clone(),
165        }),
166    );
167    router.register(
168        proto_id::QOT_GET_PLATE_SET,
169        Arc::new(GetPlateSetHandler {
170            backend: bridge.backend.clone(),
171            static_cache: static_cache.clone(),
172        }),
173    );
174    router.register(
175        proto_id::QOT_GET_PLATE_SECURITY,
176        Arc::new(GetPlateSecurityHandler {
177            backend: bridge.backend.clone(),
178            static_cache: static_cache.clone(),
179        }),
180    );
181    router.register(
182        proto_id::QOT_GET_OWNER_PLATE,
183        Arc::new(GetOwnerPlateHandler {
184            backend: bridge.backend.clone(),
185            static_cache: static_cache.clone(),
186        }),
187    );
188    router.register(
189        proto_id::QOT_GET_REFERENCE,
190        Arc::new(GetReferenceHandler {
191            backend: bridge.backend.clone(),
192            static_cache: static_cache.clone(),
193        }),
194    );
195    router.register(
196        proto_id::QOT_GET_OPTION_CHAIN,
197        Arc::new(GetOptionChainHandler {
198            backend: bridge.backend.clone(),
199            static_cache: static_cache.clone(),
200        }),
201    );
202    router.register(
203        proto_id::QOT_GET_HOLDING_CHANGE_LIST,
204        Arc::new(GetHoldingChangeListHandler),
205    );
206
207    // ===== 新版 API handler 注册 =====
208    router.register(
209        proto_id::QOT_GET_WARRANT,
210        Arc::new(GetWarrantHandler {
211            backend: bridge.backend.clone(),
212            static_cache: static_cache.clone(),
213        }),
214    );
215    router.register(
216        proto_id::QOT_GET_CAPITAL_FLOW,
217        Arc::new(GetCapitalFlowHandler {
218            backend: bridge.backend.clone(),
219            static_cache: static_cache.clone(),
220        }),
221    );
222    router.register(
223        proto_id::QOT_GET_CAPITAL_DISTRIBUTION,
224        Arc::new(GetCapitalDistributionHandler {
225            backend: bridge.backend.clone(),
226            static_cache: static_cache.clone(),
227        }),
228    );
229    router.register(
230        proto_id::QOT_GET_USER_SECURITY,
231        Arc::new(GetUserSecurityHandler {
232            backend: bridge.backend.clone(),
233            static_cache: static_cache.clone(),
234            app_lang: bridge.app_lang,
235        }),
236    );
237    router.register(
238        proto_id::QOT_MODIFY_USER_SECURITY,
239        Arc::new(ModifyUserSecurityHandler {
240            backend: bridge.backend.clone(),
241            static_cache: static_cache.clone(),
242        }),
243    );
244    router.register(
245        proto_id::QOT_STOCK_FILTER,
246        Arc::new(StockFilterHandler {
247            backend: bridge.backend.clone(),
248            static_cache: static_cache.clone(),
249        }),
250    );
251    router.register(
252        proto_id::QOT_GET_CODE_CHANGE,
253        Arc::new(GetCodeChangeHandler {
254            code_change_cache: bridge.code_change_cache.clone(),
255        }),
256    );
257    router.register(
258        proto_id::QOT_GET_IPO_LIST,
259        Arc::new(GetIpoListHandler {
260            backend: bridge.backend.clone(),
261            static_cache: static_cache.clone(),
262        }),
263    );
264    router.register(
265        proto_id::QOT_GET_FUTURE_INFO,
266        Arc::new(GetFutureInfoHandler {
267            backend: bridge.backend.clone(),
268            static_cache: static_cache.clone(),
269        }),
270    );
271    router.register(
272        proto_id::QOT_REQUEST_TRADE_DATE,
273        Arc::new(RequestTradeDateHandler {
274            backend: bridge.backend.clone(),
275        }),
276    );
277    router.register(
278        proto_id::QOT_SET_PRICE_REMINDER,
279        Arc::new(SetPriceReminderHandler {
280            backend: bridge.backend.clone(),
281            static_cache: static_cache.clone(),
282        }),
283    );
284    router.register(
285        proto_id::QOT_GET_PRICE_REMINDER,
286        Arc::new(GetPriceReminderHandler {
287            backend: bridge.backend.clone(),
288            static_cache: static_cache.clone(),
289        }),
290    );
291    router.register(
292        proto_id::QOT_GET_USER_SECURITY_GROUP,
293        Arc::new(GetUserSecurityGroupHandler {
294            backend: bridge.backend.clone(),
295            app_lang: bridge.app_lang,
296        }),
297    );
298    router.register(
299        proto_id::QOT_GET_MARKET_STATE,
300        Arc::new(GetMarketStateHandler {
301            backend: bridge.backend.clone(),
302            static_cache: static_cache.clone(),
303        }),
304    );
305    router.register(
306        proto_id::QOT_GET_OPTION_EXPIRATION_DATE,
307        Arc::new(GetOptionExpirationDateHandler {
308            backend: bridge.backend.clone(),
309            static_cache: static_cache.clone(),
310        }),
311    );
312    router.register(
313        proto_id::QOT_REQUEST_HISTORY_KL_QUOTA,
314        Arc::new(RequestHistoryKLQuotaHandler),
315    );
316    router.register(
317        proto_id::QOT_REQUEST_REHAB,
318        Arc::new(RequestRehabHandler {
319            backend: bridge.backend.clone(),
320            static_cache: static_cache.clone(),
321        }),
322    );
323
324    // GetUsedQuota (1010)
325    router.register(
326        proto_id::GET_USED_QUOTA,
327        Arc::new(GetUsedQuotaHandler {
328            subscriptions: subs.clone(),
329            kl_quota_counter: bridge.kl_quota_counter.clone(),
330        }),
331    );
332
333    tracing::debug!("quote handlers registered (all implemented)");
334}
335
336// ===== Sub =====
337struct SubHandler {
338    subscriptions: Arc<SubscriptionManager>,
339    backend: crate::bridge::SharedBackend,
340    static_cache: Arc<StaticDataCache>,
341}
342
343#[async_trait]
344impl RequestHandler for SubHandler {
345    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
346        let req: futu_proto::qot_sub::Request =
347            prost::Message::decode(request.body.as_ref()).ok()?;
348        let c2s = &req.c2s;
349
350        // 收集需要向后端订阅的 (stock_id, ftapi_market, sub_types)
351        let mut backend_subs: Vec<(u64, i32, Vec<i32>)> = Vec::new();
352
353        for sec in &c2s.security_list {
354            let sec_key = format!("{}_{}", sec.market, sec.code);
355            for &sub_type in &c2s.sub_type_list {
356                if c2s.is_sub_or_un_sub {
357                    self.subscriptions
358                        .subscribe_qot(conn_id, &sec_key, sub_type);
359                } else {
360                    self.subscriptions
361                        .unsubscribe_qot(conn_id, &sec_key, sub_type);
362                }
363            }
364
365            // 查找 stock_id 用于后端订阅
366            if c2s.is_sub_or_un_sub {
367                if let Some(info) = self.static_cache.get_security_info(&sec_key) {
368                    if info.stock_id > 0 {
369                        backend_subs.push((info.stock_id, sec.market, c2s.sub_type_list.clone()));
370                    }
371                }
372            }
373        }
374
375        if c2s.is_reg_or_un_reg_push.unwrap_or(false) {
376            self.subscriptions.subscribe_notify(conn_id);
377        }
378
379        // 转发到后端 CMD 6211
380        if let Some(backend) = super::load_backend(&self.backend) {
381            if !backend_subs.is_empty() {
382                if let Err(e) =
383                    futu_backend::quote_sub::subscribe_to_backend(&backend, &backend_subs).await
384                {
385                    tracing::warn!(error = %e, "backend subscribe failed");
386                }
387            }
388        }
389
390        let resp = futu_proto::qot_sub::Response {
391            ret_type: 0,
392            ret_msg: None,
393            err_code: None,
394            s2c: Some(futu_proto::qot_sub::S2c {}),
395        };
396        Some(prost::Message::encode_to_vec(&resp))
397    }
398}
399
400// ===== RegQotPush =====
401struct RegQotPushHandler {
402    subscriptions: Arc<SubscriptionManager>,
403}
404
405#[async_trait]
406impl RequestHandler for RegQotPushHandler {
407    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
408        let req: futu_proto::qot_reg_qot_push::Request =
409            prost::Message::decode(request.body.as_ref()).ok()?;
410        let c2s = &req.c2s;
411        for sec in &c2s.security_list {
412            let sec_key = format!("{}_{}", sec.market, sec.code);
413            for &sub_type in &c2s.sub_type_list {
414                if c2s.is_reg_or_un_reg {
415                    self.subscriptions
416                        .subscribe_qot(conn_id, &sec_key, sub_type);
417                } else {
418                    self.subscriptions
419                        .unsubscribe_qot(conn_id, &sec_key, sub_type);
420                }
421            }
422        }
423        let resp = futu_proto::qot_reg_qot_push::Response {
424            ret_type: 0,
425            ret_msg: None,
426            err_code: None,
427            s2c: Some(futu_proto::qot_reg_qot_push::S2c {}),
428        };
429        Some(prost::Message::encode_to_vec(&resp))
430    }
431}
432
433// ===== GetSubInfo =====
434struct GetSubInfoHandler {
435    subscriptions: Arc<SubscriptionManager>,
436}
437
438#[async_trait]
439impl RequestHandler for GetSubInfoHandler {
440    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
441        let req: futu_proto::qot_get_sub_info::Request =
442            prost::Message::decode(request.body.as_ref()).ok()?;
443        let is_req_all = req.c2s.is_req_all_conn.unwrap_or(false);
444
445        let used = self.subscriptions.get_total_used_quota();
446        let remain = futu_server::subscription::TOTAL_QUOTA.saturating_sub(used);
447
448        // 构建 ConnSubInfo 列表
449        let conn_ids: Vec<u64> = if is_req_all {
450            self.subscriptions
451                .get_all_qot_conn_ids()
452                .into_iter()
453                .collect()
454        } else {
455            vec![conn_id]
456        };
457
458        let conn_sub_info_list: Vec<futu_proto::qot_common::ConnSubInfo> = conn_ids
459            .iter()
460            .map(|&cid| {
461                let subs = self.subscriptions.get_conn_qot_subs(cid);
462                let sub_info_list: Vec<futu_proto::qot_common::SubInfo> = subs
463                    .iter()
464                    .map(|(&sub_type, sec_keys)| {
465                        let security_list: Vec<futu_proto::qot_common::Security> = sec_keys
466                            .iter()
467                            .filter_map(|sk| {
468                                let parts: Vec<&str> = sk.splitn(2, '_').collect();
469                                if parts.len() == 2 {
470                                    Some(futu_proto::qot_common::Security {
471                                        market: parts[0].parse().unwrap_or(0),
472                                        code: parts[1].to_string(),
473                                    })
474                                } else {
475                                    None
476                                }
477                            })
478                            .collect();
479                        futu_proto::qot_common::SubInfo {
480                            sub_type,
481                            security_list,
482                        }
483                    })
484                    .collect();
485                let conn_used = self.subscriptions.get_conn_used_quota(cid);
486                futu_proto::qot_common::ConnSubInfo {
487                    sub_info_list,
488                    used_quota: conn_used as i32,
489                    is_own_conn_data: cid == conn_id,
490                }
491            })
492            .collect();
493
494        let resp = futu_proto::qot_get_sub_info::Response {
495            ret_type: 0,
496            ret_msg: None,
497            err_code: None,
498            s2c: Some(futu_proto::qot_get_sub_info::S2c {
499                conn_sub_info_list,
500                total_used_quota: used as i32,
501                remain_quota: remain as i32,
502            }),
503        };
504        Some(prost::Message::encode_to_vec(&resp))
505    }
506}
507
508// ===== GetBasicQot =====
509struct GetBasicQotHandler {
510    cache: Arc<QotCache>,
511    static_cache: Arc<StaticDataCache>,
512}
513
514#[async_trait]
515impl RequestHandler for GetBasicQotHandler {
516    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
517        let req: futu_proto::qot_get_basic_qot::Request =
518            prost::Message::decode(request.body.as_ref()).ok()?;
519        let mut basic_qot_list = Vec::new();
520        for sec in &req.c2s.security_list {
521            let key = qot_cache::make_key(sec.market, &sec.code);
522            if let Some(c) = self.cache.get_basic_qot(&key) {
523                // 从 static_cache 补全 name 和 listing_date
524                let static_info = self.static_cache.get_security_info(&key);
525                let name = static_info.as_ref().map(|s| s.name.clone());
526                let list_time = static_info
527                    .as_ref()
528                    .map(|s| s.list_time.clone())
529                    .unwrap_or_default();
530
531                basic_qot_list.push(futu_proto::qot_common::BasicQot {
532                    security: sec.clone(),
533                    name,
534                    is_suspended: c.is_suspended,
535                    list_time,
536                    price_spread: 0.0,
537                    update_time: c.update_time.clone(),
538                    high_price: c.high_price,
539                    open_price: c.open_price,
540                    low_price: c.low_price,
541                    cur_price: c.cur_price,
542                    last_close_price: c.last_close_price,
543                    volume: c.volume,
544                    turnover: c.turnover,
545                    turnover_rate: c.turnover_rate,
546                    amplitude: c.amplitude,
547                    dark_status: None,
548                    option_ex_data: None,
549                    list_timestamp: None,
550                    update_timestamp: Some(c.update_timestamp),
551                    pre_market: None,
552                    after_market: None,
553                    sec_status: None,
554                    future_ex_data: None,
555                    warrant_ex_data: None,
556                    overnight: None,
557                });
558            }
559        }
560        let resp = futu_proto::qot_get_basic_qot::Response {
561            ret_type: 0,
562            ret_msg: None,
563            err_code: None,
564            s2c: Some(futu_proto::qot_get_basic_qot::S2c { basic_qot_list }),
565        };
566        Some(prost::Message::encode_to_vec(&resp))
567    }
568}
569
570// ===== GetKL =====
571struct GetKLHandler {
572    cache: Arc<QotCache>,
573}
574
575#[async_trait]
576impl RequestHandler for GetKLHandler {
577    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
578        let req: futu_proto::qot_get_kl::Request =
579            prost::Message::decode(request.body.as_ref()).ok()?;
580        let c2s = &req.c2s;
581        let key = qot_cache::make_key(c2s.security.market, &c2s.security.code);
582        let kl_list = self
583            .cache
584            .get_klines(&key, c2s.kl_type)
585            .unwrap_or_default()
586            .into_iter()
587            .take(c2s.req_num as usize)
588            .map(|kl| futu_proto::qot_common::KLine {
589                time: kl.time,
590                is_blank: false,
591                high_price: Some(kl.high_price),
592                open_price: Some(kl.open_price),
593                low_price: Some(kl.low_price),
594                close_price: Some(kl.close_price),
595                last_close_price: None,
596                volume: Some(kl.volume),
597                turnover: Some(kl.turnover),
598                turnover_rate: None,
599                pe: None,
600                change_rate: None,
601                timestamp: None,
602            })
603            .collect();
604        let resp = futu_proto::qot_get_kl::Response {
605            ret_type: 0,
606            ret_msg: None,
607            err_code: None,
608            s2c: Some(futu_proto::qot_get_kl::S2c {
609                security: c2s.security.clone(),
610                name: None,
611                kl_list,
612            }),
613        };
614        Some(prost::Message::encode_to_vec(&resp))
615    }
616}
617
618// ===== GetOrderBook =====
619struct GetOrderBookHandler {
620    cache: Arc<QotCache>,
621}
622
623#[async_trait]
624impl RequestHandler for GetOrderBookHandler {
625    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
626        let req: futu_proto::qot_get_order_book::Request =
627            prost::Message::decode(request.body.as_ref()).ok()?;
628        let c2s = &req.c2s;
629        let key = qot_cache::make_key(c2s.security.market, &c2s.security.code);
630        let ob = self.cache.order_books.get(&key);
631        let n = c2s.num as usize;
632        let (ask_list, bid_list) = match ob.as_deref() {
633            Some(ob) => (
634                ob.ask_list
635                    .iter()
636                    .take(n)
637                    .map(|a| futu_proto::qot_common::OrderBook {
638                        price: a.price,
639                        volume: a.volume,
640                        oreder_count: a.order_count,
641                        detail_list: vec![],
642                    })
643                    .collect(),
644                ob.bid_list
645                    .iter()
646                    .take(n)
647                    .map(|b| futu_proto::qot_common::OrderBook {
648                        price: b.price,
649                        volume: b.volume,
650                        oreder_count: b.order_count,
651                        detail_list: vec![],
652                    })
653                    .collect(),
654            ),
655            None => (vec![], vec![]),
656        };
657        let resp = futu_proto::qot_get_order_book::Response {
658            ret_type: 0,
659            ret_msg: None,
660            err_code: None,
661            s2c: Some(futu_proto::qot_get_order_book::S2c {
662                security: c2s.security.clone(),
663                name: None,
664                order_book_ask_list: ask_list,
665                order_book_bid_list: bid_list,
666                svr_recv_time_bid: None,
667                svr_recv_time_bid_timestamp: None,
668                svr_recv_time_ask: None,
669                svr_recv_time_ask_timestamp: None,
670            }),
671        };
672        Some(prost::Message::encode_to_vec(&resp))
673    }
674}
675
676// ===== GetTicker =====
677struct GetTickerHandler {
678    cache: Arc<QotCache>,
679}
680
681#[async_trait]
682impl RequestHandler for GetTickerHandler {
683    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
684        let req: futu_proto::qot_get_ticker::Request =
685            prost::Message::decode(request.body.as_ref()).ok()?;
686        let c2s = &req.c2s;
687        let key = qot_cache::make_key(c2s.security.market, &c2s.security.code);
688        let n = c2s.max_ret_num as usize;
689        let tickers = self.cache.tickers.get(&key);
690        let ticker_list: Vec<futu_proto::qot_common::Ticker> = tickers
691            .as_deref()
692            .map(|t| {
693                t.iter()
694                    .rev()
695                    .take(n)
696                    .rev()
697                    .map(|t| futu_proto::qot_common::Ticker {
698                        time: t.time.clone(),
699                        sequence: 0,
700                        dir: t.dir,
701                        price: t.price,
702                        volume: t.volume,
703                        turnover: 0.0,
704                        recv_time: None,
705                        r#type: None,
706                        type_sign: None,
707                        push_data_type: None,
708                        timestamp: None,
709                    })
710                    .collect()
711            })
712            .unwrap_or_default();
713        let resp = futu_proto::qot_get_ticker::Response {
714            ret_type: 0,
715            ret_msg: None,
716            err_code: None,
717            s2c: Some(futu_proto::qot_get_ticker::S2c {
718                security: c2s.security.clone(),
719                name: None,
720                ticker_list,
721            }),
722        };
723        Some(prost::Message::encode_to_vec(&resp))
724    }
725}
726
727// ===== GetRT =====
728struct GetRTHandler {
729    cache: Arc<QotCache>,
730}
731
732#[async_trait]
733impl RequestHandler for GetRTHandler {
734    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
735        let req: futu_proto::qot_get_rt::Request =
736            prost::Message::decode(request.body.as_ref()).ok()?;
737        let key = qot_cache::make_key(req.c2s.security.market, &req.c2s.security.code);
738        let rt_list = self
739            .cache
740            .rt_data
741            .get(&key)
742            .map(|v| {
743                v.iter()
744                    .map(|rt| futu_proto::qot_common::TimeShare {
745                        time: rt.time.clone(),
746                        minute: rt.minute,
747                        is_blank: false,
748                        price: Some(rt.price),
749                        last_close_price: if rt.last_close_price > 0.0 {
750                            Some(rt.last_close_price)
751                        } else {
752                            None
753                        },
754                        avg_price: if rt.avg_price > 0.0 {
755                            Some(rt.avg_price)
756                        } else {
757                            None
758                        },
759                        volume: Some(rt.volume),
760                        turnover: Some(rt.turnover),
761                        timestamp: Some(rt.timestamp),
762                    })
763                    .collect()
764            })
765            .unwrap_or_default();
766        let resp = futu_proto::qot_get_rt::Response {
767            ret_type: 0,
768            ret_msg: None,
769            err_code: None,
770            s2c: Some(futu_proto::qot_get_rt::S2c {
771                security: req.c2s.security.clone(),
772                name: None,
773                rt_list,
774            }),
775        };
776        Some(prost::Message::encode_to_vec(&resp))
777    }
778}
779
780// ===== GetStaticInfo =====
781struct GetStaticInfoHandler {
782    cache: Arc<StaticDataCache>,
783}
784
785#[async_trait]
786impl RequestHandler for GetStaticInfoHandler {
787    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
788        let req: futu_proto::qot_get_static_info::Request =
789            prost::Message::decode(request.body.as_ref()).ok()?;
790        let mut static_info_list = Vec::new();
791
792        if !req.c2s.security_list.is_empty() {
793            // 模式 1: 指定股票列表查询
794            for sec in &req.c2s.security_list {
795                let key = qot_cache::make_key(sec.market, &sec.code);
796                if let Some(info) = self.cache.get_security_info(&key) {
797                    static_info_list.push(make_static_info(sec.clone(), &info));
798                }
799            }
800        } else {
801            // 模式 2/3: 按市场 (+ 类型) 查询
802            let filter_market = req.c2s.market;
803            let filter_sec_type = req.c2s.sec_type;
804            for entry in self.cache.securities.iter() {
805                let info = entry.value();
806                // 对齐 C++ SQL: WHERE no_search=0 (不返回不可搜索的股票)
807                if info.no_search {
808                    continue;
809                }
810                if let Some(m) = filter_market {
811                    if info.market != m {
812                        continue;
813                    }
814                }
815                if let Some(st) = filter_sec_type {
816                    if info.sec_type != st {
817                        continue;
818                    }
819                }
820                let sec = futu_proto::qot_common::Security {
821                    market: info.market,
822                    code: info.code.clone(),
823                };
824                static_info_list.push(make_static_info(sec, info));
825            }
826        }
827
828        let resp = futu_proto::qot_get_static_info::Response {
829            ret_type: 0,
830            ret_msg: None,
831            err_code: None,
832            s2c: Some(futu_proto::qot_get_static_info::S2c { static_info_list }),
833        };
834        Some(prost::Message::encode_to_vec(&resp))
835    }
836}
837
838fn make_static_info(
839    security: futu_proto::qot_common::Security,
840    info: &futu_cache::static_data::CachedSecurityInfo,
841) -> futu_proto::qot_common::SecurityStaticInfo {
842    futu_proto::qot_common::SecurityStaticInfo {
843        basic: futu_proto::qot_common::SecurityStaticBasic {
844            security,
845            id: info.stock_id as i64,
846            lot_size: info.lot_size,
847            sec_type: info.sec_type,
848            name: info.name.clone(),
849            list_time: info.list_time.clone(),
850            delisting: Some(info.delisting),
851            list_timestamp: None,
852            exch_type: if info.exch_type != 0 {
853                Some(info.exch_type)
854            } else {
855                None
856            },
857        },
858        warrant_ex_data: None,
859        option_ex_data: None,
860        future_ex_data: None,
861    }
862}
863
864// ===== GetSecuritySnapshot (CMD 6824) =====
865struct GetSecuritySnapshotHandler {
866    backend: crate::bridge::SharedBackend,
867    static_cache: Arc<StaticDataCache>,
868}
869
870/// CMD 6824
871const CMD_PULL_SUB_DATA: u16 = 6824;
872
873/// 从 BitQuote 数据解析快照信息
874#[derive(Default)]
875struct SnapshotData {
876    // SBIT_PRICE
877    cur_price: i64,
878    last_close_price: i64,
879    timestamp: i64,
880    // SBIT_STOCK_STATE
881    suspend_flag: bool,
882    sec_status: i32,
883    // SBIT_DEAL_STATISTICS
884    open_price: i64,
885    high_price: i64,
886    low_price: i64,
887    volume: i64,
888    turnover: i64,
889    turnover_ratio: i64,
890    amplitude: i64,
891    avg_price: i64,
892    bid_ask_ratio: i64,
893    volume_ratio: i64,
894    // KCB after market
895    kcb_after_volume: i64,
896    kcb_after_turnover: i64,
897    kcb_has_after: bool,
898    // SBIT_ORDER_BOOK_SIMPLE
899    bid_price: i64,
900    ask_price: i64,
901    bid_vol: i64,
902    ask_vol: i64,
903    // SBIT_HISTORY_HIGHLOW_PRICE
904    history_highest_price: i64,
905    history_lowest_price: i64,
906    week52_highest_price: i64,
907    week52_lowest_price: i64,
908    // SBIT_HISTORY_CLOSE_PRICE
909    close_price_5min: i64,
910    // SBIT_FINANCIAL_INDICATOR (Eqty only)
911    total_shares: i64,
912    total_market_cap: i64,
913    outstanding_shares: i64,
914    pe_lyr: i64,
915    pb_ratio: i64,
916    pe_ttm: i64,
917    eps_lyr: i64,
918    dividend_ttm: i64,
919    dividend_ratio_ttm: i64,
920    dividend_lfy: i64,
921    dividend_lfy_ratio: i64,
922    // SBIT_STATIC_FINANCIAL_INDICATOR (Eqty only)
923    net_asset: i64,
924    net_asset_pershare: i64,
925    net_profit: i64,
926    ey_ratio: i32,
927    // SBIT_STOCK_TYPE_SPECIFIC
928    stock_specific: Option<StockSpecificData>,
929    // SBIT_US_PREMARKET_AFTERHOURS_DETAIL
930    pre_market: Option<PreAfterMarketItem>,
931    after_market: Option<PreAfterMarketItem>,
932    overnight: Option<PreAfterMarketItem>,
933}
934
935struct PreAfterMarketItem {
936    price: i64,
937    high_price: i64,
938    low_price: i64,
939    volume: i64,
940    turnover: i64,
941    change_val: i64,
942    change_ratio: i64,
943    amplitude: i64,
944}
945
946enum StockSpecificData {
947    Warrant(futu_backend::proto_internal::ft_cmd_stock_quote_coverage_data::HkWarrantCbbc),
948    Option(futu_backend::proto_internal::ft_cmd_stock_quote_coverage_data::Option),
949    Index(futu_backend::proto_internal::ft_cmd_stock_quote_coverage_data::Index),
950    Plate(futu_backend::proto_internal::ft_cmd_stock_quote_coverage_data::Plate),
951    Future(futu_backend::proto_internal::ft_cmd_stock_quote_coverage_data::Future),
952    Trust(futu_backend::proto_internal::ft_cmd_stock_quote_coverage_data::Trust),
953}
954
955/// 价格转换: raw / 10^precision
956fn dp(raw: i64, precision: u32) -> f64 {
957    raw as f64 / 10f64.powi(precision as i32)
958}
959
960/// 解析一个 SecurityQuote 的所有 BitQuote
961fn parse_security_quote(
962    sq: &futu_backend::proto_internal::ft_cmd_stock_quote_sub_data::SecurityQuote,
963    sec_type: i32,
964    is_us: bool,
965) -> SnapshotData {
966    use futu_backend::proto_internal::ft_cmd_stock_quote_coverage_data as cov;
967    use prost::Message;
968    let mut s = SnapshotData::default();
969    for bq in &sq.bit_qta_list {
970        let bit = bq.bit.unwrap_or(999);
971        let data = &bq.data.as_deref().unwrap_or(&[]);
972        match bit {
973            0 => {
974                // SBIT_PRICE
975                if let Ok(p) = cov::Price::decode(*data) {
976                    s.cur_price = p.price_nominal.unwrap_or(0);
977                    s.last_close_price = p.price_last_close.unwrap_or(0);
978                    let exch_time_ms = p.exchange_data_time_ms.unwrap_or(0);
979                    s.timestamp = exch_time_ms / 1000;
980                }
981            }
982            1 => {
983                // SBIT_STOCK_STATE
984                if let Ok(st) = cov::StockState::decode(*data) {
985                    let state_type = st.state_type.unwrap_or(0);
986                    s.suspend_flag = state_type == 8; // STOCK_STATE_SUSPENDED
987                    s.sec_status = stock_state_to_api_status(state_type);
988                }
989            }
990            2 => {
991                // SBIT_STOCK_TYPE_SPECIFIC
992                if let Ok(sp) = cov::StockTypeSpecific::decode(*data) {
993                    match sec_type {
994                        5 => {
995                            if let Some(w) = sp.hk_warrant_cbbc {
996                                s.stock_specific = Some(StockSpecificData::Warrant(w));
997                            }
998                        }
999                        8 => {
1000                            if let Some(o) = sp.option {
1001                                s.stock_specific = Some(StockSpecificData::Option(o));
1002                            }
1003                        }
1004                        6 => {
1005                            if let Some(i) = sp.index {
1006                                s.stock_specific = Some(StockSpecificData::Index(i));
1007                            }
1008                        }
1009                        7 => {
1010                            if let Some(p) = sp.plate {
1011                                s.stock_specific = Some(StockSpecificData::Plate(p));
1012                            }
1013                        }
1014                        10 => {
1015                            if let Some(f) = sp.future {
1016                                s.stock_specific = Some(StockSpecificData::Future(f));
1017                            }
1018                        }
1019                        4 => {
1020                            if let Some(t) = sp.trust {
1021                                s.stock_specific = Some(StockSpecificData::Trust(t));
1022                            }
1023                        }
1024                        _ => {}
1025                    }
1026                }
1027            }
1028            4 => {
1029                // SBIT_ORDER_BOOK_SIMPLE
1030                if let Ok(ob) = cov::OrderBookSimple::decode(*data) {
1031                    s.bid_price = ob.price_bid.unwrap_or(0);
1032                    s.ask_price = ob.price_ask.unwrap_or(0);
1033                    s.bid_vol = ob.volume_bid.unwrap_or(0);
1034                    s.ask_vol = ob.volume_ask.unwrap_or(0);
1035                }
1036            }
1037            5 => {
1038                // SBIT_DEAL_STATISTICS
1039                if let Ok(ds) = cov::DealStatistics::decode(*data) {
1040                    s.open_price = ds.price_open.unwrap_or(0);
1041                    s.high_price = ds.price_highest.unwrap_or(0);
1042                    s.low_price = ds.price_lowest.unwrap_or(0);
1043                    s.volume = ds.volume.unwrap_or(0);
1044                    s.turnover = ds.turnover.unwrap_or(0);
1045                    s.turnover_ratio = ds.ratio_turnover.unwrap_or(0);
1046                    s.amplitude = ds.amplitude_price.unwrap_or(0);
1047                    s.avg_price = ds.price_average.unwrap_or(0);
1048                    s.bid_ask_ratio = ds.ratio_bid_ask.unwrap_or(0);
1049                    s.volume_ratio = ds.ratio_volume.unwrap_or(0);
1050                    // KCB after market data
1051                    if let Some(kcb) = &ds.kcb_stock_static {
1052                        s.kcb_has_after = true;
1053                        s.kcb_after_volume = kcb.volume.unwrap_or(0);
1054                        s.kcb_after_turnover = kcb.turnover.unwrap_or(0);
1055                    }
1056                }
1057            }
1058            6 => {
1059                // SBIT_HISTORY_HIGHLOW_PRICE
1060                if let Ok(hh) = cov::HistoryHighLowPrice::decode(*data) {
1061                    s.history_highest_price = hh.price_highest_history.unwrap_or(0);
1062                    s.history_lowest_price = hh.price_lowest_history.unwrap_or(0);
1063                    s.week52_highest_price = hh.price_highest_52week.unwrap_or(0);
1064                    s.week52_lowest_price = hh.price_lowest_52week.unwrap_or(0);
1065                }
1066            }
1067            7 => {
1068                // SBIT_HISTORY_CLOSE_PRICE
1069                if let Ok(hc) = cov::HistoryClosePrice::decode(*data) {
1070                    s.close_price_5min = hc.price_close_5min.unwrap_or(0);
1071                }
1072            }
1073            8 => {
1074                // SBIT_FINANCIAL_INDICATOR (Eqty only)
1075                if sec_type == 3 {
1076                    // NN_QuoteSecurityType_Eqty
1077                    if let Ok(fi) = cov::FinacialIndicator::decode(*data) {
1078                        s.total_shares = fi.total_shares.unwrap_or(0);
1079                        s.total_market_cap = fi.total_market_cap.unwrap_or(0);
1080                        s.outstanding_shares = fi.outstanding_shares.unwrap_or(0);
1081                        s.pe_lyr = fi.pe_lyr.unwrap_or(0);
1082                        s.pb_ratio = fi.pb_ratio.unwrap_or(0);
1083                        s.pe_ttm = fi.pe_ttm.unwrap_or(0);
1084                        s.eps_lyr = fi.eps_lyr.unwrap_or(0);
1085                        s.dividend_ttm = fi.dividend.unwrap_or(0);
1086                        s.dividend_ratio_ttm = fi.dividend_ratio.unwrap_or(0);
1087                        s.dividend_lfy = fi.dividend_lfy.unwrap_or(0);
1088                        s.dividend_lfy_ratio = fi.dividend_lfy_ratio.unwrap_or(0);
1089                    }
1090                }
1091            }
1092            18 => {
1093                // SBIT_STATIC_FINANCIAL_INDICATOR (Eqty only)
1094                if sec_type == 3 {
1095                    if let Ok(sfi) = cov::StaticFinancialIndicator::decode(*data) {
1096                        s.net_asset = sfi.net_asset.unwrap_or(0);
1097                        s.net_asset_pershare = sfi.net_asset_pershare.unwrap_or(0);
1098                        s.net_profit = sfi.net_profit_lyr.unwrap_or(0);
1099                        s.ey_ratio = sfi.ey_ratio.unwrap_or(0);
1100                    }
1101                }
1102            }
1103            13 => {
1104                // SBIT_US_PREMARKET_AFTERHOURS_DETAIL (US only)
1105                if is_us {
1106                    if let Ok(pa) = cov::UsPreMarketAfterHoursDetail::decode(*data) {
1107                        if let Some(pm) = &pa.pre_market {
1108                            s.pre_market = Some(PreAfterMarketItem {
1109                                price: pm.price.unwrap_or(0),
1110                                high_price: pm.price_highest.unwrap_or(0),
1111                                low_price: pm.price_lowest.unwrap_or(0),
1112                                volume: pm.volume.unwrap_or(0),
1113                                turnover: pm.turnover.unwrap_or(0),
1114                                change_val: pm.price_change.unwrap_or(0),
1115                                change_ratio: pm.ratio_price_change.unwrap_or(0),
1116                                amplitude: pm.amplitude_price.unwrap_or(0),
1117                            });
1118                        }
1119                        if let Some(ah) = &pa.after_hours {
1120                            s.after_market = Some(PreAfterMarketItem {
1121                                price: ah.price.unwrap_or(0),
1122                                high_price: ah.price_highest.unwrap_or(0),
1123                                low_price: ah.price_lowest.unwrap_or(0),
1124                                volume: ah.volume.unwrap_or(0),
1125                                turnover: ah.turnover.unwrap_or(0),
1126                                change_val: ah.price_change.unwrap_or(0),
1127                                change_ratio: ah.ratio_price_change.unwrap_or(0),
1128                                amplitude: ah.amplitude_price.unwrap_or(0),
1129                            });
1130                        }
1131                        if let Some(on) = &pa.overnight {
1132                            s.overnight = Some(PreAfterMarketItem {
1133                                price: on.price.unwrap_or(0),
1134                                high_price: on.price_highest.unwrap_or(0),
1135                                low_price: on.price_lowest.unwrap_or(0),
1136                                volume: on.volume.unwrap_or(0),
1137                                turnover: on.turnover.unwrap_or(0),
1138                                change_val: on.price_change.unwrap_or(0),
1139                                change_ratio: on.ratio_price_change.unwrap_or(0),
1140                                amplitude: on.amplitude_price.unwrap_or(0),
1141                            });
1142                        }
1143                    }
1144                }
1145            }
1146            _ => {}
1147        }
1148    }
1149    s
1150}
1151
1152/// StockStateType → FTAPI Qot_Common::SecurityStatus
1153fn stock_state_to_api_status(state_type: i32) -> i32 {
1154    match state_type {
1155        0 => 0, // Normal → Unknown (C++ maps to NN_QotSecStatus_None=0 for Normal, but API maps differently)
1156        1 => 2, // PendingListing → Listing
1157        2 => 3, // IPO_Purchasing_CN → Purchasing
1158        3 => 4, // IPO_Purchasing_Other → Subscribing
1159        4 => 5, // DarkTradePending → BeforeDrakTradeOpening
1160        5 => 6, // DarkTradeHappening → DrakTrading
1161        6 => 7, // DarkTradeEnd → DrakTradeEnd
1162        7 => 8, // ToBeOpen → ToBeOpen
1163        8 => 9, // Suspended → Suspended
1164        9 => 10, // Called
1165        10 => 11, // ExpiredLastTradingDate
1166        11 => 12, // Expired
1167        12 => 13, // Delisted
1168        13 => 14, // ChangeToTemporaryCode
1169        14 => 15, // TemporaryCodeEndTrading
1170        15 => 16, // ChangedPlateEndTrading
1171        16 => 17, // ChangedCodeEndTrading
1172        17 => 18, // RecoverableCircuitBreaker
1173        18 => 19, // UnRecoverableCircuitBreaker
1174        19 => 20, // AfterCombination
1175        20 => 21, // AfterTransaction
1176        _ => 0,
1177    }
1178}
1179
1180/// PreAfterMarketItem → Qot_Common::PreAfterMarketData
1181fn build_pre_after_market(item: &PreAfterMarketItem) -> futu_proto::qot_common::PreAfterMarketData {
1182    futu_proto::qot_common::PreAfterMarketData {
1183        price: Some(dp(item.price, 9)),
1184        high_price: Some(dp(item.high_price, 9)),
1185        low_price: Some(dp(item.low_price, 9)),
1186        volume: Some(item.volume),
1187        turnover: Some(dp(item.turnover, 3)),
1188        change_val: Some(dp(item.change_val, 9)),
1189        change_rate: Some(dp(item.change_ratio, 3)),
1190        amplitude: Some(dp(item.amplitude, 4)),
1191    }
1192}
1193
1194#[async_trait]
1195impl RequestHandler for GetSecuritySnapshotHandler {
1196    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
1197        let req: futu_proto::qot_get_security_snapshot::Request =
1198            prost::Message::decode(request.body.as_ref()).ok()?;
1199
1200        let backend = match super::load_backend(&self.backend) {
1201            Some(b) => b,
1202            None => {
1203                return Some(super::make_error_response(-1, "no backend connection"));
1204            }
1205        };
1206
1207        // Build security info: stock_id, sec_type, qot_market, backend_mkt for each security
1208        struct SecEntry {
1209            sec: futu_proto::qot_common::Security,
1210            stock_id: u64,
1211            sec_type: i32,
1212            qot_market: i32,
1213            name: String,
1214            lot_size: i32,
1215            list_time: String,
1216            backend_mkt: u8, // QuoteMktType
1217        }
1218
1219        let mut entries = Vec::new();
1220        for sec in &req.c2s.security_list {
1221            let key = qot_cache::make_key(sec.market, &sec.code);
1222            let info = match self.static_cache.get_security_info(&key) {
1223                Some(i) if i.stock_id > 0 => i,
1224                _ => continue,
1225            };
1226            let backend_mkt = match futu_backend::stock_list::qot_market_to_backend(sec.market) {
1227                Some(m) => m as u8,
1228                None => continue,
1229            };
1230            entries.push(SecEntry {
1231                sec: sec.clone(),
1232                stock_id: info.stock_id,
1233                sec_type: info.sec_type,
1234                qot_market: sec.market,
1235                name: info.name.clone(),
1236                lot_size: info.lot_size,
1237                list_time: info.list_time.clone(),
1238                backend_mkt,
1239            });
1240        }
1241
1242        // Group by backend market
1243        let mut market_groups: std::collections::HashMap<u8, Vec<usize>> =
1244            std::collections::HashMap::new();
1245        for (idx, entry) in entries.iter().enumerate() {
1246            market_groups
1247                .entry(entry.backend_mkt)
1248                .or_default()
1249                .push(idx);
1250        }
1251
1252        // Send CMD 6824 for each market group, collect responses
1253        let mut stock_snapshots: std::collections::HashMap<u64, SnapshotData> =
1254            std::collections::HashMap::new();
1255
1256        for (mkt, indices) in &market_groups {
1257            // Build FetchQuoteReq
1258            let mut security_list = Vec::new();
1259            for &idx in indices {
1260                let entry = &entries[idx];
1261                let mut bit_info_list = Vec::new();
1262                // 10 subscription bits (same as C++ GetSnapshotSubBit)
1263                for &bit in &[0u32, 1, 2, 5, 4, 8, 18, 7, 6, 13] {
1264                    let prob = if bit == 6 { Some(2i64) } else { None }; // SBIT_HISTORY_HIGHLOW_PRICE: prob=Forward
1265                    bit_info_list.push(
1266                        futu_backend::proto_internal::ft_cmd_stock_quote_sub_data::BitInfo {
1267                            bit: Some(bit),
1268                            prob,
1269                            prob2: None,
1270                        },
1271                    );
1272                }
1273                security_list.push(
1274                    futu_backend::proto_internal::ft_cmd_stock_quote_sub_data::SecuritySubscribe {
1275                        security_id: Some(entry.stock_id),
1276                        bit_info_list,
1277                    },
1278                );
1279            }
1280
1281            let fetch_req = futu_backend::proto_internal::ft_cmd_stock_quote_fetch::FetchQuoteReq {
1282                security_list,
1283                reserved: Some(0),
1284            };
1285
1286            let reserved = futu_backend::stock_list::make_quote_reserved(
1287                match mkt {
1288                    1 => futu_backend::stock_list::QuoteMktType::HK,
1289                    2 => futu_backend::stock_list::QuoteMktType::US,
1290                    3 => futu_backend::stock_list::QuoteMktType::SH,
1291                    4 => futu_backend::stock_list::QuoteMktType::SZ,
1292                    5 => futu_backend::stock_list::QuoteMktType::HKFuture,
1293                    _ => futu_backend::stock_list::QuoteMktType::HK,
1294                },
1295                0, // ex_type = SECURITY
1296            );
1297
1298            let body = prost::Message::encode_to_vec(&fetch_req);
1299            match backend
1300                .request_with_reserved(CMD_PULL_SUB_DATA, body, reserved)
1301                .await
1302            {
1303                Ok(resp) => {
1304                    let fetch_rsp: futu_backend::proto_internal::ft_cmd_stock_quote_fetch::FetchQuoteRsp =
1305                        match prost::Message::decode(resp.body.as_ref()) {
1306                            Ok(r) => r,
1307                            Err(e) => {
1308                                tracing::warn!(conn_id, mkt, error = %e, "CMD6824 decode failed");
1309                                continue;
1310                            }
1311                        };
1312                    for sq in &fetch_rsp.security_qta_list {
1313                        let stock_id = sq.security_id.unwrap_or(0);
1314                        // Find sec_type and is_us for this stock
1315                        let (sec_type, is_us) = indices
1316                            .iter()
1317                            .find_map(|&i| {
1318                                let e = &entries[i];
1319                                if e.stock_id == stock_id {
1320                                    Some((e.sec_type, e.qot_market == 11))
1321                                } else {
1322                                    None
1323                                }
1324                            })
1325                            .unwrap_or((0, false));
1326                        let sd = parse_security_quote(sq, sec_type, is_us);
1327                        stock_snapshots.insert(stock_id, sd);
1328                    }
1329                }
1330                Err(e) => {
1331                    tracing::warn!(conn_id, mkt, error = %e, "CMD6824 request failed");
1332                }
1333            }
1334        }
1335
1336        // Build response snapshots in request order
1337        let mut snapshot_list = Vec::new();
1338        for entry in &entries {
1339            let sd = stock_snapshots.get(&entry.stock_id);
1340            let basic = futu_proto::qot_get_security_snapshot::SnapshotBasicData {
1341                security: entry.sec.clone(),
1342                r#type: entry.sec_type,
1343                is_suspend: sd.map(|s| s.suspend_flag).unwrap_or(false),
1344                list_time: if entry.sec_type != 8 && entry.sec_type != 10 {
1345                    entry.list_time.clone()
1346                } else {
1347                    String::new()
1348                },
1349                lot_size: entry.lot_size,
1350                price_spread: 0.0,
1351                update_time: String::new(),
1352                high_price: sd.map(|s| dp(s.high_price, 9)).unwrap_or(0.0),
1353                open_price: sd.map(|s| dp(s.open_price, 9)).unwrap_or(0.0),
1354                low_price: sd.map(|s| dp(s.low_price, 9)).unwrap_or(0.0),
1355                last_close_price: sd.map(|s| dp(s.last_close_price, 9)).unwrap_or(0.0),
1356                cur_price: sd.map(|s| dp(s.cur_price, 9)).unwrap_or(0.0),
1357                volume: sd.map(|s| s.volume).unwrap_or(0),
1358                turnover: sd.map(|s| dp(s.turnover, 3)).unwrap_or(0.0),
1359                turnover_rate: sd.map(|s| dp(s.turnover_ratio, 3)).unwrap_or(0.0),
1360                name: Some(entry.name.clone()),
1361                list_timestamp: None,
1362                update_timestamp: sd.and_then(|s| {
1363                    if s.timestamp > 0 {
1364                        Some(s.timestamp as f64)
1365                    } else {
1366                        None
1367                    }
1368                }),
1369                ask_price: sd.map(|s| dp(s.ask_price, 9)),
1370                bid_price: sd.map(|s| dp(s.bid_price, 9)),
1371                ask_vol: sd.map(|s| s.ask_vol),
1372                bid_vol: sd.map(|s| s.bid_vol),
1373                enable_margin: None,
1374                mortgage_ratio: None,
1375                long_margin_initial_ratio: None,
1376                enable_short_sell: None,
1377                short_sell_rate: None,
1378                short_available_volume: None,
1379                short_margin_initial_ratio: None,
1380                amplitude: sd.map(|s| dp(s.amplitude, 3)),
1381                avg_price: sd.map(|s| dp(s.avg_price, 9)),
1382                bid_ask_ratio: sd.map(|s| dp(s.bid_ask_ratio, 3)),
1383                volume_ratio: sd.map(|s| dp(s.volume_ratio, 3)),
1384                highest52_weeks_price: sd.map(|s| dp(s.week52_highest_price, 9)),
1385                lowest52_weeks_price: sd.map(|s| dp(s.week52_lowest_price, 9)),
1386                highest_history_price: sd.map(|s| dp(s.history_highest_price, 9)),
1387                lowest_history_price: sd.map(|s| dp(s.history_lowest_price, 9)),
1388                sec_status: sd.map(|s| s.sec_status),
1389                close_price5_minute: sd.map(|s| dp(s.close_price_5min, 9)),
1390                pre_market: sd.and_then(|s| s.pre_market.as_ref().map(build_pre_after_market)),
1391                after_market: {
1392                    // KCB stocks: after_market from DealStatistics; US stocks: from SBIT_13
1393                    if let Some(s) = sd {
1394                        if s.kcb_has_after {
1395                            Some(futu_proto::qot_common::PreAfterMarketData {
1396                                price: None,
1397                                high_price: None,
1398                                low_price: None,
1399                                volume: Some(s.kcb_after_volume),
1400                                turnover: Some(dp(s.kcb_after_turnover, 3)),
1401                                change_val: None,
1402                                change_rate: None,
1403                                amplitude: None,
1404                            })
1405                        } else {
1406                            s.after_market.as_ref().map(build_pre_after_market)
1407                        }
1408                    } else {
1409                        None
1410                    }
1411                },
1412                overnight: sd.and_then(|s| s.overnight.as_ref().map(build_pre_after_market)),
1413            };
1414
1415            // Build ex_data based on sec_type
1416            let mut equity_ex = None;
1417            let mut warrant_ex = None;
1418            let mut option_ex = None;
1419            let mut index_ex = None;
1420            let mut plate_ex = None;
1421            let mut future_ex = None;
1422            let mut trust_ex = None;
1423
1424            if let Some(s) = sd {
1425                match entry.sec_type {
1426                    3 => {
1427                        // Eqty
1428                        let cur_price_f = dp(s.cur_price, 9);
1429                        equity_ex = Some(
1430                            futu_proto::qot_get_security_snapshot::EquitySnapshotExData {
1431                                issued_shares: s.total_shares,
1432                                issued_market_val: dp(s.total_market_cap, 3),
1433                                net_asset: dp(s.net_asset, 3),
1434                                net_profit: dp(s.net_profit, 3),
1435                                earnings_pershare: dp(s.eps_lyr, 9),
1436                                outstanding_shares: s.outstanding_shares,
1437                                outstanding_market_val: s.outstanding_shares as f64 * cur_price_f,
1438                                net_asset_pershare: dp(s.net_asset_pershare, 9),
1439                                ey_rate: dp(s.ey_ratio as i64, 3),
1440                                pe_ttm_rate: dp(s.pe_ttm, 3),
1441                                pb_rate: dp(s.pb_ratio, 3),
1442                                pe_rate: dp(s.pe_lyr, 3),
1443                                dividend_ttm: Some(dp(s.dividend_ttm, 9)),
1444                                dividend_ratio_ttm: Some(dp(s.dividend_ratio_ttm, 2)), // C++: precision=2
1445                                dividend_lfy: Some(dp(s.dividend_lfy, 9)),
1446                                dividend_lfy_ratio: Some(dp(s.dividend_lfy_ratio, 3)), // C++: precision=3
1447                            },
1448                        );
1449                    }
1450                    5 => {
1451                        // Warrant
1452                        if let Some(StockSpecificData::Warrant(ref w)) = s.stock_specific {
1453                            warrant_ex = Some(
1454                                futu_proto::qot_get_security_snapshot::WarrantSnapshotExData {
1455                                    conversion_price: Some(dp(w.price_entitlement.unwrap_or(0), 9)),
1456                                    warrant_type: w.r#type.unwrap_or(0),
1457                                    strike_price: dp(w.price_strike.unwrap_or(0), 9),
1458                                    maturity_time: String::new(),
1459                                    end_trade_time: String::new(),
1460                                    owner: futu_proto::qot_common::Security {
1461                                        market: entry.qot_market,
1462                                        code: String::new(),
1463                                    },
1464                                    recovery_price: dp(w.price_call.unwrap_or(0), 9),
1465                                    street_volumn: w.volume_street.unwrap_or(0),
1466                                    issue_volumn: w.issued_shares.unwrap_or(0),
1467                                    street_rate: dp(w.ratio_street.unwrap_or(0), 5),
1468                                    delta: dp(w.delta.unwrap_or(0), 3),
1469                                    implied_volatility: dp(w.implied_volatility.unwrap_or(0), 3),
1470                                    premium: dp(w.premium.unwrap_or(0), 5),
1471                                    maturity_timestamp: w.expiry_date_time_s.map(|t| t as f64),
1472                                    end_trade_timestamp: w
1473                                        .last_trading_date_time_s
1474                                        .map(|t| t as f64),
1475                                    leverage: Some(dp(w.leverage.unwrap_or(0), 3)),
1476                                    ipop: Some(dp(w.ratio_itm_otm.unwrap_or(0), 5)),
1477                                    break_even_point: Some(dp(
1478                                        w.price_break_even_point.unwrap_or(0),
1479                                        9,
1480                                    )),
1481                                    conversion_rate: dp(w.ratio_entitlement.unwrap_or(0), 3),
1482                                    price_recovery_ratio: Some(dp(
1483                                        w.ratio_price_call.unwrap_or(0),
1484                                        5,
1485                                    )),
1486                                    score: Some(dp(w.score_faxing.unwrap_or(0) as i64, 0)),
1487                                    upper_strike_price: Some(dp(w.upper_price.unwrap_or(0), 9)),
1488                                    lower_strike_price: Some(dp(w.lower_price.unwrap_or(0), 9)),
1489                                    in_line_price_status: w.in_or_out.map(|v| {
1490                                        if v == 0 {
1491                                            1
1492                                        } else {
1493                                            2
1494                                        }
1495                                    }),
1496                                    issuer_code: None,
1497                                },
1498                            );
1499                        }
1500                    }
1501                    8 => {
1502                        // Option/Drvt
1503                        if let Some(StockSpecificData::Option(ref o)) = s.stock_specific {
1504                            let greek = o.greek.as_ref();
1505                            option_ex = Some(
1506                                futu_proto::qot_get_security_snapshot::OptionSnapshotExData {
1507                                    r#type: 0,
1508                                    owner: futu_proto::qot_common::Security {
1509                                        market: entry.qot_market,
1510                                        code: String::new(),
1511                                    },
1512                                    strike_time: String::new(),
1513                                    strike_price: dp(o.price_strike.unwrap_or(0), 9),
1514                                    contract_size: o.contract_size.unwrap_or(0) as i32,
1515                                    open_interest: o.open_interest.unwrap_or(0) as i32,
1516                                    implied_volatility: dp(o.implied_volatility.unwrap_or(0), 5),
1517                                    premium: dp(o.premium.unwrap_or(0), 5),
1518                                    delta: greek
1519                                        .map(|g| dp(g.hp_delta.unwrap_or(0), 9))
1520                                        .unwrap_or(0.0),
1521                                    gamma: greek
1522                                        .map(|g| dp(g.hp_gamma.unwrap_or(0), 9))
1523                                        .unwrap_or(0.0),
1524                                    vega: greek
1525                                        .map(|g| dp(g.hp_vega.unwrap_or(0), 9))
1526                                        .unwrap_or(0.0),
1527                                    theta: greek
1528                                        .map(|g| dp(g.hp_theta.unwrap_or(0), 9))
1529                                        .unwrap_or(0.0),
1530                                    rho: greek.map(|g| dp(g.hp_rho.unwrap_or(0), 9)).unwrap_or(0.0),
1531                                    strike_timestamp: None,
1532                                    index_option_type: None,
1533                                    net_open_interest: Some(o.open_interest_net.unwrap_or(0) as i32),
1534                                    expiry_date_distance: Some(
1535                                        o.distance_due_date.unwrap_or(0) as i32
1536                                    ),
1537                                    contract_nominal_value: Some(dp(
1538                                        o.contract_nominal_ammount.unwrap_or(0) as i64,
1539                                        9,
1540                                    )),
1541                                    owner_lot_multiplier: Some(
1542                                        o.positive_number_of_hand.unwrap_or(0) as f64,
1543                                    ),
1544                                    option_area_type: Some(o.option_type.unwrap_or(0) + 1),
1545                                    contract_multiplier: o.hp_multiplier.map(|m| dp(m as i64, 9)),
1546                                    contract_size_float: o
1547                                        .hp_contract_size
1548                                        .map(|c| dp(c as i64, 9)),
1549                                },
1550                            );
1551                        }
1552                    }
1553                    6 => {
1554                        // Index
1555                        if let Some(StockSpecificData::Index(ref i)) = s.stock_specific {
1556                            index_ex =
1557                                Some(futu_proto::qot_get_security_snapshot::IndexSnapshotExData {
1558                                    raise_count: i.raise_count.unwrap_or(0) as i32,
1559                                    fall_count: i.fall_count.unwrap_or(0) as i32,
1560                                    equal_count: i.equal_count.unwrap_or(0) as i32,
1561                                });
1562                        }
1563                    }
1564                    7 => {
1565                        // Plate
1566                        if let Some(StockSpecificData::Plate(ref p)) = s.stock_specific {
1567                            plate_ex =
1568                                Some(futu_proto::qot_get_security_snapshot::PlateSnapshotExData {
1569                                    raise_count: p.raise_count.unwrap_or(0) as i32,
1570                                    fall_count: p.fall_count.unwrap_or(0) as i32,
1571                                    equal_count: p.equal_count.unwrap_or(0) as i32,
1572                                });
1573                        }
1574                    }
1575                    10 => {
1576                        // Future
1577                        if let Some(StockSpecificData::Future(ref f)) = s.stock_specific {
1578                            future_ex = Some(
1579                                futu_proto::qot_get_security_snapshot::FutureSnapshotExData {
1580                                    last_settle_price: dp(f.last_settlement_price.unwrap_or(0), 9),
1581                                    position: f.hold_vol.unwrap_or(0) as i32,
1582                                    position_change: f.diff_hold_vol.unwrap_or(0) as i32,
1583                                    last_trade_time: String::new(),
1584                                    last_trade_timestamp: None,
1585                                    is_main_contract: f.main_flag.unwrap_or(0) == 1,
1586                                },
1587                            );
1588                        }
1589                    }
1590                    4 => {
1591                        // Trust
1592                        if let Some(StockSpecificData::Trust(ref t)) = s.stock_specific {
1593                            trust_ex =
1594                                Some(futu_proto::qot_get_security_snapshot::TrustSnapshotExData {
1595                                    dividend_yield: dp(t.dividend_yield.unwrap_or(0), 5),
1596                                    aum: t.aum.unwrap_or(0) as f64,
1597                                    outstanding_units: t.outstanding_units.unwrap_or(0) as i64,
1598                                    net_asset_value: dp(t.net_asset_value.unwrap_or(0) as i64, 9),
1599                                    premium: dp(t.premium.unwrap_or(0), 5),
1600                                    asset_class: t.asset_class.unwrap_or(0) as i32,
1601                                });
1602                        }
1603                    }
1604                    _ => {}
1605                }
1606            }
1607
1608            snapshot_list.push(futu_proto::qot_get_security_snapshot::Snapshot {
1609                basic,
1610                equity_ex_data: equity_ex,
1611                warrant_ex_data: warrant_ex,
1612                option_ex_data: option_ex,
1613                index_ex_data: index_ex,
1614                plate_ex_data: plate_ex,
1615                future_ex_data: future_ex,
1616                trust_ex_data: trust_ex,
1617            });
1618        }
1619
1620        tracing::debug!(
1621            conn_id,
1622            count = snapshot_list.len(),
1623            "GetSecuritySnapshot via CMD6824"
1624        );
1625
1626        let resp = futu_proto::qot_get_security_snapshot::Response {
1627            ret_type: 0,
1628            ret_msg: None,
1629            err_code: None,
1630            s2c: Some(futu_proto::qot_get_security_snapshot::S2c { snapshot_list }),
1631        };
1632        Some(prost::Message::encode_to_vec(&resp))
1633    }
1634}
1635
1636// ===== GetGlobalState =====
1637struct GetGlobalStateHandler {
1638    login_cache: Arc<LoginCache>,
1639    backend: crate::bridge::SharedBackend,
1640}
1641
1642#[async_trait]
1643impl RequestHandler for GetGlobalStateHandler {
1644    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
1645        let _req: futu_proto::get_global_state::Request =
1646            prost::Message::decode(request.body.as_ref()).ok()?;
1647
1648        let login_state = self.login_cache.get_login_state();
1649        let is_logged_in = login_state
1650            .as_ref()
1651            .map(|s| s.is_logged_in)
1652            .unwrap_or(false);
1653        let now = std::time::SystemTime::now()
1654            .duration_since(std::time::UNIX_EPOCH)
1655            .unwrap_or_default()
1656            .as_secs() as i64;
1657
1658        // 从后端拉取真实市场状态(对应 C++ INNData_Qot_EventNotice::GetMarketStateInfo)
1659        use futu_backend::stock_list::{pull_single_market_status, QuoteMktType};
1660        let mut market_hk: i32 = 0; // QotMarketState_None
1661        let mut market_us: i32 = 0;
1662        let mut market_sh: i32 = 0;
1663        let mut market_sz: i32 = 0;
1664        let mut market_hk_future: i32 = 0;
1665        let mut market_us_future: Option<i32> = None;
1666        let mut market_sg_future: Option<i32> = None;
1667        let mut market_jp_future: Option<i32> = None;
1668
1669        if let Some(backend) = super::load_backend(&self.backend) {
1670            let queries = [
1671                (QuoteMktType::HK, "HK"),
1672                (QuoteMktType::US, "US"),
1673                (QuoteMktType::SH, "SH"),
1674                (QuoteMktType::SZ, "SZ"),
1675                (QuoteMktType::HKFuture, "HKFuture"),
1676                (QuoteMktType::USFuture, "USFuture"),
1677                (QuoteMktType::SGFuture, "SGFuture"),
1678                (QuoteMktType::JPFuture, "JPFuture"),
1679            ];
1680            for (mkt, name) in &queries {
1681                match pull_single_market_status(&backend, *mkt).await {
1682                    Ok(statuses) if !statuses.is_empty() => {
1683                        let state = statuses[0].status as i32;
1684                        match *name {
1685                            "HK" => market_hk = state,
1686                            "US" => market_us = state,
1687                            "SH" => market_sh = state,
1688                            "SZ" => market_sz = state,
1689                            "HKFuture" => market_hk_future = state,
1690                            "USFuture" => market_us_future = Some(state),
1691                            "SGFuture" => market_sg_future = Some(state),
1692                            "JPFuture" => market_jp_future = Some(state),
1693                            _ => {}
1694                        }
1695                    }
1696                    Ok(_) => {
1697                        tracing::debug!(market = name, "no market status data");
1698                    }
1699                    Err(e) => {
1700                        tracing::debug!(market = name, error = %e, "market status query failed");
1701                    }
1702                }
1703            }
1704        }
1705
1706        let resp = futu_proto::get_global_state::Response {
1707            ret_type: 0,
1708            ret_msg: None,
1709            err_code: None,
1710            s2c: Some(futu_proto::get_global_state::S2c {
1711                market_hk,
1712                market_us,
1713                market_sh,
1714                market_sz,
1715                market_hk_future,
1716                market_us_future,
1717                market_sg_future,
1718                market_jp_future,
1719                qot_logined: is_logged_in,
1720                trd_logined: is_logged_in,
1721                server_ver: 1002,
1722                server_build_no: 1,
1723                time: now,
1724                local_time: Some(now as f64),
1725                program_status: None,
1726                qot_svr_ip_addr: login_state.as_ref().map(|s| s.server_addr.clone()),
1727                trd_svr_ip_addr: login_state.as_ref().map(|s| s.server_addr.clone()),
1728                conn_id: Some(conn_id),
1729            }),
1730        };
1731        Some(prost::Message::encode_to_vec(&resp))
1732    }
1733}
1734
1735// ===== RequestHistoryKL =====
1736struct RequestHistoryKLHandler {
1737    backend: crate::bridge::SharedBackend,
1738    static_cache: Arc<StaticDataCache>,
1739    kl_quota_counter: Arc<std::sync::atomic::AtomicU32>,
1740}
1741
1742/// FTAPI KLType → backend KlineType 映射
1743/// 两者的枚举值完全一致,直接传递
1744fn ftapi_kl_type_to_backend(kl_type: i32) -> Option<u32> {
1745    match kl_type {
1746        1 => Some(1),   // 1Min
1747        2 => Some(2),   // Day
1748        3 => Some(3),   // Week
1749        4 => Some(4),   // Month
1750        5 => Some(5),   // Year
1751        6 => Some(6),   // 5Min
1752        7 => Some(7),   // 15Min
1753        8 => Some(8),   // 30Min
1754        9 => Some(9),   // 60Min
1755        10 => Some(10), // 3Min
1756        11 => Some(11), // Quarter
1757        _ => None,
1758    }
1759}
1760
1761/// 将日期字符串转换为 UTC 时间戳,考虑市场时区。
1762/// C++ 使用 `APITimeStrToTimeStamp_Qot` + `GetTimeZoneByAPIQotMkt`。
1763/// 例如 "2024-01-02" 在 HK 市场 = 2024-01-02 00:00:00 +08:00 = 2024-01-01T16:00:00Z
1764fn date_str_to_timestamp(s: &str, market: i32) -> Option<u64> {
1765    // Support both "yyyy-MM-dd" and "yyyy-MM-dd HH:MM:SS"
1766    let date_part = s.split(' ').next().unwrap_or(s);
1767    let parts: Vec<&str> = date_part.split('-').collect();
1768    if parts.len() != 3 {
1769        return None;
1770    }
1771    let year: i32 = parts[0].parse().ok()?;
1772    let month: u32 = parts[1].parse().ok()?;
1773    let day: u32 = parts[2].parse().ok()?;
1774
1775    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
1776        return None;
1777    }
1778
1779    let mut total_days: i64 = 0;
1780    for y in 1970..year {
1781        total_days += if is_leap_year(y) { 366 } else { 365 };
1782    }
1783    let month_days = [
1784        31,
1785        if is_leap_year(year) { 29 } else { 28 },
1786        31,
1787        30,
1788        31,
1789        30,
1790        31,
1791        31,
1792        30,
1793        31,
1794        30,
1795        31,
1796    ];
1797    for &md in month_days.iter().take(month as usize - 1) {
1798        total_days += md as i64;
1799    }
1800    total_days += (day as i64) - 1;
1801
1802    if total_days < 0 {
1803        return None;
1804    }
1805    let utc_secs = total_days * 86400;
1806
1807    // 根据市场获取时区偏移(C++: GetTimeZoneByAPIQotMkt)
1808    let tz_offset_hours: i64 = market_timezone_offset(market);
1809    let ts = utc_secs - tz_offset_hours * 3600;
1810    if ts < 0 {
1811        return None;
1812    }
1813    Some(ts as u64)
1814}
1815
1816/// 获取市场时区偏移(小时)。C++ GetTimeZoneByAPIQotMkt。
1817fn market_timezone_offset(market: i32) -> i64 {
1818    match market {
1819        // QotMarket_HK_Security = 1, QotMarket_HK_Future = 2
1820        1 | 2 => 8,
1821        // QotMarket_US_Security = 11
1822        11 => -5,
1823        // QotMarket_CNSH_Security = 21, QotMarket_CNSZ_Security = 22
1824        21 | 22 => 8,
1825        // QotMarket_SG_Security = 31
1826        31 => 8,
1827        // QotMarket_JP_Security = 41
1828        41 => 9,
1829        // QotMarket_AU_Security = 51
1830        51 => 10,
1831        // 默认东八区
1832        _ => 8,
1833    }
1834}
1835
1836fn is_leap_year(y: i32) -> bool {
1837    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
1838}
1839
1840/// Unix 时间戳 → "yyyy-MM-dd HH:mm:ss" 字符串(东八区)
1841fn timestamp_to_datetime_str(ts: u64) -> String {
1842    // 所有行情时间戳需要转换为东八区(HK/SH/SZ 市场所在时区)
1843    let (year, month, day, hour, minute, second) = timestamp_to_components(ts + 8 * 3600);
1844    format!(
1845        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
1846        year, month, day, hour, minute, second
1847    )
1848}
1849
1850fn timestamp_to_date_str(ts: u64) -> String {
1851    let (year, month, day, _, _, _) = timestamp_to_components(ts + 8 * 3600);
1852    format!("{:04}-{:02}-{:02}", year, month, day)
1853}
1854
1855/// 解析 "YYYY-MM-DD" 日期字符串为 Unix 时间戳(东八区 0:00:00)
1856fn parse_date_to_timestamp(s: &str) -> Option<i64> {
1857    let parts: Vec<&str> = s.split('-').collect();
1858    if parts.len() != 3 {
1859        return None;
1860    }
1861    let y: i64 = parts[0].parse().ok()?;
1862    let m: i64 = parts[1].parse().ok()?;
1863    let d: i64 = parts[2].parse().ok()?;
1864    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1865        return None;
1866    }
1867    // 简化的日期→天数算法 (Rata Die)
1868    let (y2, m2) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
1869    let days = 365 * y2 + y2 / 4 - y2 / 100 + y2 / 400 + (m2 * 306 + 5) / 10 + d - 1 - 719468;
1870    Some(days * 86400 - 8 * 3600) // 东八区 0:00:00 → UTC
1871}
1872
1873/// 后端 ExpirationType → FTAPI ExpirationCycle 映射
1874/// C++ ExpirationCycle_NNToAPI
1875fn backend_expiration_to_api(backend_type: u32) -> i32 {
1876    match backend_type {
1877        0 => 2,   // Month → ExpirationCycle_Month
1878        1 => 1,   // Week → ExpirationCycle_Week
1879        2 => 3,   // MonthEnd → ExpirationCycle_MonthEnd
1880        3 => 4,   // Quarter → ExpirationCycle_Quarter
1881        11 => 11, // Week_Mon → ExpirationCycle_WeekMon
1882        12 => 12, // Week_Tue → ExpirationCycle_WeekTue
1883        13 => 13, // Week_Wed → ExpirationCycle_WeekWed
1884        14 => 14, // Week_Thu → ExpirationCycle_WeekThu
1885        15 => 15, // Week_Fri → ExpirationCycle_WeekFri
1886        _ => 0,   // Unknown
1887    }
1888}
1889
1890/// Unix 时间戳(UTC)→ (year, month, day, hour, minute, second)
1891fn timestamp_to_components(ts: u64) -> (i32, u32, u32, u32, u32, u32) {
1892    let secs = ts as i64;
1893    let second = ((secs % 60) + 60) as u32 % 60;
1894    let minute = (((secs / 60) % 60) + 60) as u32 % 60;
1895    let hour = (((secs / 3600) % 24) + 24) as u32 % 24;
1896
1897    let mut days = secs / 86400;
1898    let mut year = 1970_i32;
1899
1900    loop {
1901        let days_in_year: i64 = if is_leap_year(year) { 366 } else { 365 };
1902        if days < days_in_year {
1903            break;
1904        }
1905        days -= days_in_year;
1906        year += 1;
1907    }
1908
1909    let month_days = [
1910        31,
1911        if is_leap_year(year) { 29 } else { 28 },
1912        31,
1913        30,
1914        31,
1915        30,
1916        31,
1917        31,
1918        30,
1919        31,
1920        30,
1921        31,
1922    ];
1923    let mut month = 1_u32;
1924    for &md in &month_days {
1925        if days < md as i64 {
1926            break;
1927        }
1928        days -= md as i64;
1929        month += 1;
1930    }
1931    let day = days as u32 + 1;
1932
1933    (year, month, day, hour, minute, second)
1934}
1935
1936/// 将 backend KlineItem 转换为 FTAPI KLine
1937fn kline_item_to_ftapi(
1938    item: &futu_backend::proto_internal::ft_cmd_kline::KlineItem,
1939) -> futu_proto::qot_common::KLine {
1940    let close_raw = item.close_price.unwrap_or(0);
1941    let open_raw = item.open_price.unwrap_or(0);
1942    let high_raw = item.highest_price.unwrap_or(0);
1943    let low_raw = item.lowest_price.unwrap_or(0);
1944    let last_close_raw = item.last_close_price.unwrap_or(0);
1945
1946    let divisor = 1_000_000_000.0_f64;
1947
1948    let close_price = close_raw as f64 / divisor;
1949    let open_price = open_raw as f64 / divisor;
1950    let high_price = high_raw as f64 / divisor;
1951    let low_price = low_raw as f64 / divisor;
1952    let last_close_price = last_close_raw as f64 / divisor;
1953
1954    let turnover = item.turnover.unwrap_or(0) as f64 / 1_000.0;
1955    let turnover_rate = item.turnover_rate.unwrap_or(0) as f64 / 100_000.0;
1956    let pe = item.pe.unwrap_or(0) as f64 / 1_000.0;
1957    let volume = item.volume.unwrap_or(0) as i64;
1958    let timestamp = item.time.unwrap_or(0) as f64;
1959
1960    // change_rate = (close - last_close) / last_close * 100
1961    let change_rate = if last_close_raw != 0 {
1962        (close_raw - last_close_raw) as f64 / last_close_raw as f64 * 100.0
1963    } else {
1964        0.0
1965    };
1966
1967    // C++ 用 has_close_price() 判断是否空白K线,而非检查值是否为0
1968    let is_blank = item.close_price.is_none();
1969
1970    // Format time as string from timestamp (seconds), using the timezone from the item
1971    let ts = item.time.unwrap_or(0);
1972    let tz_offset = item.time_zone.unwrap_or(0) as i64;
1973    let time_str = format_timestamp_with_tz(ts, tz_offset);
1974
1975    if is_blank {
1976        // C++ KL_NNToAPI: blank entry only has time/is_blank/timestamp
1977        return futu_proto::qot_common::KLine {
1978            time: time_str,
1979            is_blank: true,
1980            high_price: None,
1981            open_price: None,
1982            low_price: None,
1983            close_price: None,
1984            last_close_price: None,
1985            volume: None,
1986            turnover: None,
1987            turnover_rate: None,
1988            pe: None,
1989            change_rate: None,
1990            timestamp: Some(timestamp),
1991        };
1992    }
1993
1994    futu_proto::qot_common::KLine {
1995        time: time_str,
1996        is_blank: false,
1997        high_price: Some(high_price),
1998        open_price: Some(open_price),
1999        low_price: Some(low_price),
2000        close_price: Some(close_price),
2001        last_close_price: Some(last_close_price),
2002        volume: Some(volume),
2003        turnover: Some(turnover),
2004        turnover_rate: Some(turnover_rate),
2005        pe: Some(pe),
2006        change_rate: Some(change_rate),
2007        timestamp: Some(timestamp),
2008    }
2009}
2010
2011/// 格式化 Unix 时间戳为 "yyyy-MM-dd HH:mm:ss",使用指定时区偏移(小时)
2012fn format_timestamp_with_tz(ts: u64, tz_hours: i64) -> String {
2013    let local_ts = ts as i64 + tz_hours * 3600;
2014    format_timestamp(local_ts as u64)
2015}
2016
2017/// 格式化 Unix 时间戳为 "yyyy-MM-dd HH:mm:ss" (UTC)
2018fn format_timestamp(ts: u64) -> String {
2019    let secs = ts as i64;
2020    let days = secs.div_euclid(86400);
2021    let day_secs = secs.rem_euclid(86400);
2022    let hours = day_secs / 3600;
2023    let minutes = (day_secs % 3600) / 60;
2024    let seconds = day_secs % 60;
2025
2026    // Convert days since epoch to date
2027    let mut remaining_days = days;
2028    let mut year = 1970_i32;
2029
2030    loop {
2031        let days_in_year: i64 = if is_leap_year(year) { 366 } else { 365 };
2032        if remaining_days < days_in_year {
2033            break;
2034        }
2035        remaining_days -= days_in_year;
2036        year += 1;
2037    }
2038
2039    let month_days = [
2040        31,
2041        if is_leap_year(year) { 29 } else { 28 },
2042        31,
2043        30,
2044        31,
2045        30,
2046        31,
2047        31,
2048        30,
2049        31,
2050        30,
2051        31,
2052    ];
2053    let mut month = 0_usize;
2054    while month < 12 && remaining_days >= month_days[month] as i64 {
2055        remaining_days -= month_days[month] as i64;
2056        month += 1;
2057    }
2058    let day = remaining_days + 1;
2059
2060    format!(
2061        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
2062        year,
2063        month + 1,
2064        day,
2065        hours,
2066        minutes,
2067        seconds
2068    )
2069}
2070
2071#[async_trait]
2072impl RequestHandler for RequestHistoryKLHandler {
2073    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
2074        let req: futu_proto::qot_request_history_kl::Request =
2075            prost::Message::decode(request.body.as_ref()).ok()?;
2076        let c2s = &req.c2s;
2077
2078        let backend = match super::load_backend(&self.backend) {
2079            Some(b) => b,
2080            None => {
2081                tracing::warn!(conn_id, "RequestHistoryKL: no backend connection");
2082                return Some(super::make_error_response(-1, "no backend connection"));
2083            }
2084        };
2085
2086        // Look up stock_id from static data cache
2087        let sec_key = format!("{}_{}", c2s.security.market, c2s.security.code);
2088        let stock_id = match self.static_cache.get_security_info(&sec_key) {
2089            Some(info) if info.stock_id > 0 => info.stock_id,
2090            _ => {
2091                tracing::warn!(conn_id, sec_key, "RequestHistoryKL: stock_id not found");
2092                return Some(super::make_error_response(
2093                    -1,
2094                    "security not found in cache",
2095                ));
2096            }
2097        };
2098
2099        // Convert FTAPI KLType → backend KlineType
2100        let backend_kl_type = match ftapi_kl_type_to_backend(c2s.kl_type) {
2101            Some(v) => v,
2102            None => {
2103                tracing::warn!(
2104                    conn_id,
2105                    kl_type = c2s.kl_type,
2106                    "RequestHistoryKL: invalid kl_type"
2107                );
2108                return Some(super::make_error_response(-1, "invalid kl_type"));
2109            }
2110        };
2111
2112        // RehabType → ExrightType (same values: 0=None, 1=Forward, 2=Backward)
2113        let exright_type = c2s.rehab_type as u32;
2114
2115        // Convert begin_time / end_time from date string to timestamp (with market timezone)
2116        let qot_market = c2s.security.market;
2117        let begin_ts = date_str_to_timestamp(&c2s.begin_time, qot_market).unwrap_or(0);
2118        // end_time: use u64::MAX-like sentinel if empty, or parse
2119        let end_ts = if c2s.end_time.is_empty() {
2120            u64::MAX
2121        } else {
2122            // Add 86400-1 to include the end day fully
2123            date_str_to_timestamp(&c2s.end_time, qot_market)
2124                .map(|t| t + 86399)
2125                .unwrap_or(u64::MAX)
2126        };
2127
2128        // C++ always uses data_range_type=1 (BEGIN_TIME + END_TIME)
2129        // max_ack_kl_num is applied locally after receiving data
2130        let max_kl_num = c2s.max_ack_kl_num.unwrap_or(0) as usize;
2131
2132        // Build KlineReq
2133        let kline_req = futu_backend::proto_internal::ft_cmd_kline::KlineReq {
2134            security_id: Some(stock_id),
2135            kline_type: Some(backend_kl_type),
2136            exright_type: Some(exright_type),
2137            data_set_type: Some(0),   // DATA_SET_TYPE_KLINE
2138            data_range_type: Some(1), // always BEGIN_TIME + END_TIME
2139            begin_time: Some(begin_ts),
2140            end_time: Some(end_ts),
2141            item_count: None,
2142            end_time_offset: None,
2143        };
2144
2145        let body = prost::Message::encode_to_vec(&kline_req);
2146
2147        tracing::debug!(
2148            conn_id,
2149            stock_id,
2150            kl_type = backend_kl_type,
2151            exright = exright_type,
2152            body_len = body.len(),
2153            "sending CMD6161 KlineReq"
2154        );
2155
2156        // Send CMD 6161 (unencrypted)
2157        let resp_frame = match backend.request(6161, body).await {
2158            Ok(f) => f,
2159            Err(e) => {
2160                tracing::error!(conn_id, error = %e, "CMD6161 request failed");
2161                return Some(super::make_error_response(-1, "backend request failed"));
2162            }
2163        };
2164
2165        // Parse KlineRsp (raw protobuf, no OMBinSrz prefix)
2166        let kline_rsp: futu_backend::proto_internal::ft_cmd_kline::KlineRsp =
2167            match prost::Message::decode(resp_frame.body.as_ref()) {
2168                Ok(r) => r,
2169                Err(e) => {
2170                    tracing::error!(conn_id, error = %e, body_len = resp_frame.body.len(), "CMD6161 decode failed");
2171                    return Some(super::make_error_response(
2172                        -1,
2173                        "backend response decode failed",
2174                    ));
2175                }
2176            };
2177
2178        let result = kline_rsp.result.unwrap_or(-1);
2179        if result != 0 {
2180            tracing::warn!(conn_id, result, "CMD6161 returned error");
2181            return Some(super::make_error_response(
2182                -1,
2183                "backend kline request failed",
2184            ));
2185        }
2186
2187        // Convert KlineItem list → FTAPI KLine list
2188        // Apply max_ack_kl_num locally (C++ SetMaxPointCount + MergeAllChildItem)
2189        let mut kl_list: Vec<futu_proto::qot_common::KLine> = kline_rsp
2190            .kline_item_list
2191            .iter()
2192            .map(kline_item_to_ftapi)
2193            .collect();
2194        if max_kl_num > 0 && kl_list.len() > max_kl_num {
2195            kl_list.truncate(max_kl_num);
2196        }
2197
2198        tracing::debug!(
2199            conn_id,
2200            count = kl_list.len(),
2201            "RequestHistoryKL returning klines"
2202        );
2203
2204        // 获取股票名称 (C++: GetStockName)
2205        let stock_name = self
2206            .static_cache
2207            .get_security_info(&sec_key)
2208            .map(|info| info.name.clone());
2209
2210        // 递增 KL 请求计数(用于 GetUsedQuota)
2211        self.kl_quota_counter
2212            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2213
2214        let resp = futu_proto::qot_request_history_kl::Response {
2215            ret_type: 0,
2216            ret_msg: None,
2217            err_code: None,
2218            s2c: Some(futu_proto::qot_request_history_kl::S2c {
2219                security: c2s.security.clone(),
2220                name: stock_name,
2221                kl_list,
2222                next_req_key: None,
2223            }),
2224        };
2225        Some(prost::Message::encode_to_vec(&resp))
2226    }
2227}
2228
2229// ===== RequestTradeDate (CMD 6733) =====
2230struct RequestTradeDateHandler {
2231    backend: crate::bridge::SharedBackend,
2232}
2233
2234/// FTAPI QotMarket → backend market_id
2235fn ftapi_market_to_backend(market: i32) -> Option<u32> {
2236    match market {
2237        1 => Some(1),   // HK
2238        11 => Some(10), // US
2239        21 => Some(30), // CNSH
2240        22 => Some(31), // CNSZ
2241        _ => None,
2242    }
2243}
2244
2245/// Parse "yyyy-MM-dd" → YYYYMMDD u32 (e.g. "2024-01-15" → 20240115)
2246fn date_str_to_yyyymmdd(s: &str) -> Option<u32> {
2247    // 兼容 "yyyy-MM-dd" 和 "yyyy-MM-dd HH:mm:ss" 格式
2248    let date_part = s.split(' ').next().unwrap_or(s);
2249    let parts: Vec<&str> = date_part.split('-').collect();
2250    if parts.len() != 3 {
2251        return None;
2252    }
2253    let year: u32 = parts[0].parse().ok()?;
2254    let month: u32 = parts[1].parse().ok()?;
2255    let day: u32 = parts[2].parse().ok()?;
2256    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
2257        return None;
2258    }
2259    Some(year * 10000 + month * 100 + day)
2260}
2261
2262/// YYYYMMDD u32 → "yyyy-MM-dd" string
2263fn yyyymmdd_to_date_str(v: u32) -> String {
2264    let year = v / 10000;
2265    let month = (v % 10000) / 100;
2266    let day = v % 100;
2267    format!("{:04}-{:02}-{:02}", year, month, day)
2268}
2269
2270/// Backend DayInfo.trading_type → FTAPI TradeDateType
2271/// NOT_CLOSE(4)→0, CLOSE_WHOLE(0)→1, CLOSE_AM(1)→2, CLOSE_PM(2)→3
2272fn backend_trading_type_to_ftapi(trading_type: u32) -> i32 {
2273    match trading_type {
2274        4 => 0, // NOT_CLOSE → 全天交易
2275        0 => 1, // CLOSE_WHOLE → 全天休市
2276        1 => 2, // CLOSE_AM → 上午休市
2277        2 => 3, // CLOSE_PM → 下午休市
2278        _ => 0, // default to 全天交易
2279    }
2280}
2281
2282#[async_trait]
2283impl RequestHandler for RequestTradeDateHandler {
2284    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
2285        let req: futu_proto::qot_request_trade_date::Request =
2286            prost::Message::decode(request.body.as_ref()).ok()?;
2287        let c2s = &req.c2s;
2288
2289        let backend = match super::load_backend(&self.backend) {
2290            Some(b) => b,
2291            None => {
2292                tracing::warn!(conn_id, "RequestTradeDate: no backend connection");
2293                return Some(super::make_error_response(-1, "no backend connection"));
2294            }
2295        };
2296
2297        let backend_market = match ftapi_market_to_backend(c2s.market) {
2298            Some(m) => m,
2299            None => {
2300                tracing::warn!(
2301                    conn_id,
2302                    market = c2s.market,
2303                    "RequestTradeDate: unsupported market"
2304                );
2305                return Some(super::make_error_response(-1, "unsupported market"));
2306            }
2307        };
2308
2309        let begin_key = match date_str_to_yyyymmdd(&c2s.begin_time) {
2310            Some(v) => v,
2311            None => {
2312                tracing::warn!(conn_id, begin = %c2s.begin_time, "RequestTradeDate: invalid begin_time");
2313                return Some(super::make_error_response(-1, "invalid begin_time"));
2314            }
2315        };
2316
2317        let end_key = match date_str_to_yyyymmdd(&c2s.end_time) {
2318            Some(v) => v,
2319            None => {
2320                tracing::warn!(conn_id, end = %c2s.end_time, "RequestTradeDate: invalid end_time");
2321                return Some(super::make_error_response(-1, "invalid end_time"));
2322            }
2323        };
2324
2325        let range_req = futu_backend::proto_internal::market_trading_day::RangeTradingDayReq {
2326            begin_date_key: Some(begin_key),
2327            end_date_key: Some(end_key),
2328            market_id: Some(backend_market),
2329        };
2330
2331        let body = prost::Message::encode_to_vec(&range_req);
2332
2333        tracing::debug!(
2334            conn_id,
2335            backend_market,
2336            begin_key,
2337            end_key,
2338            "sending CMD6733 RangeTradingDayReq"
2339        );
2340
2341        let resp_frame = match backend.request(6733, body).await {
2342            Ok(f) => f,
2343            Err(e) => {
2344                tracing::error!(conn_id, error = %e, "CMD6733 request failed");
2345                return Some(super::make_error_response(-1, "backend request failed"));
2346            }
2347        };
2348
2349        let range_rsp: futu_backend::proto_internal::market_trading_day::RangeTradingDayRsp =
2350            match prost::Message::decode(resp_frame.body.as_ref()) {
2351                Ok(r) => r,
2352                Err(e) => {
2353                    tracing::error!(conn_id, error = %e, "CMD6733 decode failed");
2354                    return Some(super::make_error_response(
2355                        -1,
2356                        "backend response decode failed",
2357                    ));
2358                }
2359            };
2360
2361        let code = range_rsp.code.unwrap_or(-1);
2362        if code != 0 {
2363            tracing::warn!(conn_id, code, "CMD6733 returned error");
2364            return Some(super::make_error_response(
2365                -1,
2366                "backend trade date request failed",
2367            ));
2368        }
2369
2370        // Convert DayInfo list → FTAPI TradeDate list
2371        let trade_date_list: Vec<futu_proto::qot_request_trade_date::TradeDate> = range_rsp
2372            .day_infos
2373            .iter()
2374            .map(|day| {
2375                let time_date = day.time_date.unwrap_or(0);
2376                let time_str = yyyymmdd_to_date_str(time_date);
2377                let trading_type = day.trading_type.unwrap_or(4); // default NOT_CLOSE
2378                let trade_date_type = backend_trading_type_to_ftapi(trading_type);
2379                // timestamp from date_key (seconds since epoch)
2380                let ts = day.date_key.unwrap_or(0) as f64;
2381
2382                futu_proto::qot_request_trade_date::TradeDate {
2383                    time: time_str,
2384                    timestamp: Some(ts),
2385                    trade_date_type: Some(trade_date_type),
2386                }
2387            })
2388            .collect();
2389
2390        tracing::debug!(
2391            conn_id,
2392            count = trade_date_list.len(),
2393            "RequestTradeDate returning trade dates"
2394        );
2395
2396        let resp = futu_proto::qot_request_trade_date::Response {
2397            ret_type: 0,
2398            ret_msg: None,
2399            err_code: None,
2400            s2c: Some(futu_proto::qot_request_trade_date::S2c { trade_date_list }),
2401        };
2402        Some(prost::Message::encode_to_vec(&resp))
2403    }
2404}
2405
2406// ===== GetRehab (CMD 6811) =====
2407struct GetRehabHandler {
2408    backend: crate::bridge::SharedBackend,
2409    static_cache: Arc<StaticDataCache>,
2410}
2411
2412#[async_trait]
2413impl RequestHandler for GetRehabHandler {
2414    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
2415        let req: futu_proto::qot_request_rehab::Request =
2416            prost::Message::decode(request.body.as_ref()).ok()?;
2417        let c2s = &req.c2s;
2418
2419        let backend = match super::load_backend(&self.backend) {
2420            Some(b) => b,
2421            None => {
2422                tracing::warn!(conn_id, "GetRehab: no backend connection");
2423                return Some(super::make_error_response(-1, "no backend connection"));
2424            }
2425        };
2426
2427        // Look up stock_id from static data cache
2428        let sec_key = format!("{}_{}", c2s.security.market, c2s.security.code);
2429        let stock_id = match self.static_cache.get_security_info(&sec_key) {
2430            Some(info) if info.stock_id > 0 => info.stock_id,
2431            _ => {
2432                tracing::warn!(conn_id, sec_key, "GetRehab: stock_id not found");
2433                return Some(super::make_error_response(
2434                    -1,
2435                    "security not found in cache",
2436                ));
2437            }
2438        };
2439
2440        let exright_req = futu_backend::proto_internal::ft_cmd6811::ExrightFactorReq {
2441            stock_id: Some(stock_id),
2442            sequence: Some(0),
2443        };
2444
2445        let body = prost::Message::encode_to_vec(&exright_req);
2446
2447        tracing::debug!(conn_id, stock_id, "sending CMD6811 ExrightFactorReq");
2448
2449        let resp_frame = match backend.request(6811, body).await {
2450            Ok(f) => f,
2451            Err(e) => {
2452                tracing::error!(conn_id, error = %e, "CMD6811 request failed");
2453                return Some(super::make_error_response(-1, "backend request failed"));
2454            }
2455        };
2456
2457        let exright_rsp: futu_backend::proto_internal::ft_cmd6811::ExrightFactorRsp =
2458            match prost::Message::decode(resp_frame.body.as_ref()) {
2459                Ok(r) => r,
2460                Err(e) => {
2461                    tracing::error!(conn_id, error = %e, "CMD6811 decode failed");
2462                    return Some(super::make_error_response(
2463                        -1,
2464                        "backend response decode failed",
2465                    ));
2466                }
2467            };
2468
2469        let result = exright_rsp.result.unwrap_or(-1);
2470        // result 0 = has updates, 1 = no updates needed; both are success
2471        if result != 0 && result != 1 {
2472            tracing::warn!(conn_id, result, "CMD6811 returned error");
2473            return Some(super::make_error_response(
2474                -1,
2475                "backend rehab request failed",
2476            ));
2477        }
2478
2479        // Convert ExrightFactorItem → FTAPI Rehab
2480        let rehab_list: Vec<futu_proto::qot_common::Rehab> = exright_rsp
2481            .exs
2482            .iter()
2483            .map(|item| {
2484                let ex_ts = item.ex_time.unwrap_or(0);
2485                let time_str = timestamp_to_date_str(ex_ts as u64);
2486
2487                // companyActFlag: bitmask of which optional fields are present
2488                let mut flag: i64 = 0;
2489                if item.split_base.is_some() {
2490                    flag |= 1;
2491                }
2492                if item.join_base.is_some() {
2493                    flag |= 2;
2494                }
2495                if item.bonus_base.is_some() {
2496                    flag |= 4;
2497                }
2498                if item.transfer_base.is_some() {
2499                    flag |= 8;
2500                }
2501                if item.allot_base.is_some() {
2502                    flag |= 16;
2503                }
2504                if item.add_base.is_some() {
2505                    flag |= 32;
2506                }
2507                if item.dividend_amount.is_some() {
2508                    flag |= 64;
2509                }
2510                if item.sp_dividend_amount.is_some() {
2511                    flag |= 128;
2512                }
2513
2514                let divisor = 100_000.0;
2515
2516                futu_proto::qot_common::Rehab {
2517                    time: time_str,
2518                    company_act_flag: flag,
2519                    fwd_factor_a: item.origin_fwd_a.unwrap_or(100_000.0) / divisor,
2520                    fwd_factor_b: item.origin_fwd_b.unwrap_or(0.0) / divisor,
2521                    bwd_factor_a: item.origin_bwd_a.unwrap_or(100_000.0) / divisor,
2522                    bwd_factor_b: item.origin_bwd_b.unwrap_or(0.0) / divisor,
2523                    split_base: item.split_base.map(|v| v as i32),
2524                    split_ert: item.split_ert.map(|v| v as i32),
2525                    join_base: item.join_base.map(|v| v as i32),
2526                    join_ert: item.join_ert.map(|v| v as i32),
2527                    bonus_base: item.bonus_base.map(|v| v as i32),
2528                    bonus_ert: item.bonus_ert.map(|v| v as i32),
2529                    transfer_base: item.transfer_base.map(|v| v as i32),
2530                    transfer_ert: item.transfer_ert.map(|v| v as i32),
2531                    allot_base: item.allot_base.map(|v| v as i32),
2532                    allot_ert: item.allot_ert.map(|v| v as i32),
2533                    allot_price: item.allot_price.map(|v| v as f64 / divisor),
2534                    add_base: item.add_base.map(|v| v as i32),
2535                    add_ert: item.add_ert.map(|v| v as i32),
2536                    add_price: item.add_price.map(|v| v as f64 / divisor),
2537                    dividend: item.dividend_amount.map(|v| v as f64 / divisor),
2538                    sp_dividend: item.sp_dividend_amount.map(|v| v as f64 / divisor),
2539                    timestamp: Some(ex_ts as f64),
2540                    spin_off_base: item.spin_off_base.map(|v| v as f64),
2541                    spin_off_ert: item.spin_off_ert.map(|v| v as f64),
2542                }
2543            })
2544            .collect();
2545
2546        tracing::debug!(
2547            conn_id,
2548            count = rehab_list.len(),
2549            "GetRehab returning rehab items"
2550        );
2551
2552        let resp = futu_proto::qot_request_rehab::Response {
2553            ret_type: 0,
2554            ret_msg: None,
2555            err_code: None,
2556            s2c: Some(futu_proto::qot_request_rehab::S2c { rehab_list }),
2557        };
2558        Some(prost::Message::encode_to_vec(&resp))
2559    }
2560}
2561
2562// ===== GetPlateSet (CMD 6600) =====
2563struct GetPlateSetHandler {
2564    backend: crate::bridge::SharedBackend,
2565    static_cache: Arc<StaticDataCache>,
2566}
2567
2568/// Map FTAPI market + plateSetType → backend plate_set_id
2569fn map_plate_set_id(market: i32, plate_set_type: i32) -> Option<u64> {
2570    match (market, plate_set_type) {
2571        // HK
2572        (1, 0) => Some(9700009), // All
2573        (1, 1) => Some(9700000), // Industry
2574        (1, 2) => Some(9700008), // Region → Other
2575        (1, 3) => Some(9700001), // Concept
2576        (1, 4) => Some(9700008), // Other
2577        // US
2578        (11, 0) => Some(9700309), // All
2579        (11, 1) => Some(9700300), // Industry
2580        (11, 2) => Some(9700308), // Region → Other
2581        (11, 3) => Some(9700301), // Concept
2582        (11, 4) => Some(9700308), // Other
2583        // CN (Shanghai + Shenzhen share same plate sets)
2584        (21 | 22, 0) => Some(9700609), // All
2585        (21 | 22, 1) => Some(9700600), // Industry
2586        (21 | 22, 2) => Some(9700602), // Region
2587        (21 | 22, 3) => Some(9700601), // Concept
2588        (21 | 22, 4) => Some(9700608), // Other
2589        _ => None,
2590    }
2591}
2592
2593#[async_trait]
2594impl RequestHandler for GetPlateSetHandler {
2595    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
2596        let req: futu_proto::qot_get_plate_set::Request =
2597            prost::Message::decode(request.body.as_ref()).ok()?;
2598        let c2s = &req.c2s;
2599
2600        let backend = match super::load_backend(&self.backend) {
2601            Some(b) => b,
2602            None => {
2603                tracing::warn!(conn_id, "GetPlateSet: no backend connection");
2604                return Some(super::make_error_response(-1, "no backend connection"));
2605            }
2606        };
2607
2608        let plate_set_id = match map_plate_set_id(c2s.market, c2s.plate_set_type) {
2609            Some(id) => id,
2610            None => {
2611                tracing::warn!(
2612                    conn_id,
2613                    market = c2s.market,
2614                    plate_set_type = c2s.plate_set_type,
2615                    "GetPlateSet: unsupported market/plateSetType"
2616                );
2617                return Some(super::make_error_response(
2618                    -1,
2619                    "unsupported market or plateSetType",
2620                ));
2621            }
2622        };
2623
2624        // Send CMD 6600 PlateListIDs_Req to get plate IDs in this set
2625        // C++ uses PullPlateOrSetID(nPlateSetID, SortMode_Code=100, SortDir_Asc=1, 0, 99999)
2626        let plate_req = futu_backend::proto_internal::ft_cmd_plate::PlateListIDsReq {
2627            plate_id: plate_set_id,
2628            sort_type: 1, // ascending
2629            sort_id: 100, // sort by code
2630            data_from: Some(0),
2631            data_max_count: Some(99999),
2632            check_code: Some(0),
2633        };
2634        let body = prost::Message::encode_to_vec(&plate_req);
2635
2636        tracing::debug!(
2637            conn_id,
2638            plate_set_id,
2639            "sending CMD6600 PlateListIDsReq for plate set"
2640        );
2641
2642        let resp_frame = match backend.request(6600, body).await {
2643            Ok(f) => f,
2644            Err(e) => {
2645                tracing::error!(conn_id, error = %e, "CMD6600 request failed");
2646                return Some(super::make_error_response(-1, "backend request failed"));
2647            }
2648        };
2649
2650        let plate_rsp: futu_backend::proto_internal::ft_cmd_plate::PlateListIDsRsp =
2651            match prost::Message::decode(resp_frame.body.as_ref()) {
2652                Ok(r) => r,
2653                Err(e) => {
2654                    tracing::error!(conn_id, error = %e, "CMD6600 decode failed");
2655                    return Some(super::make_error_response(
2656                        -1,
2657                        "backend response decode failed",
2658                    ));
2659                }
2660            };
2661
2662        if plate_rsp.result != 0 {
2663            tracing::warn!(
2664                conn_id,
2665                result = plate_rsp.result,
2666                "CMD6600 returned error for plate set"
2667            );
2668            return Some(super::make_error_response(
2669                -1,
2670                "backend plate set request failed",
2671            ));
2672        }
2673
2674        // Convert plate IDs to FTAPI PlateInfo
2675        // C++ does: SearchSecByID(plateID) → szCode, szNameZhCn
2676        // We use id_to_key → get_security_info to do the same
2677        let plate_info_list: Vec<futu_proto::qot_common::PlateInfo> = plate_rsp
2678            .arry_items
2679            .iter()
2680            .filter_map(|&pid| {
2681                // Look up plate code and name from static cache via stock_id
2682                let key = self.static_cache.id_to_key.get(&pid);
2683                if key.is_none() {
2684                    tracing::warn!(
2685                        conn_id,
2686                        plate_id = pid,
2687                        "plate_id not found in id_to_key cache"
2688                    );
2689                    return None;
2690                }
2691                let key = key.unwrap();
2692                let info = self.static_cache.get_security_info(&key)?;
2693
2694                Some(futu_proto::qot_common::PlateInfo {
2695                    plate: futu_proto::qot_common::Security {
2696                        market: info.market,
2697                        code: info.code.clone(),
2698                    },
2699                    name: info.name.clone(),
2700                    plate_type: None,
2701                })
2702            })
2703            .collect();
2704
2705        tracing::debug!(
2706            conn_id,
2707            count = plate_info_list.len(),
2708            "GetPlateSet returning plates"
2709        );
2710
2711        let resp = futu_proto::qot_get_plate_set::Response {
2712            ret_type: 0,
2713            ret_msg: None,
2714            err_code: None,
2715            s2c: Some(futu_proto::qot_get_plate_set::S2c { plate_info_list }),
2716        };
2717        Some(prost::Message::encode_to_vec(&resp))
2718    }
2719}
2720
2721// ===== GetPlateSecurity (CMD 6600) =====
2722struct GetPlateSecurityHandler {
2723    backend: crate::bridge::SharedBackend,
2724    static_cache: Arc<StaticDataCache>,
2725}
2726
2727#[async_trait]
2728impl RequestHandler for GetPlateSecurityHandler {
2729    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
2730        let req: futu_proto::qot_get_plate_security::Request =
2731            prost::Message::decode(request.body.as_ref()).ok()?;
2732        let c2s = &req.c2s;
2733
2734        let backend = match super::load_backend(&self.backend) {
2735            Some(b) => b,
2736            None => {
2737                tracing::warn!(conn_id, "GetPlateSecurity: no backend connection");
2738                return Some(super::make_error_response(-1, "no backend connection"));
2739            }
2740        };
2741
2742        // plate.code is a symbolic code like "LIST1001" / "GangGuTong" / "MotherBoard",
2743        // NOT a numeric plate_id. 对齐 C++ OpenD (APIServer_Inner_API.cpp::GetStockIDInternal):
2744        // 查 StaticDataCache 从 (market, code) → stock_id(板块在缓存里是以 security 形式存在)。
2745        let key = futu_cache::qot_cache::make_key(c2s.plate.market, &c2s.plate.code);
2746        let plate_id: u64 = match self.static_cache.get_security_info(&key) {
2747            Some(info) => info.stock_id,
2748            None => {
2749                tracing::warn!(
2750                    conn_id,
2751                    market = c2s.plate.market,
2752                    code = %c2s.plate.code,
2753                    "GetPlateSecurity: plate not found in static cache"
2754                );
2755                return Some(super::make_error_response(-1, "invalid plate code"));
2756            }
2757        };
2758
2759        // Map sort field: default ascending by code (sort_type=0, sort_id=0)
2760        let sort_type = if c2s.ascend.unwrap_or(true) { 0 } else { 1 };
2761        let sort_id = c2s.sort_field.unwrap_or(0);
2762
2763        // Send CMD 6600 PlateListIDsReq to get stock IDs in this plate
2764        let plate_req = futu_backend::proto_internal::ft_cmd_plate::PlateListIDsReq {
2765            plate_id,
2766            sort_type,
2767            sort_id,
2768            data_from: None,
2769            data_max_count: None,
2770            check_code: None,
2771        };
2772        let body = prost::Message::encode_to_vec(&plate_req);
2773
2774        tracing::debug!(conn_id, plate_id, "sending CMD6600 PlateListIDsReq");
2775
2776        let resp_frame = match backend.request(6600, body).await {
2777            Ok(f) => f,
2778            Err(e) => {
2779                tracing::error!(conn_id, error = %e, "CMD6600 request failed");
2780                return Some(super::make_error_response(-1, "backend request failed"));
2781            }
2782        };
2783
2784        let plate_rsp: futu_backend::proto_internal::ft_cmd_plate::PlateListIDsRsp =
2785            match prost::Message::decode(resp_frame.body.as_ref()) {
2786                Ok(r) => r,
2787                Err(e) => {
2788                    tracing::error!(conn_id, error = %e, "CMD6600 decode failed");
2789                    return Some(super::make_error_response(
2790                        -1,
2791                        "backend response decode failed",
2792                    ));
2793                }
2794            };
2795
2796        if plate_rsp.result != 0 {
2797            tracing::warn!(conn_id, result = plate_rsp.result, "CMD6600 returned error");
2798            return Some(super::make_error_response(
2799                -1,
2800                "backend plate request failed",
2801            ));
2802        }
2803
2804        // Look up each stock_id in StaticDataCache to build SecurityStaticInfo
2805        let static_info_list: Vec<futu_proto::qot_common::SecurityStaticInfo> = plate_rsp
2806            .arry_items
2807            .iter()
2808            .filter_map(|&stock_id| {
2809                let key = self.static_cache.id_to_key.get(&stock_id)?;
2810                let info = self.static_cache.get_security_info(key.value())?;
2811                Some(futu_proto::qot_common::SecurityStaticInfo {
2812                    basic: futu_proto::qot_common::SecurityStaticBasic {
2813                        security: futu_proto::qot_common::Security {
2814                            market: info.market,
2815                            code: info.code.clone(),
2816                        },
2817                        id: info.stock_id as i64,
2818                        lot_size: info.lot_size,
2819                        sec_type: info.sec_type,
2820                        name: info.name.clone(),
2821                        list_time: info.list_time.clone(),
2822                        delisting: None,
2823                        list_timestamp: None,
2824                        exch_type: None,
2825                    },
2826                    warrant_ex_data: None,
2827                    option_ex_data: None,
2828                    future_ex_data: None,
2829                })
2830            })
2831            .collect();
2832
2833        tracing::debug!(
2834            conn_id,
2835            count = static_info_list.len(),
2836            "GetPlateSecurity returning securities"
2837        );
2838
2839        let resp = futu_proto::qot_get_plate_security::Response {
2840            ret_type: 0,
2841            ret_msg: None,
2842            err_code: None,
2843            s2c: Some(futu_proto::qot_get_plate_security::S2c { static_info_list }),
2844        };
2845        Some(prost::Message::encode_to_vec(&resp))
2846    }
2847}
2848
2849// ===== GetOwnerPlate (CMD 6608) =====
2850struct GetOwnerPlateHandler {
2851    backend: crate::bridge::SharedBackend,
2852    static_cache: Arc<StaticDataCache>,
2853}
2854
2855#[async_trait]
2856impl RequestHandler for GetOwnerPlateHandler {
2857    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
2858        let req: futu_proto::qot_get_owner_plate::Request =
2859            prost::Message::decode(request.body.as_ref()).ok()?;
2860        let c2s = &req.c2s;
2861
2862        let backend = match super::load_backend(&self.backend) {
2863            Some(b) => b,
2864            None => {
2865                tracing::warn!(conn_id, "GetOwnerPlate: no backend connection");
2866                return Some(super::make_error_response(-1, "no backend connection"));
2867            }
2868        };
2869
2870        // Look up stock_ids from cache
2871        let mut stock_ids: Vec<u64> = Vec::new();
2872        let mut sec_info_map: Vec<(futu_proto::qot_common::Security, u64)> = Vec::new();
2873
2874        for sec in &c2s.security_list {
2875            let sec_key = format!("{}_{}", sec.market, sec.code);
2876            if let Some(info) = self.static_cache.get_security_info(&sec_key) {
2877                if info.stock_id > 0 {
2878                    stock_ids.push(info.stock_id);
2879                    sec_info_map.push((sec.clone(), info.stock_id));
2880                }
2881            }
2882        }
2883
2884        if stock_ids.is_empty() {
2885            // Return empty result
2886            let resp = futu_proto::qot_get_owner_plate::Response {
2887                ret_type: 0,
2888                ret_msg: None,
2889                err_code: None,
2890                s2c: Some(futu_proto::qot_get_owner_plate::S2c {
2891                    owner_plate_list: vec![],
2892                }),
2893            };
2894            return Some(prost::Message::encode_to_vec(&resp));
2895        }
2896
2897        // Send CMD 6608 OwnerPlateReq
2898        let owner_req = futu_backend::proto_internal::ft_cmd_plate::OwnerPlateReq {
2899            stock_id_list: stock_ids,
2900            owner_plate_type: 0, // OPT_ALL
2901            need_plate_quote: None,
2902        };
2903        let body = prost::Message::encode_to_vec(&owner_req);
2904
2905        tracing::debug!(
2906            conn_id,
2907            count = sec_info_map.len(),
2908            "sending CMD6608 OwnerPlateReq"
2909        );
2910
2911        let resp_frame = match backend.request(6608, body).await {
2912            Ok(f) => f,
2913            Err(e) => {
2914                tracing::error!(conn_id, error = %e, "CMD6608 request failed");
2915                return Some(super::make_error_response(-1, "backend request failed"));
2916            }
2917        };
2918
2919        let owner_rsp: futu_backend::proto_internal::ft_cmd_plate::OwnerPlateRsp =
2920            match prost::Message::decode(resp_frame.body.as_ref()) {
2921                Ok(r) => r,
2922                Err(e) => {
2923                    tracing::error!(conn_id, error = %e, "CMD6608 decode failed");
2924                    return Some(super::make_error_response(
2925                        -1,
2926                        "backend response decode failed",
2927                    ));
2928                }
2929            };
2930
2931        if owner_rsp.result != 0 {
2932            tracing::warn!(conn_id, result = owner_rsp.result, "CMD6608 returned error");
2933            return Some(super::make_error_response(
2934                -1,
2935                "backend owner plate request failed",
2936            ));
2937        }
2938
2939        // Convert backend response to FTAPI format
2940        let owner_plate_list: Vec<futu_proto::qot_get_owner_plate::SecurityOwnerPlate> = owner_rsp
2941            .stock_info_list
2942            .iter()
2943            .filter_map(|stock_info| {
2944                // Find the original security for this stock_id
2945                let (sec, _) = sec_info_map
2946                    .iter()
2947                    .find(|(_, sid)| *sid == stock_info.stock_id)?;
2948                let sec_key = format!("{}_{}", sec.market, sec.code);
2949                let cached_info = self.static_cache.get_security_info(&sec_key);
2950
2951                let plate_info_list: Vec<futu_proto::qot_common::PlateInfo> = stock_info
2952                    .plate_info_list
2953                    .iter()
2954                    .filter_map(|pi| {
2955                        // Look up plate code and name from static cache via stock_id
2956                        let key = self.static_cache.id_to_key.get(&pi.plate_id)?;
2957                        let plate_info = self.static_cache.get_security_info(&key)?;
2958
2959                        Some(futu_proto::qot_common::PlateInfo {
2960                            plate: futu_proto::qot_common::Security {
2961                                market: plate_info.market,
2962                                code: plate_info.code.clone(),
2963                            },
2964                            name: plate_info.name.clone(),
2965                            plate_type: pi.owner_set_id.map(|set_id| {
2966                                // C++ GetPlateSetType: map owner_set_id → PlateSetType
2967                                // Trade → Industry(1), Region(2), Concept(3), else → Other(4)
2968                                match set_id {
2969                                    9700000 | 9700300 | 9700600 => 1, // Industry
2970                                    9700602 => 2,                     // Region (CN only)
2971                                    9700001 | 9700301 | 9700601 => 3, // Concept
2972                                    _ => 4,                           // Other (includes All)
2973                                }
2974                            }),
2975                        })
2976                    })
2977                    .collect();
2978
2979                Some(futu_proto::qot_get_owner_plate::SecurityOwnerPlate {
2980                    security: sec.clone(),
2981                    name: cached_info.map(|i| i.name),
2982                    plate_info_list,
2983                })
2984            })
2985            .collect();
2986
2987        tracing::debug!(
2988            conn_id,
2989            count = owner_plate_list.len(),
2990            "GetOwnerPlate returning results"
2991        );
2992
2993        let resp = futu_proto::qot_get_owner_plate::Response {
2994            ret_type: 0,
2995            ret_msg: None,
2996            err_code: None,
2997            s2c: Some(futu_proto::qot_get_owner_plate::S2c { owner_plate_list }),
2998        };
2999        Some(prost::Message::encode_to_vec(&resp))
3000    }
3001}
3002
3003// ===== GetMarketState =====
3004struct GetMarketStateHandler {
3005    backend: crate::bridge::SharedBackend,
3006    static_cache: Arc<StaticDataCache>,
3007}
3008
3009#[async_trait]
3010impl RequestHandler for GetMarketStateHandler {
3011    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
3012        let req: futu_proto::qot_get_market_state::Request =
3013            prost::Message::decode(request.body.as_ref()).ok()?;
3014
3015        let backend = match super::load_backend(&self.backend) {
3016            Some(b) => b,
3017            None => {
3018                tracing::warn!(conn_id, "GetMarketState: no backend connection");
3019                return Some(super::make_error_response(-1, "no backend connection"));
3020            }
3021        };
3022
3023        // Collect unique markets from the request
3024        let mut markets_needed: std::collections::HashSet<i32> = std::collections::HashSet::new();
3025        for sec in &req.c2s.security_list {
3026            markets_needed.insert(sec.market);
3027        }
3028
3029        // Fetch market status from backend for each unique market
3030        // Backend returns MarketStatus with market_id (which is the internal market ID)
3031        // and status (which maps directly to QotMarketState)
3032        let mut market_status_map: std::collections::HashMap<i32, u32> =
3033            std::collections::HashMap::new();
3034        for &qot_market in &markets_needed {
3035            if let Some(mkt) = futu_backend::stock_list::qot_market_to_backend(qot_market) {
3036                match futu_backend::stock_list::pull_single_market_status(&backend, mkt).await {
3037                    Ok(statuses) => {
3038                        // Use the first status entry as the market state
3039                        // (backend may return multiple sub-markets)
3040                        if let Some(first) = statuses.first() {
3041                            market_status_map.insert(qot_market, first.status);
3042                            tracing::debug!(
3043                                conn_id,
3044                                qot_market,
3045                                status = first.status,
3046                                text = %first.status_text,
3047                                "GetMarketState: backend status"
3048                            );
3049                        }
3050                    }
3051                    Err(e) => {
3052                        tracing::warn!(
3053                            conn_id,
3054                            qot_market,
3055                            error = %e,
3056                            "GetMarketState: backend request failed, using default"
3057                        );
3058                    }
3059                }
3060            }
3061        }
3062
3063        let market_info_list: Vec<futu_proto::qot_get_market_state::MarketInfo> = req
3064            .c2s
3065            .security_list
3066            .iter()
3067            .map(|sec| {
3068                let sec_key = format!("{}_{}", sec.market, sec.code);
3069                let name = self
3070                    .static_cache
3071                    .get_security_info(&sec_key)
3072                    .map(|info| info.name)
3073                    .unwrap_or_default();
3074
3075                // Use real status from backend, fallback to QotMarketState_None (0)
3076                let market_state = market_status_map.get(&sec.market).copied().unwrap_or(0) as i32;
3077
3078                futu_proto::qot_get_market_state::MarketInfo {
3079                    security: sec.clone(),
3080                    name,
3081                    market_state,
3082                }
3083            })
3084            .collect();
3085
3086        tracing::debug!(conn_id, count = market_info_list.len(), "GetMarketState");
3087
3088        let resp = futu_proto::qot_get_market_state::Response {
3089            ret_type: 0,
3090            ret_msg: None,
3091            err_code: None,
3092            s2c: Some(futu_proto::qot_get_market_state::S2c { market_info_list }),
3093        };
3094        Some(prost::Message::encode_to_vec(&resp))
3095    }
3096}
3097
3098// ===== GetOptionExpirationDate (CMD 6311) =====
3099struct GetOptionExpirationDateHandler {
3100    backend: crate::bridge::SharedBackend,
3101    static_cache: Arc<StaticDataCache>,
3102}
3103
3104#[async_trait]
3105impl RequestHandler for GetOptionExpirationDateHandler {
3106    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
3107        let req: futu_proto::qot_get_option_expiration_date::Request =
3108            prost::Message::decode(request.body.as_ref()).ok()?;
3109        let c2s = &req.c2s;
3110
3111        let backend = match super::load_backend(&self.backend) {
3112            Some(b) => b,
3113            None => {
3114                tracing::warn!(conn_id, "GetOptionExpirationDate: no backend connection");
3115                return Some(super::make_error_response(-1, "no backend connection"));
3116            }
3117        };
3118
3119        // Resolve owner security to stock_id
3120        let sec_key = format!("{}_{}", c2s.owner.market, c2s.owner.code);
3121        let stock_id = match self.static_cache.get_security_info(&sec_key) {
3122            Some(info) if info.stock_id > 0 => info.stock_id,
3123            _ => {
3124                tracing::warn!(
3125                    conn_id,
3126                    sec_key,
3127                    "GetOptionExpirationDate: stock_id not found"
3128                );
3129                return Some(super::make_error_response(
3130                    -1,
3131                    "security not found in cache",
3132                ));
3133            }
3134        };
3135
3136        // Send CMD 6311 StrikeDateReq
3137        let strike_req = futu_backend::proto_internal::ftcmd_option_chain::StrikeDateReq {
3138            stock_id: Some(stock_id),
3139        };
3140        let body = prost::Message::encode_to_vec(&strike_req);
3141
3142        tracing::debug!(conn_id, stock_id, "sending CMD6311 StrikeDateReq");
3143
3144        let resp_frame = match backend.request(6311, body).await {
3145            Ok(f) => f,
3146            Err(e) => {
3147                tracing::error!(conn_id, error = %e, "CMD6311 request failed");
3148                return Some(super::make_error_response(-1, "backend request failed"));
3149            }
3150        };
3151
3152        let strike_rsp: futu_backend::proto_internal::ftcmd_option_chain::StrikeDateRsp =
3153            match prost::Message::decode(resp_frame.body.as_ref()) {
3154                Ok(r) => r,
3155                Err(e) => {
3156                    tracing::error!(conn_id, error = %e, "CMD6311 decode failed");
3157                    return Some(super::make_error_response(
3158                        -1,
3159                        "backend response decode failed",
3160                    ));
3161                }
3162            };
3163
3164        if strike_rsp.ret.unwrap_or(1) != 0 {
3165            tracing::warn!(conn_id, ret = ?strike_rsp.ret, "CMD6311 returned error");
3166            return Some(super::make_error_response(
3167                -1,
3168                "backend strike date request failed",
3169            ));
3170        }
3171
3172        // Convert strike_date_list (unix timestamps) to FTAPI OptionExpirationDate
3173        let now_ts = std::time::SystemTime::now()
3174            .duration_since(std::time::UNIX_EPOCH)
3175            .map(|d| d.as_secs())
3176            .unwrap_or(0);
3177
3178        // Prefer strike_dates (structured, new version), fallback to legacy fields
3179        let date_list: Vec<futu_proto::qot_get_option_expiration_date::OptionExpirationDate> =
3180            if !strike_rsp.strike_dates.is_empty() {
3181                strike_rsp
3182                    .strike_dates
3183                    .iter()
3184                    .map(|item| {
3185                        let ts = item.strike_date.unwrap_or(0) as u64;
3186                        let strike_time_str = timestamp_to_date_str(ts);
3187                        let left_day = item.left_day.unwrap_or(0);
3188                        let cycle = item.expiration.map(backend_expiration_to_api);
3189
3190                        futu_proto::qot_get_option_expiration_date::OptionExpirationDate {
3191                            strike_time: Some(strike_time_str),
3192                            strike_timestamp: Some(ts as f64),
3193                            option_expiry_date_distance: left_day,
3194                            cycle,
3195                        }
3196                    })
3197                    .collect::<Vec<_>>()
3198            } else {
3199                strike_rsp
3200                    .strike_date_list
3201                    .iter()
3202                    .enumerate()
3203                    .map(|(i, &ts)| {
3204                        let strike_time_str = timestamp_to_date_str(ts as u64);
3205                        let left_day = strike_rsp
3206                            .left_day
3207                            .get(i)
3208                            .copied()
3209                            .unwrap_or((((ts as i64) - now_ts as i64) / 86400) as i32);
3210                        let cycle = strike_rsp
3211                            .expiration_cycle
3212                            .get(i)
3213                            .map(|&e| backend_expiration_to_api(e));
3214
3215                        futu_proto::qot_get_option_expiration_date::OptionExpirationDate {
3216                            strike_time: Some(strike_time_str),
3217                            strike_timestamp: Some(ts as f64),
3218                            option_expiry_date_distance: left_day,
3219                            cycle,
3220                        }
3221                    })
3222                    .collect()
3223            };
3224
3225        tracing::debug!(
3226            conn_id,
3227            count = date_list.len(),
3228            "GetOptionExpirationDate returning"
3229        );
3230
3231        let resp = futu_proto::qot_get_option_expiration_date::Response {
3232            ret_type: 0,
3233            ret_msg: None,
3234            err_code: None,
3235            s2c: Some(futu_proto::qot_get_option_expiration_date::S2c { date_list }),
3236        };
3237        Some(prost::Message::encode_to_vec(&resp))
3238    }
3239}
3240
3241// ===== RequestHistoryKLQuota =====
3242struct RequestHistoryKLQuotaHandler;
3243
3244#[async_trait]
3245impl RequestHandler for RequestHistoryKLQuotaHandler {
3246    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
3247        // Parse request to validate format
3248        let _req: futu_proto::qot_request_history_kl_quota::Request =
3249            prost::Message::decode(request.body.as_ref()).ok()?;
3250
3251        tracing::debug!(conn_id, "RequestHistoryKLQuota: returning unlimited quota");
3252
3253        // Return high quota since backend forwarding doesn't have the same limits
3254        let resp = futu_proto::qot_request_history_kl_quota::Response {
3255            ret_type: 0,
3256            ret_msg: None,
3257            err_code: None,
3258            s2c: Some(futu_proto::qot_request_history_kl_quota::S2c {
3259                used_quota: 0,
3260                remain_quota: 10000,
3261                detail_list: Vec::new(),
3262            }),
3263        };
3264        Some(prost::Message::encode_to_vec(&resp))
3265    }
3266}
3267
3268// ===== RequestRehab (CMD 6811, same as GetRehab) =====
3269struct RequestRehabHandler {
3270    backend: crate::bridge::SharedBackend,
3271    static_cache: Arc<StaticDataCache>,
3272}
3273
3274#[async_trait]
3275impl RequestHandler for RequestRehabHandler {
3276    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
3277        let req: futu_proto::qot_request_rehab::Request =
3278            prost::Message::decode(request.body.as_ref()).ok()?;
3279        let c2s = &req.c2s;
3280
3281        let backend = match super::load_backend(&self.backend) {
3282            Some(b) => b,
3283            None => {
3284                tracing::warn!(conn_id, "RequestRehab: no backend connection");
3285                return Some(super::make_error_response(-1, "no backend connection"));
3286            }
3287        };
3288
3289        let sec_key = format!("{}_{}", c2s.security.market, c2s.security.code);
3290        let stock_id = match self.static_cache.get_security_info(&sec_key) {
3291            Some(info) if info.stock_id > 0 => info.stock_id,
3292            _ => {
3293                tracing::warn!(conn_id, sec_key, "RequestRehab: stock_id not found");
3294                return Some(super::make_error_response(
3295                    -1,
3296                    "security not found in cache",
3297                ));
3298            }
3299        };
3300
3301        let exright_req = futu_backend::proto_internal::ft_cmd6811::ExrightFactorReq {
3302            stock_id: Some(stock_id),
3303            sequence: Some(0),
3304        };
3305        let body = prost::Message::encode_to_vec(&exright_req);
3306
3307        tracing::debug!(conn_id, stock_id, "RequestRehab: sending CMD6811");
3308
3309        let resp_frame = match backend.request(6811, body).await {
3310            Ok(f) => f,
3311            Err(e) => {
3312                tracing::error!(conn_id, error = %e, "RequestRehab CMD6811 request failed");
3313                return Some(super::make_error_response(-1, "backend request failed"));
3314            }
3315        };
3316
3317        let exright_rsp: futu_backend::proto_internal::ft_cmd6811::ExrightFactorRsp =
3318            match prost::Message::decode(resp_frame.body.as_ref()) {
3319                Ok(r) => r,
3320                Err(e) => {
3321                    tracing::error!(conn_id, error = %e, "RequestRehab CMD6811 decode failed");
3322                    return Some(super::make_error_response(
3323                        -1,
3324                        "backend response decode failed",
3325                    ));
3326                }
3327            };
3328
3329        let result = exright_rsp.result.unwrap_or(-1);
3330        if result != 0 && result != 1 {
3331            tracing::warn!(conn_id, result, "RequestRehab CMD6811 returned error");
3332            return Some(super::make_error_response(
3333                -1,
3334                "backend rehab request failed",
3335            ));
3336        }
3337
3338        let rehab_list: Vec<futu_proto::qot_common::Rehab> = exright_rsp
3339            .exs
3340            .iter()
3341            .map(|item| {
3342                let ex_ts = item.ex_time.unwrap_or(0);
3343                let time_str = timestamp_to_date_str(ex_ts as u64);
3344
3345                let mut flag: i64 = 0;
3346                if item.split_base.is_some() {
3347                    flag |= 1;
3348                }
3349                if item.join_base.is_some() {
3350                    flag |= 2;
3351                }
3352                if item.bonus_base.is_some() {
3353                    flag |= 4;
3354                }
3355                if item.transfer_base.is_some() {
3356                    flag |= 8;
3357                }
3358                if item.allot_base.is_some() {
3359                    flag |= 16;
3360                }
3361                if item.add_base.is_some() {
3362                    flag |= 32;
3363                }
3364                if item.dividend_amount.is_some() {
3365                    flag |= 64;
3366                }
3367                if item.sp_dividend_amount.is_some() {
3368                    flag |= 128;
3369                }
3370
3371                let divisor = 100_000.0;
3372
3373                futu_proto::qot_common::Rehab {
3374                    time: time_str,
3375                    company_act_flag: flag,
3376                    fwd_factor_a: item.origin_fwd_a.unwrap_or(100_000.0) / divisor,
3377                    fwd_factor_b: item.origin_fwd_b.unwrap_or(0.0) / divisor,
3378                    bwd_factor_a: item.origin_bwd_a.unwrap_or(100_000.0) / divisor,
3379                    bwd_factor_b: item.origin_bwd_b.unwrap_or(0.0) / divisor,
3380                    split_base: item.split_base.map(|v| v as i32),
3381                    split_ert: item.split_ert.map(|v| v as i32),
3382                    join_base: item.join_base.map(|v| v as i32),
3383                    join_ert: item.join_ert.map(|v| v as i32),
3384                    bonus_base: item.bonus_base.map(|v| v as i32),
3385                    bonus_ert: item.bonus_ert.map(|v| v as i32),
3386                    transfer_base: item.transfer_base.map(|v| v as i32),
3387                    transfer_ert: item.transfer_ert.map(|v| v as i32),
3388                    allot_base: item.allot_base.map(|v| v as i32),
3389                    allot_ert: item.allot_ert.map(|v| v as i32),
3390                    allot_price: item.allot_price.map(|v| v as f64 / divisor),
3391                    add_base: item.add_base.map(|v| v as i32),
3392                    add_ert: item.add_ert.map(|v| v as i32),
3393                    add_price: item.add_price.map(|v| v as f64 / divisor),
3394                    dividend: item.dividend_amount.map(|v| v as f64 / divisor),
3395                    sp_dividend: item.sp_dividend_amount.map(|v| v as f64 / divisor),
3396                    timestamp: Some(ex_ts as f64),
3397                    spin_off_base: item.spin_off_base.map(|v| v as f64),
3398                    spin_off_ert: item.spin_off_ert.map(|v| v as f64),
3399                }
3400            })
3401            .collect();
3402
3403        tracing::debug!(conn_id, count = rehab_list.len(), "RequestRehab returning");
3404
3405        let resp = futu_proto::qot_request_rehab::Response {
3406            ret_type: 0,
3407            ret_msg: None,
3408            err_code: None,
3409            s2c: Some(futu_proto::qot_request_rehab::S2c { rehab_list }),
3410        };
3411        Some(prost::Message::encode_to_vec(&resp))
3412    }
3413}
3414
3415// ===== GetWarrant (CMD 6513 → 后端转发) =====
3416struct GetWarrantHandler {
3417    backend: crate::bridge::SharedBackend,
3418    static_cache: Arc<StaticDataCache>,
3419}
3420
3421#[async_trait]
3422impl RequestHandler for GetWarrantHandler {
3423    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
3424        let req: futu_proto::qot_get_warrant::Request =
3425            prost::Message::decode(request.body.as_ref()).ok()?;
3426        let c2s = &req.c2s;
3427
3428        let backend = match super::load_backend(&self.backend) {
3429            Some(b) => b,
3430            None => {
3431                tracing::warn!(conn_id, "GetWarrant: no backend connection");
3432                return Some(super::make_error_response(-1, "no backend connection"));
3433            }
3434        };
3435
3436        // Resolve owner stock_id if present
3437        let owner_stock_id = if let Some(ref owner) = c2s.owner {
3438            let sec_key = format!("{}_{}", owner.market, owner.code);
3439            match self.static_cache.get_security_info(&sec_key) {
3440                Some(info) if info.stock_id > 0 => Some(info.stock_id),
3441                _ => None,
3442            }
3443        } else {
3444            None
3445        };
3446
3447        // Build backend WarrantListReq with essential fields
3448        let backend_req = futu_backend::proto_internal::ftcmd6513::WarrantListReq {
3449            only_count: None,
3450            issuer_id: None,
3451            stock_owner: owner_stock_id,
3452            arry_warrant_type: c2s.type_list.clone(),
3453            cur_min: None,
3454            cur_max: None,
3455            street_min: None,
3456            street_max: None,
3457            vol_min: c2s.vol_min,
3458            vol_max: c2s.vol_max,
3459            maturity_date_min: None,
3460            maturity_date_max: None,
3461            strick_min: None,
3462            strick_max: None,
3463            conversion_min: None,
3464            conversion_max: None,
3465            ipop_min: None,
3466            ipop_max: None,
3467            premium_min: None,
3468            premium_max: None,
3469            recovery_min: None,
3470            recovery_max: None,
3471            implied_min: None,
3472            implied_max: None,
3473            leverage_ratio_min: None,
3474            leverage_ratio_max: None,
3475            lang_id: None,
3476            price_recovery_ratio_min: None,
3477            price_recovery_ratio_max: None,
3478            delta_min: None,
3479            delta_max: None,
3480            sort_col: c2s.sort_field,
3481            sort_ascend: if c2s.ascend { 1 } else { 0 },
3482            data_from: Some(c2s.begin),
3483            data_max_count: Some(c2s.num),
3484            status_filter: c2s.status,
3485            multiple_issuers: c2s.issuer_list.clone(),
3486            ipo_period: c2s.ipo_period,
3487            buy_vol_min: None,
3488            buy_vol_max: None,
3489            sell_vol_min: None,
3490            sell_vol_max: None,
3491            effective_leverage_min: None,
3492            effective_leverage_max: None,
3493            filter_no_trade_status: None,
3494            is_bmp: None,
3495            maturity_date_screens: Vec::new(),
3496            leverage_ratio_screens: Vec::new(),
3497            status_filter_screens: Vec::new(),
3498            market: None,
3499        };
3500        let body = prost::Message::encode_to_vec(&backend_req);
3501
3502        tracing::debug!(conn_id, "sending CMD6513 WarrantListReq");
3503
3504        let resp_frame = match backend.request(6513, body).await {
3505            Ok(f) => f,
3506            Err(e) => {
3507                tracing::error!(conn_id, error = %e, "CMD6513 request failed");
3508                return Some(super::make_error_response(-1, "backend request failed"));
3509            }
3510        };
3511
3512        let backend_rsp: futu_backend::proto_internal::ftcmd6513::WarrantListRsp =
3513            match prost::Message::decode(resp_frame.body.as_ref()) {
3514                Ok(r) => r,
3515                Err(e) => {
3516                    tracing::error!(conn_id, error = %e, "CMD6513 decode failed");
3517                    return Some(super::make_error_response(
3518                        -1,
3519                        "backend response decode failed",
3520                    ));
3521                }
3522            };
3523
3524        if backend_rsp.result != 0 {
3525            tracing::warn!(
3526                conn_id,
3527                result = backend_rsp.result,
3528                "CMD6513 returned error"
3529            );
3530            return Some(super::make_error_response(
3531                -1,
3532                "backend warrant request failed",
3533            ));
3534        }
3535
3536        let all_count = backend_rsp.all_count as i32;
3537        let last_page = backend_rsp.if_last_page != 0;
3538
3539        // Map each backend WarrantItem to FTAPI WarrantData
3540        let warrant_data_list: Vec<futu_proto::qot_get_warrant::WarrantData> = backend_rsp
3541            .arry_items
3542            .iter()
3543            .filter_map(|item| {
3544                // Resolve stock Security from stock_id
3545                let stock_id = item.stock_id?;
3546                let stock_key = self.static_cache.id_to_key.get(&stock_id)?;
3547                let stock_info = self.static_cache.get_security_info(stock_key.value())?;
3548                let stock = futu_proto::qot_common::Security {
3549                    market: stock_info.market,
3550                    code: stock_info.code.clone(),
3551                };
3552
3553                // Resolve owner Security from stock_owner
3554                let owner_id = item.stock_owner?;
3555                let owner_key = self.static_cache.id_to_key.get(&owner_id)?;
3556                let owner_info = self.static_cache.get_security_info(owner_key.value())?;
3557                let owner = futu_proto::qot_common::Security {
3558                    market: owner_info.market,
3559                    code: owner_info.code.clone(),
3560                };
3561
3562                let warrant_type = item.warrant_type.unwrap_or(0);
3563
3564                // Issuer: map backend IssuerAgency enum to API Issuer enum
3565                let issuer = map_issuer_backend_to_api(item.issuer_id.unwrap_or(0));
3566
3567                // Status: map backend WarrantStatus to API WarrantStatus
3568                let status = map_warrant_status_backend_to_api(item.status.unwrap_or(0));
3569
3570                // Prices: backend values are *1000, divide to get real values
3571                let cur_price = item.current_price.unwrap_or(0) as f64 / 1000.0;
3572                let last_close_price = item.lastclose_price.unwrap_or(0) as f64 / 1000.0;
3573                let high_price = item.high_price.unwrap_or(0) as f64 / 1000.0;
3574                let low_price = item.low_price.unwrap_or(0) as f64 / 1000.0;
3575                let strike_price = item.strick_price.unwrap_or(0) as f64 / 1000.0;
3576                let bid_price = item.buy_price.unwrap_or(0) as f64 / 1000.0;
3577                let ask_price = item.sell_price.unwrap_or(0) as f64 / 1000.0;
3578                let recovery_price_val = item.recovery_price.unwrap_or(0) as f64 / 1000.0;
3579                let break_even_point = item.break_even_point.unwrap_or(0) as f64 / 1000.0;
3580
3581                // Computed fields (matching C++ logic)
3582                let price_change_val = cur_price - last_close_price;
3583                let change_rate = if last_close_price.abs() > 1e-6 {
3584                    price_change_val / last_close_price * 100.0
3585                } else {
3586                    0.0
3587                };
3588
3589                // Ratio/percentage fields: backend *1000, divide
3590                let conversion_ratio = item.conversion_ratio.unwrap_or(0) as f64 / 1000.0;
3591                let conversion_price = conversion_ratio * cur_price;
3592                let street_rate = item.street_rate.unwrap_or(0) as f64 / 1000.0;
3593                let premium = item.premium.unwrap_or(0) as f64 / 1000.0;
3594                let leverage = item.leverage.unwrap_or(0) as f64 / 1000.0;
3595                let effective_leverage = item.effective_leverage.unwrap_or(0) as f64 / 1000.0;
3596                let ipop = item.ipop.unwrap_or(0) as f64 / 1000.0;
3597                let amplitude = item.amplitude.unwrap_or(0) as f64 / 1000.0;
3598                let turnover = item.turnover.unwrap_or(0) as f64 / 1000.0;
3599                let score = item.fx_score.unwrap_or(0) as f64 / 1000.0;
3600
3601                // Volumes (no scaling)
3602                let volume = item.volume.unwrap_or(0) as i64;
3603                let bid_vol = item.buy_vol.unwrap_or(0) as i64;
3604                let ask_vol = item.sell_vol.unwrap_or(0) as i64;
3605                let street_vol = item.street_vol.unwrap_or(0) as i64;
3606                let issue_size = item.issue_size.unwrap_or(0) as i64;
3607                let lot_size = item.lot_size.unwrap_or(0) as i32;
3608
3609                // Timestamps -> date strings
3610                let maturity_time = item.maturity_date.map(format_timestamp).unwrap_or_default();
3611                let maturity_timestamp = item.maturity_date.map(|ts| ts as f64);
3612                let list_time = item.ipo_time.map(format_timestamp).unwrap_or_default();
3613                let list_timestamp = item.ipo_time.map(|ts| ts as f64);
3614                let last_trade_time = item
3615                    .last_trade_date
3616                    .map(format_timestamp)
3617                    .unwrap_or_default();
3618                let last_trade_timestamp = item.last_trade_date.map(|ts| ts as f64);
3619
3620                // Name from static cache
3621                let name = stock_info.name.clone();
3622
3623                // Type-specific optional fields (matching C++ GetAPIStockWarrant)
3624                let (delta, implied_volatility) = if warrant_type == 1 || warrant_type == 2 {
3625                    // Buy/Sell
3626                    (
3627                        Some(item.delta.unwrap_or(0) as f64 / 1000.0),
3628                        Some(item.implied_volatility.unwrap_or(0) as f64 / 1000.0),
3629                    )
3630                } else {
3631                    (None, None)
3632                };
3633
3634                let (recovery_price, price_recovery_ratio) =
3635                    if warrant_type == 3 || warrant_type == 4 {
3636                        // Bull/Bear
3637                        (
3638                            Some(recovery_price_val),
3639                            Some(item.price_recovery_ratio.unwrap_or(0) as f64 / 1000.0),
3640                        )
3641                    } else {
3642                        (None, None)
3643                    };
3644
3645                // Upper/lower strike price: backend *10^9
3646                let (upper_strike_price, lower_strike_price, in_line_price_status) =
3647                    if warrant_type == 5 {
3648                        // InLine
3649                        let upper = item.upper_strike_price.unwrap_or(0) as f64 / 1_000_000_000.0;
3650                        let lower = item.lower_strike_price.unwrap_or(0) as f64 / 1_000_000_000.0;
3651                        // iw_price_status: 0=WithIn(1), 1=Outside(2)
3652                        let price_status = if item.iw_price_status.unwrap_or(0) == 0 {
3653                            1_i32
3654                        } else {
3655                            2_i32
3656                        };
3657                        (Some(upper), Some(lower), Some(price_status))
3658                    } else {
3659                        (None, None, None)
3660                    };
3661
3662                Some(futu_proto::qot_get_warrant::WarrantData {
3663                    stock,
3664                    owner,
3665                    r#type: warrant_type,
3666                    issuer,
3667                    maturity_time,
3668                    maturity_timestamp,
3669                    list_time,
3670                    list_timestamp,
3671                    last_trade_time,
3672                    last_trade_timestamp,
3673                    recovery_price,
3674                    conversion_ratio,
3675                    lot_size,
3676                    strike_price,
3677                    last_close_price,
3678                    name,
3679                    cur_price,
3680                    price_change_val,
3681                    change_rate,
3682                    status,
3683                    bid_price,
3684                    ask_price,
3685                    bid_vol,
3686                    ask_vol,
3687                    volume,
3688                    turnover,
3689                    score,
3690                    premium,
3691                    break_even_point,
3692                    leverage,
3693                    ipop,
3694                    price_recovery_ratio,
3695                    conversion_price,
3696                    street_rate,
3697                    street_vol,
3698                    amplitude,
3699                    issue_size,
3700                    high_price,
3701                    low_price,
3702                    implied_volatility,
3703                    delta,
3704                    effective_leverage,
3705                    upper_strike_price,
3706                    lower_strike_price,
3707                    in_line_price_status,
3708                })
3709            })
3710            .collect();
3711
3712        tracing::debug!(
3713            conn_id,
3714            all_count,
3715            last_page,
3716            mapped_count = warrant_data_list.len(),
3717            "GetWarrant: mapped backend warrant items"
3718        );
3719
3720        let resp = futu_proto::qot_get_warrant::Response {
3721            ret_type: 0,
3722            ret_msg: None,
3723            err_code: None,
3724            s2c: Some(futu_proto::qot_get_warrant::S2c {
3725                last_page,
3726                all_count,
3727                warrant_data_list,
3728            }),
3729        };
3730        Some(prost::Message::encode_to_vec(&resp))
3731    }
3732}
3733
3734/// Map backend IssuerAgency enum value to FTAPI Issuer enum value.
3735/// Backend uses FTCMD6513/20334 IssuerAgency, API uses Qot_Common::Issuer.
3736fn map_issuer_backend_to_api(backend_issuer: u32) -> i32 {
3737    match backend_issuer {
3738        1 => 19,  // BC -> Issuer_BC
3739        2 => 12,  // BI -> Issuer_BI
3740        3 => 2,   // BP -> Issuer_BP
3741        4 => 3,   // CS -> Issuer_CS
3742        5 => 4,   // CT -> Issuer_CT
3743        6 => 13,  // DB -> Issuer_DB
3744        7 => 14,  // DC -> Issuer_DC
3745        8 => 5,   // EA -> Issuer_EA
3746        9 => 6,   // GS -> Issuer_GS
3747        10 => 7,  // HS -> Issuer_HS
3748        11 => 8,  // JP -> Issuer_JP
3749        12 => 22, // KC -> Issuer_KC
3750        13 => 9,  // MB -> Issuer_MB
3751        // 14 = MC, no API mapping
3752        15 => 15, // ML -> Issuer_ML
3753        16 => 16, // NM -> Issuer_NM
3754        17 => 17, // RB -> Issuer_RB
3755        18 => 18, // RS -> Issuer_RS
3756        19 => 10, // SC -> Issuer_SC
3757        20 => 1,  // SG -> Issuer_SG
3758        21 => 11, // UB -> Issuer_UB
3759        22 => 20, // HT -> Issuer_HT
3760        23 => 21, // VT -> Issuer_VT
3761        24 => 23, // MS -> Issuer_MS
3762        25 => 24, // GJ -> Issuer_GJ
3763        26 => 25, // XZ -> Issuer_XZ
3764        27 => 26, // HU -> Issuer_HU
3765        28 => 27, // KS -> Issuer_KS
3766        29 => 28, // CI -> Issuer_CI
3767        _ => 0,   // Issuer_Unknow
3768    }
3769}
3770
3771/// Map backend WarrantStatus enum value to FTAPI WarrantStatus enum value.
3772fn map_warrant_status_backend_to_api(backend_status: i32) -> i32 {
3773    match backend_status {
3774        0 => 1, // status_normal -> WarrantStatus_Normal
3775        1 => 2, // status_common (suspend) -> WarrantStatus_Suspend
3776        2 => 3, // status_stop -> WarrantStatus_StopTrade
3777        3 => 4, // status_listing -> WarrantStatus_PendingListing
3778        _ => 0, // WarrantStatus_Unknow
3779    }
3780}
3781
3782// ===== GetCapitalFlow (CMD 6694 → 后端转发) =====
3783struct GetCapitalFlowHandler {
3784    backend: crate::bridge::SharedBackend,
3785    static_cache: Arc<StaticDataCache>,
3786}
3787
3788#[async_trait]
3789impl RequestHandler for GetCapitalFlowHandler {
3790    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
3791        let req: futu_proto::qot_get_capital_flow::Request =
3792            prost::Message::decode(request.body.as_ref()).ok()?;
3793        let c2s = &req.c2s;
3794
3795        let backend = match super::load_backend(&self.backend) {
3796            Some(b) => b,
3797            None => {
3798                tracing::warn!(conn_id, "GetCapitalFlow: no backend connection");
3799                return Some(super::make_error_response(-1, "no backend connection"));
3800            }
3801        };
3802
3803        // Resolve stock_id
3804        let sec_key = format!("{}_{}", c2s.security.market, c2s.security.code);
3805        let stock_id = match self.static_cache.get_security_info(&sec_key) {
3806            Some(info) if info.stock_id > 0 => info.stock_id,
3807            _ => {
3808                tracing::warn!(conn_id, sec_key, "GetCapitalFlow: stock_id not found");
3809                return Some(super::make_error_response(
3810                    -1,
3811                    "security not found in cache",
3812                ));
3813            }
3814        };
3815
3816        // 判断 period_type: INTRADAY(1) 或未设置 → CMD 6694, 其他 → CMD 6695
3817        let period_type = c2s.period_type.unwrap_or(1); // 默认 INTRADAY
3818
3819        let (flow_item_list, last_valid_time, last_valid_ts) = if period_type <= 1 {
3820            // 实时资金流向: CMD 6694
3821            let backend_req = futu_backend::proto_internal::cash_flow_cs::RealFlowTrendReq {
3822                stock_id: Some(stock_id),
3823                req_section: Some(0), // REQUEST_SECTION_NORMAL (C++ 对齐)
3824            };
3825            let body = prost::Message::encode_to_vec(&backend_req);
3826
3827            tracing::debug!(conn_id, stock_id, "sending CMD6694 RealFlowTrendReq");
3828
3829            let resp_frame = match backend.request(6694, body).await {
3830                Ok(f) => f,
3831                Err(e) => {
3832                    tracing::error!(conn_id, error = %e, "CMD6694 request failed");
3833                    return Some(super::make_error_response(-1, "backend request failed"));
3834                }
3835            };
3836
3837            let backend_rsp: futu_backend::proto_internal::cash_flow_cs::RealFlowTrendRsp =
3838                match prost::Message::decode(resp_frame.body.as_ref()) {
3839                    Ok(r) => r,
3840                    Err(e) => {
3841                        tracing::error!(conn_id, error = %e, "CMD6694 decode failed");
3842                        return Some(super::make_error_response(
3843                            -1,
3844                            "backend response decode failed",
3845                        ));
3846                    }
3847                };
3848
3849            if let Some(err) = backend_rsp.error_code {
3850                if err != 0 {
3851                    tracing::warn!(conn_id, err, "CMD6694 returned error");
3852                    return Some(super::make_error_response(
3853                        -1,
3854                        "backend capital flow request failed",
3855                    ));
3856                }
3857            }
3858
3859            let items: Vec<futu_proto::qot_get_capital_flow::CapitalFlowItem> = backend_rsp
3860                .section_list
3861                .iter()
3862                .flat_map(|section| {
3863                    section.point_list.iter().filter_map(|pt| {
3864                        // C++ 跳过 bIsEmpty 的条目 (is_empty != 0 表示空点)
3865                        if pt.is_empty.unwrap_or(0) != 0 {
3866                            return None;
3867                        }
3868                        Some(futu_proto::qot_get_capital_flow::CapitalFlowItem {
3869                            in_flow: pt.total_net_in.unwrap_or(0) as f64 / 1000.0,
3870                            time: pt.time.map(timestamp_to_datetime_str),
3871                            timestamp: pt.time.map(|t| t as f64),
3872                            main_in_flow: None, // C++: 实时数据不设 main_in_flow
3873                            super_in_flow: pt.super_net_in.map(|v| v as f64 / 1000.0),
3874                            big_in_flow: pt.big_net_in.map(|v| v as f64 / 1000.0),
3875                            mid_in_flow: pt.mid_net_in.map(|v| v as f64 / 1000.0),
3876                            sml_in_flow: pt.sml_net_in.map(|v| v as f64 / 1000.0),
3877                        })
3878                    })
3879                })
3880                .collect();
3881
3882            let last_valid_ts = backend_rsp.update_time.map(|t| t as f64);
3883            let last_valid_time = backend_rsp.update_time.map(timestamp_to_datetime_str);
3884            (items, last_valid_time, last_valid_ts)
3885        } else {
3886            // 历史资金流向: CMD 6695
3887            // FTAPI PeriodType → 后端 PeriodType 映射
3888            let nn_period = match period_type {
3889                2 => 1u32, // DAY
3890                3 => 2,    // WEEK
3891                4 => 3,    // MONTH
3892                _ => 1,    // 默认 DAY
3893            };
3894
3895            // 解析时间范围
3896            let begin_time = c2s.begin_time.as_deref().and_then(parse_date_to_timestamp);
3897            let end_time = c2s.end_time.as_deref().and_then(parse_date_to_timestamp);
3898
3899            // 如果时间无效,返回空数据 (C++ 行为)
3900            if let (Some(bt), Some(et)) = (begin_time, end_time) {
3901                if bt > et {
3902                    let resp = futu_proto::qot_get_capital_flow::Response {
3903                        ret_type: 0,
3904                        ret_msg: None,
3905                        err_code: None,
3906                        s2c: Some(futu_proto::qot_get_capital_flow::S2c {
3907                            flow_item_list: vec![],
3908                            last_valid_time: None,
3909                            last_valid_timestamp: None,
3910                        }),
3911                    };
3912                    return Some(prost::Message::encode_to_vec(&resp));
3913                }
3914            }
3915
3916            let backend_req = futu_backend::proto_internal::cash_flow_cs::HistoryCashFlowReq {
3917                stock_id: Some(stock_id),
3918                req_period_type: Some(nn_period),
3919                base_time: end_time.map(|t| t as u64),
3920                count: Some(200), // C++ MaxReqNum = 200
3921                need_detail: Some(0),
3922                analysis_type: Some(0),
3923            };
3924            let body = prost::Message::encode_to_vec(&backend_req);
3925
3926            tracing::debug!(
3927                conn_id,
3928                stock_id,
3929                nn_period,
3930                "sending CMD6695 HistoryCashFlowReq"
3931            );
3932
3933            let resp_frame = match backend.request(6695, body).await {
3934                Ok(f) => f,
3935                Err(e) => {
3936                    tracing::error!(conn_id, error = %e, "CMD6695 request failed");
3937                    return Some(super::make_error_response(-1, "backend request failed"));
3938                }
3939            };
3940
3941            let backend_rsp: futu_backend::proto_internal::cash_flow_cs::HistoryCashFlowRsp =
3942                match prost::Message::decode(resp_frame.body.as_ref()) {
3943                    Ok(r) => r,
3944                    Err(e) => {
3945                        tracing::error!(conn_id, error = %e, "CMD6695 decode failed");
3946                        return Some(super::make_error_response(
3947                            -1,
3948                            "backend response decode failed",
3949                        ));
3950                    }
3951                };
3952
3953            if let Some(err) = backend_rsp.error_code {
3954                if err != 0 {
3955                    tracing::warn!(conn_id, err, "CMD6695 returned error");
3956                    return Some(super::make_error_response(
3957                        -1,
3958                        "backend history capital flow request failed",
3959                    ));
3960                }
3961            }
3962
3963            let items: Vec<futu_proto::qot_get_capital_flow::CapitalFlowItem> = backend_rsp
3964                .point_list
3965                .iter()
3966                .filter_map(|pt| {
3967                    if pt.is_empty.unwrap_or(0) != 0 {
3968                        return None;
3969                    }
3970                    Some(futu_proto::qot_get_capital_flow::CapitalFlowItem {
3971                        in_flow: pt.total_net_in.unwrap_or(0) as f64 / 1000.0,
3972                        time: pt.time.map(timestamp_to_datetime_str),
3973                        timestamp: pt.time.map(|t| t as f64),
3974                        main_in_flow: pt.main_net_in.map(|v| v as f64 / 1000.0),
3975                        super_in_flow: pt.super_net_in.map(|v| v as f64 / 1000.0),
3976                        big_in_flow: pt.big_net_in.map(|v| v as f64 / 1000.0),
3977                        mid_in_flow: pt.mid_net_in.map(|v| v as f64 / 1000.0),
3978                        sml_in_flow: pt.sml_net_in.map(|v| v as f64 / 1000.0),
3979                    })
3980                })
3981                .collect();
3982
3983            (items, None, None::<f64>)
3984        };
3985
3986        tracing::debug!(
3987            conn_id,
3988            count = flow_item_list.len(),
3989            "GetCapitalFlow: returning flow items"
3990        );
3991
3992        let resp = futu_proto::qot_get_capital_flow::Response {
3993            ret_type: 0,
3994            ret_msg: None,
3995            err_code: None,
3996            s2c: Some(futu_proto::qot_get_capital_flow::S2c {
3997                flow_item_list,
3998                last_valid_time,
3999                last_valid_timestamp: last_valid_ts,
4000            }),
4001        };
4002        Some(prost::Message::encode_to_vec(&resp))
4003    }
4004}
4005
4006// ===== GetCapitalDistribution (CMD 6693 → 后端转发) =====
4007struct GetCapitalDistributionHandler {
4008    backend: crate::bridge::SharedBackend,
4009    static_cache: Arc<StaticDataCache>,
4010}
4011
4012#[async_trait]
4013impl RequestHandler for GetCapitalDistributionHandler {
4014    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
4015        let req: futu_proto::qot_get_capital_distribution::Request =
4016            prost::Message::decode(request.body.as_ref()).ok()?;
4017        let c2s = &req.c2s;
4018
4019        let backend = match super::load_backend(&self.backend) {
4020            Some(b) => b,
4021            None => {
4022                tracing::warn!(conn_id, "GetCapitalDistribution: no backend connection");
4023                return Some(super::make_error_response(-1, "no backend connection"));
4024            }
4025        };
4026
4027        // Resolve stock_id
4028        let sec_key = format!("{}_{}", c2s.security.market, c2s.security.code);
4029        let stock_id = match self.static_cache.get_security_info(&sec_key) {
4030            Some(info) if info.stock_id > 0 => info.stock_id,
4031            _ => {
4032                tracing::warn!(
4033                    conn_id,
4034                    sec_key,
4035                    "GetCapitalDistribution: stock_id not found"
4036                );
4037                return Some(super::make_error_response(
4038                    -1,
4039                    "security not found in cache",
4040                ));
4041            }
4042        };
4043
4044        // Build backend RealDistributionReq (CMD 6693)
4045        let backend_req = futu_backend::proto_internal::cash_flow_cs::RealDistributionReq {
4046            stock_id: Some(stock_id),
4047        };
4048        let body = prost::Message::encode_to_vec(&backend_req);
4049
4050        tracing::debug!(conn_id, stock_id, "sending CMD6693 RealDistributionReq");
4051
4052        let resp_frame = match backend.request(6693, body).await {
4053            Ok(f) => f,
4054            Err(e) => {
4055                tracing::error!(conn_id, error = %e, "CMD6693 request failed");
4056                return Some(super::make_error_response(-1, "backend request failed"));
4057            }
4058        };
4059
4060        let backend_rsp: futu_backend::proto_internal::cash_flow_cs::RealDistributionRsp =
4061            match prost::Message::decode(resp_frame.body.as_ref()) {
4062                Ok(r) => r,
4063                Err(e) => {
4064                    tracing::error!(conn_id, error = %e, "CMD6693 decode failed");
4065                    return Some(super::make_error_response(
4066                        -1,
4067                        "backend response decode failed",
4068                    ));
4069                }
4070            };
4071
4072        if let Some(err) = backend_rsp.error_code {
4073            if err != 0 {
4074                tracing::warn!(conn_id, err, "CMD6693 returned error");
4075                return Some(super::make_error_response(
4076                    -1,
4077                    "backend capital distribution request failed",
4078                ));
4079            }
4080        }
4081
4082        // Convert backend DistributionItem list → FTAPI S2c fields
4083        // Backend OrderType: 1=Small, 2=Middle, 3=Big, 4=Supper
4084        // Backend values are in 0.001 units
4085        let mut in_super = 0.0_f64;
4086        let mut in_big = 0.0_f64;
4087        let mut in_mid = 0.0_f64;
4088        let mut in_small = 0.0_f64;
4089        let mut out_super = 0.0_f64;
4090        let mut out_big = 0.0_f64;
4091        let mut out_mid = 0.0_f64;
4092        let mut out_small = 0.0_f64;
4093
4094        for item in &backend_rsp.items {
4095            let flow_in = item.r#in.unwrap_or(0) as f64 / 1000.0;
4096            let flow_out = item.out.unwrap_or(0) as f64 / 1000.0;
4097            match item.order_type.unwrap_or(0) {
4098                1 => {
4099                    in_small = flow_in;
4100                    out_small = flow_out;
4101                }
4102                2 => {
4103                    in_mid = flow_in;
4104                    out_mid = flow_out;
4105                }
4106                3 => {
4107                    in_big = flow_in;
4108                    out_big = flow_out;
4109                }
4110                4 => {
4111                    in_super = flow_in;
4112                    out_super = flow_out;
4113                }
4114                _ => {}
4115            }
4116        }
4117
4118        let update_timestamp = backend_rsp.update_time.map(|t| t as f64);
4119
4120        tracing::debug!(
4121            conn_id,
4122            items = backend_rsp.items.len(),
4123            "GetCapitalDistribution: returning distribution"
4124        );
4125
4126        let resp = futu_proto::qot_get_capital_distribution::Response {
4127            ret_type: 0,
4128            ret_msg: None,
4129            err_code: None,
4130            s2c: Some(futu_proto::qot_get_capital_distribution::S2c {
4131                capital_in_super: Some(in_super),
4132                capital_in_big: in_big,
4133                capital_in_mid: in_mid,
4134                capital_in_small: in_small,
4135                capital_out_super: Some(out_super),
4136                capital_out_big: out_big,
4137                capital_out_mid: out_mid,
4138                capital_out_small: out_small,
4139                update_time: backend_rsp.update_time.map(timestamp_to_datetime_str),
4140                update_timestamp,
4141            }),
4142        };
4143        Some(prost::Message::encode_to_vec(&resp))
4144    }
4145}
4146
4147// ===== GetHistoryKL (CMD 6161, same as RequestHistoryKL) =====
4148struct GetHistoryKLHandler {
4149    backend: crate::bridge::SharedBackend,
4150    static_cache: Arc<StaticDataCache>,
4151}
4152
4153#[async_trait]
4154impl RequestHandler for GetHistoryKLHandler {
4155    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
4156        // GetHistoryKL uses the same request format as RequestHistoryKL
4157        let req: futu_proto::qot_request_history_kl::Request =
4158            prost::Message::decode(request.body.as_ref()).ok()?;
4159        let c2s = &req.c2s;
4160
4161        let backend = match super::load_backend(&self.backend) {
4162            Some(b) => b,
4163            None => {
4164                tracing::warn!(conn_id, "GetHistoryKL: no backend connection");
4165                return Some(super::make_error_response(-1, "no backend connection"));
4166            }
4167        };
4168
4169        let sec_key = format!("{}_{}", c2s.security.market, c2s.security.code);
4170        let stock_id = match self.static_cache.get_security_info(&sec_key) {
4171            Some(info) if info.stock_id > 0 => info.stock_id,
4172            _ => {
4173                tracing::warn!(conn_id, sec_key, "GetHistoryKL: stock_id not found");
4174                return Some(super::make_error_response(
4175                    -1,
4176                    "security not found in cache",
4177                ));
4178            }
4179        };
4180
4181        let backend_kl_type = match ftapi_kl_type_to_backend(c2s.kl_type) {
4182            Some(v) => v,
4183            None => {
4184                tracing::warn!(
4185                    conn_id,
4186                    kl_type = c2s.kl_type,
4187                    "GetHistoryKL: invalid kl_type"
4188                );
4189                return Some(super::make_error_response(-1, "invalid kl_type"));
4190            }
4191        };
4192
4193        let exright_type = c2s.rehab_type as u32;
4194        let qot_market = c2s.security.market;
4195        let begin_ts = date_str_to_timestamp(&c2s.begin_time, qot_market).unwrap_or(0);
4196        let end_ts = if c2s.end_time.is_empty() {
4197            u64::MAX
4198        } else {
4199            date_str_to_timestamp(&c2s.end_time, qot_market)
4200                .map(|t| t + 86399)
4201                .unwrap_or(u64::MAX)
4202        };
4203
4204        // C++ always uses data_range_type=1 (BEGIN_TIME + END_TIME)
4205        let max_kl_num = c2s.max_ack_kl_num.unwrap_or(0) as usize;
4206
4207        let kline_req = futu_backend::proto_internal::ft_cmd_kline::KlineReq {
4208            security_id: Some(stock_id),
4209            kline_type: Some(backend_kl_type),
4210            exright_type: Some(exright_type),
4211            data_set_type: Some(0),
4212            data_range_type: Some(1), // always BEGIN_TIME + END_TIME
4213            begin_time: Some(begin_ts),
4214            end_time: Some(end_ts),
4215            item_count: None,
4216            end_time_offset: None,
4217        };
4218
4219        let body = prost::Message::encode_to_vec(&kline_req);
4220
4221        tracing::debug!(
4222            conn_id,
4223            stock_id,
4224            kl_type = backend_kl_type,
4225            exright = exright_type,
4226            "GetHistoryKL: sending CMD6161 KlineReq"
4227        );
4228
4229        let resp_frame = match backend.request(6161, body).await {
4230            Ok(f) => f,
4231            Err(e) => {
4232                tracing::error!(conn_id, error = %e, "GetHistoryKL: CMD6161 request failed");
4233                return Some(super::make_error_response(-1, "backend request failed"));
4234            }
4235        };
4236
4237        let kline_rsp: futu_backend::proto_internal::ft_cmd_kline::KlineRsp =
4238            match prost::Message::decode(resp_frame.body.as_ref()) {
4239                Ok(r) => r,
4240                Err(e) => {
4241                    tracing::error!(conn_id, error = %e, "GetHistoryKL: CMD6161 decode failed");
4242                    return Some(super::make_error_response(
4243                        -1,
4244                        "backend response decode failed",
4245                    ));
4246                }
4247            };
4248
4249        let result = kline_rsp.result.unwrap_or(-1);
4250        if result != 0 {
4251            tracing::warn!(conn_id, result, "GetHistoryKL: CMD6161 returned error");
4252            return Some(super::make_error_response(
4253                -1,
4254                "backend kline request failed",
4255            ));
4256        }
4257
4258        let mut kl_list: Vec<futu_proto::qot_common::KLine> = kline_rsp
4259            .kline_item_list
4260            .iter()
4261            .map(kline_item_to_ftapi)
4262            .collect();
4263        if max_kl_num > 0 && kl_list.len() > max_kl_num {
4264            kl_list.truncate(max_kl_num);
4265        }
4266
4267        tracing::debug!(
4268            conn_id,
4269            count = kl_list.len(),
4270            "GetHistoryKL returning klines"
4271        );
4272
4273        // GetHistoryKL uses the same response format as RequestHistoryKL
4274        let resp = futu_proto::qot_request_history_kl::Response {
4275            ret_type: 0,
4276            ret_msg: None,
4277            err_code: None,
4278            s2c: Some(futu_proto::qot_request_history_kl::S2c {
4279                security: c2s.security.clone(),
4280                name: None,
4281                kl_list,
4282                next_req_key: None,
4283            }),
4284        };
4285        Some(prost::Message::encode_to_vec(&resp))
4286    }
4287}
4288
4289// ===== GetHistoryKLPoints (已在 v5.21 分支删除) =====
4290// C++ 源码中 FTAPI_ProtoID_Qot_GetHistoryKLPoints (3101) 已被注释掉:
4291// //#define FTAPI_ProtoID_Qot_GetHistoryKLPoints 3101 /**< 获取多只股票历史单点K线(v5.21分支删除相关功能) */
4292struct GetHistoryKLPointsHandler;
4293
4294#[async_trait]
4295impl RequestHandler for GetHistoryKLPointsHandler {
4296    async fn handle(&self, conn_id: u64, _request: &IncomingRequest) -> Option<Vec<u8>> {
4297        // 此接口已在 C++ v5.21 分支中被删除,proto 也已移除,不再支持。
4298        // 不尝试解析请求体,直接返回错误。
4299        tracing::debug!(conn_id, "GetHistoryKLPoints: feature removed in v5.21");
4300        Some(super::make_error_response(
4301            -1,
4302            "GetHistoryKLPoints has been removed since v5.21. Please use RequestHistoryKL instead.",
4303        ))
4304    }
4305}
4306
4307// ===== GetTradeDate (CMD 6733, same logic as RequestTradeDate) =====
4308struct GetTradeDateHandler {
4309    backend: crate::bridge::SharedBackend,
4310}
4311
4312#[async_trait]
4313impl RequestHandler for GetTradeDateHandler {
4314    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
4315        // GetTradeDate uses the same request format as RequestTradeDate
4316        let req: futu_proto::qot_request_trade_date::Request =
4317            prost::Message::decode(request.body.as_ref()).ok()?;
4318        let c2s = &req.c2s;
4319
4320        let backend = match super::load_backend(&self.backend) {
4321            Some(b) => b,
4322            None => {
4323                tracing::warn!(conn_id, "GetTradeDate: no backend connection");
4324                return Some(super::make_error_response(-1, "no backend connection"));
4325            }
4326        };
4327
4328        let backend_market = match ftapi_market_to_backend(c2s.market) {
4329            Some(m) => m,
4330            None => {
4331                tracing::warn!(
4332                    conn_id,
4333                    market = c2s.market,
4334                    "GetTradeDate: unsupported market"
4335                );
4336                return Some(super::make_error_response(-1, "unsupported market"));
4337            }
4338        };
4339
4340        let begin_key = match date_str_to_yyyymmdd(&c2s.begin_time) {
4341            Some(v) => v,
4342            None => {
4343                tracing::warn!(conn_id, begin = %c2s.begin_time, "GetTradeDate: invalid begin_time");
4344                return Some(super::make_error_response(-1, "invalid begin_time"));
4345            }
4346        };
4347
4348        let end_key = match date_str_to_yyyymmdd(&c2s.end_time) {
4349            Some(v) => v,
4350            None => {
4351                tracing::warn!(conn_id, end = %c2s.end_time, "GetTradeDate: invalid end_time");
4352                return Some(super::make_error_response(-1, "invalid end_time"));
4353            }
4354        };
4355
4356        let range_req = futu_backend::proto_internal::market_trading_day::RangeTradingDayReq {
4357            begin_date_key: Some(begin_key),
4358            end_date_key: Some(end_key),
4359            market_id: Some(backend_market),
4360        };
4361
4362        let body = prost::Message::encode_to_vec(&range_req);
4363
4364        tracing::debug!(
4365            conn_id,
4366            backend_market,
4367            begin_key,
4368            end_key,
4369            "GetTradeDate: sending CMD6733 RangeTradingDayReq"
4370        );
4371
4372        let resp_frame = match backend.request(6733, body).await {
4373            Ok(f) => f,
4374            Err(e) => {
4375                tracing::error!(conn_id, error = %e, "GetTradeDate: CMD6733 request failed");
4376                return Some(super::make_error_response(-1, "backend request failed"));
4377            }
4378        };
4379
4380        let range_rsp: futu_backend::proto_internal::market_trading_day::RangeTradingDayRsp =
4381            match prost::Message::decode(resp_frame.body.as_ref()) {
4382                Ok(r) => r,
4383                Err(e) => {
4384                    tracing::error!(conn_id, error = %e, "GetTradeDate: CMD6733 decode failed");
4385                    return Some(super::make_error_response(
4386                        -1,
4387                        "backend response decode failed",
4388                    ));
4389                }
4390            };
4391
4392        let code = range_rsp.code.unwrap_or(-1);
4393        if code != 0 {
4394            tracing::warn!(conn_id, code, "GetTradeDate: CMD6733 returned error");
4395            return Some(super::make_error_response(
4396                -1,
4397                "backend trade date request failed",
4398            ));
4399        }
4400
4401        let trade_date_list: Vec<futu_proto::qot_request_trade_date::TradeDate> = range_rsp
4402            .day_infos
4403            .iter()
4404            .map(|day| {
4405                let time_date = day.time_date.unwrap_or(0);
4406                let time_str = yyyymmdd_to_date_str(time_date);
4407                let trading_type = day.trading_type.unwrap_or(4);
4408                let trade_date_type = backend_trading_type_to_ftapi(trading_type);
4409                let ts = day.date_key.unwrap_or(0) as f64;
4410
4411                futu_proto::qot_request_trade_date::TradeDate {
4412                    time: time_str,
4413                    timestamp: Some(ts),
4414                    trade_date_type: Some(trade_date_type),
4415                }
4416            })
4417            .collect();
4418
4419        tracing::debug!(
4420            conn_id,
4421            count = trade_date_list.len(),
4422            "GetTradeDate returning trade dates"
4423        );
4424
4425        let resp = futu_proto::qot_request_trade_date::Response {
4426            ret_type: 0,
4427            ret_msg: None,
4428            err_code: None,
4429            s2c: Some(futu_proto::qot_request_trade_date::S2c { trade_date_list }),
4430        };
4431        Some(prost::Message::encode_to_vec(&resp))
4432    }
4433}
4434
4435// ===== GetSuspend =====
4436// C++ 对应: APIServer_Qot_Suspend::OnClientReq_Suspend
4437// 停牌数据来源是 HTTP 下载的 zip 文件(从 cosgz.myqcloud.com 下载),
4438// 解压后解析 StockSuspend.proto 格式的二进制 .dat 文件,缓存在本地内存中。
4439// 查询时对每只股票用 lower_bound/upper_bound 在已排序的时间戳数组中筛选。
4440struct GetSuspendHandler {
4441    suspend_cache: futu_backend::suspend_data::SuspendCache,
4442    static_cache: Arc<StaticDataCache>,
4443}
4444
4445#[async_trait]
4446impl RequestHandler for GetSuspendHandler {
4447    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
4448        let req: futu_proto::qot_get_suspend::Request =
4449            prost::Message::decode(request.body.as_ref()).ok()?;
4450
4451        let c2s = &req.c2s;
4452
4453        // C++ 流程: APIServer_Qot_Suspend::OnClientReq_Suspend
4454        // 1. 解析 security_list → stock_id
4455        // 2. 解析 begin_time / end_time → timestamp
4456        // 3. 对每只股票调用 GetSuspendData(stockID, startTime, endTime)
4457        //    内部使用 lower_bound/upper_bound 在已排序的时间戳数组中筛选
4458        // 4. 将时间戳转为 API 时间字符串
4459
4460        let security_suspend_list: Vec<futu_proto::qot_get_suspend::SecuritySuspend> = c2s
4461            .security_list
4462            .iter()
4463            .map(|sec| {
4464                // Look up stock_id from static cache
4465                let sec_key = format!("{}_{}", sec.market, sec.code);
4466                let stock_id = self
4467                    .static_cache
4468                    .get_security_info(&sec_key)
4469                    .map(|info| info.stock_id)
4470                    .unwrap_or(0);
4471
4472                // Parse begin_time/end_time to timestamps
4473                let begin_ts = parse_api_time_str(&c2s.begin_time, sec.market).unwrap_or(0);
4474                let end_ts = parse_api_time_str(&c2s.end_time, sec.market).unwrap_or(u64::MAX);
4475
4476                // Query suspend data using binary search (matching C++ lower_bound/upper_bound)
4477                let suspend_list = if stock_id > 0 {
4478                    if let Some(timestamps) = self.suspend_cache.get(&stock_id) {
4479                        // C++ 使用 lower_bound(begin) + upper_bound(end) 在已排序数组上筛选
4480                        let start_idx = timestamps.partition_point(|&t| t < begin_ts);
4481                        let end_idx = timestamps.partition_point(|&t| t <= end_ts);
4482                        timestamps[start_idx..end_idx]
4483                            .iter()
4484                            .map(|&ts| futu_proto::qot_get_suspend::Suspend {
4485                                time: timestamp_to_datetime_str(ts),
4486                                timestamp: Some(ts as f64),
4487                            })
4488                            .collect()
4489                    } else {
4490                        Vec::new()
4491                    }
4492                } else {
4493                    Vec::new()
4494                };
4495
4496                futu_proto::qot_get_suspend::SecuritySuspend {
4497                    security: sec.clone(),
4498                    suspend_list,
4499                }
4500            })
4501            .collect();
4502
4503        tracing::debug!(conn_id, count = security_suspend_list.len(), "GetSuspend");
4504
4505        let resp = futu_proto::qot_get_suspend::Response {
4506            ret_type: 0,
4507            ret_msg: None,
4508            err_code: None,
4509            s2c: Some(futu_proto::qot_get_suspend::S2c {
4510                security_suspend_list,
4511            }),
4512        };
4513        Some(prost::Message::encode_to_vec(&resp))
4514    }
4515}
4516
4517/// 解析 API 时间字符串 "yyyy-MM-dd" 或 "yyyy-MM-dd HH:mm:ss" 为 Unix 时间戳
4518fn parse_api_time_str(s: &str, market: i32) -> Option<u64> {
4519    // 先尝试 "yyyy-MM-dd HH:mm:ss" 格式
4520    if s.len() >= 19 {
4521        let date_part = &s[..10];
4522        let time_part = &s[11..19];
4523        if let Some(day_ts) = date_str_to_timestamp(date_part, market) {
4524            let time_parts: Vec<&str> = time_part.split(':').collect();
4525            if time_parts.len() == 3 {
4526                let h: u64 = time_parts[0].parse().ok()?;
4527                let m: u64 = time_parts[1].parse().ok()?;
4528                let sec: u64 = time_parts[2].parse().ok()?;
4529                return Some(day_ts + h * 3600 + m * 60 + sec);
4530            }
4531        }
4532    }
4533    // 回退到 "yyyy-MM-dd" 格式
4534    date_str_to_timestamp(s, market)
4535}
4536
4537// ===== GetBroker (cache-based) =====
4538struct GetBrokerHandler {
4539    cache: Arc<QotCache>,
4540    static_cache: Arc<StaticDataCache>,
4541}
4542
4543#[async_trait]
4544impl RequestHandler for GetBrokerHandler {
4545    async fn handle(&self, _conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
4546        let req: futu_proto::qot_get_broker::Request =
4547            prost::Message::decode(request.body.as_ref()).ok()?;
4548        let sec = &req.c2s.security;
4549        let sec_key = qot_cache::make_key(sec.market, &sec.code);
4550
4551        let name = self
4552            .static_cache
4553            .get_security_info(&sec_key)
4554            .map(|info| info.name);
4555
4556        let cached = self.cache.get_broker(&sec_key);
4557        let (broker_ask_list, broker_bid_list) = match cached {
4558            Some(b) => {
4559                let to_proto =
4560                    |items: &[qot_cache::CachedBrokerItem]| -> Vec<futu_proto::qot_common::Broker> {
4561                        items
4562                            .iter()
4563                            .map(|item| futu_proto::qot_common::Broker {
4564                                id: item.id,
4565                                name: item.name.clone(),
4566                                pos: item.pos,
4567                                order_id: None,
4568                                volume: None,
4569                            })
4570                            .collect()
4571                    };
4572                (to_proto(&b.ask_list), to_proto(&b.bid_list))
4573            }
4574            None => (Vec::new(), Vec::new()),
4575        };
4576
4577        let resp = futu_proto::qot_get_broker::Response {
4578            ret_type: 0,
4579            ret_msg: None,
4580            err_code: None,
4581            s2c: Some(futu_proto::qot_get_broker::S2c {
4582                security: sec.clone(),
4583                name,
4584                broker_ask_list,
4585                broker_bid_list,
4586            }),
4587        };
4588        Some(prost::Message::encode_to_vec(&resp))
4589    }
4590}
4591
4592// ===== GetOptionChain (CMD 6311 strike dates + CMD 6312 chain) =====
4593struct GetOptionChainHandler {
4594    backend: crate::bridge::SharedBackend,
4595    static_cache: Arc<StaticDataCache>,
4596}
4597
4598#[async_trait]
4599impl RequestHandler for GetOptionChainHandler {
4600    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
4601        let req: futu_proto::qot_get_option_chain::Request =
4602            prost::Message::decode(request.body.as_ref()).ok()?;
4603        let c2s = &req.c2s;
4604
4605        let backend = match super::load_backend(&self.backend) {
4606            Some(b) => b,
4607            None => {
4608                tracing::warn!(conn_id, "GetOptionChain: no backend connection");
4609                return Some(super::make_error_response(-1, "no backend connection"));
4610            }
4611        };
4612
4613        // Resolve owner security to stock_id
4614        let sec_key = format!("{}_{}", c2s.owner.market, c2s.owner.code);
4615        let stock_id = match self.static_cache.get_security_info(&sec_key) {
4616            Some(info) if info.stock_id > 0 => info.stock_id,
4617            _ => {
4618                tracing::warn!(conn_id, sec_key, "GetOptionChain: stock_id not found");
4619                return Some(super::make_error_response(
4620                    -1,
4621                    "security not found in cache",
4622                ));
4623            }
4624        };
4625
4626        // Step 1: Get strike dates via CMD 6311
4627        let strike_req = futu_backend::proto_internal::ftcmd_option_chain::StrikeDateReq {
4628            stock_id: Some(stock_id),
4629        };
4630        let body = prost::Message::encode_to_vec(&strike_req);
4631
4632        tracing::debug!(conn_id, stock_id, "GetOptionChain: sending CMD6311");
4633
4634        let resp_frame = match backend.request(6311, body).await {
4635            Ok(f) => f,
4636            Err(e) => {
4637                tracing::error!(conn_id, error = %e, "GetOptionChain: CMD6311 request failed");
4638                return Some(super::make_error_response(-1, "backend request failed"));
4639            }
4640        };
4641
4642        let strike_rsp: futu_backend::proto_internal::ftcmd_option_chain::StrikeDateRsp =
4643            match prost::Message::decode(resp_frame.body.as_ref()) {
4644                Ok(r) => r,
4645                Err(e) => {
4646                    tracing::error!(conn_id, error = %e, "GetOptionChain: CMD6311 decode failed");
4647                    return Some(super::make_error_response(
4648                        -1,
4649                        "backend response decode failed",
4650                    ));
4651                }
4652            };
4653
4654        if strike_rsp.ret.unwrap_or(1) != 0 {
4655            tracing::warn!(conn_id, ret = ?strike_rsp.ret, "GetOptionChain: CMD6311 error");
4656            return Some(super::make_error_response(
4657                -1,
4658                "backend strike date request failed",
4659            ));
4660        }
4661
4662        // Filter strike dates by begin_time / end_time
4663        let qot_market = c2s.owner.market;
4664        let begin_ts = date_str_to_timestamp(&c2s.begin_time, qot_market).unwrap_or(0);
4665        let end_ts = if c2s.end_time.is_empty() {
4666            u64::MAX
4667        } else {
4668            date_str_to_timestamp(&c2s.end_time, qot_market)
4669                .map(|t| t + 86399)
4670                .unwrap_or(u64::MAX)
4671        };
4672
4673        let filtered_dates: Vec<u32> = strike_rsp
4674            .strike_date_list
4675            .iter()
4676            .copied()
4677            .filter(|&ts| {
4678                let ts64 = ts as u64;
4679                ts64 >= begin_ts && ts64 <= end_ts
4680            })
4681            .collect();
4682
4683        // Step 2: For each strike date, fetch option chain via CMD 6312
4684        let option_type = c2s.r#type.unwrap_or(0) as u32; // 0=all, 1=call, 2=put
4685
4686        let mut option_chain_list: Vec<futu_proto::qot_get_option_chain::OptionChain> = Vec::new();
4687
4688        for &strike_date in &filtered_dates {
4689            let chain_req = futu_backend::proto_internal::ftcmd_option_chain::OptionChainReq {
4690                stock_id: Some(stock_id),
4691                strike_date: Some(strike_date),
4692                option_type: if option_type > 0 {
4693                    Some(option_type)
4694                } else {
4695                    Some(0)
4696                },
4697                sort_type: Some(2), // ASC
4698                sort_id: Some(1),   // by strike price
4699                from: Some(0),
4700                count: Some(200),
4701            };
4702            let body = prost::Message::encode_to_vec(&chain_req);
4703
4704            let resp_frame = match backend.request(6312, body).await {
4705                Ok(f) => f,
4706                Err(e) => {
4707                    tracing::warn!(
4708                        conn_id,
4709                        error = %e,
4710                        strike_date,
4711                        "GetOptionChain: CMD6312 request failed"
4712                    );
4713                    continue;
4714                }
4715            };
4716
4717            let chain_rsp: futu_backend::proto_internal::ftcmd_option_chain::OptionChainRsp =
4718                match prost::Message::decode(resp_frame.body.as_ref()) {
4719                    Ok(r) => r,
4720                    Err(e) => {
4721                        tracing::warn!(conn_id, error = %e, "GetOptionChain: CMD6312 decode failed");
4722                        continue;
4723                    }
4724                };
4725
4726            if chain_rsp.ret.unwrap_or(1) != 0 {
4727                continue;
4728            }
4729
4730            let strike_time_str = timestamp_to_date_str(strike_date as u64);
4731
4732            let options: Vec<futu_proto::qot_get_option_chain::OptionItem> = chain_rsp
4733                .option_chain
4734                .iter()
4735                .map(|item| {
4736                    let call_info = item.call_option.as_ref().map(|opt| {
4737                        backend_option_to_static_info(opt, c2s.owner.market, strike_date)
4738                    });
4739                    let put_info = item.put_option.as_ref().map(|opt| {
4740                        backend_option_to_static_info(opt, c2s.owner.market, strike_date)
4741                    });
4742                    futu_proto::qot_get_option_chain::OptionItem {
4743                        call: call_info,
4744                        put: put_info,
4745                    }
4746                })
4747                .collect();
4748
4749            option_chain_list.push(futu_proto::qot_get_option_chain::OptionChain {
4750                strike_time: strike_time_str,
4751                option: options,
4752                strike_timestamp: Some(strike_date as f64),
4753            });
4754        }
4755
4756        tracing::debug!(
4757            conn_id,
4758            chain_count = option_chain_list.len(),
4759            "GetOptionChain returning"
4760        );
4761
4762        let resp = futu_proto::qot_get_option_chain::Response {
4763            ret_type: 0,
4764            ret_msg: None,
4765            err_code: None,
4766            s2c: Some(futu_proto::qot_get_option_chain::S2c {
4767                option_chain: option_chain_list,
4768            }),
4769        };
4770        Some(prost::Message::encode_to_vec(&resp))
4771    }
4772}
4773
4774/// Convert backend Option to FTAPI SecurityStaticInfo for option chain
4775fn backend_option_to_static_info(
4776    opt: &futu_backend::proto_internal::ftcmd_option_chain::Option,
4777    market: i32,
4778    strike_date: u32,
4779) -> futu_proto::qot_common::SecurityStaticInfo {
4780    let code = opt
4781        .option_string_code
4782        .clone()
4783        .unwrap_or_else(|| opt.option_id.unwrap_or(0).to_string());
4784
4785    let strike_price_raw = opt.hp_strike_price.unwrap_or(0);
4786    let strike_price = strike_price_raw as f64 / 1_000_000_000.0;
4787
4788    let option_type = opt.option_type.unwrap_or(0);
4789    // FTAPI OptionType: 1=Call, 2=Put
4790    let ftapi_option_type = match option_type {
4791        1 => 1,
4792        2 => 2,
4793        _ => 0,
4794    };
4795
4796    let name = opt.option_name.clone().unwrap_or_default();
4797    let strike_time_str = timestamp_to_date_str(strike_date as u64);
4798
4799    futu_proto::qot_common::SecurityStaticInfo {
4800        basic: futu_proto::qot_common::SecurityStaticBasic {
4801            security: futu_proto::qot_common::Security {
4802                market,
4803                code: code.clone(),
4804            },
4805            id: opt.option_id.unwrap_or(0) as i64,
4806            lot_size: opt.contract_share_size.unwrap_or(100) as i32,
4807            sec_type: 8, // SecurityType_Drvt (derivative/option)
4808            name: name.clone(),
4809            list_time: String::new(),
4810            delisting: opt.delisting_flag.map(|f| f != 0),
4811            list_timestamp: None,
4812            exch_type: None,
4813        },
4814        warrant_ex_data: None,
4815        option_ex_data: Some(futu_proto::qot_common::OptionStaticExData {
4816            r#type: ftapi_option_type,
4817            owner: futu_proto::qot_common::Security {
4818                market,
4819                code: String::new(),
4820            },
4821            strike_time: strike_time_str,
4822            strike_price,
4823            suspend: opt.suspend_flag.unwrap_or(0) != 0,
4824            market: opt.market.clone().unwrap_or_default(),
4825            index_option_type: None,
4826            strike_timestamp: Some(strike_date as f64),
4827            expiration_cycle: None,
4828            option_standard_type: None,
4829            option_settlement_mode: None,
4830        }),
4831        future_ex_data: None,
4832    }
4833}
4834
4835// ===== GetReference (关联股票查询) =====
4836// C++ 流程 (APIServer_Qot_StockList::OnClientReq_Reference):
4837// - ReferenceType_Warrant(1): 本地 SecList 搜索该正股对应的窝轮列表 (INNBiz_Qot_SecList::SearchWarrantByOwnerID)
4838//   使用 StaticDataCache.owner_to_warrants 反向映射实现
4839// - ReferenceType_Future(2): 通过后端 CMD 6701 (PullFutureRelated) 拉取相关期货合约
4840//   C++ 中仅当输入为期货类型且 code 包含 "main" 时才发请求,否则返回空
4841struct GetReferenceHandler {
4842    backend: crate::bridge::SharedBackend,
4843    static_cache: Arc<StaticDataCache>,
4844}
4845
4846#[async_trait]
4847impl RequestHandler for GetReferenceHandler {
4848    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
4849        let req: futu_proto::qot_get_reference::Request =
4850            prost::Message::decode(request.body.as_ref()).ok()?;
4851
4852        let ref_type = req.c2s.reference_type;
4853        let security = &req.c2s.security;
4854        let key = futu_cache::qot_cache::make_key(security.market, &security.code);
4855
4856        tracing::debug!(
4857            conn_id,
4858            ref_type,
4859            code = %security.code,
4860            market = security.market,
4861            "GetReference"
4862        );
4863
4864        // ReferenceType: 0=Unknow, 1=Warrant, 2=Future
4865        match ref_type {
4866            1 => {
4867                // Warrant: 从 static_cache 的 owner_to_warrants 映射中搜索
4868                let stock_info = self.static_cache.get_security_info(&key);
4869                let stock_id = stock_info.map(|i| i.stock_id).unwrap_or(0);
4870
4871                let warrant_ids = if stock_id > 0 {
4872                    self.static_cache.search_warrants_by_owner(stock_id)
4873                } else {
4874                    Vec::new()
4875                };
4876
4877                let static_info_list: Vec<futu_proto::qot_common::SecurityStaticInfo> = warrant_ids
4878                    .iter()
4879                    .filter_map(|&wid| {
4880                        let wkey = self.static_cache.id_to_key.get(&wid)?;
4881                        let winfo = self.static_cache.get_security_info(wkey.value())?;
4882                        // 过滤掉 market=0 (Unknown) 的窝轮,与 C++ 行为一致
4883                        if winfo.market == 0 {
4884                            return None;
4885                        }
4886                        Some(make_static_info(
4887                            futu_proto::qot_common::Security {
4888                                market: winfo.market,
4889                                code: winfo.code.clone(),
4890                            },
4891                            &winfo,
4892                        ))
4893                    })
4894                    .collect();
4895
4896                tracing::debug!(
4897                    conn_id,
4898                    count = static_info_list.len(),
4899                    "GetReference(Warrant) returning securities"
4900                );
4901
4902                let resp = futu_proto::qot_get_reference::Response {
4903                    ret_type: 0,
4904                    ret_msg: None,
4905                    err_code: None,
4906                    s2c: Some(futu_proto::qot_get_reference::S2c { static_info_list }),
4907                };
4908                Some(prost::Message::encode_to_vec(&resp))
4909            }
4910            2 => {
4911                // Future: 通过后端 CMD 6701 拉取相关期货合约
4912                // C++ 中仅当 sec_type 为 Future 且 code 包含 "main" 时才发请求
4913                let stock_info = self.static_cache.get_security_info(&key);
4914                let (stock_id, sec_type) = stock_info
4915                    .map(|i| (i.stock_id, i.sec_type))
4916                    .unwrap_or((0, 0));
4917
4918                // sec_type 6 = SecurityType_Future (C++ NN_QuoteSecurityType_Future)
4919                // 非期货类型或非主连合约,直接返回空 (与 C++ 行为一致)
4920                if sec_type != 6 || !security.code.contains("main") {
4921                    tracing::debug!(
4922                        conn_id,
4923                        sec_type,
4924                        code = %security.code,
4925                        "GetReference(Future): not a main future contract, returning empty"
4926                    );
4927                    let resp = futu_proto::qot_get_reference::Response {
4928                        ret_type: 0,
4929                        ret_msg: None,
4930                        err_code: None,
4931                        s2c: Some(futu_proto::qot_get_reference::S2c {
4932                            static_info_list: Vec::new(),
4933                        }),
4934                    };
4935                    return Some(prost::Message::encode_to_vec(&resp));
4936                }
4937
4938                let backend = match super::load_backend(&self.backend) {
4939                    Some(b) => b,
4940                    None => {
4941                        tracing::warn!(conn_id, "GetReference(Future): no backend connection");
4942                        return Some(super::make_error_response(-1, "Network interruption"));
4943                    }
4944                };
4945
4946                // 发送 CMD 6701 到后端
4947                let backend_req =
4948                    futu_backend::proto_internal::ft_cmd_hp_plate::PlateUsFutrueRelatedListReq {
4949                        future_id: Some(stock_id),
4950                    };
4951                let body = prost::Message::encode_to_vec(&backend_req);
4952
4953                let frame = match backend.request(6701, body).await {
4954                    Ok(f) => f,
4955                    Err(e) => {
4956                        tracing::error!(conn_id, error = %e, "CMD6701 request failed");
4957                        return Some(super::make_error_response(-1, "pull future related failed"));
4958                    }
4959                };
4960
4961                let rsp: futu_backend::proto_internal::ft_cmd_hp_plate::PlateUsFutrueRelatedListRsp =
4962                    match prost::Message::decode(frame.body.as_ref()) {
4963                        Ok(r) => r,
4964                        Err(e) => {
4965                            tracing::error!(conn_id, error = %e, "CMD6701 decode failed");
4966                            return Some(super::make_error_response(
4967                                -1,
4968                                "decode future related response failed",
4969                            ));
4970                        }
4971                    };
4972
4973                if rsp.result != 0 {
4974                    tracing::warn!(conn_id, result = rsp.result, "CMD6701 returned error");
4975                    return Some(super::make_error_response(
4976                        -1,
4977                        "backend future related query failed",
4978                    ));
4979                }
4980
4981                // 提取 security_id 列表,查找 static_cache 构建 SecurityStaticInfo
4982                let static_info_list: Vec<futu_proto::qot_common::SecurityStaticInfo> = rsp
4983                    .security_qta_list
4984                    .iter()
4985                    .filter_map(|sq| {
4986                        let sid = sq.security_id?;
4987                        let skey = self.static_cache.id_to_key.get(&sid)?;
4988                        let sinfo = self.static_cache.get_security_info(skey.value())?;
4989                        Some(make_static_info(
4990                            futu_proto::qot_common::Security {
4991                                market: sinfo.market,
4992                                code: sinfo.code.clone(),
4993                            },
4994                            &sinfo,
4995                        ))
4996                    })
4997                    .collect();
4998
4999                tracing::debug!(
5000                    conn_id,
5001                    count = static_info_list.len(),
5002                    "GetReference(Future) returning securities"
5003                );
5004
5005                let resp = futu_proto::qot_get_reference::Response {
5006                    ret_type: 0,
5007                    ret_msg: None,
5008                    err_code: None,
5009                    s2c: Some(futu_proto::qot_get_reference::S2c { static_info_list }),
5010                };
5011                Some(prost::Message::encode_to_vec(&resp))
5012            }
5013            _ => {
5014                // ReferenceType_Unknow or invalid
5015                Some(super::make_error_response(-1, "unsupported reference type"))
5016            }
5017        }
5018    }
5019}
5020
5021// ===== GetHoldingChangeList (已废弃, C++ 返回 NNData_StaticText_HoldingChangeNoMoreSupport) =====
5022struct GetHoldingChangeListHandler;
5023
5024#[async_trait]
5025impl RequestHandler for GetHoldingChangeListHandler {
5026    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
5027        let _req: futu_proto::qot_get_holding_change_list::Request =
5028            prost::Message::decode(request.body.as_ref()).ok()?;
5029        // C++ 源码中此接口已被废弃,直接返回错误信息,不再查询后端数据。
5030        // 参见: APIServer_Qot_HoldingChange.cpp → strErrDesc = StcText(NNData_StaticText_HoldingChangeNoMoreSupport)
5031        tracing::debug!(
5032            conn_id,
5033            "GetHoldingChangeList: interface deprecated since 2020-12-21"
5034        );
5035        Some(super::make_error_response(
5036            -1,
5037            "Due to the reasons of the upstream data provider, the interface for Get Major Shareholders' Shareholding Changes (protocol ID:3208) will be abandoned after 2020-12-21",
5038        ))
5039    }
5040}
5041
5042// ===== GetUserSecurity (CMD 5121 拉分组 + CMD 5120 拉股票列表) =====
5043struct GetUserSecurityHandler {
5044    backend: crate::bridge::SharedBackend,
5045    static_cache: Arc<StaticDataCache>,
5046    app_lang: i32,
5047}
5048
5049#[async_trait]
5050impl RequestHandler for GetUserSecurityHandler {
5051    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
5052        let req: futu_proto::qot_get_user_security::Request =
5053            prost::Message::decode(request.body.as_ref()).ok()?;
5054        let group_name = &req.c2s.group_name;
5055
5056        let backend = match super::load_backend(&self.backend) {
5057            Some(b) => b,
5058            None => {
5059                tracing::warn!(conn_id, "GetUserSecurity: no backend connection");
5060                return Some(super::make_error_response(-1, "no backend connection"));
5061            }
5062        };
5063
5064        let user_id = backend.user_id.load(std::sync::atomic::Ordering::Relaxed) as u64;
5065
5066        // Step 1: 拉取分组信息 (CMD 5121)
5067        let group_req = futu_backend::proto_internal::wch_lst::GetGroupListReq {
5068            user_id: Some(user_id),
5069        };
5070        let group_body = prost::Message::encode_to_vec(&group_req);
5071        tracing::debug!(conn_id, %group_name, "GetUserSecurity: sending CMD5121");
5072
5073        let group_frame = match backend.request(5121, group_body).await {
5074            Ok(f) => f,
5075            Err(e) => {
5076                tracing::error!(conn_id, error = %e, "CMD5121 request failed");
5077                return Some(super::make_error_response(-1, "pull group info failed"));
5078            }
5079        };
5080
5081        // CMD5121 响应的 SRPC field 5 包含 repeated GroupInfo(不是 GetGroupListResp),
5082        // 需要使用专用解码函数
5083        let (group_rsp, group_langs) = super::decode_cmd5121_groups(group_frame.body.as_ref());
5084
5085        if group_rsp.result_code.unwrap_or(-1) != 0 {
5086            tracing::warn!(conn_id, ret = ?group_rsp.result_code, "CMD5121 returned error");
5087            return Some(super::make_error_response(-1, "pull group info failed"));
5088        }
5089
5090        // Step 2: 通过 groupName 找到 groupID
5091        // 优先匹配 multi_lang_name(按客户端语言),再匹配 group_name,最后用硬编码映射
5092        let app_lang = self.app_lang;
5093        let group_id = group_rsp
5094            .group_list
5095            .iter()
5096            .enumerate()
5097            .find(|(i, g)| {
5098                // 先检查 multi_lang_name
5099                if let Some(langs) = group_langs.get(*i) {
5100                    if langs
5101                        .iter()
5102                        .any(|ml| ml.language_id == app_lang && ml.name == group_name.as_str())
5103                    {
5104                        return true;
5105                    }
5106                }
5107                // 再检查 group_name
5108                g.group_name.as_deref() == Some(group_name.as_str())
5109            })
5110            .map(|(_, g)| g.group_id.unwrap_or(0))
5111            .or_else(|| system_group_name_to_id(group_name))
5112            .unwrap_or(0);
5113        if group_id == 0 {
5114            tracing::warn!(conn_id, %group_name, "GetUserSecurity: unknown group name");
5115            return Some(super::make_error_response(
5116                -1,
5117                "unknown user security group",
5118            ));
5119        }
5120
5121        // 不支持的系统分组: 基金宝(890), 外汇(891), 持仓(896)
5122        if group_id == 890 || group_id == 891 || group_id == 896 {
5123            return Some(super::make_error_response(
5124                -1,
5125                "unsupported user security group",
5126            ));
5127        }
5128
5129        // Step 3: 拉取该分组的股票列表 (CMD 5120)
5130        let stock_req = futu_backend::proto_internal::wch_lst::GetStockListReq {
5131            user_id: Some(user_id),
5132            group_id: Some(group_id),
5133            is_case_top: None,
5134        };
5135        let stock_body = prost::Message::encode_to_vec(&stock_req);
5136        tracing::info!(conn_id, group_id, "GetUserSecurity: sending CMD5120");
5137
5138        let stock_frame = match backend.request(5120, stock_body).await {
5139            Ok(f) => f,
5140            Err(e) => {
5141                tracing::error!(conn_id, error = %e, "CMD5120 request failed");
5142                return Some(super::make_error_response(-1, "backend request failed"));
5143            }
5144        };
5145
5146        // CMD5120 响应可能被 SRPC 封装
5147        let body = stock_frame.body.as_ref();
5148
5149        let stock_rsp = super::decode_srpc_or_direct::<
5150            futu_backend::proto_internal::wch_lst::GetStockListResp,
5151        >(body, |r| {
5152            r.result_code == Some(0) && r.stock_list.len() == r.stock_count.unwrap_or(0) as usize
5153        });
5154
5155        tracing::info!(conn_id, result_code = ?stock_rsp.result_code,
5156            stock_list_len = stock_rsp.stock_list.len(),
5157            "CMD5120 response");
5158
5159        if stock_rsp.result_code.unwrap_or(0) != 0 && stock_rsp.stock_list.is_empty() {
5160            return Some(super::make_error_response(-1, "pull stock list failed"));
5161        }
5162
5163        // Step 4: stock_id -> SecurityStaticInfo
5164        let mut miss_id_count = 0u32;
5165        let mut miss_info_count = 0u32;
5166        let static_info_list: Vec<futu_proto::qot_common::SecurityStaticInfo> = stock_rsp
5167            .stock_list
5168            .iter()
5169            .filter_map(|si| {
5170                let stock_id = si.stock_id?;
5171                let key = match self.static_cache.id_to_key.get(&stock_id) {
5172                    Some(k) => k,
5173                    None => {
5174                        miss_id_count += 1;
5175                        if miss_id_count <= 3 {
5176                            tracing::debug!(stock_id, "GetUserSecurity: stock_id not in id_to_key");
5177                        }
5178                        return None;
5179                    }
5180                };
5181                let info = match self.static_cache.get_security_info(key.value()) {
5182                    Some(i) => i,
5183                    None => {
5184                        miss_info_count += 1;
5185                        return None;
5186                    }
5187                };
5188                let security = futu_proto::qot_common::Security {
5189                    market: info.market,
5190                    code: info.code.clone(),
5191                };
5192                Some(make_static_info(security, &info))
5193            })
5194            .collect();
5195
5196        tracing::info!(
5197            conn_id,
5198            total = stock_rsp.stock_list.len(),
5199            found = static_info_list.len(),
5200            miss_id = miss_id_count,
5201            miss_info = miss_info_count,
5202            "GetUserSecurity result"
5203        );
5204
5205        let resp = futu_proto::qot_get_user_security::Response {
5206            ret_type: 0,
5207            ret_msg: None,
5208            err_code: None,
5209            s2c: Some(futu_proto::qot_get_user_security::S2c { static_info_list }),
5210        };
5211        Some(prost::Message::encode_to_vec(&resp))
5212    }
5213}
5214
5215// ===== ModifyUserSecurity (CMD 6682 修改自选股) =====
5216struct ModifyUserSecurityHandler {
5217    backend: crate::bridge::SharedBackend,
5218    static_cache: Arc<StaticDataCache>,
5219}
5220
5221#[async_trait]
5222impl RequestHandler for ModifyUserSecurityHandler {
5223    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
5224        let req: futu_proto::qot_modify_user_security::Request =
5225            prost::Message::decode(request.body.as_ref()).ok()?;
5226        let c2s = &req.c2s;
5227        let group_name = &c2s.group_name;
5228        let op = c2s.op;
5229
5230        let backend = match super::load_backend(&self.backend) {
5231            Some(b) => b,
5232            None => {
5233                tracing::warn!(conn_id, "ModifyUserSecurity: no backend connection");
5234                return Some(super::make_error_response(-1, "no backend connection"));
5235            }
5236        };
5237
5238        // 空股票列表时直接返回成功 (与 C++ 一致)
5239        if c2s.security_list.is_empty() {
5240            let resp = futu_proto::qot_modify_user_security::Response {
5241                ret_type: 0,
5242                ret_msg: None,
5243                err_code: None,
5244                s2c: Some(futu_proto::qot_modify_user_security::S2c {}),
5245            };
5246            return Some(prost::Message::encode_to_vec(&resp));
5247        }
5248
5249        // Resolve stock IDs
5250        let mut stock_ids = Vec::new();
5251        for sec in &c2s.security_list {
5252            let sec_key = format!("{}_{}", sec.market, sec.code);
5253            if let Some(info) = self.static_cache.get_security_info(&sec_key) {
5254                if info.stock_id > 0 {
5255                    stock_ids.push(info.stock_id);
5256                }
5257            }
5258        }
5259
5260        if stock_ids.is_empty() {
5261            tracing::warn!(conn_id, "ModifyUserSecurity: no valid stock_ids found");
5262            return Some(super::make_error_response(-1, "unknown stock"));
5263        }
5264
5265        let user_id = backend.user_id.load(std::sync::atomic::Ordering::Relaxed) as u64;
5266
5267        // 拉取分组信息 (CMD 5121)
5268        let group_req = futu_backend::proto_internal::wch_lst::GetGroupListReq {
5269            user_id: Some(user_id),
5270        };
5271        let group_body = prost::Message::encode_to_vec(&group_req);
5272
5273        let group_frame = match backend.request(5121, group_body).await {
5274            Ok(f) => f,
5275            Err(e) => {
5276                tracing::error!(conn_id, error = %e, "CMD5121 request failed");
5277                return Some(super::make_error_response(-1, "pull group info failed"));
5278            }
5279        };
5280
5281        // CMD5121 响应的 SRPC field 5 包含 repeated GroupInfo
5282        let (group_rsp, _group_langs) = super::decode_cmd5121_groups(group_frame.body.as_ref());
5283
5284        if group_rsp.result_code.unwrap_or(-1) != 0 {
5285            return Some(super::make_error_response(-1, "pull group info failed"));
5286        }
5287
5288        let group_id = match group_rsp
5289            .group_list
5290            .iter()
5291            .find(|g| g.group_name.as_deref() == Some(group_name.as_str()))
5292        {
5293            Some(g) => g.group_id.unwrap_or(0),
5294            None => {
5295                return Some(super::make_error_response(
5296                    -1,
5297                    "unknown user security group",
5298                ));
5299            }
5300        };
5301
5302        // 系统分组不可修改
5303        let is_system = (group_id > 0 && group_id < 900) || group_id == 1000;
5304        if is_system {
5305            return Some(super::make_error_response(
5306                -1,
5307                "system group cannot be modified",
5308            ));
5309        }
5310
5311        let action = match op {
5312            1 => 1u32,
5313            2 => 2u32,
5314            3 => 2u32, // MoveOut -> Del from this group only
5315            _ => {
5316                return Some(super::make_error_response(-1, "unknown modify operation"));
5317            }
5318        };
5319
5320        // Add: 先添加到"全部"分组(1000)
5321        if op == 1 && group_id != 1000 {
5322            let all_req = futu_backend::proto_internal::wch_lst::SetStockReq {
5323                action: Some(1),
5324                group_id: Some(1000),
5325                stock_info: stock_ids
5326                    .iter()
5327                    .map(|&id| futu_backend::proto_internal::wch_lst::StockInfo {
5328                        stock_id: Some(id),
5329                        is_top: None,
5330                    })
5331                    .collect(),
5332            };
5333            let _ = backend
5334                .request(6682, prost::Message::encode_to_vec(&all_req))
5335                .await;
5336        }
5337
5338        // 发送目标分组操作 (CMD 6682)
5339        let set_req = futu_backend::proto_internal::wch_lst::SetStockReq {
5340            action: Some(action),
5341            group_id: Some(group_id),
5342            stock_info: stock_ids
5343                .iter()
5344                .map(|&id| futu_backend::proto_internal::wch_lst::StockInfo {
5345                    stock_id: Some(id),
5346                    is_top: None,
5347                })
5348                .collect(),
5349        };
5350
5351        tracing::debug!(
5352            conn_id,
5353            group_id,
5354            op,
5355            count = stock_ids.len(),
5356            "ModifyUserSecurity: sending CMD6682"
5357        );
5358
5359        let set_frame = match backend
5360            .request(6682, prost::Message::encode_to_vec(&set_req))
5361            .await
5362        {
5363            Ok(f) => f,
5364            Err(e) => {
5365                tracing::error!(conn_id, error = %e, "CMD6682 request failed");
5366                return Some(super::make_error_response(-1, "backend request failed"));
5367            }
5368        };
5369
5370        let set_rsp: futu_backend::proto_internal::wch_lst::SetStockResp =
5371            match prost::Message::decode(set_frame.body.as_ref()) {
5372                Ok(r) => r,
5373                Err(e) => {
5374                    tracing::error!(conn_id, error = %e, "CMD6682 decode failed");
5375                    return Some(super::make_error_response(
5376                        -1,
5377                        "backend response decode failed",
5378                    ));
5379                }
5380            };
5381
5382        if set_rsp.result_code.unwrap_or(-1) != 0 {
5383            let msg = if set_rsp.result_code == Some(3) {
5384                "user security total number exceeded"
5385            } else {
5386                "modify user security failed"
5387            };
5388            return Some(super::make_error_response(-1, msg));
5389        }
5390
5391        // Del: 从其他所有非系统分组 + "全部"分组也删除 (与 C++ 一致)
5392        if op == 2 {
5393            let mut other_groups = vec![1000u32];
5394            for g in &group_rsp.group_list {
5395                let gid = g.group_id.unwrap_or(0);
5396                let g_is_system = (gid > 0 && gid < 900) || gid == 1000;
5397                if !g_is_system && gid != group_id {
5398                    other_groups.push(gid);
5399                }
5400            }
5401            for gid in other_groups {
5402                let del_req = futu_backend::proto_internal::wch_lst::SetStockReq {
5403                    action: Some(2),
5404                    group_id: Some(gid),
5405                    stock_info: stock_ids
5406                        .iter()
5407                        .map(|&id| futu_backend::proto_internal::wch_lst::StockInfo {
5408                            stock_id: Some(id),
5409                            is_top: None,
5410                        })
5411                        .collect(),
5412                };
5413                let _ = backend
5414                    .request(6682, prost::Message::encode_to_vec(&del_req))
5415                    .await;
5416            }
5417        }
5418
5419        let resp = futu_proto::qot_modify_user_security::Response {
5420            ret_type: 0,
5421            ret_msg: None,
5422            err_code: None,
5423            s2c: Some(futu_proto::qot_modify_user_security::S2c {}),
5424        };
5425        Some(prost::Message::encode_to_vec(&resp))
5426    }
5427}
5428
5429// ===== StockFilter (条件选股, 后端 CMD 9010 Screen + CMD 9011 Retrieve) =====
5430// C++ 流程 (APIServer_Qot_StockFilter + NNBiz_Qot_StockFilter):
5431// 1. 将 FTAPI Qot_StockFilter 请求中的过滤条件转换为后端 FTCmdStockScreener::ScreenRequest
5432//    - 涉及 SimpleProperty, CumulativeProperty, FinancialProperty, PatternProperty,
5433//      CustomIndicator 等数百个字段的枚举映射
5434// 2. 发送 CMD 9010 (ScreenRequest) 到后端,获取符合条件的 stock_id 列表 (ScreenResponse)
5435// 3. 再发送 CMD 9011 (RetrieveRequest) 获取这些股票的详细数据 (RetrieveResponse)
5436// 4. 将后端返回的数据转换回 FTAPI StockData 格式
5437struct StockFilterHandler {
5438    backend: crate::bridge::SharedBackend,
5439    static_cache: Arc<StaticDataCache>,
5440}
5441
5442#[async_trait]
5443impl RequestHandler for StockFilterHandler {
5444    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
5445        let req: futu_proto::qot_stock_filter::Request =
5446            prost::Message::decode(request.body.as_ref()).ok()?;
5447        let c2s = &req.c2s;
5448
5449        let backend = match super::load_backend(&self.backend) {
5450            Some(b) => b,
5451            None => {
5452                tracing::warn!(conn_id, "StockFilter: no backend connection");
5453                return Some(super::make_error_response(-1, "no backend connection"));
5454            }
5455        };
5456
5457        // 参数校验 (与 C++ 一致)
5458        if c2s.begin < 0 {
5459            return Some(super::make_error_response(-1, "begin must be >= 0"));
5460        }
5461        if c2s.num < 0 {
5462            return Some(super::make_error_response(-1, "num must be >= 0"));
5463        }
5464        if c2s.num > 200 {
5465            return Some(super::make_error_response(-1, "num exceeds limit 200"));
5466        }
5467
5468        let nn_market = stock_filter_market_api_to_nn(c2s.market);
5469        if nn_market == 0 {
5470            return Some(super::make_error_response(-1, "unsupported market"));
5471        }
5472
5473        tracing::debug!(
5474            conn_id,
5475            market = c2s.market,
5476            begin = c2s.begin,
5477            num = c2s.num,
5478            base_filters = c2s.base_filter_list.len(),
5479            accumulate_filters = c2s.accumulate_filter_list.len(),
5480            financial_filters = c2s.financial_filter_list.len(),
5481            pattern_filters = c2s.pattern_filter_list.len(),
5482            custom_filters = c2s.custom_indicator_filter_list.len(),
5483            "StockFilter: building CMD 9010 ScreenRequest"
5484        );
5485
5486        // ===== Step 1: 构建 CMD 9010 ScreenRequest =====
5487        let mut screen_req = futu_backend::proto_internal::ft_cmd_stock_screener::ScreenRequest {
5488            market: nn_market,
5489            ..Default::default()
5490        };
5491
5492        // 板块过滤
5493        if let Some(plate) = &c2s.plate {
5494            if !plate.code.is_empty() {
5495                if let Some(plate_id) = stock_filter_security_to_id(&self.static_cache, plate) {
5496                    screen_req.categories.push(plate_id);
5497                }
5498            }
5499        }
5500
5501        // 构建简单属性查询 (base_filter_list)
5502        stock_filter_build_base_queries(c2s, &mut screen_req);
5503
5504        // 构建累积属性查询 (accumulate_filter_list)
5505        stock_filter_build_accumulate_queries(c2s, &mut screen_req);
5506
5507        // 构建财务属性查询 (financial_filter_list)
5508        stock_filter_build_financial_queries(c2s, nn_market, &mut screen_req);
5509
5510        // 构建形态指标查询 (pattern_filter_list)
5511        stock_filter_build_pattern_queries(c2s, &mut screen_req);
5512
5513        // 构建自定义指标查询 (custom_indicator_filter_list)
5514        stock_filter_build_custom_queries(c2s, &mut screen_req);
5515
5516        // 构建排序条件
5517        stock_filter_build_sort(c2s, &mut screen_req);
5518
5519        // ===== Step 2: 发送 CMD 9010 =====
5520        let screen_body = prost::Message::encode_to_vec(&screen_req);
5521        tracing::debug!(
5522            conn_id,
5523            body_len = screen_body.len(),
5524            "sending CMD 9010 ScreenRequest"
5525        );
5526
5527        let screen_frame = match backend.request(9010, screen_body).await {
5528            Ok(f) => f,
5529            Err(e) => {
5530                tracing::error!(conn_id, error = %e, "CMD 9010 request failed");
5531                return Some(super::make_error_response(
5532                    -1,
5533                    "backend screen request failed",
5534                ));
5535            }
5536        };
5537
5538        let screen_rsp: futu_backend::proto_internal::ft_cmd_stock_screener::ScreenResponse =
5539            match prost::Message::decode(screen_frame.body.as_ref()) {
5540                Ok(r) => r,
5541                Err(e) => {
5542                    tracing::error!(conn_id, error = %e, "CMD 9010 decode failed");
5543                    return Some(super::make_error_response(
5544                        -1,
5545                        "backend screen response decode failed",
5546                    ));
5547                }
5548            };
5549
5550        if screen_rsp.result_code != 0 {
5551            tracing::warn!(
5552                conn_id,
5553                result = screen_rsp.result_code,
5554                "CMD 9010 returned error"
5555            );
5556            return Some(super::make_error_response(
5557                -1,
5558                "backend screen request failed",
5559            ));
5560        }
5561
5562        let all_stock_ids = &screen_rsp.stock_ids;
5563        let all_count = all_stock_ids.len() as i32;
5564
5565        // 分页: 与 C++ 一致
5566        let real_begin = 0.max(c2s.begin.min(all_count)) as usize;
5567        let real_count = 0.max(c2s.num.min(all_count - real_begin as i32)) as usize;
5568        let last_page = (real_begin + real_count) >= all_count as usize;
5569
5570        let paged_ids: Vec<u64> = if real_count > 0 {
5571            all_stock_ids[real_begin..real_begin + real_count].to_vec()
5572        } else {
5573            Vec::new()
5574        };
5575
5576        if paged_ids.is_empty() {
5577            let resp = futu_proto::qot_stock_filter::Response {
5578                ret_type: 0,
5579                ret_msg: None,
5580                err_code: None,
5581                s2c: Some(futu_proto::qot_stock_filter::S2c {
5582                    last_page: true,
5583                    all_count,
5584                    data_list: Vec::new(),
5585                }),
5586            };
5587            return Some(prost::Message::encode_to_vec(&resp));
5588        }
5589
5590        // ===== Step 3: 构建 CMD 9011 RetrieveRequest =====
5591        let mut retrieve_req =
5592            futu_backend::proto_internal::ft_cmd_stock_screener::RetrieveRequest {
5593                stock_ids: paged_ids.clone(),
5594                ..Default::default()
5595            };
5596
5597        // 默认返回 code + name
5598        retrieve_req.basic_properties.push(
5599            futu_backend::proto_internal::ft_cmd_stock_screener::PropertyBasic {
5600                name: Some(1101), // PROPERTY_BASIC_INFO_CODE
5601            },
5602        );
5603        retrieve_req.basic_properties.push(
5604            futu_backend::proto_internal::ft_cmd_stock_screener::PropertyBasic {
5605                name: Some(1102), // PROPERTY_BASIC_INFO_NAME
5606            },
5607        );
5608
5609        // 根据筛选条件添加 retrieve 属性 (与 C++ StructInnerToPb 对应)
5610        stock_filter_build_retrieve_properties(c2s, &mut retrieve_req);
5611
5612        // ===== Step 4: 发送 CMD 9011 =====
5613        let retrieve_body = prost::Message::encode_to_vec(&retrieve_req);
5614        tracing::debug!(
5615            conn_id,
5616            body_len = retrieve_body.len(),
5617            stock_count = paged_ids.len(),
5618            "sending CMD 9011 RetrieveRequest"
5619        );
5620
5621        let retrieve_frame = match backend.request(9011, retrieve_body).await {
5622            Ok(f) => f,
5623            Err(e) => {
5624                tracing::error!(conn_id, error = %e, "CMD 9011 request failed");
5625                return Some(super::make_error_response(
5626                    -1,
5627                    "backend retrieve request failed",
5628                ));
5629            }
5630        };
5631
5632        let retrieve_rsp: futu_backend::proto_internal::ft_cmd_stock_screener::RetrieveResponse =
5633            match prost::Message::decode(retrieve_frame.body.as_ref()) {
5634                Ok(r) => r,
5635                Err(e) => {
5636                    tracing::error!(conn_id, error = %e, "CMD 9011 decode failed");
5637                    return Some(super::make_error_response(
5638                        -1,
5639                        "backend retrieve response decode failed",
5640                    ));
5641                }
5642            };
5643
5644        if retrieve_rsp.result_code != 0 {
5645            tracing::warn!(
5646                conn_id,
5647                result = retrieve_rsp.result_code,
5648                "CMD 9011 returned error"
5649            );
5650            return Some(super::make_error_response(
5651                -1,
5652                "backend retrieve request failed",
5653            ));
5654        }
5655
5656        // ===== Step 5: 将 RetrieveResponse 转换为 FTAPI StockData =====
5657        let data_list: Vec<futu_proto::qot_stock_filter::StockData> = retrieve_rsp
5658            .items
5659            .iter()
5660            .filter_map(|item| self.stock_filter_item_to_stock_data(item))
5661            .collect();
5662
5663        tracing::debug!(
5664            conn_id,
5665            all_count,
5666            returned = data_list.len(),
5667            last_page,
5668            "StockFilter returning data"
5669        );
5670
5671        let resp = futu_proto::qot_stock_filter::Response {
5672            ret_type: 0,
5673            ret_msg: None,
5674            err_code: None,
5675            s2c: Some(futu_proto::qot_stock_filter::S2c {
5676                last_page,
5677                all_count,
5678                data_list,
5679            }),
5680        };
5681        Some(prost::Message::encode_to_vec(&resp))
5682    }
5683}
5684
5685impl StockFilterHandler {
5686    /// 将 RetrieveResponse::Item 转换为 FTAPI StockData
5687    fn stock_filter_item_to_stock_data(
5688        &self,
5689        item: &futu_backend::proto_internal::ft_cmd_stock_screener::retrieve_response::Item,
5690    ) -> Option<futu_proto::qot_stock_filter::StockData> {
5691        let stock_id = item.stock_id;
5692
5693        // 解析 security (market + code) 从 static_cache
5694        let security = self.resolve_security_from_stock_id(stock_id)?;
5695
5696        // 从 basic_property_results 获取 name
5697        let name = item
5698            .basic_property_results
5699            .iter()
5700            .find(|r| r.property.name == Some(1102)) // PROPERTY_BASIC_INFO_NAME
5701            .map(|r| r.value.clone())
5702            .unwrap_or_default();
5703
5704        // 转换简单属性数据 (simple → BaseData)
5705        let mut base_data_list = Vec::new();
5706
5707        for r in &item.simple_property_results {
5708            let prop = &r.property;
5709            let nn_name = prop.name.unwrap_or(0);
5710            let api_field = stock_filter_simple_nn_to_api(nn_name);
5711            if api_field != 0 {
5712                let scale = stock_filter_base_scaling_ratio(nn_name);
5713                let value = if scale > 0.0 {
5714                    r.value as f64 / scale
5715                } else {
5716                    r.value as f64
5717                };
5718                base_data_list.push(futu_proto::qot_stock_filter::BaseData {
5719                    field_name: api_field,
5720                    value,
5721                });
5722            }
5723        }
5724
5725        // 转换累积属性数据
5726        let accumulate_data_list: Vec<futu_proto::qot_stock_filter::AccumulateData> = item
5727            .cumulative_property_results
5728            .iter()
5729            .filter_map(|r| {
5730                let prop = &r.property;
5731                let nn_name = prop.name.unwrap_or(0);
5732                let api_field = stock_filter_accumulate_nn_to_api(nn_name);
5733                if api_field == 0 {
5734                    return None;
5735                }
5736                let scale = stock_filter_accumulate_scaling_ratio(nn_name);
5737                let value = if scale > 0.0 {
5738                    r.value as f64 / scale
5739                } else {
5740                    r.value as f64
5741                };
5742                let days = prop.days.unwrap_or(1) as i32;
5743                Some(futu_proto::qot_stock_filter::AccumulateData {
5744                    field_name: api_field,
5745                    value,
5746                    days,
5747                })
5748            })
5749            .collect();
5750
5751        // 转换财务属性数据
5752        // 后端的 financial_property_results 中,"基础量价"字段映射为 BaseData,其余为 FinancialData
5753        let mut financial_data_list = Vec::new();
5754        for r in &item.financial_property_results {
5755            let prop = &r.property;
5756            let nn_name = prop.name.unwrap_or(0);
5757            let scale = stock_filter_financial_scaling_ratio(nn_name);
5758            let value = if scale > 0.0 {
5759                r.value as f64 / scale
5760            } else {
5761                r.value as f64
5762            };
5763
5764            // 检查是否是 "基础量价" 字段 (映射为 BaseData)
5765            if let Some(api_base) = stock_filter_financial_to_base_api(nn_name) {
5766                base_data_list.push(futu_proto::qot_stock_filter::BaseData {
5767                    field_name: api_base,
5768                    value,
5769                });
5770            } else {
5771                let api_field = stock_filter_financial_nn_to_api(nn_name);
5772                if api_field != 0 {
5773                    let quarter = stock_filter_financial_quarter_nn_to_api(prop.term.unwrap_or(0));
5774                    financial_data_list.push(futu_proto::qot_stock_filter::FinancialData {
5775                        field_name: api_field,
5776                        value,
5777                        quarter,
5778                    });
5779                }
5780            }
5781        }
5782
5783        // 转换自定义指标数据
5784        let custom_indicator_data_list: Vec<futu_proto::qot_stock_filter::CustomIndicatorData> =
5785            item.indicator_property_results
5786                .iter()
5787                .filter_map(|r| {
5788                    let prop = &r.property;
5789                    let nn_indicator = prop.name.unwrap_or(0);
5790                    let api_field = stock_filter_indicator_nn_to_custom_api(nn_indicator);
5791                    if api_field == 0 {
5792                        return None;
5793                    }
5794                    let scale = stock_filter_custom_scaling_ratio(nn_indicator);
5795                    let value = if scale > 0.0 {
5796                        r.value as f64 / scale
5797                    } else {
5798                        r.value as f64
5799                    };
5800                    let kl_type = stock_filter_period_nn_to_api(prop.period.unwrap_or(0));
5801                    let field_para_list: Vec<i32> =
5802                        prop.indicator_params.iter().map(|&p| p as i32).collect();
5803                    Some(futu_proto::qot_stock_filter::CustomIndicatorData {
5804                        field_name: api_field,
5805                        value,
5806                        kl_type,
5807                        field_para_list,
5808                    })
5809                })
5810                .collect();
5811
5812        Some(futu_proto::qot_stock_filter::StockData {
5813            security,
5814            name,
5815            base_data_list,
5816            accumulate_data_list,
5817            financial_data_list,
5818            custom_indicator_data_list,
5819        })
5820    }
5821
5822    fn resolve_security_from_stock_id(
5823        &self,
5824        stock_id: u64,
5825    ) -> Option<futu_proto::qot_common::Security> {
5826        let key = self.static_cache.id_to_key.get(&stock_id)?;
5827        let parts: Vec<&str> = key.split('_').collect();
5828        if parts.len() != 2 {
5829            return None;
5830        }
5831        let market: i32 = parts[0].parse().ok()?;
5832        let code = parts[1].to_string();
5833        Some(futu_proto::qot_common::Security { market, code })
5834    }
5835}
5836
5837// ===== StockFilter 辅助函数: FTAPI market → 后端 FTCmdStockScreener::Market =====
5838
5839fn stock_filter_market_api_to_nn(api_market: i32) -> i32 {
5840    match api_market {
5841        1 | 12 => 1,  // HK_Security / HK_Future → MKT_HK
5842        11 => 2,      // US_Security → MKT_US
5843        21 | 22 => 3, // CNSH_Security / CNSZ_Security → MKT_CN
5844        _ => 0,       // MKT_UNKNOWN
5845    }
5846}
5847
5848fn stock_filter_security_to_id(
5849    cache: &StaticDataCache,
5850    sec: &futu_proto::qot_common::Security,
5851) -> Option<u64> {
5852    let key = format!("{}_{}", sec.market, sec.code);
5853    cache.id_to_key.iter().find_map(|entry| {
5854        if *entry.value() == key {
5855            Some(*entry.key())
5856        } else {
5857            None
5858        }
5859    })
5860}
5861
5862// ===== 简单属性字段映射 (FTAPI StockField → 后端 PropertyNameSimple) =====
5863
5864fn stock_filter_base_api_to_nn_simple(api_field: i32) -> i32 {
5865    match api_field {
5866        3 => 2201,  // CurPrice → PROPERTY_SIMPLE_QUOTE_PRICE
5867        4 => 2209,  // CurPriceToHighest52WeeksRatio
5868        5 => 2210,  // CurPriceToLowest52WeeksRatio
5869        6 => 2211,  // HighPriceToHighest52WeeksRatio
5870        7 => 2212,  // LowPriceToLowest52WeeksRatio
5871        8 => 2217,  // VolumeRatio
5872        9 => 2218,  // BidAskRatio
5873        10 => 2219, // LotPrice
5874        11 => 2301, // MarketVal
5875        12 => 2302, // PeAnnual
5876        13 => 2303, // PeTTM
5877        14 => 2304, // PbRate
5878        15 => 2213, // ChangeRate5min
5879        16 => 2214, // ChangeRateBeginYear
5880        _ => 0,
5881    }
5882}
5883
5884/// "基础量价" — FTAPI StockField 中映射到后端 financial 的字段
5885fn stock_filter_base_api_to_nn_financial(api_field: i32) -> i32 {
5886    match api_field {
5887        17 => 4904, // PSTTM → PROPERTY_FINANCIAL_PS_TTM
5888        18 => 4905, // PCFTTM → PROPERTY_FINANCIAL_PRICE_CASH_FLOW_RATIO_TTM
5889        19 => 4901, // TotalShare → PROPERTY_FINANCIAL_TOTAL_SHARE
5890        20 => 4902, // FloatShare → PROPERTY_FINANCIAL_FLOAT_SHARE
5891        21 => 4903, // FloatMarketVal → PROPERTY_FINANCIAL_FLOAT_MARKET_CAP
5892        _ => 0,
5893    }
5894}
5895
5896fn stock_filter_is_base_price_field(api_field: i32) -> bool {
5897    matches!(api_field, 17..=21)
5898}
5899
5900fn stock_filter_simple_nn_to_api(nn_name: i32) -> i32 {
5901    match nn_name {
5902        2201 => 3,  // QUOTE_PRICE → CurPrice
5903        2209 => 4,  // CurPriceToHighest52WeeksRatio
5904        2210 => 5,  // CurPriceToLowest52WeeksRatio
5905        2211 => 6,  // HighPriceToHighest52WeeksRatio
5906        2212 => 7,  // LowPriceToLowest52WeeksRatio
5907        2217 => 8,  // VolumeRatio
5908        2218 => 9,  // BidAskRatio
5909        2219 => 10, // LotPrice
5910        2301 => 11, // MarketVal
5911        2302 => 12, // PeAnnual
5912        2303 => 13, // PeTTM
5913        2304 => 14, // PbRate
5914        2213 => 15, // ChangeRate5min
5915        2214 => 16, // ChangeRateBeginYear
5916        _ => 0,
5917    }
5918}
5919
5920fn stock_filter_base_scaling_ratio(nn_name: i32) -> f64 {
5921    match nn_name {
5922        2201 | 2209 | 2210 | 2211 | 2212 | 2213 | 2214 | 2219 | 2301 => 1000.0,
5923        2217 | 2218 | 2302 | 2303 | 2304 => 100_000.0,
5924        _ => 1.0,
5925    }
5926}
5927
5928// ===== 累积属性字段映射 =====
5929
5930fn stock_filter_accumulate_api_to_nn(api_field: i32) -> i32 {
5931    match api_field {
5932        1 => 3102, // ChangeRate → CUMULATIVE_QUOTE_PRICE_CHANGE_PERCENTAGE
5933        2 => 3103, // Amplitude
5934        3 => 3104, // Volume
5935        4 => 3105, // Turnover
5936        5 => 3106, // TurnoverRate
5937        _ => 0,
5938    }
5939}
5940
5941fn stock_filter_accumulate_nn_to_api(nn_name: i32) -> i32 {
5942    match nn_name {
5943        3102 => 1, // ChangeRate
5944        3103 => 2, // Amplitude
5945        3104 => 3, // Volume
5946        3105 => 4, // Turnover
5947        3106 => 5, // TurnoverRate
5948        _ => 0,
5949    }
5950}
5951
5952fn stock_filter_accumulate_scaling_ratio(nn_name: i32) -> f64 {
5953    match nn_name {
5954        3102 | 3103 | 3105 | 3106 => 1000.0,
5955        3104 => 1.0,
5956        _ => 1.0,
5957    }
5958}
5959
5960// ===== 财务属性字段映射 =====
5961
5962fn stock_filter_financial_api_to_nn(api_field: i32) -> i32 {
5963    match api_field {
5964        1 => 4101,  // NetProfit
5965        2 => 4102,  // NetProfitGrowth
5966        3 => 4105,  // SumOfBusiness → Revenue
5967        4 => 4106,  // SumOfBusinessGrowth → RevenueGrowth
5968        5 => 4107,  // NetProfitRate
5969        6 => 4108,  // GrossProfitRate
5970        7 => 4109,  // DebtAssetsRate
5971        8 => 4110,  // ReturnOnEquityRate
5972        9 => 4202,  // ROIC
5973        10 => 4209, // ROATTM
5974        11 => 4206, // EBITTTM
5975        12 => 4201, // EBITDA
5976        13 => 4210, // OperatingMarginTTM
5977        14 => 4203, // EBITMargin
5978        15 => 4204, // EBITDAMargin
5979        16 => 4205, // FinancialCostRate
5980        17 => 4207, // OperatingProfitTTM
5981        18 => 4208, // ShareholderNetProfitTTM
5982        19 => 4211, // NetProfitCashCoverTTM
5983        20 => 4301, // CurrentRatio
5984        21 => 4302, // QuickRatio
5985        22 => 4402, // CurrentAssetRatio
5986        23 => 4403, // CurrentDebtRatio
5987        24 => 4404, // EquityMultiplier
5988        25 => 4405, // PropertyRatio
5989        26 => 4401, // CashAndCashEquivalents
5990        27 => 4502, // TotalAssetTurnover
5991        28 => 4503, // FixedAssetTurnover
5992        29 => 4504, // InventoryTurnover
5993        30 => 4505, // OperatingCashFlowTTM
5994        31 => 4501, // AccountsReceivable
5995        32 => 4601, // EBITGrowthRate
5996        33 => 4602, // OperatingProfitGrowthRate
5997        34 => 4603, // TotalAssetsGrowthRate
5998        35 => 4604, // ProfitToShareholdersGrowthRate
5999        36 => 4605, // ProfitBeforeTaxGrowthRate
6000        37 => 4606, // EPSGrowthRate
6001        38 => 4607, // ROEGrowthRate
6002        39 => 4608, // ROICGrowthRate
6003        40 => 4609, // NOCFGrowthRate
6004        41 => 4610, // NOCFPerShareGrowthRate
6005        42 => 4701, // OperatingRevenueCashCover
6006        43 => 4702, // OperatingProfitToTotalProfit
6007        44 => 4801, // BasicEPS
6008        45 => 4802, // DilutedEPS
6009        46 => 4803, // NOCFPerShare
6010        _ => 0,
6011    }
6012}
6013
6014fn stock_filter_financial_nn_to_api(nn_name: i32) -> i32 {
6015    match nn_name {
6016        4101 => 1,
6017        4102 => 2,
6018        4105 => 3,
6019        4106 => 4,
6020        4107 => 5,
6021        4108 => 6,
6022        4109 => 7,
6023        4110 => 8,
6024        4202 => 9,
6025        4209 => 10,
6026        4206 => 11,
6027        4201 => 12,
6028        4210 => 13,
6029        4203 => 14,
6030        4204 => 15,
6031        4205 => 16,
6032        4207 => 17,
6033        4208 => 18,
6034        4211 => 19,
6035        4301 => 20,
6036        4302 => 21,
6037        4402 => 22,
6038        4403 => 23,
6039        4404 => 24,
6040        4405 => 25,
6041        4401 => 26,
6042        4502 => 27,
6043        4503 => 28,
6044        4504 => 29,
6045        4505 => 30,
6046        4501 => 31,
6047        4601 => 32,
6048        4602 => 33,
6049        4603 => 34,
6050        4604 => 35,
6051        4605 => 36,
6052        4606 => 37,
6053        4607 => 38,
6054        4608 => 39,
6055        4609 => 40,
6056        4610 => 41,
6057        4701 => 42,
6058        4702 => 43,
6059        4801 => 44,
6060        4802 => 45,
6061        4803 => 46,
6062        _ => 0,
6063    }
6064}
6065
6066/// 后端 PropertyNameFinancial 中 "基础量价" 字段 → FTAPI StockField
6067/// 这些后端字段是 financial,但 FTAPI 中归类为 BaseData
6068fn stock_filter_financial_to_base_api(nn_name: i32) -> Option<i32> {
6069    match nn_name {
6070        4901 => Some(19), // TotalShare
6071        4902 => Some(20), // FloatShare
6072        4903 => Some(21), // FloatMarketVal
6073        4904 => Some(17), // PSTTM
6074        4905 => Some(18), // PCFTTM
6075        _ => None,
6076    }
6077}
6078
6079fn stock_filter_financial_scaling_ratio(nn_name: i32) -> f64 {
6080    match nn_name {
6081        4404 => 100_000.0, // EquityMultiplier
6082        _ => 1000.0,       // 绝大多数财务字段使用 1000
6083    }
6084}
6085
6086// ===== 财务季度映射 =====
6087
6088fn stock_filter_financial_quarter_api_to_nn(api_quarter: i32) -> i32 {
6089    match api_quarter {
6090        1 => 100, // Annual → TERM_ANNUAL
6091        2 => 1,   // FirstQuarter → TERM_Q1
6092        3 => 6,   // Interim → TERM_Q6
6093        4 => 9,   // ThirdQuarter → TERM_Q9
6094        5 => 10,  // MostRecentQuarter → TERM_Q_LATEST
6095        _ => 0,
6096    }
6097}
6098
6099fn stock_filter_financial_quarter_nn_to_api(nn_term: i32) -> i32 {
6100    match nn_term {
6101        100 => 1, // Annual
6102        1 => 2,   // Q1
6103        6 => 3,   // Interim
6104        9 => 4,   // ThirdQuarter
6105        10 => 5,  // MostRecentQuarter
6106        _ => 0,
6107    }
6108}
6109
6110fn stock_filter_is_annual_only(api_field: i32) -> bool {
6111    matches!(api_field, 10 | 11 | 13 | 17 | 18 | 19 | 30)
6112}
6113
6114// ===== 形态指标映射 =====
6115
6116fn stock_filter_pattern_api_to_nn(api_field: i32) -> i32 {
6117    match api_field {
6118        1 => 1,   // MAAlignmentLong
6119        2 => 2,   // MAAlignmentShort
6120        3 => 3,   // EMAAlignmentLong
6121        4 => 4,   // EMAAlignmentShort
6122        5 => 31,  // RSIGoldCrossLow
6123        6 => 32,  // RSIDeathCrossHigh
6124        7 => 33,  // RSITopDivergence
6125        8 => 34,  // RSIBottomDivergence
6126        9 => 11,  // KDJGoldCrossLow
6127        10 => 12, // KDJDeathCrossHigh
6128        11 => 13, // KDJTopDivergence
6129        12 => 14, // KDJBottomDivergence
6130        13 => 21, // MACDGoldCrossLow
6131        14 => 22, // MACDDeathCrossHigh
6132        15 => 23, // MACDTopDivergence
6133        16 => 24, // MACDBottomDivergence
6134        17 => 41, // BOLLBreakUpper
6135        18 => 42, // BOLLBreakLower
6136        19 => 43, // BOLLCrossMiddleUp
6137        20 => 44, // BOLLCrossMiddleDown
6138        _ => 0,
6139    }
6140}
6141
6142// ===== 自定义指标映射 =====
6143
6144fn stock_filter_custom_api_to_nn(api_field: i32) -> i32 {
6145    match api_field {
6146        1 => 1,   // Price → INDICATOR_PRICE
6147        2 => 11,  // MA5
6148        3 => 12,  // MA10
6149        4 => 13,  // MA20
6150        5 => 14,  // MA30
6151        6 => 15,  // MA60
6152        7 => 16,  // MA120
6153        8 => 17,  // MA250
6154        9 => 52,  // RSI → INDICATOR_RSI (动态)
6155        10 => 21, // EMA5
6156        11 => 22, // EMA10
6157        12 => 23, // EMA20
6158        13 => 24, // EMA30
6159        14 => 25, // EMA60
6160        15 => 26, // EMA120
6161        16 => 27, // EMA250
6162        17 => 0,  // Value (特殊处理)
6163        30 => 18, // MA (动态)
6164        40 => 28, // EMA (动态)
6165        50 => 34, // KDJ_K (动态)
6166        51 => 35, // KDJ_D
6167        52 => 36, // KDJ_J
6168        60 => 44, // MACD_DIFF (动态)
6169        61 => 45, // MACD_DEA
6170        62 => 46, // MACD_MACD
6171        70 => 64, // BOLL_UPPER (动态)
6172        71 => 65, // BOLL_MIDDLER
6173        72 => 66, // BOLL_LOWER
6174        _ => 0,
6175    }
6176}
6177
6178fn stock_filter_indicator_nn_to_custom_api(nn_indicator: i32) -> i32 {
6179    match nn_indicator {
6180        1 => 1,        // PRICE
6181        11 => 2,       // MA5
6182        12 => 3,       // MA10
6183        13 => 4,       // MA20
6184        14 => 5,       // MA30
6185        15 => 6,       // MA60
6186        16 => 7,       // MA120
6187        17 => 8,       // MA250
6188        18 => 30,      // MA (动态)
6189        21 => 10,      // EMA5
6190        22 => 11,      // EMA10
6191        23 => 12,      // EMA20
6192        24 => 13,      // EMA30
6193        25 => 14,      // EMA60
6194        26 => 15,      // EMA120
6195        27 => 16,      // EMA250
6196        28 => 40,      // EMA (动态)
6197        31 | 34 => 50, // KDJ_K
6198        32 | 35 => 51, // KDJ_D
6199        33 | 36 => 52, // KDJ_J
6200        41 | 44 => 60, // MACD_DIFF
6201        42 | 45 => 61, // MACD_DEA
6202        43 | 46 => 62, // MACD_MACD
6203        51 | 52 => 9,  // RSI
6204        61 | 64 => 70, // BOLL_UPPER
6205        62 | 65 => 71, // BOLL_MIDDLER
6206        63 | 66 => 72, // BOLL_LOWER
6207        _ => 0,
6208    }
6209}
6210
6211fn stock_filter_custom_scaling_ratio(nn_indicator: i32) -> f64 {
6212    match nn_indicator {
6213        0 => 1.0,
6214        _ => 1000.0,
6215    }
6216}
6217
6218// ===== 相对位置 / KL周期 / 排序方向映射 =====
6219
6220fn stock_filter_relative_position_api_to_nn(api_pos: i32) -> i32 {
6221    match api_pos {
6222        1 => 1, // More → POSITION_OVER
6223        2 => 2, // Less → POSITION_BELOW
6224        3 => 3, // CrossUp
6225        4 => 4, // CrossDown
6226        _ => 0,
6227    }
6228}
6229
6230fn stock_filter_kl_type_api_to_nn(api_kl: i32) -> i32 {
6231    match api_kl {
6232        6 => 5,   // KLType_60Min → PERIOD_HOUR_1
6233        8 => 11,  // KLType_Day → PERIOD_DAY_1
6234        9 => 21,  // KLType_Week → PERIOD_WEEK_1
6235        10 => 31, // KLType_Month → PERIOD_MONTH_1
6236        _ => 0,
6237    }
6238}
6239
6240fn stock_filter_period_nn_to_api(nn_period: i32) -> i32 {
6241    match nn_period {
6242        5 => 6,   // KLType_60Min
6243        11 => 8,  // KLType_Day
6244        21 => 9,  // KLType_Week
6245        31 => 10, // KLType_Month
6246        _ => 0,
6247    }
6248}
6249
6250fn stock_filter_sort_dir_api_to_nn(api_dir: i32) -> i32 {
6251    match api_dir {
6252        1 => 1, // Ascend
6253        2 => 2, // Descend
6254        _ => 0,
6255    }
6256}
6257
6258// ===== ScreenRequest 构建函数 =====
6259
6260fn stock_filter_make_boundary(
6261    value: f64,
6262    scale: f64,
6263) -> futu_backend::proto_internal::ft_cmd_stock_screener::Bounary {
6264    let scaled = if scale > 0.0 {
6265        (value * scale) as i64
6266    } else {
6267        value as i64
6268    };
6269    futu_backend::proto_internal::ft_cmd_stock_screener::Bounary {
6270        value: scaled,
6271        includes: true,
6272    }
6273}
6274
6275fn stock_filter_build_base_queries(
6276    c2s: &futu_proto::qot_stock_filter::C2s,
6277    screen_req: &mut futu_backend::proto_internal::ft_cmd_stock_screener::ScreenRequest,
6278) {
6279    for f in &c2s.base_filter_list {
6280        if f.is_no_filter.unwrap_or(true) {
6281            continue;
6282        }
6283        let api_field = f.field_name;
6284        if api_field == 1 || api_field == 2 {
6285            continue;
6286        }
6287
6288        if stock_filter_is_base_price_field(api_field) {
6289            let nn_fin = stock_filter_base_api_to_nn_financial(api_field);
6290            if nn_fin == 0 {
6291                continue;
6292            }
6293            let scale = stock_filter_financial_scaling_ratio(nn_fin);
6294            let mut query =
6295                futu_backend::proto_internal::ft_cmd_stock_screener::QueryPropertyFinancial {
6296                    property:
6297                        futu_backend::proto_internal::ft_cmd_stock_screener::PropertyFinancial {
6298                            name: Some(nn_fin),
6299                            term: None,
6300                            duration: None,
6301                            year: None,
6302                            period_average: None,
6303                        },
6304                    ..Default::default()
6305                };
6306            if let Some(min) = f.filter_min {
6307                query.lower = Some(stock_filter_make_boundary(min, scale));
6308            }
6309            if let Some(max) = f.filter_max {
6310                query.upper = Some(stock_filter_make_boundary(max, scale));
6311            }
6312            screen_req.financial_property_queries.push(query);
6313        } else {
6314            let nn_simple = stock_filter_base_api_to_nn_simple(api_field);
6315            if nn_simple == 0 {
6316                continue;
6317            }
6318            let scale = stock_filter_base_scaling_ratio(nn_simple);
6319            let mut query =
6320                futu_backend::proto_internal::ft_cmd_stock_screener::QueryPropertySimple {
6321                    property: futu_backend::proto_internal::ft_cmd_stock_screener::PropertySimple {
6322                        name: Some(nn_simple),
6323                    },
6324                    ..Default::default()
6325                };
6326            if let Some(min) = f.filter_min {
6327                query.lower = Some(stock_filter_make_boundary(min, scale));
6328            }
6329            if let Some(max) = f.filter_max {
6330                query.upper = Some(stock_filter_make_boundary(max, scale));
6331            }
6332            screen_req.simple_property_queries.push(query);
6333        }
6334    }
6335}
6336
6337fn stock_filter_build_accumulate_queries(
6338    c2s: &futu_proto::qot_stock_filter::C2s,
6339    screen_req: &mut futu_backend::proto_internal::ft_cmd_stock_screener::ScreenRequest,
6340) {
6341    for f in &c2s.accumulate_filter_list {
6342        if f.is_no_filter.unwrap_or(true) {
6343            continue;
6344        }
6345        let nn_field = stock_filter_accumulate_api_to_nn(f.field_name);
6346        if nn_field == 0 {
6347            continue;
6348        }
6349        let scale = stock_filter_accumulate_scaling_ratio(nn_field);
6350        let mut query =
6351            futu_backend::proto_internal::ft_cmd_stock_screener::QueryPropertyCumulative {
6352                property: futu_backend::proto_internal::ft_cmd_stock_screener::PropertyCumulative {
6353                    name: Some(nn_field),
6354                    days: Some(f.days as u32),
6355                    period_average: None,
6356                },
6357                ..Default::default()
6358            };
6359        if let Some(min) = f.filter_min {
6360            query.lower = Some(stock_filter_make_boundary(min, scale));
6361        }
6362        if let Some(max) = f.filter_max {
6363            query.upper = Some(stock_filter_make_boundary(max, scale));
6364        }
6365        screen_req.cumulative_property_queries.push(query);
6366    }
6367}
6368
6369fn stock_filter_build_financial_queries(
6370    c2s: &futu_proto::qot_stock_filter::C2s,
6371    nn_market: i32,
6372    screen_req: &mut futu_backend::proto_internal::ft_cmd_stock_screener::ScreenRequest,
6373) {
6374    for f in &c2s.financial_filter_list {
6375        if f.is_no_filter.unwrap_or(true) {
6376            continue;
6377        }
6378        let nn_field = stock_filter_financial_api_to_nn(f.field_name);
6379        if nn_field == 0 {
6380            continue;
6381        }
6382        let scale = stock_filter_financial_scaling_ratio(nn_field);
6383
6384        let nn_term = if stock_filter_is_annual_only(f.field_name) {
6385            100
6386        } else {
6387            let q = stock_filter_financial_quarter_api_to_nn(f.quarter);
6388            if q == 0 {
6389                continue;
6390            }
6391            if (nn_market == 1 || nn_market == 3) && q == 10 {
6392                continue;
6393            }
6394            if nn_market == 2 && matches!(q, 1 | 6 | 9) {
6395                continue;
6396            }
6397            q
6398        };
6399
6400        let mut query =
6401            futu_backend::proto_internal::ft_cmd_stock_screener::QueryPropertyFinancial {
6402                property: futu_backend::proto_internal::ft_cmd_stock_screener::PropertyFinancial {
6403                    name: Some(nn_field),
6404                    term: Some(nn_term),
6405                    duration: None,
6406                    year: None,
6407                    period_average: None,
6408                },
6409                ..Default::default()
6410            };
6411        if let Some(min) = f.filter_min {
6412            query.lower = Some(stock_filter_make_boundary(min, scale));
6413        }
6414        if let Some(max) = f.filter_max {
6415            query.upper = Some(stock_filter_make_boundary(max, scale));
6416        }
6417        screen_req.financial_property_queries.push(query);
6418    }
6419}
6420
6421fn stock_filter_build_pattern_queries(
6422    c2s: &futu_proto::qot_stock_filter::C2s,
6423    screen_req: &mut futu_backend::proto_internal::ft_cmd_stock_screener::ScreenRequest,
6424) {
6425    for f in &c2s.pattern_filter_list {
6426        if f.is_no_filter.unwrap_or(true) {
6427            continue;
6428        }
6429        let nn_pattern = stock_filter_pattern_api_to_nn(f.field_name);
6430        if nn_pattern == 0 {
6431            continue;
6432        }
6433        let nn_period = stock_filter_kl_type_api_to_nn(f.kl_type);
6434        let mut query =
6435            futu_backend::proto_internal::ft_cmd_stock_screener::QueryIndicatorPattern {
6436                pattern: nn_pattern,
6437                period: nn_period,
6438                continuous_period: None,
6439            };
6440        if let Some(cp) = f.consecutive_period {
6441            if cp > 0 {
6442                query.continuous_period = Some(cp);
6443            }
6444        }
6445        screen_req.indicator_pattern_queries.push(query);
6446    }
6447}
6448
6449fn stock_filter_build_custom_queries(
6450    c2s: &futu_proto::qot_stock_filter::C2s,
6451    screen_req: &mut futu_backend::proto_internal::ft_cmd_stock_screener::ScreenRequest,
6452) {
6453    for f in &c2s.custom_indicator_filter_list {
6454        if f.is_no_filter.unwrap_or(true) {
6455            continue;
6456        }
6457        let nn_first = stock_filter_custom_api_to_nn(f.first_field_name);
6458        if nn_first == 0 {
6459            continue;
6460        }
6461        let nn_second_raw = f.second_field_name;
6462        let nn_second = stock_filter_custom_api_to_nn(nn_second_raw);
6463        let nn_position = stock_filter_relative_position_api_to_nn(f.relative_position);
6464        let nn_period = stock_filter_kl_type_api_to_nn(f.kl_type);
6465        let scale = stock_filter_custom_scaling_ratio(nn_first);
6466
6467        let mut query =
6468            futu_backend::proto_internal::ft_cmd_stock_screener::QueryIndicatorPositional {
6469                position: nn_position,
6470                period: nn_period,
6471                first_indicator: nn_first,
6472                second_indicator: None,
6473                second_value: None,
6474                first_indicator_params: f.first_field_para_list.iter().map(|&v| v as i64).collect(),
6475                second_indicator_params: f
6476                    .second_field_para_list
6477                    .iter()
6478                    .map(|&v| v as i64)
6479                    .collect(),
6480                continuous_period: None,
6481            };
6482
6483        if nn_second_raw != 17 && nn_second_raw != 0 && nn_second != 0 {
6484            query.second_indicator = Some(nn_second);
6485        }
6486        if nn_second_raw == 17 {
6487            if let Some(val) = f.field_value {
6488                query.second_value = Some((val * scale) as i64);
6489            }
6490        }
6491        if let Some(cp) = f.consecutive_period {
6492            if cp > 0 {
6493                query.continuous_period = Some(cp);
6494            }
6495        }
6496        screen_req.indicator_positional_queries.push(query);
6497    }
6498}
6499
6500fn stock_filter_build_sort(
6501    c2s: &futu_proto::qot_stock_filter::C2s,
6502    screen_req: &mut futu_backend::proto_internal::ft_cmd_stock_screener::ScreenRequest,
6503) {
6504    // base_filter_list
6505    for f in &c2s.base_filter_list {
6506        if f.is_no_filter.unwrap_or(true) {
6507            continue;
6508        }
6509        let dir = f.sort_dir.unwrap_or(0);
6510        let nn_dir = stock_filter_sort_dir_api_to_nn(dir);
6511        if nn_dir == 0 {
6512            continue;
6513        }
6514        let api_field = f.field_name;
6515        if api_field == 1 || api_field == 2 {
6516            continue;
6517        }
6518        let mut sort = futu_backend::proto_internal::ft_cmd_stock_screener::Sort {
6519            direction: nn_dir,
6520            ..Default::default()
6521        };
6522        if stock_filter_is_base_price_field(api_field) {
6523            sort.financial_property = Some(
6524                futu_backend::proto_internal::ft_cmd_stock_screener::PropertyFinancial {
6525                    name: Some(stock_filter_base_api_to_nn_financial(api_field)),
6526                    term: None,
6527                    duration: None,
6528                    year: None,
6529                    period_average: None,
6530                },
6531            );
6532        } else {
6533            sort.simple_property = Some(
6534                futu_backend::proto_internal::ft_cmd_stock_screener::PropertySimple {
6535                    name: Some(stock_filter_base_api_to_nn_simple(api_field)),
6536                },
6537            );
6538        }
6539        screen_req.sort = Some(sort);
6540        return;
6541    }
6542
6543    // accumulate_filter_list
6544    for f in &c2s.accumulate_filter_list {
6545        if f.is_no_filter.unwrap_or(true) {
6546            continue;
6547        }
6548        let dir = f.sort_dir.unwrap_or(0);
6549        let nn_dir = stock_filter_sort_dir_api_to_nn(dir);
6550        if nn_dir == 0 {
6551            continue;
6552        }
6553        let nn_field = stock_filter_accumulate_api_to_nn(f.field_name);
6554        if nn_field == 0 {
6555            continue;
6556        }
6557        screen_req.sort = Some(futu_backend::proto_internal::ft_cmd_stock_screener::Sort {
6558            direction: nn_dir,
6559            cumulative_property: Some(
6560                futu_backend::proto_internal::ft_cmd_stock_screener::PropertyCumulative {
6561                    name: Some(nn_field),
6562                    days: Some(f.days as u32),
6563                    period_average: None,
6564                },
6565            ),
6566            ..Default::default()
6567        });
6568        return;
6569    }
6570
6571    // financial_filter_list
6572    for f in &c2s.financial_filter_list {
6573        if f.is_no_filter.unwrap_or(true) {
6574            continue;
6575        }
6576        let dir = f.sort_dir.unwrap_or(0);
6577        let nn_dir = stock_filter_sort_dir_api_to_nn(dir);
6578        if nn_dir == 0 {
6579            continue;
6580        }
6581        let nn_field = stock_filter_financial_api_to_nn(f.field_name);
6582        if nn_field == 0 {
6583            continue;
6584        }
6585        let nn_term = if stock_filter_is_annual_only(f.field_name) {
6586            100
6587        } else {
6588            stock_filter_financial_quarter_api_to_nn(f.quarter)
6589        };
6590        screen_req.sort = Some(futu_backend::proto_internal::ft_cmd_stock_screener::Sort {
6591            direction: nn_dir,
6592            financial_property: Some(
6593                futu_backend::proto_internal::ft_cmd_stock_screener::PropertyFinancial {
6594                    name: Some(nn_field),
6595                    term: Some(nn_term),
6596                    duration: None,
6597                    year: None,
6598                    period_average: None,
6599                },
6600            ),
6601            ..Default::default()
6602        });
6603        return;
6604    }
6605}
6606
6607// ===== RetrieveRequest 属性构建 =====
6608
6609fn stock_filter_build_retrieve_properties(
6610    c2s: &futu_proto::qot_stock_filter::C2s,
6611    retrieve_req: &mut futu_backend::proto_internal::ft_cmd_stock_screener::RetrieveRequest,
6612) {
6613    for f in &c2s.base_filter_list {
6614        if f.is_no_filter.unwrap_or(true) {
6615            continue;
6616        }
6617        let api_field = f.field_name;
6618        if api_field == 1 || api_field == 2 {
6619            continue;
6620        }
6621        if stock_filter_is_base_price_field(api_field) {
6622            let nn_fin = stock_filter_base_api_to_nn_financial(api_field);
6623            if nn_fin != 0 {
6624                retrieve_req.financial_properties.push(
6625                    futu_backend::proto_internal::ft_cmd_stock_screener::PropertyFinancial {
6626                        name: Some(nn_fin),
6627                        term: None,
6628                        duration: None,
6629                        year: None,
6630                        period_average: None,
6631                    },
6632                );
6633            }
6634        } else {
6635            let nn_simple = stock_filter_base_api_to_nn_simple(api_field);
6636            if nn_simple != 0 {
6637                retrieve_req.simple_properties.push(
6638                    futu_backend::proto_internal::ft_cmd_stock_screener::PropertySimple {
6639                        name: Some(nn_simple),
6640                    },
6641                );
6642            }
6643        }
6644    }
6645
6646    for f in &c2s.accumulate_filter_list {
6647        if f.is_no_filter.unwrap_or(true) {
6648            continue;
6649        }
6650        let nn_field = stock_filter_accumulate_api_to_nn(f.field_name);
6651        if nn_field != 0 {
6652            retrieve_req.cumulative_properties.push(
6653                futu_backend::proto_internal::ft_cmd_stock_screener::PropertyCumulative {
6654                    name: Some(nn_field),
6655                    days: Some(f.days as u32),
6656                    period_average: None,
6657                },
6658            );
6659        }
6660    }
6661
6662    for f in &c2s.financial_filter_list {
6663        if f.is_no_filter.unwrap_or(true) {
6664            continue;
6665        }
6666        let nn_field = stock_filter_financial_api_to_nn(f.field_name);
6667        if nn_field != 0 {
6668            let nn_term = if stock_filter_is_annual_only(f.field_name) {
6669                100
6670            } else {
6671                stock_filter_financial_quarter_api_to_nn(f.quarter)
6672            };
6673            retrieve_req.financial_properties.push(
6674                futu_backend::proto_internal::ft_cmd_stock_screener::PropertyFinancial {
6675                    name: Some(nn_field),
6676                    term: Some(nn_term),
6677                    duration: None,
6678                    year: None,
6679                    period_average: None,
6680                },
6681            );
6682        }
6683    }
6684
6685    // custom_indicator_filter_list → indicator_properties (去重)
6686    let mut seen_indicators: std::collections::HashSet<(i32, i32, Vec<i32>)> =
6687        std::collections::HashSet::new();
6688    for f in &c2s.custom_indicator_filter_list {
6689        if f.is_no_filter.unwrap_or(true) {
6690            continue;
6691        }
6692        if f.consecutive_period.unwrap_or(0) > 1 {
6693            continue;
6694        }
6695        let nn_period = stock_filter_kl_type_api_to_nn(f.kl_type);
6696
6697        let nn_first = stock_filter_custom_api_to_nn(f.first_field_name);
6698        if nn_first != 0 {
6699            let params: Vec<i32> = f.first_field_para_list.clone();
6700            let key = (nn_first, nn_period, params.clone());
6701            if seen_indicators.insert(key) {
6702                retrieve_req.indicator_properties.push(
6703                    futu_backend::proto_internal::ft_cmd_stock_screener::PropertyIndicator {
6704                        name: Some(nn_first),
6705                        period: Some(nn_period),
6706                        indicator_params: params.iter().map(|&v| v as i64).collect(),
6707                    },
6708                );
6709            }
6710        }
6711
6712        let nn_second_raw = f.second_field_name;
6713        if nn_second_raw != 17 && nn_second_raw != 0 {
6714            let nn_second = stock_filter_custom_api_to_nn(nn_second_raw);
6715            if nn_second != 0 {
6716                let params: Vec<i32> = f.second_field_para_list.clone();
6717                let key = (nn_second, nn_period, params.clone());
6718                if seen_indicators.insert(key) {
6719                    retrieve_req.indicator_properties.push(
6720                        futu_backend::proto_internal::ft_cmd_stock_screener::PropertyIndicator {
6721                            name: Some(nn_second),
6722                            period: Some(nn_period),
6723                            indicator_params: params.iter().map(|&v| v as i64).collect(),
6724                        },
6725                    );
6726                }
6727            }
6728        }
6729    }
6730}
6731
6732// ===== GetCodeChange (HTTP 数据源 → 本地缓存 + 条件过滤) =====
6733//
6734// C++ 流程 (APIServer_Qot_CodeChange::OnClientReq_CodeChange):
6735// 1. 数据源: 通过 HTTP 从腾讯云 CDN 下载 JSON 数据:
6736//    - CodeRelation: 创业板转主板 (CodeDefine=202 → GemToMain)
6737//    - CodeTemp: 临时代码 (EventType 1-7 → Unpaid/ChangeLot/Split/Joint/JointSplit/SplitJoint/Other)
6738// 2. 查询: 从本地缓存获取全部 CodeChangeInfo, 按 C2S 条件过滤:
6739//    - security_list: 按股票代码筛选
6740//    - type_list: 按 CodeChangeType 筛选
6741//    - time_filter_list: 按 Public/Effective/End 时间范围筛选
6742struct GetCodeChangeHandler {
6743    code_change_cache: futu_backend::code_change::CodeChangeCache,
6744}
6745
6746/// C++ IsFitCond: 检查 CodeChangeInfo 是否匹配 C2S 过滤条件
6747fn code_change_fits_condition(
6748    info: &futu_backend::code_change::CodeChangeInfo,
6749    c2s: &futu_proto::qot_get_code_change::C2s,
6750) -> bool {
6751    // C++: If_OMWarn_Return(stNN.enType == CodeChangeType_Unkonw, false)
6752    if info.change_type == futu_backend::code_change::CodeChangeType::Unknown {
6753        return false;
6754    }
6755
6756    // 股票筛选: C++ 遍历 securitylist, 如果任意一个不匹配就 return false
6757    for sec in &c2s.security_list {
6758        if sec.market != info.qot_market || sec.code != info.sec_code {
6759            return false;
6760        }
6761    }
6762
6763    // 类型筛选: C++ 遍历 typelist, 如果任意一个不匹配就 return false
6764    for &type_val in &c2s.type_list {
6765        if type_val != info.change_type as i32 {
6766            return false;
6767        }
6768    }
6769
6770    // 时间筛选
6771    for tf in &c2s.time_filter_list {
6772        let has_begin = tf.begin_time.is_some();
6773        let has_end = tf.end_time.is_some();
6774        if !has_begin && !has_end {
6775            continue;
6776        }
6777
6778        // 根据 TimeFilterType 选择对应的时间字段
6779        let time_val = match tf.r#type {
6780            1 => info.public_time,    // TimeFilterType_Public
6781            2 => info.effective_time, // TimeFilterType_Effective
6782            3 => info.end_time,       // TimeFilterType_End
6783            _ => continue,
6784        };
6785        if time_val == 0 {
6786            continue;
6787        }
6788
6789        // CodeChange 的时间过滤默认使用东八区 (market=1 HK)
6790        let filter_market = c2s.security_list.first().map(|s| s.market).unwrap_or(1);
6791        if let Some(ref begin_str) = tf.begin_time {
6792            let begin_ts = parse_api_time_str(begin_str, filter_market).unwrap_or(0);
6793            if begin_ts > 0 && time_val < begin_ts {
6794                return false;
6795            }
6796        }
6797
6798        if let Some(ref end_str) = tf.end_time {
6799            // C++: nEndTime = GetTimeCache(...) + M_OneDaySecs - 1
6800            // 即结束时间包含整天 (加上 86400-1 秒)
6801            let end_ts = parse_api_time_str(end_str, filter_market).unwrap_or(0) + 86400 - 1;
6802            if end_ts > 0 && time_val > end_ts {
6803                return false;
6804            }
6805        }
6806    }
6807
6808    true
6809}
6810
6811// parse_api_time_str 定义在 line ~3469,返回 Option<u64>
6812
6813/// CodeChangeInfo → Qot_GetCodeChange::CodeChangeInfo proto
6814///
6815/// C++ CodeChangeInfo_NNToAPI
6816fn code_change_info_to_proto(
6817    info: &futu_backend::code_change::CodeChangeInfo,
6818) -> futu_proto::qot_get_code_change::CodeChangeInfo {
6819    let security = futu_proto::qot_common::Security {
6820        market: info.qot_market,
6821        code: info.sec_code.clone(),
6822    };
6823
6824    let related_security = futu_proto::qot_common::Security {
6825        market: info.qot_market,
6826        code: info.relate_sec_code.clone(),
6827    };
6828
6829    // C++: bReplyTimestamp / bReplyTime — 同时返回时间戳和时间字符串
6830    let public_time = if info.public_time > 0 {
6831        Some(format_timestamp(info.public_time))
6832    } else {
6833        None
6834    };
6835    let public_timestamp = if info.public_time > 0 {
6836        Some(info.public_time as f64)
6837    } else {
6838        None
6839    };
6840
6841    let effective_time = if info.effective_time > 0 {
6842        Some(format_timestamp(info.effective_time))
6843    } else {
6844        None
6845    };
6846    let effective_timestamp = if info.effective_time > 0 {
6847        Some(info.effective_time as f64)
6848    } else {
6849        None
6850    };
6851
6852    // C++: GemToMain 类型不设置 endTime/endTimestamp
6853    let (end_time, end_timestamp) = if info.change_type
6854        != futu_backend::code_change::CodeChangeType::GemToMain
6855        && info.end_time > 0
6856    {
6857        (
6858            Some(format_timestamp(info.end_time)),
6859            Some(info.end_time as f64),
6860        )
6861    } else {
6862        (None, None)
6863    };
6864
6865    futu_proto::qot_get_code_change::CodeChangeInfo {
6866        r#type: info.change_type as i32,
6867        security,
6868        related_security,
6869        public_time,
6870        public_timestamp,
6871        effective_time,
6872        effective_timestamp,
6873        end_time,
6874        end_timestamp,
6875    }
6876}
6877
6878#[async_trait]
6879impl RequestHandler for GetCodeChangeHandler {
6880    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
6881        let req: futu_proto::qot_get_code_change::Request =
6882            prost::Message::decode(request.body.as_ref()).ok()?;
6883        let c2s = &req.c2s;
6884
6885        let cache = self.code_change_cache.read();
6886        let mut code_change_list = Vec::new();
6887
6888        for info in cache.iter() {
6889            if code_change_fits_condition(info, c2s) {
6890                code_change_list.push(code_change_info_to_proto(info));
6891            }
6892        }
6893
6894        tracing::debug!(
6895            conn_id,
6896            total_cached = cache.len(),
6897            matched = code_change_list.len(),
6898            "GetCodeChange"
6899        );
6900
6901        let resp = futu_proto::qot_get_code_change::Response {
6902            ret_type: 0,
6903            ret_msg: None,
6904            err_code: None,
6905            s2c: Some(futu_proto::qot_get_code_change::S2c { code_change_list }),
6906        };
6907        Some(prost::Message::encode_to_vec(&resp))
6908    }
6909}
6910
6911// ===== GetIpoList (CMD 6956 → 后端转发) =====
6912struct GetIpoListHandler {
6913    backend: crate::bridge::SharedBackend,
6914    static_cache: Arc<StaticDataCache>,
6915}
6916
6917#[async_trait]
6918impl RequestHandler for GetIpoListHandler {
6919    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
6920        let req: futu_proto::qot_get_ipo_list::Request =
6921            prost::Message::decode(request.body.as_ref()).ok()?;
6922        let c2s = &req.c2s;
6923
6924        let backend = match super::load_backend(&self.backend) {
6925            Some(b) => b,
6926            None => {
6927                tracing::warn!(conn_id, "GetIpoList: no backend connection");
6928                return Some(super::make_error_response(-1, "no backend connection"));
6929            }
6930        };
6931
6932        // Build backend IpoListReq (CMD 6956)
6933        // 对齐 C++: HK 需要发两个请求 (HK_APPLYING=6 + HK_WAITING_IPO=7)
6934        //           CN 需要发四个请求 (NEW_NOTICE=1 + APPLYING=2 + APPLY_RESULT=3 + WAITING_IPO=4)
6935        //           US 只需一个请求 (ALL=5)
6936        let request_types: Vec<i32> = match c2s.market {
6937            1 => vec![6, 7],             // HK: HK_APPLYING, HK_WAITING_IPO
6938            2 => vec![5],                // US: ALL
6939            21 | 22 => vec![1, 2, 3, 4], // CN: NEW_NOTICE, APPLYING, APPLY_RESULT, WAITING_IPO
6940            _ => vec![5],                // Default: ALL
6941        };
6942
6943        // 合并多个请求的响应
6944        let mut merged_hk_list = Vec::new();
6945        let mut merged_us_list = Vec::new();
6946        let mut merged_cn_list = Vec::new();
6947
6948        for req_type in &request_types {
6949            let backend_req =
6950                futu_backend::proto_internal::ft_cmd_ipo_calender6955_6959::IpoListReq {
6951                    market: Some(c2s.market),
6952                    request_type: Some(*req_type),
6953                    language: Some(1), // 简体中文
6954                };
6955            let body = prost::Message::encode_to_vec(&backend_req);
6956
6957            tracing::debug!(
6958                conn_id,
6959                market = c2s.market,
6960                request_type = req_type,
6961                "sending CMD6956 IpoListReq"
6962            );
6963
6964            let resp_frame = match backend.request(6956, body).await {
6965                Ok(f) => f,
6966                Err(e) => {
6967                    tracing::warn!(conn_id, error = %e, request_type = req_type, "CMD6956 request failed");
6968                    continue;
6969                }
6970            };
6971
6972            let rsp: futu_backend::proto_internal::ft_cmd_ipo_calender6955_6959::IpoListRsp =
6973                match prost::Message::decode(resp_frame.body.as_ref()) {
6974                    Ok(r) => r,
6975                    Err(e) => {
6976                        tracing::warn!(conn_id, error = %e, request_type = req_type, "CMD6956 decode failed");
6977                        continue;
6978                    }
6979                };
6980
6981            merged_hk_list.extend(rsp.hk_list);
6982            merged_us_list.extend(rsp.us_list);
6983            merged_cn_list.extend(rsp.cn_list);
6984        }
6985
6986        let backend_rsp = futu_backend::proto_internal::ft_cmd_ipo_calender6955_6959::IpoListRsp {
6987            market: None,
6988            hk_list: merged_hk_list,
6989            us_list: merged_us_list,
6990            cn_list: merged_cn_list,
6991            request_type: None,
6992        };
6993
6994        let ipo_count =
6995            backend_rsp.cn_list.len() + backend_rsp.hk_list.len() + backend_rsp.us_list.len();
6996
6997        tracing::debug!(
6998            conn_id,
6999            cn = backend_rsp.cn_list.len(),
7000            hk = backend_rsp.hk_list.len(),
7001            us = backend_rsp.us_list.len(),
7002            "GetIpoList: received {} IPO items from backend",
7003            ipo_count
7004        );
7005
7006        let mut ipo_list = Vec::with_capacity(ipo_count);
7007
7008        // Map HK IPO items
7009        for item in &backend_rsp.hk_list {
7010            let stock_id = match item.stock_id {
7011                Some(id) if id > 0 => id,
7012                _ => continue,
7013            };
7014            let security = match self.resolve_security(stock_id) {
7015                Some(s) => s,
7016                None => {
7017                    tracing::warn!(
7018                        stock_id,
7019                        name = ?item.stock_name,
7020                        "GetIpoList: HK stock_id not found in cache, skipping"
7021                    );
7022                    continue;
7023                }
7024            };
7025            let name = item.stock_name.clone().unwrap_or_default();
7026
7027            // 上市日期
7028            let (list_time, list_timestamp) = ipo_timestamp_fields(item.ipo_timestamp);
7029
7030            let basic = futu_proto::qot_get_ipo_list::BasicIpoData {
7031                security,
7032                name,
7033                list_time,
7034                list_timestamp,
7035            };
7036
7037            // 发售价区间: ipo_price_low/ipo_price_high 保留两位精度(即123代表1.23)
7038            let ipo_price_min = item.ipo_price_low.map(|v| v as f64 / 100.0).unwrap_or(0.0);
7039            let ipo_price_max = item.ipo_price_high.map(|v| v as f64 / 100.0).unwrap_or(0.0);
7040            // 上市价 (string → f64)
7041            let list_price = item
7042                .ipo_price
7043                .as_deref()
7044                .and_then(|s| s.parse::<f64>().ok())
7045                .unwrap_or(0.0);
7046            // 每手股数 (string → i32)
7047            let lot_size = item
7048                .lot_size
7049                .as_deref()
7050                .and_then(|s| s.parse::<i32>().ok())
7051                .unwrap_or(0);
7052            // 入场费: entrance_fee_num 2位精度(即123434表示1234.34)
7053            let entrance_price = item
7054                .entrance_fee_num
7055                .map(|v| v as f64 / 100.0)
7056                .unwrap_or(0.0);
7057            // 是否为认购状态: eipo_flag != 0 且 apply_countdown_secs > 0
7058            let is_subscribe_status =
7059                item.eipo_flag.unwrap_or(0) != 0 && item.apply_countdown_secs.unwrap_or(0) > 0;
7060
7061            // 截止认购时间
7062            let (apply_end_time, apply_end_timestamp) =
7063                ipo_timestamp_fields(item.apply_end_timestamp);
7064
7065            let hk_ex = futu_proto::qot_get_ipo_list::HkIpoExData {
7066                ipo_price_min,
7067                ipo_price_max,
7068                list_price,
7069                lot_size,
7070                entrance_price,
7071                is_subscribe_status,
7072                apply_end_time,
7073                apply_end_timestamp,
7074            };
7075
7076            ipo_list.push(futu_proto::qot_get_ipo_list::IpoData {
7077                basic,
7078                cn_ex_data: None,
7079                hk_ex_data: Some(hk_ex),
7080                us_ex_data: None,
7081            });
7082        }
7083
7084        // Map US IPO items
7085        for item in &backend_rsp.us_list {
7086            let stock_id = match item.stock_id {
7087                Some(id) if id > 0 => id,
7088                _ => continue,
7089            };
7090            let security = match self.resolve_security(stock_id) {
7091                Some(s) => s,
7092                None => continue,
7093            };
7094            let name = item.stock_name.clone().unwrap_or_default();
7095
7096            // 预计上市日
7097            let (list_time, list_timestamp) = ipo_timestamp_fields_u64(item.ipo_date_timestamp);
7098
7099            let basic = futu_proto::qot_get_ipo_list::BasicIpoData {
7100                security,
7101                name,
7102                list_time,
7103                list_timestamp,
7104            };
7105
7106            // 发行价区间: ipo_price_low/ipo_price_high 保留两位精度(即1234表示12.34)
7107            let ipo_price_min = item.ipo_price_low.map(|v| v as f64 / 100.0).unwrap_or(0.0);
7108            let ipo_price_max = item.ipo_price_high.map(|v| v as f64 / 100.0).unwrap_or(0.0);
7109            // 发行量
7110            let issue_size = item.shares_num.unwrap_or(0);
7111
7112            let us_ex = futu_proto::qot_get_ipo_list::UsIpoExData {
7113                ipo_price_min,
7114                ipo_price_max,
7115                issue_size,
7116            };
7117
7118            ipo_list.push(futu_proto::qot_get_ipo_list::IpoData {
7119                basic,
7120                cn_ex_data: None,
7121                hk_ex_data: None,
7122                us_ex_data: Some(us_ex),
7123            });
7124        }
7125
7126        // Map CN IPO items
7127        for item in &backend_rsp.cn_list {
7128            let stock_id = match item.stock_id {
7129                Some(id) if id > 0 => id,
7130                _ => continue,
7131            };
7132            let security = match self.resolve_security(stock_id) {
7133                Some(s) => s,
7134                None => continue,
7135            };
7136            let name = item.stock_name.clone().unwrap_or_default();
7137
7138            // 上市日期
7139            let (list_time, list_timestamp) = ipo_timestamp_fields_u64(item.ipo_date_timestamp);
7140
7141            let basic = futu_proto::qot_get_ipo_list::BasicIpoData {
7142                security,
7143                name,
7144                list_time,
7145                list_timestamp,
7146            };
7147
7148            let apply_code = item.apply_code.clone().unwrap_or_default();
7149            // 发行价 (string → f64)
7150            let ipo_price = item
7151                .ipo_price
7152                .as_deref()
7153                .and_then(|s| s.parse::<f64>().ok())
7154                .unwrap_or(0.0);
7155            let is_estimate_ipo_price = item.is_ipo_price_preview.unwrap_or(0) != 0;
7156            // 申购上限
7157            let apply_upper_limit = item.apply_limit_num.unwrap_or(0);
7158            // 发行市盈率: pe_num 两位精度(即2012表示20.12)
7159            let issue_pe_rate = item.pe_num.map(|v| v as f64 / 100.0).unwrap_or(0.0);
7160            // 中签率 (string → f64)
7161            let winning_ratio = item
7162                .lucky_ratio
7163                .as_deref()
7164                .and_then(|s| s.parse::<f64>().ok())
7165                .unwrap_or(0.0);
7166            let is_estimate_winning_ratio = item.is_lucky_ratio_preview.unwrap_or(0) != 0;
7167            // 是否已公布中签号
7168            let is_has_won = item.lucky_ok.unwrap_or(0) != 0;
7169
7170            // 申购日期
7171            let (apply_time, apply_timestamp) = ipo_timestamp_fields_u64(item.apply_date_timestamp);
7172            // 公布中签日
7173            let (winning_time, winning_timestamp) =
7174                ipo_timestamp_fields_u64(item.lucky_date_timestamp);
7175
7176            // issue_size / online_issue_size / apply_limit_market_value / industry_pe_rate:
7177            // 后端列表接口没有单独提供这些字段的数值格式,使用 0 作为默认值
7178            // (与C++行为一致:C++从二进制流解析,默认为0)
7179            let cn_ex = futu_proto::qot_get_ipo_list::CnIpoExData {
7180                apply_code,
7181                issue_size: 0,
7182                online_issue_size: 0,
7183                apply_upper_limit,
7184                apply_limit_market_value: 0,
7185                is_estimate_ipo_price,
7186                ipo_price,
7187                industry_pe_rate: 0.0,
7188                is_estimate_winning_ratio,
7189                winning_ratio,
7190                issue_pe_rate,
7191                apply_time,
7192                apply_timestamp,
7193                winning_time,
7194                winning_timestamp,
7195                is_has_won,
7196                winning_num_data: Vec::new(),
7197            };
7198
7199            ipo_list.push(futu_proto::qot_get_ipo_list::IpoData {
7200                basic,
7201                cn_ex_data: Some(cn_ex),
7202                hk_ex_data: None,
7203                us_ex_data: None,
7204            });
7205        }
7206
7207        tracing::debug!(
7208            conn_id,
7209            mapped = ipo_list.len(),
7210            "GetIpoList: mapped {} IPO items to FTAPI",
7211            ipo_list.len()
7212        );
7213
7214        let resp = futu_proto::qot_get_ipo_list::Response {
7215            ret_type: 0,
7216            ret_msg: None,
7217            err_code: None,
7218            s2c: Some(futu_proto::qot_get_ipo_list::S2c { ipo_list }),
7219        };
7220        Some(prost::Message::encode_to_vec(&resp))
7221    }
7222}
7223
7224impl GetIpoListHandler {
7225    /// 通过 stock_id 从 static_cache 解析出 Security(market + code)
7226    fn resolve_security(&self, stock_id: u64) -> Option<futu_proto::qot_common::Security> {
7227        let key = self.static_cache.id_to_key.get(&stock_id)?;
7228        let parts: Vec<&str> = key.split('_').collect();
7229        if parts.len() != 2 {
7230            return None;
7231        }
7232        let market: i32 = parts[0].parse().ok()?;
7233        let code = parts[1].to_string();
7234        Some(futu_proto::qot_common::Security { market, code })
7235    }
7236}
7237
7238/// 将 Option<u32> 时间戳转换为日期字符串和 f64 时间戳(用于 FTAPI 响应)
7239/// 如果时间戳为 None 或 0,返回 (None, None)
7240fn ipo_timestamp_fields(ts: Option<u32>) -> (Option<String>, Option<f64>) {
7241    match ts {
7242        Some(v) if v > 0 => {
7243            let time_str = timestamp_to_date_str(v as u64);
7244            (Some(time_str), Some(v as f64))
7245        }
7246        _ => (None, None),
7247    }
7248}
7249
7250/// 将 Option<u64> 时间戳转换为日期字符串和 f64 时间戳
7251fn ipo_timestamp_fields_u64(ts: Option<u64>) -> (Option<String>, Option<f64>) {
7252    match ts {
7253        Some(v) if v > 0 => {
7254            let time_str = timestamp_to_date_str(v);
7255            (Some(time_str), Some(v as f64))
7256        }
7257        _ => (None, None),
7258    }
7259}
7260
7261// ===== GetFutureInfo (CMD 6337 → 后端转发) =====
7262struct GetFutureInfoHandler {
7263    backend: crate::bridge::SharedBackend,
7264    static_cache: Arc<StaticDataCache>,
7265}
7266
7267#[async_trait]
7268impl RequestHandler for GetFutureInfoHandler {
7269    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
7270        let req: futu_proto::qot_get_future_info::Request =
7271            prost::Message::decode(request.body.as_ref()).ok()?;
7272        let c2s = &req.c2s;
7273
7274        let backend = match super::load_backend(&self.backend) {
7275            Some(b) => b,
7276            None => {
7277                tracing::warn!(conn_id, "GetFutureInfo: no backend connection");
7278                return Some(super::make_error_response(-1, "no backend connection"));
7279            }
7280        };
7281
7282        // Resolve stock_ids from security_list
7283        let mut stock_ids = Vec::new();
7284        for sec in &c2s.security_list {
7285            let sec_key = format!("{}_{}", sec.market, sec.code);
7286            if let Some(info) = self.static_cache.get_security_info(&sec_key) {
7287                if info.stock_id > 0 {
7288                    stock_ids.push(info.stock_id);
7289                }
7290            }
7291        }
7292
7293        if stock_ids.is_empty() {
7294            tracing::warn!(conn_id, "GetFutureInfo: no valid stock_ids found");
7295            let resp = futu_proto::qot_get_future_info::Response {
7296                ret_type: 0,
7297                ret_msg: None,
7298                err_code: None,
7299                s2c: Some(futu_proto::qot_get_future_info::S2c {
7300                    future_info_list: Vec::new(),
7301                }),
7302            };
7303            return Some(prost::Message::encode_to_vec(&resp));
7304        }
7305
7306        // Build backend FutureDetailInfoListReq (CMD 6337)
7307        let backend_req =
7308            futu_backend::proto_internal::ft_cmd_us_future_info::FutureDetailInfoListReq {
7309                security_id_list: stock_ids.clone(),
7310            };
7311        let body = prost::Message::encode_to_vec(&backend_req);
7312
7313        tracing::debug!(
7314            conn_id,
7315            count = stock_ids.len(),
7316            "sending CMD6337 FutureDetailInfoListReq"
7317        );
7318
7319        let resp_frame = match backend.request(6337, body).await {
7320            Ok(f) => f,
7321            Err(e) => {
7322                tracing::error!(conn_id, error = %e, "CMD6337 request failed");
7323                return Some(super::make_error_response(-1, "backend request failed"));
7324            }
7325        };
7326
7327        let backend_rsp: futu_backend::proto_internal::ft_cmd_us_future_info::FutureDetailInfoListRsp =
7328            match prost::Message::decode(resp_frame.body.as_ref()) {
7329                Ok(r) => r,
7330                Err(e) => {
7331                    tracing::error!(conn_id, error = %e, "CMD6337 decode failed");
7332                    return Some(super::make_error_response(
7333                        -1,
7334                        "backend response decode failed",
7335                    ));
7336                }
7337            };
7338
7339        if backend_rsp.ret_code.unwrap_or(-1) != 0 {
7340            tracing::warn!(conn_id, ret = ?backend_rsp.ret_code, "CMD6337 returned error");
7341            return Some(super::make_error_response(
7342                -1,
7343                "backend future info request failed",
7344            ));
7345        }
7346
7347        // Convert backend FutureDetailInfo → FTAPI FutureInfo
7348        let future_info_list: Vec<futu_proto::qot_get_future_info::FutureInfo> = backend_rsp
7349            .future_detail_info_list
7350            .iter()
7351            .filter_map(|detail| {
7352                // Resolve security_id back to market/code
7353                let security_id = detail.security_id?;
7354                let key = self.static_cache.id_to_key.get(&security_id)?;
7355                let parts: Vec<&str> = key.split('_').collect();
7356                if parts.len() != 2 {
7357                    return None;
7358                }
7359                let market: i32 = parts[0].parse().ok()?;
7360                let code = parts[1].to_string();
7361                let security = futu_proto::qot_common::Security { market, code };
7362
7363                // Convert last_trade_date from timestamp
7364                let last_trade_time = detail
7365                    .last_trade_date
7366                    .map(|ts| timestamp_to_date_str(ts as u64))
7367                    .unwrap_or_default();
7368
7369                let last_trade_timestamp = detail.last_trade_date.map(|ts| ts as f64);
7370
7371                // Trade time conversion
7372                let trade_time: Vec<futu_proto::qot_get_future_info::TradeTime> = detail
7373                    .trade_time_detail_list
7374                    .iter()
7375                    .map(|tt| futu_proto::qot_get_future_info::TradeTime {
7376                        begin: tt.begin.map(|v| v as f64),
7377                        end: tt.end.map(|v| v as f64),
7378                    })
7379                    .collect();
7380
7381                Some(futu_proto::qot_get_future_info::FutureInfo {
7382                    name: detail.name.clone().unwrap_or_default(),
7383                    security,
7384                    last_trade_time,
7385                    last_trade_timestamp,
7386                    owner: None,
7387                    owner_other: detail.show_variety.clone().unwrap_or_default(),
7388                    exchange: detail.exchange.clone().unwrap_or_default(),
7389                    contract_type: detail.category.clone().unwrap_or_default(),
7390                    contract_size: detail.scale.unwrap_or(0) as f64,
7391                    contract_size_unit: detail.scale_unit.clone().unwrap_or_default(),
7392                    quote_currency: detail.currency.clone().unwrap_or_default(),
7393                    min_var: detail.minimum_variation_value.unwrap_or(0.0),
7394                    min_var_unit: detail.minimum_variation_unit.clone().unwrap_or_default(),
7395                    quote_unit: detail.price_quotation.clone(),
7396                    trade_time,
7397                    time_zone: detail.time_zone.clone().unwrap_or_default(),
7398                    exchange_format_url: detail.specification_url.clone().unwrap_or_default(),
7399                    origin: None,
7400                })
7401            })
7402            .collect();
7403
7404        tracing::debug!(
7405            conn_id,
7406            count = future_info_list.len(),
7407            "GetFutureInfo: returning future info"
7408        );
7409
7410        let resp = futu_proto::qot_get_future_info::Response {
7411            ret_type: 0,
7412            ret_msg: None,
7413            err_code: None,
7414            s2c: Some(futu_proto::qot_get_future_info::S2c { future_info_list }),
7415        };
7416        Some(prost::Message::encode_to_vec(&resp))
7417    }
7418}
7419
7420// ===== SetPriceReminder (CMD 6809 设置到价提醒) =====
7421struct SetPriceReminderHandler {
7422    backend: crate::bridge::SharedBackend,
7423    static_cache: Arc<StaticDataCache>,
7424}
7425
7426#[async_trait]
7427impl RequestHandler for SetPriceReminderHandler {
7428    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
7429        let req: futu_proto::qot_set_price_reminder::Request =
7430            prost::Message::decode(request.body.as_ref()).ok()?;
7431        let c2s = &req.c2s;
7432
7433        let backend = match super::load_backend(&self.backend) {
7434            Some(b) => b,
7435            None => {
7436                tracing::warn!(conn_id, "SetPriceReminder: no backend connection");
7437                return Some(super::make_error_response(-1, "no backend connection"));
7438            }
7439        };
7440
7441        // Resolve stock_id
7442        let sec = &c2s.security;
7443        let sec_key = format!("{}_{}", sec.market, sec.code);
7444        let stock_id = match self.static_cache.get_security_info(&sec_key) {
7445            Some(info) if info.stock_id > 0 => info.stock_id,
7446            _ => {
7447                return Some(super::make_error_response(-1, "unknown stock"));
7448            }
7449        };
7450
7451        // 映射 API op -> 后端 oper_type: 1=Add, 2=Del, 3=Modify
7452        // API: 1=Add, 2=Del, 3=Enable, 4=Disable, 5=Modify, 6=DelAll
7453        let api_op = c2s.op;
7454        let is_del_all = api_op == 6;
7455
7456        let mut backend_req = futu_backend::proto_internal::ft_cmd_price_warn::PriceWarnSetNewReq {
7457            sid: Some(stock_id),
7458            delete_flag: None,
7459            attr: Vec::new(),
7460        };
7461
7462        if is_del_all {
7463            backend_req.delete_flag = Some(true);
7464        } else {
7465            let oper_type: u32 = match api_op {
7466                1 => 1,     // Add
7467                2 => 2,     // Del
7468                3..=5 => 3, // Enable/Disable/Modify -> Modify
7469                _ => {
7470                    return Some(super::make_error_response(-1, "unknown price reminder op"));
7471                }
7472            };
7473
7474            let key_val = c2s.key.filter(|_| api_op != 1); // Add 不需要 key
7475            let enable = match api_op {
7476                3 => Some(true),
7477                4 => Some(false),
7478                _ => None,
7479            };
7480
7481            // 映射 API PriceReminderType -> 后端 WarnType
7482            let warn_type = c2s.r#type.and_then(api_reminder_type_to_warn_type);
7483
7484            // 映射 API PriceReminderFreq -> 后端 FreqType
7485            let freq_type = c2s.freq.and_then(api_freq_to_backend_freq);
7486
7487            // value -> warn_param (value * 1000, 四舍五入)
7488            let warn_param = c2s.value.map(|v| (v * 1000.0).round() as i64);
7489
7490            let note = c2s.note.clone();
7491
7492            // 提醒时段
7493            let notify_time_periods: Vec<u32> = c2s
7494                .reminder_session_list
7495                .iter()
7496                .filter_map(|&s| api_market_status_to_notify_period(s))
7497                .collect();
7498
7499            let now_ts = std::time::SystemTime::now()
7500                .duration_since(std::time::UNIX_EPOCH)
7501                .unwrap_or_default()
7502                .as_secs();
7503
7504            let attr = futu_backend::proto_internal::ft_cmd_price_warn::ItemAttr {
7505                key: key_val.map(|k| k as u64),
7506                warn_type,
7507                warn_param,
7508                note,
7509                enable,
7510                update_time: Some(now_ts),
7511                freq_type,
7512                oper_type: Some(oper_type),
7513                stock_id: None,
7514                fine_warn_param: None,
7515                notify_time_periods,
7516            };
7517            backend_req.attr.push(attr);
7518        }
7519
7520        let body = prost::Message::encode_to_vec(&backend_req);
7521        tracing::debug!(
7522            conn_id,
7523            stock_id,
7524            api_op,
7525            "SetPriceReminder: sending CMD6809"
7526        );
7527
7528        let resp_frame = match backend.request(6809, body).await {
7529            Ok(f) => f,
7530            Err(e) => {
7531                tracing::error!(conn_id, error = %e, "CMD6809 request failed");
7532                return Some(super::make_error_response(-1, "backend request failed"));
7533            }
7534        };
7535
7536        let backend_rsp: futu_backend::proto_internal::ft_cmd_price_warn::PriceWarnSetNewRsp =
7537            match prost::Message::decode(resp_frame.body.as_ref()) {
7538                Ok(r) => r,
7539                Err(e) => {
7540                    tracing::error!(conn_id, error = %e, "CMD6809 decode failed");
7541                    return Some(super::make_error_response(
7542                        -1,
7543                        "backend response decode failed",
7544                    ));
7545                }
7546            };
7547
7548        if backend_rsp.result.unwrap_or(-1) != 0 {
7549            let err_text = backend_rsp.err_text.unwrap_or_default();
7550            let msg = if err_text.is_empty() {
7551                "set price reminder failed".to_string()
7552            } else {
7553                err_text
7554            };
7555            return Some(super::make_error_response(-1, &msg));
7556        }
7557
7558        // 对于 Add 操作, 后端返回 warn_items 中包含新 key
7559        // server_seq 可以作为 key 返回 (与 C++ 行为类似, C++ 从 warn_items 中匹配)
7560        let key = backend_rsp.server_seq.unwrap_or(0) as i64;
7561
7562        let resp = futu_proto::qot_set_price_reminder::Response {
7563            ret_type: 0,
7564            ret_msg: None,
7565            err_code: None,
7566            s2c: Some(futu_proto::qot_set_price_reminder::S2c { key }),
7567        };
7568        Some(prost::Message::encode_to_vec(&resp))
7569    }
7570}
7571
7572/// API PriceReminderType -> 后端 WarnType
7573fn api_reminder_type_to_warn_type(api_type: i32) -> Option<u32> {
7574    match api_type {
7575        1 => Some(4),   // PriceUp -> UP_PRICE
7576        2 => Some(8),   // PriceDown -> DOWN_PRICE
7577        3 => Some(1),   // ChangeRateUp -> DAY_UP_RATIO
7578        4 => Some(2),   // ChangeRateDown -> DAY_DOWN_RATIO
7579        5 => Some(9),   // 5MinChangeRateUp -> FIVE_MIN_UP_RATIO
7580        6 => Some(10),  // 5MinChangeRateDown -> FIVE_MIN_DOWN_RATIO
7581        7 => Some(11),  // VolumeUp -> UP_VOLUME
7582        8 => Some(12),  // TurnoverUp -> UP_TURNOVER
7583        9 => Some(13),  // TurnoverRateUp -> UP_TOR
7584        10 => Some(14), // BidPriceUp -> UP_BUY_PRICE_1
7585        11 => Some(15), // AskPriceDown -> DOWN_SELL_PRICE_1
7586        12 => Some(16), // BidVolUp -> UP_BUY_VOLUME_1
7587        13 => Some(17), // AskVolUp -> DOWN_SELL_VOLUME_1
7588        14 => Some(19), // 3MinChangeRateUp -> THREE_MIN_UP_RATIO
7589        15 => Some(20), // 3MinChangeRateDown -> THREE_MIN_DOWN_RATIO
7590        _ => None,
7591    }
7592}
7593
7594/// 后端 WarnType -> API PriceReminderType
7595fn warn_type_to_api_reminder_type(warn_type: u32) -> i32 {
7596    match warn_type {
7597        4 => 1,   // UP_PRICE -> PriceUp
7598        8 => 2,   // DOWN_PRICE -> PriceDown
7599        1 => 3,   // DAY_UP_RATIO -> ChangeRateUp
7600        2 => 4,   // DAY_DOWN_RATIO -> ChangeRateDown
7601        9 => 5,   // FIVE_MIN_UP_RATIO -> 5MinChangeRateUp
7602        10 => 6,  // FIVE_MIN_DOWN_RATIO -> 5MinChangeRateDown
7603        11 => 7,  // UP_VOLUME -> VolumeUp
7604        12 => 8,  // UP_TURNOVER -> TurnoverUp
7605        13 => 9,  // UP_TOR -> TurnoverRateUp
7606        14 => 10, // UP_BUY_PRICE_1 -> BidPriceUp
7607        15 => 11, // DOWN_SELL_PRICE_1 -> AskPriceDown
7608        16 => 12, // UP_BUY_VOLUME_1 -> BidVolUp
7609        17 => 13, // DOWN_SELL_VOLUME_1 -> AskVolUp
7610        19 => 14, // THREE_MIN_UP_RATIO -> 3MinChangeRateUp
7611        20 => 15, // THREE_MIN_DOWN_RATIO -> 3MinChangeRateDown
7612        _ => 0,   // Unknown
7613    }
7614}
7615
7616/// API PriceReminderFreq -> 后端 FreqType
7617fn api_freq_to_backend_freq(api_freq: i32) -> Option<u32> {
7618    match api_freq {
7619        1 => Some(1), // Always -> FREQ_TYPE_ALWAYS
7620        2 => Some(2), // OnceADay -> FREQ_TYPE_ONCE_FOR_ONE_DAY
7621        3 => Some(4), // OnlyOnce -> FREQ_TYPE_ONLY_ONCE
7622        _ => None,
7623    }
7624}
7625
7626/// 后端 FreqType -> API PriceReminderFreq
7627fn backend_freq_to_api_freq(freq: u32) -> i32 {
7628    match freq {
7629        1 => 1, // FREQ_TYPE_ALWAYS -> Always
7630        2 => 2, // FREQ_TYPE_ONCE_FOR_ONE_DAY -> OnceADay
7631        4 => 3, // FREQ_TYPE_ONLY_ONCE -> OnlyOnce
7632        _ => 0, // Unknown
7633    }
7634}
7635
7636/// API PriceReminderMarketStatus -> 后端 NotifyTimePeriod
7637fn api_market_status_to_notify_period(status: i32) -> Option<u32> {
7638    match status {
7639        1 => Some(1), // Open -> NORMAL
7640        2 => Some(2), // USPre -> US_BEFORE
7641        3 => Some(3), // USAfter -> US_AFTER
7642        4 => Some(4), // USOverNight -> US_OVERNIGHT
7643        _ => None,
7644    }
7645}
7646
7647/// 后端 NotifyTimePeriod -> API PriceReminderMarketStatus
7648fn notify_period_to_api_market_status(period: u32) -> i32 {
7649    match period {
7650        1 => 1, // NORMAL -> Open
7651        2 => 2, // US_BEFORE -> USPre
7652        3 => 3, // US_AFTER -> USAfter
7653        4 => 4, // US_OVERNIGHT -> USOverNight
7654        _ => 0, // Unknown
7655    }
7656}
7657
7658/// API QotMarket -> 后端 PriceReminder MarketType
7659fn api_qot_market_to_price_reminder_market(qot_market: i32) -> u32 {
7660    match qot_market {
7661        1 | 6 => 1,    // HK_Security | HK_Future -> HK(1)
7662        11 => 10,      // US_Security -> US(10)
7663        21 | 22 => 30, // CNSH | CNSZ -> HS(30)
7664        _ => 0,
7665    }
7666}
7667
7668// ===== GetPriceReminder (CMD 6808 拉取到价提醒) =====
7669struct GetPriceReminderHandler {
7670    backend: crate::bridge::SharedBackend,
7671    static_cache: Arc<StaticDataCache>,
7672}
7673
7674#[async_trait]
7675impl RequestHandler for GetPriceReminderHandler {
7676    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
7677        let req: futu_proto::qot_get_price_reminder::Request =
7678            prost::Message::decode(request.body.as_ref()).ok()?;
7679        let c2s = &req.c2s;
7680
7681        let backend = match super::load_backend(&self.backend) {
7682            Some(b) => b,
7683            None => {
7684                tracing::warn!(conn_id, "GetPriceReminder: no backend connection");
7685                return Some(super::make_error_response(-1, "no backend connection"));
7686            }
7687        };
7688
7689        // security 和 market 二选一, security 优先
7690        let (stock_id_opt, market_opt) = if let Some(sec) = &c2s.security {
7691            let sec_key = format!("{}_{}", sec.market, sec.code);
7692            match self.static_cache.get_security_info(&sec_key) {
7693                Some(info) if info.stock_id > 0 => (Some(info.stock_id), None),
7694                _ => {
7695                    return Some(super::make_error_response(-1, "unknown stock"));
7696                }
7697            }
7698        } else if let Some(market) = c2s.market {
7699            let backend_market = api_qot_market_to_price_reminder_market(market);
7700            if backend_market == 0 {
7701                return Some(super::make_error_response(-1, "unsupported market"));
7702            }
7703            (None, Some(backend_market))
7704        } else {
7705            return Some(super::make_error_response(
7706                -1,
7707                "must specify security or market",
7708            ));
7709        };
7710
7711        // Build backend PriceWarnGetNew_Req (CMD 6808)
7712        let backend_req = futu_backend::proto_internal::ft_cmd_price_warn::PriceWarnGetNewReq {
7713            stock_id: stock_id_opt,
7714            market: market_opt,
7715        };
7716        let body = prost::Message::encode_to_vec(&backend_req);
7717
7718        tracing::debug!(
7719            conn_id,
7720            stock_id = ?stock_id_opt,
7721            market = ?market_opt,
7722            "GetPriceReminder: sending CMD6808"
7723        );
7724
7725        let resp_frame = match backend.request(6808, body).await {
7726            Ok(f) => f,
7727            Err(e) => {
7728                tracing::error!(conn_id, error = %e, "CMD6808 request failed");
7729                return Some(super::make_error_response(-1, "backend request failed"));
7730            }
7731        };
7732
7733        let backend_rsp: futu_backend::proto_internal::ft_cmd_price_warn::PriceWarnGetNewRsp =
7734            match prost::Message::decode(resp_frame.body.as_ref()) {
7735                Ok(r) => r,
7736                Err(e) => {
7737                    tracing::error!(conn_id, error = %e, "CMD6808 decode failed");
7738                    return Some(super::make_error_response(
7739                        -1,
7740                        "backend response decode failed",
7741                    ));
7742                }
7743            };
7744
7745        if backend_rsp.result.unwrap_or(-1) != 0 {
7746            let err_text = backend_rsp.err_text.unwrap_or_default();
7747            let msg = if err_text.is_empty() {
7748                "get price reminder failed".to_string()
7749            } else {
7750                err_text
7751            };
7752            return Some(super::make_error_response(-1, &msg));
7753        }
7754
7755        // 转换 WarnItem -> PriceReminder
7756        let price_reminder_list: Vec<futu_proto::qot_get_price_reminder::PriceReminder> =
7757            backend_rsp
7758                .warn_items
7759                .iter()
7760                .filter_map(|warn_item| {
7761                    let sid = warn_item.stock_id?;
7762                    // Resolve stock_id -> Security
7763                    let key_ref = self.static_cache.id_to_key.get(&sid)?;
7764                    let info = self.static_cache.get_security_info(key_ref.value())?;
7765                    let security = futu_proto::qot_common::Security {
7766                        market: info.market,
7767                        code: info.code.clone(),
7768                    };
7769
7770                    let item_list: Vec<futu_proto::qot_get_price_reminder::PriceReminderItem> =
7771                        warn_item
7772                            .attr
7773                            .iter()
7774                            .filter_map(|attr| {
7775                                let wt = attr.warn_type?;
7776                                // 过滤公告提醒类型 (INFO_WARN = 18)
7777                                if wt == 18 {
7778                                    return None;
7779                                }
7780                                let api_type = warn_type_to_api_reminder_type(wt);
7781                                let value = attr.warn_param.unwrap_or(0) as f64 / 1000.0;
7782                                let note = attr.note.clone().unwrap_or_default();
7783                                let freq = backend_freq_to_api_freq(attr.freq_type.unwrap_or(0));
7784                                let is_enable = attr.enable.unwrap_or(false);
7785                                let key = attr.key.unwrap_or(0) as i64;
7786
7787                                let reminder_session_list: Vec<i32> = attr
7788                                    .notify_time_periods
7789                                    .iter()
7790                                    .map(|&p| notify_period_to_api_market_status(p))
7791                                    .collect();
7792
7793                                Some(futu_proto::qot_get_price_reminder::PriceReminderItem {
7794                                    key,
7795                                    r#type: api_type,
7796                                    value,
7797                                    note,
7798                                    freq,
7799                                    is_enable,
7800                                    reminder_session_list,
7801                                })
7802                            })
7803                            .collect();
7804
7805                    Some(futu_proto::qot_get_price_reminder::PriceReminder {
7806                        security,
7807                        name: Some(info.name.clone()),
7808                        item_list,
7809                    })
7810                })
7811                .collect();
7812
7813        tracing::debug!(
7814            conn_id,
7815            count = price_reminder_list.len(),
7816            "GetPriceReminder: returning {} reminders",
7817            price_reminder_list.len()
7818        );
7819
7820        let resp = futu_proto::qot_get_price_reminder::Response {
7821            ret_type: 0,
7822            ret_msg: None,
7823            err_code: None,
7824            s2c: Some(futu_proto::qot_get_price_reminder::S2c {
7825                price_reminder_list,
7826            }),
7827        };
7828        Some(prost::Message::encode_to_vec(&resp))
7829    }
7830}
7831
7832// ===== GetUserSecurityGroup (CMD 5121 拉取分组列表) =====
7833// C++ 流程: 拉取分组信息后, 按 groupType 过滤(Custom/System/All),
7834// 跳过基金宝(890), 外汇(891), 持仓(896)
7835struct GetUserSecurityGroupHandler {
7836    backend: crate::bridge::SharedBackend,
7837    app_lang: i32,
7838}
7839
7840#[async_trait]
7841impl RequestHandler for GetUserSecurityGroupHandler {
7842    async fn handle(&self, conn_id: u64, request: &IncomingRequest) -> Option<Vec<u8>> {
7843        let req: futu_proto::qot_get_user_security_group::Request =
7844            prost::Message::decode(request.body.as_ref()).ok()?;
7845        let group_type = req.c2s.group_type;
7846
7847        let backend = match super::load_backend(&self.backend) {
7848            Some(b) => b,
7849            None => {
7850                tracing::warn!(conn_id, "GetUserSecurityGroup: no backend connection");
7851                return Some(super::make_error_response(-1, "no backend connection"));
7852            }
7853        };
7854
7855        // 校验 groupType: 1=Custom, 2=System, 3=All
7856        if !(1..=3).contains(&group_type) {
7857            return Some(super::make_error_response(-1, "unsupported group type"));
7858        }
7859
7860        let user_id = backend.user_id.load(std::sync::atomic::Ordering::Relaxed) as u64;
7861
7862        let group_req = futu_backend::proto_internal::wch_lst::GetGroupListReq {
7863            user_id: Some(user_id),
7864        };
7865        let group_body = prost::Message::encode_to_vec(&group_req);
7866        tracing::info!(conn_id, group_type, "GetUserSecurityGroup: sending CMD5121");
7867
7868        let group_frame = match backend.request(5121, group_body).await {
7869            Ok(f) => f,
7870            Err(e) => {
7871                tracing::error!(conn_id, error = %e, "CMD5121 request failed");
7872                return Some(super::make_error_response(-1, "pull group info failed"));
7873            }
7874        };
7875
7876        // CMD5121 响应的 SRPC field 5 包含 repeated GroupInfo(不是 GetGroupListResp),
7877        // 需要使用专用解码函数
7878        let (group_rsp, group_langs) = super::decode_cmd5121_groups(group_frame.body.as_ref());
7879
7880        if group_rsp.result_code.unwrap_or(-1) != 0 {
7881            return Some(super::make_error_response(-1, "pull group info failed"));
7882        }
7883
7884        // 过滤并转换分组
7885        let app_lang = self.app_lang;
7886        let mut group_list = Vec::new();
7887        for (i, g) in group_rsp.group_list.iter().enumerate() {
7888            let gid = g.group_id.unwrap_or(0);
7889            let is_system_group = (gid > 0 && gid < 900) || gid == 1000;
7890
7891            // 系统分组名称获取优先级(与 C++ 一致):
7892            // 1. 从 multi_lang_name 中按客户端语言匹配
7893            // 2. group_name 字段
7894            // 3. 硬编码映射表 fallback
7895            let mut name = String::new();
7896            if is_system_group {
7897                if let Some(langs) = group_langs.get(i) {
7898                    if let Some(ml) = langs.iter().find(|m| m.language_id == app_lang) {
7899                        name = ml.name.clone();
7900                    }
7901                }
7902            }
7903            if name.is_empty() {
7904                name = g.group_name.clone().unwrap_or_default();
7905            }
7906            if is_system_group && name.is_empty() {
7907                if let Some(localized) = system_group_id_to_name(gid, app_lang as u8) {
7908                    name = localized.to_string();
7909                }
7910            }
7911
7912            // 跳过: 基金宝(890), 外汇(891), 持仓(896)
7913            if gid == 890 || gid == 891 || gid == 896 {
7914                continue;
7915            }
7916
7917            let include = match group_type {
7918                1 => !is_system_group, // Custom
7919                2 => is_system_group,  // System
7920                3 => true,             // All
7921                _ => false,
7922            };
7923
7924            if include {
7925                let api_group_type = if is_system_group { 2 } else { 1 };
7926                group_list.push(futu_proto::qot_get_user_security_group::GroupData {
7927                    group_name: name,
7928                    group_type: api_group_type,
7929                });
7930            }
7931        }
7932
7933        tracing::debug!(
7934            conn_id,
7935            count = group_list.len(),
7936            "GetUserSecurityGroup: returning {} groups",
7937            group_list.len()
7938        );
7939
7940        let resp = futu_proto::qot_get_user_security_group::Response {
7941            ret_type: 0,
7942            ret_msg: None,
7943            err_code: None,
7944            s2c: Some(futu_proto::qot_get_user_security_group::S2c { group_list }),
7945        };
7946        Some(prost::Message::encode_to_vec(&resp))
7947    }
7948}
7949
7950/// 系统分组名称到 group_id 的映射。
7951/// 支持简体中文、繁体中文和英文名。
7952/// C++ 中系统分组名称来自 multi_lang_name,按客户端语言匹配。
7953fn system_group_name_to_id(name: &str) -> Option<u32> {
7954    match name {
7955        "全部" | "ALL" | "All" => Some(1000),
7956        "沪深" | "滬深" | "A股" | "CN" => Some(899),
7957        "港股" | "HK" => Some(898),
7958        "美股" | "US" => Some(897),
7959        "持仓" | "持倉" | "Position" => Some(896),
7960        "美股期权" | "美股期權" | "US Options" => Some(895),
7961        "特别关注" | "特別關注" | "Focus" => Some(894),
7962        "港股期权" | "港股期權" | "HK Options" => Some(893),
7963        "期货" | "期貨" | "Futures" => Some(892),
7964        "外汇" | "外匯" | "Forex" => Some(891),
7965        "基金宝" | "基金寶" | "Fund" => Some(890),
7966        "期权" | "期權" | "Options" => Some(889),
7967        "债券" | "債券" | "Bond" => Some(888),
7968        "新加坡" | "SG" => Some(887),
7969        "指数" | "指數" | "Index" => Some(886),
7970        "数字货币" | "數字貨幣" | "Crypto" => Some(885),
7971        _ => None,
7972    }
7973}
7974
7975/// 系统分组 ID 到本地化名称的映射。
7976/// lang: 0=简体中文, 1=繁体中文, 2=English
7977fn system_group_id_to_name(id: u32, lang: u8) -> Option<&'static str> {
7978    // 未知语言 fallback 到简体中文
7979    let lang = if lang > 2 { 0 } else { lang };
7980    match (id, lang) {
7981        // 简体中文
7982        (1000, 0) => Some("全部"),
7983        (899, 0) => Some("沪深"),
7984        (898, 0) => Some("港股"),
7985        (897, 0) => Some("美股"),
7986        (896, 0) => Some("持仓"),
7987        (895, 0) => Some("美股期权"),
7988        (894, 0) => Some("特别关注"),
7989        (893, 0) => Some("港股期权"),
7990        (892, 0) => Some("期货"),
7991        (891, 0) => Some("外汇"),
7992        (890, 0) => Some("基金宝"),
7993        (889, 0) => Some("期权"),
7994        (888, 0) => Some("债券"),
7995        (887, 0) => Some("新加坡"),
7996        (886, 0) => Some("指数"),
7997        (885, 0) => Some("数字货币"),
7998        // 繁体中文
7999        (1000, 1) => Some("全部"),
8000        (899, 1) => Some("滬深"),
8001        (898, 1) => Some("港股"),
8002        (897, 1) => Some("美股"),
8003        (896, 1) => Some("持倉"),
8004        (895, 1) => Some("美股期權"),
8005        (894, 1) => Some("特別關注"),
8006        (893, 1) => Some("港股期權"),
8007        (892, 1) => Some("期貨"),
8008        (891, 1) => Some("外匯"),
8009        (890, 1) => Some("基金寶"),
8010        (889, 1) => Some("期權"),
8011        (888, 1) => Some("債券"),
8012        (887, 1) => Some("新加坡"),
8013        (886, 1) => Some("指數"),
8014        (885, 1) => Some("數字貨幣"),
8015        // English
8016        (1000, 2) => Some("All"),
8017        (899, 2) => Some("CN"),
8018        (898, 2) => Some("HK"),
8019        (897, 2) => Some("US"),
8020        (896, 2) => Some("Position"),
8021        (895, 2) => Some("US Options"),
8022        (894, 2) => Some("Focus"),
8023        (893, 2) => Some("HK Options"),
8024        (892, 2) => Some("Futures"),
8025        (891, 2) => Some("Forex"),
8026        (890, 2) => Some("Fund"),
8027        (889, 2) => Some("Options"),
8028        (888, 2) => Some("Bond"),
8029        (887, 2) => Some("SG"),
8030        (886, 2) => Some("Index"),
8031        (885, 2) => Some("Crypto"),
8032        _ => None,
8033    }
8034}
8035
8036// ===== GetUsedQuota (1010) =====
8037struct GetUsedQuotaHandler {
8038    subscriptions: Arc<SubscriptionManager>,
8039    kl_quota_counter: Arc<std::sync::atomic::AtomicU32>,
8040}
8041
8042#[async_trait]
8043impl RequestHandler for GetUsedQuotaHandler {
8044    async fn handle(&self, conn_id: u64, _req: &IncomingRequest) -> Option<Vec<u8>> {
8045        let used_sub_quota = self.subscriptions.get_total_used_quota() as i32;
8046        let used_kl_quota = self
8047            .kl_quota_counter
8048            .load(std::sync::atomic::Ordering::Relaxed) as i32;
8049
8050        tracing::debug!(conn_id, used_sub_quota, used_kl_quota, "GetUsedQuota");
8051
8052        let resp = futu_proto::used_quota::Response {
8053            ret_type: 0,
8054            ret_msg: None,
8055            err_code: None,
8056            s2c: Some(futu_proto::used_quota::S2c {
8057                used_sub_quota: Some(used_sub_quota),
8058                used_k_line_quota: Some(used_kl_quota),
8059            }),
8060        };
8061        Some(prost::Message::encode_to_vec(&resp))
8062    }
8063}