Skip to main content

futu_mcp/handlers/trade/
bond.rs

1use std::sync::Arc;
2
3use anyhow::{Result, bail};
4use futu_backend::proto_internal::bond_client_view;
5use futu_net::client::FutuClient;
6use prost::Message as _;
7use serde::Serialize;
8
9use super::parse_trd_env_int;
10
11// ============================================================================
12// v1.4.95 U2-B Tier M (mobile-driven extension): bond holdings + trade prep
13//
14// 来源: ftcnnproto/.../bond_client_view.proto + FLCltProtocol.h
15// 5 endpoint × 5 cmd_id (9373/9374/9375/10043/10057), 共享
16// DaemonBondHeader (acc_id + trd_env + market: "HK"/"US"/"SG").
17//
18// **市场覆盖**: 仅 HK / US / SG 债券账户; 其他账户 backend 优雅返空.
19// ============================================================================
20
21#[derive(Serialize)]
22struct BondTotalAssetOut {
23    total_asset: String,
24    position_incomes: String,
25    today_incomes: String,
26    accrued_interest: String,
27    show_flag: bool,
28    ccy: String,
29}
30
31#[derive(Serialize)]
32struct BondNoticeOut {
33    text: String,
34    url: String,
35    /// 1=派息, 2=到期退出, 3=提前赎回
36    notice_type: i32,
37    url_id: i32,
38    updated_time: u64,
39}
40
41#[derive(Serialize)]
42struct BondLegacyNotificationOut {
43    text: String,
44    url: String,
45}
46
47#[derive(Serialize)]
48struct BondSingleAssetOut {
49    market_value: String,
50    today_incomes: String,
51    position_incomes: String,
52    quantity: String,
53    cost: String,
54    /// epoch 秒 (0=未知)
55    expired_time: u64,
56    next_dividend_time: u64,
57    /// 0=不派息, 1=每年1次, 2=每年2次, 3=每年4次, 4=每月1次, 5=5年1次, 6=2年1次
58    dividend_type: u32,
59    accrued_interest: String,
60    /// 1=固定, 2=不固定
61    dividend_option: u32,
62    notification: Option<BondLegacyNotificationOut>,
63    notice: Option<BondNoticeOut>,
64    notice_list: Vec<BondNoticeOut>,
65    ccy: String,
66    coupon_cash: String,
67    position_cost: String,
68    /// 中间价 (without accrued interest)
69    price: String,
70}
71
72#[derive(Serialize)]
73struct BondPositionItemOut {
74    name: String,
75    name_short: String,
76    symbol: String,
77    make_for_call_flag: bool,
78    market_value: String,
79    quantity: String,
80    price: String,
81    cost: String,
82    today_incomes: String,
83    today_incomes_rate: String,
84    position_incomes: String,
85    position_incomes_rate: String,
86    accrued_interest: String,
87    ccy: String,
88    notice: Option<BondNoticeOut>,
89}
90
91#[derive(Serialize)]
92struct BondPositionListOut {
93    total: i32,
94    bond_list: Vec<BondPositionItemOut>,
95}
96
97#[derive(Serialize)]
98struct BondNoticeUrlOut {
99    title: String,
100    content: String,
101    confirm_button_title: String,
102    confirm_url: String,
103    content_url_title: String,
104    content_url: String,
105    cancel_button_title: String,
106}
107
108#[derive(Serialize)]
109struct BondAnswerStateOut {
110    need_to_answer: bool,
111    notice: Option<BondNoticeUrlOut>,
112}
113
114#[derive(Serialize)]
115struct BondReminderItemOut {
116    /// 0/1 标识值 (含义见各字段注释)
117    value: i32,
118    title: String,
119    text: String,
120    /// 0=无提醒, 1=弱(底部文案), 2=中(按钮弹窗), 3=强(进详情页弹窗)
121    reminder_level: i32,
122    url_id: i64,
123}
124
125#[derive(Serialize)]
126struct BondTradeReminderOut {
127    /// 是否可买入 (0=不可, 1=可)
128    tradeable: Option<BondReminderItemOut>,
129    /// 复杂产品 (0=否, 1=是)
130    complex_product: Option<BondReminderItemOut>,
131    /// 高风险预警 (0=否, 1=是)
132    high_risk: Option<BondReminderItemOut>,
133    /// 是否可卖出 (0=不可, 1=可)
134    sell_tradeable: Option<BondReminderItemOut>,
135    /// 是否需要非 AI 资格预审 (0=否, 1=是)
136    pre_qualification: Option<BondReminderItemOut>,
137}
138
139/// 共用 helper: env str → trd_env_int + market str validation
140///
141/// v1.4.102 codex 41 F3 (P2): typo (e.g. "prod") 现 reject, 不 silent → sim.
142fn parse_bond_inputs(env: &str, market: &str) -> Result<(i32, String)> {
143    let trd_env_int: i32 = parse_trd_env_int(env)?;
144    let market_clean = market.trim().to_string();
145    if market_clean.is_empty() {
146        bail!("market 必填: HK / US / SG");
147    }
148    let market_upper = market_clean.to_ascii_uppercase();
149    if !matches!(
150        market_upper.as_str(),
151        "HK" | "US" | "USA" | "SG" | "SG_UNIVERSAL"
152    ) {
153        bail!(
154            "unsupported market {market_clean:?} (supported: HK / US / SG; \
155             仅 HK/US/SG 债券账户有数据)"
156        );
157    }
158    Ok((trd_env_int, market_upper))
159}
160
161fn convert_notice(n: bond_client_view::Notice) -> BondNoticeOut {
162    BondNoticeOut {
163        text: n.text.unwrap_or_default(),
164        url: n.url.unwrap_or_default(),
165        notice_type: n.notice_type.unwrap_or(0),
166        url_id: n.url_id.unwrap_or(0),
167        updated_time: n.updated_time.unwrap_or(0),
168    }
169}
170
171fn convert_bond_notification(
172    n: bond_client_view::income_with_bond_rsp::Notification,
173) -> BondLegacyNotificationOut {
174    BondLegacyNotificationOut {
175        text: n.text.unwrap_or_default(),
176        url: n.url.unwrap_or_default(),
177    }
178}
179
180fn convert_reminder(r: bond_client_view::ReminderItem) -> BondReminderItemOut {
181    BondReminderItemOut {
182        value: r.value.unwrap_or(0),
183        title: r.title.unwrap_or_default(),
184        text: r.text.unwrap_or_default(),
185        reminder_level: r.reminder_level.unwrap_or(0),
186        url_id: r.url_id.unwrap_or(0),
187    }
188}
189
190fn bond_single_asset_out_from_proto(
191    inner: bond_client_view::IncomeWithBondRsp,
192) -> BondSingleAssetOut {
193    BondSingleAssetOut {
194        market_value: inner.market_value.unwrap_or_default(),
195        today_incomes: inner.today_incomes.unwrap_or_default(),
196        position_incomes: inner.position_incomes.unwrap_or_default(),
197        quantity: inner.quantity.unwrap_or_default(),
198        cost: inner.cost.unwrap_or_default(),
199        expired_time: inner.expired_time.unwrap_or(0),
200        next_dividend_time: inner.next_dividend_time.unwrap_or(0),
201        dividend_type: inner.dividend_type.unwrap_or(0),
202        accrued_interest: inner.accrued_interest.unwrap_or_default(),
203        dividend_option: inner.dividend_option.unwrap_or(0),
204        notification: inner.notification.map(convert_bond_notification),
205        notice: inner.notice.map(convert_notice),
206        notice_list: inner.notice_list.into_iter().map(convert_notice).collect(),
207        ccy: inner.ccy.unwrap_or_default(),
208        coupon_cash: inner.coupon_cash.unwrap_or_default(),
209        position_cost: inner.position_cost.unwrap_or_default(),
210        price: inner.price.unwrap_or_default(),
211    }
212}
213
214/// v1.4.95 U2-B: MCP tool `futu_get_bond_total_asset`
215pub async fn get_bond_total_asset(
216    client: &Arc<FutuClient>,
217    env: &str,
218    acc_id: u64,
219    market: &str,
220) -> Result<String> {
221    if acc_id == 0 {
222        bail!("acc_id 必填 (call futu_list_accounts to discover)");
223    }
224    let (trd_env_int, market_upper) = parse_bond_inputs(env, market)?;
225
226    let req = bond_client_view::DaemonGetBondTotalAssetReq {
227        c2s: bond_client_view::daemon_get_bond_total_asset_req::C2s {
228            header: bond_client_view::DaemonBondHeader {
229                acc_id,
230                trd_env: Some(trd_env_int),
231                market: market_upper,
232            },
233        },
234    };
235    let body = req.encode_to_vec();
236    let frame = client
237        .request(futu_core::proto_id::TRD_GET_BOND_TOTAL_ASSET, body)
238        .await?;
239    let resp = <bond_client_view::DaemonGetBondTotalAssetRsp as prost::Message>::decode(
240        frame.body.as_ref(),
241    )
242    .map_err(|e| anyhow::anyhow!("decode DaemonGetBondTotalAssetRsp: {e}"))?;
243    if resp.ret_type != 0 {
244        bail!(
245            "GetBondTotalAsset ret_type={} msg={:?}",
246            resp.ret_type,
247            resp.ret_msg
248        );
249    }
250    let inner = resp
251        .s2c
252        .and_then(|s| s.inner)
253        .ok_or_else(|| anyhow::anyhow!("empty s2c.inner in GetBondTotalAssetRsp"))?;
254
255    let out = BondTotalAssetOut {
256        total_asset: inner.total_asset.unwrap_or_default(),
257        position_incomes: inner.position_incomes.unwrap_or_default(),
258        today_incomes: inner.today_incomes.unwrap_or_default(),
259        accrued_interest: inner.accrued_interest.unwrap_or_default(),
260        show_flag: inner.show_flag.unwrap_or(false),
261        ccy: inner.ccy.unwrap_or_default(),
262    };
263    Ok(serde_json::to_string_pretty(&out)?)
264}
265
266/// v1.4.95 U2-B: MCP tool `futu_get_bond_single_asset`
267pub async fn get_bond_single_asset(
268    client: &Arc<FutuClient>,
269    env: &str,
270    acc_id: u64,
271    market: &str,
272    symbol: &str,
273) -> Result<String> {
274    if acc_id == 0 {
275        bail!("acc_id 必填 (call futu_list_accounts to discover)");
276    }
277    if symbol.trim().is_empty() {
278        bail!("symbol 必填 (债券代码)");
279    }
280    let (trd_env_int, market_upper) = parse_bond_inputs(env, market)?;
281
282    let req = bond_client_view::DaemonGetBondSingleAssetReq {
283        c2s: bond_client_view::daemon_get_bond_single_asset_req::C2s {
284            header: bond_client_view::DaemonBondHeader {
285                acc_id,
286                trd_env: Some(trd_env_int),
287                market: market_upper,
288            },
289            symbol: symbol.to_string(),
290        },
291    };
292    let body = req.encode_to_vec();
293    let frame = client
294        .request(futu_core::proto_id::TRD_GET_BOND_SINGLE_ASSET, body)
295        .await?;
296    let resp = <bond_client_view::DaemonGetBondSingleAssetRsp as prost::Message>::decode(
297        frame.body.as_ref(),
298    )
299    .map_err(|e| anyhow::anyhow!("decode DaemonGetBondSingleAssetRsp: {e}"))?;
300    if resp.ret_type != 0 {
301        bail!(
302            "GetBondSingleAsset ret_type={} msg={:?}",
303            resp.ret_type,
304            resp.ret_msg
305        );
306    }
307    let inner = resp
308        .s2c
309        .and_then(|s| s.inner)
310        .ok_or_else(|| anyhow::anyhow!("empty s2c.inner in GetBondSingleAssetRsp"))?;
311
312    let out = bond_single_asset_out_from_proto(inner);
313    Ok(serde_json::to_string_pretty(&out)?)
314}
315
316/// v1.4.95 U2-B: MCP tool `futu_get_bond_position_list`
317pub async fn get_bond_position_list(
318    client: &Arc<FutuClient>,
319    env: &str,
320    acc_id: u64,
321    market: &str,
322) -> Result<String> {
323    if acc_id == 0 {
324        bail!("acc_id 必填 (call futu_list_accounts to discover)");
325    }
326    let (trd_env_int, market_upper) = parse_bond_inputs(env, market)?;
327
328    let req = bond_client_view::DaemonGetBondPositionListReq {
329        c2s: bond_client_view::daemon_get_bond_position_list_req::C2s {
330            header: bond_client_view::DaemonBondHeader {
331                acc_id,
332                trd_env: Some(trd_env_int),
333                market: market_upper,
334            },
335        },
336    };
337    let body = req.encode_to_vec();
338    let frame = client
339        .request(futu_core::proto_id::TRD_GET_BOND_POSITION_LIST, body)
340        .await?;
341    let resp = <bond_client_view::DaemonGetBondPositionListRsp as prost::Message>::decode(
342        frame.body.as_ref(),
343    )
344    .map_err(|e| anyhow::anyhow!("decode DaemonGetBondPositionListRsp: {e}"))?;
345    if resp.ret_type != 0 {
346        bail!(
347            "GetBondPositionList ret_type={} msg={:?}",
348            resp.ret_type,
349            resp.ret_msg
350        );
351    }
352    let inner = resp
353        .s2c
354        .and_then(|s| s.inner)
355        .ok_or_else(|| anyhow::anyhow!("empty s2c.inner in GetBondPositionListRsp"))?;
356
357    let bond_list: Vec<BondPositionItemOut> = inner
358        .bond_list
359        .into_iter()
360        .map(|b| BondPositionItemOut {
361            name: b.name.unwrap_or_default(),
362            name_short: b.name_short.unwrap_or_default(),
363            symbol: b.symbol.unwrap_or_default(),
364            make_for_call_flag: b.make_for_call_flag.unwrap_or(false),
365            market_value: b.market_value.unwrap_or_default(),
366            quantity: b.quantity.unwrap_or_default(),
367            price: b.price.unwrap_or_default(),
368            cost: b.cost.unwrap_or_default(),
369            today_incomes: b.today_incomes.unwrap_or_default(),
370            today_incomes_rate: b.today_incomes_rate.unwrap_or_default(),
371            position_incomes: b.position_incomes.unwrap_or_default(),
372            position_incomes_rate: b.position_incomes_rate.unwrap_or_default(),
373            accrued_interest: b.accrued_interest.unwrap_or_default(),
374            ccy: b.ccy.unwrap_or_default(),
375            notice: b.notice.map(convert_notice),
376        })
377        .collect();
378
379    let out = BondPositionListOut {
380        total: inner.total.unwrap_or(0),
381        bond_list,
382    };
383    Ok(serde_json::to_string_pretty(&out)?)
384}
385
386/// v1.4.95 U2-B: MCP tool `futu_get_bond_answer_state`
387pub async fn get_bond_answer_state(
388    client: &Arc<FutuClient>,
389    env: &str,
390    acc_id: u64,
391    market: &str,
392    symbol: &str,
393) -> Result<String> {
394    if acc_id == 0 {
395        bail!("acc_id 必填");
396    }
397    if symbol.trim().is_empty() {
398        bail!("symbol 必填 (债券 symbol, 类似 11000018)");
399    }
400    let (trd_env_int, market_upper) = parse_bond_inputs(env, market)?;
401
402    let req = bond_client_view::DaemonGetBondAnswerStateReq {
403        c2s: bond_client_view::daemon_get_bond_answer_state_req::C2s {
404            header: bond_client_view::DaemonBondHeader {
405                acc_id,
406                trd_env: Some(trd_env_int),
407                market: market_upper,
408            },
409            symbol: symbol.to_string(),
410        },
411    };
412    let body = req.encode_to_vec();
413    let frame = client
414        .request(futu_core::proto_id::TRD_GET_BOND_ANSWER_STATE, body)
415        .await?;
416    let resp = <bond_client_view::DaemonGetBondAnswerStateRsp as prost::Message>::decode(
417        frame.body.as_ref(),
418    )
419    .map_err(|e| anyhow::anyhow!("decode DaemonGetBondAnswerStateRsp: {e}"))?;
420    if resp.ret_type != 0 {
421        bail!(
422            "GetBondAnswerState ret_type={} msg={:?}",
423            resp.ret_type,
424            resp.ret_msg
425        );
426    }
427    let inner = resp
428        .s2c
429        .and_then(|s| s.inner)
430        .ok_or_else(|| anyhow::anyhow!("empty s2c.inner in GetBondAnswerStateRsp"))?;
431
432    let out = BondAnswerStateOut {
433        need_to_answer: inner.need_to_answer.unwrap_or(false),
434        notice: inner.notice.map(|n| BondNoticeUrlOut {
435            title: n.title.unwrap_or_default(),
436            content: n.content.unwrap_or_default(),
437            confirm_button_title: n.confirm_button_title.unwrap_or_default(),
438            confirm_url: n.confirm_url.unwrap_or_default(),
439            content_url_title: n.content_url_title.unwrap_or_default(),
440            content_url: n.content_url.unwrap_or_default(),
441            cancel_button_title: n.cancel_button_title.unwrap_or_default(),
442        }),
443    };
444    Ok(serde_json::to_string_pretty(&out)?)
445}
446
447/// v1.4.95 U2-B: MCP tool `futu_get_bond_trade_reminder`
448pub async fn get_bond_trade_reminder(
449    client: &Arc<FutuClient>,
450    env: &str,
451    acc_id: u64,
452    market: &str,
453    symbol: &str,
454) -> Result<String> {
455    if acc_id == 0 {
456        bail!("acc_id 必填");
457    }
458    if symbol.trim().is_empty() {
459        bail!("symbol 必填 (债券 symbol)");
460    }
461    let (trd_env_int, market_upper) = parse_bond_inputs(env, market)?;
462
463    let req = bond_client_view::DaemonGetBondTradeReminderReq {
464        c2s: bond_client_view::daemon_get_bond_trade_reminder_req::C2s {
465            header: bond_client_view::DaemonBondHeader {
466                acc_id,
467                trd_env: Some(trd_env_int),
468                market: market_upper,
469            },
470            symbol: symbol.to_string(),
471        },
472    };
473    let body = req.encode_to_vec();
474    let frame = client
475        .request(futu_core::proto_id::TRD_GET_BOND_TRADE_REMINDER, body)
476        .await?;
477    let resp = <bond_client_view::DaemonGetBondTradeReminderRsp as prost::Message>::decode(
478        frame.body.as_ref(),
479    )
480    .map_err(|e| anyhow::anyhow!("decode DaemonGetBondTradeReminderRsp: {e}"))?;
481    if resp.ret_type != 0 {
482        bail!(
483            "GetBondTradeReminder ret_type={} msg={:?}",
484            resp.ret_type,
485            resp.ret_msg
486        );
487    }
488    let inner = resp
489        .s2c
490        .and_then(|s| s.inner)
491        .ok_or_else(|| anyhow::anyhow!("empty s2c.inner in GetBondTradeReminderRsp"))?;
492
493    let out = BondTradeReminderOut {
494        tradeable: inner.tradeable.map(convert_reminder),
495        complex_product: inner.complex_product.map(convert_reminder),
496        high_risk: inner.high_risk.map(convert_reminder),
497        sell_tradeable: inner.sell_tradeable.map(convert_reminder),
498        pre_qualification: inner.pre_qualification.map(convert_reminder),
499    };
500    Ok(serde_json::to_string_pretty(&out)?)
501}
502
503#[cfg(test)]
504mod tests;