Skip to main content

tvdata_rs/calendar/
mod.rs

1use bon::Builder;
2use time::{Duration, OffsetDateTime};
3
4use crate::client::TradingViewClient;
5use crate::error::Result;
6use crate::market_data::{InstrumentIdentity, RowDecoder, identity_columns, merge_columns};
7use crate::scanner::fields::{analyst, calendar as calendar_fields};
8use crate::scanner::filter::SortOrder;
9use crate::scanner::{Column, Market, ScanQuery, ScanRow};
10
11const DEFAULT_CALENDAR_LIMIT: usize = 100;
12const CALENDAR_PAGE_SIZE: usize = 200;
13
14fn default_calendar_from() -> OffsetDateTime {
15    OffsetDateTime::now_utc()
16}
17
18fn default_calendar_to() -> OffsetDateTime {
19    OffsetDateTime::now_utc() + Duration::days(30)
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Builder)]
23pub struct CalendarWindowRequest {
24    #[builder(into)]
25    pub market: Market,
26    #[builder(default = default_calendar_from())]
27    pub from: OffsetDateTime,
28    #[builder(default = default_calendar_to())]
29    pub to: OffsetDateTime,
30    #[builder(default = DEFAULT_CALENDAR_LIMIT)]
31    pub limit: usize,
32}
33
34impl CalendarWindowRequest {
35    pub fn new(market: impl Into<Market>, from: OffsetDateTime, to: OffsetDateTime) -> Self {
36        Self::builder().market(market).from(from).to(to).build()
37    }
38
39    pub fn upcoming(market: impl Into<Market>, days: i64) -> Self {
40        let now = OffsetDateTime::now_utc();
41        Self::builder()
42            .market(market)
43            .from(now)
44            .to(now + Duration::days(days.max(0)))
45            .build()
46    }
47
48    pub fn trailing(market: impl Into<Market>, days: i64) -> Self {
49        let now = OffsetDateTime::now_utc();
50        Self::builder()
51            .market(market)
52            .from(now - Duration::days(days.max(0)))
53            .to(now)
54            .build()
55    }
56
57    pub fn limit(mut self, limit: usize) -> Self {
58        self.limit = limit;
59        self
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum DividendDateKind {
65    #[default]
66    ExDate,
67    PaymentDate,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Builder)]
71pub struct DividendCalendarRequest {
72    #[builder(into)]
73    pub market: Market,
74    #[builder(default = default_calendar_from())]
75    pub from: OffsetDateTime,
76    #[builder(default = default_calendar_to())]
77    pub to: OffsetDateTime,
78    #[builder(default = DEFAULT_CALENDAR_LIMIT)]
79    pub limit: usize,
80    #[builder(default)]
81    pub date_kind: DividendDateKind,
82}
83
84impl DividendCalendarRequest {
85    pub fn new(market: impl Into<Market>, from: OffsetDateTime, to: OffsetDateTime) -> Self {
86        Self::builder().market(market).from(from).to(to).build()
87    }
88
89    pub fn upcoming(market: impl Into<Market>, days: i64) -> Self {
90        let now = OffsetDateTime::now_utc();
91        Self::builder()
92            .market(market)
93            .from(now)
94            .to(now + Duration::days(days.max(0)))
95            .build()
96    }
97
98    pub fn trailing(market: impl Into<Market>, days: i64) -> Self {
99        let now = OffsetDateTime::now_utc();
100        Self::builder()
101            .market(market)
102            .from(now - Duration::days(days.max(0)))
103            .to(now)
104            .build()
105    }
106
107    pub fn limit(mut self, limit: usize) -> Self {
108        self.limit = limit;
109        self
110    }
111
112    pub fn date_kind(mut self, date_kind: DividendDateKind) -> Self {
113        self.date_kind = date_kind;
114        self
115    }
116}
117
118#[derive(Debug, Clone, PartialEq)]
119pub struct EarningsCalendarEntry {
120    pub instrument: InstrumentIdentity,
121    pub release_at: OffsetDateTime,
122    pub release_time_code: Option<u32>,
123    pub calendar_date: Option<OffsetDateTime>,
124    pub eps_forecast_next_fq: Option<f64>,
125}
126
127#[derive(Debug, Clone, PartialEq)]
128pub struct DividendCalendarEntry {
129    pub instrument: InstrumentIdentity,
130    pub effective_date: OffsetDateTime,
131    pub ex_date: Option<OffsetDateTime>,
132    pub payment_date: Option<OffsetDateTime>,
133    pub amount: Option<f64>,
134    pub yield_percent: Option<f64>,
135}
136
137#[derive(Debug, Clone, PartialEq)]
138pub struct IpoCalendarEntry {
139    pub instrument: InstrumentIdentity,
140    pub offer_date: OffsetDateTime,
141    pub offer_time_code: Option<u32>,
142    pub announcement_date: Option<OffsetDateTime>,
143    pub offer_price_usd: Option<f64>,
144    pub deal_amount_usd: Option<f64>,
145    pub market_cap_usd: Option<f64>,
146    pub price_range_usd_min: Option<f64>,
147    pub price_range_usd_max: Option<f64>,
148    pub offered_shares: Option<f64>,
149    pub offered_shares_primary: Option<f64>,
150    pub offered_shares_secondary: Option<f64>,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154enum WindowOrdering {
155    Asc,
156    Desc,
157}
158
159struct CalendarScanSpec<T, Decode, Date>
160where
161    Decode: Fn(&RowDecoder, &ScanRow) -> Option<T>,
162    Date: Fn(&T) -> Option<OffsetDateTime>,
163{
164    sort_by: Column,
165    ordering: WindowOrdering,
166    columns: Vec<Column>,
167    decode: Decode,
168    event_date: Date,
169}
170
171impl TradingViewClient {
172    pub(crate) async fn corporate_earnings_calendar(
173        &self,
174        request: &CalendarWindowRequest,
175    ) -> Result<Vec<EarningsCalendarEntry>> {
176        let columns = earnings_calendar_columns();
177        scan_calendar_window(
178            self,
179            &request.market,
180            request.from,
181            request.to,
182            request.limit,
183            CalendarScanSpec {
184                sort_by: analyst::EARNINGS_RELEASE_NEXT_DATE,
185                ordering: WindowOrdering::Asc,
186                columns,
187                decode: decode_earnings_entry,
188                event_date: EarningsCalendarEntry::event_date,
189            },
190        )
191        .await
192    }
193
194    pub(crate) async fn corporate_dividend_calendar(
195        &self,
196        request: &DividendCalendarRequest,
197    ) -> Result<Vec<DividendCalendarEntry>> {
198        let columns = dividend_calendar_columns();
199        let sort_by = match request.date_kind {
200            DividendDateKind::ExDate => calendar_fields::EX_DIVIDEND_DATE_UPCOMING,
201            DividendDateKind::PaymentDate => calendar_fields::PAYMENT_DATE_UPCOMING,
202        };
203
204        scan_calendar_window(
205            self,
206            &request.market,
207            request.from,
208            request.to,
209            request.limit,
210            CalendarScanSpec {
211                sort_by,
212                ordering: WindowOrdering::Asc,
213                columns,
214                decode: |decoder, row| decode_dividend_entry(decoder, row, request.date_kind),
215                event_date: DividendCalendarEntry::event_date,
216            },
217        )
218        .await
219    }
220
221    pub(crate) async fn corporate_ipo_calendar(
222        &self,
223        request: &CalendarWindowRequest,
224    ) -> Result<Vec<IpoCalendarEntry>> {
225        let columns = ipo_calendar_columns();
226        scan_calendar_window(
227            self,
228            &request.market,
229            request.from,
230            request.to,
231            request.limit,
232            CalendarScanSpec {
233                sort_by: calendar_fields::IPO_OFFER_DATE,
234                ordering: WindowOrdering::Desc,
235                columns,
236                decode: decode_ipo_entry,
237                event_date: IpoCalendarEntry::event_date,
238            },
239        )
240        .await
241    }
242}
243
244fn earnings_calendar_columns() -> Vec<Column> {
245    merge_columns([
246        identity_columns(),
247        vec![
248            analyst::EARNINGS_RELEASE_NEXT_DATE,
249            analyst::EARNINGS_RELEASE_NEXT_CALENDAR_DATE,
250            analyst::EARNINGS_RELEASE_NEXT_TIME,
251            analyst::EPS_FORECAST_NEXT_FQ,
252        ],
253    ])
254}
255
256fn dividend_calendar_columns() -> Vec<Column> {
257    merge_columns([
258        identity_columns(),
259        vec![
260            calendar_fields::DIVIDEND_AMOUNT_UPCOMING,
261            calendar_fields::DIVIDEND_YIELD_UPCOMING,
262            calendar_fields::EX_DIVIDEND_DATE_UPCOMING,
263            calendar_fields::PAYMENT_DATE_UPCOMING,
264        ],
265    ])
266}
267
268fn ipo_calendar_columns() -> Vec<Column> {
269    merge_columns([
270        identity_columns(),
271        vec![
272            calendar_fields::IPO_OFFER_DATE,
273            calendar_fields::IPO_OFFER_TIME,
274            calendar_fields::IPO_ANNOUNCEMENT_DATE,
275            calendar_fields::IPO_OFFER_PRICE_USD,
276            calendar_fields::IPO_DEAL_AMOUNT_USD,
277            calendar_fields::IPO_MARKET_CAP_USD,
278            calendar_fields::IPO_PRICE_RANGE_USD_MIN,
279            calendar_fields::IPO_PRICE_RANGE_USD_MAX,
280            calendar_fields::IPO_OFFERED_SHARES,
281            calendar_fields::IPO_OFFERED_SHARES_PRIMARY,
282            calendar_fields::IPO_OFFERED_SHARES_SECONDARY,
283        ],
284    ])
285}
286
287async fn scan_calendar_window<T, Decode, Date>(
288    client: &TradingViewClient,
289    market: &Market,
290    from: OffsetDateTime,
291    to: OffsetDateTime,
292    limit: usize,
293    spec: CalendarScanSpec<T, Decode, Date>,
294) -> Result<Vec<T>>
295where
296    Decode: Fn(&RowDecoder, &ScanRow) -> Option<T>,
297    Date: Fn(&T) -> Option<OffsetDateTime>,
298{
299    if limit == 0 || from > to {
300        return Ok(Vec::new());
301    }
302
303    let CalendarScanSpec {
304        sort_by,
305        ordering,
306        columns,
307        decode,
308        event_date,
309    } = spec;
310    let decoder = RowDecoder::new(&columns);
311    let sort_order = match ordering {
312        WindowOrdering::Asc => SortOrder::Asc,
313        WindowOrdering::Desc => SortOrder::Desc,
314    };
315    let base_query = ScanQuery::new()
316        .market(market.clone())
317        .select(columns)
318        .filter(sort_by.clone().not_empty())
319        .sort(sort_by.sort(sort_order));
320
321    let mut results = Vec::new();
322    let mut offset = 0usize;
323
324    loop {
325        let query = base_query.clone().page(offset, CALENDAR_PAGE_SIZE)?;
326        let response = client.scan(&query).await?;
327        if response.rows.is_empty() {
328            break;
329        }
330
331        let mut reached_window_end = false;
332        for row in &response.rows {
333            let Some(entry) = decode(&decoder, row) else {
334                continue;
335            };
336            let Some(entry_date) = event_date(&entry) else {
337                continue;
338            };
339
340            match ordering {
341                WindowOrdering::Asc => {
342                    if entry_date < from {
343                        continue;
344                    }
345                    if entry_date > to {
346                        reached_window_end = true;
347                        break;
348                    }
349                    results.push(entry);
350                    if results.len() >= limit {
351                        return Ok(results);
352                    }
353                }
354                WindowOrdering::Desc => {
355                    if entry_date > to {
356                        continue;
357                    }
358                    if entry_date < from {
359                        reached_window_end = true;
360                        break;
361                    }
362                    results.push(entry);
363                }
364            }
365        }
366
367        if reached_window_end {
368            break;
369        }
370
371        offset += response.rows.len();
372        if offset >= response.total_count || response.rows.len() < CALENDAR_PAGE_SIZE {
373            break;
374        }
375    }
376
377    if matches!(ordering, WindowOrdering::Desc) {
378        results.reverse();
379        if results.len() > limit {
380            results.truncate(limit);
381        }
382    }
383
384    Ok(results)
385}
386
387fn decode_earnings_entry(decoder: &RowDecoder, row: &ScanRow) -> Option<EarningsCalendarEntry> {
388    Some(EarningsCalendarEntry {
389        instrument: decoder.identity(row),
390        release_at: decoder.timestamp(row, analyst::EARNINGS_RELEASE_NEXT_DATE.as_str())?,
391        release_time_code: decoder.whole_number(row, analyst::EARNINGS_RELEASE_NEXT_TIME.as_str()),
392        calendar_date: decoder
393            .timestamp(row, analyst::EARNINGS_RELEASE_NEXT_CALENDAR_DATE.as_str()),
394        eps_forecast_next_fq: decoder.number(row, analyst::EPS_FORECAST_NEXT_FQ.as_str()),
395    })
396}
397
398fn decode_dividend_entry(
399    decoder: &RowDecoder,
400    row: &ScanRow,
401    date_kind: DividendDateKind,
402) -> Option<DividendCalendarEntry> {
403    let ex_date = decoder.timestamp(row, calendar_fields::EX_DIVIDEND_DATE_UPCOMING.as_str());
404    let payment_date = decoder.timestamp(row, calendar_fields::PAYMENT_DATE_UPCOMING.as_str());
405    let effective_date = match date_kind {
406        DividendDateKind::ExDate => ex_date,
407        DividendDateKind::PaymentDate => payment_date,
408    }?;
409
410    Some(DividendCalendarEntry {
411        instrument: decoder.identity(row),
412        effective_date,
413        ex_date,
414        payment_date,
415        amount: decoder.number(row, calendar_fields::DIVIDEND_AMOUNT_UPCOMING.as_str()),
416        yield_percent: decoder.number(row, calendar_fields::DIVIDEND_YIELD_UPCOMING.as_str()),
417    })
418}
419
420fn decode_ipo_entry(decoder: &RowDecoder, row: &ScanRow) -> Option<IpoCalendarEntry> {
421    Some(IpoCalendarEntry {
422        instrument: decoder.identity(row),
423        offer_date: decoder.timestamp(row, calendar_fields::IPO_OFFER_DATE.as_str())?,
424        offer_time_code: decoder.whole_number(row, calendar_fields::IPO_OFFER_TIME.as_str()),
425        announcement_date: decoder.timestamp(row, calendar_fields::IPO_ANNOUNCEMENT_DATE.as_str()),
426        offer_price_usd: decoder.number(row, calendar_fields::IPO_OFFER_PRICE_USD.as_str()),
427        deal_amount_usd: decoder.number(row, calendar_fields::IPO_DEAL_AMOUNT_USD.as_str()),
428        market_cap_usd: decoder.number(row, calendar_fields::IPO_MARKET_CAP_USD.as_str()),
429        price_range_usd_min: decoder.number(row, calendar_fields::IPO_PRICE_RANGE_USD_MIN.as_str()),
430        price_range_usd_max: decoder.number(row, calendar_fields::IPO_PRICE_RANGE_USD_MAX.as_str()),
431        offered_shares: decoder.number(row, calendar_fields::IPO_OFFERED_SHARES.as_str()),
432        offered_shares_primary: decoder
433            .number(row, calendar_fields::IPO_OFFERED_SHARES_PRIMARY.as_str()),
434        offered_shares_secondary: decoder
435            .number(row, calendar_fields::IPO_OFFERED_SHARES_SECONDARY.as_str()),
436    })
437}
438
439impl EarningsCalendarEntry {
440    fn event_date(&self) -> Option<OffsetDateTime> {
441        Some(self.release_at)
442    }
443}
444
445impl DividendCalendarEntry {
446    fn event_date(&self) -> Option<OffsetDateTime> {
447        Some(self.effective_date)
448    }
449}
450
451impl IpoCalendarEntry {
452    fn event_date(&self) -> Option<OffsetDateTime> {
453        Some(self.offer_date)
454    }
455}
456
457#[cfg(test)]
458mod tests;