The code main.py, the file the app installs.
python Copy """Exchange calendars: holidays, early closes and calendar effects as feeds.
No network. Before a run it computes, from published rules and a dated list
of one-off closures, a feed per exchange that a strategy reads by day:
calendar_nyse New York Stock Exchange
calendar_lse London Stock Exchange
calendar_xetra Xetra, Frankfurt (and Eurex equity derivatives)
calendar_jpx Tokyo Stock Exchange (Japan Exchange Group)
calendar_fx spot FX: the days most of the market is away
def on_market(self, ctx):
bars = ctx.bars(1)
if not bars:
return
day = ctx.data("calendar_nyse", bars[-1].ts)
if day is not None and day["before_holiday"]:
ctx.signal("EXIT")
Each day holds whether the exchange is open, the holiday's name, an early
close, the day before and after a holiday, trading days to and from the next
and last holiday, the trading day of the month counted from either end (the
turn of the month), and the monthly and quarterly expiry day.
Why these values sit under the day they describe ("same_day") when every other
feed waits a day: a holiday is set by rules the exchange publishes a year or
more ahead, so on any day the calendar for that day and the weeks after it was
already known. The exception is a closure nobody saw coming (11 September
2001, a funeral, a storm). Each of those carries the day it was announced, and
the forward looking fields (before_holiday, days_to_holiday, month_days_left,
the expiry day) of a day only count closures announced by then, so the feed
never knows of a closure before the market did.
The days are the exchange's own calendar days. For the four exchanges here
the regular session lies inside one UTC day, so a bar's UTC time finds the
right day during the session.
"""
from datetime import date, timedelta
from orionquant_engine.plugins.kit import dates, feeds
FEED_PREFIX = "calendar_"
DELAY_RULE = "same_day"
#: Raised whenever a rule or a table below changes, so cached feeds are rewritten.
RULES_VERSION = "1.0.0"
MON, TUE, WED, THU, FRI, SAT, SUN = dates.MON, dates.TUE, dates.WED, dates.THU, dates.FRI, dates.SAT, dates.SUN
# --- one-off closures ----------------------------------------------------------------
#
# (day, name, announced, source). `announced` is the first day the closure was
# public; where the exchange's own notice could not be dated it is the day
# after the government order that caused it, or the day itself.
NYSE_CLOSURES = (
("2001-09-11", "September 11 attacks", "2001-09-11", "NYSE, closed before the open"),
("2001-09-12", "September 11 attacks", "2001-09-12", "NYSE"),
("2001-09-13", "September 11 attacks", "2001-09-13", "NYSE"),
("2001-09-14", "September 11 attacks", "2001-09-14", "NYSE, reopened 17 September"),
("2004-06-11", "National Day of Mourning, President Reagan", "2004-06-07", "EO 13343 of 6 June 2004"),
("2007-01-02", "National Day of Mourning, President Ford", "2006-12-29", "EO 13421 of 28 December 2006"),
("2012-10-29", "Hurricane Sandy", "2012-10-29", "NYSE, announced the evening before"),
("2012-10-30", "Hurricane Sandy", "2012-10-30", "NYSE"),
("2018-12-05", "National Day of Mourning, President George H. W. Bush", "2018-12-02",
"EO 13852 of 1 December 2018"),
("2025-01-09", "National Day of Mourning, President Carter", "2024-12-31", "EO 14133 of 30 December 2024"),
)
LSE_CLOSURES = (
("2011-04-29", "Royal Wedding", "2010-11-24", "UK bank holiday announced 23 November 2010"),
("2022-09-19", "State Funeral of Queen Elizabeth II", "2022-09-11",
"UK bank holiday announced 10 September 2022"),
("2023-05-08", "Coronation of King Charles III", "2022-11-07", "UK bank holiday announced 6 November 2022"),
)
JPX_CLOSURES = (
("2020-10-01", "System failure, trading halted all day", "2020-10-01", "JPX, halted before the open"),
)
#: Xetra closures beyond the fixed eight (New Year's Day, Good Friday, Easter
#: Monday, 1 May, 24 to 26 December, 31 December), year by year, from the
#: Eurex trading calendars 2012 to 2026 ("closed for trading and exercise in
#: German equity ... based on Xetra listings"). Set by the Frankfurt exchange
#: council in the year before, so known in advance. A year missing here traded
#: on Whit Monday and 3 October.
XETRA_EXTRA = {
2014: (("2014-10-03", "Day of German Unity"),),
2015: (("2015-05-25", "Whit Monday"),),
2016: (("2016-05-16", "Whit Monday"), ("2016-10-03", "Day of German Unity")),
2017: (("2017-06-05", "Whit Monday"), ("2017-10-03", "Day of German Unity"),
("2017-10-31", "Reformation Day, 500th anniversary")),
2018: (("2018-05-21", "Whit Monday"), ("2018-10-03", "Day of German Unity")),
2019: (("2019-06-10", "Whit Monday"), ("2019-10-03", "Day of German Unity")),
2020: (("2020-06-01", "Whit Monday"),),
2021: (("2021-05-24", "Whit Monday"),),
}
# --- the rules, year by year -----------------------------------------------------------
def _us_observed(out, day, name):
"""A US holiday on a Saturday is kept on the Friday, on a Sunday on the Monday."""
if day.weekday() == SAT:
out[day - timedelta(days=1)] = name + " (observed)"
elif day.weekday() == SUN:
out[day + timedelta(days=1)] = name + " (observed)"
else:
out[day] = name
def nyse_holidays(y):
"""NYSE Rule 7.2 holidays. New Year's Day on a Saturday is not moved to the
Friday (31 December closes a year), so 2022 and 2028 have none."""
out = {}
jan1 = date(y, 1, 1)
if jan1.weekday() == SUN:
out[jan1 + timedelta(days=1)] = "New Year's Day (observed)"
elif jan1.weekday() != SAT:
out[jan1] = "New Year's Day"
out[dates.nth_weekday(y, 1, MON, 3)] = "Martin Luther King Jr. Day"
out[dates.nth_weekday(y, 2, MON, 3)] = "Washington's Birthday"
out[dates.easter(y) - timedelta(days=2)] = "Good Friday"
out[dates.nth_weekday(y, 5, MON, -1)] = "Memorial Day"
if y >= 2022: # a federal holiday from 2021; the NYSE closed for it from 2022
_us_observed(out, date(y, 6, 19), "Juneteenth")
_us_observed(out, date(y, 7, 4), "Independence Day")
out[dates.nth_weekday(y, 9, MON, 1)] = "Labor Day"
out[dates.nth_weekday(y, 11, THU, 4)] = "Thanksgiving Day"
_us_observed(out, date(y, 12, 25), "Christmas Day")
return out
def nyse_early_closes(y):
"""13:00 New York closes: 3 July when it is Monday to Thursday (in 2002 the
5th instead), the day after Thanksgiving, 24 December Monday to Thursday,
and 26 December 2003. NYSE notices of each year."""
out = {}
jul3 = date(y, 7, 3)
if y == 2002:
out[date(2002, 7, 5)] = "13:00"
elif jul3.weekday() <= THU:
out[jul3] = "13:00"
out[dates.nth_weekday(y, 11, THU, 4) + timedelta(days=1)] = "13:00"
dec24 = date(y, 12, 24)
if dec24.weekday() <= THU:
out[dec24] = "13:00"
if y == 2003:
out[date(2003, 12, 26)] = "13:00"
return out
def _uk_substitutes(out, days_names):
"""Fixed date UK holidays: those on a weekday stay, those on a weekend move
to the next weekday not already taken (Christmas on a Saturday is Monday,
Boxing Day on the Sunday then Tuesday)."""
for day, name in days_names:
if day.weekday() < SAT:
out[day] = name
for day, name in days_names:
if day.weekday() >= SAT:
out[dates.substitute(day, taken=list(out))] = name + " (substitute day)"
def lse_holidays(y):
"""The bank holidays of England and Wales, on which the LSE closes, with
the year by year moves: Early May moved to 8 May for VE Day in 2020, the
Spring holiday moved for the Golden (2002), Diamond (2012) and Platinum
(2022) Jubilees with an extra jubilee day."""
out = {}
_uk_substitutes(out, [(date(y, 1, 1), "New Year's Day")])
out[dates.easter(y) - timedelta(days=2)] = "Good Friday"
out[dates.easter(y) + timedelta(days=1)] = "Easter Monday"
if y == 2020:
out[date(2020, 5, 8)] = "Early May bank holiday (VE Day)"
else:
out[dates.nth_weekday(y, 5, MON, 1)] = "Early May bank holiday"
spring = {
2002: ((date(2002, 6, 4), "Spring bank holiday"), (date(2002, 6, 3), "Golden Jubilee")),
2012: ((date(2012, 6, 4), "Spring bank holiday"), (date(2012, 6, 5), "Diamond Jubilee")),
2022: ((date(2022, 6, 2), "Spring bank holiday"), (date(2022, 6, 3), "Platinum Jubilee")),
}
for day, name in spring.get(y, ((dates.nth_weekday(y, 5, MON, -1), "Spring bank holiday"),)):
out[day] = name
out[dates.nth_weekday(y, 8, MON, -1)] = "Summer bank holiday"
_uk_substitutes(out, [(date(y, 12, 25), "Christmas Day"), (date(y, 12, 26), "Boxing Day")])
return out
def lse_early_closes(y):
"""12:30 London closes on 24 and 31 December when they are weekdays and
not a holiday."""
out = {}
for day in (date(y, 12, 24), date(y, 12, 31)):
if day.weekday() < SAT:
out[day] = "12:30"
return out
def xetra_holidays(y):
"""The eight days Xetra is closed every year, plus XETRA_EXTRA."""
out = {
date(y, 1, 1): "New Year's Day",
dates.easter(y) - timedelta(days=2): "Good Friday",
dates.easter(y) + timedelta(days=1): "Easter Monday",
date(y, 5, 1): "Labour Day",
date(y, 12, 24): "Christmas Eve",
date(y, 12, 25): "Christmas Day",
date(y, 12, 26): "Boxing Day",
date(y, 12, 31): "New Year's Eve",
}
for day, name in XETRA_EXTRA.get(y, ()):
out[date.fromisoformat(day)] = name
return out
def _equinox(y, base):
"""The day of a Japanese equinox holiday, by the formula the National
Astronomical Observatory's dates follow for 1980 to 2099."""
return int(base + 0.242194 * (y - 1980)) - (y - 1980) // 4
def japan_holidays(y):
"""Japan's national holidays (the Act on National Holidays as amended up to
2021), with substitute holidays and the citizens' holiday between two
holidays."""
base = {date(y, 1, 1): "New Year's Day"}
base[dates.nth_weekday(y, 1, MON, 2)] = "Coming of Age Day"
base[date(y, 2, 11)] = "National Foundation Day"
if y >= 2020:
base[date(y, 2, 23)] = "Emperor's Birthday"
base[date(y, 3, _equinox(y, 20.8431))] = "Vernal Equinox Day"
base[date(y, 4, 29)] = "Showa Day" if y >= 2007 else "Greenery Day"
base[date(y, 5, 3)] = "Constitution Memorial Day"
if y >= 2007:
base[date(y, 5, 4)] = "Greenery Day"
base[date(y, 5, 5)] = "Children's Day"
marine = {2020: date(2020, 7, 23), 2021: date(2021, 7, 22)}
if y in marine:
base[marine[y]] = "Marine Day"
else:
base[dates.nth_weekday(y, 7, MON, 3) if y >= 2003 else date(y, 7, 20)] = "Marine Day"
if y >= 2016:
mountain = {2020: date(2020, 8, 10), 2021: date(2021, 8, 8)}
base[mountain.get(y, date(y, 8, 11))] = "Mountain Day"
base[dates.nth_weekday(y, 9, MON, 3) if y >= 2003 else date(y, 9, 15)] = "Respect for the Aged Day"
base[date(y, 9, _equinox(y, 23.2488))] = "Autumnal Equinox Day"
sports = {2020: date(2020, 7, 24), 2021: date(2021, 7, 23)}
if y in sports:
base[sports[y]] = "Sports Day"
else:
base[dates.nth_weekday(y, 10, MON, 2)] = "Sports Day" if y >= 2020 else "Health and Sports Day"
base[date(y, 11, 3)] = "Culture Day"
base[date(y, 11, 23)] = "Labour Thanksgiving Day"
if 1989 <= y <= 2018:
base[date(y, 12, 23)] = "Emperor's Birthday"
if y == 2019:
base[date(2019, 5, 1)] = "Enthronement of the Emperor"
base[date(2019, 10, 22)] = "Enthronement Ceremony"
out = dict(base)
# Substitute holiday: a holiday on a Sunday moves to the next day that is
# not a holiday (until 2006 only to the Monday).
for day in sorted(base):
if day.weekday() == SUN:
sub = day + timedelta(days=1)
while y >= 2007 and sub in out:
sub += timedelta(days=1)
if sub not in out:
out[sub] = "Substitute holiday"
# Citizens' holiday: a day between two national holidays, unless a Sunday.
for day in sorted(base):
gap = day + timedelta(days=1)
if gap not in out and gap + timedelta(days=1) in base and gap.weekday() != SUN:
out[gap] = "Citizens' holiday"
return {d: n for d, n in out.items() if d.year == y}
def jpx_holidays(y):
"""The Tokyo Stock Exchange closes on Japan's national holidays and from
31 December to 3 January."""
out = japan_holidays(y)
out.setdefault(date(y, 1, 2), "Year end holidays")
out.setdefault(date(y, 1, 3), "Year end holidays")
out.setdefault(date(y, 12, 31), "Year end holidays")
return out
def _no_early_closes(y):
return {}
# --- FX: no exchange, only days most of the market is away ----------------------------------
def fx_closed(y):
"""Christmas Day and New Year's Day: most banks and brokers are away."""
return {date(y, 1, 1): "New Year's Day", date(y, 12, 25): "Christmas Day"}
def fx_thin(y):
"""Days with little liquidity by market convention, not by any rule:
the Christmas and New Year days, the Easter holidays of London and
Europe, and the two US holidays that empty New York."""
easter = dates.easter(y)
out = {
date(y, 12, 24): "Christmas Eve",
date(y, 12, 26): "Boxing Day",
date(y, 12, 31): "New Year's Eve",
easter - timedelta(days=2): "Good Friday",
easter + timedelta(days=1): "Easter Monday",
dates.nth_weekday(y, 11, THU, 4): "US Thanksgiving",
}
jul4 = {}
_us_observed(jul4, date(y, 7, 4), "US Independence Day")
out.update(jul4)
out.update(fx_closed(y))
return out
def _second_friday_expiry(y, m):
return dates.nth_weekday(y, m, FRI, 2)
def _third_friday_expiry(y, m):
return dates.nth_weekday(y, m, FRI, 3)
#: key: (name, time zone, first year, holidays(y), early closes(y), one-off closures, expiry Friday(y, m))
EXCHANGES = {
"nyse": ("New York Stock Exchange", "America/New_York", 2000, nyse_holidays, nyse_early_closes,
NYSE_CLOSURES, _third_friday_expiry),
"lse": ("London Stock Exchange", "Europe/London", 2000, lse_holidays, lse_early_closes,
LSE_CLOSURES, _third_friday_expiry),
"xetra": ("Xetra and Eurex equity derivatives, Frankfurt", "Europe/Berlin", 2012, xetra_holidays,
_no_early_closes, (), _third_friday_expiry),
"jpx": ("Tokyo Stock Exchange, Japan Exchange Group", "Asia/Tokyo", 2000, jpx_holidays, _no_early_closes,
JPX_CLOSURES, _second_friday_expiry),
"fx": ("Spot FX, days most of the market is away", "UTC", 2000, fx_closed, _no_early_closes, (), None),
}
# --- the day by day computation ----------------------------------------------------------------
def _counts(days, closed):
"""For one set of closures: per day whether it trades, trading days to the
next and from the last closure, and its place in the month."""
trading = [d.weekday() < SAT and d not in closed for d in days]
n = len(days)
to_hol, from_hol = [None] * n, [None] * n
count = None
for i in range(n - 1, -1, -1):
to_hol[i] = count
if days[i].weekday() < SAT and days[i] in closed:
count = 0
elif trading[i] and count is not None:
count += 1
count = None
for i in range(n):
from_hol[i] = count
if days[i].weekday() < SAT and days[i] in closed:
count = 0
elif trading[i] and count is not None:
count += 1
month_day, left = [None] * n, [0] * n
i = 0
while i < n:
j = i
while j < n and (days[j].year, days[j].month) == (days[i].year, days[i].month):
j += 1
total = sum(trading[i:j])
seen = 0
for k in range(i, j):
if trading[k]:
seen += 1
month_day[k] = seen
left[k] = total - seen
i = j
return trading, to_hol, from_hol, month_day, left
def _expiry_days(first_year, last_year, closed, friday_of):
"""The monthly expiry day: the given Friday, or the trading day before it
when the exchange is closed that Friday."""
out = set()
for y in range(first_year, last_year + 1):
for m in range(1, 13):
day = friday_of(y, m)
while day.weekday() >= SAT or day in closed:
day -= timedelta(days=1)
out.add(day)
return out
def build_calendar(key, first_year, last_year):
"""{day: value} for one exchange, every calendar day of first_year to
last_year. One extra year after is computed so the forward looking
fields at the end of the range are right."""
name, tz, _first, holidays_of, early_of, one_offs, friday_of = EXCHANGES[key]
days = dates.days(date(first_year, 1, 1), date(last_year + 1, 12, 31))
rule_closed = {}
early = {}
for y in range(first_year, last_year + 2):
rule_closed.update(holidays_of(y))
early.update(early_of(y))
thin = {}
if key == "fx":
for y in range(first_year, last_year + 2):
thin.update(fx_thin(y))
# One pass per state of knowledge: before any one-off closure was announced,
# then after each announcement in turn.
extra = sorted((date.fromisoformat(a), date.fromisoformat(d), n) for d, n, a, _src in one_offs)
epochs = [(date.min, dict(rule_closed))]
known = dict(rule_closed)
for announced, day, label in extra:
known = dict(known)
known[day] = label
epochs.append((announced, known))
passes = []
for _start, closed in epochs:
expiry = _expiry_days(first_year, last_year + 1, closed, friday_of) if friday_of else set()
passes.append((closed, expiry, _counts(days, closed)))
out = {}
epoch = 0
for i, day in enumerate(days):
if day.year > last_year:
break
while epoch + 1 < len(epochs) and epochs[epoch + 1][0] <= day:
epoch += 1
closed, expiry, (trading, to_hol, from_hol, month_day, left) = passes[epoch]
is_open = trading[i]
prev_wd = day - timedelta(days=3 if day.weekday() == MON else 1)
next_wd = day + timedelta(days=3 if day.weekday() == FRI else 1)
value = {
"open": is_open,
"holiday": closed.get(day) if day.weekday() < SAT else None,
"early_close": early.get(day) if is_open else None,
"early_close_utc": None,
"before_holiday": bool(is_open and next_wd in closed),
"after_holiday": bool(is_open and prev_wd in closed),
"days_to_holiday": to_hol[i],
"days_from_holiday": from_hol[i],
"month_day": month_day[i],
"month_days_left": left[i],
}
if value["early_close"]:
value["early_close_utc"] = dates.at_local(day, value["early_close"], tz).isoformat().replace(
"+00:00", "Z")
if friday_of:
value["expiry"] = bool(is_open and day in expiry)
value["quarterly_expiry"] = bool(value["expiry"] and day.month % 3 == 0)
if key == "fx":
value["thin"] = day in thin and day.weekday() < SAT
value["reason"] = thin.get(day) if value["thin"] else None
out[day.isoformat()] = value
return out
# --- the hook ---------------------------------------------------------------------------------------
_API = {"api": None}
def _log(message):
if _API["api"] is not None:
_API["api"].log(message)
def _config(key, default):
api = _API["api"]
return api.get_config(key, default) if api is not None else default
def wanted_exchanges(setting):
out = []
for token in str(setting or "").replace(",", " ").lower().split():
if token in EXCHANGES:
out.append(token)
else:
_log("{!r} is not one of {}".format(token, ", ".join(EXCHANGES)))
return list(dict.fromkeys(out))
def data_fetch(context):
"""Lifecycle hook: write the calendars that are missing or out of date.
Pure computation; a calendar is only rewritten when the rules, the first
year or the year changed."""
try:
first_year = int(float(_config("first_year", 2000)))
except (TypeError, ValueError):
first_year = 2000
last_year = int(feeds.today()[:4]) + 1
for key in wanted_exchanges(_config("exchanges", "nyse, lse, xetra, jpx, fx")):
name, tz, earliest, *_rest = EXCHANGES[key]
start = max(first_year, earliest)
feed = FEED_PREFIX + key
meta = feeds.read_meta(feed) or {}
if (meta.get("rules") == RULES_VERSION and meta.get("first_year") == start
and meta.get("last_year") == last_year):
continue
feeds.write_feed(
feed,
build_calendar(key, start, last_year),
meta={
"source": "Computed from the published holiday rules and closures of the {}".format(name),
"terms": "Rules and dates only, no vendor data (see the plugin's docs)",
"delay_rule": DELAY_RULE,
"time_zone": tz,
"rules": RULES_VERSION,
"first_year": start,
"last_year": last_year,
},
)
_log("wrote {} for {} to {}".format(feed, start, last_year))
def register(api):
_API["api"] = api
api.register_data_fetch(data_fetch)