Created
May 23, 2024 19:13
-
-
Save pmarreck/0b60a7732fe50d670bdda320d02563df to your computer and use it in GitHub Desktop.
Computing holidays with a DSL in Elixir
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule Addigence.Holidays do | |
use Timex | |
@holidays [ | |
{:new_years_day, [:fixed, 1, :January]}, | |
{:new_years_day_observed, [:nearest_weekday_to, 1, :January]}, | |
{:martin_luther_king_jr_day, [:third, :monday, :January]}, | |
{:washingtons_birthday, [:third, :monday, :February]}, | |
{:good_friday, &Addigence.Holidays.compute_good_friday/1}, | |
{:memorial_day, [:last, :monday, :May]}, | |
{:juneteenth, [:fixed, 19, :June]}, | |
{:independence_day, [:fixed, 4, :July]}, | |
{:independence_day_observed, [:nearest_weekday_to, 4, :July]}, | |
{:labor_day, [:first, :monday, :September]}, | |
{:thanksgiving_day, [:fourth, :thursday, :November]}, | |
{:christmas_day, [:fixed, 25, :December]}, | |
{:christmas_day_observed, [:nearest_weekday_to, 25, :December]}, | |
{:easter, &Addigence.Holidays.compute_easter/1} | |
] | |
@months %{ | |
January: 1, | |
February: 2, | |
March: 3, | |
April: 4, | |
May: 5, | |
June: 6, | |
July: 7, | |
August: 8, | |
September: 9, | |
October: 10, | |
November: 11, | |
December: 12 | |
} | |
@weeks %{ | |
first: 1, | |
second: 2, | |
third: 3, | |
fourth: 4 | |
} | |
@weekdays %{ | |
monday: 1, | |
tuesday: 2, | |
wednesday: 3, | |
thursday: 4, | |
friday: 5, | |
saturday: 6, | |
sunday: 7 | |
} | |
def nyse_holidays(year) do | |
@holidays | |
|> Enum.map(fn {holiday, desc} -> {holiday, compute_holiday(year, desc)} end) | |
|> Enum.filter(fn {_holiday, date} -> date != nil end) | |
end | |
def get_nth_weekday(date, week, weekday) when week in 1..4 and weekday in 1..7 do | |
date | |
|> Timex.beginning_of_month() | |
|> Timex.shift(days: days_to_next_weekday(date, weekday)) | |
|> Timex.shift(weeks: week - 1) | |
end | |
defp days_to_next_weekday(date, sought_weekday) do # 1 is Monday, 2 is Tuesday etc. | |
current_weekday = Timex.weekday(date) | |
if sought_weekday == current_weekday do | |
0 | |
else | |
rem(sought_weekday - current_weekday + 7, 7) | |
end | |
end | |
def compute_holiday(year, [:fixed, day, month]) do | |
Date.new!(year, @months[month], day) | |
end | |
def compute_holiday(year, [:nearest_weekday_to, day, month]) do | |
date = Date.new!(year, @months[month], day) | |
adjust_for_weekend(date) | |
end | |
def compute_holiday(year, [:last, weekday, month]) do | |
date = Date.new!(year, @months[month], Timex.days_in_month(Date.new!(year, @months[month], 1))) | |
if Timex.weekday(date) == @weekdays[weekday] do | |
date | |
else | |
start_search_date = Timex.shift(date, days: -6) | |
Timex.shift(start_search_date, days: days_to_next_weekday(start_search_date, @weekdays[weekday])) | |
end | |
end | |
def compute_holiday(year, [week, weekday, month]) do | |
date = Date.new!(year, @months[month], 1) | |
get_nth_weekday(date, @weeks[week], @weekdays[weekday]) | |
end | |
def compute_holiday(year, special_function) when is_function(special_function) do | |
special_function.(year) | |
end | |
def compute_good_friday(year) do | |
easter = compute_easter(year) | |
Timex.shift(easter, days: -2) | |
end | |
def compute_easter(year) do | |
# Using the Anonymous Gregorian algorithm | |
a = rem(year, 19) | |
b = div(year, 100) | |
c = rem(year, 100) | |
d = div(b, 4) | |
e = rem(b, 4) | |
f = div(b + 8, 25) | |
g = div(b - f + 1, 3) | |
h = rem(19 * a + b - d - g + 15, 30) | |
i = div(c, 4) | |
k = rem(c, 4) | |
l = rem(32 + 2 * e + 2 * i - h - k, 7) | |
m = div(a + 11 * h + 22 * l, 451) | |
month = div(h + l - 7 * m + 114, 31) | |
day = rem(h + l - 7 * m + 114, 31) + 1 | |
Date.new!(year, month, day) | |
end | |
def adjust_for_weekend(date) do | |
case Timex.weekday(date) do | |
6 -> Timex.shift(date, days: -1) # If Saturday, shift to Friday | |
7 -> Timex.shift(date, days: 1) # If Sunday, shift to Monday | |
_ -> date | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule Addigence.HolidaysTest do | |
use ExUnit.Case, async: true | |
alias Addigence.Holidays | |
test "NYSE holidays in 2024" do | |
holidays = Holidays.nyse_holidays(2024) # |> IO.inspect | |
assert Enum.any?(holidays, fn date -> date == {:new_years_day, ~D[2024-01-01]} end), "New Year's Day" | |
assert Enum.any?(holidays, fn date -> date == {:martin_luther_king_jr_day, ~D[2024-01-15]} end), "Martin Luther King Jr. Day" | |
assert Enum.any?(holidays, fn date -> date == {:washingtons_birthday, ~D[2024-02-19]} end), "Washington's Birthday" | |
assert Enum.any?(holidays, fn date -> date == {:good_friday, ~D[2024-03-29]} end), "Good Friday" | |
assert Enum.any?(holidays, fn date -> date == {:memorial_day, ~D[2024-05-27]} end), "Memorial Day" | |
assert Enum.any?(holidays, fn date -> date == {:juneteenth, ~D[2024-06-19]} end), "Juneteenth" | |
assert Enum.any?(holidays, fn date -> date == {:independence_day, ~D[2024-07-04]} end), "Independence Day" | |
assert Enum.any?(holidays, fn date -> date == {:labor_day, ~D[2024-09-02]} end), "Labor Day" | |
assert Enum.any?(holidays, fn date -> date == {:thanksgiving_day, ~D[2024-11-28]} end), "Thanksgiving Day" | |
assert Enum.any?(holidays, fn date -> date == {:christmas_day, ~D[2024-12-25]} end), "Christmas Day" | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment