Skip to main content

futucli/cmd/
kline.rs

1//! `futucli kline` — 历史 K 线查询
2
3use anyhow::{Result, bail};
4use chrono::NaiveDate;
5use serde::Serialize;
6use tabled::Tabled;
7
8use crate::common::{connect_gateway, parse_symbol};
9use crate::output::OutputFormat;
10use futu_qot::types::{KLType, RehabType};
11
12pub fn parse_kl_type(s: &str) -> Result<KLType> {
13    let t = match s.trim().to_ascii_lowercase().as_str() {
14        "day" => KLType::Day,
15        "week" => KLType::Week,
16        "month" => KLType::Month,
17        "quarter" => KLType::Quarter,
18        "year" => KLType::Year,
19        "1min" => KLType::Min1,
20        "3min" => KLType::Min3,
21        "5min" => KLType::Min5,
22        "15min" => KLType::Min15,
23        "30min" => KLType::Min30,
24        "60min" => KLType::Min60,
25        other => bail!(
26            "unknown kline type {other:?} (day|week|month|quarter|year|1min|3min|5min|15min|30min|60min)"
27        ),
28    };
29    Ok(t)
30}
31
32#[derive(Tabled)]
33struct KLineRow {
34    #[tabled(rename = "Time")]
35    time: String,
36    #[tabled(rename = "Open")]
37    open: String,
38    #[tabled(rename = "High")]
39    high: String,
40    #[tabled(rename = "Low")]
41    low: String,
42    #[tabled(rename = "Close")]
43    close: String,
44    #[tabled(rename = "Change%")]
45    change_pct: String,
46    #[tabled(rename = "Volume")]
47    volume: String,
48    #[tabled(rename = "Turnover")]
49    turnover: String,
50}
51
52#[derive(Serialize)]
53struct KLineJson {
54    time: String,
55    timestamp: f64,
56    open: f64,
57    high: f64,
58    low: f64,
59    close: f64,
60    last_close: f64,
61    change_rate: f64,
62    volume: i64,
63    turnover: f64,
64    turnover_rate: f64,
65    pe: f64,
66}
67
68pub async fn run_with_format(
69    gateway: &str,
70    symbol: &str,
71    kl_type_str: &str,
72    count: Option<i32>,
73    begin: Option<&str>,
74    end: Option<&str>,
75    format: OutputFormat,
76) -> Result<()> {
77    let sec = parse_symbol(symbol)?;
78    let kl_type = parse_kl_type(kl_type_str)?;
79
80    // v1.4.96 BUG #011 hotfix (eli double-tester report 2026-04-26):
81    // 默认 end_date 必须 fresh — 不能 cache / 不能 lazy_static. 用
82    // `default_end_date_today_utc()` 显式取 Utc::now() (与 daemon side
83    // history_kline.rs `now_utc = SystemTime::now()` 对齐, 避免用户 Local TZ
84    // 与 market TZ 偏移导致 end_date 字符串日期错跨日).
85    //
86    // 早期 v1.4.94 用 `chrono::Local::now()` 仍是 fresh 调用, 但用户在 HK
87    // 跑 US.AAPL kline 时 Local 日期可能比 US market 日期超前 1 天, 触发
88    // backend 回退 stale K (eli 真机看见的就是这个症状). UTC 跨市场行为最
89    // 一致, 显式给 daemon 让 it 内部按 market TZ 扩展 end_ts.
90    let today_utc = default_end_date_today_utc();
91    let end_date = match end {
92        Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")?,
93        None => today_utc,
94    };
95    let n = count.unwrap_or(100);
96    let lookback_days = estimate_lookback_days(kl_type, n);
97    let begin_date = match begin {
98        Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")?,
99        None => end_date
100            .checked_sub_days(chrono::Days::new(lookback_days as u64))
101            .unwrap_or(end_date),
102    };
103
104    let (client, _push_rx) = connect_gateway(gateway, "futucli-kline").await?;
105    // v1.4.104 eli P2-007 (P2) fix: backend cmd 1100 max_ack_kl_num 语义 = "返
106    // first N from begin_time" (= oldest N), 不是 "newest N from end_time".
107    // 之前 user 传 count=2 + end=2026-04-29 → backend 返 04-23/24 (oldest 2 of
108    // 4-5 trading days in range), 用户期望 04-28 (newest 2).
109    //
110    // 修法: 客户端**不传** max_ack_kl_num 给 backend (传 None), 让 backend 返
111    // range 内**所有** trading days, 然后 client 端 sort 后 take last N.
112    // trade-off: 多 ~3-5 倍 wire size for small N, 但语义对了 + 也避免 lookback
113    // 估算偏差 (PI=2 时 buffer 3 day 让 backend 返 oldest 2 == 错).
114    let result = futu_qot::history_kl::get_history_kl(
115        &client,
116        &sec,
117        RehabType::None,
118        kl_type,
119        &begin_date.format("%Y-%m-%d").to_string(),
120        &end_date.format("%Y-%m-%d").to_string(),
121        None, // 不传 max_num, 客户端 sort + take 后置 cap
122    )
123    .await?;
124
125    // 客户端 sort by time desc + take first n (= newest n).
126    let mut sorted_kl_list = result.kl_list.clone();
127    sorted_kl_list.sort_by(|a, b| b.time.cmp(&a.time));
128    sorted_kl_list.truncate(n as usize);
129    // 用户视觉一般期望按时间升序展示, 重新排回 asc.
130    sorted_kl_list.sort_by(|a, b| a.time.cmp(&b.time));
131
132    let mut rows = Vec::new();
133    let mut jsons = Vec::new();
134    for k in &sorted_kl_list {
135        let sign = if k.change_rate >= 0.0 { "+" } else { "" };
136        rows.push(KLineRow {
137            time: k.time.clone(),
138            open: format!("{:.3}", k.open_price),
139            high: format!("{:.3}", k.high_price),
140            low: format!("{:.3}", k.low_price),
141            close: format!("{:.3}", k.close_price),
142            change_pct: format!("{sign}{:.2}%", k.change_rate),
143            volume: k.volume.to_string(),
144            turnover: format!("{:.0}", k.turnover),
145        });
146        jsons.push(KLineJson {
147            time: k.time.clone(),
148            timestamp: k.timestamp,
149            open: k.open_price,
150            high: k.high_price,
151            low: k.low_price,
152            close: k.close_price,
153            last_close: k.last_close_price,
154            change_rate: k.change_rate,
155            volume: k.volume,
156            turnover: k.turnover,
157            turnover_rate: k.turnover_rate,
158            pe: k.pe,
159        });
160    }
161
162    format.print_rows(&rows, &jsons)?;
163    Ok(())
164}
165
166/// v1.4.96 BUG #011 helper: fresh UTC date for default `end_time` in CLI kline.
167///
168/// **永远 fresh** — 每次调用走 `chrono::Utc::now()` (与 daemon-side
169/// `history_kline.rs::now_utc = SystemTime::now()` 一致). UTC 跨市场行为最
170/// 一致 — 用户在 HK 跑 US.AAPL 时, UTC 日期不会比 NY 跨日.
171///
172/// 不能用 lazy_static / OnceLock / 任何 cache; eli double-tester 真机抓到
173/// kline 返 yesterday's close 的根症状之一.
174fn default_end_date_today_utc() -> NaiveDate {
175    chrono::Utc::now().date_naive()
176}
177
178/// 给定 kl_type 和要求根数,估算回溯天数。
179///
180/// **eli BUG-006 fix (P2, 2026-04-27)**: 之前 Day padding `+10` 导致 lookback
181/// 比 N 大太多, backend cmd 1100 返 first N 落在 range **最前** 而非 **最近**.
182/// eli 真机 verify: count=5 today=04-27 backend 返 04-09 to 04-15 (oldest 5).
183/// 用户期望 "last N trading days" → daemon 应紧贴 N tradedays 让 backend
184/// first N ≈ newest N.
185///
186/// 修法: lookback_days = ceil(N * 7/5) + 3 (5 天/周转换 + 假日 buffer).
187/// - count=5 → 10 days. Range [today-10, today] = 5-7 trading days. backend
188///   first 5 = newest 5. ✅ (vs 旧版 18 天 = 12-13 trading days, first 5 = oldest 5).
189/// - count=100 → 143 days ≈ 100+ trading days. backend first 100 ≈ newest 100.
190fn estimate_lookback_days(kl_type: KLType, count: i32) -> i32 {
191    let n = count.max(1);
192    match kl_type {
193        // Day: ceil(N * 7/5) + 3 buffer (per eli BUG-006 fix)
194        KLType::Day => ((n as f32 * 7.0 / 5.0).ceil() as i32) + 3,
195        KLType::Week => n * 7 + 7,
196        KLType::Month => n * 31 + 7,
197        KLType::Quarter => n * 92 + 30,
198        KLType::Year => n * 366 + 30,
199        // 分钟线:紧凑 buffer
200        KLType::Min1 => (n as f32 / 240.0).ceil() as i32 + 1,
201        KLType::Min3 => (n as f32 / 80.0).ceil() as i32 + 1,
202        KLType::Min5 => (n as f32 / 48.0).ceil() as i32 + 1,
203        KLType::Min15 => (n as f32 / 16.0).ceil() as i32 + 1,
204        KLType::Min30 => (n as f32 / 8.0).ceil() as i32 + 2,
205        KLType::Min60 => (n as f32 / 4.0).ceil() as i32 + 3,
206        _ => 365,
207    }
208}
209
210#[cfg(test)]
211mod tests_v1_4_96_bug_011_kline_default_end;