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