Skip to main content

futucli/cmd/analysis/company/financials/
calendar.rs

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    // Values come from mobile/moomoo financial_calendar.Market.
78    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    // Values come from mobile/moomoo financial_calendar.PubType.
96    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    // Values come from mobile/moomoo financial_calendar.StockType.
115    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    // Values come from mobile/moomoo financial_calendar.EstimateType.
136    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;