Skip to main content

futu_backend/
option_rank.rs

1//! Two-stage backend workflows for Public QOT 3305/3306.
2
3use bytes::Bytes;
4use prost::Message as _;
5
6use futu_command_spec::QotReadOperation;
7use futu_core::error::{FutuError, Result};
8use futu_domain_qot_option::{OptionRankPlan, RankFilter, RankFilterInterval, UnderlyingRankPlan};
9
10use crate::command_runtime::execute_qot_read_with_reserved;
11use crate::conn::BackendConn;
12use crate::proto_internal::{
13    ft_cmd_option_screener as option_screen, ft_cmd_screener as screen,
14    ft_cmd_underlying_screener as underlying_screen, option_move_reader as move_reader,
15    option_rank_reader as rank_reader,
16};
17
18#[derive(Debug)]
19pub struct UnderlyingRankBackendPage {
20    pub request_serial_no: u32,
21    pub trading_date: i64,
22    pub response: move_reader::GetUnderlyingRankRsp,
23}
24
25#[derive(Debug)]
26pub struct OptionRankBackendPage {
27    pub request_serial_no: u32,
28    pub trading_date: i64,
29    pub response: rank_reader::GetOptionRankRsp,
30}
31
32pub async fn pull_underlying_rank(
33    backend: &BackendConn,
34    plan: &UnderlyingRankPlan,
35) -> Result<UnderlyingRankBackendPage> {
36    let trading_date = match plan.trading_date.filter(|value| *value > 0) {
37        Some(value) => value,
38        None => {
39            let request = build_underlying_rank_date_request(plan);
40            let response = execute(
41                backend,
42                QotReadOperation::OptionUnderlyingRankDate,
43                request,
44                plan.quote_mkt_type,
45            )
46            .await?;
47            let response = move_reader::GetUnderlyingRankDateRsp::decode(response.body.as_ref())
48                .map_err(FutuError::Proto)?;
49            require_code(response.code, None, "option underlying rank date")?;
50            response.date_list.first().copied().ok_or_else(|| {
51                FutuError::Codec("option underlying rank date returned an empty date list".into())
52            })?
53        }
54    };
55    let response = execute(
56        backend,
57        QotReadOperation::OptionUnderlyingRank,
58        build_underlying_rank_request(plan, trading_date),
59        plan.quote_mkt_type,
60    )
61    .await?;
62    let request_serial_no = response.request_serial_no;
63    let decoded = move_reader::GetUnderlyingRankRsp::decode(response.body.as_ref())
64        .map_err(FutuError::Proto)?;
65    require_code(decoded.code, None, "option underlying rank")?;
66    Ok(UnderlyingRankBackendPage {
67        request_serial_no,
68        trading_date,
69        response: decoded,
70    })
71}
72
73pub async fn pull_option_rank(
74    backend: &BackendConn,
75    plan: &OptionRankPlan,
76) -> Result<OptionRankBackendPage> {
77    let trading_date = match plan.trading_date.filter(|value| *value > 0) {
78        Some(value) => value,
79        None => {
80            let response = execute(
81                backend,
82                QotReadOperation::OptionRankDate,
83                build_option_rank_date_request(plan),
84                plan.quote_mkt_type,
85            )
86            .await?;
87            let response = rank_reader::GetOptionRankDateRsp::decode(response.body.as_ref())
88                .map_err(FutuError::Proto)?;
89            require_code(response.code, response.message, "option rank date")?;
90            response.date_list.first().copied().ok_or_else(|| {
91                FutuError::Codec("option rank date returned an empty date list".into())
92            })?
93        }
94    };
95    let response = execute(
96        backend,
97        QotReadOperation::OptionRank,
98        build_option_rank_request(plan, trading_date),
99        plan.quote_mkt_type,
100    )
101    .await?;
102    let request_serial_no = response.request_serial_no;
103    let decoded =
104        rank_reader::GetOptionRankRsp::decode(response.body.as_ref()).map_err(FutuError::Proto)?;
105    require_code(decoded.code, decoded.message.clone(), "option rank")?;
106    let final_trading_date = decoded
107        .trade_date
108        .filter(|value| *value != 0)
109        .unwrap_or(trading_date);
110    Ok(OptionRankBackendPage {
111        request_serial_no,
112        trading_date: final_trading_date,
113        response: decoded,
114    })
115}
116
117#[must_use]
118pub fn build_underlying_rank_date_request(
119    plan: &UnderlyingRankPlan,
120) -> move_reader::GetUnderlyingRankDateReq {
121    move_reader::GetUnderlyingRankDateReq {
122        market_type: Some(plan.backend_market),
123        underlying_type: Some(plan.underlying_type),
124        from: Some(0),
125        count: Some(1),
126        rank_type: Some(i64::from(plan.sort_field)),
127    }
128}
129
130#[must_use]
131pub fn build_underlying_rank_request(
132    plan: &UnderlyingRankPlan,
133    trading_date: i64,
134) -> move_reader::GetUnderlyingRankReq {
135    move_reader::GetUnderlyingRankReq {
136        market_type: Some(plan.backend_market),
137        underlying_type: Some(plan.underlying_type),
138        sort_field: Some(plan.sort_field),
139        is_asc: Some(plan.is_asc),
140        rank_date: Some(trading_date),
141        from: Some(plan.from),
142        count: Some(plan.count),
143        secondary_sort_field: None,
144        secondary_is_asc: None,
145        strategy: (!plan.filters.is_empty()).then(|| build_underlying_strategy(&plan.filters)),
146    }
147}
148
149#[must_use]
150pub fn build_option_rank_date_request(plan: &OptionRankPlan) -> rank_reader::GetOptionRankDateReq {
151    rank_reader::GetOptionRankDateReq {
152        market_category: Some(plan.market_category as u32),
153        rank_type: Some(option_sort_field(plan.sort_type)),
154    }
155}
156
157#[must_use]
158pub fn build_option_rank_request(
159    plan: &OptionRankPlan,
160    trading_date: i64,
161) -> rank_reader::GetOptionRankReq {
162    let strategy = option_screen::ScreenStrategy {
163        id: None,
164        name: None,
165        market_category_list: vec![screener_market_category(plan.market_category)],
166        filter_group_list: build_option_filter_groups(&plan.filters),
167    };
168    // Ref: NNBiz_Qot_OptionRank.cpp:522-550. Proto default is intentionally
169    // used as a sparse presence mask; only these exact fields are requested.
170    let field_filter = option_screen::OptionItem {
171        option_name: Some("0".into()),
172        option_type: Some(0),
173        volume: Some(0),
174        turnover: Some(0),
175        open_interest: Some(0),
176        open_interest_market_cap: Some(0),
177        oi_day_chg: Some(0),
178        oi_day_market_cap_chg: Some(0),
179        implied_volatility: Some(0),
180        price: Some(0),
181        chg_ratio: Some(0),
182        mid_price: Some(0),
183        bid_price: Some(0),
184        bid_volume: Some(0),
185        ask_price: Some(0),
186        ask_volume: Some(0),
187        delta: Some(0),
188        gamma: Some(0),
189        theta: Some(0),
190        vega: Some(0),
191        rho: Some(0),
192        ..Default::default()
193    };
194    rank_reader::GetOptionRankReq {
195        trade_date: Some(trading_date),
196        screen_req: Some(option_screen::OptionScreenerReq {
197            strategy: Some(strategy),
198            strategy_param: None,
199            field_filter: Some(field_filter),
200            sort_list: vec![option_screen::SortObj {
201                sort_field: Some(option_sort_field(plan.sort_type)),
202                is_asc: Some(i32::from(plan.is_asc)),
203            }],
204            pagination: Some(option_screen::OffsetPageObj {
205                from: Some(plan.from),
206                count: Some(plan.count),
207            }),
208            request_exact_data: Some(1),
209        }),
210    }
211}
212
213fn build_underlying_strategy(filters: &[RankFilter]) -> underlying_screen::ScreenStrategy {
214    // Ref: NNBiz_Qot_OptionRank.cpp:53-199. These numeric values are backend
215    // proto enum identities; they are not dynamic configuration and become
216    // invalid only when the upstream screener proto changes.
217    underlying_screen::ScreenStrategy {
218        market_category_list: Vec::new(),
219        filter_group_list: filters
220            .iter()
221            .filter_map(|filter| {
222                if !filter.has_value {
223                    return Some(underlying_screen::FilterGroup {
224                        filter_list: Vec::new(),
225                    });
226                }
227                let (indicator_type, multiplier, values, exact_value) = match filter.indicator_type
228                {
229                    1 => (
230                        101,
231                        1.0,
232                        filter
233                            .security_stock_ids
234                            .iter()
235                            .map(|value| *value as i64)
236                            .collect(),
237                        true,
238                    ),
239                    2 => (105, 1.0, filter.value_list.clone(), true),
240                    3 => (201, 1.0, Vec::new(), false),
241                    4 => (202, 1.0, Vec::new(), false),
242                    5 => (203, 1e5, Vec::new(), false),
243                    6 => (204, 1e5, Vec::new(), false),
244                    7 => (205, 1e5, Vec::new(), false),
245                    8 => (206, 1e5, Vec::new(), false),
246                    9 => (207, 1e5, Vec::new(), false),
247                    10 => (208, 1e5, Vec::new(), false),
248                    11 => (209, 1e5, Vec::new(), false),
249                    12 => (210, 1e5, Vec::new(), false),
250                    13 => (401, 1e3, Vec::new(), false),
251                    _ => return None,
252                };
253                Some(underlying_screen::FilterGroup {
254                    filter_list: vec![underlying_screen::Filter {
255                        indicator_type: Some(indicator_type),
256                        indicator_value: (exact_value || filter.interval.is_some())
257                            .then(|| indicator_value(values, filter.interval, multiplier)),
258                    }],
259                })
260            })
261            .collect(),
262    }
263}
264
265fn build_option_filter_groups(filters: &[RankFilter]) -> Vec<option_screen::FilterGroup> {
266    // Ref: NNBiz_Qot_OptionRank.cpp:552-707. Each public indicator maps to one
267    // fixed backend enum/precision and one AND group; upstream proto drift is
268    // the replacement trigger.
269    filters
270        .iter()
271        .filter_map(|filter| {
272            if !filter.has_value {
273                return None;
274            }
275            let (underlying_type, option_type, multiplier, values, exact_value) =
276                match filter.indicator_type {
277                    1 => (
278                        Some(107),
279                        None,
280                        1.0,
281                        filter
282                            .value_list
283                            .iter()
284                            .filter_map(|value| match value {
285                                1 => Some(3),
286                                2 => Some(4),
287                                _ => None,
288                            })
289                            .collect(),
290                        true,
291                    ),
292                    2 => (Some(401), None, 1e3, Vec::new(), false),
293                    3 => (
294                        Some(101),
295                        None,
296                        1.0,
297                        filter
298                            .security_stock_ids
299                            .iter()
300                            .map(|value| *value as i64)
301                            .collect(),
302                        true,
303                    ),
304                    4 => (Some(203), None, 1e5, Vec::new(), false),
305                    5 => (Some(204), None, 1e5, Vec::new(), false),
306                    6 => (Some(205), None, 1e5, Vec::new(), false),
307                    7 => (Some(206), None, 1e5, Vec::new(), false),
308                    8 => (None, Some(3001), 1e5, Vec::new(), false),
309                    9 => (None, Some(1003), 1.0, filter.value_list.clone(), true),
310                    10 => (None, Some(1002), 1.0, Vec::new(), false),
311                    11 => (None, Some(2001), 1.0, filter.value_list.clone(), true),
312                    12 => (None, Some(2011), 1.0, Vec::new(), false),
313                    13 => (None, Some(2013), 1.0, Vec::new(), false),
314                    14 => (None, Some(3004), 1e5, Vec::new(), false),
315                    15 => (None, Some(3005), 1e5, Vec::new(), false),
316                    16 => (None, Some(3007), 1e5, Vec::new(), false),
317                    17 => (None, Some(3006), 1e5, Vec::new(), false),
318                    18 => (None, Some(3008), 1e5, Vec::new(), false),
319                    _ => return None,
320                };
321            let value = (exact_value || filter.interval.is_some())
322                .then(|| indicator_value(values, filter.interval, multiplier));
323            Some(option_screen::FilterGroup {
324                underlying_list: underlying_type.map_or_else(Vec::new, |indicator_type| {
325                    vec![option_screen::UnderlyingIndicator {
326                        indicator_type: Some(indicator_type),
327                        indicator_value: value.clone(),
328                        stock_strategy_list: Vec::new(),
329                        plate_list: Vec::new(),
330                        sub_indicator_list: Vec::new(),
331                        stock_strategy_wch_list_stocks: Vec::new(),
332                    }]
333                }),
334                option_list: option_type.map_or_else(Vec::new, |indicator_type| {
335                    vec![option_screen::OptionIndicator {
336                        indicator_type: Some(indicator_type),
337                        indicator_value: value,
338                        sub_indicator_list: Vec::new(),
339                    }]
340                }),
341                combo_list: Vec::new(),
342                chain_list: Vec::new(),
343            })
344        })
345        .collect()
346}
347
348fn indicator_value(
349    values: Vec<i64>,
350    interval: Option<RankFilterInterval>,
351    multiplier: f64,
352) -> screen::IndicatorValue {
353    screen::IndicatorValue {
354        value_list: values,
355        value_interval: interval.map(|interval| screen::Interval {
356            min_value: interval.min.map(|(value, _)| (value * multiplier) as i64),
357            max_value: interval.max.map(|(value, _)| (value * multiplier) as i64),
358            exclude_min: interval.min.map(|(_, includes)| !includes),
359            exclude_max: interval.max.map(|(_, includes)| !includes),
360            unit: None,
361        }),
362    }
363}
364
365fn option_sort_field(sort_type: i32) -> option_screen::OptionItem {
366    let mut item = option_screen::OptionItem::default();
367    // Ref: NNBiz_Qot_OptionRank.cpp:463-506. The backend reflects the one
368    // present zero-valued field to select its sort column.
369    match sort_type {
370        1 => item.volume = Some(0),
371        2 => item.turnover = Some(0),
372        3 => item.open_interest = Some(0),
373        4 | 5 => item.oi_day_chg = Some(0),
374        6 => item.open_interest_market_cap = Some(0),
375        7 | 8 => item.oi_day_market_cap_chg = Some(0),
376        9 => item.chg_ratio = Some(0),
377        10 => item.implied_volatility = Some(0),
378        _ => {}
379    }
380    item
381}
382
383fn screener_market_category(category: i32) -> i32 {
384    // Ref: NNBiz_Qot_OptionRank.cpp:508-520. Two backend protos intentionally
385    // use different fixed enum numbers; no server/cache source exists.
386    match category {
387        1 => 0,
388        2 => 1,
389        4 => 3,
390        5 => 4,
391        _ => 0,
392    }
393}
394
395async fn execute<Req: prost::Message>(
396    backend: &BackendConn,
397    operation: QotReadOperation,
398    request: Req,
399    quote_mkt_type: u8,
400) -> Result<futu_command_runtime::CommandResponse> {
401    // Ref: NNBiz_Qot_OptionRank.cpp:249-283,758-796. Both stages use the
402    // option market header route with SECURITY(0) in reserved[1].
403    let mut reserved = [0_u8; 10];
404    reserved[0] = quote_mkt_type;
405    execute_qot_read_with_reserved(
406        backend,
407        operation,
408        Bytes::from(request.encode_to_vec()),
409        reserved,
410    )
411    .await
412}
413
414fn require_code(code: Option<i32>, message: Option<String>, label: &str) -> Result<()> {
415    match code {
416        Some(0) => Ok(()),
417        Some(code) => Err(FutuError::ServerError {
418            ret_type: code,
419            msg: message.unwrap_or_else(|| format!("{label} backend rejected request")),
420        }),
421        None => Err(FutuError::Codec(format!(
422            "{label} backend response missing code"
423        ))),
424    }
425}