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;