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#[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 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 expired_time: u64,
56 next_dividend_time: u64,
57 dividend_type: u32,
59 accrued_interest: String,
60 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 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 value: i32,
118 title: String,
119 text: String,
120 reminder_level: i32,
122 url_id: i64,
123}
124
125#[derive(Serialize)]
126struct BondTradeReminderOut {
127 tradeable: Option<BondReminderItemOut>,
129 complex_product: Option<BondReminderItemOut>,
131 high_risk: Option<BondReminderItemOut>,
133 sell_tradeable: Option<BondReminderItemOut>,
135 pre_qualification: Option<BondReminderItemOut>,
137}
138
139fn 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
214pub 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
266pub 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
316pub 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
386pub 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
447pub 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;