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