Skip to main content

futu_backend/stock_list/
event_notice_push.rs

1//! CMD6301 event-notice binary envelope decoder.
2//!
3//! C++ behavior owner: `NNBiz_Qot_EventNotice.cpp:233-349`.
4
5use futu_core::error::{FutuError, Result};
6use futu_domain_qot_market_state::{MarketEventUpdate, MarketStatePatch, MarketTradeDateSnapshot};
7use prost::Message;
8
9use super::market::quote_market_type_from_market_id;
10
11// Protocol enum values used by C++ at
12// `NNBiz_Qot_EventNotice.cpp:250-257`; they are not server configuration.
13const EVENT_TYPE_TRADING_DAY: u16 = 5;
14const EVENT_TYPE_MARKET_STATE: u16 = 9;
15
16#[derive(Clone, Debug, Default, PartialEq, Eq)]
17pub struct MarketEventPushBatch {
18    pub updates: Vec<MarketEventUpdate>,
19}
20
21/// Local wire view preserves proto2 required-field presence that Prost's
22/// generated required scalar representation erases. Tags 1-4 come from C++
23/// `FTCommonQta.proto:48-59`.
24#[derive(Clone, PartialEq, Message)]
25struct MarketStatusListWire {
26    #[prost(message, repeated, tag = "1")]
27    list: Vec<MarketStatusItemWire>,
28}
29
30#[derive(Clone, PartialEq, Message)]
31struct MarketStatusItemWire {
32    // Option intentionally preserves C++ proto2 `has_market_id()` semantics.
33    #[prost(uint32, optional, tag = "1")]
34    market_id: Option<u32>,
35    // Option intentionally preserves C++ proto2 `has_status()` semantics.
36    #[prost(uint32, optional, tag = "2")]
37    status: Option<u32>,
38    #[prost(string, optional, tag = "3")]
39    status_text_sc: Option<String>,
40    #[prost(string, optional, tag = "4")]
41    status_text_tc: Option<String>,
42}
43
44pub fn decode_event_notice_push(body: &[u8]) -> Result<MarketEventPushBatch> {
45    let mut cursor = NetworkCursor::new(body);
46    let event_count = cursor.read_u16("event count")?;
47    let mut updates = Vec::new();
48
49    for _ in 0..event_count {
50        let event_type = cursor.read_u16("event type")?;
51        let payload_len = usize::from(cursor.read_u16("event payload length")?);
52        let payload = cursor.take(payload_len, "event payload")?;
53        match event_type {
54            EVENT_TYPE_TRADING_DAY => decode_trading_day_payload(payload, &mut updates)?,
55            EVENT_TYPE_MARKET_STATE if payload.is_empty() => {
56                // C++ explicitly accepts this known server bug at
57                // `NNBiz_Qot_EventNotice.cpp:307-309`.
58            }
59            EVENT_TYPE_MARKET_STATE => decode_market_state_payload(payload, &mut updates)?,
60            _ => {}
61        }
62    }
63
64    Ok(MarketEventPushBatch { updates })
65}
66
67fn decode_trading_day_payload(payload: &[u8], updates: &mut Vec<MarketEventUpdate>) -> Result<()> {
68    let mut cursor = NetworkCursor::new(payload);
69    let row_count = cursor.read_u8("trading-day row count")?;
70    for _ in 0..row_count {
71        let market_id = u32::from(cursor.read_u8("trading-day market id")?);
72        let trade_date = cursor.read_u32("trading-day timestamp")?;
73        updates.push(MarketEventUpdate::MarketTradeDate(
74            MarketTradeDateSnapshot {
75                market_id,
76                quote_market_type: quote_market_type_for_market_id(market_id),
77                trade_date,
78            },
79        ));
80    }
81    Ok(())
82}
83
84fn decode_market_state_payload(payload: &[u8], updates: &mut Vec<MarketEventUpdate>) -> Result<()> {
85    let list = MarketStatusListWire::decode(payload).map_err(FutuError::Proto)?;
86    for item in list.list {
87        let market_id = item.market_id.ok_or_else(|| {
88            FutuError::Codec("CMD6301 market state missing required market_id".to_string())
89        })?;
90        let status = item.status.ok_or_else(|| {
91            FutuError::Codec("CMD6301 market state missing required status".to_string())
92        })?;
93        updates.push(MarketEventUpdate::MarketState(MarketStatePatch {
94            market_id,
95            quote_market_type: quote_market_type_for_market_id(market_id),
96            status,
97            status_text_sc: item.status_text_sc,
98            status_text_tc: item.status_text_tc,
99        }));
100    }
101    Ok(())
102}
103
104fn quote_market_type_for_market_id(market_id: u32) -> u8 {
105    quote_market_type_from_market_id(market_id)
106        // C++ `NN_QuoteMktType_From_NN_QuoteMktID` returns UNKNOWN=0;
107        // TradingDay stores it but does not call ReSubMkt.
108        .unwrap_or(0)
109}
110
111struct NetworkCursor<'a> {
112    bytes: &'a [u8],
113    position: usize,
114}
115
116impl<'a> NetworkCursor<'a> {
117    fn new(bytes: &'a [u8]) -> Self {
118        Self { bytes, position: 0 }
119    }
120
121    fn read_u8(&mut self, field: &str) -> Result<u8> {
122        Ok(self.take(1, field)?[0])
123    }
124
125    fn read_u16(&mut self, field: &str) -> Result<u16> {
126        let bytes: [u8; 2] = self
127            .take(2, field)?
128            .try_into()
129            .map_err(|_| FutuError::Codec(format!("CMD6301 invalid {field}")))?;
130        Ok(u16::from_be_bytes(bytes))
131    }
132
133    fn read_u32(&mut self, field: &str) -> Result<u32> {
134        let bytes: [u8; 4] = self
135            .take(4, field)?
136            .try_into()
137            .map_err(|_| FutuError::Codec(format!("CMD6301 invalid {field}")))?;
138        Ok(u32::from_be_bytes(bytes))
139    }
140
141    fn take(&mut self, len: usize, field: &str) -> Result<&'a [u8]> {
142        let end = self
143            .position
144            .checked_add(len)
145            .ok_or_else(|| FutuError::Codec(format!("CMD6301 {field} length overflow")))?;
146        let bytes = self.bytes.get(self.position..end).ok_or_else(|| {
147            FutuError::Codec(format!(
148                "CMD6301 truncated {field}: need {len} bytes at {}",
149                self.position
150            ))
151        })?;
152        self.position = end;
153        Ok(bytes)
154    }
155}