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 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 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 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 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 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
212fn default_end_date_today_utc() -> NaiveDate {
221 chrono::Utc::now().date_naive()
222}
223
224fn estimate_lookback_days(kl_type: KLType, count: i32) -> i32 {
237 let n = count.max(1);
238 match kl_type {
239 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 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;