File Coverage

blib/lib/Finance/Calendar.pm
Criterion Covered Total %
statement 316 328 96.3
branch 129 160 80.6
condition 49 87 56.3
subroutine 43 43 100.0
pod 24 24 100.0
total 561 642 87.3


line stmt bran cond sub pod time code
1             package Finance::Calendar;
2              
3             =head1 NAME
4              
5             Finance::Calendar - represents the trading calendar.
6              
7             =head1 SYNOPSIS
8              
9             use Finance::Calendar;
10             use Date::Utility;
11              
12             my $calendar = {
13             holidays => {
14             "25-Dec-2013" => {
15             "Christmas Day" => [qw(FOREX METAL)],
16             },
17             "1-Jan-2014" => {
18             "New Year's Day" => [qw( FOREX METAL)],
19             },
20             "1-Apr-2013" => {
21             "Easter Monday" => [qw( USD)],
22             },
23             },
24             early_closes => {
25             '24-Dec-2009' => {
26             '16:30' => ['HKSE'],
27             },
28             '22-Dec-2016' => {
29             '18:00' => ['FOREX', 'METAL'],
30             },
31             },
32             late_opens => {
33             '24-Dec-2010' => {
34             '14:30' => ['HKSE'],
35             },
36             },
37             };
38             my $calendar = Finance::Calendar->new(calendar => $calendar);
39             my $now = Date::Utility->new;
40              
41             # Does London Stocks Exchange trade on $now
42             $calendar->trades_on(Finance::Exchange->create_exchange('LSE'), $now);
43              
44             # Is it a country holiday for the United States on $now
45             $calendar->is_holiday_for('USD', $now);
46              
47             # Returns the opening time of Australian Stocks Exchange on $now
48             $calendar->opening_on(Finance::Exchange->create_exchange('ASX'), $now);
49              
50             # Returns the closing time of Forex on $now
51             $calendar->closing_on(Finance::Exchange->create_exchange('FOREX'), $now);
52             ...
53              
54             =head1 DESCRIPTION
55              
56             This class is responsible for providing trading times or holidays related information of a given financial stock exchange on a specific date.
57              
58             =cut
59              
60 3     3   2079343 use Moose;
  3         835077  
  3         28  
61              
62             our $VERSION = '0.07';
63              
64 3     3   25915 use List::Util qw(min max first);
  3         8  
  3         380  
65 3     3   2138 use Date::Utility;
  3         2364567  
  3         201  
66 3     3   2394 use Memoize;
  3         13479  
  3         318  
67 3     3   651 use Finance::Exchange;
  3         118836  
  3         163  
68 3     3   25 use Carp qw(croak);
  3         5  
  3         29848  
