Skip to main content

futucli/cmd/trade_ext/
cash_flow.rs

1use anyhow::{Result, bail};
2use chrono::Datelike;
3use prost::Message as _;
4
5use crate::cmd::account::{parse_trd_env, parse_trd_market};
6use crate::common::connect_gateway;
7use crate::output::OutputFormat;
8
9/// v1.4.30 P2: 账户资金流水(对齐 py-futu-api `get_acc_cash_flow`)
10pub async fn run_acc_cash_flow(
11    gateway: &str,
12    acc_id: u64,
13    clearing_date: &str,
14    env: &str,
15    market: &str,
16    direction: Option<i32>,
17    _format: OutputFormat,
18) -> Result<()> {
19    let entries =
20        fetch_acc_cash_flow_single(gateway, acc_id, clearing_date, env, market, direction).await?;
21    println!(
22        "account {} clearing={}  entries: {}",
23        acc_id,
24        clearing_date,
25        entries.len()
26    );
27    for f in &entries {
28        println!(
29            "  date={:?} type={:?} amount={:?} remark={:?}",
30            f.clearing_date, f.cash_flow_type, f.cash_flow_amount, f.cash_flow_remark
31        );
32    }
33    Ok(())
34}
35
36/// v1.4.32: `--date-range FROM..TO` 便利封装。proto `clearing_date` 一次只接
37/// 一天,这里在 CLI 层循环每个工作日调一次单日版,跳周末。
38///
39/// **服务端限流考虑**:31 天硬上限,防止 bot 脚本无意中打出几百个连续日期
40/// 请求被服务端 rate-limit 掉。
41pub struct AccCashFlowRangeCommand<'a> {
42    pub gateway: &'a str,
43    pub acc_id: u64,
44    pub date_from: chrono::NaiveDate,
45    pub date_to: chrono::NaiveDate,
46    pub env: &'a str,
47    pub market: &'a str,
48    pub direction: Option<i32>,
49}
50
51pub async fn run_acc_cash_flow_range(input: AccCashFlowRangeCommand<'_>) -> Result<()> {
52    let date_from = input.date_from;
53    let date_to = input.date_to;
54    if date_to < date_from {
55        bail!("--date-range: 结束日期 {date_to} 不能早于开始日期 {date_from}");
56    }
57    let span_days = (date_to - date_from).num_days();
58    if span_days > 31 {
59        bail!(
60            "--date-range: 跨度 {} 天超过 31 天上限(防 bot 脚本误调用被服务端限流)",
61            span_days
62        );
63    }
64    let mut day = date_from;
65    let mut total_entries = 0usize;
66    let mut failed_days = Vec::new();
67    let mut success_days = 0usize; // v1.4.102 codex 45 F2: success ≠ total_entries
68    while day <= date_to {
69        // 跳周末(省 proto 请求;真·非交易日服务端会返空,也安全)
70        if matches!(day.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
71            if !acc_cash_flow_advance_day(&mut day, date_to)? {
72                break;
73            }
74            continue;
75        }
76        let date_str = day.format("%Y-%m-%d").to_string();
77        match fetch_acc_cash_flow_single(
78            input.gateway,
79            input.acc_id,
80            &date_str,
81            input.env,
82            input.market,
83            input.direction,
84        )
85        .await
86        {
87            Ok(entries) => {
88                // v1.4.102 codex 45 F2: 成功但空 (假日 / 无流水) 也算 success.
89                // 之前 total_entries==0 + failed_days 非空 = all-failed 误判:
90                // 部分成功 + 那些成功日恰好无流水 → 错认 all-failed.
91                success_days += 1;
92                if !entries.is_empty() {
93                    println!(
94                        "== {} ({}):  entries: {}",
95                        date_str,
96                        day.weekday(),
97                        entries.len()
98                    );
99                    for f in &entries {
100                        println!(
101                            "  type={:?} amount={:?} remark={:?}",
102                            f.cash_flow_type, f.cash_flow_amount, f.cash_flow_remark
103                        );
104                    }
105                    total_entries += entries.len();
106                }
107            }
108            Err(e) => {
109                eprintln!("⚠️  {date_str}: {e}");
110                failed_days.push(date_str);
111                // 继续下一天,不 abort(某天 proto 错不影响其他天)
112            }
113        }
114        if !acc_cash_flow_advance_day(&mut day, date_to)? {
115            break;
116        }
117    }
118    println!(
119        "---\n{date_from}..{date_to}  attempted: {}  success_days: {success_days}  failed days: {}  total entries: {total_entries}",
120        success_days + failed_days.len(),
121        failed_days.len()
122    );
123    if !failed_days.is_empty() {
124        eprintln!("failed days: {failed_days:?}");
125    }
126    // v1.4.102 codex 38 F5 + 45 F2 (P2): all-failed 必须返 Err 让 shell exit non-zero.
127    // 之前 v1.4.102 codex 38 F5 用 `total_entries==0 + failed_days 非空` 判
128    // all-failed, 但 codex 45 F2 指出: 成功的 daily fetch 也可能合法返空 entries
129    // (假日 / 无流水); 部分成功 + 成功日空流水 → 误判 all-failed.
130    // 真正 all-failed 判: success_days == 0 (没有任何一天成功).
131    if success_days == 0 && !failed_days.is_empty() {
132        anyhow::bail!(
133            "acc-cash-flow --date-range {date_from}..{date_to} 所有 {} 天全部失败 \
134             (no successful daily fetch). 失败 days: {:?}. 检查 acc_id / env / market / \
135             daemon 连接, 或单日重试 (`futucli acc-cash-flow --date <day>`).",
136            failed_days.len(),
137            failed_days
138        );
139    }
140    Ok(())
141}
142
143/// 拆出的单日拉取 helper:单日版和 range 版共用。
144async fn fetch_acc_cash_flow_single(
145    gateway: &str,
146    acc_id: u64,
147    clearing_date: &str,
148    env: &str,
149    market: &str,
150    direction: Option<i32>,
151) -> Result<Vec<futu_proto::trd_flow_summary::FlowSummaryInfo>> {
152    let env_p = parse_trd_env(env)?;
153    let market_p = parse_trd_market(market)?;
154    let proto_header = futu_proto::trd_common::TrdHeader {
155        trd_env: env_p as i32,
156        acc_id,
157        trd_market: market_p as i32,
158        jp_acc_type: None,
159    };
160    let req = futu_proto::trd_flow_summary::Request {
161        c2s: futu_proto::trd_flow_summary::C2s {
162            header: proto_header,
163            clearing_date: clearing_date.to_string(),
164            cash_flow_direction: direction,
165            start_create_date: None,
166            end_create_date: None,
167        },
168    };
169    let (client, _rx) = connect_gateway(gateway, "futucli-cash-flow").await?;
170    let body = req.encode_to_vec();
171    let frame = client
172        .request(futu_core::proto_id::TRD_FLOW_SUMMARY, body)
173        .await?;
174    let resp = futu_proto::trd_flow_summary::Response::decode(frame.body.as_ref())
175        .map_err(|e| anyhow::anyhow!("decode: {e}"))?;
176    if resp.ret_type != 0 {
177        bail!(
178            "flow_summary ret_type={} msg={:?}",
179            resp.ret_type,
180            resp.ret_msg
181        );
182    }
183    let s = resp.s2c.ok_or_else(|| anyhow::anyhow!("missing s2c"))?;
184    Ok(s.flow_summary_info_list)
185}
186
187pub(crate) fn acc_cash_flow_advance_day(
188    day: &mut chrono::NaiveDate,
189    date_to: chrono::NaiveDate,
190) -> Result<bool> {
191    if *day >= date_to {
192        return Ok(false);
193    }
194    let next = day
195        .succ_opt()
196        .ok_or_else(|| anyhow::anyhow!("acc-cash-flow date range cannot advance beyond {day}"))?;
197    *day = next;
198    Ok(true)
199}