Skip to main content

futu_backend/
option_product_zone.rs

1//! Public QOT 3311-3314 backend adapters.
2
3use bytes::Bytes;
4use futu_command_spec::QotReadOperation;
5use futu_core::error::{FutuError, Result};
6use futu_domain_qot_option_screen::{
7    EarningsScreenerPlan, OptionProductContractPlan, ProductFilterInput, SellerScreenerPlan,
8    ZeroDteScreenerPlan, api_expiration_to_internal,
9};
10
11use crate::command_runtime::execute_qot_read_with_reserved;
12use crate::conn::BackendConn;
13use crate::proto_internal::option_product_zone as proto;
14
15#[must_use]
16pub fn build_zero_dte_screener_request(
17    plan: &ZeroDteScreenerPlan,
18) -> proto::OptionZeroDteScreenerReq {
19    proto::OptionZeroDteScreenerReq {
20        strategy: Some(proto::ScreenStrategy {
21            market_type_list: vec![plan.backend_market],
22            filter_group_list: plan
23                .filters
24                .iter()
25                .filter_map(zero_dte_underlying_filter)
26                .collect(),
27        }),
28        sort_obj: Some(proto::ZeroDteSortObj {
29            sort_field: Some(zero_dte_sort_field(plan.sort_type)),
30            is_asc: Some(plan.is_asc),
31        }),
32        begin_index: plan.from,
33        count: Some(plan.count),
34    }
35}
36
37#[must_use]
38pub fn build_zero_dte_contract_request(
39    plan: &OptionProductContractPlan,
40) -> proto::OptionZeroDteContractListReq {
41    let chain = &plan.chain_info;
42    proto::OptionZeroDteContractListReq {
43        stock_id: Some(plan.owner_stock_id as i64),
44        strike_date_timestamp: Some(plan.strike_date_timestamp),
45        filter_group_list: plan
46            .filters
47            .iter()
48            .filter_map(zero_dte_contract_filter)
49            .collect(),
50        sort_obj: (plan.sort_type != 0).then(|| proto::ZeroDteContractObj {
51            sort_field: Some(zero_dte_contract_sort_field(plan.sort_type)),
52            is_asc: Some(plan.is_asc),
53        }),
54        option_chain: Some(proto::OptionChainType {
55            // Ref: NNBiz_Qot_OptionProductZone.cpp:249-269. These are the
56            // exact 3311 chain fields; none may be derived from owner/date.
57            strike_date: chain.strike_date_timestamp,
58            product_code: chain.product_code.clone(),
59            hp_multiplier: chain.multiplier.map(|value| (value * 1e9) as i64),
60            hp_contract_share_size: chain.contract_share_size.map(|value| (value * 1e9) as i64),
61            expiration_type: chain.expiration_type.and_then(api_expiration_to_internal),
62            underlying_stock_id: chain.underlying_stock_id,
63        }),
64    }
65}
66
67#[must_use]
68pub fn build_earnings_screener_request(
69    plan: &EarningsScreenerPlan,
70) -> proto::ReportUnderlyingListReq {
71    let mut filter_group_list = Vec::new();
72    if plan.add_default_earnings_day_filter {
73        filter_group_list.push(underlying_interval_group(
74            301,
75            Some(0),
76            Some(365),
77            false,
78            false,
79        ));
80    }
81    filter_group_list.extend(plan.filters.iter().filter_map(earnings_filter));
82    proto::ReportUnderlyingListReq {
83        strategy: Some(proto::ScreenStrategy {
84            market_type_list: vec![plan.backend_market],
85            filter_group_list,
86        }),
87        sort_obj: vec![earnings_sort(plan.sort_type, plan.is_asc)],
88        begin_index: plan.from,
89        count: Some(plan.count),
90        is_card_scene: None,
91    }
92}
93
94#[must_use]
95pub fn build_seller_screener_request(plan: &SellerScreenerPlan) -> proto::OptionSellerScreenerReq {
96    let mut filter_group_list = seller_default_filters(plan.default_filter_mask);
97    filter_group_list.extend(plan.filters.iter().filter_map(seller_filter));
98    proto::OptionSellerScreenerReq {
99        option_id_list: Vec::new(),
100        seller_type: Some(plan.seller_type),
101        account_id: None,
102        strategy: Some(proto::ScreenStrategy {
103            market_type_list: vec![plan.backend_market],
104            filter_group_list,
105        }),
106        need_field: Some(proto::OptionItem {
107            // Ref: NNBiz_Qot_OptionProductZone.cpp:848-865. Zero values are
108            // protobuf presence markers selecting every public output field,
109            // not substitute data returned to callers.
110            option_id: Some(0),
111            option_name: Some(String::new()),
112            hp_strike_price: Some(0),
113            strike_date_timestamp: Some(0),
114            option_type: Some(0),
115            left_day: Some(0),
116            premium: Some(0),
117            otm_degree: Some(0),
118            implied_volatility: Some(0),
119            interval_return: Some(0),
120            sell_annualized_return: Some(0),
121            striked_interval_return: Some(0),
122            striked_annualized_return: Some(0),
123            itm_probability: Some(0),
124            stock_price: Some(0),
125            option_price: Some(0),
126        }),
127        sort_obj: (plan.sort_type != 0).then(|| proto::SellerSortObj {
128            sort_field: Some(seller_sort_field(plan.sort_type)),
129            is_asc: Some(plan.is_asc),
130        }),
131    }
132}
133
134pub async fn pull_zero_dte_screener(
135    backend: &BackendConn,
136    plan: &ZeroDteScreenerPlan,
137) -> Result<proto::OptionZeroDteScreenerRsp> {
138    pull(
139        backend,
140        QotReadOperation::OptionZeroDteScreener,
141        build_zero_dte_screener_request(plan),
142        plan.quote_mkt_type,
143        "option zero-DTE screener",
144    )
145    .await
146}
147
148pub async fn pull_zero_dte_contract(
149    backend: &BackendConn,
150    plan: &OptionProductContractPlan,
151) -> Result<proto::OptionZeroDteContractListRsp> {
152    pull(
153        backend,
154        QotReadOperation::OptionZeroDteContract,
155        build_zero_dte_contract_request(plan),
156        plan.quote_mkt_type,
157        "option zero-DTE contract",
158    )
159    .await
160}
161
162pub async fn pull_earnings_screener(
163    backend: &BackendConn,
164    plan: &EarningsScreenerPlan,
165) -> Result<proto::ReportUnderlyingListRsp> {
166    pull(
167        backend,
168        QotReadOperation::OptionEarningsScreener,
169        build_earnings_screener_request(plan),
170        plan.quote_mkt_type,
171        "option earnings screener",
172    )
173    .await
174}
175
176pub async fn pull_seller_screener(
177    backend: &BackendConn,
178    plan: &SellerScreenerPlan,
179) -> Result<proto::OptionSellerScreenerRsp> {
180    pull(
181        backend,
182        QotReadOperation::OptionSellerScreener,
183        build_seller_screener_request(plan),
184        plan.quote_mkt_type,
185        "option seller screener",
186    )
187    .await
188}
189
190async fn pull<Req, Rsp>(
191    backend: &BackendConn,
192    operation: QotReadOperation,
193    request: Req,
194    quote_mkt_type: u8,
195    label: &'static str,
196) -> Result<Rsp>
197where
198    Req: prost::Message,
199    Rsp: prost::Message + Default + ProductZoneResponse,
200{
201    let mut reserved = [0_u8; 10];
202    // Ref: NNBiz_Qot_OptionProductZone.cpp:191-192,411-412,867-868 and
203    // NNBiz_Qot_OptionEarnings.cpp:299-300. `quote_mkt_type` is the upstream
204    // NN_QuoteMktType wire enum; it is not a configurable endpoint.
205    reserved[0] = quote_mkt_type;
206    let response = execute_qot_read_with_reserved(
207        backend,
208        operation,
209        Bytes::from(request.encode_to_vec()),
210        reserved,
211    )
212    .await?;
213    let decoded = Rsp::decode(response.body.as_ref()).map_err(FutuError::Proto)?;
214    match decoded.ret_code() {
215        Some(0) => Ok(decoded),
216        Some(code) => Err(FutuError::ServerError {
217            ret_type: code,
218            msg: decoded
219                .err_msg()
220                .unwrap_or_else(|| format!("{label} backend rejected request")),
221        }),
222        None => Err(FutuError::Codec(format!(
223            "{label} backend response missing ret_code"
224        ))),
225    }
226}
227
228trait ProductZoneResponse {
229    fn ret_code(&self) -> Option<i32>;
230    fn err_msg(&self) -> Option<String>;
231}
232
233macro_rules! impl_response {
234    ($($type:ty),+ $(,)?) => {$(
235        impl ProductZoneResponse for $type {
236            fn ret_code(&self) -> Option<i32> { self.ret_code }
237            fn err_msg(&self) -> Option<String> { self.err_msg.clone() }
238        }
239    )+};
240}
241
242impl_response!(
243    proto::OptionZeroDteScreenerRsp,
244    proto::OptionZeroDteContractListRsp,
245    proto::ReportUnderlyingListRsp,
246    proto::OptionSellerScreenerRsp,
247);
248
249fn zero_dte_underlying_filter(filter: &ProductFilterInput) -> Option<proto::FilterGroup> {
250    // Ref: NNBiz_Qot_OptionProductZone.cpp:62-154. Public indicator values,
251    // backend indicator values and scales are protocol identities. Revisit as
252    // one matrix only when either public or backend proto changes.
253    let (kind, multiplier, exact_security, exact_values) = match filter.indicator_type {
254        1 => (101, 1.0, true, false),
255        2 => (308, 1.0, false, true),
256        3 => (201, 1.0, false, false),
257        4 => (202, 1.0, false, false),
258        5 => (203, 1e5, false, false),
259        6 => (204, 1e5, false, false),
260        7 => (205, 1e5, false, false),
261        8 => (206, 1e5, false, false),
262        9 => (402, 1e9, false, false),
263        10 => (403, 1e5, false, false),
264        _ => return None,
265    };
266    Some(underlying_group(indicator(
267        kind,
268        filter_value(filter, multiplier, exact_security, exact_values),
269    )))
270}
271
272fn zero_dte_contract_filter(filter: &ProductFilterInput) -> Option<proto::FilterGroup> {
273    // Ref: NNBiz_Qot_OptionProductZone.cpp:271-393. Same protocol-matrix
274    // constraint as 3311; the matrix test covers all 15 public indicators.
275    let (kind, multiplier, exact_values) = match filter.indicator_type {
276        1 => (1003, 1.0, true),
277        2 => (2011, 1.0, false),
278        3 => (2013, 1.0, false),
279        4 => (3001, 1e5, false),
280        5 => (3004, 1e5, false),
281        6 => (3005, 1e5, false),
282        7 => (3007, 1e5, false),
283        8 => (3006, 1e5, false),
284        9 => (3008, 1e5, false),
285        10 => (2002, 1e9, false),
286        11 => (2010, 1e5, false),
287        12 => (3023, 1e9, false),
288        // Ref: NNBiz_Qot_OptionProductZone.cpp:369-387. C++ intentionally
289        // uses 1e9 for these three request filters despite their public
290        // percentage semantics.
291        13 => (3011, 1e9, false),
292        14 => (3013, 1e9, false),
293        15 => (3014, 1e9, false),
294        _ => return None,
295    };
296    Some(option_group(indicator(
297        kind,
298        filter_value(filter, multiplier, false, exact_values),
299    )))
300}
301
302fn earnings_filter(filter: &ProductFilterInput) -> Option<proto::FilterGroup> {
303    // Ref: NNBiz_Qot_OptionEarnings.cpp:54-244. These values cannot be loaded
304    // dynamically because they select concrete protobuf fields on the wire.
305    let (kind, multiplier, exact_security, exact_values, expiration_values) =
306        match filter.indicator_type {
307            1 => (101, 1.0, true, false, false),
308            2 => (500, 1.0, false, true, false),
309            3 => (501, 1.0, true, false, false),
310            4 => (401, 1e3, false, false, false),
311            5 => (502, 1.0, false, false, true),
312            6 => (203, 1e5, false, false, false),
313            7 => (302, 1e5, false, false, false),
314            8 => (303, 1e5, false, false, false),
315            9 => (205, 1e5, false, false, false),
316            10 => (206, 1e5, false, false, false),
317            11 => (201, 1.0, false, false, false),
318            12 => (202, 1.0, false, false, false),
319            13 => (402, 1e9, false, false, false),
320            14 => (403, 1e5, false, false, false),
321            15 => (503, 1e5, false, false, false),
322            16 => (304, 1e5, false, false, false),
323            17 => (305, 1e5, false, false, false),
324            18 => (307, 1e5, false, false, false),
325            19 => (306, 1e5, false, false, false),
326            20 => (301, 1.0, false, false, false),
327            _ => return None,
328        };
329    let value = if expiration_values {
330        exact_expiration_value(filter)
331    } else {
332        filter_value(filter, multiplier, exact_security, exact_values)
333    };
334    Some(underlying_group(indicator(kind, value)))
335}
336
337fn seller_filter(filter: &ProductFilterInput) -> Option<proto::FilterGroup> {
338    // Ref: NNBiz_Qot_OptionProductZone.cpp:587-830. These values cannot be
339    // configuration-driven; backend proto changes must update this full matrix.
340    let (kind, multiplier, side, exact_security, exact_values, expiration_values) =
341        match filter.indicator_type {
342            1 => (101, 1.0, false, true, false, false),
343            2 => (105, 1.0, false, false, true, false),
344            3 => (201, 1.0, false, false, false, false),
345            4 => (202, 1.0, false, false, false, false),
346            5 => (203, 1e5, false, false, false, false),
347            6 => (204, 1e5, false, false, false, false),
348            7 => (205, 1e5, false, false, false, false),
349            8 => (206, 1e5, false, false, false, false),
350            9 => (401, 1e3, false, false, false, false),
351            10 => (402, 1e9, false, false, false, false),
352            11 => (403, 1e5, false, false, false, false),
353            12 => (501, 1.0, false, false, true, false),
354            13 => (502, 1.0, false, false, false, true),
355            14 => (1002, 1.0, true, false, false, false),
356            15 => (1005, 1.0, true, false, false, true),
357            16 => (1007, 1.0, true, false, true, false),
358            17 => (2021, 1e9, true, false, false, false),
359            18 => (3021, 1e5, true, false, false, false),
360            19 => (3022, 1e5, true, false, false, false),
361            20 => (3018, 1e5, true, false, false, false),
362            21 => (3020, 1e5, true, false, false, false),
363            22 => (3001, 1e5, true, false, false, false),
364            23 => (2004, 1e9, true, false, false, false),
365            24 => (2005, 1e9, true, false, false, false),
366            25 => (2011, 1.0, true, false, false, false),
367            26 => (2013, 1.0, true, false, false, false),
368            _ => return None,
369        };
370    let value = if expiration_values {
371        exact_expiration_value(filter)
372    } else {
373        filter_value(filter, multiplier, exact_security, exact_values)
374    };
375    let indicator = indicator(kind, value);
376    Some(if side {
377        option_group(indicator)
378    } else {
379        underlying_group(indicator)
380    })
381}
382
383fn filter_value(
384    filter: &ProductFilterInput,
385    multiplier: f64,
386    exact_security: bool,
387    exact_values: bool,
388) -> Option<proto::IndicatorValue> {
389    if exact_security {
390        return Some(proto::IndicatorValue {
391            value_list: filter
392                .security_stock_ids
393                .iter()
394                .map(|value| *value as i64)
395                .collect(),
396            value_interval: None,
397            value_string_list: Vec::new(),
398        });
399    }
400    if exact_values {
401        return Some(proto::IndicatorValue {
402            value_list: filter.value_list.clone(),
403            value_interval: None,
404            value_string_list: Vec::new(),
405        });
406    }
407    filter.interval.map(|interval| proto::IndicatorValue {
408        value_list: Vec::new(),
409        value_interval: Some(proto::indicator_value::Interval {
410            min_value: interval.min.map(|bound| (bound.value * multiplier) as i64),
411            max_value: interval.max.map(|bound| (bound.value * multiplier) as i64),
412            exclude_min: interval.min.map(|bound| !bound.includes),
413            exclude_max: interval.max.map(|bound| !bound.includes),
414        }),
415        value_string_list: Vec::new(),
416    })
417}
418
419fn exact_expiration_value(filter: &ProductFilterInput) -> Option<proto::IndicatorValue> {
420    Some(proto::IndicatorValue {
421        value_list: filter
422            .value_list
423            .iter()
424            .filter_map(|value| i32::try_from(*value).ok())
425            .filter_map(api_expiration_to_internal)
426            .map(i64::from)
427            .collect(),
428        value_interval: None,
429        value_string_list: Vec::new(),
430    })
431}
432
433fn indicator(kind: i32, value: Option<proto::IndicatorValue>) -> proto::ScreenerIndicator {
434    proto::ScreenerIndicator {
435        indicator_type: Some(kind),
436        indicator_value: value,
437    }
438}
439
440fn underlying_group(indicator: proto::ScreenerIndicator) -> proto::FilterGroup {
441    proto::FilterGroup {
442        underlying_list: vec![indicator],
443        option_list: Vec::new(),
444    }
445}
446
447fn option_group(indicator: proto::ScreenerIndicator) -> proto::FilterGroup {
448    proto::FilterGroup {
449        underlying_list: Vec::new(),
450        option_list: vec![indicator],
451    }
452}
453
454fn underlying_interval_group(
455    kind: i32,
456    min: Option<i64>,
457    max: Option<i64>,
458    exclude_min: bool,
459    exclude_max: bool,
460) -> proto::FilterGroup {
461    underlying_group(indicator(
462        kind,
463        Some(proto::IndicatorValue {
464            value_list: Vec::new(),
465            value_interval: Some(proto::indicator_value::Interval {
466                min_value: min,
467                max_value: max,
468                exclude_min: min.map(|_| exclude_min),
469                exclude_max: max.map(|_| exclude_max),
470            }),
471            value_string_list: Vec::new(),
472        }),
473    ))
474}
475
476fn option_interval_group(kind: i32, min: i64) -> proto::FilterGroup {
477    option_group(indicator(
478        kind,
479        Some(proto::IndicatorValue {
480            value_list: Vec::new(),
481            value_interval: Some(proto::indicator_value::Interval {
482                min_value: Some(min),
483                max_value: None,
484                exclude_min: Some(true),
485                exclude_max: None,
486            }),
487            value_string_list: Vec::new(),
488        }),
489    ))
490}
491
492fn seller_default_filters(mask: u16) -> Vec<proto::FilterGroup> {
493    // Ref: NNBiz_Qot_OptionProductZone.cpp:471-585. These ten defaults are
494    // observable product behavior, including $10B/$1/$0.01 thresholds. Remove
495    // or alter them only when the upstream seller screener contract changes.
496    let definitions = [
497        (false, 401, 10_000_000_000_000_i64),
498        (false, 402, 1_000_000_000),
499        (false, 201, 0),
500        (false, 202, 0),
501        (true, 1002, 0),
502        (true, 2004, 10_000_000),
503        (true, 2005, 10_000_000),
504        (true, 3018, 0),
505        (true, 2011, 0),
506        (true, 2013, 0),
507    ];
508    definitions
509        .into_iter()
510        .enumerate()
511        .filter(|(bit, _)| mask & (1 << bit) != 0)
512        .map(|(_, (option_side, kind, min))| {
513            if option_side {
514                option_interval_group(kind, min)
515            } else {
516                underlying_interval_group(kind, Some(min), None, true, false)
517            }
518        })
519        .collect()
520}
521
522fn zero_dte_sort_field(sort_type: i32) -> proto::UnderlyingStatisticInfo {
523    let mut field = proto::UnderlyingStatisticInfo::default();
524    match sort_type {
525        2 => field.iv = Some(0),
526        3 => field.change_ratio = Some(0),
527        4 => field.open_interest = Some(0),
528        5 => field.market_cap = Some(0),
529        _ => field.volume = Some(0),
530    }
531    field
532}
533
534fn zero_dte_contract_sort_field(sort_type: i32) -> proto::ZeroDteOptionItem {
535    let mut field = proto::ZeroDteOptionItem::default();
536    match sort_type {
537        2 => field.open_interest = Some(0),
538        3 => field.implied_volatility = Some(0),
539        4 => field.delta = Some(0),
540        _ => field.volume = Some(0),
541    }
542    field
543}
544
545fn earnings_sort(sort_type: i32, is_asc: bool) -> proto::ReportSortObj {
546    if sort_type == 1 {
547        return proto::ReportSortObj {
548            underlying_info: None,
549            financial_item: Some(proto::FinancialItem::default()),
550            is_asc: Some(i32::from(is_asc)),
551        };
552    }
553    let mut field = proto::UnderlyingStatisticInfo::default();
554    match sort_type {
555        2 => field.volume = Some(0),
556        3 => field.iv = Some(0),
557        4 => field.market_cap = Some(0),
558        5 => field.change_ratio = Some(0),
559        6 => field.price = Some(0),
560        7 => field.iv_rank = Some(0),
561        8 => field.iv_percentile = Some(0),
562        9 => field.hv = Some(0),
563        10 => field.open_interest = Some(0),
564        11 => field.last_report_iv_crush = Some(0),
565        12 => field.history_report_iv_crush = Some(0),
566        13 => field.last_report_chg_ratio = Some(0),
567        14 => field.history_report_chg_ratio = Some(0),
568        15 => field.estimate_eps_yoy = Some(0),
569        16 => field.estimate_revenue_yoy = Some(0),
570        17 => field.expected_move_ratio_newest = Some(0),
571        _ => {}
572    }
573    proto::ReportSortObj {
574        underlying_info: Some(field),
575        financial_item: None,
576        is_asc: Some(i32::from(is_asc)),
577    }
578}
579
580fn seller_sort_field(sort_type: i32) -> proto::OptionItem {
581    let mut field = proto::OptionItem::default();
582    match sort_type {
583        2 => field.interval_return = Some(0),
584        3 => field.itm_probability = Some(0),
585        4 => field.premium = Some(0),
586        _ => field.sell_annualized_return = Some(0),
587    }
588    field
589}