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 if !trade_market::TRD_MARKET_NON_FUND_INT_VALUES.contains(&(m as i32)) {
57 bail!(
58 "trd market {} ({}) 不在普通 write/calculation 市场 allowlist 内 ({}); \
59 KRX=18 仅通过 batch-close-positions 原子合同放行, 普通 write 命令 fail closed",
60 m as i32,
61 s.trim().to_ascii_uppercase(),
62 trade_market::TRD_MARKET_NON_FUND_PARSE_CHOICES
63 )
64 }
65 Ok(m)
66}
67
68pub fn parse_trd_market(s: &str) -> Result<TrdMarket> {
69 let market = trade_market::parse_trd_market_id(s).ok_or_else(|| {
70 anyhow::anyhow!(
71 "unknown trd market {:?} ({})",
72 s.trim().to_ascii_uppercase(),
73 trade_market::TRD_MARKET_PARSE_CHOICES
74 )
75 })?;
76 trd_sdk_adapter::trd_market_from_id(market).ok_or_else(|| {
77 anyhow::anyhow!("unknown trd market id {market} after core parser accepted it")
78 })
79}
80
81pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
82 let env = trade_parsing::parse_trd_env_id(s).ok_or_else(|| {
83 anyhow::anyhow!(
84 "unknown trd env {:?} ({})",
85 s.trim().to_ascii_lowercase(),
86 trade_parsing::TRD_ENV_PARSE_CHOICES
87 )
88 })?;
89 trd_sdk_adapter::trd_env_from_id(env)
90 .ok_or_else(|| anyhow::anyhow!("unknown trd env id {env} after core parser accepted it"))
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(trade_market::trd_market_label).unwrap_or("?")
179}
180
181pub async fn funds(
182 gateway: &str,
183 env: &str,
184 acc_id: u64,
185 market: Option<&str>,
186 currency: Option<&str>,
187 format: OutputFormat,
188) -> Result<()> {
189 let trd_market = match market {
197 Some(m) => parse_trd_market(m)?,
198 None => TrdMarket::Unknown,
199 };
200 let header = build_header(parse_trd_env(env)?, acc_id, trd_market);
201 let (client, _push_rx) = connect_gateway(gateway, "futucli-funds").await?;
202
203 let currency_int: Option<i32> = match currency {
205 Some(s) => Some(currency::parse_currency_label(s)?),
206 None => None,
207 };
208
209 let f = futu_trd::account::get_funds_with_currency(&client, &header, currency_int).await?;
210
211 let currency_warning = funds_currency_mismatch_warning(currency_int, f.currency);
214 if let Some(ref warn) = currency_warning {
215 eprintln!("⚠️ {warn}");
216 }
217
218 let currency = currency::known_currency_label(f.currency);
220 let cash_summary_label: String = currency
227 .map(|cur| format!("CashSummary({cur})"))
228 .unwrap_or_else(|| "CashSummary".to_string());
229 let mut rows = vec![
230 FundsRow {
231 name: "Power",
232 value: format!("{:.2}", f.power),
233 },
234 FundsRow {
235 name: "TotalAssets",
236 value: format!("{:.2}", f.total_assets),
237 },
238 FundsRow {
239 name: Box::leak(cash_summary_label.into_boxed_str()),
240 value: format!("{:.2}", f.cash),
241 },
242 FundsRow {
243 name: "MarketVal",
244 value: format!("{:.2}", f.market_val),
245 },
246 FundsRow {
247 name: "FrozenCash",
248 value: format!("{:.2}", f.frozen_cash),
249 },
250 FundsRow {
251 name: "DebtCash",
252 value: format!("{:.2}", f.debt_cash),
253 },
254 FundsRow {
255 name: "AvlWithdrawalCash",
256 value: format!("{:.2}", f.avl_withdrawal_cash),
257 },
258 ];
259 rows.push(FundsRow {
261 name: "Currency",
262 value: currency
263 .map(|s| s.to_string())
264 .unwrap_or_else(|| "-".into()),
265 });
266 if let Some(value) = f.crypto_mv {
267 rows.push(FundsRow {
268 name: "CryptoMv",
269 value: format!("{value:.2}"),
270 });
271 }
272 if let Some(value) = f.exposure_level {
273 rows.push(FundsRow {
274 name: "ExposureLevel",
275 value: value.to_string(),
276 });
277 }
278 if let Some(value) = f.exposure_limit {
279 rows.push(FundsRow {
280 name: "ExposureLimit",
281 value: format!("{value:.2}"),
282 });
283 }
284 if let Some(value) = f.used_limit {
285 rows.push(FundsRow {
286 name: "UsedLimit",
287 value: format!("{value:.2}"),
288 });
289 }
290 if let Some(value) = f.remaining_limit {
291 rows.push(FundsRow {
292 name: "RemainingLimit",
293 value: format!("{value:.2}"),
294 });
295 }
296
297 if !f.cash_info_list.is_empty() {
302 rows.push(FundsRow {
303 name: "── CashByCurrency ──",
304 value: String::new(),
305 });
306 for ci in &f.cash_info_list {
307 let cur_str = currency::known_currency_label(ci.currency).unwrap_or("?");
308 rows.push(FundsRow {
309 name: Box::leak(format!(" {} cash", cur_str).into_boxed_str()),
310 value: format!("{:.2}", ci.cash.unwrap_or(0.0)),
311 });
312 let ncp = ci.net_cash_power.unwrap_or(0.0);
313 if ncp.abs() > 0.001 {
314 rows.push(FundsRow {
315 name: Box::leak(format!(" {} netCashPower", cur_str).into_boxed_str()),
316 value: format!("{:.2}", ncp),
317 });
318 }
319 }
320 }
321 if !f.market_info_list.is_empty() {
322 rows.push(FundsRow {
323 name: "── AssetsByMarket ──",
324 value: String::new(),
325 });
326 for mi in &f.market_info_list {
327 let assets = mi.assets.unwrap_or(0.0);
329 if assets.abs() < 0.001 {
330 continue;
331 }
332 let mkt_str = trd_market_int_to_str(mi.trd_market);
333 rows.push(FundsRow {
334 name: Box::leak(format!(" {} assets", mkt_str).into_boxed_str()),
335 value: format!("{:.2}", assets),
336 });
337 }
338 }
339
340 let cash_info_jsons: Vec<CashInfoJson> = f
342 .cash_info_list
343 .iter()
344 .map(|ci| CashInfoJson {
345 currency: currency::known_currency_label(ci.currency).unwrap_or("UNKNOWN"),
346 cash: ci.cash.unwrap_or(0.0),
347 available_balance: ci.available_balance.unwrap_or(0.0),
348 net_cash_power: ci.net_cash_power.unwrap_or(0.0),
349 })
350 .collect();
351 let market_info_jsons: Vec<MarketInfoJson> = f
352 .market_info_list
353 .iter()
354 .map(|mi| MarketInfoJson {
355 market: trd_market_int_to_str(mi.trd_market),
356 assets: mi.assets.unwrap_or(0.0),
357 })
358 .collect();
359 let jsons = vec![FundsJson {
360 power: f.power,
361 total_assets: f.total_assets,
362 cash: f.cash,
363 market_val: f.market_val,
364 frozen_cash: f.frozen_cash,
365 debt_cash: f.debt_cash,
366 avl_withdrawal_cash: f.avl_withdrawal_cash,
367 crypto_mv: f.crypto_mv,
368 exposure_level: f.exposure_level,
369 exposure_limit: f.exposure_limit,
370 used_limit: f.used_limit,
371 remaining_limit: f.remaining_limit,
372 currency,
373 cash_info_list: cash_info_jsons,
374 market_info_list: market_info_jsons,
375 currency_warning,
376 }];
377
378 format.print_rows(&rows, &jsons)?;
379 Ok(())
380}
381
382#[derive(Tabled)]
385struct PosRow {
386 #[tabled(rename = "Code")]
387 code: String,
388 #[tabled(rename = "Name")]
389 name: String,
390 #[tabled(rename = "Qty")]
391 qty: String,
392 #[tabled(rename = "Sellable")]
393 sellable: String,
394 #[tabled(rename = "Cost")]
395 cost: String,
396 #[tabled(rename = "Price")]
397 price: String,
398 #[tabled(rename = "Val")]
399 val: String,
400 #[tabled(rename = "PL")]
401 pl: String,
402 #[tabled(rename = "PL%")]
403 pl_pct: String,
404}
405
406#[derive(Serialize)]
407struct PosJson {
408 position_id: u64,
409 position_side: i32,
410 code: String,
411 name: String,
412 qty: f64,
413 can_sell_qty: f64,
414 price: f64,
415 cost_price: f64,
416 val: f64,
417 pl_val: f64,
418 pl_ratio: f64,
419}
420
421pub async fn positions(
422 gateway: &str,
423 env: &str,
424 acc_id: u64,
425 market: &str,
426 currency_arg: Option<&str>,
427 option_strategy_view: bool,
428 format: OutputFormat,
429) -> Result<()> {
430 let header = build_header(parse_trd_env(env)?, acc_id, parse_trd_market(market)?);
431 let (client, _push_rx) = connect_gateway(gateway, "futucli-position").await?;
432 let currency_int = match currency_arg {
433 Some(s) => Some(currency::parse_currency_label(s)?),
434 None => None,
435 };
436 let list = futu_trd::account::get_position_list_with_options(
437 &client,
438 &header,
439 futu_trd::account::PositionListOptions {
440 filter_market: Some(header.trd_market as i32),
441 currency: currency_int,
442 option_strategy_view: option_strategy_view.then_some(true),
443 },
444 )
445 .await?;
446
447 let rows: Vec<PosRow> = list
448 .iter()
449 .map(|p| PosRow {
450 code: p.code.clone(),
451 name: p.name.clone(),
452 qty: format!("{:.0}", p.qty),
453 sellable: format!("{:.0}", p.can_sell_qty),
454 cost: format!("{:.3}", p.cost_price),
455 price: format!("{:.3}", p.price),
456 val: format!("{:.2}", p.val),
457 pl: format!("{:.2}", p.pl_val),
458 pl_pct: format_pl_ratio_percent(p.pl_ratio),
459 })
460 .collect();
461
462 let jsons: Vec<PosJson> = list
463 .iter()
464 .map(|p| PosJson {
465 position_id: p.position_id,
466 position_side: p.position_side,
467 code: p.code.clone(),
468 name: p.name.clone(),
469 qty: p.qty,
470 can_sell_qty: p.can_sell_qty,
471 price: p.price,
472 cost_price: p.cost_price,
473 val: p.val,
474 pl_val: p.pl_val,
475 pl_ratio: p.pl_ratio,
476 })
477 .collect();
478
479 format.print_rows(&rows, &jsons)?;
480 Ok(())
481}
482
483#[derive(Tabled)]
486struct OrderRow {
487 #[tabled(rename = "OrderID")]
488 order_id: String,
489 #[tabled(rename = "Code")]
490 code: String,
491 #[tabled(rename = "Side")]
492 side: String,
493 #[tabled(rename = "Type")]
494 order_type: i32,
495 #[tabled(rename = "Status")]
496 status: i32,
497 #[tabled(rename = "Qty")]
498 qty: String,
499 #[tabled(rename = "Price")]
500 price: String,
501 #[tabled(rename = "FillQty")]
502 fill_qty: String,
503 #[tabled(rename = "FillAvg")]
504 fill_avg: String,
505 #[tabled(rename = "Updated")]
506 update_time: String,
507}
508
509#[derive(Serialize)]
510struct OrderJson {
511 order_id: u64,
512 order_id_ex: String,
513 trd_side: i32,
514 order_type: i32,
515 order_status: i32,
516 code: String,
517 name: String,
518 qty: f64,
519 price: f64,
520 create_time: String,
521 update_time: String,
522 fill_qty: f64,
523 fill_avg_price: f64,
524 last_err_msg: String,
525}
526
527fn trd_side_label(d: i32) -> &'static str {
528 match d {
529 1 => "BUY",
530 2 => "SELL",
531 3 => "SELL_SHORT",
532 4 => "BUY_BACK",
533 _ => "?",
534 }
535}
536
537pub async fn orders(
538 gateway: &str,
539 env: &str,
540 acc_id: u64,
541 market: &str,
542 format: OutputFormat,
543) -> Result<()> {
544 let header = build_header(
545 parse_trd_env(env)?,
546 acc_id,
547 parse_trd_market_for_write(market)?,
548 );
549 let (client, _push_rx) = connect_gateway(gateway, "futucli-order").await?;
550 let list = futu_trd::query::get_order_list(&client, &header).await?;
551
552 let rows: Vec<OrderRow> = list
553 .iter()
554 .map(|o| OrderRow {
555 order_id: o.order_id.to_string(),
556 code: o.code.clone(),
557 side: trd_side_label(o.trd_side).to_string(),
558 order_type: o.order_type,
559 status: o.order_status,
560 qty: format!("{:.0}", o.qty),
561 price: format!("{:.3}", o.price),
562 fill_qty: format!("{:.0}", o.fill_qty),
563 fill_avg: format!("{:.3}", o.fill_avg_price),
564 update_time: o.update_time.clone(),
565 })
566 .collect();
567
568 let jsons: Vec<OrderJson> = list
569 .iter()
570 .map(|o| OrderJson {
571 order_id: o.order_id,
572 order_id_ex: o.order_id_ex.clone(),
573 trd_side: o.trd_side,
574 order_type: o.order_type,
575 order_status: o.order_status,
576 code: o.code.clone(),
577 name: o.name.clone(),
578 qty: o.qty,
579 price: o.price,
580 create_time: o.create_time.clone(),
581 update_time: o.update_time.clone(),
582 fill_qty: o.fill_qty,
583 fill_avg_price: o.fill_avg_price,
584 last_err_msg: o.last_err_msg.clone(),
585 })
586 .collect();
587
588 format.print_rows(&rows, &jsons)?;
589 Ok(())
590}
591
592#[derive(Tabled)]
595struct DealRow {
596 #[tabled(rename = "FillID")]
597 fill_id: String,
598 #[tabled(rename = "OrderID")]
599 order_id: String,
600 #[tabled(rename = "Code")]
601 code: String,
602 #[tabled(rename = "Side")]
603 side: String,
604 #[tabled(rename = "Qty")]
605 qty: String,
606 #[tabled(rename = "Price")]
607 price: String,
608 #[tabled(rename = "Time")]
609 time: String,
610}
611
612#[derive(Serialize)]
613struct DealJson {
614 fill_id: u64,
615 fill_id_ex: String,
616 order_id: u64,
617 trd_side: i32,
618 code: String,
619 name: String,
620 qty: f64,
621 price: f64,
622 create_time: String,
623}
624
625pub async fn deals(
626 gateway: &str,
627 env: &str,
628 acc_id: u64,
629 market: &str,
630 format: OutputFormat,
631) -> Result<()> {
632 let header = build_header(
633 parse_trd_env(env)?,
634 acc_id,
635 parse_trd_market_for_write(market)?,
636 );
637 let (client, _push_rx) = connect_gateway(gateway, "futucli-deal").await?;
638 let list = futu_trd::query::get_order_fill_list(&client, &header).await?;
639
640 let rows: Vec<DealRow> = list
641 .iter()
642 .map(|f| DealRow {
643 fill_id: f.fill_id.to_string(),
644 order_id: f.order_id.to_string(),
645 code: f.code.clone(),
646 side: trd_side_label(f.trd_side).to_string(),
647 qty: format!("{:.0}", f.qty),
648 price: format!("{:.3}", f.price),
649 time: f.create_time.clone(),
650 })
651 .collect();
652
653 let jsons: Vec<DealJson> = list
654 .iter()
655 .map(|f| DealJson {
656 fill_id: f.fill_id,
657 fill_id_ex: f.fill_id_ex.clone(),
658 order_id: f.order_id,
659 trd_side: f.trd_side,
660 code: f.code.clone(),
661 name: f.name.clone(),
662 qty: f.qty,
663 price: f.price,
664 create_time: f.create_time.clone(),
665 })
666 .collect();
667
668 format.print_rows(&rows, &jsons)?;
669 Ok(())
670}