1use 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
23const CMD_GET_SECURITIES_INFO: u16 = 20106;
25const SYMBOL_BATCH_SIZE: usize = 90;
27const HP_CONTRACT_SHARE_SIZE_SCALE: u64 = 1_000_000_000;
28const DEFAULT_OPTION_LOT_SIZE: i32 = 100;
29
30pub 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
141pub 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 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;