69              
70             =head1 ATTRIBUTES - Object Construction
71              
72             =head2 calendar
73              
74             A hash reference that has information on:
75             - exchange and country holidays
76             - late opens
77             - early closes
78              
79             =cut
80              
81             has calendar => (
82             is => 'ro',
83             required => 1,
84             );
85              
86             has _cache => (
87             is => 'ro',
88             default => sub { {} },
89             );
90              
91             sub _get_cache {
92 606     606   1912 my ($self, $method_name, $exchange, @dates) = @_;
93              
94 606 100       26136 return undef unless exists $self->_cache->{$method_name};
95              
96 595         18909 my $key = join "_", ($exchange->symbol, (map { $self->trading_date_for($exchange, $_)->epoch } @dates));
  599         6564  
97 595         57471 return $self->_cache->{$method_name}{$key};
98             }
99              
100             sub _set_cache {
101 264     264   2674 my ($self, $value, $method_name, $exchange, @dates) = @_;
102              
103 264         12088 my $key = join "_", ($exchange->symbol, (map { $self->trading_date_for($exchange, $_)->epoch } @dates));
  270         2620  
104 264         40956 $self->_cache->{$method_name}{$key} = $value;
105              
106 264         829 return undef;
107             }
108              
109             =head1 METHODS - TRADING DAYS RELATED
110              
111             =head2 trades_on
112              
113             ->trades_on($exchange_object, $date_object);
114              
115             Returns true if trading is done on the day of a given Date::Utility.
116              
117             =cut
118              
119             sub trades_on {
120 362     362 1 17985 my ($self, $exchange, $when) = @_;
121              
122 362 100       1036 if (my $cache = $self->_get_cache('trades_on', $exchange, $when)) {
123 219         2178 return $cache;
124             }
125              
126 143         427 my $really_when = $self->trading_date_for($exchange, $when);
127 143 100 100     10767 my $result = (@{$exchange->trading_days_list}[$really_when->day_of_week] && !$self->is_holiday_for($exchange->symbol, $really_when)) ? 1 : 0;
128              
129 143         1252 $self->_set_cache($result, 'trades_on', $exchange, $when);
130 143         715 return $result;
131             }
132              
133             =head2 trade_date_before
134              
135             ->trade_date_before($exchange_object, $date_object);
136              
137             Returns a Date::Utility object for the previous trading day of an exchange for the given date.
138              
139             =cut
140              
141             sub trade_date_before {
142 5     5 1 12272 my ($self, $exchange, $when) = @_;
143              
144 5         18 my $begin = $self->trading_date_for($exchange, $when);
145              
146 5 50       158 if (my $cache = $self->_get_cache('trade_date_before', $exchange, $begin)) {
147 0         0 return $cache;
148             }
149              
150 5         11 my $date_behind;
151 5         10 my $counter = 1;
152              
153             # look back at most 10 days. The previous trading day could have span over a weekend with multiple consecutive holidays.
154             # Previously it was 7 days, but need to increase a little bit since there is a case
155             # where the holidays was more than 7. That was during end of ramadhan at Saudi Arabia Exchange.
156 5   66     28 while (not $date_behind and $counter < 10) {
157 8         42 my $possible = $begin->minus_time_interval($counter . 'd');
158 8 100       1591 $date_behind = $possible if $self->trades_on($exchange, $possible);
159 8         39 $counter++;
160             }
161              
162 5         18 $self->_set_cache($date_behind, 'trade_date_before', $exchange, $begin);
163 5         116 return $date_behind;
164             }
165              
166             =head2 trade_date_after
167              
168             ->trade_date_after($exchange_object, $date_object);
169              
170             Returns a Date::Utility object of the next trading day of an exchange for a given date.
171              
172             =cut
173              
174             sub trade_date_after {
175 19     19 1 11832 my ($self, $exchange, $date) = @_;
176              
177 19         33 my $date_next;
178 19         39 my $counter = 1;
179 19         84 my $begin = $self->trading_date_for($exchange, $date);
180              
181 19 100       1690 if (my $cache = $self->_get_cache('trade_date_after', $exchange, $begin)) {
182 2         15 return $cache;
183             }
184              
185             # look forward at most 11 days. The next trading day could have span over a weekend with multiple consecutive holidays.
186             # We chosed 11 due to the fact that the longest trading holidays we have got so far was 10 days(TSE).
187 17   66     111 while (not $date_next and $counter <= 11) {
188 30         182 my $possible = $begin->plus_time_interval($counter . 'd');
189 30 100       6110 $date_next = $possible if $self->trades_on($exchange, $possible);
190 30         159 $counter++;
191             }
192              
193 17         62 $self->_set_cache($date_next, 'trade_date_after', $exchange, $begin);
194 17         197 return $date_next;
195             }
196              
197             =head2 trading_date_for
198              
199             ->trading_date_for($exchange_object, $date_object);
200              
201             The date on which trading is considered to be taking place even if it is not the same as the GMT date.
202             Note that this does not handle trading dates are offset forward beyond the next day (24h). It will need additional work if these are found to exist.
203              
204             Returns a Date object representing midnight GMT of the trading date.
205              
206             =cut
207              
208             sub trading_date_for {
209 1188     1188 1 8187 my ($self, $exchange, $date) = @_;
210              
211             # if there's no pre-midnight open, then returns the same day.
212 1188 100       45818 return $date->truncate_to_day unless ($exchange->trading_date_can_differ);
213              
214 35         5687 my $next_day = $date->plus_time_interval('1d')->truncate_to_day;
215             my $open_ti =
216 35         13215 $exchange->market_times->{$self->_times_dst_key($exchange, $next_day)}->{daily_open};
217              
218 35 100 66     4484 return $next_day if ($open_ti and $next_day->epoch + $open_ti->seconds <= $date->epoch);
219 34         1021 return $date->truncate_to_day;
220             }
221              
222             =head2 calendar_days_to_trade_date_after
223              
224             ->calendar_days_to_trade_date_after($exchange_object, $date_object);
225              
226             Returns the number of calendar days between a given Date::Utility
227             and the next day on which trading is open.
228              
229             =cut
230              
231             sub calendar_days_to_trade_date_after {
232 6     6 1 7233 my ($self, $exchange, $when) = @_;
233              
234 6 50       25 if (my $cache = $self->_get_cache('calendar_days_to_trade_date_after', $exchange, $when)) {
235 0         0 return $cache;
236             }
237              
238 6         27 my $number_of_days = $self->trade_date_after($exchange, $when)->days_between($when);
239              
240 6         652 $self->_set_cache($number_of_days, 'calendar_days_to_trade_date_after', $exchange, $when);
241 6         55 return $number_of_days;
242             }
243              
244             =head2 trading_days_between
245              
246              
247             ->trading_days_between($exchange_object, Date::Utility->new('4-May-10'),Date::Utility->new('5-May-10'));
248              
249             Returns the number of trading days _between_ two given dates.
250              
251             =cut
252              
253             sub trading_days_between {
254 4     4 1 7182 my ($self, $exchange, $begin, $end) = @_;
255              
256 4 50       16 if (my $cache = $self->_get_cache('trading_days_between', $exchange, $begin, $end)) {
257 0         0 return $cache;
258             }
259              
260             # Count up how many are trading days.
261 4         10 my $number_of_days = scalar grep { $self->trades_on($exchange, $_) } @{$self->_days_between($begin, $end)};
  6         46  
  4         22  
262              
263 4         25 $self->_set_cache($number_of_days, 'trading_days_between', $exchange, $begin, $end);
264 4         29 return $number_of_days;
265             }
266              
267             =head2 holiday_days_between
268              
269             ->holiday_days_between($exchange_object, Date::Utility->new('4-May-10'),Date::Utility->new('5-May-10'));
270              
271             Returns the number of holidays _between_ two given dates.
272              
273             =cut
274              
275             sub holiday_days_between {
276 2     2 1 6374 my ($self, $exchange, $begin, $end) = @_;
277              
278 2 50       10 if (my $cache = $self->_get_cache('holiday_days_between', $exchange, $begin, $end)) {
279 0         0 return $cache;
280             }
281              
282             # Count up how many are trading days.
283 2         7 my $number_of_days = scalar grep { $self->is_holiday_for($exchange->symbol, $_) } @{$self->_days_between($begin, $end)};
  11         430  
  2         11  
284              
285 2         11 $self->_set_cache($number_of_days, 'holiday_days_between', $exchange, $begin, $end);
286 2         13 return $number_of_days;
287             }
288              
289             =head1 METHODS - TRADING TIMES RELATED.
290              
291             =head2 is_open
292              
293             ->is_open($exchange_object);
294              
295             Returns true is exchange is open now, false otherwise.
296              
297             =cut
298              
299             sub is_open {
300 1     1 1 25888 my ($self, $exchange) = @_;
301              
302 1         59 return $self->is_open_at($exchange, Date::Utility->new);
303             }
304              
305             =head2 is_open_at
306              
307             ->is_open_at($exchange_object, $epoch);
308              
309             Return true is exchange is open at the given epoch, false otherwise.
310              
311             =cut
312              
313             sub is_open_at {
314 17     17 1 5218 my ($self, $exchange, $date) = @_;
315 17         67 my $opening = $self->opening_on($exchange, $date);
316 17 100 100     123 return undef if (not $opening or $self->_is_in_trading_break($exchange, $date));
317 14 100 100     72 return 1 if (not $date->is_before($opening) and not $date->is_after($self->closing_on($exchange, $date)));
318             # if everything falls through, assume it is not open
319 11         192 return undef;
320             }
321              
322             =head2 seconds_since_open_at
323              
324             ->seconds_since_open_at($exchange_object, $epoch);
325              
326             Returns the number of seconds since the exchange opened from the given epoch.
327              
328             =cut
329              
330             sub seconds_since_open_at {
331 5     5 1 223 my ($self, $exchange, $date) = @_;
332              
333 5         28 return $self->_market_opens($exchange, $date)->{'opened'};
334             }
335              
336             =head2 seconds_since_close_at
337              
338             ->seconds_since_close_at($exchange_object, $epoch);
339              
340             Returns the number of seconds since the exchange closed from the given epoch.
341              
342             =cut
343              
344             sub seconds_since_close_at {
345 5     5 1 281 my ($self, $exchange, $date) = @_;
346              
347 5         26 return $self->_market_opens($exchange, $date)->{'closed'};
348             }
349              
350             =head2 opening_on
351              
352             ->opening_on($exchange_object, Date::Utility->new('25-Dec-10')); # returns undef (given Xmas is a holiday)
353              
354             Returns the opening time (Date::Utility) of the exchange for a given Date::Utility, undefined otherwise.
355              
356             =cut
357              
358             sub opening_on {
359 119     119 1 24685 my ($self, $exchange, $when) = @_;
360              
361 119 100       367 if (my $cache = $self->_get_cache('opening_on', $exchange, $when)) {
362 68         313 return $cache;
363             }
364              
365 51   100     287 my $opening_on = $self->opens_late_on($exchange, $when) // $self->get_exchange_open_times($exchange, $when, 'daily_open');
366              
367 51         246 $self->_set_cache($opening_on, 'opening_on', $exchange, $when);
368 51         445 return $opening_on;
369             }
370              
371             =head2 closing_on
372              
373             ->closing_on($exchange_object, Date::Utility->new('25-Dec-10')); # returns undef (given Xmas is a holiday)
374              
375             Returns the closing time (Date::Utility) of the exchange for a given Date::Utility, undefined otherwise.
376              
377             =cut
378              
379             sub closing_on {
380 89     89 1 37772 my ($self, $exchange, $when) = @_;
381              
382 89 100       286 if (my $cache = $self->_get_cache('closing_on', $exchange, $when)) {
383 53         184 return $cache;
384             }
385              
386 36   100     188 my $closing_on = $self->closes_early_on($exchange, $when) // $self->get_exchange_open_times($exchange, $when, 'daily_close');
387              
388 36         218 $self->_set_cache($closing_on, 'closing_on', $exchange, $when);
389 36         476 return $closing_on;
390             }
391              
392             =head2 trading_breaks
393              
394             ->trading_breaks($exchange_object, $date_object);
395              
396             Defines the breaktime for this exchange.
397              
398             =cut
399              
400             sub trading_breaks {
401 81     81 1 3660 my ($self, $exchange, $when) = @_;
402              
403 81         216 return $self->get_exchange_open_times($exchange, $when, 'trading_breaks');
404             }
405              
406             =head2 regularly_adjusts_trading_hours_on
407              
408             Returns a hashref of special-case changes that may apply on specific trading days.
409             Currently, this applies on:
410             - Sundays (for RSI exchanges opening times)
411             - Fridays (for closing times).
412              
413             =over 4
414              
415             =item C<exchange> - a L<Finance::Exchange> instance
416              
417             =item C<when> - a L<Date::Utility> instance
418              
419             =back
420              
421             Examples:
422              
423             # Friday closing adjustment for FOREX
424             my $changes = $calendar->regularly_adjusts_trading_hours_on(
425             Finance::Exchange->create_exchange('FOREX'),
426             Date::Utility->new('2023-02-17') # Friday
427             );
428             # Returns a hashref with adjusted closing time on Fridays:
429             # {
430             # 'daily_close' => {
431             # 'to' => '20h55m',
432             # 'rule' => 'Fridays'
433             # }
434             # }
435              
436             # Sunday opening adjustment for RSI exchanges
437             my $changes = $calendar->regularly_adjusts_trading_hours_on(
438             Finance::Exchange->create_exchange('TACTICAL_FOREX_EURUSD'),
439             Date::Utility->new('2023-02-12') # Sunday
440             );
441             # Returns a hashref with adjusted opening time on Sundays:
442             # {
443             # 'daily_open' => {
444             # 'to' => '22h35m',
445             # 'rule' => 'Sundays' # or 'Sundays (DST)' if in DST
446             # }
447             # }
448              
449             =cut
450              
451             sub regularly_adjusts_trading_hours_on {
452 88     88 1 32953 my ($self, $exchange, $when) = @_;
453              
454 88         3087 my $day_of_week = $when->day_of_week;
455 88         1043 my $changes;
456 88         269 my $use_dst_time = $self->is_in_dst_at($exchange, $when);
457              
458             # Handle Sunday opening adjustments for tactical exchanges, except tactical crypto which opens 24/7
459 88 100       65993 if ($day_of_week == 0) { # Sunday
    100          
460 10 100       413 if ($exchange->symbol =~ /^(TACTICAL_FOREX.*|TACTICAL_METALS)$/) {
461 8         404 my $partial_trading = $exchange->market_times->{partial_trading};
462              
463 8 50       115 if ($partial_trading) {
464 8 100       30 my $open_key = $use_dst_time ? 'dst_open' : 'standard_open';
465 8         28 my $open_time = $partial_trading->{$open_key};
466              
467 8 50       31 if ($open_time) {
468 8 100       73 $changes = {
469             'daily_open' => {
470             to => $open_time,
471             rule => $use_dst_time ? 'Sundays (DST)' : 'Sundays',
472             },
473             };
474             }
475             }
476             }
477             }
478             # Handle Friday closing adjustments
479             elsif ($day_of_week == 5) { # Friday
480 34         87 my $rule = 'Fridays';
481              
482 34 100       1308 if ($exchange->symbol =~ /^(FOREX|METAL)$/) {
    100          
483 4         53 $changes = {
484             'daily_close' => {
485             to => '20h55m',
486             rule => $rule,
487             },
488             };
489             } elsif ($exchange->symbol =~ /^(TACTICAL_FOREX.*|TACTICAL_METALS)$/) {
490 15 100       1336 my $close_time = $use_dst_time ? $exchange->market_times->{dst}->{friday_close} : $exchange->market_times->{standard}->{friday_close};
491 15 100       281 $changes = {
492             'daily_close' => {
493             to => $close_time,
494             rule => $use_dst_time ? 'Fridays (DST)' : $rule,
495             },
496             };
497             }
498             }
499              
500 88         1133 return $changes;
501             }
502              
503             =head2 closes_early_on
504              
505             ->closes_early_on($exchange_object, $date_object);
506              
507             Returns the closing time as a L<Date::Utility> instance if the exchange closes early on the given date,
508             or C<undef>.
509              
510             =cut
511              
512             sub closes_early_on {
513 44     44 1 14146 my ($self, $exchange, $when) = @_;
514              
515 44 100       174 return undef unless $self->trades_on($exchange, $when);
516              
517 40         74 my $closes_early;
518 40         165 my $listed = $self->_get_partial_trading_for($exchange, 'early_closes', $when);
519 40 100       235 if ($listed) {
    100          
520 3         13 $closes_early = $when->truncate_to_day->plus_time_interval($listed);
521             } elsif (my $scheduled_changes = $self->regularly_adjusts_trading_hours_on($exchange, $when)) {
522             $closes_early = $when->truncate_to_day->plus_time_interval($scheduled_changes->{daily_close}->{to})
523 11 50       93 if ($scheduled_changes->{daily_close});
524             }
525              
526 40         9185 return $closes_early;
527             }
528              
529             =head2 opens_late_on
530              
531             ->opens_late_on($exchange_object, $date_object);
532              
533             Returns true if the exchange opens late on the given date.
534              
535             =cut
536              
537             sub opens_late_on {
538 55     55 1 625 my ($self, $exchange, $when) = @_;
539              
540 55 100       196 return undef unless $self->trades_on($exchange, $when);
541              
542 42         89 my $opens_late;
543 42         262 my $listed = $self->_get_partial_trading_for($exchange, 'late_opens', $when);
544 42 100       242 if ($listed) {
    100          
545 2         9 $opens_late = $when->truncate_to_day->plus_time_interval($listed);
546             } elsif (my $scheduled_changes = $self->regularly_adjusts_trading_hours_on($exchange, $when)) {
547             $opens_late = $when->truncate_to_day->plus_time_interval($scheduled_changes->{daily_open}->{to})
548 8 50       57 if ($scheduled_changes->{daily_open});
549             }
550              
551 42         2321 return $opens_late;
552             }
553              
554             =head2 seconds_of_trading_between_epochs
555              
556             ->seconds_of_trading_between_epochs($exchange_object, $epoch1, $epoch2);
557              
558             Get total number of seconds of trading time between two epochs accounting for breaks.
559              
560             =cut
561              
562             my $full_day = 86400;
563              
564             sub seconds_of_trading_between_epochs {
565 22     22 1 33790 my ($self, $exchange, $start, $end) = @_;
566              
567 22         95 my ($start_epoch, $end_epoch) = ($start->epoch, $end->epoch);
568 22         34 my $result = 0;
569              
570             # step 1: calculate non-cached incomplete start-day and end_dates
571 22         34 my $day_start = $start_epoch - ($start_epoch % $full_day);
572 22         30 my $day_end = $end_epoch - ($end_epoch % $full_day);
573 22 100 66     126 if (($day_start != $start_epoch) && ($start_epoch < $end_epoch)) {
574 17         107 $result += $self->_computed_trading_seconds($exchange, $start_epoch, min($day_start + 86399, $end_epoch));
575 17         24 $start_epoch = $day_start + $full_day;
576             }
577 22 100 100     105 if (($day_end != $end_epoch) && ($start_epoch < $end_epoch)) {
578 10         34 $result += $self->_computed_trading_seconds($exchange, max($start_epoch, $day_end), $end_epoch);
579 10         13 $end_epoch = $day_end;
580             }
581              
582             # step 2: calculate intermediated values (which are guaranteed to be day-boundary)
583             # with cache-aware way
584 22 100       41 if ($start_epoch < $end_epoch) {
585 7         20 $result += $self->_seconds_of_trading_between_epochs_days_boundary($exchange, $start_epoch, $end_epoch);
586             }
587              
588 22         157 return $result;
589             }
590              
591             =head2 regular_trading_day_after
592              
593             ->regular_trading_day_after($exchange_object, $date_object);
594              
595             Returns a Date::Utility object on a trading day where the exchange does not close early or open late after the given date.
596              
597             =cut
598              
599             sub regular_trading_day_after {
600 2     2 1 5188 my ($self, $exchange, $when) = @_;
601              
602 2 50       7 return undef if $self->closing_on($exchange, $when);
603              
604 2         3 my $counter = 0;
605 2         7 my $regular_trading_day = $self->trade_date_after($exchange, $when);
606 2         5 while ($counter <= 10) {
607 2         8 my $possible = $regular_trading_day->plus_time_interval($counter . 'd');
608 2 50 33     145 if ( not $self->closes_early_on($exchange, $possible)
      33        
609             and not $self->opens_late_on($exchange, $possible)
610             and $self->trades_on($exchange, $possible))
611             {
612 2         5 $regular_trading_day = $possible;
613 2         5 last;
614             }
615 0         0 $counter++;
616             }
617              
618 2         8 return $regular_trading_day;
619             }
620              
621             =head2 trading_period
622              
623             ->trading_period('HKSE', Date::Utility->new);
624              
625             Returns an array reference of hash references of open and close time of the given exchange and epoch
626              
627             =cut
628              
629             sub trading_period {
630 2     2 1 10936 my ($self, $exchange, $when) = @_;
631              
632 2 50       14 return [] if not $self->trades_on($exchange, $when);
633 2         12 my $open = $self->opening_on($exchange, $when);
634 2         13 my $close = $self->closing_on($exchange, $when);
635 2         13 my $breaks = $self->trading_breaks($exchange, $when);
636              
637 2         7 my @times = ($open);
638 2 100       10 if (defined $breaks) {
639 1         3 push @times, @{$_} for @{$breaks};
  1         4  
  1         6  
640             }
641 2         6 push @times, $close;
642              
643 2         5 my @periods;
644 2         11 for (my $i = 0; $i < $#times; $i += 2) {
645 3         31 push @periods,
646             {
647             open => $times[$i]->epoch,
648             close => $times[$i + 1]->epoch
649             };
650             }
651              
652 2         17 return \@periods;
653             }
654              
655             =head2 is_holiday_for
656              
657             Check if it is a holiday for a specific exchange or a country on a specific day
658              
659             ->is_holiday_for('ASX', '2013-01-01'); # Australian exchange holiday
660             ->is_holiday_for('USD', Date::Utility->new); # United States country holiday
661              
662             Returns the description of the holiday if it is a holiday.
663              
664             =cut
665              
666             sub is_holiday_for {
667 109     109 1 21136 my ($self, $symbol, $date) = @_;
668              
669 109         379 return $self->_get_holidays_for($symbol, $date);
670             }
671              
672             =head2 is_in_dst_at
673              
674             ->is_in_dst_at($exchange_object, $date_object);
675              
676             Is this exchange trading on daylight savings times for the given epoch?
677              
678             =cut
679              
680             sub is_in_dst_at {
681 261     261 1 99404 my ($self, $exchange, $epoch) = @_;
682              
683 261         6351 return Date::Utility->new($epoch)->is_dst_in_zone($exchange->trading_timezone);
684             }
685              
686             ### PRIVATE ###
687              
688             sub _get_holidays_for {
689 109     109   295 my ($self, $symbol, $when) = @_;
690              
691 109         399 my $date = $when->truncate_to_day->epoch;
692 109         5196 my $calendar = $self->calendar->{holidays};
693 109         316 my $holiday = $calendar->{$date};
694              
695 109 100       844 return undef unless $holiday;
696              
697 24         86 foreach my $holiday_desc (keys %$holiday) {
698 24 100   31   3196 return $holiday_desc if (first { $symbol eq $_ } @{$holiday->{$holiday_desc}});
  31         286  
  24         151  
699             }
700              
701 2         16 return undef;
702             }
703              
704             sub _is_in_trading_break {
705 16     16   47 my ($self, $exchange, $when) = @_;
706              
707 16         501 $when = Date::Utility->new($when);
708 16         1532 my $in_trading_break = 0;
709 16 100       71 if (my $breaks = $self->trading_breaks($exchange, $when)) {
710 5         9 foreach my $break_interval (@{$breaks}) {
  5         14  
711 5 100 100     59 if ($when->epoch >= $break_interval->[0]->epoch and $when->epoch <= $break_interval->[1]->epoch) {
712 2         6 $in_trading_break++;
713 2         4 last;
714             }
715             }
716             }
717              
718 16         120 return $in_trading_break;
719             }
720              
721             =head2 get_exchange_open_times
722              
723             Query an exchange for valid opening times. Expects 3 parameters:
724              
725             =over 4
726              
727             =item * C<$exchange> - a L<Finance::Exchange> instance
728              
729             =item * C<$date> - a L<Date::Utility>
730              
731             =item * C<$which> - which market information to request, see below
732              
733             =back
734              
735             The possible values for C<$which> include:
736              
737             =over 4
738              
739             =item * C<daily_open>
740              
741             =item * C<daily_close>
742              
743             =item * C<trading_breaks>
744              
745             =back
746              
747             Returns either C<undef>, a single L<Date::Utility>, or an arrayref of L<Date::Utility> instances.
748              
749             =cut
750              
751             sub get_exchange_open_times {
752 149     149 1 374 my ($self, $exchange, $date, $which) = @_;
753              
754 149 50       383 my $when = (ref $date) ? $date : Date::Utility->new($date);
755 149         417 my $that_midnight = $self->trading_date_for($exchange, $when);
756 149         13326 my $requested_time;
757 149 100       451 if ($self->trades_on($exchange, $that_midnight)) {
758 132         470 my $dst_key = $self->_times_dst_key($exchange, $that_midnight);
759 132         9110 my $ti = $exchange->market_times->{$dst_key}->{$which};
760 132         1342 my $extended_lunch_hour;
761 132 100       358 if ($which eq 'trading_breaks') {
762 81         2167 my $extended_trading_breaks = $exchange->market_times->{$dst_key}->{day_of_week_extended_trading_breaks};
763 81 50 33     792 $extended_lunch_hour = ($extended_trading_breaks and $when->day_of_week == $extended_trading_breaks) ? 1 : 0;
764             }
765 132 100       330 if ($ti) {
766 112 100       382 if (ref $ti eq 'ARRAY') {
767 61 50       142 my $trading_breaks = $extended_lunch_hour ? @$ti[1] : @$ti[0];
768 61         193 my $start_of_break = $that_midnight->plus_time_interval($trading_breaks->[0]);
769 61         5201 my $end_of_break = $that_midnight->plus_time_interval($trading_breaks->[1]);
770 61         4823 push @{$requested_time}, [$start_of_break, $end_of_break];
  61         212  
771             } else {
772 51         217 $requested_time = $that_midnight->plus_time_interval($ti);
773             }
774             }
775             }
776 149         7313 return $requested_time; # returns null on no trading days.
777             }
778              
779             sub _times_dst_key {
780 167     167   813 my ($self, $exchange, $when) = @_;
781              
782 167 50       596 my $epoch = (ref $when) ? $when->epoch : $when;
783              
784 167 100       470 return 'dst' if $self->is_in_dst_at($exchange, $epoch);
785 121         15826 return 'standard';
786             }
787              
788             # get partial trading data for a given exchange
789             sub _get_partial_trading_for {
790 82     82   242 my ($self, $exchange, $type, $when) = @_;
791              
792 82         6551 my $cached = $self->calendar->{$type};
793 82         327 my $date = $when->truncate_to_day->epoch;
794 82         4901 my $partial_defined = $cached->{$date};
795              
796 82 100       384 return undef unless $partial_defined;
797              
798 5         10 foreach my $close_time (keys %{$cached->{$date}}) {
  5         26  
799 5         14 my $symbols = $cached->{$date}{$close_time};
800 5 50   5   50 return $close_time if (first { $exchange->symbol eq $_ } @$symbols);
  5         171  
801             }
802              
803 0         0 return undef;
804             }
805              
806             sub _days_between {
807             my ($self, $begin, $end) = @_;
808              
809             my @days_between = ();
810              
811             # Don't include start and end days.
812             my $current = Date::Utility->new($begin)->truncate_to_day->plus_time_interval('1d');
813             $end = Date::Utility->new($end)->truncate_to_day->minus_time_interval('1d');
814              
815             # Generate all days between.
816             while (not $current->is_after($end)) {
817             push @days_between, $current;
818             $current = $current->plus_time_interval('1d'); # Next day, please!
819             }
820              
821             return \@days_between;
822             }
823              
824             Memoize::memoize('_days_between', NORMALIZER => '_normalize_on_just_dates');
825              
826             =head2 next_open_at
827              
828             ->next_open_at($exchange_object, Date::Utility->new('2023-02-16 15:30:00'));
829              
830             Returns Date::Utility object of the next opening date and time.
831              
832             Returns undef if exchange is open for the requested date.
833              
834             =cut
835              
836             sub next_open_at {
837 7     7 1 24471 my ($self, $exchange, $date) = @_;
838              
839 7 50       37 return undef if $self->is_open_at($exchange, $date);
840              
841 7         35 my $market_opens = $self->_market_opens($exchange, $date);
842             # exchange is closed for the trading day
843 7 100       34 unless (defined $market_opens->{open}) {
844 4         27 my $next_trading = $self->trade_date_after($exchange, $date);
845 4         18 return $self->opening_on($exchange, $next_trading);
846             }
847              
848             # exchange is closed for trading breaks, will open again
849 3 50       13 unless ($market_opens->{open}) {
850 3         12 my $trading_breaks = $self->trading_breaks($exchange, $date);
851              
852 3         12 foreach my $break ($trading_breaks->@*) {
853 2         9 my ($close, $open) = $break->@*;
854              
855             # Between trading brakes
856 2 100 66     11 if ($date->is_after($close) and $date->is_before($open)) {
    50 33        
857 1         29 return $open;
858             } elsif ($date->is_before($close) and $date->is_after($date->truncate_to_day)) { # Between midnight and first opening
859 1         227 return $self->opening_on($exchange, $date);
860             }
861             }
862              
863             # When there is no trading break but opens on same day
864 1 50       4 if (!@$trading_breaks) {
865 1         6 my $opening_late = $self->opening_on($exchange, $date);
866 1         10 return $opening_late;
867             }
868             }
869              
870             # we shouldn't reach here but, return undef instead of a wrong time here.
871 0         0 return undef;
872             }
873              
874             ## PRIVATE _market_opens
875             #
876             # PARAMETERS :
877             # - time : the time as a timestamp
878             #
879             # RETURNS : A reference to a hash with the following keys:
880             # - open : is set to 1 if the market is currently open, 0 if market is closed
881             # but will open, 'undef' if market is closed and will not open again
882             # today.
883             # - closed : undefined if market has not been open yet, otherwise contains the
884             # seconds for how long the market was closed.
885             # - opens : undefined if market is currently open and does not open anymore today,
886             # otherwise the market will open in 'opens' seconds.
887             # - closes : undefined if open is undef, otherwise market will close in 'closes' seconds.
888             # - opened : undefined if market is closed, contains the seconds the market has
889             # been open.
890             #
891             #
892             ########
893             sub _market_opens {
894 17     17   52 my ($self, $exchange, $when) = @_;
895              
896 17         37 my $date = $when;
897             # Figure out which "trading day" we are on
898             # even if it differs from the GMT calendar day.
899 17         85 my $next_day = $date->plus_time_interval('1d')->truncate_to_day;
900 17         5753 my $next_open = $self->opening_on($exchange, $next_day);
901 17 50 66     98 $date = $next_day if ($next_open and not $date->is_before($next_open));
902              
903 17         142 my $open = $self->opening_on($exchange, $date);
904 17         113 my $close = $self->closing_on($exchange, $date);
905              
906 17 100       67 if (not $open) {
907              
908             # date is not a trading day: will not and has not been open today
909 2         12 my $next_open = $self->opening_on($exchange, $self->trade_date_after($exchange, $when));
910             return {
911 2         48 open => undef,
912             opens => $next_open->epoch - $when->epoch,
913             opened => undef,
914             closes => undef,
915             closed => undef,
916             };
917             }
918              
919 15         57 my $breaks = $self->trading_breaks($exchange, $when);
920             # not trading breaks
921 15 100       59 if (not $breaks) {
922             # Past closing time: opens next trading day, and has been open today
923 7 100 66     50 if ($close and not $when->is_before($close)) {
    50 0        
    0 0        
      0        
924             return {
925 4         209 open => undef,
926             opens => undef,
927             opened => $when->epoch - $open->epoch,
928             closes => undef,
929             closed => $when->epoch - $close->epoch,
930             };
931             } elsif ($when->is_before($open)) {
932             return {
933 3         112 open => 0,
934             opens => $open->epoch - $when->epoch,
935             opened => undef,
936             closes => $close->epoch - $when->epoch,
937             closed => undef,
938             };
939             } elsif ($when->is_same_as($open) or ($when->is_after($open) and $when->is_before($close)) or $when->is_same_same($close)) {
940             return {
941 0         0 open => 1,
942             opens => undef,
943             opened => $when->epoch - $open->epoch,
944             closes => $close->epoch - $when->epoch,
945             closed => undef,
946             };
947             }
948             } else {
949 8         25 my @breaks = @$breaks;
950             # Past closing time: opens next trading day, and has been open today
951 8 100 66     68 if ($close and not $when->is_before($close)) {
    100          
952             return {
953 3         89 open => undef,
954             opens => undef,
955             opened => $when->epoch - $breaks[-1][1]->epoch,
956             closes => undef,
957             closed => $when->epoch - $close->epoch,
958             };
959             } elsif ($when->is_before($open)) {
960             return {
961 1         36 open => 0,
962             opens => $open->epoch - $when->epoch,
963             opened => undef,
964             closes => $breaks[0][0]->epoch - $when->epoch,
965             closed => undef,
966             };
967             } else {
968 4         79 my $current_open = $open;
969 4         17 for (my $i = 0; $i <= $#breaks; $i++) {
970 4         10 my $int_open = $breaks[$i][0];
971 4         8 my $int_close = $breaks[$i][1];
972 4 50       14 my $next_open = exists $breaks[$i + 1] ? $breaks[$i + 1][0] : $close;
973              
974 4 100 33     15 if ($when->is_before($int_open)
    50 66        
    0 33        
      33        
      33        
      0        
975             and ($when->is_same_as($current_open) or $when->is_after($current_open)))
976             {
977             return {
978 1         49 open => 1,
979             opens => undef,
980             opened => $when->epoch - $current_open->epoch,
981             closes => $int_open->epoch - $when->epoch,
982             closed => undef,
983             };
984             } elsif ($when->is_same_as($int_open)
985             or ($when->is_after($int_open) and $when->is_before($int_close))
986             or $when->is_same_as($int_close))
987             {
988             return {
989 3         238 open => 0,
990             opens => $int_close->epoch - $when->epoch,
991             opened => undef,
992             closes => $close->epoch - $when->epoch, # we want to know seconds to official close
993             closed => $when->epoch - $int_open->epoch,
994             };
995             } elsif ($when->is_after($int_close) and $when->is_before($next_open)) {
996             return {
997 0         0 open => 1,
998             opens => undef,
999             opened => $when->epoch - $int_close->epoch,
1000             closes => $next_open->epoch - $when->epoch,
1001             closed => undef,
1002             };
1003             }
1004             }
1005              
1006             }
1007             }
1008              
1009 0         0 return undef;
1010             }
1011              
1012             ## PRIVATE method _seconds_of_trading_between_epochs_days_boundary
1013             #
1014             # there is a strict assumption, that start and end epoch are day boundaries
1015             #
1016             my %cached_seconds_for_interval; # key ${epoch1}-${epoch2}, value: seconds
1017              
1018             sub _seconds_of_trading_between_epochs_days_boundary {
1019 23     23   43 my ($self, $exchange, $start_epoch, $end_epoch) = @_;
1020              
1021 23         464 my $cache_key = join('-', $exchange->symbol, $start_epoch, $end_epoch);
1022 23   66     178 my $result = $cached_seconds_for_interval{$cache_key} //= do {
1023 22         48 my $head = $self->_computed_trading_seconds($exchange, $start_epoch, $start_epoch + 86399);
1024 22 100       80 if ($end_epoch - $start_epoch > $full_day - 1) {
1025 16         51 my $tail = $self->_seconds_of_trading_between_epochs_days_boundary($exchange, $start_epoch + $full_day, $end_epoch);
1026 16         26 $head + $tail;
1027             }
1028             };
1029              
1030 23         40 return $result;
1031             }
1032              
1033             ## PRIVATE method _computed_trading_seconds
1034             #
1035             # This one ACTUALLY does the heavy lifting of determining the number of trading seconds in an intraday period.
1036             #
1037             sub _computed_trading_seconds {
1038 49     49   80 my ($self, $exchange, $start, $end) = @_;
1039              
1040 49         58 my $total_trading_time = 0;
1041 49         861 my $when = Date::Utility->new($start);
1042              
1043 49 100       3934 if ($self->trades_on($exchange, $when)) {
1044              
1045             # Do the full computation.
1046 42         94 my $opening_epoch = $self->opening_on($exchange, $when)->epoch;
1047 42         111 my $closing_epoch = $self->closing_on($exchange, $when)->epoch;
1048              
1049             # Total trading time left in interval. This is always between 0 to $period_secs_basis.
1050             # This will automatically take care of early close because market close will just be the early close time.
1051 42         138 my $total_trading_time_including_lunchbreaks =
1052             max(min($closing_epoch, $end), $opening_epoch) - min(max($opening_epoch, $start), $closing_epoch);
1053              
1054 42         65 my $total_lunch_break_time = 0;
1055              
1056             # Now take care of lunch breaks. But handle early close properly. It could be that
1057             # the early close already wipes out the need to handle lunch breaks.
1058             # Handle early close. For example on 24 Dec 2009, HKSE opens at 2:00, and stops
1059             # for lunch at 4:30 and never reopens. In that case the value of $self->closing_on($thisday)
1060             # is 4:30, and lunch time between 4:30 to 6:00 is no longer relevant.
1061 42 50       1472 if (my $breaks = $self->trading_breaks($exchange, $when)) {
1062 42         63 for my $break_interval (@{$breaks}) {
  42         68  
1063 42         54 my $interval_open = $break_interval->[0];
1064 42         51 my $interval_close = $break_interval->[1];
1065 42         118 my $close_am = min($interval_open->epoch, $closing_epoch);
1066 42         70 my $open_pm = min($interval_close->epoch, $closing_epoch);
1067              
1068 42         103 $total_lunch_break_time = max(min($open_pm, $end), $close_am) - min(max($close_am, $start), $open_pm);
1069              
1070 42 50       603 if ($total_lunch_break_time < 0) {
1071 0         0 die 'Total lunch break time between ' . $start . '] and [' . $end . '] for exchange[' . $self->exchange->symbol . '] is negative';
1072             }
1073             }
1074             }
1075              
1076 42         55 $total_trading_time = $total_trading_time_including_lunchbreaks - $total_lunch_break_time;
1077 42 50       121 if ($total_trading_time < 0) {
1078 0         0 croak 'Total trading time (minus lunch) between '
1079             . $start
1080             . '] and ['
1081             . $end
1082             . '] for exchange['
1083             . $self->exchange->symbol
1084             . '] is negative.';
1085             }
1086             }
1087              
1088 49         100 return $total_trading_time;
1089             }
1090              
1091             ## PRIVATE static methods
1092             #
1093             # Many of these functions don't change their results if asked for the
1094             # same dates many times. Let's exploit that for time over space
1095             #
1096             # This actually comes up in our pricing where we have to do many interpolations
1097             # over the same ranges on different values.
1098             #
1099             # This attaches to the static method on the class for the lifetime of this instance.
1100             # Since we only want the cache for our specific symbol, we need to include an identifier.
1101              
1102             sub _normalize_on_just_dates {
1103 6     6   51 my ($self, @dates) = @_;
1104              
1105 6         15 return join '|', (map { Date::Utility->new($_)->days_since_epoch } @dates);
  12         1244  
1106             }
1107              
1108 3     3   35 no Moose;
  3         6  
  3         33  
1109             __PACKAGE__->meta->make_immutable;
1110              
1111             1;