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