Skip to main content

futu_gateway_core/
security_on_demand.rs

1//! Shared on-demand static-security hydration helpers.
2//!
3//! C++ reference:
4//! - `APIServer_Inner_API.cpp:6361-6365` calls `PullComboLegsQotInfo` before
5//!   parsing combo legs.
6//! - `APIServer_Inner_API.cpp:1321-1379` collects only HK/US option-looking
7//!   codes missing from local stock-list cache and calls
8//!   `INNBiz_Qot_SecList::PullOptionInfoByCode`.
9//! - `NNBiz_Qot_StockInfoReq.cpp:236-299` sends CMD20106 `SecuritiesReq` with
10//!   `symbols=CODE.SUFFIX`, `is_completed=true`, and chunks symbols by 90.
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::sync::Arc;
14
15use futu_backend::conn::BackendConn;
16use futu_backend::proto_internal::stock_information_svr::{
17    OptionResultInfo, SecuritiesReq, SecuritiesRsp, StockInfoItem,
18};
19use futu_cache::qot_cache::make_key;
20use futu_cache::static_data::{CachedSecurityInfo, OptionContractInfo, StaticDataCache};
21use prost::Message;
22
23// Ref: FutuOpenD/Src/NNBase/NNBase_Define_ProtoCmd.h:187.
24const CMD_GET_SECURITIES_INFO: u16 = 20106;
25// Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_StockInfoReq.cpp:253-256.
26const SYMBOL_BATCH_SIZE: usize = 90;
27const HP_CONTRACT_SHARE_SIZE_SCALE: u64 = 1_000_000_000;
28const DEFAULT_OPTION_LOT_SIZE: i32 = 100;
29
30/// Fetch option static info by user-facing option code and write it into
31/// [`StaticDataCache`].
32///
33/// This intentionally mirrors the C++ symbols path (`ReqStockInfoByCode`), not
34/// the stock-id path: request symbols are `CODE.SUFFIX`, top-level
35/// `ret_code=0` is required, and item-level `ret=0` gates each option row.
36pub async fn fetch_and_cache_options_by_code(
37    backend: &BackendConn,
38    cache: &Arc<StaticDataCache>,
39    missing_codes: &[(i32, String)],
40) -> usize {
41    if missing_codes.is_empty() {
42        return 0;
43    }
44
45    let mut by_market: BTreeMap<i32, BTreeSet<String>> = BTreeMap::new();
46    for (market, code) in missing_codes {
47        let code = code.trim();
48        if code.is_empty() {
49            continue;
50        }
51        by_market
52            .entry(*market)
53            .or_default()
54            .insert(code.to_owned());
55    }
56
57    let mut inserted = 0usize;
58    for (market, codes) in by_market {
59        let Some(suffix) = futu_cache::qot_market::qot_market_to_symbol_suffix(market) else {
60            tracing::debug!(
61                market,
62                "CMD20106 option static warmup skipped unsupported market"
63            );
64            continue;
65        };
66        let symbols: Vec<String> = codes
67            .into_iter()
68            .map(|code| format!("{code}.{suffix}"))
69            .collect();
70        for (batch_idx, batch) in symbols.chunks(SYMBOL_BATCH_SIZE).enumerate() {
71            let req = SecuritiesReq {
72                stock_ids: vec![],
73                symbols: batch.to_vec(),
74                src_code_item: vec![],
75                is_completed: Some(true),
76            };
77            let frame = match backend
78                .request(CMD_GET_SECURITIES_INFO, req.encode_to_vec())
79                .await
80            {
81                Ok(frame) => frame,
82                Err(err) => {
83                    tracing::debug!(
84                        error = %err,
85                        market,
86                        batch_idx,
87                        "CMD20106 option static warmup request failed"
88                    );
89                    continue;
90                }
91            };
92            let rsp: SecuritiesRsp = match Message::decode(frame.body.as_ref()) {
93                Ok(rsp) => rsp,
94                Err(err) => {
95                    tracing::debug!(
96                        error = %err,
97                        market,
98                        batch_idx,
99                        "CMD20106 option static warmup decode failed"
100                    );
101                    continue;
102                }
103            };
104            if rsp.ret_code != Some(0) {
105                tracing::debug!(
106                    market,
107                    batch_idx,
108                    ret_code = ?rsp.ret_code,
109                    err_msg = rsp.err_msg.as_deref().unwrap_or(""),
110                    "CMD20106 option static warmup returned non-zero ret_code"
111                );
112                continue;
113            }
114            for info in &rsp.option_info_items {
115                if info.ret.unwrap_or(1) != 0 {
116                    continue;
117                }
118                if write_option_result_info_to_cache(cache, info, market) {
119                    inserted += 1;
120                }
121            }
122        }
123    }
124
125    if inserted > 0 {
126        tracing::debug!(
127            inserted,
128            "CMD20106 option static warmup inserted option rows"
129        );
130    }
131    inserted
132}
133
134#[derive(Debug, Clone)]
135struct SecuritySymbolTarget {
136    market: i32,
137    code: String,
138    key: String,
139}
140
141/// Fetch basic stock static info by user-facing code and write/update
142/// [`StaticDataCache`].
143///
144/// This is the shared equivalent of C++ `PullSecInfo`/`ReqStockInfoByCode` for
145/// request paths that cannot safely wait for the full stock-list sync. The
146/// cache key remains the public FTAPI QotMarket (`market`), while backend
147/// `StockInfoItem.market_code` is stored as `CachedSecurityInfo.mkt_id`.
148pub async fn fetch_and_cache_securities_by_code(
149    backend: &BackendConn,
150    cache: &Arc<StaticDataCache>,
151    missing_codes: &[(i32, String)],
152) -> usize {
153    if missing_codes.is_empty() {
154        return 0;
155    }
156
157    let mut by_market: BTreeMap<i32, BTreeSet<String>> = BTreeMap::new();
158    for (market, code) in missing_codes {
159        let code = code.trim();
160        if code.is_empty() {
161            continue;
162        }
163        by_market
164            .entry(*market)
165            .or_default()
166            .insert(code.to_owned());
167    }
168
169    let mut written = 0usize;
170    for (market, codes) in by_market {
171        let mut symbols = Vec::new();
172        let mut symbol_to_target: BTreeMap<String, SecuritySymbolTarget> = BTreeMap::new();
173        for code in codes {
174            let Some(symbol) = futu_cache::qot_market::make_cmd20106_symbol(market, &code) else {
175                tracing::debug!(
176                    market,
177                    code = %code,
178                    "CMD20106 security static warmup skipped unsupported market"
179                );
180                continue;
181            };
182            let key = make_key(market, &code);
183            let target = SecuritySymbolTarget {
184                market,
185                code: code.clone(),
186                key,
187            };
188            symbol_to_target.insert(symbol.clone(), target.clone());
189            symbol_to_target.insert(
190                futu_cache::qot_market::cmd20106_symbol_code(&symbol).to_string(),
191                target,
192            );
193            symbols.push(symbol);
194        }
195
196        for (batch_idx, batch) in symbols.chunks(SYMBOL_BATCH_SIZE).enumerate() {
197            let req = SecuritiesReq {
198                stock_ids: vec![],
199                symbols: batch.to_vec(),
200                src_code_item: vec![],
201                is_completed: Some(true),
202            };
203            let frame = match backend
204                .request(CMD_GET_SECURITIES_INFO, req.encode_to_vec())
205                .await
206            {
207                Ok(frame) => frame,
208                Err(err) => {
209                    tracing::debug!(
210                        error = %err,
211                        market,
212                        batch_idx,
213                        "CMD20106 security static warmup request failed"
214                    );
215                    continue;
216                }
217            };
218            let rsp: SecuritiesRsp = match Message::decode(frame.body.as_ref()) {
219                Ok(rsp) => rsp,
220                Err(err) => {
221                    tracing::debug!(
222                        error = %err,
223                        market,
224                        batch_idx,
225                        "CMD20106 security static warmup decode failed"
226                    );
227                    continue;
228                }
229            };
230            if rsp.ret_code != Some(0) {
231                tracing::debug!(
232                    market,
233                    batch_idx,
234                    ret_code = ?rsp.ret_code,
235                    err_msg = rsp.err_msg.as_deref().unwrap_or(""),
236                    "CMD20106 security static warmup returned non-zero ret_code"
237                );
238                continue;
239            }
240            for info in &rsp.stock_info_items {
241                if write_stock_info_item_to_cache(cache, info, &symbol_to_target) {
242                    written += 1;
243                }
244            }
245        }
246    }
247
248    if written > 0 {
249        tracing::debug!(
250            written,
251            "CMD20106 security static warmup inserted or updated stock rows"
252        );
253    }
254    written
255}
256
257fn write_stock_info_item_to_cache(
258    cache: &Arc<StaticDataCache>,
259    info: &StockInfoItem,
260    symbol_to_target: &BTreeMap<String, SecuritySymbolTarget>,
261) -> bool {
262    let symbol = info
263        .src_code
264        .as_deref()
265        .or(info.display_symbol.as_deref())
266        .or(info.symbol.as_deref())
267        .unwrap_or("");
268    if info.ret.unwrap_or(1) != 0 || symbol.is_empty() {
269        return false;
270    }
271    let Some(target) = symbol_to_target
272        .get(symbol)
273        .or_else(|| symbol_to_target.get(futu_cache::qot_market::cmd20106_symbol_code(symbol)))
274    else {
275        return false;
276    };
277    let Some(stock_id) = info.stock_id.filter(|stock_id| *stock_id > 0) else {
278        return false;
279    };
280    let Some(mkt_id) = info.market_code.filter(|mkt_id| *mkt_id > 0) else {
281        return false;
282    };
283
284    if let Some(existing) = cache.get_security_info(&target.key) {
285        if existing.stock_id > 0 {
286            return cache.update_mkt_id(&target.key, mkt_id);
287        }
288        return false;
289    }
290
291    cache.upsert_basic_security_info(
292        &target.key,
293        CachedSecurityInfo {
294            stock_id,
295            market: target.market,
296            mkt_id,
297            code: target.code.clone(),
298            name: info
299                .sc_name
300                .clone()
301                .or_else(|| info.tc_name.clone())
302                .or_else(|| info.name.clone())
303                .unwrap_or_default(),
304            lot_size: info.lot_size.unwrap_or(0),
305            sec_type: info
306                .instrument_type_v2
307                .or(info.instrument_type)
308                .unwrap_or(0),
309            spread_table_code: info.spread_table_code.unwrap_or(0),
310            sub_instrument_type_v2: info
311                .sub_instrument_type_v2
312                .and_then(|value| u32::try_from(value).ok())
313                .unwrap_or(0),
314            list_time: String::new(),
315            warrnt_stock_owner: 0,
316            delisting: false,
317            exch_type: 0,
318            no_search: info.no_search.unwrap_or(0) != 0,
319            listed_exchange: info.listed_exchange.clone().unwrap_or_default(),
320            source: futu_cache::static_data::SecurityInfoSource::OnDemandBasic,
321            ..Default::default()
322        },
323    );
324    true
325}
326
327fn write_option_result_info_to_cache(
328    cache: &Arc<StaticDataCache>,
329    info: &OptionResultInfo,
330    market: i32,
331) -> bool {
332    let stock_id = info.option_id.unwrap_or(0);
333    if stock_id == 0 {
334        return false;
335    }
336    let Some(code) = info
337        .option_string_code
338        .as_deref()
339        .filter(|code| !code.is_empty())
340        .map(cut_code_market_suffix)
341        .filter(|code| !code.is_empty())
342        .map(ToOwned::to_owned)
343    else {
344        return false;
345    };
346    let lot_size = option_lot_size_from_hp_contract_share_size(info.hp_contract_share_size);
347    let key = make_key(market, &code);
348    let cached = CachedSecurityInfo {
349        stock_id,
350        market,
351        mkt_id: info.market_code.unwrap_or(0),
352        code,
353        name: info.option_name.clone().unwrap_or_default(),
354        lot_size,
355        sec_type: futu_proto::qot_common::SecurityType::Drvt as i32,
356        list_time: String::new(),
357        warrnt_stock_owner: 0,
358        delisting: info.delisting_flag.map(|flag| flag != 0).unwrap_or(false),
359        exch_type: 0,
360        no_search: false,
361        source: futu_cache::static_data::SecurityInfoSource::OnDemandBasic,
362        ..Default::default()
363    };
364    cache.upsert_basic_security_info(&key, cached);
365    if let Some(contract) = option_contract_info_from_result(info) {
366        cache.set_option_contract_info(stock_id, contract);
367    }
368    true
369}
370
371fn option_contract_info_from_result(info: &OptionResultInfo) -> Option<OptionContractInfo> {
372    let underlying_stock_id = info.underlying_stock_id.filter(|value| *value > 0)?;
373    let strike_date = info.strike_date.filter(|value| *value > 0)? as u64;
374    let real_expiration_time = info
375        .real_expiration_time
376        .filter(|value| *value > 0)
377        .map(|value| value as u64)
378        .unwrap_or(0);
379    let strike_price = info.strike_price.filter(|value| *value > 0)?;
380    let option_type = info.option_type.filter(|value| *value > 0)?;
381    let index_option_type = info
382        .index_option_type
383        .filter(|value| *value > 0)
384        .unwrap_or(0);
385    Some(OptionContractInfo {
386        underlying_stock_id,
387        strike_date,
388        real_expiration_time,
389        strike_price,
390        option_type,
391        index_option_type,
392    })
393}
394
395fn option_lot_size_from_hp_contract_share_size(value: Option<u64>) -> i32 {
396    // `hp_contract_share_size` is fixed-point with 9 decimal digits in the
397    // backend proto. The fallback preserves the previous Rust cache behavior
398    // when backend omits the field; it is documented in the follow-up ledger as
399    // an implementation fallback, not as an asserted C++ default.
400    value
401        .map(|value| (value / HP_CONTRACT_SHARE_SIZE_SCALE).max(1) as i32)
402        .unwrap_or(DEFAULT_OPTION_LOT_SIZE)
403}
404
405fn cut_code_market_suffix(code: &str) -> &str {
406    code.split_once('.')
407        .map(|(prefix, _suffix)| prefix)
408        .unwrap_or(code)
409}
410
411#[cfg(test)]
412mod tests;