Skip to main content

futucli/cmd/
kline.rs

1//! `futucli kline` — 历史 K 线查询
2
3use anyhow::{Result, bail};
4use base64::Engine as _;
5use chrono::NaiveDate;
6use serde::Serialize;
7use tabled::Tabled;
8
9use crate::common::{connect_gateway, parse_symbol};
10use crate::output::OutputFormat;
11use futu_qot::types::{KLType, RehabType};
12
13pub fn parse_kl_type(s: &str) -> Result<KLType> {
14    let Some(t) = KLType::from_str_alias(s) else {
15        let other = s.trim().to_ascii_lowercase();
16        bail!(
17            "unknown kline type {other:?} (day|week|month|quarter|year|1min|3min|5min|10min|15min|30min|60min|120min|180min|240min)"
18        );
19    };
20    Ok(t)
21}
22
23pub fn parse_rehab_type(s: &str) -> Result<RehabType> {
24    let normalized = s.trim().to_ascii_lowercase();
25    Ok(match normalized.as_str() {
26        "" | "none" | "no" | "0" => RehabType::None,
27        "forward" | "qfq" | "1" => RehabType::Forward,
28        "backward" | "hfq" | "2" => RehabType::Backward,
29        other => bail!("unknown rehab type {other:?} (none|forward|backward)"),
30    })
31}
32
33pub fn parse_history_session(value: Option<&str>) -> Result<Option<i32>> {
34    let Some(raw) = value else {
35        return Ok(None);
36    };
37    let trimmed = raw.trim();
38    if trimmed.is_empty() {
39        return Ok(None);
40    }
41    let parsed = match trimmed.to_ascii_uppercase().as_str() {
42        "NONE" => 0,
43        "RTH" => 1,
44        "ETH" | "EXTENDED" => 2,
45        "ALL" | "ALL_DAY" => 3,
46        "OVERNIGHT" | "NIGHT" => 4,
47        other => other
48            .parse::<i32>()
49            .map_err(|_| anyhow::anyhow!("invalid --session {trimmed:?}"))?,
50    };
51    match parsed {
52        0..=3 => Ok(Some(parsed)),
53        4 => bail!(
54            "invalid --session {trimmed:?}; history-kline rejects OVERNIGHT/4, use ETH/2 or ALL/3"
55        ),
56        _ => bail!("invalid --session {trimmed:?}; use NONE|RTH|ETH|ALL or 0|1|2|3"),
57    }
58}
59
60#[derive(Tabled)]
61struct KLineRow {
62    #[tabled(rename = "Time")]
63    time: String,
64    #[tabled(rename = "Open")]
65    open: String,
66    #[tabled(rename = "High")]
67    high: String,
68    #[tabled(rename = "Low")]
69    low: String,
70    #[tabled(rename = "Close")]
71    close: String,
72    #[tabled(rename = "Change%")]
73    change_pct: String,
74    #[tabled(rename = "Volume")]
75    volume: String,
76    #[tabled(rename = "Turnover")]
77    turnover: String,
78}
79
80#[derive(Serialize)]
81struct KLineJson {
82    time: String,
83    timestamp: f64,
84    open: f64,
85    high: f64,
86    low: f64,
87    close: f64,
88    last_close: f64,
89    change_rate: f64,
90    volume: i64,
91    turnover: f64,
92    turnover_rate: f64,
93    pe: f64,
94}
95
96pub async fn run_with_format(
97    gateway: &str,
98    symbol: &str,
99    kl_type_str: &str,
100    count: Option<i32>,
101    rehab_type_str: &str,
102    page_size: Option<i32>,
103    next_req_key: Option<&str>,
104    need_kl_fields_flag: Option<i64>,
105    extended_time: bool,
106    session: Option<&str>,
107    begin: Option<&str>,
108    end: Option<&str>,
109    format: OutputFormat,
110) -> Result<()> {
111    let sec = parse_symbol(symbol)?;
112    let kl_type = parse_kl_type(kl_type_str)?;
113    let rehab_type = parse_rehab_type(rehab_type_str)?;
114    let session = parse_history_session(session)?;
115    let decoded_next_req_key = decode_next_req_key(next_req_key)?;
116
117    // v1.4.96 BUG #011 hotfix (external reviewer double-tester report 2026-04-26):
118    // 默认 end_date 必须 fresh — 不能 cache / 不能 lazy_static. 用
119    // `default_end_date_today_utc()` 显式取 Utc::now() (与 daemon side
120    // history_kline.rs `now_utc = SystemTime::now()` 对齐, 避免用户 Local TZ
121    // 与 market TZ 偏移导致 end_date 字符串日期错跨日).
122    //
123    // 早期 v1.4.94 用 `chrono::Local::now()` 仍是 fresh 调用, 但用户在 HK
124    // 跑 US.AAPL kline 时 Local 日期可能比 US market 日期超前 1 天, 触发
125    // backend 回退 stale K (external reviewer 真机看见的就是这个症状). UTC 跨市场行为最
126    // 一致, 显式给 daemon 让 it 内部按 market TZ 扩展 end_ts.
127    let today_utc = default_end_date_today_utc();
128    let end_date = match end {
129        Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")?,
130        None => today_utc,
131    };
132    let n_for_lookback = count.or(page_size).unwrap_or(100).max(1);
133    let lookback_days = estimate_lookback_days(kl_type, n_for_lookback);
134    let begin_date = match begin {
135        Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")?,
136        None => end_date
137            .checked_sub_days(chrono::Days::new(lookback_days as u64))
138            .unwrap_or(end_date),
139    };
140
141    let (client, _push_rx) = connect_gateway(gateway, "futucli-kline").await?;
142    // v1.4.104 external reviewer P2-007 (P2) fix: backend cmd 1100 max_ack_kl_num 语义 = "返
143    // first N from begin_time" (= oldest N), 不是 "newest N from end_time".
144    // 之前 user 传 count=2 + end=2026-04-29 → backend 返 04-23/24 (oldest 2 of
145    // 4-5 trading days in range), 用户期望 04-28 (newest 2).
146    //
147    // 修法: 客户端**不传** max_ack_kl_num 给 backend (传 None), 让 backend 返
148    // range 内**所有** trading days, 然后 client 端 sort 后 take last N.
149    // trade-off: 多 ~3-5 倍 wire size for small N, 但语义对了 + 也避免 lookback
150    // 估算偏差 (PI=2 时 buffer 3 day 让 backend 返 oldest 2 == 错).
151    let backend_page_mode = page_size.is_some() || decoded_next_req_key.is_some();
152    let result = futu_qot::history_kl::get_history_kl_with_options(
153        &client,
154        &sec,
155        rehab_type,
156        kl_type,
157        &begin_date.format("%Y-%m-%d").to_string(),
158        &end_date.format("%Y-%m-%d").to_string(),
159        if backend_page_mode { page_size } else { None },
160        futu_qot::history_kl::HistoryKLOptions {
161            need_kl_fields_flag,
162            next_req_key: decoded_next_req_key.as_deref(),
163            extended_time: extended_time.then_some(true),
164            session,
165        },
166    )
167    .await?;
168
169    // 客户端 sort by time desc + take first n (= newest n).
170    let mut sorted_kl_list = result.kl_list.clone();
171    sorted_kl_list.sort_by(|a, b| b.time.cmp(&a.time));
172    let display_limit = count
173        .map(|n| n.max(0) as usize)
174        .or_else(|| (!backend_page_mode).then_some(100));
175    if let Some(limit) = display_limit {
176        sorted_kl_list.truncate(limit);
177    }
178    // 用户视觉一般期望按时间升序展示, 重新排回 asc.
179    sorted_kl_list.sort_by(|a, b| a.time.cmp(&b.time));
180
181    let mut rows = Vec::new();
182    let mut jsons = Vec::new();
183    for k in &sorted_kl_list {
184        let sign = if k.change_rate >= 0.0 { "+" } else { "" };
185        rows.push(KLineRow {
186            time: k.time.clone(),
187            open: format!("{:.3}", k.open_price),
188            high: format!("{:.3}", k.high_price),
189            low: format!("{:.3}", k.low_price),
190            close: format!("{:.3}", k.close_price),
191            change_pct: format!("{sign}{:.2}%", k.change_rate),
192            volume: k.volume.to_string(),
193            turnover: format!("{:.0}", k.turnover),
194        });
195        jsons.push(KLineJson {
196            time: k.time.clone(),
197            timestamp: k.timestamp,
198            open: k.open_price,
199            high: k.high_price,
200            low: k.low_price,
201            close: k.close_price,
202            last_close: k.last_close_price,
203            change_rate: k.change_rate,
204            volume: k.volume,
205            turnover: k.turnover,
206            turnover_rate: k.turnover_rate,
207            pe: k.pe,
208        });
209    }
210
211    format.print_rows(&rows, &jsons)?;
212    if let Some(next_key) = result.next_req_key.as_ref().filter(|key| !key.is_empty()) {
213        let encoded = base64::engine::general_purpose::STANDARD.encode(next_key);
214        eprintln!("# next_req_key={encoded}");
215    }
216    Ok(())
217}
218
219fn decode_next_req_key(next_req_key: Option<&str>) -> Result<Option<Vec<u8>>> {
220    let Some(raw) = next_req_key else {
221        return Ok(None);
222    };
223    let trimmed = raw.trim();
224    if trimmed.is_empty() {
225        return Ok(None);
226    }
227    base64::engine::general_purpose::STANDARD
228        .decode(trimmed)
229        .map(Some)
230        .map_err(|e| anyhow::anyhow!("--next-req-key must be base64: {e}"))
231}
232
233/// v1.4.96 BUG #011 helper: fresh UTC date for default `end_time` in CLI kline.
234///
235/// **永远 fresh** — 每次调用走 `chrono::Utc::now()` (与 daemon-side
236/// `history_kline.rs::now_utc = SystemTime::now()` 一致). UTC 跨市场行为最
237/// 一致 — 用户在 HK 跑 US.AAPL 时, UTC 日期不会比 NY 跨日.
238///
239/// 不能用 lazy_static / OnceLock / 任何 cache; external reviewer double-tester 真机抓到
240/// kline 返 yesterday's close 的根症状之一.
241fn default_end_date_today_utc() -> NaiveDate {
242    chrono::Utc::now().date_naive()
243}
244
245/// 给定 kl_type 和要求根数,估算回溯天数。
246///
247/// **external reviewer BUG-006 fix (P2, 2026-04-27)**: 之前 Day padding `+10` 导致 lookback
248/// 比 N 大太多, backend cmd 1100 返 first N 落在 range **最前** 而非 **最近**.
249/// external reviewer 真机 verify: count=5 today=04-27 backend 返 04-09 to 04-15 (oldest 5).
250/// 用户期望 "last N trading days" → daemon 应紧贴 N tradedays 让 backend
251/// first N ≈ newest N.
252///
253/// 修法: lookback_days = ceil(N * 7/5) + 3 (5 天/周转换 + 假日 buffer).
254/// - count=5 → 10 days. Range [today-10, today] = 5-7 trading days. backend
255///   first 5 = newest 5. ✅ (vs 旧版 18 天 = 12-13 trading days, first 5 = oldest 5).
256/// - count=100 → 143 days ≈ 100+ trading days. backend first 100 ≈ newest 100.
257fn estimate_lookback_days(kl_type: KLType, count: i32) -> i32 {
258    let n = count.max(1);
259    match kl_type {
260        // Day: ceil(N * 7/5) + 3 buffer (per external reviewer BUG-006 fix)
261        KLType::Day => ((n as f32 * 7.0 / 5.0).ceil() as i32) + 3,
262        KLType::Week => n * 7 + 7,
263        KLType::Month => n * 31 + 7,
264        KLType::Quarter => n * 92 + 30,
265        KLType::Year => n * 366 + 30,
266        // 分钟线:紧凑 buffer
267        KLType::Min1 => (n as f32 / 240.0).ceil() as i32 + 1,
268        KLType::Min3 => (n as f32 / 80.0).ceil() as i32 + 1,
269        KLType::Min5 => (n as f32 / 48.0).ceil() as i32 + 1,
270        KLType::Min15 => (n as f32 / 16.0).ceil() as i32 + 1,
271        KLType::Min30 => (n as f32 / 8.0).ceil() as i32 + 2,
272        KLType::Min60 => (n as f32 / 4.0).ceil() as i32 + 3,
273        KLType::Min10 => (n as f32 / 24.0).ceil() as i32 + 1,
274        KLType::Min120 => (n as f32 / 2.0).ceil() as i32 + 3,
275        KLType::Min180 => (n as f32 / 2.0).ceil() as i32 + 4,
276        KLType::Min240 => n + 3,
277        _ => 365,
278    }
279}
280
281#[cfg(test)]
282mod tests_v1_4_96_bug_011_kline_default_end;