1use anyhow::{Result, anyhow, bail};
2use prost::Message;
3use tabled::Tabled;
4
5use crate::common::connect_gateway;
6use crate::output::OutputFormat;
7
8use super::super::{display_opt, display_opt_i32, display_opt_i64};
9
10pub struct FinancialCalendarCommand<'a> {
11 pub begin_date: &'a str,
12 pub end_date: &'a str,
13 pub markets: &'a [String],
14 pub pub_types: &'a [String],
15 pub stock_types: &'a [String],
16 pub count: Option<i32>,
17 pub ranking_type: Option<i32>,
18 pub estimate_types: &'a [String],
19 pub custom_filters: &'a [String],
20 pub watchlist_only: bool,
21 pub positions_only: bool,
22 pub watchlist_stock_ids: &'a [u64],
23 pub holding_stock_ids: &'a [u64],
24}
25
26pub struct FinancialCalendarTargetCommand<'a> {
27 pub stock_ids: &'a [u64],
28 pub markets: &'a [String],
29 pub size: i32,
30 pub start: Option<i32>,
31}
32
33fn validate_yyyymmdd(field: &str, value: &str) -> Result<()> {
34 if value.len() != 8 || !value.bytes().all(|b| b.is_ascii_digit()) {
35 bail!("{field} must be YYYYMMDD, got {value:?}");
36 }
37 Ok(())
38}
39
40fn normalize_enum_token(value: &str) -> String {
41 value.trim().replace(['-', ' '], "_").to_ascii_uppercase()
42}
43
44fn parse_named_i32(value: &str, field: &str, mapping: &[(&str, i32)]) -> Result<i32> {
45 let trimmed = value.trim();
46 if let Ok(parsed) = trimmed.parse::<i32>() {
47 return Ok(parsed);
48 }
49 let normalized = normalize_enum_token(trimmed);
50 mapping
51 .iter()
52 .find_map(|(name, code)| (*name == normalized).then_some(*code))
53 .ok_or_else(|| {
54 anyhow!(
55 "unknown {field} {value:?}; expected integer or one of {}",
56 mapping
57 .iter()
58 .map(|(name, _)| *name)
59 .collect::<Vec<_>>()
60 .join("|")
61 )
62 })
63}
64
65fn parse_named_i32_list(
66 values: &[String],
67 field: &str,
68 mapping: &[(&str, i32)],
69) -> Result<Vec<i32>> {
70 values
71 .iter()
72 .map(|value| parse_named_i32(value, field, mapping))
73 .collect()
74}
75
76fn parse_financial_calendar_market_list(values: &[String]) -> Result<Vec<i32>> {
77 parse_named_i32_list(
79 values,
80 "market",
81 &[
82 ("HK", 1),
83 ("US", 2),
84 ("CN", 3),
85 ("AU", 4),
86 ("SG", 5),
87 ("CA", 6),
88 ("JP", 7),
89 ("MY", 8),
90 ],
91 )
92}
93
94fn parse_financial_calendar_pub_type_list(values: &[String]) -> Result<Vec<i32>> {
95 parse_named_i32_list(
97 values,
98 "pub_type",
99 &[
100 ("REGULAR", 0),
101 ("TODAY", 0),
102 ("SAME_DAY", 0),
103 ("BEFORE", 1),
104 ("PRE", 1),
105 ("PRE_MARKET", 1),
106 ("AFTER", 2),
107 ("POST", 2),
108 ("AFTER_HOURS", 2),
109 ],
110 )
111}
112
113fn parse_financial_calendar_stock_type_list(values: &[String]) -> Result<Vec<i32>> {
114 parse_named_i32_list(
116 values,
117 "stock_type",
118 &[
119 ("WATCHLIST", 1),
120 ("WATCH", 1),
121 ("FAVORITE", 1),
122 ("POSITION", 2),
123 ("POS", 2),
124 ("HOLDING", 2),
125 ("SPECIAL", 3),
126 ("SPECIAL_WATCH", 3),
127 ],
128 )
129}
130
131fn parse_financial_calendar_estimate_type_list(values: &[String]) -> Result<Vec<i32>> {
132 if values.is_empty() {
133 return Ok(vec![1, 2, 3]);
134 }
135 parse_named_i32_list(
137 values,
138 "estimate_type",
139 &[("EPS_GAAP", 1), ("EPS", 1), ("REVENUE", 2), ("EBIT", 3)],
140 )
141}
142
143fn is_financial_calendar_ranking_type(value: i32) -> bool {
144 (1..=7).contains(&value) || value == 100
145}
146
147fn validate_financial_calendar_ranking_type(value: Option<i32>) -> Result<()> {
148 if let Some(value) = value
149 && !is_financial_calendar_ranking_type(value)
150 {
151 bail!("ranking_type must be 1..=7 or 100, got {value}");
152 }
153 Ok(())
154}
155
156fn parse_financial_calendar_custom_filter_type(value: &str) -> Result<i32> {
157 let filter_type = parse_named_i32(
158 value,
159 "custom_filter",
160 &[
161 ("MARKET_CAP", 1),
162 ("OPTION_VOLUME", 2),
163 ("IV", 3),
164 ("IV_RANK", 4),
165 ("IV_PCTL", 5),
166 ("IV_PERCENTILE", 5),
167 ("RT_MARKET_CAP", 6),
168 ],
169 )?;
170 if !(1..=6).contains(&filter_type) {
171 bail!("custom_filter type must be 1..=6, got {filter_type}");
172 }
173 Ok(filter_type)
174}
175
176fn parse_optional_i64_bound(raw: &str, field: &str) -> Result<Option<i64>> {
177 if raw.trim().is_empty() {
178 return Ok(None);
179 }
180 raw.trim()
181 .parse::<i64>()
182 .map(Some)
183 .map_err(|err| anyhow!("{field} must be an integer, got {raw:?}: {err}"))
184}
185
186fn parse_financial_calendar_custom_filter(
187 value: &str,
188) -> Result<futu_backend::proto_internal::financial_calendar::CustomFilter> {
189 let parts: Vec<&str> = value.split(':').collect();
190 if parts.is_empty() || parts.len() > 3 || parts[0].trim().is_empty() {
191 bail!("custom_filter must be TYPE[:MIN[:MAX]], got {value:?}");
192 }
193 let customer_filter_type = parse_financial_calendar_custom_filter_type(parts[0])?;
194 let customer_filter_value_start_at = parts
195 .get(1)
196 .map(|raw| parse_optional_i64_bound(raw, "custom_filter min"))
197 .transpose()?
198 .flatten();
199 let customer_filter_value_end_at = parts
200 .get(2)
201 .map(|raw| parse_optional_i64_bound(raw, "custom_filter max"))
202 .transpose()?
203 .flatten();
204 if let (Some(start), Some(end)) = (customer_filter_value_start_at, customer_filter_value_end_at)
205 && start > end
206 {
207 bail!("custom_filter min must be <= max, got {start}>{end}");
208 }
209 Ok(
210 futu_backend::proto_internal::financial_calendar::CustomFilter {
211 customer_filter_type: Some(customer_filter_type),
212 customer_filter_value_start_at,
213 customer_filter_value_end_at,
214 },
215 )
216}
217
218fn parse_financial_calendar_custom_filter_list(
219 values: &[String],
220) -> Result<Vec<futu_backend::proto_internal::financial_calendar::CustomFilter>> {
221 values
222 .iter()
223 .map(|value| parse_financial_calendar_custom_filter(value))
224 .collect()
225}
226
227fn financial_calendar_new_pc_field_selector()
228-> futu_backend::proto_internal::financial_calendar::NewPcFieldSelector {
229 futu_backend::proto_internal::financial_calendar::NewPcFieldSelector {
230 with_pub_type: Some(true),
231 with_pub_time: Some(true),
232 with_financial_period: Some(true),
233 with_option_volume: Some(true),
234 with_iv: Some(true),
235 with_iv_rank: Some(true),
236 with_iv_pctl: Some(true),
237 with_rt_market_cap: Some(true),
238 with_rt_price: Some(true),
239 with_rt_pe_ttm: Some(true),
240 with_rt_price_last_close: Some(true),
241 }
242}
243
244#[derive(Tabled)]
245struct FinancialCalendarRow {
246 #[tabled(rename = "Date")]
247 date: String,
248 #[tabled(rename = "Total")]
249 total: String,
250 #[tabled(rename = "Stock ID")]
251 stock_id: String,
252 #[tabled(rename = "Pub Type")]
253 pub_type: String,
254 #[tabled(rename = "Published")]
255 is_published: String,
256 #[tabled(rename = "Quarter")]
257 earnings_quarter: String,
258 #[tabled(rename = "Pub Time")]
259 pub_time: String,
260 #[tabled(rename = "Estimate Items")]
261 estimate_items: String,
262}
263
264#[derive(Tabled)]
265struct TargetFinancialCalendarRow {
266 #[tabled(rename = "Pub Date")]
267 pub_date: String,
268 #[tabled(rename = "Stock ID")]
269 stock_id: String,
270 #[tabled(rename = "Pub Type")]
271 pub_type: String,
272 #[tabled(rename = "Quarter")]
273 earnings_quarter: String,
274 #[tabled(rename = "Pub Time")]
275 pub_time: String,
276 #[tabled(rename = "Predict")]
277 is_predict: String,
278}
279
280pub async fn run_financial_calendar(
281 gateway: &str,
282 command: FinancialCalendarCommand<'_>,
283 format: OutputFormat,
284) -> Result<()> {
285 validate_yyyymmdd("begin_date", command.begin_date)?;
286 validate_yyyymmdd("end_date", command.end_date)?;
287 if command.begin_date >= command.end_date {
288 bail!("begin_date must be earlier than end_date");
289 }
290 if let Some(count) = command.count
291 && !(1..=1000).contains(&count)
292 {
293 bail!("count must be in 1..=1000, got {count}");
294 }
295 validate_financial_calendar_ranking_type(command.ranking_type)?;
296
297 let market_list = parse_financial_calendar_market_list(command.markets)?;
298 let pub_type_list = parse_financial_calendar_pub_type_list(command.pub_types)?;
299 let mut stock_type_tokens = command.stock_types.to_vec();
300 if command.watchlist_only {
301 stock_type_tokens.push("watchlist".to_string());
302 }
303 if command.positions_only {
304 stock_type_tokens.push("position".to_string());
305 }
306 let stock_type_list = parse_financial_calendar_stock_type_list(&stock_type_tokens)?;
307 let estimate_type_list = parse_financial_calendar_estimate_type_list(command.estimate_types)?;
308 let custom_filter_list = parse_financial_calendar_custom_filter_list(command.custom_filters)?;
309
310 let (client, _rx) = connect_gateway(gateway, "futucli-financial-calendar").await?;
311 let req =
312 futu_backend::proto_internal::financial_calendar::GetFinancialStatementCalendarViewReq {
313 market_list,
314 pub_type_list,
315 stock_type_list,
316 count: command.count,
317 begin_date: Some(command.begin_date.to_string()),
318 end_date: Some(command.end_date.to_string()),
319 ranking_type: command.ranking_type,
320 estimate_type_list,
321 custom_filter_list,
322 watchlist_stock_ids: command.watchlist_stock_ids.to_vec(),
323 holding_stock_ids: command.holding_stock_ids.to_vec(),
324 field_selector: None,
325 new_pc_field_selector: Some(financial_calendar_new_pc_field_selector()),
326 };
327 let body = req.encode_to_vec();
328 let frame = client
329 .request(
330 futu_core::proto_id::QOT_GET_FINANCIAL_CALENDAR_VIEW_INTERNAL,
331 body,
332 )
333 .await?;
334 let resp =
335 futu_backend::proto_internal::financial_calendar::GetFinancialStatementCalendarViewRsp::decode(
336 frame.body.as_ref(),
337 )
338 .map_err(|e| anyhow!("decode financial_calendar: {e}"))?;
339 if resp.ret_code.unwrap_or(0) != 0 {
340 bail!(
341 "financial_calendar ret_code={:?} message={:?}",
342 resp.ret_code,
343 resp.message
344 );
345 }
346
347 let rows: Vec<FinancialCalendarRow> = resp
348 .financial_statement_group_list
349 .iter()
350 .flat_map(|group| {
351 group
352 .financial_statement_list
353 .iter()
354 .map(move |statement| FinancialCalendarRow {
355 date: display_opt(&group.that_date),
356 total: display_opt_i64(group.total),
357 stock_id: statement
358 .stock_id
359 .map(|v| v.to_string())
360 .unwrap_or_default(),
361 pub_type: display_opt_i32(statement.pub_type),
362 is_published: display_opt_i32(statement.is_published),
363 earnings_quarter: display_opt(&statement.earnings_quarter),
364 pub_time: display_opt_i64(statement.pub_time),
365 estimate_items: statement.estimate_item.len().to_string(),
366 })
367 })
368 .collect();
369 let json = serde_json::json!({
370 "begin_date": command.begin_date,
371 "end_date": command.end_date,
372 "ret_code": resp.ret_code,
373 "message": resp.message,
374 "financial_statement_group_list": resp.financial_statement_group_list,
375 });
376 format.print_rows(&rows, &[json])?;
377 Ok(())
378}
379
380pub async fn run_financial_calendar_target(
381 gateway: &str,
382 command: FinancialCalendarTargetCommand<'_>,
383 format: OutputFormat,
384) -> Result<()> {
385 if command.stock_ids.is_empty() || command.stock_ids.contains(&0) {
386 bail!("stock_id list must be non-empty and positive");
387 }
388 if command.size <= 0 {
389 bail!("size must be positive, got {}", command.size);
390 }
391 if let Some(start) = command.start
392 && start < 0
393 {
394 bail!("start must be non-negative, got {start}");
395 }
396
397 let market_list = parse_financial_calendar_market_list(command.markets)?;
398 let (client, _rx) = connect_gateway(gateway, "futucli-financial-calendar-target").await?;
399 let req =
400 futu_backend::proto_internal::financial_calendar::SearchTargetStockFinancialCalendarReq {
401 stock_id: command.stock_ids.to_vec(),
402 market_list,
403 size: Some(command.size),
404 start: command.start,
405 };
406 let body = req.encode_to_vec();
407 let frame = client
408 .request(
409 futu_core::proto_id::QOT_SEARCH_TARGET_FINANCIAL_CALENDAR,
410 body,
411 )
412 .await?;
413 let resp =
414 futu_backend::proto_internal::financial_calendar::SearchTargetStockFinancialCalendarRsp::decode(
415 frame.body.as_ref(),
416 )
417 .map_err(|e| anyhow!("decode target_financial_calendar: {e}"))?;
418 if resp.code.unwrap_or(0) != 0 {
419 bail!(
420 "target_financial_calendar code={:?} message={:?}",
421 resp.code,
422 resp.message
423 );
424 }
425
426 let rows: Vec<TargetFinancialCalendarRow> = resp
427 .target_stock_financial_calendar_list
428 .iter()
429 .map(|item| {
430 let statement = item.financial_statement_list.as_ref();
431 TargetFinancialCalendarRow {
432 pub_date: display_opt(&item.pub_date),
433 stock_id: statement
434 .and_then(|s| s.stock_id)
435 .map(|value| value.to_string())
436 .unwrap_or_default(),
437 pub_type: statement
438 .and_then(|s| s.pub_type)
439 .map(|value| value.to_string())
440 .unwrap_or_default(),
441 earnings_quarter: statement
442 .and_then(|s| s.earnings_quarter.clone())
443 .unwrap_or_default(),
444 pub_time: statement
445 .and_then(|s| s.pub_time)
446 .map(|value| value.to_string())
447 .unwrap_or_default(),
448 is_predict: statement
449 .and_then(|s| s.is_predict)
450 .map(|value| value.to_string())
451 .unwrap_or_default(),
452 }
453 })
454 .collect();
455 let json = serde_json::json!({
456 "code": resp.code,
457 "message": resp.message,
458 "has_more": resp.has_more,
459 "target_stock_financial_calendar_list": resp.target_stock_financial_calendar_list,
460 });
461 format.print_rows(&rows, &[json])?;
462 Ok(())
463}
464
465#[cfg(test)]
466#[path = "calendar_tests.rs"]
467mod calendar_tests;