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) -> 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) -> 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 entry = decode(&decoder, row);
334            let Some(entry_date) = event_date(&entry) else {
335                continue;
336            };
337
338            match ordering {
339                WindowOrdering::Asc => {
340                    if entry_date < from {
341                        continue;
342                    }
343                    if entry_date > to {
344                        reached_window_end = true;
345                        break;
346                    }
347                    results.push(entry);
348                    if results.len() >= limit {
349                        return Ok(results);
350                    }
351                }
352                WindowOrdering::Desc => {
353                    if entry_date > to {
354                        continue;
355                    }
356                    if entry_date < from {
357                        reached_window_end = true;
358                        break;
359                    }
360                    results.push(entry);
361                }
362            }
363        }
364
365        if reached_window_end {
366            break;
367        }
368
369        offset += response.rows.len();
370        if offset >= response.total_count || response.rows.len() < CALENDAR_PAGE_SIZE {
371            break;
372        }
373    }
374
375    if matches!(ordering, WindowOrdering::Desc) {
376        results.reverse();
377        if results.len() > limit {
378            results.truncate(limit);
379        }
380    }
381
382    Ok(results)
383}
384
385fn decode_earnings_entry(decoder: &RowDecoder, row: &ScanRow) -> EarningsCalendarEntry {
386    EarningsCalendarEntry {
387        instrument: decoder.identity(row),
388        release_at: decoder
389            .timestamp(row, analyst::EARNINGS_RELEASE_NEXT_DATE.as_str())
390            .expect("earnings calendar rows must have a release timestamp"),
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) -> 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    .expect("dividend calendar rows must have an effective timestamp");
410
411    DividendCalendarEntry {
412        instrument: decoder.identity(row),
413        effective_date,
414        ex_date,
415        payment_date,
416        amount: decoder.number(row, calendar_fields::DIVIDEND_AMOUNT_UPCOMING.as_str()),
417        yield_percent: decoder.number(row, calendar_fields::DIVIDEND_YIELD_UPCOMING.as_str()),
418    }
419}
420
421fn decode_ipo_entry(decoder: &RowDecoder, row: &ScanRow) -> IpoCalendarEntry {
422    IpoCalendarEntry {
423        instrument: decoder.identity(row),
424        offer_date: decoder
425            .timestamp(row, calendar_fields::IPO_OFFER_DATE.as_str())
426            .expect("IPO calendar rows must have an offer timestamp"),
427        offer_time_code: decoder.whole_number(row, calendar_fields::IPO_OFFER_TIME.as_str()),
428        announcement_date: decoder.timestamp(row, calendar_fields::IPO_ANNOUNCEMENT_DATE.as_str()),
429        offer_price_usd: decoder.number(row, calendar_fields::IPO_OFFER_PRICE_USD.as_str()),
430        deal_amount_usd: decoder.number(row, calendar_fields::IPO_DEAL_AMOUNT_USD.as_str()),
431        market_cap_usd: decoder.number(row, calendar_fields::IPO_MARKET_CAP_USD.as_str()),
432        price_range_usd_min: decoder.number(row, calendar_fields::IPO_PRICE_RANGE_USD_MIN.as_str()),
433        price_range_usd_max: decoder.number(row, calendar_fields::IPO_PRICE_RANGE_USD_MAX.as_str()),
434        offered_shares: decoder.number(row, calendar_fields::IPO_OFFERED_SHARES.as_str()),
435        offered_shares_primary: decoder
436            .number(row, calendar_fields::IPO_OFFERED_SHARES_PRIMARY.as_str()),
437        offered_shares_secondary: decoder
438            .number(row, calendar_fields::IPO_OFFERED_SHARES_SECONDARY.as_str()),
439    }
440}
441
442impl EarningsCalendarEntry {
443    fn event_date(&self) -> Option<OffsetDateTime> {
444        Some(self.release_at)
445    }
446}
447
448impl DividendCalendarEntry {
449    fn event_date(&self) -> Option<OffsetDateTime> {
450        Some(self.effective_date)
451    }
452}
453
454impl IpoCalendarEntry {
455    fn event_date(&self) -> Option<OffsetDateTime> {
456        Some(self.offer_date)
457    }
458}
459
460#[cfg(test)]
461mod tests;