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