diff --git a/BetterHolidays/__init__.py b/BetterHolidays/__init__.py index 3f46d2f..b722e11 100644 --- a/BetterHolidays/__init__.py +++ b/BetterHolidays/__init__.py @@ -1,6 +1,6 @@ from .days import Day, Holiday, TradingDay, PartialTradingDay, NonTradingDay from .multi import get_market -from .markets import Market, NYSE, MARKETS +from .markets import Market, NYSE, NASDAQ, SSE, LSE, MARKETS __all__ = [ "Day", @@ -10,6 +10,9 @@ "NonTradingDay", "MARKETS", "NYSE", + "NASDAQ", + "SSE", + "LSE", "Market", - "get_market" -] \ No newline at end of file + "get_market", +] diff --git a/BetterHolidays/const.py b/BetterHolidays/const.py index 33eae88..0e7fa5d 100644 --- a/BetterHolidays/const.py +++ b/BetterHolidays/const.py @@ -1,18 +1,10 @@ import typing as t -DAYS = ( - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" -) +DAYS = ("MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY") DAYS_MAP = {day: i for i, day in enumerate(DAYS)} -MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = 0,1,2,3,4,5,6 -DAYS_TYPE = t.Literal[0,1,2,3,4,5,6] +MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = 0, 1, 2, 3, 4, 5, 6 +DAYS_TYPE = t.Literal[0, 1, 2, 3, 4, 5, 6] MONTHS = ( "JANUARY", @@ -26,11 +18,24 @@ "SEPTEMBER", "OCTOBER", "NOVEMBER", - "DECEMBER" + "DECEMBER", ) MONTHS_MAP = {month: i for i, month in enumerate(MONTHS, 1)} -JANUARY, FEBRUARY, MARCH, APRIL, MAY, JUNE, JULY, AUGUST, SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER = range(1, 13) +( + JANUARY, + FEBRUARY, + MARCH, + APRIL, + MAY, + JUNE, + JULY, + AUGUST, + SEPTEMBER, + OCTOBER, + NOVEMBER, + DECEMBER, +) = range(1, 13) DAYS_IN_MONTH = { @@ -45,6 +50,5 @@ "SEPTEMBER": 30, "OCTOBER": 31, "NOVEMBER": 30, - "DECEMBER": 31 + "DECEMBER": 31, } - diff --git a/BetterHolidays/days.py b/BetterHolidays/days.py index 53ddf10..a588c2f 100644 --- a/BetterHolidays/days.py +++ b/BetterHolidays/days.py @@ -1,32 +1,39 @@ -import dataclasses as dc -import datetime as dt +import peewee as pw +from . import utils -@dc.dataclass(frozen=True) -class Day: +class Day(pw.Model): """Base class representing a calendar day.""" - date: dt.date -@dc.dataclass(frozen=True) + class Meta: + database = utils.get_db() + + date = pw.DateField(primary_key=True) + market = pw.CharField(null=True) + class Holiday(Day): """Represents a full holiday (market closed).""" - name: str -@dc.dataclass(frozen=True) + name = pw.CharField() + + class TradingDay(Day): """Represents a full trading day with standard open/close times.""" - open_time: dt.time - close_time: dt.time -@dc.dataclass(frozen=True) + open_time = pw.TimeField() + close_time = pw.TimeField() + + class NonTradingDay(Day): """Represents a non-trading day (e.g. weekends).""" pass -@dc.dataclass(frozen=True) + class PartialTradingDay(TradingDay, Holiday): """Represents a partial trading day (early close or late open).""" - name: str - early_close: bool = False - late_open: bool = False - early_close_reason: str = "" - late_open_reason: str = "" + + early_close = pw.BooleanField(default=False) + late_open = pw.BooleanField(default=False) + early_close_reason = pw.CharField(default="") + late_open_reason = pw.CharField(default="") + +utils.get_db().create_tables([Day, Holiday, TradingDay, NonTradingDay, PartialTradingDay]) diff --git a/BetterHolidays/markets/__init__.py b/BetterHolidays/markets/__init__.py index 9499699..c1913d4 100644 --- a/BetterHolidays/markets/__init__.py +++ b/BetterHolidays/markets/__init__.py @@ -1,9 +1,15 @@ from .market import Market from .nyse import NYSE +from .nasdaq import NASDAQ +from .sse import SSE +from .lse import LSE -MARKETS:'dict[str, type[Market]]' = { - "NYSE": NYSE +MARKETS: "dict[str, type[Market]]" = { + "NYSE": NYSE, + "NASDAQ": NASDAQ, + "SSE": SSE, + "LSE": LSE, } -__all__ = ["MARKETS", "Market", "NYSE"] \ No newline at end of file +__all__ = ["MARKETS", "Market", "NYSE", "NASDAQ", "SSE", "LSE"] diff --git a/BetterHolidays/markets/cache.py b/BetterHolidays/markets/cache.py index ab46a49..b9fe477 100644 --- a/BetterHolidays/markets/cache.py +++ b/BetterHolidays/markets/cache.py @@ -1,42 +1,61 @@ import datetime as dt import typing as t from ..days import Day -from ..utils import NOT_SET +from ..utils import NOT_SET, get_db +import peewee as pw T = t.TypeVar("T") +DB = get_db() + + class Cache: - def __init__(self): - self.cache: 'dict[dt.date, Day]' = {} + def __init__(self, market: "str"): + self.market = market - def get(self, key: 'dt.date') -> 't.Optional[Day]': - return self.cache.get(key) + def get(self, key: "dt.date") -> "Day": + selection = Day.select().where(Day.date == key, Day.market == self.market) + print(selection) + return selection.get() - def set(self, key: 'dt.date', value: 'Day'): - self.cache[key] = value + def set(self, day: "Day"): + day.market = self.market + day.save() - def get_or_set(self, key: 'dt.date', func: 't.Callable[[int], None]') -> 'Day': - if key in self.cache: + def get_or_set(self, key: "dt.date", func: "t.Callable[[int], None]") -> "Day": + try: return self.get(key) - func(key.year) - if key in self.cache: + except Exception: + func(key.year) + + try: return self.get(key) - raise ValueError("Cache miss") + except Exception: + raise ValueError(f"Could not find day {key} after fetching data") def clear(self): - self.cache.clear() + Day.delete().where(Day.market == self.market).execute() @t.overload - def pop(self, key:'dt.date') -> 'Day': ... + def pop(self, key: "dt.date") -> "Day": ... @t.overload - def pop(self, key:'dt.date', default:'T') -> 't.Union[Day, T]': ... + def pop(self, key: "dt.date", default: "T") -> "t.Union[Day, T]": ... def pop(self, key, default=NOT_SET): - if default == NOT_SET: - return self.cache.pop(key) - - return self.cache.pop(key, default) - - def __contains__(self, key: 'dt.date') -> bool: - return key in self.cache \ No newline at end of file + query = Day.select().where(Day.market == self.market, Day.date == key) + try: + item = query.get() + except pw.DoesNotExist: + if default is NOT_SET: + raise KeyError(key) from None + return default + + if default is NOT_SET: + item.delete_instance(recursive=False) + return item + + def __contains__(self, key: "dt.date") -> bool: + return ( + key in Day.select().where(Day.market == self.market, Day.date == key).get() + ) diff --git a/BetterHolidays/markets/holidays.py b/BetterHolidays/markets/holidays.py index fa8eacc..a6cb787 100644 --- a/BetterHolidays/markets/holidays.py +++ b/BetterHolidays/markets/holidays.py @@ -1,37 +1,83 @@ import datetime as dt from ..days import Day, Holiday, PartialTradingDay +from ..utils import flatten_list, default import BetterMD as md -from ..const import MONTHS_MAP, DAYS_TYPE, MONDAY, THURSDAY +from ..const import MONTHS_MAP, DAYS_TYPE, MONDAY, THURSDAY, SUNDAY import re +import typing as t -def next_day(day: 'DAYS_TYPE', want:'DAYS_TYPE') -> 'DAYS_TYPE': +def next_day(day: "DAYS_TYPE", want: "DAYS_TYPE") -> "DAYS_TYPE": """ Args: day: current day want: day to get - + Returns: days until want """ if day == want: return 0 elif want < day: - return (7-day)+want - return want-day + return (7 - day) + want + return want - day + + +def last_day(day: "DAYS_TYPE", want: "DAYS_TYPE") -> "DAYS_TYPE": + """ + Args: + day: current day + want: day to get + + Returns: + days until want + """ + return next_day(day, want) + + +def next_weekend(day: "DAYS_TYPE") -> "DAYS_TYPE": + """ + Args: + day: current day + + Returns: + days until want + """ + if day in [5, 6]: + return min(next_day(day + 1, 5), next_day(day + 1, 6)) + 1 + return next_day(day, 5) + + +def last_weekend(day: "DAYS_TYPE") -> "DAYS_TYPE": + """ + Args: + day: current day + + Returns: + days until want + """ + if day in [5, 6]: + return min(last_day(day - 1, 5), last_day(day - 1, 6)) - 1 + return last_day(day, 6) class CommonHoliday: - name: 'str' - month: 'int' = None - day: 'int' = None - type: 'type[Day]' = Holiday - - open_time: 'dt.time' = None - close_time: 'dt.time' = None - early_close: 'bool' = False - late_open: 'bool' = False - holiday_reason: 'str' = "" - - def __init__(self, days:'list[int]', change:'dict[int, int]', start: dt.date = None, end: dt.date = None): + name: "str" + month: "int" = None + day: "int" = None + type: "type[Day]" = Holiday + + open_time: "dt.time" = None + close_time: "dt.time" = None + early_close: "bool" = False + late_open: "bool" = False + holiday_reason: "str" = "" + + def __init__( + self, + days: "list[int]", + change: "dict[int, int]", + start: dt.date = None, + end: dt.date = None, + ): """ Args: days: What days this holiday is on @@ -42,82 +88,247 @@ def __init__(self, days:'list[int]', change:'dict[int, int]', start: dt.date = N self.start = start self.end = end - def get_date(self, year: 'int'): + def get_date(self, year: "int") -> "t.Union[dt.date, list[dt.date]]": return dt.date(year, self.month, self.day) - def __call__(self, year: 'int'): + def __call__(self, year: "int"): day = self.get_date(year) - if day.weekday() not in self.days: - if day.weekday() in self.change: - day += dt.timedelta(days=self.change[day.weekday()]) - if self.start and day < self.start: - return None + def for_day(day): + if day.weekday() not in self.days: + if day.weekday() in self.change: + day += dt.timedelta(days=self.change[day.weekday()]) + + if self.start and day < self.start: + return None + + if self.end and day > self.end: + return None + + if issubclass(self.type, Holiday): + return self.type( + date=day, + name=self.name, + ) + + elif issubclass(self.type, PartialTradingDay): + return self.type( + date=day, + open_time=self.open_time, + close_time=self.close_time, + early_close=self.early_close, + late_open=self.late_open, + early_close_reason=self.holiday_reason, + late_open_reason=self.holiday_reason, + ) - if self.end and day > self.end: return None - if issubclass(self.type, Holiday): - return self.type( - date=day, - name=self.name, - ) + if isinstance(day, list): + return [for_day(d) for d in day] + return for_day(day) - elif issubclass(self.type, PartialTradingDay): - return self.type( - date=day, - open_time=self.open_time, - close_time=self.close_time, - early_close=self.early_close, - late_open=self.late_open, - early_close_reason=self.holiday_reason, - late_open_reason=self.holiday_reason - ) +class ConsistentAbnormalDay(CommonHoliday): + def __init__( + self, + name: "str", + days: list[int], + change: dict[int, int], + month: "int" = None, + day: "int" = None, + type: "type[Day]" = Holiday, + open_time: "dt.time" = None, + close_time: "dt.time" = None, + early_close: "bool" = False, + late_open: "bool" = False, + holiday_reason: "str" = "", + start: dt.date = None, + end: dt.date = None, + ): + self.name = name + self.month = month + self.day = day + self.type = type + self.open_time = open_time + self.close_time = close_time + self.early_close = early_close + self.late_open = late_open + self.holiday_reason = holiday_reason + super().__init__(days, change, start, end) - return None class NewYearsDay(CommonHoliday): name = "New Year's Day" month = 1 day = 1 + +def get_date_from_timeanddate(td: "md.elements.Td", year: "int") -> "dt.date": + table = td.table + table_td = table.parent + table_row: "md.elements.Tr" = table_td.parent + table_body: "md.elements.TBody" = table_row.parent + header_row: "md.elements.Tr" = table_body.children[ + table_body.children.index(table_row) - 1 + ] + header_td: "md.elements.Td" = header_row.children[table_row.index(table_td)] + month = MONTHS_MAP[header_td.text.strip().upper()] + day = int(td.text.strip()) + return dt.date(year, month, day) + +class ChineseNewYearsDay(CommonHoliday): + name = "Chinese New Year's Day" + + def get_date(self, year: "int") -> "list[dt.date]": + page = md.HTML.from_url( + f"https://www.timeanddate.com/calendar/?year={year}&country=41" + ) + print(page) + print(len(page)) + page = page[1] + cny = [ + get_date_from_timeanddate(td, year) + for td in page.inner_html.get_by_attr("title", "Chinese New Year") + ] + return flatten_list( + [ + cny[0] - dt.timedelta(last_day(cny[0].weekday(), SUNDAY)), + cny, + cny[-1] + dt.timedelta(days=next_weekend(cny[-1].weekday())), + ] + ) + + +class ChineseNationalDay(CommonHoliday): + name = "Chinese National Day" + + def get_date(self, year: int) -> 'dt.date | list[dt.date]': + page = md.HTML.from_url( + f"https://www.timeanddate.com/calendar/?year={year}&country=41" + ) + cnd = page.inner_html.get_by_attr("title", "Chinese National Day")[0] + cndw = [ + get_date_from_timeanddate(td, year) + for td in page.inner_html.get_by_attr( + "title", "National Day Golden Week holiday" + ) + ] + + return flatten_list( + [ + cnd - dt.timedelta(last_weekend(cnd.weekday())), + cnd, + cndw, + cndw[-1] + dt.timedelta(days=next_weekend(cndw[-1].weekday())), + ] + ) + + +class QingMingFestival(CommonHoliday): + name = "Qing Ming Jie Festival" + + def get_date(self, year: "int") -> "list[dt.date]": + page = md.HTML.from_url( + f"https://www.timeanddate.com/calendar/?year={year}&country=41" + ) + qmf = [ + get_date_from_timeanddate(td, year) + for td in page.inner_html.get_by_attr("title", "Qing Ming Jie holiday") + ] + return flatten_list( + [qmf, qmf[-1] + dt.timedelta(days=next_weekend(qmf[-1].weekday()))] + ) + + +class ChineseLabourDay(CommonHoliday): + name = "Chinese Labour Day" + + def get_date(self, year: "int") -> "list[dt.date]": + may1 = dt.date(year, 5, 1) + return [ + may1 - dt.timedelta(days=last_weekend(may1.weekday())), + may1, + dt.date(year, 5, 2), + dt.date(year, 5, 3), + dt.date(year, 5, 4), + dt.date(year, 5, 5), + ] + +class DragonBoatFestival(CommonHoliday): + name = "Dragon Boat Festival" + + def get_date(self, year: "int") -> "list[dt.date]": + page = md.HTML.from_url( + f"https://www.timeanddate.com/calendar/?year={year}&country=41" + ) + dbf = [ + get_date_from_timeanddate(td, year) + for td in page.inner_html.get_by_attr("title", "Dragon Boat Festival") + ] + + return dbf + + +class AutumnFestival(CommonHoliday): + name = "Autumn Festival" + + def get_date(self, year: "int") -> "list[dt.date]": + page = md.HTML.from_url( + f"https://www.timeanddate.com/calendar/?year={year}&country=41" + ) + af = [ + get_date_from_timeanddate(td, year) + for td in page.inner_html.get_by_attr("title", "Autumn Festival") + ] + af.insert(0, af[0] - dt.timedelta(last_weekend(af[0].weekday()))) + return af + + class MartinLutherKingJrDay(CommonHoliday): """ 3rd Monday in January """ + name = "Martin Luther King Jr. Day" - def get_date(self, year: 'int'): + def get_date(self, year: "int"): jan21 = dt.date(year, 1, 21) - return jan21+dt.timedelta(days=(next_day(jan21.weekday(), MONDAY))) + return jan21 + dt.timedelta(days=(next_day(jan21.weekday(), MONDAY))) + class WashingtonsBirthday(CommonHoliday): """ 3rd Monday in February """ + name = "Washington's Birthday" - def get_date(self, year: 'int'): + def get_date(self, year: "int"): feb15 = dt.date(year, 2, 15) - return feb15+dt.timedelta(days=next_day(feb15.weekday(), MONDAY)) + return feb15 + dt.timedelta(days=next_day(feb15.weekday(), MONDAY)) + class LincolnsBirthday(CommonHoliday): """ 3rd Monday in February """ + name = "Lincolns Birthday" month = 2 day = 12 + + class GoodFriday(CommonHoliday): """ See website for day """ + name = "Good Friday" regex = re.compile(r"(\d+) ([a-zA-Z]+) (\d+)") - def get_date(self, year: 'int') -> 'dt.date': + def get_date(self, year: "int") -> "dt.date": try: url = f"https://www.calendar-365.co.uk/holidays/{year}.html" try: @@ -126,9 +337,19 @@ def get_date(self, year: 'int') -> 'dt.date': raise ValueError(f"Better Markdown error: {str(e)}") from e try: - elements = html.inner_html.advanced_find("a", attrs={"href": "https://www.calendar-365.co.uk/holidays/good-friday.html", "class": "link_arrow", "title": "Good Friday 2026", "text": "Good Friday"}) # The title is 'Good Friday 2026' for all years + elements = html.inner_html.advanced_find( + "a", + attrs={ + "href": "https://www.calendar-365.co.uk/holidays/good-friday.html", + "class": "link_arrow", + "title": "Good Friday 2026", + "text": "Good Friday", + }, + ) # The title is 'Good Friday 2026' for all years if not elements: - raise ValueError(f"Could not find Good Friday information for {year}") + raise ValueError( + f"Could not find Good Friday information for {year}" + ) except Exception as e: raise ValueError(f"Error finding Good Friday information: {str(e)}") @@ -136,47 +357,58 @@ def get_date(self, year: 'int') -> 'dt.date': day, month, _ = self.regex.match(tr.children[0].text).groups() return dt.date(year, MONTHS_MAP[month.upper()], int(day)) except Exception as e: - raise ValueError(f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})") + raise ValueError( + f"Error determining Good Friday date for {year}: {str(e)} ({type(e)})" + ) + class MemorialDay(CommonHoliday): """ Last Monday in May """ + name = "Memorial Day" - def get_date(self, year: 'int'): + def get_date(self, year: "int"): may31 = dt.date(year, 5, 31).weekday() - return dt.date(year, 5, 31-may31) + return dt.date(year, 5, 31 - may31) + class JuneteenthNationalIndependenceDay(CommonHoliday): name = "Juneteenth National Independence Day" month = 6 day = 19 + class IndependenceDay(CommonHoliday): name = "Independence Day" month = 7 day = 4 + class LaborDay(CommonHoliday): """ 1st Monday in September """ + name = "Labor Day" - def get_date(self, year: 'int'): + def get_date(self, year: "int"): sept1 = dt.date(year, 9, 1) - return sept1+dt.timedelta(days=next_day(sept1.weekday(), MONDAY)) + return sept1 + dt.timedelta(days=next_day(sept1.weekday(), MONDAY)) + class Thanksgiving(CommonHoliday): """ 4th Thursday in November """ + name = "Thanksgiving" - def get_date(self, year: 'int'): + def get_date(self, year: "int"): nov25 = dt.date(year, 11, 25) - return nov25+dt.timedelta(days=next_day(nov25.weekday(), THURSDAY)) + return nov25 + dt.timedelta(days=next_day(nov25.weekday(), THURSDAY)) + class Christmas(CommonHoliday): name = "Christmas" diff --git a/BetterHolidays/markets/lse.py b/BetterHolidays/markets/lse.py new file mode 100644 index 0000000..72e790a --- /dev/null +++ b/BetterHolidays/markets/lse.py @@ -0,0 +1,56 @@ +from .market import Market +import datetime as dt +from ..days import Holiday, TradingDay, NonTradingDay +from ..const import MONTHS_MAP +from ..utils import iter_year, classproperty +import BetterMD as md +import re + + +class LSE(Market): + name = "London Stock Exchange" + country = "UK" + include_country_holidays = True + excluded_country_holidays = [] + + standard_open_time = dt.time(hour=9, minute=30) + standard_close_time = dt.time(hour=16) + + abnormal_days = {} + + REGEX = re.compile(r"(\d+) ([a-zA-Z]+) (\d+)") + + @classproperty + def weekdays(cls): + return [0, 1, 2, 3, 4] + + @classmethod + def fetch_data(cls, year: "int"): + try: + table: "md.elements.Table" = md.HTML.from_url( + f"https://www.calendar-365.co.uk/holidays/{year}.html" + ).inner_html.get_elements_by_class_name("table")[0] + holidays = {} + + for tr in table.body[0]: + day, month, _ = cls.REGEX.match(tr.children[0].text).groups() + date = dt.date(year, MONTHS_MAP[month.upper()], int(day)) + holidays[date] = Holiday(date, name=tr.children[1].text) + + for day in iter_year(year): + if day in holidays: + cls.cache.set(holidays[day]) + elif day.weekday() in cls.weekdays: + cls.cache.set( + TradingDay( + date=day, + open_time=cls.standard_open_time, + close_time=cls.standard_close_time, + ) + ) + else: + cls.cache.set(NonTradingDay(date=day)) + except Exception as e: + raise ValueError(f"Error fetching LSE data for {year}: {e}") + + return holidays diff --git a/BetterHolidays/markets/market.py b/BetterHolidays/markets/market.py index 2bdcc7e..a132b65 100644 --- a/BetterHolidays/markets/market.py +++ b/BetterHolidays/markets/market.py @@ -1,29 +1,38 @@ import datetime as dt import typing as t from abc import ABC, abstractmethod -from ..days import Day, Holiday, TradingDay, PartialTradingDay +from ..days import Day, Holiday, TradingDay, PartialTradingDay, NonTradingDay from ..const import DAYS_TYPE -from ..utils import iterate_date, abstract_const, classproperty +from ..utils import iterate_date, iter_year, abstract_const, classproperty, default from .cache import Cache + class Market(ABC): - cache: 'Cache' + cache: "Cache" def __init_subclass__(cls) -> None: - cls.cache = Cache() + cls.cache = Cache(cls.name) name = abstract_const() country = abstract_const() include_country_holidays = abstract_const() excluded_country_holidays = abstract_const() _weekends = None + abnormal_days = default(dict) + holidays = default(list) + standard_open_time = None + standard_close_time = None @classmethod def validate_options(cls): assert isinstance(cls.name, str), "Market name must be a string" assert isinstance(cls.country, str), "Country must be a string" - assert isinstance(cls.include_country_holidays, bool), "Include country holidays must be a boolean" - assert isinstance(cls.excluded_country_holidays, list), "Excluded country holidays must be a list" + assert isinstance(cls.include_country_holidays, bool), ( + "Include country holidays must be a boolean" + ) + assert isinstance(cls.excluded_country_holidays, list), ( + "Excluded country holidays must be a list" + ) @classproperty @abstractmethod @@ -45,64 +54,137 @@ def weekends(cls) -> DAYS_TYPE: return cls._weekends @classmethod - @abstractmethod - def fetch_data(cls, year: 'int'): + def fetch_data(cls, year: "int"): """ - Fetch data between start and end dates. - Must be implemented by subclasses. + Fetch data for a given year. """ - ... + yr = {} + + for holiday in cls.holidays: + d = holiday(year) + if d is None: + continue + elif isinstance(d, list): + for day in d: + if day is None: + continue + yr[day.date] = day + else: + yr[d.date] = d + + for day in iter_year(year): + if day in yr: + cls.cache.set(yr[day]) + elif day in cls.abnormal_days: + cls.cache.set(cls.abnormal_days[day]) + elif day.weekday() in cls.weekdays: + cls.cache.set( + TradingDay( + date=day, + open_time=cls.standard_open_time, + close_time=cls.standard_close_time, + ), + ) + else: + cls.cache.set(NonTradingDay(date=day)) @classmethod - def get_holidays(cls, start: 'dt.date', end: 'dt.date') -> list[Holiday]: + def get_holidays(cls, start: "dt.date", end: "dt.date") -> list[Holiday]: """Return list of holidays between start and end dates.""" - return list(filter(lambda d: isinstance(d, Holiday), [cls.day(day) for day in iterate_date(start, end)])) + return list( + filter( + lambda d: isinstance(d, Holiday), + [cls.day(day) for day in iterate_date(start, end)], + ) + ) + + @classmethod + def get_holidays_for_year(cls, year: int) -> list[Holiday]: + """Return list of holidays for a given year.""" + start = dt.date(year, 1, 1) + end = dt.date(year, 12, 31) + return cls.get_holidays(start, end) @classmethod - def get_partial_days(cls, start: 'dt.date', end: 'dt.date') -> 'list[PartialTradingDay]': - return list(filter(lambda d: isinstance(d, PartialTradingDay), [cls.day(day) for day in iterate_date(start, end)])) + def get_partial_days( + cls, start: "dt.date", end: "dt.date" + ) -> "list[PartialTradingDay]": + return list( + filter( + lambda d: isinstance(d, PartialTradingDay), + [cls.day(day) for day in iterate_date(start, end)], + ) + ) + + @classmethod + def get_partial_days_for_year(cls, year: int) -> list[PartialTradingDay]: + """Return list of partial trading days for a given year.""" + start = dt.date(year, 1, 1) + end = dt.date(year, 12, 31) + return cls.get_partial_days(start, end) @classmethod - def get_trading_days(cls, start: 'dt.date', end: 'dt.date') -> list[TradingDay]: + def get_trading_days(cls, start: "dt.date", end: "dt.date") -> list[TradingDay]: """Return list of trading days between start and end dates.""" - return list(filter(lambda d: isinstance(d, TradingDay), [cls.day(day) for day in iterate_date(start, end)])) + return list( + filter( + lambda d: isinstance(d, TradingDay), + [cls.day(day) for day in iterate_date(start, end)], + ) + ) + + @classmethod + def get_trading_days_for_year(cls, year: int) -> list[TradingDay]: + """Return list of trading days for a given year.""" + start = dt.date(year, 1, 1) + end = dt.date(year, 12, 31) + return cls.get_trading_days(start, end) @classmethod - def is_weekday(cls, date: 'dt.date') -> bool: + def is_weekday(cls, date: "dt.date") -> bool: return date.weekday() in cls.weekdays @classmethod - def is_weekend(cls, date: 'dt.date') -> bool: + def is_weekend(cls, date: "dt.date") -> bool: return date.weekday() in cls.weekends @classmethod - def is_holiday(cls, date: 'dt.date') -> bool: + def is_holiday(cls, date: "dt.date") -> bool: day = cls.day(date) if not isinstance(day, Holiday): return False return True @classmethod - def is_partial_day(cls, date: 'dt.date') -> 'bool': + def is_partial_day(cls, date: "dt.date") -> "bool": day = cls.day(date) if not isinstance(day, PartialTradingDay): return False return True @classmethod - def is_trading_day(cls, date: 'dt.date') -> 'bool': + def is_trading_day(cls, date: "dt.date") -> "bool": day = cls.day(date) if not isinstance(day, TradingDay): return False return True @classmethod - def get_trading_day(cls, date: 'dt.date') -> 't.Optional[TradingDay]': + def get_trading_day(cls, date: "dt.date") -> "t.Optional[TradingDay]": day = cls.day(date) if not isinstance(day, TradingDay): return None return day @classmethod - def day(cls, date: 'dt.date') -> 'Day': - return cls.cache.get_or_set(date, cls.fetch_data) \ No newline at end of file + def day(cls, date: "dt.date") -> "Day": + return cls.cache.get_or_set(date, cls.fetch_data) + + @classmethod + def days(cls, start: "dt.date", end: "dt.date") -> "list[Day]": + return list( + filter( + lambda d: isinstance(d, Day), + [cls.day(day) for day in iterate_date(start, end)], + ) + ) diff --git a/BetterHolidays/markets/nasdaq.py b/BetterHolidays/markets/nasdaq.py new file mode 100644 index 0000000..406942c --- /dev/null +++ b/BetterHolidays/markets/nasdaq.py @@ -0,0 +1,159 @@ +import datetime as dt +from ..days import Day, Holiday, TradingDay, PartialTradingDay, NonTradingDay +from ..const import MONTHS_MAP +from ..utils import iter_year, classproperty +from .market import Market +from .holidays import ( + NewYearsDay, + MartinLutherKingJrDay, + WashingtonsBirthday, + GoodFriday, + MemorialDay, + JuneteenthNationalIndependenceDay, + IndependenceDay, + LaborDay, + Thanksgiving, + Christmas, + ConsistentAbnormalDay, +) +import BetterMD as md +import zoneinfo as zi + + +class NASDAQ(Market): + name = "NASDAQ" + country = "US" + include_country_holidays = True + excluded_country_holidays = [] + + tz = zi.ZoneInfo("America/New_York") + + standard_open_time = dt.time(hour=9, minute=30, tzinfo=tz) + standard_close_time = dt.time(hour=16, tzinfo=tz) + + abnormal_days: "dict[dt.date, Day]" = {} + + holidays = [ + NewYearsDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + MartinLutherKingJrDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + WashingtonsBirthday([0, 1, 2, 3, 4], change={6: 1}), + GoodFriday([0, 1, 2, 3, 4], change={6: 1}), + MemorialDay([0, 1, 2, 3, 4, 5], change={6: 1}), + JuneteenthNationalIndependenceDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + ConsistentAbnormalDay( + "Independence Day", + [0, 1, 2, 3, 4], + change={6: 1, 5: -1}, + month=7, + day=3, + type=PartialTradingDay, + open_time=dt.time(hour=9, minute=30), + close_time=dt.time(hour=13), + early_close=True, + late_open=True, + holiday_reason="Independence Day", + ), + IndependenceDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + LaborDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + Thanksgiving([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + ConsistentAbnormalDay( + "Thanksgiving", + [0, 1, 2, 3, 4], + change={6: 1, 5: -1}, + month=11, + day=27, + type=PartialTradingDay, + open_time=dt.time(hour=9, minute=30), + close_time=dt.time(hour=13), + early_close=True, + late_open=True, + holiday_reason="Thanksgiving", + ), + ConsistentAbnormalDay( + "Christmas", + [0, 1, 2, 3, 4], + change={6: 1, 5: -1}, + month=12, + day=24, + type=PartialTradingDay, + open_time=dt.time(hour=9, minute=30), + close_time=dt.time(hour=13), + early_close=True, + late_open=True, + holiday_reason="Christmas", + ), + Christmas([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + ] + + @classproperty + def weekdays(cls): + return [0, 1, 2, 3, 4] + + @classmethod + def fetch_data(cls, year: "int"): + if year < dt.date.today().year: + return cls.fetch_past(year) + elif year == dt.date.today().year: + return cls.fetch_future() + + @classmethod + def fetch_past(cls, year: "int"): + return super().fetch_data(year) + + @classmethod + def fetch_future(cls): + page = md.HTML.from_url( + f"https://www.nasdaq.com/market-activity/stock-market-holiday-schedule" + ) + table: "md.elements.Table" = page.inner_html.get_elements_by_class_name( + "nsdq_table" + )[0] + holidays = {} + for tr in table.body[0]: + name, day, status = ( + tr.children[0].text, + tr.children[1].text, + tr.children[2].text, + ) + + month, date = day.split(" ") + date = dt.date(dt.date.today().year, MONTHS_MAP[month.upper()], int(date)) + + if status == "Closed": + holidays[date] = Holiday(date=date, name=name) + + elif name == "Early Close": + time, period = status.split(" ") + hour, minute = time.split(":") + time = dt.datetime.combine( + date, dt.time(hour=int(hour), minute=int(minute)) + ) + if period == "p.m.": + time = time + dt.timedelta(hours=12) + + time = time.time() + + holidays[date] = PartialTradingDay( + date=date, + name=name, + open_time=cls.standard_open_time, + close_time=time, + early_close=True, + early_close_reason=name, + ) + else: + holidays[date] = Holiday(date=date, name=name) + + for day in iter_year(dt.date.today().year): + if day in holidays: + cls.cache.set(holidays[day]) + elif day.weekday() in cls.weekdays: + cls.cache.set( + TradingDay( + date=day, + open_time=cls.standard_open_time, + close_time=cls.standard_close_time, + ) + ) + else: + cls.cache.set(NonTradingDay(date=day)) diff --git a/BetterHolidays/markets/nyse.py b/BetterHolidays/markets/nyse.py index f03c406..bcc1c86 100644 --- a/BetterHolidays/markets/nyse.py +++ b/BetterHolidays/markets/nyse.py @@ -1,180 +1,252 @@ import BetterMD as md from BetterMD import elements as elm from .market import Market, classproperty -from .holidays import NewYearsDay, MartinLutherKingJrDay, WashingtonsBirthday, LincolnsBirthday, GoodFriday, MemorialDay, JuneteenthNationalIndependenceDay, IndependenceDay, LaborDay, Thanksgiving, Christmas, CommonHoliday +from .holidays import ( + NewYearsDay, + MartinLutherKingJrDay, + WashingtonsBirthday, + LincolnsBirthday, + GoodFriday, + MemorialDay, + JuneteenthNationalIndependenceDay, + IndependenceDay, + LaborDay, + Thanksgiving, + Christmas, + CommonHoliday, +) from ..days import Day, Holiday, TradingDay, PartialTradingDay, NonTradingDay from ..const import MONTHS_MAP +from ..utils import iter_year import datetime as dt +import zoneinfo as zi # Standard open/close times = 9:30 - 4:00 # * Close at 1pm # ** Closes at 1pm # *** Closes at 1pm -def iter_days(start: dt.date, end: dt.date): - current = start - while current <= end: - yield current - current += dt.timedelta(days=1) - -def iter_year(year: int): - start = dt.date(year, 1, 1) - end = dt.date(year, 12, 31) - return iter_days(start, end) class NYSE(Market): - name = "NYSE" - country = "US" - include_country_holidays = True - excluded_country_holidays = [] - - standard_open_time = dt.time(hour=9, minute=30) - standard_close_time = dt.time(hour=16) - - abnormal_days: 'dict[dt.date, Day]' = { - dt.date(1903, 2, 1): Holiday(date=dt.date(1903, 2, 1), name="Washington's Birthday"), - dt.date(1901, 2, 23): Holiday(date=dt.date(1901, 2, 23), name="Washington's Birthday"), - dt.date(1907, 2, 23): Holiday(date=dt.date(1907, 2, 23), name="Washington's Birthday"), - dt.date(1929, 2, 23): Holiday(date=dt.date(1929, 2, 23), name="Washington's Birthday"), - dt.date(1946, 2, 23): Holiday(date=dt.date(1911, 2, 23), name="Washington's Birthday"), - - - dt.date(2001, 9, 11): Holiday(date=dt.date(2001, 9, 11), name="9/11"), - dt.date(2001, 9, 12): Holiday(date=dt.date(2001, 9, 12), name="9/11"), - dt.date(2001, 9, 13): Holiday(date=dt.date(2001, 9, 13), name="9/11"), - dt.date(2001, 9, 14): Holiday(date=dt.date(2001, 9, 14), name="9/11"), - - dt.date(2001, 9, 17): PartialTradingDay(name="9/11 moment of silence", date=dt.date(2001, 9, 17), open_time=dt.time(hour=9, minute=33), close_time=standard_close_time, late_open=True, late_open_reason="Moment of silence for 9/11"), - dt.date(2001, 10, 8): PartialTradingDay(name="Enduring Freedom", date=dt.date(2001, 10, 8), open_time=dt.time(hour=9, minute=31), close_time=standard_close_time, late_open=True, late_open_reason="Moment of silence for Enduring Freedom"), - - dt.date(2002, 9, 11): PartialTradingDay(name="9/11 Anniversary", date=dt.date(2002, 9, 11), open_time=dt.time(hour=12), close_time=standard_close_time, late_open=True, late_open_reason="9/11 Anniversary"), - - dt.date(2003, 2, 20): PartialTradingDay(name="Enduring Freedom", date=dt.date(2003, 2, 20), open_time=dt.time(hour=9, minute=32), close_time=standard_close_time, late_open=True, late_open_reason="Moment of silence for Enduring Freedom"), - - dt.date(2004, 6, 7): PartialTradingDay(name="President Ronald Reagan's death", date=dt.date(2004, 6, 7), open_time=dt.time(hour=9, minute=32), close_time=standard_close_time, late_open=True, late_open_reason="Moment of silence for President Ronald Reagan's death"), - dt.date(2004, 6, 11): Holiday(date=dt.date(2004, 6, 11), name="Morning President Ronald Reagan's death"), - - dt.date(2005,6, 1): PartialTradingDay(name="President Ronald Reagan's death", date=dt.date(2005, 6, 1), open_time=standard_open_time, close_time=dt.time(hour=15, minute=36), early_close=True, early_close_reason="Moment of silence for President Ronald Reagan's death"), - - dt.date(2006, 12, 27): PartialTradingDay(name="President Gerald Ford's death", date=dt.date(2006, 12, 27), open_time=dt.time(hour=9, minute=32), close_time=standard_close_time, late_open=True, late_open_reason="Moment of silence for President Gerald Ford's death"),# - - dt.date(2007, 1, 2): Holiday(date=dt.date(2007, 1, 2), name="Mourning of President Gerald Ford's death"), - dt.date(2012, 10, 29): Holiday(date=dt.date(2012, 10, 29), name="Hurricane Sandy"), - dt.date(2012, 10, 30): Holiday(date=dt.date(2012, 10, 30), name="Hurricane Sandy"), - - dt.date(2018, 12, 5): Holiday(date=dt.date(2018, 12, 5), name="President George H.W. Bush's death"), - - dt.date(2025, 1, 9): Holiday(date=dt.date(2025, 1, 9), name="President Jimmy Carter's death"), - } - - holidays:'list[CommonHoliday]' = [ - NewYearsDay([0,1,2,3,4], change={6: 1, 5: -1}, start=dt.date(1952, 9, 29) ), # Saturday -> Friday, Sunday -> Monday - NewYearsDay([0,1,2,3,4,5], change={6: 1}, end=dt.date(1952, 9, 28) ), # Saturday -> Friday, Sunday -> Monday - MartinLutherKingJrDay([0,1,2,3,4], change={6: 1, 5: -1}, start=dt.date(1998, 1, 1)), - WashingtonsBirthday([0,1,2,3,4], change={6: 1}, end=dt.date(1952, 9, 28)), - WashingtonsBirthday([0,1,2,3,4], change={6: 1, 5:-1}, start=dt.date(1952, 9, 28), end=dt.date(1963, 12, 31)), - WashingtonsBirthday([0,1,2,3,4], change={6: 1, 5:-1}, start=dt.date(1964, 1, 1), end=dt.date(1970, 12, 31)), - LincolnsBirthday([0,1,2,3,4], change={6: 1, 5: -1} ), - GoodFriday([0,1,2,3,4], change={6: 1, 5: -1}), - MemorialDay([0,1,2,3,4,5], change={6: 1}, end=dt.date(1952, 9, 28) ), - MemorialDay([0,1,2,3,4], change={6: 1, 5:-1}, start=dt.date(1952, 9, 28), end=dt.date(1963, 12, 31) ), - MemorialDay([0,1,2,3,4], change={6: 1, 5:-1}, start=dt.date(1964, 1, 1)), - JuneteenthNationalIndependenceDay([0,1,2,3,4], change={6: 1, 5: -1}, start=dt.date(2022, 1, 1)), - IndependenceDay([0,1,2,3,4], change={6: 1, 5: -1} ), - LaborDay([0,1,2,3,4], change={6: 1, 5: -1} ), - Thanksgiving([0,1,2,3,4], change={6: 1, 5: -1} ), - Christmas([0,1,2,3,4], change={6: 1, 5: -1} ) - ] - - - @classproperty - def weekdays(cls): - return [0,1,2,3,4] - - @classmethod - def fetch_data(cls, year: 'int'): - if year < dt.date.today().year: - return cls.fetch_past(year) - else: - return cls.fetch_future() - - @classmethod - def fetch_past(cls, year: 'int'): - yr = {} - - for holiday in cls.holidays: - d = holiday(year) - if d is None: - continue - yr[d.date] = d - - for day in iter_year(year): - if day in yr: - cls.cache.set(day, yr[day]) - elif day in cls.abnormal_days: - cls.cache.set(day, cls.abnormal_days[day]) - elif day.weekday() in cls.weekdays: - cls.cache.set(day, TradingDay(date=day, open_time=cls.standard_open_time, close_time=cls.standard_close_time)) - else: - cls.cache.set(day, NonTradingDay(date=day)) - - @classmethod - def get_day_type(cls, day: dt.date) -> type[Day]: - if day in cls.abnormal_days: - return cls.abnormal_days[day] - - elif day in cls.weekdays: - return TradingDay - else: - return NonTradingDay - - @classmethod - def fetch_future(cls): - doc = md.HTML.from_url("https://www.nyse.com/markets/hours-calendars") - table:'elm.Table' = doc.inner_html.get_elements_by_class_name("table-data")[0] - - table_dict = table.to_dict() - holidays = table_dict.pop("Holiday") - - for year, dates in table_dict.items(): - def handle_date(date:'str'): - split_date = date.split(" ") - - return dt.date(int(year), int(MONTHS_MAP[split_date[1].upper()]), int(split_date[2].replace("*", ""))) - - hol_dates = {handle_date(date): hol for date, hol in zip(dates, holidays)} - - for day in iter_year(int(year)): - if day in hol_dates: - name = hol_dates[day] - if name.endswith("*"): - cls.cache.set( - day, - PartialTradingDay( - date=day, - name=name.removesuffix("*"), - open_time=dt.time(hour=9, minute=30), - close_time=dt.time(hour=13), - early_close=True, - early_close_reason=name.removesuffix("*") - ) - ) - else: - cls.cache.set( - day, - Holiday( - date=day, - name=name - ) - ) + name = "NYSE" + country = "US" + include_country_holidays = True + excluded_country_holidays = [] + + tz = zi.ZoneInfo("America/New_York") + + standard_open_time = dt.time(hour=9, minute=30, tzinfo=tz) + standard_close_time = dt.time(hour=16, tzinfo=tz) + + abnormal_days: "dict[dt.date, Day]" = { + dt.date(1903, 2, 1): Holiday( + date=dt.date(1903, 2, 1), name="Washington's Birthday" + ), + dt.date(1901, 2, 23): Holiday( + date=dt.date(1901, 2, 23), name="Washington's Birthday" + ), + dt.date(1907, 2, 23): Holiday( + date=dt.date(1907, 2, 23), name="Washington's Birthday" + ), + dt.date(1929, 2, 23): Holiday( + date=dt.date(1929, 2, 23), name="Washington's Birthday" + ), + dt.date(1946, 2, 23): Holiday( + date=dt.date(1911, 2, 23), name="Washington's Birthday" + ), + dt.date(2001, 9, 11): Holiday(date=dt.date(2001, 9, 11), name="9/11"), + dt.date(2001, 9, 12): Holiday(date=dt.date(2001, 9, 12), name="9/11"), + dt.date(2001, 9, 13): Holiday(date=dt.date(2001, 9, 13), name="9/11"), + dt.date(2001, 9, 14): Holiday(date=dt.date(2001, 9, 14), name="9/11"), + dt.date(2001, 9, 17): PartialTradingDay( + name="9/11 moment of silence", + date=dt.date(2001, 9, 17), + open_time=dt.time(hour=9, minute=33), + close_time=standard_close_time, + late_open=True, + late_open_reason="Moment of silence for 9/11", + ), + dt.date(2001, 10, 8): PartialTradingDay( + name="Enduring Freedom", + date=dt.date(2001, 10, 8), + open_time=dt.time(hour=9, minute=31), + close_time=standard_close_time, + late_open=True, + late_open_reason="Moment of silence for Enduring Freedom", + ), + dt.date(2002, 9, 11): PartialTradingDay( + name="9/11 Anniversary", + date=dt.date(2002, 9, 11), + open_time=dt.time(hour=12), + close_time=standard_close_time, + late_open=True, + late_open_reason="9/11 Anniversary", + ), + dt.date(2003, 2, 20): PartialTradingDay( + name="Enduring Freedom", + date=dt.date(2003, 2, 20), + open_time=dt.time(hour=9, minute=32), + close_time=standard_close_time, + late_open=True, + late_open_reason="Moment of silence for Enduring Freedom", + ), + dt.date(2004, 6, 7): PartialTradingDay( + name="President Ronald Reagan's death", + date=dt.date(2004, 6, 7), + open_time=dt.time(hour=9, minute=32), + close_time=standard_close_time, + late_open=True, + late_open_reason="Moment of silence for President Ronald Reagan's death", + ), + dt.date(2004, 6, 11): Holiday( + date=dt.date(2004, 6, 11), name="Morning President Ronald Reagan's death" + ), + dt.date(2005, 6, 1): PartialTradingDay( + name="President Ronald Reagan's death", + date=dt.date(2005, 6, 1), + open_time=standard_open_time, + close_time=dt.time(hour=15, minute=36), + early_close=True, + early_close_reason="Moment of silence for President Ronald Reagan's death", + ), + dt.date(2006, 12, 27): PartialTradingDay( + name="President Gerald Ford's death", + date=dt.date(2006, 12, 27), + open_time=dt.time(hour=9, minute=32), + close_time=standard_close_time, + late_open=True, + late_open_reason="Moment of silence for President Gerald Ford's death", + ), # + dt.date(2007, 1, 2): Holiday( + date=dt.date(2007, 1, 2), name="Mourning of President Gerald Ford's death" + ), + dt.date(2012, 10, 29): Holiday( + date=dt.date(2012, 10, 29), name="Hurricane Sandy" + ), + dt.date(2012, 10, 30): Holiday( + date=dt.date(2012, 10, 30), name="Hurricane Sandy" + ), + dt.date(2018, 12, 5): Holiday( + date=dt.date(2018, 12, 5), name="President George H.W. Bush's death" + ), + dt.date(2025, 1, 9): Holiday( + date=dt.date(2025, 1, 9), name="President Jimmy Carter's death" + ), + } + + holidays: "list[CommonHoliday]" = [ + NewYearsDay( + [0, 1, 2, 3, 4], change={6: 1, 5: -1}, start=dt.date(1952, 9, 29) + ), # Saturday -> Friday, Sunday -> Monday + NewYearsDay( + [0, 1, 2, 3, 4, 5], change={6: 1}, end=dt.date(1952, 9, 28) + ), # Saturday -> Friday, Sunday -> Monday + MartinLutherKingJrDay( + [0, 1, 2, 3, 4], change={6: 1, 5: -1}, start=dt.date(1998, 1, 1) + ), + WashingtonsBirthday([0, 1, 2, 3, 4], change={6: 1}, end=dt.date(1952, 9, 28)), + WashingtonsBirthday( + [0, 1, 2, 3, 4], + change={6: 1, 5: -1}, + start=dt.date(1952, 9, 28), + end=dt.date(1963, 12, 31), + ), + WashingtonsBirthday( + [0, 1, 2, 3, 4], + change={6: 1, 5: -1}, + start=dt.date(1964, 1, 1), + end=dt.date(1970, 12, 31), + ), + LincolnsBirthday([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + GoodFriday([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + MemorialDay([0, 1, 2, 3, 4, 5], change={6: 1}, end=dt.date(1952, 9, 28)), + MemorialDay( + [0, 1, 2, 3, 4], + change={6: 1, 5: -1}, + start=dt.date(1952, 9, 28), + end=dt.date(1963, 12, 31), + ), + MemorialDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}, start=dt.date(1964, 1, 1)), + JuneteenthNationalIndependenceDay( + [0, 1, 2, 3, 4], change={6: 1, 5: -1}, start=dt.date(2022, 1, 1) + ), + IndependenceDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + LaborDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + Thanksgiving([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + Christmas([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + ] + + @classproperty + def weekdays(cls): + return [0, 1, 2, 3, 4] + + @classmethod + def fetch_data(cls, year: "int"): + if year < dt.date.today().year: + return cls.fetch_past(year) + else: + return cls.fetch_future() + + @classmethod + def fetch_past(cls, year: "int"): + return super().fetch_data(year) + + @classmethod + def get_day_type(cls, day: dt.date) -> "type[Day]": + if day in cls.abnormal_days: + return type(cls.abnormal_days[day]) + elif day.weekday() in cls.weekdays: - cls.cache.set( - day, - TradingDay( - date=day, - open_time=cls.standard_open_time, - close_time=cls.standard_close_time - ) - ) + return TradingDay + else: - cls.cache.set(day, NonTradingDay(date=day)) \ No newline at end of file + return NonTradingDay + + @classmethod + def fetch_future(cls): + doc = md.HTML.from_url("https://www.nyse.com/markets/hours-calendars") + table: "elm.Table" = doc.inner_html.get_elements_by_class_name("table-data")[0] + + table_dict = table.to_dict() + holidays = table_dict.pop("Holiday") + + for year, dates in table_dict.items(): + + def handle_date(date: "str", year: "int"): + split_date = date.split(" ") + + return dt.date( + year, + int(MONTHS_MAP[split_date[1].upper()]), + int(split_date[2].replace("*", "")), + ) + + year = int(year) + hol_dates = {handle_date(date, year): hol for date, hol in zip(dates, holidays)} + + for day in iter_year(int(year)): + if day in hol_dates: + name = hol_dates[day] + if name.endswith("*"): + cls.cache.set( + PartialTradingDay( + date=day, + name=name.removesuffix("*"), + open_time=dt.time(hour=9, minute=30), + close_time=dt.time(hour=13), + early_close=True, + early_close_reason=name.removesuffix("*"), + ), + ) + else: + cls.cache.set(Holiday(date=day, name=name)) + elif day.weekday() in cls.weekdays: + cls.cache.set( + TradingDay( + date=day, + open_time=cls.standard_open_time, + close_time=cls.standard_close_time, + ), + ) + else: + cls.cache.set(NonTradingDay(date=day)) diff --git a/BetterHolidays/markets/sse.py b/BetterHolidays/markets/sse.py new file mode 100644 index 0000000..fb6059d --- /dev/null +++ b/BetterHolidays/markets/sse.py @@ -0,0 +1,41 @@ +from .market import Market +import datetime as dt +import zoneinfo as zi +from ..utils import classproperty +from .holidays import ( + ChineseNewYearsDay, + NewYearsDay, + QingMingFestival, + ChineseLabourDay, + DragonBoatFestival, + ChineseNationalDay, + AutumnFestival, +) + + +class SSE(Market): + name = "Shanghai Stock Exchange" + country = "China" + include_country_holidays = True + excluded_country_holidays = [] + + tz = zi.ZoneInfo("Asia/Shanghai") + + standard_open_time = dt.time(hour=9, minute=15, tzinfo=tz) + standard_close_time = dt.time(hour=15, minute=30, tzinfo=tz) + + abnormal_days = {} + + holidays = [ + NewYearsDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + ChineseNewYearsDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + QingMingFestival([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + ChineseLabourDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + DragonBoatFestival([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + AutumnFestival([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + ChineseNationalDay([0, 1, 2, 3, 4], change={6: 1, 5: -1}), + ] + + @classproperty + def weekdays(cls): + return [0, 1, 2, 3, 4] diff --git a/BetterHolidays/multi.py b/BetterHolidays/multi.py index e7974b9..bdaecf7 100644 --- a/BetterHolidays/multi.py +++ b/BetterHolidays/multi.py @@ -6,17 +6,20 @@ T = t.TypeVar("T", bound=t.Any) + @t.overload -def get_market(name:'str') -> 'type[Market]': ... +def get_market(name: "str") -> "type[Market]": ... + @t.overload -def get_market(name:'str', default:'T') -> 't.Union[T, type[Market]]': ... +def get_market(name: "str", default: "T") -> "t.Union[T, type[Market]]": ... + def get_market(name, default=NOT_SET): if name in MARKETS: return MARKETS[name] - + if default == NOT_SET: raise KeyError(name) - - return default \ No newline at end of file + + return default diff --git a/BetterHolidays/utils.py b/BetterHolidays/utils.py index 557be81..3123dcf 100644 --- a/BetterHolidays/utils.py +++ b/BetterHolidays/utils.py @@ -1,38 +1,56 @@ import functools as ft import datetime as dt from .typing import ClassMethod +import platformdirs as pd +import peewee as pw +import os import typing as t -T = t.TypeVar('T') -T1 = t.TypeVar('T1') +T = t.TypeVar("T") +T1 = t.TypeVar("T1") T2 = t.TypeVar("T2", bound=t.Any) +T3 = t.TypeVar("T3") +T4 = t.TypeVar("T4") NOT_SET = type("NOT_SET", (object,), {}) +PATH_TO_DB = pd.user_cache_dir("betterhollidays", "Better-Python") +os.makedirs(PATH_TO_DB, exist_ok=True) def not_set(type_: str, attr: str): raise AttributeError(f"Can't {type_} attribute {attr}") + def method_cache(cache_method: t.Callable[[], T1]): - def wrapper(func: t.Callable[[T], T1]): + def wrapper(func: t.Callable[[T3 | T1], T4]): cache = cache_method() @ft.wraps(func) - def inner(*args, **kwargs): + def inner(*args, **kwargs) -> "T4": return func(*args, **kwargs, cache=cache) return inner return wrapper + +@method_cache(lambda: { + "db": ( + lambda _db: + (_db.connect(reuse_if_open=True), _db)[1]) + (pw.SqliteDatabase(os.path.join(PATH_TO_DB, "holidays.db"))) +}) +def get_db(cache) -> pw.SqliteDatabase: + return cache["db"] + class classproperty(t.Generic[T, T1]): def __init__(self, getter: ClassMethod[T, T1]): self.getter = getter self.setter = lambda val: not_set("set", self.getter.__name__) self.deleter = lambda: not_set("delete", self.getter.__name__) - def set(self, method: 'ClassMethod[T, None]'): + def set(self, method: "ClassMethod[T, None]"): self.setter = method return self - def delete(self, method: 'ClassMethod[T, None]'): + def delete(self, method: "ClassMethod[T, None]"): self.deleter = method return self @@ -45,14 +63,39 @@ def __set__(self, instance, value): def __delete__(self, instance): self.deleter() -@method_cache(lambda: {"type": type("ABSTRACT_CONST", (object,), {"__isabstractmethod__": True})}) + +@method_cache( + lambda: {"type": type("ABSTRACT_CONST", (object,), {"__isabstractmethod__": True})} +) def abstract_const(cache): return cache["type"]() -def iterate_date(start: 'dt.date', end: 'dt.date'): + +def iterate_date(start: "dt.date", end: "dt.date"): current = start while current <= end: yield current current += dt.timedelta(days=1) +def iter_year(year: int): + start = dt.date(year, 1, 1) + end = dt.date(year, 12, 31) + return iterate_date(start, end) + + +def flatten_list(l: "list[list[T]|T1]", recursive: bool = False) -> "list[T1|T]": + ret = [] + for item in l: + if isinstance(item, list): + if recursive: + ret.extend(flatten_list(item, recursive)) + else: + ret.extend(item) + else: + ret.append(item) + return ret + + +def default(type_: type[T]) -> T: + return type_() diff --git a/requirements.txt b/requirements.txt index 8c1b956..ccfd962 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -better-md>=0.3.4 \ No newline at end of file +better-md>=0.3.4 +platformdirs>=4.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py index a61f907..fe69b3d 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,8 @@ def read(path): long_description_content_type="text/markdown", packages=find_packages(exclude="tests"), install_requires=[ - "better-md>=0.3.4" + "better-md>=0.3.4", + "platformdirs>=4.0.0", ], extras_require={}, keywords=["python", "better holidays", "better", "market", "stocks", "finance", "holidays", "better python"], diff --git a/test.py b/test.py new file mode 100644 index 0000000..d8b57da --- /dev/null +++ b/test.py @@ -0,0 +1,4 @@ +import BetterHolidays as bh + + +print(bh.SSE.get_holidays_for_year(2020)) \ No newline at end of file