Skip to main content

futu_backend/
option_event_alert.rs

1use bytes::Bytes;
2use prost::Message as _;
3
4use futu_command_spec::{QotReadOperation, QotWriteOperation};
5use futu_core::error::{FutuError, Result};
6use futu_domain_qot_option_event::{AlertInterval, AlertItem, AlertOp, AlertWritePlan};
7
8use crate::command_runtime::{execute_qot_read_with_reserved, execute_qot_write};
9use crate::conn::BackendConn;
10use crate::proto_internal::option_move_warn_svc as proto;
11
12pub struct AlertBackendPage {
13    pub items: Vec<AlertItem>,
14    pub next_page: Option<String>,
15    pub total: Option<i32>,
16}
17
18pub async fn pull_alerts(
19    backend: &BackendConn,
20    count: Option<i32>,
21    from: Option<i64>,
22) -> Result<AlertBackendPage> {
23    let request = proto::OptionMoveWarnGetNewReq {
24        start_idx: from,
25        query_count: count,
26        key_list: Vec::new(),
27    };
28    let mut reserved = [0; 10];
29    // Ref: NNBiz_Qot_OptionEvent.cpp:1160-1163. Public 3308 has no market;
30    // current C++ intentionally routes the complete list through US options.
31    reserved[0] = 7;
32    let response = execute_qot_read_with_reserved(
33        backend,
34        QotReadOperation::OptionEventAlert,
35        Bytes::from(request.encode_to_vec()),
36        reserved,
37    )
38    .await?;
39    let decoded =
40        proto::OptionMoveWarnGetNewRsp::decode(response.body.as_ref()).map_err(FutuError::Proto)?;
41    require_success(decoded.ret_code, decoded.err_msg, "option event alert get")?;
42    Ok(AlertBackendPage {
43        items: decoded
44            .warn_items
45            .into_iter()
46            .map(alert_from_backend)
47            .collect(),
48        next_page: (decoded.has_more == Some(true))
49            .then(|| decoded.next_idx.map(|value| value.to_string()))
50            .flatten(),
51        total: decoded
52            .total_count
53            .map(|value| value as i32)
54            .filter(|value| *value > 0),
55    })
56}
57
58pub async fn set_alerts(backend: &BackendConn, op: AlertOp, plan: &AlertWritePlan) -> Result<()> {
59    let backend_op = match op {
60        AlertOp::Add => 1,
61        AlertOp::Delete | AlertOp::DeleteAll => 2,
62        AlertOp::Modify | AlertOp::Enable | AlertOp::Disable => 3,
63    };
64    let request = proto::OptionMoveWarnSetNewReq {
65        oper_type: Some(backend_op),
66        warn_items: plan.backend_items.iter().map(alert_to_backend).collect(),
67    };
68    let mut reserved = [0; 10];
69    reserved[0] = if matches!(plan.route_market, Some(3 | 4)) {
70        9
71    } else {
72        7
73    };
74    let response = execute_qot_write(
75        backend,
76        QotWriteOperation::OptionEventAlert,
77        Bytes::from(request.encode_to_vec()),
78        reserved,
79    )
80    .await?;
81    let decoded =
82        proto::OptionMoveWarnSetNewRsp::decode(response.body.as_ref()).map_err(FutuError::Proto)?;
83    require_success(decoded.ret_code, decoded.err_msg, "option event alert set")
84}
85
86fn alert_from_backend(item: proto::NewWarnItem) -> AlertItem {
87    AlertItem {
88        key: item.key.unwrap_or(0),
89        option_market: item.market_category.and_then(category_to_option_market),
90        enable: item.enable,
91        watchlist_group_id: item.wch_lst_group_id.filter(|value| *value != 0),
92        underlying_stock_id: item.underlying_id.filter(|value| *value != 0),
93        option_type: item.option_type.filter(|value| *value != 0),
94        side_types: item.side_types.into_iter().take(8).collect(),
95        order_types: item
96            .order_types
97            .into_iter()
98            .take(8)
99            .map(order_type_to_api)
100            .collect(),
101        market_cap: item.market_cap.map(interval_from_backend),
102        expiry_days: item.left_day.map(interval_from_backend),
103        price: item.price.map(interval_from_backend),
104        size: item.size.map(interval_from_backend),
105        premium: item.premium.map(interval_from_backend),
106        iv: item.iv.map(interval_from_backend),
107        earnings_utc: item.earning_report_utc.map(interval_from_backend),
108        note: item.note.filter(|value| !value.is_empty()),
109    }
110}
111
112fn alert_to_backend(item: &AlertItem) -> proto::NewWarnItem {
113    proto::NewWarnItem {
114        key: (item.key != 0).then_some(item.key),
115        market_category: item.option_market.and_then(option_market_to_category),
116        wch_lst_group_id: item.watchlist_group_id,
117        underlying_id: item.underlying_stock_id,
118        enable: item.enable,
119        market_cap: item.market_cap.map(interval_to_backend),
120        earning_report: None,
121        option_type: item.option_type,
122        side_types: item.side_types.clone(),
123        order_types: item
124            .order_types
125            .iter()
126            .copied()
127            .map(order_type_to_backend)
128            .collect(),
129        left_day: item.expiry_days.map(interval_to_backend),
130        price: item.price.map(interval_to_backend),
131        size: item.size.map(interval_to_backend),
132        premium: item.premium.map(interval_to_backend),
133        iv: item.iv.map(interval_to_backend),
134        note: item.note.clone(),
135        earning_report_period_type: None,
136        earning_report_utc: item.earnings_utc.map(interval_to_backend),
137    }
138}
139
140fn interval_from_backend(value: proto::Interval) -> AlertInterval {
141    AlertInterval {
142        min: value.min_value,
143        max: value.max_value,
144        exclude_min: value.exclude_min,
145        exclude_max: value.exclude_max,
146    }
147}
148
149fn interval_to_backend(value: AlertInterval) -> proto::Interval {
150    proto::Interval {
151        min_value: value.min,
152        max_value: value.max,
153        exclude_min: value.exclude_min,
154        exclude_max: value.exclude_max,
155    }
156}
157
158fn option_market_to_category(value: i32) -> Option<i32> {
159    match value {
160        1 => Some(1),
161        2 => Some(2),
162        3 => Some(4),
163        4 => Some(5),
164        _ => None,
165    }
166}
167
168fn category_to_option_market(value: i32) -> Option<i32> {
169    match value {
170        1 => Some(1),
171        2 => Some(2),
172        4 => Some(3),
173        5 => Some(4),
174        _ => None,
175    }
176}
177
178fn order_type_to_backend(value: i32) -> i32 {
179    match value {
180        0 => 4,
181        1 => 1,
182        2 => 3,
183        4 => 2,
184        _ => value,
185    }
186}
187
188fn order_type_to_api(value: i32) -> i32 {
189    match value {
190        1 => 1,
191        2 => 4,
192        3 => 2,
193        4 => 0,
194        _ => value,
195    }
196}
197
198fn require_success(code: Option<i32>, message: Option<String>, label: &str) -> Result<()> {
199    match code {
200        Some(0) => Ok(()),
201        Some(code) => Err(FutuError::ServerError {
202            ret_type: code,
203            msg: message.unwrap_or_else(|| format!("{label} backend rejected request")),
204        }),
205        None => Err(FutuError::Codec(format!(
206            "{label} backend response missing ret_code"
207        ))),
208    }
209}