Skip to main content

futu_mcp/handlers/trade/
cash_log.rs

1use std::sync::Arc;
2
3use anyhow::{Result, bail};
4use futu_backend::proto_internal::realtime_asset_log;
5use futu_net::client::FutuClient;
6use prost::Message as _;
7use serde::Serialize;
8
9use super::parse_trd_env;
10
11// ============================================================================
12// v1.4.95 U1 (Tier M MCP tools): cash log mobile-driven extension
13//
14// 来源: v1.4.94 M1 ship 了 REST + gRPC FTAPI endpoints (cmd 3000/3001/3002),
15// 但 MCP wrapper 推迟到 v1.4.95. 现在补齐 3 个 MCP tools.
16//
17// 架构:
18//   client → MCP `futu_get_cash_log` → handler → FutuClient FTAPI proto_id
19//   22701 (TRD_GET_CASH_LOG) → daemon CashLogHandler → backend cmd 3000
20//
21// 与 REST `/api/cash-log` 平行: 两条路径走同 daemon handler (CashLogHandler),
22// 同 backend cmd 3000.
23// ============================================================================
24
25#[derive(Serialize)]
26struct CashLogLabelOut {
27    label: String,
28    label_desc: String,
29}
30
31#[derive(Serialize)]
32struct CashLogEntryOut {
33    log_id: String,
34    title: String,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    label: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    label_desc: Option<String>,
39    created_time: String,
40    created_timestamp: u32,
41    cash_change: String,
42    balance: String,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    stock_name: Option<String>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    icon_url: Option<String>,
47    symbol_code: String,
48    symbol_name: String,
49    #[serde(skip_serializing_if = "Vec::is_empty")]
50    label_list: Vec<CashLogLabelOut>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    stock_id: Option<u64>,
53    #[serde(skip_serializing_if = "Vec::is_empty")]
54    stock_name_label_list: Vec<CashLogLabelOut>,
55    biz_type_id: u32,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    contract_direction: Option<i32>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    contract_direction_label: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    contract_symbol_name: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    currency: Option<String>,
64}
65
66fn cash_log_label_out_from_proto(label: realtime_asset_log::Label) -> CashLogLabelOut {
67    CashLogLabelOut {
68        label: label.label.unwrap_or_default(),
69        label_desc: label.label_desc.unwrap_or_default(),
70    }
71}
72
73fn cash_log_entry_out_from_proto(c: realtime_asset_log::CashLog) -> CashLogEntryOut {
74    CashLogEntryOut {
75        log_id: c.log_id.unwrap_or_default(),
76        title: c.title.unwrap_or_default(),
77        label: c.label,
78        label_desc: c.label_desc,
79        created_time: c.created_time.unwrap_or_default(),
80        created_timestamp: c.created_timestamp.unwrap_or(0),
81        cash_change: c.cash_change.unwrap_or_default(),
82        balance: c.balance.unwrap_or_default(),
83        stock_name: c.stock_name,
84        icon_url: c.icon_url,
85        symbol_code: c.symbol_code.unwrap_or_default(),
86        symbol_name: c.symbol_name.unwrap_or_default(),
87        label_list: c
88            .label_list
89            .into_iter()
90            .map(cash_log_label_out_from_proto)
91            .collect(),
92        stock_id: c.stock_id,
93        stock_name_label_list: c
94            .stock_name_label_list
95            .into_iter()
96            .map(cash_log_label_out_from_proto)
97            .collect(),
98        biz_type_id: c.biz_type_id.unwrap_or(0),
99        contract_direction: c.contract_direction,
100        contract_direction_label: c.contract_direction_label,
101        contract_symbol_name: c.contract_symbol_name,
102        currency: c.currency,
103    }
104}
105
106#[derive(Serialize)]
107struct CashLogMonthlyOut {
108    period_desc: String,
109    in_value: String,
110    out_value: String,
111    entries: Vec<CashLogEntryOut>,
112}
113
114#[derive(Serialize)]
115struct CashLogOut {
116    monthly_logs: Vec<CashLogMonthlyOut>,
117    has_more: bool,
118    next_log_id: String,
119}
120
121/// v1.4.95 U1: MCP tool `futu_get_cash_log` — 资金明细 (mobile-driven).
122///
123/// 比 `futu_get_acc_cash_flow` 字段更全 (10+ vs 3): 时间范围 / 业务分组 /
124/// 货币 / 关键词 / 股票 / 方向 多维过滤 + cursor 分页.
125///
126/// 与 REST `/api/cash-log` 共用 daemon handler,gateway 层统一派生 native
127/// account/market 并补齐移动端默认分页字段。
128pub struct CashLogInput<'a> {
129    pub env: &'a str,
130    pub acc_id: u64,
131    pub begin_time: Option<u64>,
132    pub end_time: Option<u64>,
133    pub biz_group_id: Option<u32>,
134    pub biz_sub_group_id: Option<u32>,
135    pub in_out: Option<u32>,
136    pub keyword: Option<String>,
137    pub symbol: Option<String>,
138    pub stock_id: Option<u64>,
139    pub log_id: Option<String>,
140    pub max_cnt: Option<u32>,
141    pub currency: Option<String>,
142}
143
144pub async fn get_cash_log(client: &Arc<FutuClient>, input: CashLogInput<'_>) -> Result<String> {
145    let trd_env = parse_trd_env(input.env)?;
146    if input.acc_id == 0 {
147        bail!("acc_id 必填 (call futu_list_accounts to discover)");
148    }
149
150    // v1.4.102 codex 38 F1 (P1): 不预填 inner.market / inner.account_id —
151    // 让 daemon derive_cash_log_market(acc_id, cache) 走 verified translation
152    // (HKFUND=113 → HK=1; USFUND=123 → US=2 per real-machine). MCP 直传
153    // header.trd_market 为 113/123 时旧 wrapper 写 raw 113 进 inner.market →
154    // 绕过 daemon 翻译 → backend 看 fund market enum 返空. 现在 None 让
155    // daemon 用 cache.trd_market + acc_id high bits 走中央 helper.
156    let inner = realtime_asset_log::GetCashLogReq {
157        market: None,     // codex 38 F1: daemon 派生
158        account_id: None, // codex 38 F1: daemon 派生 (低 32 位 acc_id)
159        biz_group_id: input.biz_group_id,
160        in_out: input.in_out,
161        begin_time: input.begin_time,
162        end_time: input.end_time,
163        need_stock_name: Some(true),
164        log_id: input.log_id,
165        max_cnt: input.max_cnt,
166        keyword: input.keyword,
167        biz_sub_group_id: input.biz_sub_group_id,
168        currency: input.currency,
169        symbol: input.symbol,
170        stock_id: input.stock_id,
171    };
172
173    // Wrap in daemon FTAPI envelope
174    let req = realtime_asset_log::DaemonGetCashLogReq {
175        c2s: realtime_asset_log::daemon_get_cash_log_req::C2s {
176            header: realtime_asset_log::DaemonGetCashLogHeader {
177                acc_id: input.acc_id,
178                trd_env: Some(trd_env as i32),
179            },
180            inner: Some(inner),
181        },
182    };
183
184    let body = req.encode_to_vec();
185    let frame = client
186        .request(futu_core::proto_id::TRD_GET_CASH_LOG, body)
187        .await?;
188    let resp =
189        <realtime_asset_log::DaemonGetCashLogRsp as prost::Message>::decode(frame.body.as_ref())
190            .map_err(|e| anyhow::anyhow!("decode DaemonGetCashLogRsp: {e}"))?;
191    if resp.ret_type != 0 {
192        bail!(
193            "GetCashLog ret_type={} msg={:?} (fallback: try futu_get_acc_cash_flow)",
194            resp.ret_type,
195            resp.ret_msg
196        );
197    }
198
199    let inner_rsp = resp
200        .s2c
201        .and_then(|s| s.inner)
202        .ok_or_else(|| anyhow::anyhow!("empty s2c.inner in GetCashLogRsp"))?;
203
204    let monthly_logs: Vec<CashLogMonthlyOut> = inner_rsp
205        .monthly_log_list
206        .into_iter()
207        .map(|m| CashLogMonthlyOut {
208            period_desc: m
209                .monthly_info
210                .as_ref()
211                .map(|mi| mi.period_desc.clone().unwrap_or_default())
212                .unwrap_or_default(),
213            in_value: m
214                .monthly_info
215                .as_ref()
216                .map(|mi| mi.in_value.clone().unwrap_or_default())
217                .unwrap_or_default(),
218            out_value: m
219                .monthly_info
220                .as_ref()
221                .map(|mi| mi.out_value.clone().unwrap_or_default())
222                .unwrap_or_default(),
223            entries: m
224                .cash_log_list
225                .into_iter()
226                .map(cash_log_entry_out_from_proto)
227                .collect(),
228        })
229        .collect();
230
231    let out = CashLogOut {
232        monthly_logs,
233        has_more: inner_rsp.has_more.unwrap_or(false),
234        next_log_id: inner_rsp.next_log_id.unwrap_or_default(),
235    };
236    Ok(serde_json::to_string_pretty(&out)?)
237}
238
239#[derive(Serialize)]
240struct CashDetailOut {
241    title: String,
242    sub_title: String,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    title_detail: Option<DetailItemOut>,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    sub_title_detail: Option<DetailItemOut>,
247    sections: Vec<Vec<DetailItemOut>>,
248    external_links: Vec<DetailItemOut>,
249}
250
251#[derive(Clone, Debug, Serialize)]
252struct DetailItemOut {
253    title: String,
254    value: String,
255    url: String,
256    old_value: String,
257    icon_url_id: String,
258    type_id: u32,
259}
260
261fn detail_item_out_from_proto(d: realtime_asset_log::Detail) -> Option<DetailItemOut> {
262    let title = d.title.unwrap_or_default();
263    if title.is_empty() {
264        return None;
265    }
266    Some(DetailItemOut {
267        title,
268        value: d.value.unwrap_or_default(),
269        url: d.url.unwrap_or_default(),
270        old_value: d.old_value.unwrap_or_default(),
271        icon_url_id: d.icon_url_id.unwrap_or_default(),
272        type_id: d.type_id.unwrap_or_default(),
273    })
274}
275
276fn cash_detail_out_from_inner_rsp(
277    inner_rsp: realtime_asset_log::GetCashDetailRsp,
278) -> CashDetailOut {
279    let title_detail = inner_rsp.detail_title.and_then(detail_item_out_from_proto);
280    let sub_title_detail = inner_rsp
281        .sub_detail_title
282        .and_then(detail_item_out_from_proto);
283
284    let title_str = title_detail
285        .as_ref()
286        .map(|d| d.title.clone())
287        .unwrap_or_default();
288    let sub_title_str = sub_title_detail
289        .as_ref()
290        .map(|d| d.title.clone())
291        .unwrap_or_default();
292
293    let sections: Vec<Vec<DetailItemOut>> = inner_rsp
294        .section_list
295        .into_iter()
296        .filter_map(|s| {
297            let details: Vec<DetailItemOut> = s
298                .detail_list
299                .into_iter()
300                .filter_map(detail_item_out_from_proto)
301                .collect();
302            if details.is_empty() {
303                None
304            } else {
305                Some(details)
306            }
307        })
308        .collect();
309
310    let mut external_links: Vec<DetailItemOut> = inner_rsp
311        .all_external_link
312        .into_iter()
313        .filter_map(detail_item_out_from_proto)
314        .collect();
315    if external_links.is_empty()
316        && let Some(single) = inner_rsp.external_link.and_then(detail_item_out_from_proto)
317    {
318        external_links.push(single);
319    }
320
321    CashDetailOut {
322        title: title_str,
323        sub_title: sub_title_str,
324        title_detail,
325        sub_title_detail,
326        sections,
327        external_links,
328    }
329}
330
331/// v1.4.95 U1: MCP tool `futu_get_cash_detail` — 单条资金流水详情.
332pub async fn get_cash_detail(
333    client: &Arc<FutuClient>,
334    env: &str,
335    acc_id: u64,
336    log_id: String,
337) -> Result<String> {
338    if log_id.is_empty() {
339        bail!("log_id 必填 (从 futu_get_cash_log 响应里取)");
340    }
341    let trd_env = parse_trd_env(env)?;
342    if acc_id == 0 {
343        bail!("acc_id 必填");
344    }
345
346    // v1.4.102 codex 38 F1 (P1): 不预填 inner.market / inner.account_id,
347    // 让 daemon 走 verified translation.
348    let inner = realtime_asset_log::GetCashDetailReq {
349        market: None,
350        account_id: None,
351        log_id: Some(log_id),
352    };
353    let req = realtime_asset_log::DaemonGetCashDetailReq {
354        c2s: realtime_asset_log::daemon_get_cash_detail_req::C2s {
355            header: realtime_asset_log::DaemonGetCashLogHeader {
356                acc_id,
357                trd_env: Some(trd_env as i32),
358            },
359            inner: Some(inner),
360        },
361    };
362
363    let body = req.encode_to_vec();
364    let frame = client
365        .request(futu_core::proto_id::TRD_GET_CASH_DETAIL, body)
366        .await?;
367    let resp =
368        <realtime_asset_log::DaemonGetCashDetailRsp as prost::Message>::decode(frame.body.as_ref())
369            .map_err(|e| anyhow::anyhow!("decode DaemonGetCashDetailRsp: {e}"))?;
370    if resp.ret_type != 0 {
371        bail!(
372            "GetCashDetail ret_type={} msg={:?}",
373            resp.ret_type,
374            resp.ret_msg
375        );
376    }
377
378    let inner_rsp = resp
379        .s2c
380        .and_then(|s| s.inner)
381        .ok_or_else(|| anyhow::anyhow!("empty s2c.inner"))?;
382
383    let out = cash_detail_out_from_inner_rsp(inner_rsp);
384    Ok(serde_json::to_string_pretty(&out)?)
385}
386
387#[derive(Serialize)]
388struct BizGroupItemOut {
389    id: u32,
390    name: String,
391    sub_groups: Vec<BizSubGroupOut>,
392}
393
394#[derive(Serialize)]
395struct BizSubGroupOut {
396    id: u32,
397    name: String,
398}
399
400#[derive(Serialize)]
401struct CurrencyConfigOut {
402    currency: String,
403    name: String,
404}
405
406#[derive(Serialize)]
407struct DirectionOut {
408    side: u32,
409    name: String,
410}
411
412#[derive(Serialize)]
413struct BizGroupOut {
414    biz_groups: Vec<BizGroupItemOut>,
415    currencies: Vec<CurrencyConfigOut>,
416    directions: Vec<DirectionOut>,
417}
418
419/// v1.4.95 U1: MCP tool `futu_get_biz_group` — 业务分类元数据.
420///
421/// 给 client 构造 cash log 过滤下拉菜单用 (业务分组 / 货币 / 方向).
422pub async fn get_biz_group(client: &Arc<FutuClient>, env: &str, acc_id: u64) -> Result<String> {
423    let trd_env = parse_trd_env(env)?;
424    if acc_id == 0 {
425        bail!("acc_id 必填");
426    }
427
428    // v1.4.102 codex 38 F1 (P1): 不预填 inner.market, 让 daemon 派生 verified.
429    let inner = realtime_asset_log::GetBizGroupReq { market: None };
430    let req = realtime_asset_log::DaemonGetBizGroupReq {
431        c2s: realtime_asset_log::daemon_get_biz_group_req::C2s {
432            header: realtime_asset_log::DaemonGetCashLogHeader {
433                acc_id,
434                trd_env: Some(trd_env as i32),
435            },
436            inner: Some(inner),
437        },
438    };
439
440    let body = req.encode_to_vec();
441    let frame = client
442        .request(futu_core::proto_id::TRD_GET_BIZ_GROUP, body)
443        .await?;
444    let resp =
445        <realtime_asset_log::DaemonGetBizGroupRsp as prost::Message>::decode(frame.body.as_ref())
446            .map_err(|e| anyhow::anyhow!("decode DaemonGetBizGroupRsp: {e}"))?;
447    if resp.ret_type != 0 {
448        bail!(
449            "GetBizGroup ret_type={} msg={:?}",
450            resp.ret_type,
451            resp.ret_msg
452        );
453    }
454
455    let inner_rsp = resp
456        .s2c
457        .and_then(|s| s.inner)
458        .ok_or_else(|| anyhow::anyhow!("empty s2c.inner"))?;
459
460    let biz_groups: Vec<BizGroupItemOut> = inner_rsp
461        .biz_group_list
462        .into_iter()
463        .map(|g| BizGroupItemOut {
464            id: g.id.unwrap_or(0),
465            name: g.name.unwrap_or_default(),
466            sub_groups: g
467                .biz_sub_group_list
468                .into_iter()
469                .map(|s| BizSubGroupOut {
470                    id: s.id.unwrap_or(0),
471                    name: s.name.unwrap_or_default(),
472                })
473                .collect(),
474        })
475        .collect();
476
477    let currencies: Vec<CurrencyConfigOut> = inner_rsp
478        .currency_config_list
479        .into_iter()
480        .map(|c| CurrencyConfigOut {
481            currency: c.currency.unwrap_or_default(),
482            name: c.name.unwrap_or_default(),
483        })
484        .collect();
485
486    let directions: Vec<DirectionOut> = inner_rsp
487        .direction_list
488        .into_iter()
489        .map(|d| DirectionOut {
490            side: d.side.unwrap_or(0),
491            name: d.name.unwrap_or_default(),
492        })
493        .collect();
494
495    let out = BizGroupOut {
496        biz_groups,
497        currencies,
498        directions,
499    };
500    Ok(serde_json::to_string_pretty(&out)?)
501}
502
503#[cfg(test)]
504mod tests;