1use anyhow::{Result, bail};
7use serde::Serialize;
8use tabled::Tabled;
9
10use crate::common::connect_gateway;
11use crate::output::OutputFormat;
12use crate::trd_sdk_adapter;
13use futu_core::trade_currency::funds_currency_mismatch_warning;
14use futu_core::{trade_market, trade_parsing};
15use futu_trd::{
16 currency,
17 types::{TrdEnv, TrdHeader, TrdMarket},
18};
19
20mod list;
21#[cfg(test)]
22mod tests;
23
24#[cfg(test)]
25pub(crate) use list::read_private_account_id_file;
26#[cfg(test)]
27use list::{
28 AccJson, account_matches_sdk_filter, app_visible_card_num_resolution,
29 parse_account_market_filter, parse_account_security_firm_filter,
30};
31pub use list::{list_accounts, resolve_account_locator};
32
33pub fn parse_trd_market_for_write(s: &str) -> Result<TrdMarket> {
42 let m = parse_trd_market(s)?;
43 if let Some(label) = trade_market::canonical_fund_trd_market_label(m as i32) {
44 bail!(
45 "trd market {label} 仅支持 view-only read commands \
46 (positions/funds/cash-log/history-orders/history-fills); \
47 write commands (place-order/modify-order/cancel-order/cancel-all-order) \
48 用对应主市场, daemon 自动按持仓 broker 路由. v1.4.102 audit 27 F7 fix"
49 )
50 }
51 Ok(m)
52}
53
54pub fn parse_trd_market(s: &str) -> Result<TrdMarket> {
55 let market = trade_market::parse_trd_market_id(s).ok_or_else(|| {
56 anyhow::anyhow!(
57 "unknown trd market {:?} ({})",
58 s.trim().to_ascii_uppercase(),
59 trade_market::TRD_MARKET_PARSE_CHOICES
60 )
61 })?;
62 trd_sdk_adapter::trd_market_from_id(market).ok_or_else(|| {
63 anyhow::anyhow!("unknown trd market id {market} after core parser accepted it")
64 })
65}
66
67pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
68 let env = trade_parsing::parse_trd_env_id(s).ok_or_else(|| {
69 anyhow::anyhow!(
70 "unknown trd env {:?} ({})",
71 s.trim().to_ascii_lowercase(),
72 trade_parsing::TRD_ENV_PARSE_CHOICES
73 )
74 })?;
75 trd_sdk_adapter::trd_env_from_id(env)
76 .ok_or_else(|| anyhow::anyhow!("unknown trd env id {env} after core parser accepted it"))
77}
78
79fn build_header(env: TrdEnv, acc_id: u64, market: TrdMarket) -> TrdHeader {
80 TrdHeader {
81 trd_env: env,
82 acc_id,
83 trd_market: market,
84 jp_acc_type: None,
85 }
86}
87
88fn format_pl_ratio_percent(ratio_value: f64) -> String {
89 let percent = ratio_value * 100.0;
92 if percent > 0.0 {
93 format!("+{percent:.2}%")
94 } else {
95 format!("{percent:.2}%")
96 }
97}
98
99#[derive(Tabled)]
102struct FundsRow {
103 #[tabled(rename = "Metric")]
104 name: &'static str,
105 #[tabled(rename = "Value")]
106 value: String,
107}
108
109#[derive(Serialize)]
110struct FundsJson {
111 power: f64,
112 total_assets: f64,
113 cash: f64,
114 market_val: f64,
115 frozen_cash: f64,
116 debt_cash: f64,
117 avl_withdrawal_cash: f64,
118 #[serde(skip_serializing_if = "Option::is_none")]
119 crypto_mv: Option<f64>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 exposure_level: Option<i32>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 exposure_limit: Option<f64>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 used_limit: Option<f64>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 remaining_limit: Option<f64>,
128 #[serde(skip_serializing_if = "Option::is_none")]
132 currency: Option<&'static str>,
133 #[serde(skip_serializing_if = "Vec::is_empty")]
136 cash_info_list: Vec<CashInfoJson>,
137 #[serde(skip_serializing_if = "Vec::is_empty")]
140 market_info_list: Vec<MarketInfoJson>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 currency_warning: Option<String>,
144}
145
146#[derive(Serialize)]
148struct CashInfoJson {
149 currency: &'static str,
150 cash: f64,
151 available_balance: f64,
152 net_cash_power: f64,
153}
154
155#[derive(Serialize)]
157struct MarketInfoJson {
158 market: &'static str,
159 assets: f64,
160}
161
162fn trd_market_int_to_str(m: Option<i32>) -> &'static str {
164 m.and_then(trade_market::trd_market_label).unwrap_or("?")
165}
166
167pub async fn funds(
168 gateway: &str,
169 env: &str,
170 acc_id: u64,
171 market: Option<&str>,
172 currency: Option<&str>,
173 format: OutputFormat,
174) -> Result<()> {
175 let trd_market = match market {
183 Some(m) => parse_trd_market(m)?,
184 None => TrdMarket::Unknown,
185 };
186 let header = build_header(parse_trd_env(env)?, acc_id, trd_market);
187 let (client, _push_rx) = connect_gateway(gateway, "futucli-funds").await?;
188
189 let currency_int: Option<i32> = match currency {
191 Some(s) => Some(currency::parse_currency_label(s)?),
192 None => None,
193 };
194
195 let f = futu_trd::account::get_funds_with_currency(&client, &header, currency_int).await?;
196
197 let currency_warning = funds_currency_mismatch_warning(currency_int, f.currency);
200 if let Some(ref warn) = currency_warning {
201 eprintln!("⚠️ {warn}");
202 }
203
204 let currency = currency::known_currency_label(f.currency);
206 let cash_summary_label: String = currency
213 .map(|cur| format!("CashSummary({cur})"))
214 .unwrap_or_else(|| "CashSummary".to_string());
215 let mut rows = vec![
216 FundsRow {
217 name: "Power",
218 value: format!("{:.2}", f.power),
219 },
220 FundsRow {
221 name: "TotalAssets",
222 value: format!("{:.2}", f.total_assets),
223 },
224 FundsRow {
225 name: Box::leak(cash_summary_label.into_boxed_str()),
226 value: format!("{:.2}", f.cash),
227 },
228 FundsRow {
229 name: "MarketVal",
230 value: format!("{:.2}", f.market_val),
231 },
232 FundsRow {
233 name: "FrozenCash",
234 value: format!("{:.2}", f.frozen_cash),
235 },
236 FundsRow {
237 name: "DebtCash",
238 value: format!("{:.2}", f.debt_cash),
239 },
240 FundsRow {
241 name: "AvlWithdrawalCash",
242 value: format!("{:.2}", f.avl_withdrawal_cash),
243 },
244 ];
245 rows.push(FundsRow {
247 name: "Currency",
248 value: currency
249 .map(|s| s.to_string())
250 .unwrap_or_else(|| "-".into()),
251 });
252 if let Some(value) = f.crypto_mv {
253 rows.push(FundsRow {
254 name: "CryptoMv",
255 value: format!("{value:.2}"),
256 });
257 }
258 if let Some(value) = f.exposure_level {
259 rows.push(FundsRow {
260 name: "ExposureLevel",
261 value: value.to_string(),
262 });
263 }
264 if let Some(value) = f.exposure_limit {
265 rows.push(FundsRow {
266 name: "ExposureLimit",
267 value: format!("{value:.2}"),
268 });
269 }
270 if let Some(value) = f.used_limit {
271 rows.push(FundsRow {
272 name: "UsedLimit",
273 value: format!("{value:.2}"),
274 });
275 }
276 if let Some(value) = f.remaining_limit {
277 rows.push(FundsRow {
278 name: "RemainingLimit",
279 value: format!("{value:.2}"),
280 });
281 }
282
283 if !f.cash_info_list.is_empty() {
288 rows.push(FundsRow {
289 name: "── CashByCurrency ──",
290 value: String::new(),
291 });
292 for ci in &f.cash_info_list {
293 let cur_str = currency::known_currency_label(ci.currency).unwrap_or("?");
294 rows.push(FundsRow {
295 name: Box::leak(format!(" {} cash", cur_str).into_boxed_str()),
296 value: format!("{:.2}", ci.cash.unwrap_or(0.0)),
297 });
298 let ncp = ci.net_cash_power.unwrap_or(0.0);
299 if ncp.abs() > 0.001 {
300 rows.push(FundsRow {
301 name: Box::leak(format!(" {} netCashPower", cur_str).into_boxed_str()),
302 value: format!("{:.2}", ncp),
303 });
304 }
305 }
306 }
307 if !f.market_info_list.is_empty() {
308 rows.push(FundsRow {
309 name: "── AssetsByMarket ──",
310 value: String::new(),
311 });
312 for mi in &f.market_info_list {
313 let assets = mi.assets.unwrap_or(0.0);
315 if assets.abs() < 0.001 {
316 continue;
317 }
318 let mkt_str = trd_market_int_to_str(mi.trd_market);
319 rows.push(FundsRow {
320 name: Box::leak(format!(" {} assets", mkt_str).into_boxed_str()),
321 value: format!("{:.2}", assets),
322 });
323 }
324 }
325
326 let cash_info_jsons: Vec<CashInfoJson> = f
328 .cash_info_list
329 .iter()
330 .map(|ci| CashInfoJson {
331 currency: currency::known_currency_label(ci.currency).unwrap_or("UNKNOWN"),
332 cash: ci.cash.unwrap_or(0.0),
333 available_balance: ci.available_balance.unwrap_or(0.0),
334 net_cash_power: ci.net_cash_power.unwrap_or(0.0),
335 })
336 .collect();
337 let market_info_jsons: Vec<MarketInfoJson> = f
338 .market_info_list
339 .iter()
340 .map(|mi| MarketInfoJson {
341 market: trd_market_int_to_str(mi.trd_market),
342 assets: mi.assets.unwrap_or(0.0),
343 })
344 .collect();
345 let jsons = vec![FundsJson {
346 power: f.power,
347 total_assets: f.total_assets,
348 cash: f.cash,
349 market_val: f.market_val,
350 frozen_cash: f.frozen_cash,
351 debt_cash: f.debt_cash,
352 avl_withdrawal_cash: f.avl_withdrawal_cash,
353 crypto_mv: f.crypto_mv,
354 exposure_level: f.exposure_level,
355 exposure_limit: f.exposure_limit,
356 used_limit: f.used_limit,
357 remaining_limit: f.remaining_limit,
358 currency,
359 cash_info_list: cash_info_jsons,
360 market_info_list: market_info_jsons,
361 currency_warning,
362 }];
363
364 format.print_rows(&rows, &jsons)?;
365 Ok(())
366}
367
368#[derive(Tabled)]
371struct PosRow {
372 #[tabled(rename = "Code")]
373 code: String,
374 #[tabled(rename = "Name")]
375 name: String,
376 #[tabled(rename = "Qty")]
377 qty: String,
378 #[tabled(rename = "Sellable")]
379 sellable: String,
380 #[tabled(rename = "Cost")]
381 cost: String,
382 #[tabled(rename = "Price")]
383 price: String,
384 #[tabled(rename = "Val")]
385 val: String,
386 #[tabled(rename = "PL")]
387 pl: String,
388 #[tabled(rename = "PL%")]
389 pl_pct: String,
390}
391
392#[derive(Serialize)]
393struct PosJson {
394 position_id: u64,
395 position_side: i32,
396 code: String,
397 name: String,
398 qty: f64,
399 can_sell_qty: f64,
400 price: f64,
401 cost_price: f64,
402 val: f64,
403 pl_val: f64,
404 pl_ratio: f64,
405}
406
407pub async fn positions(
408 gateway: &str,
409 env: &str,
410 acc_id: u64,
411 market: &str,
412 currency_arg: Option<&str>,
413 option_strategy_view: bool,
414 format: OutputFormat,
415) -> Result<()> {
416 let header = build_header(parse_trd_env(env)?, acc_id, parse_trd_market(market)?);
417 let (client, _push_rx) = connect_gateway(gateway, "futucli-position").await?;
418 let currency_int = match currency_arg {
419 Some(s) => Some(currency::parse_currency_label(s)?),
420 None => None,
421 };
422 let list = futu_trd::account::get_position_list_with_options(
423 &client,
424 &header,
425 futu_trd::account::PositionListOptions {
426 filter_market: Some(header.trd_market as i32),
427 currency: currency_int,
428 option_strategy_view: option_strategy_view.then_some(true),
429 },
430 )
431 .await?;
432
433 let rows: Vec<PosRow> = list
434 .iter()
435 .map(|p| PosRow {
436 code: p.code.clone(),
437 name: p.name.clone(),
438 qty: format!("{:.0}", p.qty),
439 sellable: format!("{:.0}", p.can_sell_qty),
440 cost: format!("{:.3}", p.cost_price),
441 price: format!("{:.3}", p.price),
442 val: format!("{:.2}", p.val),
443 pl: format!("{:.2}", p.pl_val),
444 pl_pct: format_pl_ratio_percent(p.pl_ratio),
445 })
446 .collect();
447
448 let jsons: Vec<PosJson> = list
449 .iter()
450 .map(|p| PosJson {
451 position_id: p.position_id,
452 position_side: p.position_side,
453 code: p.code.clone(),
454 name: p.name.clone(),
455 qty: p.qty,
456 can_sell_qty: p.can_sell_qty,
457 price: p.price,
458 cost_price: p.cost_price,
459 val: p.val,
460 pl_val: p.pl_val,
461 pl_ratio: p.pl_ratio,
462 })
463 .collect();
464
465 format.print_rows(&rows, &jsons)?;
466 Ok(())
467}
468
469#[derive(Tabled)]
472struct OrderRow {
473 #[tabled(rename = "OrderID")]
474 order_id: String,
475 #[tabled(rename = "Code")]
476 code: String,
477 #[tabled(rename = "Side")]
478 side: String,
479 #[tabled(rename = "Type")]
480 order_type: i32,
481 #[tabled(rename = "Status")]
482 status: i32,
483 #[tabled(rename = "Qty")]
484 qty: String,
485 #[tabled(rename = "Price")]
486 price: String,
487 #[tabled(rename = "FillQty")]
488 fill_qty: String,
489 #[tabled(rename = "FillAvg")]
490 fill_avg: String,
491 #[tabled(rename = "Updated")]
492 update_time: String,
493}
494
495#[derive(Serialize)]
496struct OrderJson {
497 order_id: u64,
498 order_id_ex: String,
499 trd_side: i32,
500 order_type: i32,
501 order_status: i32,
502 code: String,
503 name: String,
504 qty: f64,
505 price: f64,
506 create_time: String,
507 update_time: String,
508 fill_qty: f64,
509 fill_avg_price: f64,
510 last_err_msg: String,
511}
512
513fn trd_side_label(d: i32) -> &'static str {
514 match d {
515 1 => "BUY",
516 2 => "SELL",
517 3 => "SELL_SHORT",
518 4 => "BUY_BACK",
519 _ => "?",
520 }
521}
522
523pub async fn orders(
524 gateway: &str,
525 env: &str,
526 acc_id: u64,
527 market: &str,
528 format: OutputFormat,
529) -> Result<()> {
530 let header = build_header(
531 parse_trd_env(env)?,
532 acc_id,
533 parse_trd_market_for_write(market)?,
534 );
535 let (client, _push_rx) = connect_gateway(gateway, "futucli-order").await?;
536 let list = futu_trd::query::get_order_list(&client, &header).await?;
537
538 let rows: Vec<OrderRow> = list
539 .iter()
540 .map(|o| OrderRow {
541 order_id: o.order_id.to_string(),
542 code: o.code.clone(),
543 side: trd_side_label(o.trd_side).to_string(),
544 order_type: o.order_type,
545 status: o.order_status,
546 qty: format!("{:.0}", o.qty),
547 price: format!("{:.3}", o.price),
548 fill_qty: format!("{:.0}", o.fill_qty),
549 fill_avg: format!("{:.3}", o.fill_avg_price),
550 update_time: o.update_time.clone(),
551 })
552 .collect();
553
554 let jsons: Vec<OrderJson> = list
555 .iter()
556 .map(|o| OrderJson {
557 order_id: o.order_id,
558 order_id_ex: o.order_id_ex.clone(),
559 trd_side: o.trd_side,
560 order_type: o.order_type,
561 order_status: o.order_status,
562 code: o.code.clone(),
563 name: o.name.clone(),
564 qty: o.qty,
565 price: o.price,
566 create_time: o.create_time.clone(),
567 update_time: o.update_time.clone(),
568 fill_qty: o.fill_qty,
569 fill_avg_price: o.fill_avg_price,
570 last_err_msg: o.last_err_msg.clone(),
571 })
572 .collect();
573
574 format.print_rows(&rows, &jsons)?;
575 Ok(())
576}
577
578#[derive(Tabled)]
581struct DealRow {
582 #[tabled(rename = "FillID")]
583 fill_id: String,
584 #[tabled(rename = "OrderID")]
585 order_id: String,
586 #[tabled(rename = "Code")]
587 code: String,
588 #[tabled(rename = "Side")]
589 side: String,
590 #[tabled(rename = "Qty")]
591 qty: String,
592 #[tabled(rename = "Price")]
593 price: String,
594 #[tabled(rename = "Time")]
595 time: String,
596}
597
598#[derive(Serialize)]
599struct DealJson {
600 fill_id: u64,
601 fill_id_ex: String,
602 order_id: u64,
603 trd_side: i32,
604 code: String,
605 name: String,
606 qty: f64,
607 price: f64,
608 create_time: String,
609}
610
611pub async fn deals(
612 gateway: &str,
613 env: &str,
614 acc_id: u64,
615 market: &str,
616 format: OutputFormat,
617) -> Result<()> {
618 let header = build_header(
619 parse_trd_env(env)?,
620 acc_id,
621 parse_trd_market_for_write(market)?,
622 );
623 let (client, _push_rx) = connect_gateway(gateway, "futucli-deal").await?;
624 let list = futu_trd::query::get_order_fill_list(&client, &header).await?;
625
626 let rows: Vec<DealRow> = list
627 .iter()
628 .map(|f| DealRow {
629 fill_id: f.fill_id.to_string(),
630 order_id: f.order_id.to_string(),
631 code: f.code.clone(),
632 side: trd_side_label(f.trd_side).to_string(),
633 qty: format!("{:.0}", f.qty),
634 price: format!("{:.3}", f.price),
635 time: f.create_time.clone(),
636 })
637 .collect();
638
639 let jsons: Vec<DealJson> = list
640 .iter()
641 .map(|f| DealJson {
642 fill_id: f.fill_id,
643 fill_id_ex: f.fill_id_ex.clone(),
644 order_id: f.order_id,
645 trd_side: f.trd_side,
646 code: f.code.clone(),
647 name: f.name.clone(),
648 qty: f.qty,
649 price: f.price,
650 create_time: f.create_time.clone(),
651 })
652 .collect();
653
654 format.print_rows(&rows, &jsons)?;
655 Ok(())
656}