Finance-Calendar
view release on metacpan or search on metacpan
lib/Finance/Calendar.pm view on Meta::CPAN
package Finance::Calendar;
=head1 NAME
Finance::Calendar - represents the trading calendar.
=head1 SYNOPSIS
use Finance::Calendar;
use Date::Utility;
my $calendar = {
holidays => {
"25-Dec-2013" => {
"Christmas Day" => [qw(FOREX METAL)],
},
"1-Jan-2014" => {
"New Year's Day" => [qw( FOREX METAL)],
},
"1-Apr-2013" => {
"Easter Monday" => [qw( USD)],
},
},
early_closes => {
'24-Dec-2009' => {
'16:30' => ['HKSE'],
},
'22-Dec-2016' => {
'18:00' => ['FOREX', 'METAL'],
},
},
late_opens => {
'24-Dec-2010' => {
'14:30' => ['HKSE'],
},
},
};
my $calendar = Finance::Calendar->new(calendar => $calendar);
my $now = Date::Utility->new;
# Does London Stocks Exchange trade on $now
$calendar->trades_on(Finance::Exchange->create_exchange('LSE'), $now);
# Is it a country holiday for the United States on $now
$calendar->is_holiday_for('USD', $now);
# Returns the opening time of Australian Stocks Exchange on $now
$calendar->opening_on(Finance::Exchange->create_exchange('ASX'), $now);
# Returns the closing time of Forex on $now
$calendar->closing_on(Finance::Exchange->create_exchange('FOREX'), $now);
...
=head1 DESCRIPTION
This class is responsible for providing trading times or holidays related information of a given financial stock exchange on a specific date.
=cut
use Moose;
our $VERSION = '0.07';
use List::Util qw(min max first);
use Date::Utility;
use Memoize;
use Finance::Exchange;
use Carp qw(croak);
=head1 ATTRIBUTES - Object Construction
=head2 calendar
A hash reference that has information on:
- exchange and country holidays
- late opens
- early closes
=cut
has calendar => (
is => 'ro',
required => 1,
);
has _cache => (
is => 'ro',
default => sub { {} },
);
sub _get_cache {
my ($self, $method_name, $exchange, @dates) = @_;
return undef unless exists $self->_cache->{$method_name};
my $key = join "_", ($exchange->symbol, (map { $self->trading_date_for($exchange, $_)->epoch } @dates));
return $self->_cache->{$method_name}{$key};
}
sub _set_cache {
my ($self, $value, $method_name, $exchange, @dates) = @_;
my $key = join "_", ($exchange->symbol, (map { $self->trading_date_for($exchange, $_)->epoch } @dates));
$self->_cache->{$method_name}{$key} = $value;
return undef;
}
=head1 METHODS - TRADING DAYS RELATED
=head2 trades_on
->trades_on($exchange_object, $date_object);
Returns true if trading is done on the day of a given Date::Utility.
=cut
sub trades_on {
my ($self, $exchange, $when) = @_;
if (my $cache = $self->_get_cache('trades_on', $exchange, $when)) {
return $cache;
}
my $really_when = $self->trading_date_for($exchange, $when);
my $result = (@{$exchange->trading_days_list}[$really_when->day_of_week] && !$self->is_holiday_for($exchange->symbol, $really_when)) ? 1 : 0;
$self->_set_cache($result, 'trades_on', $exchange, $when);
return $result;
}
=head2 trade_date_before
->trade_date_before($exchange_object, $date_object);
Returns a Date::Utility object for the previous trading day of an exchange for the given date.
=cut
sub trade_date_before {
lib/Finance/Calendar.pm view on Meta::CPAN
$self->_set_cache($date_behind, 'trade_date_before', $exchange, $begin);
return $date_behind;
}
=head2 trade_date_after
->trade_date_after($exchange_object, $date_object);
Returns a Date::Utility object of the next trading day of an exchange for a given date.
=cut
sub trade_date_after {
my ($self, $exchange, $date) = @_;
my $date_next;
my $counter = 1;
my $begin = $self->trading_date_for($exchange, $date);
if (my $cache = $self->_get_cache('trade_date_after', $exchange, $begin)) {
return $cache;
}
# look forward at most 11 days. The next trading day could have span over a weekend with multiple consecutive holidays.
# We chosed 11 due to the fact that the longest trading holidays we have got so far was 10 days(TSE).
while (not $date_next and $counter <= 11) {
my $possible = $begin->plus_time_interval($counter . 'd');
$date_next = $possible if $self->trades_on($exchange, $possible);
$counter++;
}
$self->_set_cache($date_next, 'trade_date_after', $exchange, $begin);
return $date_next;
}
=head2 trading_date_for
->trading_date_for($exchange_object, $date_object);
The date on which trading is considered to be taking place even if it is not the same as the GMT date.
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.
Returns a Date object representing midnight GMT of the trading date.
=cut
sub trading_date_for {
my ($self, $exchange, $date) = @_;
# if there's no pre-midnight open, then returns the same day.
return $date->truncate_to_day unless ($exchange->trading_date_can_differ);
my $next_day = $date->plus_time_interval('1d')->truncate_to_day;
my $open_ti =
$exchange->market_times->{$self->_times_dst_key($exchange, $next_day)}->{daily_open};
return $next_day if ($open_ti and $next_day->epoch + $open_ti->seconds <= $date->epoch);
return $date->truncate_to_day;
}
=head2 calendar_days_to_trade_date_after
->calendar_days_to_trade_date_after($exchange_object, $date_object);
Returns the number of calendar days between a given Date::Utility
and the next day on which trading is open.
=cut
sub calendar_days_to_trade_date_after {
my ($self, $exchange, $when) = @_;
if (my $cache = $self->_get_cache('calendar_days_to_trade_date_after', $exchange, $when)) {
return $cache;
}
my $number_of_days = $self->trade_date_after($exchange, $when)->days_between($when);
$self->_set_cache($number_of_days, 'calendar_days_to_trade_date_after', $exchange, $when);
return $number_of_days;
}
=head2 trading_days_between
->trading_days_between($exchange_object, Date::Utility->new('4-May-10'),Date::Utility->new('5-May-10'));
Returns the number of trading days _between_ two given dates.
=cut
sub trading_days_between {
my ($self, $exchange, $begin, $end) = @_;
if (my $cache = $self->_get_cache('trading_days_between', $exchange, $begin, $end)) {
return $cache;
}
# Count up how many are trading days.
my $number_of_days = scalar grep { $self->trades_on($exchange, $_) } @{$self->_days_between($begin, $end)};
$self->_set_cache($number_of_days, 'trading_days_between', $exchange, $begin, $end);
return $number_of_days;
}
=head2 holiday_days_between
->holiday_days_between($exchange_object, Date::Utility->new('4-May-10'),Date::Utility->new('5-May-10'));
Returns the number of holidays _between_ two given dates.
=cut
sub holiday_days_between {
my ($self, $exchange, $begin, $end) = @_;
if (my $cache = $self->_get_cache('holiday_days_between', $exchange, $begin, $end)) {
return $cache;
}
# Count up how many are trading days.
my $number_of_days = scalar grep { $self->is_holiday_for($exchange->symbol, $_) } @{$self->_days_between($begin, $end)};
$self->_set_cache($number_of_days, 'holiday_days_between', $exchange, $begin, $end);
return $number_of_days;
}
=head1 METHODS - TRADING TIMES RELATED.
=head2 is_open
->is_open($exchange_object);
Returns true is exchange is open now, false otherwise.
=cut
sub is_open {
my ($self, $exchange) = @_;
lib/Finance/Calendar.pm view on Meta::CPAN
my $opening_on = $self->opens_late_on($exchange, $when) // $self->get_exchange_open_times($exchange, $when, 'daily_open');
$self->_set_cache($opening_on, 'opening_on', $exchange, $when);
return $opening_on;
}
=head2 closing_on
->closing_on($exchange_object, Date::Utility->new('25-Dec-10')); # returns undef (given Xmas is a holiday)
Returns the closing time (Date::Utility) of the exchange for a given Date::Utility, undefined otherwise.
=cut
sub closing_on {
my ($self, $exchange, $when) = @_;
if (my $cache = $self->_get_cache('closing_on', $exchange, $when)) {
return $cache;
}
my $closing_on = $self->closes_early_on($exchange, $when) // $self->get_exchange_open_times($exchange, $when, 'daily_close');
$self->_set_cache($closing_on, 'closing_on', $exchange, $when);
return $closing_on;
}
=head2 trading_breaks
->trading_breaks($exchange_object, $date_object);
Defines the breaktime for this exchange.
=cut
sub trading_breaks {
my ($self, $exchange, $when) = @_;
return $self->get_exchange_open_times($exchange, $when, 'trading_breaks');
}
=head2 regularly_adjusts_trading_hours_on
Returns a hashref of special-case changes that may apply on specific trading days.
Currently, this applies on:
- Sundays (for RSI exchanges opening times)
- Fridays (for closing times).
=over 4
=item C<exchange> - a L<Finance::Exchange> instance
=item C<when> - a L<Date::Utility> instance
=back
Examples:
# Friday closing adjustment for FOREX
my $changes = $calendar->regularly_adjusts_trading_hours_on(
Finance::Exchange->create_exchange('FOREX'),
Date::Utility->new('2023-02-17') # Friday
);
# Returns a hashref with adjusted closing time on Fridays:
# {
# 'daily_close' => {
# 'to' => '20h55m',
# 'rule' => 'Fridays'
# }
# }
# Sunday opening adjustment for RSI exchanges
my $changes = $calendar->regularly_adjusts_trading_hours_on(
Finance::Exchange->create_exchange('TACTICAL_FOREX_EURUSD'),
Date::Utility->new('2023-02-12') # Sunday
);
# Returns a hashref with adjusted opening time on Sundays:
# {
# 'daily_open' => {
# 'to' => '22h35m',
# 'rule' => 'Sundays' # or 'Sundays (DST)' if in DST
# }
# }
=cut
sub regularly_adjusts_trading_hours_on {
my ($self, $exchange, $when) = @_;
my $day_of_week = $when->day_of_week;
my $changes;
my $use_dst_time = $self->is_in_dst_at($exchange, $when);
# Handle Sunday opening adjustments for tactical exchanges, except tactical crypto which opens 24/7
if ($day_of_week == 0) { # Sunday
if ($exchange->symbol =~ /^(TACTICAL_FOREX.*|TACTICAL_METALS)$/) {
my $partial_trading = $exchange->market_times->{partial_trading};
if ($partial_trading) {
my $open_key = $use_dst_time ? 'dst_open' : 'standard_open';
my $open_time = $partial_trading->{$open_key};
if ($open_time) {
$changes = {
'daily_open' => {
to => $open_time,
rule => $use_dst_time ? 'Sundays (DST)' : 'Sundays',
},
};
}
}
}
}
# Handle Friday closing adjustments
elsif ($day_of_week == 5) { # Friday
my $rule = 'Fridays';
if ($exchange->symbol =~ /^(FOREX|METAL)$/) {
$changes = {
'daily_close' => {
to => '20h55m',
rule => $rule,
},
};
} elsif ($exchange->symbol =~ /^(TACTICAL_FOREX.*|TACTICAL_METALS)$/) {
my $close_time = $use_dst_time ? $exchange->market_times->{dst}->{friday_close} : $exchange->market_times->{standard}->{friday_close};
$changes = {
'daily_close' => {
to => $close_time,
rule => $use_dst_time ? 'Fridays (DST)' : $rule,
},
};
}
lib/Finance/Calendar.pm view on Meta::CPAN
return [] if not $self->trades_on($exchange, $when);
my $open = $self->opening_on($exchange, $when);
my $close = $self->closing_on($exchange, $when);
my $breaks = $self->trading_breaks($exchange, $when);
my @times = ($open);
if (defined $breaks) {
push @times, @{$_} for @{$breaks};
}
push @times, $close;
my @periods;
for (my $i = 0; $i < $#times; $i += 2) {
push @periods,
{
open => $times[$i]->epoch,
close => $times[$i + 1]->epoch
};
}
return \@periods;
}
=head2 is_holiday_for
Check if it is a holiday for a specific exchange or a country on a specific day
->is_holiday_for('ASX', '2013-01-01'); # Australian exchange holiday
->is_holiday_for('USD', Date::Utility->new); # United States country holiday
Returns the description of the holiday if it is a holiday.
=cut
sub is_holiday_for {
my ($self, $symbol, $date) = @_;
return $self->_get_holidays_for($symbol, $date);
}
=head2 is_in_dst_at
->is_in_dst_at($exchange_object, $date_object);
Is this exchange trading on daylight savings times for the given epoch?
=cut
sub is_in_dst_at {
my ($self, $exchange, $epoch) = @_;
return Date::Utility->new($epoch)->is_dst_in_zone($exchange->trading_timezone);
}
### PRIVATE ###
sub _get_holidays_for {
my ($self, $symbol, $when) = @_;
my $date = $when->truncate_to_day->epoch;
my $calendar = $self->calendar->{holidays};
my $holiday = $calendar->{$date};
return undef unless $holiday;
foreach my $holiday_desc (keys %$holiday) {
return $holiday_desc if (first { $symbol eq $_ } @{$holiday->{$holiday_desc}});
}
return undef;
}
sub _is_in_trading_break {
my ($self, $exchange, $when) = @_;
$when = Date::Utility->new($when);
my $in_trading_break = 0;
if (my $breaks = $self->trading_breaks($exchange, $when)) {
foreach my $break_interval (@{$breaks}) {
if ($when->epoch >= $break_interval->[0]->epoch and $when->epoch <= $break_interval->[1]->epoch) {
$in_trading_break++;
last;
}
}
}
return $in_trading_break;
}
=head2 get_exchange_open_times
Query an exchange for valid opening times. Expects 3 parameters:
=over 4
=item * C<$exchange> - a L<Finance::Exchange> instance
=item * C<$date> - a L<Date::Utility>
=item * C<$which> - which market information to request, see below
=back
The possible values for C<$which> include:
=over 4
=item * C<daily_open>
=item * C<daily_close>
=item * C<trading_breaks>
=back
Returns either C<undef>, a single L<Date::Utility>, or an arrayref of L<Date::Utility> instances.
=cut
sub get_exchange_open_times {
my ($self, $exchange, $date, $which) = @_;
my $when = (ref $date) ? $date : Date::Utility->new($date);
my $that_midnight = $self->trading_date_for($exchange, $when);
my $requested_time;
if ($self->trades_on($exchange, $that_midnight)) {
my $dst_key = $self->_times_dst_key($exchange, $that_midnight);
my $ti = $exchange->market_times->{$dst_key}->{$which};
my $extended_lunch_hour;
if ($which eq 'trading_breaks') {
my $extended_trading_breaks = $exchange->market_times->{$dst_key}->{day_of_week_extended_trading_breaks};
$extended_lunch_hour = ($extended_trading_breaks and $when->day_of_week == $extended_trading_breaks) ? 1 : 0;
}
if ($ti) {
if (ref $ti eq 'ARRAY') {
my $trading_breaks = $extended_lunch_hour ? @$ti[1] : @$ti[0];
my $start_of_break = $that_midnight->plus_time_interval($trading_breaks->[0]);
my $end_of_break = $that_midnight->plus_time_interval($trading_breaks->[1]);
push @{$requested_time}, [$start_of_break, $end_of_break];
} else {
$requested_time = $that_midnight->plus_time_interval($ti);
}
}
}
return $requested_time; # returns null on no trading days.
}
sub _times_dst_key {
my ($self, $exchange, $when) = @_;
my $epoch = (ref $when) ? $when->epoch : $when;
return 'dst' if $self->is_in_dst_at($exchange, $epoch);
return 'standard';
}
# get partial trading data for a given exchange
sub _get_partial_trading_for {
my ($self, $exchange, $type, $when) = @_;
my $cached = $self->calendar->{$type};
my $date = $when->truncate_to_day->epoch;
my $partial_defined = $cached->{$date};
return undef unless $partial_defined;
foreach my $close_time (keys %{$cached->{$date}}) {
my $symbols = $cached->{$date}{$close_time};
return $close_time if (first { $exchange->symbol eq $_ } @$symbols);
}
return undef;
}
sub _days_between {
my ($self, $begin, $end) = @_;
my @days_between = ();
# Don't include start and end days.
my $current = Date::Utility->new($begin)->truncate_to_day->plus_time_interval('1d');
$end = Date::Utility->new($end)->truncate_to_day->minus_time_interval('1d');
# Generate all days between.
while (not $current->is_after($end)) {
push @days_between, $current;
$current = $current->plus_time_interval('1d'); # Next day, please!
}
return \@days_between;
}
Memoize::memoize('_days_between', NORMALIZER => '_normalize_on_just_dates');
=head2 next_open_at
->next_open_at($exchange_object, Date::Utility->new('2023-02-16 15:30:00'));
Returns Date::Utility object of the next opening date and time.
Returns undef if exchange is open for the requested date.
=cut
sub next_open_at {
my ($self, $exchange, $date) = @_;
return undef if $self->is_open_at($exchange, $date);
my $market_opens = $self->_market_opens($exchange, $date);
# exchange is closed for the trading day
unless (defined $market_opens->{open}) {
my $next_trading = $self->trade_date_after($exchange, $date);
return $self->opening_on($exchange, $next_trading);
}
# exchange is closed for trading breaks, will open again
unless ($market_opens->{open}) {
my $trading_breaks = $self->trading_breaks($exchange, $date);
foreach my $break ($trading_breaks->@*) {
my ($close, $open) = $break->@*;
# Between trading brakes
if ($date->is_after($close) and $date->is_before($open)) {
return $open;
} elsif ($date->is_before($close) and $date->is_after($date->truncate_to_day)) { # Between midnight and first opening
return $self->opening_on($exchange, $date);
}
}
# When there is no trading break but opens on same day
if (!@$trading_breaks) {
my $opening_late = $self->opening_on($exchange, $date);
return $opening_late;
}
}
# we shouldn't reach here but, return undef instead of a wrong time here.
return undef;
}
## PRIVATE _market_opens
#
# PARAMETERS :
# - time : the time as a timestamp
#
# RETURNS : A reference to a hash with the following keys:
# - open : is set to 1 if the market is currently open, 0 if market is closed
# but will open, 'undef' if market is closed and will not open again
# today.
# - closed : undefined if market has not been open yet, otherwise contains the
# seconds for how long the market was closed.
# - opens : undefined if market is currently open and does not open anymore today,
# otherwise the market will open in 'opens' seconds.
# - closes : undefined if open is undef, otherwise market will close in 'closes' seconds.
# - opened : undefined if market is closed, contains the seconds the market has
# been open.
#
#
########
sub _market_opens {
my ($self, $exchange, $when) = @_;
my $date = $when;
# Figure out which "trading day" we are on
# even if it differs from the GMT calendar day.
my $next_day = $date->plus_time_interval('1d')->truncate_to_day;
my $next_open = $self->opening_on($exchange, $next_day);
$date = $next_day if ($next_open and not $date->is_before($next_open));
my $open = $self->opening_on($exchange, $date);
my $close = $self->closing_on($exchange, $date);
if (not $open) {
# date is not a trading day: will not and has not been open today
my $next_open = $self->opening_on($exchange, $self->trade_date_after($exchange, $when));
return {
open => undef,
opens => $next_open->epoch - $when->epoch,
opened => undef,
closes => undef,
closed => undef,
};
}
my $breaks = $self->trading_breaks($exchange, $when);
# not trading breaks
if (not $breaks) {
# Past closing time: opens next trading day, and has been open today
if ($close and not $when->is_before($close)) {
return {
open => undef,
opens => undef,
opened => $when->epoch - $open->epoch,
closes => undef,
closed => $when->epoch - $close->epoch,
};
} elsif ($when->is_before($open)) {
return {
open => 0,
opens => $open->epoch - $when->epoch,
opened => undef,
closes => $close->epoch - $when->epoch,
closed => undef,
};
} elsif ($when->is_same_as($open) or ($when->is_after($open) and $when->is_before($close)) or $when->is_same_same($close)) {
return {
open => 1,
opens => undef,
opened => $when->epoch - $open->epoch,
closes => $close->epoch - $when->epoch,
closed => undef,
};
}
} else {
my @breaks = @$breaks;
# Past closing time: opens next trading day, and has been open today
if ($close and not $when->is_before($close)) {
return {
open => undef,
opens => undef,
opened => $when->epoch - $breaks[-1][1]->epoch,
closes => undef,
closed => $when->epoch - $close->epoch,
};
( run in 0.545 second using v1.01-cache-2.11-cpan-ff9377addf4 )