1use 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 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 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 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 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
233fn default_end_date_today_utc() -> NaiveDate {
242 chrono::Utc::now().date_naive()
243}
244
245fn estimate_lookback_days(kl_type: KLType, count: i32) -> i32 {
258 let n = count.max(1);
259 match kl_type {
260 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 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;