1use anyhow::{Context, Result, bail};
31use prost::Message;
32
33use crate::common::connect_gateway;
34use crate::output::OutputFormat;
35use futu_backend::proto_internal::{
36 account_flag, bond_client_view, realtime_asset_log, risk_user_account_info,
37};
38use futu_core::proto_id;
39
40fn parse_env_int(env: &str) -> Result<i32> {
46 match env.trim().to_ascii_lowercase().as_str() {
47 "real" => Ok(1),
48 "sim" | "simulate" => Ok(0),
49 "" => Ok(1),
52 other => Err(anyhow::anyhow!(
53 "unknown env {other:?} (expected: real / sim / simulate). \
54 v1.4.102 codex 42 F2 fix: typo 拒, 不 silent 当 sim"
55 )),
56 }
57}
58
59async fn dispatch<RspT: prost::Message + Default>(
61 gateway: &str,
62 client_id: &str,
63 proto_id: u32,
64 body: Vec<u8>,
65) -> Result<RspT> {
66 let (client, _push_rx) = connect_gateway(gateway, client_id).await?;
67 let frame = client
68 .request(proto_id, body)
69 .await
70 .with_context(|| format!("request proto_id {proto_id}"))?;
71 RspT::decode(frame.body.as_ref())
72 .with_context(|| format!("decode response for proto_id {proto_id}"))
73}
74
75fn print_json_pretty<T: serde::Serialize>(_format: OutputFormat, val: &T) -> Result<()> {
77 let s = serde_json::to_string_pretty(val)?;
78 println!("{s}");
79 Ok(())
80}
81
82pub struct CashLogCommand<'a> {
88 pub gateway: &'a str,
89 pub env: &'a str,
90 pub acc_id: u64,
91 pub begin_time: u64,
92 pub end_time: u64,
93 pub log_id_cursor: Option<String>,
94 pub biz_group_id: Option<u32>,
95 pub biz_sub_group_id: Option<u32>,
96 pub in_out: Option<u32>,
97 pub keyword: Option<String>,
98 pub symbol: Option<String>,
99 pub stock_id: Option<u64>,
100 pub max_cnt: Option<u32>,
101 pub currency: Option<String>,
102 pub format: OutputFormat,
103}
104
105pub async fn run_cash_log(input: CashLogCommand<'_>) -> Result<()> {
106 if input.acc_id == 0 {
107 bail!("--acc-id 必填 (call `futucli list-accounts` to discover)");
108 }
109 if input.end_time < input.begin_time {
110 bail!(
111 "--end-time {} 不能早于 --begin-time {}",
112 input.end_time,
113 input.begin_time
114 );
115 }
116 let req = realtime_asset_log::DaemonGetCashLogReq {
117 c2s: realtime_asset_log::daemon_get_cash_log_req::C2s {
118 header: realtime_asset_log::DaemonGetCashLogHeader {
119 acc_id: input.acc_id,
120 trd_env: Some(parse_env_int(input.env)?),
121 },
122 inner: Some(realtime_asset_log::GetCashLogReq {
124 begin_time: Some(input.begin_time),
125 end_time: Some(input.end_time),
126 log_id: input.log_id_cursor,
127 biz_group_id: input.biz_group_id,
128 biz_sub_group_id: input.biz_sub_group_id,
129 in_out: input.in_out,
130 keyword: input.keyword,
131 symbol: input.symbol,
132 stock_id: input.stock_id,
133 max_cnt: input.max_cnt,
134 currency: input.currency,
135 ..Default::default()
136 }),
137 },
138 };
139 let body = req.encode_to_vec();
140 let resp: realtime_asset_log::DaemonGetCashLogRsp = dispatch(
141 input.gateway,
142 "futucli-cash-log",
143 proto_id::TRD_GET_CASH_LOG,
144 body,
145 )
146 .await?;
147 if resp.ret_type != 0 {
148 bail!(
149 "GetCashLog failed: ret_type={} ret_msg={:?}",
150 resp.ret_type,
151 resp.ret_msg
152 );
153 }
154 print_json_pretty(input.format, &resp.s2c.and_then(|s| s.inner))?;
155 Ok(())
156}
157
158pub async fn run_cash_detail(
160 gateway: &str,
161 env: &str,
162 acc_id: u64,
163 log_id: String,
164 format: OutputFormat,
165) -> Result<()> {
166 if acc_id == 0 {
167 bail!("--acc-id 必填");
168 }
169 if log_id.trim().is_empty() {
170 bail!("--log-id 必填 (从 cash-log 返回的 log_id 字段拿, 字符串)");
171 }
172 let req = realtime_asset_log::DaemonGetCashDetailReq {
173 c2s: realtime_asset_log::daemon_get_cash_detail_req::C2s {
174 header: realtime_asset_log::DaemonGetCashLogHeader {
175 acc_id,
176 trd_env: Some(parse_env_int(env)?),
177 },
178 inner: Some(realtime_asset_log::GetCashDetailReq {
180 log_id: Some(log_id),
181 market: None,
182 account_id: None,
183 }),
184 },
185 };
186 let body = req.encode_to_vec();
187 let resp: realtime_asset_log::DaemonGetCashDetailRsp = dispatch(
188 gateway,
189 "futucli-cash-detail",
190 proto_id::TRD_GET_CASH_DETAIL,
191 body,
192 )
193 .await?;
194 if resp.ret_type != 0 {
195 bail!(
196 "GetCashDetail failed: ret_type={} ret_msg={:?}",
197 resp.ret_type,
198 resp.ret_msg
199 );
200 }
201 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
202 Ok(())
203}
204
205pub async fn run_biz_group(
207 gateway: &str,
208 env: &str,
209 acc_id: u64,
210 format: OutputFormat,
211) -> Result<()> {
212 if acc_id == 0 {
213 bail!("--acc-id 必填");
214 }
215 let req = realtime_asset_log::DaemonGetBizGroupReq {
216 c2s: realtime_asset_log::daemon_get_biz_group_req::C2s {
217 header: realtime_asset_log::DaemonGetCashLogHeader {
218 acc_id,
219 trd_env: Some(parse_env_int(env)?),
220 },
221 inner: Some(realtime_asset_log::GetBizGroupReq { market: None }),
223 },
224 };
225 let body = req.encode_to_vec();
226 let resp: realtime_asset_log::DaemonGetBizGroupRsp = dispatch(
227 gateway,
228 "futucli-biz-group",
229 proto_id::TRD_GET_BIZ_GROUP,
230 body,
231 )
232 .await?;
233 if resp.ret_type != 0 {
234 bail!(
235 "GetBizGroup failed: ret_type={} ret_msg={:?}",
236 resp.ret_type,
237 resp.ret_msg
238 );
239 }
240 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
241 Ok(())
242}
243
244pub async fn run_margin_info(
250 gateway: &str,
251 env: &str,
252 acc_id: u64,
253 market: &str,
254 format: OutputFormat,
255) -> Result<()> {
256 if acc_id == 0 {
257 bail!("--acc-id 必填");
258 }
259 let market_clean = market.trim();
260 if market_clean.is_empty() {
261 bail!("--market 必填: HK / US / CN_AH");
262 }
263 let req = risk_user_account_info::DaemonGetMarginInfoReq {
264 c2s: risk_user_account_info::daemon_get_margin_info_req::C2s {
265 header: risk_user_account_info::DaemonMarginInfoHeader {
266 acc_id,
267 trd_env: Some(parse_env_int(env)?),
268 market: market_clean.to_string(),
269 },
270 inner: None,
271 },
272 };
273 let body = req.encode_to_vec();
274 let resp: risk_user_account_info::DaemonGetMarginInfoRsp = dispatch(
275 gateway,
276 "futucli-margin-info",
277 proto_id::TRD_GET_MARGIN_INFO,
278 body,
279 )
280 .await?;
281 if resp.ret_type != 0 {
282 bail!(
283 "GetMarginInfo failed: ret_type={} ret_msg={:?}",
284 resp.ret_type,
285 resp.ret_msg
286 );
287 }
288 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
289 Ok(())
290}
291
292pub async fn run_account_flag(
298 gateway: &str,
299 env: &str,
300 acc_id: u64,
301 flag_id: u32,
302 format: OutputFormat,
303) -> Result<()> {
304 if acc_id == 0 {
305 bail!("--acc-id 必填");
306 }
307 if flag_id == 0 {
308 bail!(
309 "--flag-id 必填 (常用值: 5=US 期权确认, 22=衍生品风批, 10=基金 KYC, \
310 16=PDT, 23=美股 OTC; 详见 proto 头部完整 36+ 项列表)"
311 );
312 }
313 let req = account_flag::DaemonGetAccountFlagReq {
314 c2s: account_flag::daemon_get_account_flag_req::C2s {
315 header: account_flag::DaemonGetAccountFlagHeader {
316 acc_id,
317 trd_env: Some(parse_env_int(env)?),
318 flag_id,
319 },
320 },
321 };
322 let body = req.encode_to_vec();
323 let resp: account_flag::DaemonGetAccountFlagRsp = dispatch(
324 gateway,
325 "futucli-account-flag",
326 proto_id::TRD_GET_ACCOUNT_FLAG,
327 body,
328 )
329 .await?;
330 if resp.ret_type != 0 {
331 bail!(
332 "GetAccountFlag failed: ret_type={} ret_msg={:?}",
333 resp.ret_type,
334 resp.ret_msg
335 );
336 }
337 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
338 Ok(())
339}
340
341fn validate_bond_market(market: &str) -> Result<String> {
347 let m = market.trim();
348 if m.is_empty() {
349 bail!("--market 必填: HK / US / SG (债券业务仅 3 市场)");
350 }
351 let upper = m.to_ascii_uppercase();
352 match upper.as_str() {
353 "HK" | "US" | "USA" | "SG" | "SG_UNIVERSAL" => Ok(upper),
354 _ => bail!(
355 "unsupported market {m:?} (supported: HK / US / SG; \
356 仅 HK/US/SG 债券账户有数据)"
357 ),
358 }
359}
360
361pub async fn run_bond_total_asset(
363 gateway: &str,
364 env: &str,
365 acc_id: u64,
366 market: &str,
367 format: OutputFormat,
368) -> Result<()> {
369 if acc_id == 0 {
370 bail!("--acc-id 必填");
371 }
372 let market_upper = validate_bond_market(market)?;
373 let req = bond_client_view::DaemonGetBondTotalAssetReq {
374 c2s: bond_client_view::daemon_get_bond_total_asset_req::C2s {
375 header: bond_client_view::DaemonBondHeader {
376 acc_id,
377 trd_env: Some(parse_env_int(env)?),
378 market: market_upper,
379 },
380 },
381 };
382 let body = req.encode_to_vec();
383 let resp: bond_client_view::DaemonGetBondTotalAssetRsp = dispatch(
384 gateway,
385 "futucli-bond-total-asset",
386 proto_id::TRD_GET_BOND_TOTAL_ASSET,
387 body,
388 )
389 .await?;
390 if resp.ret_type != 0 {
391 bail!(
392 "GetBondTotalAsset failed: ret_type={} ret_msg={:?}",
393 resp.ret_type,
394 resp.ret_msg
395 );
396 }
397 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
398 Ok(())
399}
400
401pub async fn run_bond_single_asset(
403 gateway: &str,
404 env: &str,
405 acc_id: u64,
406 market: &str,
407 symbol: &str,
408 format: OutputFormat,
409) -> Result<()> {
410 if acc_id == 0 {
411 bail!("--acc-id 必填");
412 }
413 if symbol.trim().is_empty() {
414 bail!("--symbol 必填 (债券代码)");
415 }
416 let market_upper = validate_bond_market(market)?;
417 let req = bond_client_view::DaemonGetBondSingleAssetReq {
418 c2s: bond_client_view::daemon_get_bond_single_asset_req::C2s {
419 header: bond_client_view::DaemonBondHeader {
420 acc_id,
421 trd_env: Some(parse_env_int(env)?),
422 market: market_upper,
423 },
424 symbol: symbol.to_string(),
425 },
426 };
427 let body = req.encode_to_vec();
428 let resp: bond_client_view::DaemonGetBondSingleAssetRsp = dispatch(
429 gateway,
430 "futucli-bond-single-asset",
431 proto_id::TRD_GET_BOND_SINGLE_ASSET,
432 body,
433 )
434 .await?;
435 if resp.ret_type != 0 {
436 bail!(
437 "GetBondSingleAsset failed: ret_type={} ret_msg={:?}",
438 resp.ret_type,
439 resp.ret_msg
440 );
441 }
442 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
443 Ok(())
444}
445
446pub async fn run_bond_position_list(
448 gateway: &str,
449 env: &str,
450 acc_id: u64,
451 market: &str,
452 format: OutputFormat,
453) -> Result<()> {
454 if acc_id == 0 {
455 bail!("--acc-id 必填");
456 }
457 let market_upper = validate_bond_market(market)?;
458 let req = bond_client_view::DaemonGetBondPositionListReq {
459 c2s: bond_client_view::daemon_get_bond_position_list_req::C2s {
460 header: bond_client_view::DaemonBondHeader {
461 acc_id,
462 trd_env: Some(parse_env_int(env)?),
463 market: market_upper,
464 },
465 },
466 };
467 let body = req.encode_to_vec();
468 let resp: bond_client_view::DaemonGetBondPositionListRsp = dispatch(
469 gateway,
470 "futucli-bond-position-list",
471 proto_id::TRD_GET_BOND_POSITION_LIST,
472 body,
473 )
474 .await?;
475 if resp.ret_type != 0 {
476 bail!(
477 "GetBondPositionList failed: ret_type={} ret_msg={:?}",
478 resp.ret_type,
479 resp.ret_msg
480 );
481 }
482 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
483 Ok(())
484}
485
486pub async fn run_bond_answer_state(
488 gateway: &str,
489 env: &str,
490 acc_id: u64,
491 market: &str,
492 symbol: &str,
493 format: OutputFormat,
494) -> Result<()> {
495 if acc_id == 0 {
496 bail!("--acc-id 必填");
497 }
498 if symbol.trim().is_empty() {
499 bail!("--symbol 必填 (债券 symbol, 类似 11000018)");
500 }
501 let market_upper = validate_bond_market(market)?;
502 let req = bond_client_view::DaemonGetBondAnswerStateReq {
503 c2s: bond_client_view::daemon_get_bond_answer_state_req::C2s {
504 header: bond_client_view::DaemonBondHeader {
505 acc_id,
506 trd_env: Some(parse_env_int(env)?),
507 market: market_upper,
508 },
509 symbol: symbol.to_string(),
510 },
511 };
512 let body = req.encode_to_vec();
513 let resp: bond_client_view::DaemonGetBondAnswerStateRsp = dispatch(
514 gateway,
515 "futucli-bond-answer-state",
516 proto_id::TRD_GET_BOND_ANSWER_STATE,
517 body,
518 )
519 .await?;
520 if resp.ret_type != 0 {
521 bail!(
522 "GetBondAnswerState failed: ret_type={} ret_msg={:?}",
523 resp.ret_type,
524 resp.ret_msg
525 );
526 }
527 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
528 Ok(())
529}
530
531pub async fn run_bond_trade_reminder(
533 gateway: &str,
534 env: &str,
535 acc_id: u64,
536 market: &str,
537 symbol: &str,
538 format: OutputFormat,
539) -> Result<()> {
540 if acc_id == 0 {
541 bail!("--acc-id 必填");
542 }
543 if symbol.trim().is_empty() {
544 bail!("--symbol 必填");
545 }
546 let market_upper = validate_bond_market(market)?;
547 let req = bond_client_view::DaemonGetBondTradeReminderReq {
548 c2s: bond_client_view::daemon_get_bond_trade_reminder_req::C2s {
549 header: bond_client_view::DaemonBondHeader {
550 acc_id,
551 trd_env: Some(parse_env_int(env)?),
552 market: market_upper,
553 },
554 symbol: symbol.to_string(),
555 },
556 };
557 let body = req.encode_to_vec();
558 let resp: bond_client_view::DaemonGetBondTradeReminderRsp = dispatch(
559 gateway,
560 "futucli-bond-trade-reminder",
561 proto_id::TRD_GET_BOND_TRADE_REMINDER,
562 body,
563 )
564 .await?;
565 if resp.ret_type != 0 {
566 bail!(
567 "GetBondTradeReminder failed: ret_type={} ret_msg={:?}",
568 resp.ret_type,
569 resp.ret_msg
570 );
571 }
572 print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
573 Ok(())
574}
575
576#[cfg(test)]
577mod tests;