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