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