Skip to main content

futu_mcp/handlers/core/
sub_action.rs

1//! mcp/handlers/core/sub_action — subscribe + unsubscribe
2//! (v1.4.110 CC Batch N: 拆自 core.rs L808-1100)
3
4use std::sync::Arc;
5
6use anyhow::{Result, anyhow, bail};
7use futu_core::qot_subscription_options::{SubscribeOptionsPlan, SubscribePlanError};
8use futu_net::client::FutuClient;
9use futu_surface_spec::input::validate_subscription_session_id;
10use prost::Message;
11
12use crate::state::parse_symbol;
13
14/// 对齐 Python SDK `OpenQuoteContext.subscribe`。
15pub async fn subscribe(
16    client: &Arc<FutuClient>,
17    symbols: &[String],
18    sub_types: &[i32],
19    is_first_push: bool,
20    is_reg_push: bool,
21    extended_time: Option<bool>,
22    session: Option<i32>,
23    is_sub_order_book_detail: Option<bool>,
24) -> Result<String> {
25    let session = validate_subscription_session_id(session)
26        .map_err(|error| anyhow!("invalid subscribe session: {error}"))?;
27    if let Err(err) =
28        SubscribeOptionsPlan::from_raw(session, extended_time, is_sub_order_book_detail)
29    {
30        match err {
31            SubscribePlanError::OvernightSessionUnsupported => {
32                bail!(
33                    "subscribe: session=4 (OVERNIGHT) is not supported by QOT subscribe; \
34                     use 2=ETH or 3=ALL"
35                );
36            }
37        }
38    }
39    let sec_list: Vec<_> = symbols
40        .iter()
41        .map(|s| parse_symbol(s))
42        .collect::<Result<Vec<_>>>()?;
43    let proto_secs: Vec<_> = sec_list
44        .iter()
45        .map(|s| futu_proto::qot_common::Security {
46            market: s.market as i32,
47            code: s.code.clone(),
48        })
49        .collect();
50    let req = futu_proto::qot_sub::Request {
51        c2s: futu_proto::qot_sub::C2s {
52            security_list: proto_secs,
53            sub_type_list: sub_types.to_vec(),
54            is_sub_or_un_sub: true, // true = 订阅
55            is_reg_or_un_reg_push: Some(is_reg_push),
56            reg_push_rehab_type_list: vec![],
57            is_first_push: Some(is_first_push),
58            is_unsub_all: None,
59            is_sub_order_book_detail,
60            extended_time,
61            session,
62            header: None,
63        },
64    };
65    let body = req.encode_to_vec();
66    let frame = client.request(futu_core::proto_id::QOT_SUB, body).await?;
67    let resp = futu_proto::qot_sub::Response::decode(frame.body.as_ref())
68        .map_err(|e| anyhow!("decode subscribe: {e}"))?;
69    if resp.ret_type != 0 {
70        bail!(
71            "subscribe ret_type={} msg={:?}",
72            resp.ret_type,
73            resp.ret_msg
74        );
75    }
76    Ok(serde_json::to_string_pretty(&serde_json::json!({
77        "ok": true,
78        "subscribed_symbols": symbols,
79        "sub_types": sub_types,
80        "is_first_push": is_first_push,
81        "is_reg_push": is_reg_push,
82        "extended_time": extended_time,
83        "session": session,
84        "is_sub_order_book_detail": is_sub_order_book_detail,
85    }))?)
86}
87
88/// 反订阅。传 `symbols` + `sub_types` 去掉这些订阅,或传 `unsub_all=true`
89/// 清当前连接所有订阅。(共用 CMD 3001 qot_sub)
90pub async fn unsubscribe(
91    client: &Arc<FutuClient>,
92    symbols: &[String],
93    sub_types: &[i32],
94    unsub_all: bool,
95) -> Result<String> {
96    let sec_list: Vec<_> = if unsub_all {
97        Vec::new()
98    } else {
99        symbols
100            .iter()
101            .map(|s| parse_symbol(s))
102            .collect::<Result<Vec<_>>>()?
103    };
104    let proto_secs: Vec<_> = sec_list
105        .iter()
106        .map(|s| futu_proto::qot_common::Security {
107            market: s.market as i32,
108            code: s.code.clone(),
109        })
110        .collect();
111    let req = futu_proto::qot_sub::Request {
112        c2s: futu_proto::qot_sub::C2s {
113            security_list: proto_secs,
114            sub_type_list: sub_types.to_vec(),
115            is_sub_or_un_sub: false, // false = 反订阅
116            is_reg_or_un_reg_push: Some(false),
117            reg_push_rehab_type_list: vec![],
118            is_first_push: None,
119            is_unsub_all: Some(unsub_all),
120            is_sub_order_book_detail: None,
121            extended_time: None,
122            session: None,
123            header: None,
124        },
125    };
126    let body = req.encode_to_vec();
127    let frame = client.request(futu_core::proto_id::QOT_SUB, body).await?;
128    let resp = futu_proto::qot_sub::Response::decode(frame.body.as_ref())
129        .map_err(|e| anyhow!("decode unsubscribe: {e}"))?;
130    if resp.ret_type != 0 {
131        bail!(
132            "unsubscribe ret_type={} msg={:?}",
133            resp.ret_type,
134            resp.ret_msg
135        );
136    }
137    Ok(serde_json::to_string_pretty(&serde_json::json!({
138        "ok": true,
139        "unsub_all": unsub_all,
140        "count": symbols.len(),
141    }))?)
142}
143
144/// v1.4.98 T2-8 (mobile-source-audit Phase 2): NN+MM token 状态查询.
145///
146/// 调 daemon `/api/token-state` 等价 cmd 1326 `CS_CMDID_NewToken_GetStateInfo`.
147/// 返 NN (Futu Token app) + MM (moomoo Token app) 两边 token enable + bind
148/// 4 字段 (1=已启用/已绑定, 0=未启用/未绑定).
149pub async fn get_token_state(client: &Arc<FutuClient>, app_id: Option<&str>) -> Result<String> {
150    use futu_backend::proto_internal::futu_token_state;
151    let req = futu_token_state::DaemonGetTokenStateReq {
152        c2s: futu_token_state::daemon_get_token_state_req::C2s {
153            app_id: app_id.map(|s| s.to_string()),
154        },
155    };
156    let body = req.encode_to_vec();
157    let frame = client
158        .request(futu_core::proto_id::GET_TOKEN_STATE, body)
159        .await?;
160    let resp = futu_token_state::DaemonGetTokenStateRsp::decode(frame.body.as_ref())
161        .map_err(|e| anyhow!("decode token_state: {e}"))?;
162    if resp.ret_type != 0 {
163        bail!(
164            "token_state ret_type={} msg={:?}",
165            resp.ret_type,
166            resp.ret_msg
167        );
168    }
169    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
170    Ok(serde_json::to_string_pretty(&serde_json::json!({
171        "nn_token_enable": s.nn_token_enable,
172        "nn_token_bind": s.nn_token_bind,
173        "mm_token_enable": s.mm_token_enable,
174        "mm_token_bind": s.mm_token_bind,
175    }))?)
176}
177
178/// v1.4.98 T2-2 (mobile-source-audit Phase 2): 无风险利率 (期权定价).
179///
180/// 调 daemon `/api/risk-free-rate` 等价 cmd 20231 `GetRiskFreeRate`. 返
181/// HK/US/JP 3 市场无风险利率 (百分比 + raw uint64).
182pub async fn get_risk_free_rate(client: &Arc<FutuClient>) -> Result<String> {
183    use futu_backend::proto_internal::risk_free_rate;
184    let req = risk_free_rate::DaemonGetRiskFreeRateReq {
185        c2s: risk_free_rate::daemon_get_risk_free_rate_req::C2s { rate_time: None },
186    };
187    let body = req.encode_to_vec();
188    let frame = client
189        .request(futu_core::proto_id::QOT_GET_RISK_FREE_RATE, body)
190        .await?;
191    let resp = risk_free_rate::DaemonGetRiskFreeRateRsp::decode(frame.body.as_ref())
192        .map_err(|e| anyhow!("decode risk_free_rate: {e}"))?;
193    if resp.ret_type != 0 {
194        bail!(
195            "risk_free_rate ret_type={} msg={:?}",
196            resp.ret_type,
197            resp.ret_msg
198        );
199    }
200    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
201    Ok(serde_json::to_string_pretty(&serde_json::json!({
202        "hk_rate_pct": s.hk_rate_pct,
203        "us_rate_pct": s.us_rate_pct,
204        "jp_rate_pct": s.jp_rate_pct,
205        "update_time": s.update_time,
206        "hk_rate_raw": s.hk_rate_raw,
207        "us_rate_raw": s.us_rate_raw,
208        "jp_rate_raw": s.jp_rate_raw,
209    }))?)
210}
211
212/// v1.4.98 T2-1 (mobile-source-audit Phase 2): 摆盘步长 SpreadTable.
213pub async fn get_spread_table(client: &Arc<FutuClient>) -> Result<String> {
214    use futu_backend::proto_internal::spread_table_6503;
215    let req = spread_table_6503::DaemonGetSpreadTableReq {
216        c2s: spread_table_6503::daemon_get_spread_table_req::C2s { reserved: None },
217    };
218    let body = req.encode_to_vec();
219    let frame = client
220        .request(futu_core::proto_id::QOT_GET_SPREAD_TABLE, body)
221        .await?;
222    let resp = spread_table_6503::DaemonGetSpreadTableRsp::decode(frame.body.as_ref())
223        .map_err(|e| anyhow!("decode spread_table: {e}"))?;
224    if resp.ret_type != 0 {
225        bail!(
226            "spread_table ret_type={} msg={:?}",
227            resp.ret_type,
228            resp.ret_msg
229        );
230    }
231    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
232    Ok(serde_json::to_string_pretty(&s)?)
233}
234
235/// v1.4.98 T2-3 (mobile-source-audit Phase 2): 逐笔统计 TickerStatistic.
236pub async fn get_ticker_statistic(
237    client: &Arc<FutuClient>,
238    symbol: &str,
239    ticker_type: Option<i32>,
240    stat_type: Option<u32>,
241) -> Result<String> {
242    use futu_backend::proto_internal::ticker_statistic_daemon;
243    let sec = parse_symbol(symbol)?;
244    let req = ticker_statistic_daemon::DaemonGetTickerStatisticReq {
245        c2s: ticker_statistic_daemon::daemon_get_ticker_statistic_req::C2s {
246            security: ticker_statistic_daemon::Security {
247                market: sec.market as i32,
248                code: sec.code,
249            },
250            ticker_type,
251            ticker_time: None,
252            stat_type,
253            // v1.4.99 codex F3 fix: proto 删 owner 字段 (silent inconsistency
254            // 风险), REST adapter strip injected owner.
255        },
256    };
257    let body = req.encode_to_vec();
258    let frame = client
259        .request(futu_core::proto_id::QOT_GET_TICKER_STATISTIC, body)
260        .await?;
261    let resp = ticker_statistic_daemon::DaemonGetTickerStatisticRsp::decode(frame.body.as_ref())
262        .map_err(|e| anyhow!("decode ticker_statistic: {e}"))?;
263    if resp.ret_type != 0 {
264        bail!(
265            "ticker_statistic ret_type={} msg={:?}",
266            resp.ret_type,
267            resp.ret_msg
268        );
269    }
270    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
271    Ok(serde_json::to_string_pretty(&s)?)
272}
273
274/// v1.4.106 codex 0500 ζ23-redo: 逐笔统计 Detail (cmd 6366) — 价位级分布.
275///
276/// 配套 `get_ticker_statistic` (Info) 用法: 客户端先调 Info 拿 ticker_time,
277/// 再调 Detail 传同 ticker_time 拿这个时点的 DetailItem 列表 (price /
278/// buy_volume / sell_volume / volume / ratio / neutral_volume). 也可不传
279/// ticker_time 用 backend 默认 (latest available).
280pub struct TickerStatisticDetailInput<'a> {
281    pub symbol: &'a str,
282    pub ticker_type: Option<i32>,
283    pub ticker_time: Option<u64>,
284    pub select_num: Option<u32>,
285    pub data_from: Option<u32>,
286    pub data_max_count: Option<u32>,
287    pub stat_type: Option<u32>,
288}
289
290pub async fn get_ticker_statistic_detail(
291    client: &Arc<FutuClient>,
292    input: TickerStatisticDetailInput<'_>,
293) -> Result<String> {
294    use futu_backend::proto_internal::ticker_statistic_daemon;
295    let sec = parse_symbol(input.symbol)?;
296    let req = ticker_statistic_daemon::DaemonGetTickerStatisticDetailReq {
297        c2s: ticker_statistic_daemon::daemon_get_ticker_statistic_detail_req::C2s {
298            security: ticker_statistic_daemon::Security {
299                market: sec.market as i32,
300                code: sec.code,
301            },
302            ticker_type: input.ticker_type,
303            ticker_time: input.ticker_time,
304            select_num: input.select_num,
305            data_from: input.data_from,
306            data_max_count: input.data_max_count,
307            stat_type: input.stat_type,
308        },
309    };
310    let body = req.encode_to_vec();
311    let frame = client
312        .request(futu_core::proto_id::QOT_GET_TICKER_STATISTIC_DETAIL, body)
313        .await?;
314    let resp =
315        ticker_statistic_daemon::DaemonGetTickerStatisticDetailRsp::decode(frame.body.as_ref())
316            .map_err(|e| anyhow!("decode ticker_statistic_detail: {e}"))?;
317    if resp.ret_type != 0 {
318        bail!(
319            "ticker_statistic_detail ret_type={} msg={:?}",
320            resp.ret_type,
321            resp.ret_msg
322        );
323    }
324    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
325    Ok(serde_json::to_string_pretty(&s)?)
326}