From 17005ee664193f03f5a48848e7b4cf70d60517f8 Mon Sep 17 00:00:00 2001 From: Daniel Chew Date: Thu, 22 Jun 2023 17:40:33 +0800 Subject: [PATCH] fix get_next_market_open/close (#33) * fix get_next_market_open/close logic * bump * crypto return none for next market open * add unit tests and fix logic * update python dep to 3.9 * update pytest to 3.9 --- .github/workflows/pytest.yml | 2 +- pythclient/calendar.py | 70 +++++-- setup.py | 4 +- tests/test_calendar.py | 361 +++++++++++++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 21 deletions(-) create mode 100644 tests/test_calendar.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 756d032..2f89004 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9"] + python-version: ["3.9"] steps: - uses: actions/checkout@v2 diff --git a/pythclient/calendar.py b/pythclient/calendar.py index 2a1588c..3d6ac57 100644 --- a/pythclient/calendar.py +++ b/pythclient/calendar.py @@ -47,11 +47,11 @@ def is_market_open(asset_type: str, dt: datetime.datetime) -> bool: if ( date in EQUITY_EARLY_HOLIDAYS and time >= EQUITY_OPEN - and time <= EQUITY_EARLY_CLOSE + and time < EQUITY_EARLY_CLOSE ): return True return False - if day < 5 and time >= EQUITY_OPEN and time <= EQUITY_CLOSE: + if day < 5 and time >= EQUITY_OPEN and time < EQUITY_CLOSE: return True return False @@ -59,7 +59,7 @@ def is_market_open(asset_type: str, dt: datetime.datetime) -> bool: if date in FX_METAL_HOLIDAYS: return False # On Friday the market is closed after 5pm - if day == 4 and time > FX_METAL_OPEN_CLOSE_TIME: + if day == 4 and time >= FX_METAL_OPEN_CLOSE_TIME: return False # On Saturday the market is closed all the time if day == 5: @@ -79,9 +79,6 @@ def get_next_market_open(asset_type: str, dt: datetime.datetime) -> str: dt = dt.astimezone(NY_TZ) time = dt.time() - if is_market_open(asset_type, dt): - return dt.astimezone(UTC_TZ).strftime("%Y-%m-%dT%H:%M:%S") + "Z" - if asset_type == "equity": if time < EQUITY_OPEN: next_market_open = dt.replace( @@ -113,30 +110,53 @@ def get_next_market_open(asset_type: str, dt: datetime.datetime) -> str: second=0, microsecond=0, ) - next_market_open += datetime.timedelta(days=1) + while is_market_open(asset_type, next_market_open): + next_market_open += datetime.timedelta(days=1) + else: - next_market_open = dt.replace(hour=0, minute=0, second=0, microsecond=0) - next_market_open += datetime.timedelta(days=1) + return None while not is_market_open(asset_type, next_market_open): next_market_open += datetime.timedelta(days=1) return next_market_open.astimezone(UTC_TZ).strftime("%Y-%m-%dT%H:%M:%S") + "Z" + def get_next_market_close(asset_type: str, dt: datetime.datetime) -> str: # make sure time is in NY timezone dt = dt.astimezone(NY_TZ) - if not is_market_open(asset_type, dt): - return dt.astimezone(UTC_TZ).strftime("%Y-%m-%dT%H:%M:%S") + "Z" + time = dt.time() if asset_type == "equity": if dt.date() in EQUITY_EARLY_HOLIDAYS: - - next_market_close = dt.replace( - hour=EQUITY_EARLY_CLOSE.hour, - minute=EQUITY_EARLY_CLOSE.minute, - second=0, - microsecond=0, + if time < EQUITY_EARLY_CLOSE: + next_market_close = dt.replace( + hour=EQUITY_EARLY_CLOSE.hour, + minute=EQUITY_EARLY_CLOSE.minute, + second=0, + microsecond=0, + ) + else: + next_market_close = dt.replace( + hour=EQUITY_CLOSE.hour, + minute=EQUITY_CLOSE.minute, + second=0, + microsecond=0, + ) + next_market_close += datetime.timedelta(days=1) + elif dt.date() in EQUITY_HOLIDAYS: + next_market_open = get_next_market_open( + asset_type, dt + datetime.timedelta(days=1) + ) + next_market_close = ( + datetime.datetime.fromisoformat(next_market_open.replace("Z", "+00:00")) + .astimezone(NY_TZ) + .replace( + hour=EQUITY_CLOSE.hour, + minute=EQUITY_CLOSE.minute, + second=0, + microsecond=0, + ) ) else: next_market_close = dt.replace( @@ -145,6 +165,16 @@ def get_next_market_close(asset_type: str, dt: datetime.datetime) -> str: second=0, microsecond=0, ) + if time >= EQUITY_CLOSE: + next_market_close += datetime.timedelta(days=1) + + # while next_market_close.date() is in EQUITY_HOLIDAYS or weekend, add 1 day + while ( + next_market_close.date() in EQUITY_HOLIDAYS + or next_market_close.weekday() >= 5 + ): + next_market_close += datetime.timedelta(days=1) + elif asset_type in ["fx", "metal"]: next_market_close = dt.replace( hour=FX_METAL_OPEN_CLOSE_TIME.hour, @@ -152,7 +182,11 @@ def get_next_market_close(asset_type: str, dt: datetime.datetime) -> str: second=0, microsecond=0, ) - else: # crypto markets never close + while not is_market_open(asset_type, next_market_close): + next_market_close += datetime.timedelta(days=1) + while is_market_open(asset_type, next_market_close): + next_market_close += datetime.timedelta(days=1) + else: # crypto markets never close return None return next_market_close.astimezone(UTC_TZ).strftime("%Y-%m-%dT%H:%M:%S") + "Z" diff --git a/setup.py b/setup.py index 6445a1a..79b29c1 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='pythclient', - version='0.1.6', + version='0.1.7', packages=['pythclient'], author='Pyth Developers', author_email='contact@pyth.network', @@ -28,5 +28,5 @@ 'testing': requirements + ['mock', 'pytest', 'pytest-cov', 'pytest-socket', 'pytest-mock', 'pytest-asyncio'], }, - python_requires='>=3.7.0', + python_requires='>=3.9.0', ) diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000..f9bf1a9 --- /dev/null +++ b/tests/test_calendar.py @@ -0,0 +1,361 @@ +import datetime +from zoneinfo import ZoneInfo + +import pytest + +from pythclient.calendar import ( + get_next_market_close, + get_next_market_open, + is_market_open, +) + +NY_TZ = ZoneInfo("America/New_York") +UTC_TZ = ZoneInfo("UTC") + + +@pytest.fixture +def equity_open_weekday_datetime(): + # A weekday, within equity market hours + return datetime.datetime(2023, 6, 21, 12, 0, 0, tzinfo=NY_TZ) + + +@pytest.fixture +def equity_close_weekday_datetime(): + # A weekday, out of equity market hours + return datetime.datetime(2023, 6, 21, 17, 0, 0, tzinfo=NY_TZ) + + +@pytest.fixture +def equity_close_weekend_datetime(): + # A weekend, out of equity market hours + return datetime.datetime(2023, 6, 10, 17, 0, 0, tzinfo=NY_TZ) + + +@pytest.fixture +def equity_holiday_datetime(): + # A weekday, NYSE holiday + return datetime.datetime(2023, 6, 19, tzinfo=NY_TZ) + + +@pytest.fixture +def equity_early_holiday_open_datetime(): + # A weekday, NYSE early close holiday + return datetime.datetime(2023, 11, 24, 11, 0, 0, tzinfo=NY_TZ) + + +@pytest.fixture +def equity_early_holiday_close_datetime(): + # A weekday, NYSE early close holiday + return datetime.datetime(2023, 11, 24, 14, 0, 0, tzinfo=NY_TZ) + + +@pytest.fixture +def fx_metal_open_weekday_datetime(): + # A weekday, within fx & metal market hours + return datetime.datetime(2023, 6, 21, 22, 0, 0, tzinfo=NY_TZ) + + +@pytest.fixture +def fx_metal_close_weekend_datetime(): + # A weekend, out of fx & metal market hours + return datetime.datetime(2023, 6, 18, 16, 0, 0, tzinfo=NY_TZ) + + +@pytest.fixture +def fx_metal_holiday_datetime(): + # CBOE holiday + return datetime.datetime(2023, 1, 1, tzinfo=NY_TZ) + + +@pytest.fixture +def crypto_open_weekday_datetime(): + return datetime.datetime(2023, 6, 21, 12, 0, 0, tzinfo=NY_TZ) + + +@pytest.fixture +def crypto_open_weekend_datetime(): + return datetime.datetime(2023, 6, 18, 12, 0, 0, tzinfo=NY_TZ) + + +def test_is_market_open( + equity_open_weekday_datetime, + equity_close_weekday_datetime, + equity_close_weekend_datetime, + equity_holiday_datetime, + equity_early_holiday_open_datetime, + equity_early_holiday_close_datetime, + fx_metal_open_weekday_datetime, + fx_metal_close_weekend_datetime, + fx_metal_holiday_datetime, + crypto_open_weekday_datetime, + crypto_open_weekend_datetime, +): + # equity + # weekday, within equity market hours + assert is_market_open("equity", equity_open_weekday_datetime) == True + + # weekday, out of equity market hours + assert is_market_open("equity", equity_close_weekday_datetime) == False + + # weekend, out of equity market hours + assert is_market_open("equity", equity_close_weekend_datetime) == False + + # weekday, NYSE holiday + assert is_market_open("equity", equity_holiday_datetime) == False + + # weekday, NYSE early close holiday + assert is_market_open("equity", equity_early_holiday_open_datetime) == True + assert is_market_open("equity", equity_early_holiday_close_datetime) == False + + # fx & metal + # weekday, within fx & metal market hours + assert is_market_open("fx", fx_metal_open_weekday_datetime) == True + assert is_market_open("metal", fx_metal_open_weekday_datetime) == True + + # weekday, out of fx & metal market hours + assert is_market_open("fx", fx_metal_close_weekend_datetime) == False + assert is_market_open("metal", fx_metal_close_weekend_datetime) == False + + # fx & metal holiday + assert is_market_open("fx", fx_metal_holiday_datetime) == False + assert is_market_open("metal", fx_metal_holiday_datetime) == False + + # crypto + assert is_market_open("crypto", crypto_open_weekday_datetime) == True + assert is_market_open("crypto", crypto_open_weekend_datetime) == True + + +def test_get_next_market_open( + equity_open_weekday_datetime, + equity_close_weekday_datetime, + equity_close_weekend_datetime, + equity_holiday_datetime, + equity_early_holiday_open_datetime, + equity_early_holiday_close_datetime, + fx_metal_open_weekday_datetime, + fx_metal_close_weekend_datetime, + fx_metal_holiday_datetime, + crypto_open_weekday_datetime, + crypto_open_weekend_datetime, +): + # equity within market hours + assert ( + get_next_market_open("equity", equity_open_weekday_datetime) + == datetime.datetime(2023, 6, 22, 9, 30, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # equity out of market hours + assert ( + get_next_market_open("equity", equity_close_weekday_datetime) + == datetime.datetime(2023, 6, 22, 9, 30, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # equity weekend + assert ( + get_next_market_open("equity", equity_close_weekend_datetime) + == datetime.datetime(2023, 6, 12, 9, 30, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # equity holiday + assert ( + get_next_market_open("equity", equity_holiday_datetime) + == datetime.datetime(2023, 6, 20, 9, 30, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # equity early close holiday + assert ( + get_next_market_open("equity", equity_early_holiday_open_datetime) + == datetime.datetime(2023, 11, 27, 9, 30, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + assert ( + get_next_market_open("equity", equity_early_holiday_close_datetime) + == datetime.datetime(2023, 11, 27, 9, 30, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # fx & metal within market hours + assert ( + get_next_market_open("fx", fx_metal_open_weekday_datetime) + == datetime.datetime(2023, 6, 25, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + assert ( + get_next_market_open("metal", fx_metal_open_weekday_datetime) + == datetime.datetime(2023, 6, 25, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # fx & metal out of market hours + assert ( + get_next_market_open("fx", fx_metal_close_weekend_datetime) + == datetime.datetime(2023, 6, 18, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + assert ( + get_next_market_open("metal", fx_metal_close_weekend_datetime) + == datetime.datetime(2023, 6, 18, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # fx & metal holiday + assert ( + get_next_market_open("fx", fx_metal_holiday_datetime) + == datetime.datetime(2023, 1, 2, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + assert ( + get_next_market_open("metal", fx_metal_holiday_datetime) + == datetime.datetime(2023, 1, 2, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # crypto + assert get_next_market_open("crypto", crypto_open_weekday_datetime) == None + assert get_next_market_open("crypto", crypto_open_weekend_datetime) == None + + +def test_get_next_market_close( + equity_open_weekday_datetime, + equity_close_weekday_datetime, + equity_close_weekend_datetime, + equity_holiday_datetime, + equity_early_holiday_open_datetime, + equity_early_holiday_close_datetime, + fx_metal_open_weekday_datetime, + fx_metal_close_weekend_datetime, + fx_metal_holiday_datetime, + crypto_open_weekday_datetime, + crypto_open_weekend_datetime, +): + # equity within market hours + assert ( + get_next_market_close("equity", equity_open_weekday_datetime) + == datetime.datetime(2023, 6, 21, 16, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # equity out of market hours + assert ( + get_next_market_close("equity", equity_close_weekday_datetime) + == datetime.datetime(2023, 6, 22, 16, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # equity weekend + assert ( + get_next_market_close("equity", equity_close_weekend_datetime) + == datetime.datetime(2023, 6, 12, 16, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # equity holiday + assert ( + get_next_market_close("equity", equity_holiday_datetime) + == datetime.datetime(2023, 6, 20, 16, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # equity early close holiday + assert ( + get_next_market_close("equity", equity_early_holiday_open_datetime) + == datetime.datetime(2023, 11, 24, 13, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + assert ( + get_next_market_close("equity", equity_early_holiday_close_datetime) + == datetime.datetime(2023, 11, 27, 16, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # fx & metal within market hours + assert ( + get_next_market_close("fx", fx_metal_open_weekday_datetime) + == datetime.datetime(2023, 6, 23, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + assert ( + get_next_market_close("metal", fx_metal_open_weekday_datetime) + == datetime.datetime(2023, 6, 23, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # fx & metal out of market hours + assert ( + get_next_market_close("fx", fx_metal_close_weekend_datetime) + == datetime.datetime(2023, 6, 23, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + assert ( + get_next_market_close("metal", fx_metal_close_weekend_datetime) + == datetime.datetime(2023, 6, 23, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # fx & metal holiday + assert ( + get_next_market_close("fx", fx_metal_holiday_datetime) + == datetime.datetime(2023, 1, 6, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + assert ( + get_next_market_close("metal", fx_metal_holiday_datetime) + == datetime.datetime(2023, 1, 6, 17, 0, 0, tzinfo=NY_TZ) + .astimezone(UTC_TZ) + .strftime("%Y-%m-%dT%H:%M:%S") + + "Z" + ) + + # crypto + assert get_next_market_close("crypto", crypto_open_weekday_datetime) == None + assert get_next_market_close("crypto", crypto_open_weekend_datetime) == None