diff --git a/docs/conf.py b/docs/conf.py index 4f14967..2815f02 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,7 +9,7 @@ project = 'tastytrade' copyright = '2023, Graeme Holliday' author = 'Graeme Holliday' -release = '6.5' +release = '6.6' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/requirements.txt b/requirements.txt index d9be753..ba2398e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ flake8==5.0.4 isort==5.11.5 types-requests==2.31.0.1 websockets==11.0.3 +pandas_market_calendars==4.3.3 pydantic==1.10.11 pytest==7.4.0 pytest_cov==4.1.0 diff --git a/setup.py b/setup.py index bbe7ff4..757b4d5 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='tastytrade', - version='6.5', + version='6.6', description='An unofficial SDK for Tastytrade!', long_description=LONG_DESCRIPTION, long_description_content_type='text/x-rst', @@ -18,7 +18,8 @@ install_requires=[ 'requests<3', 'websockets>=11.0.3', - 'pydantic<2' + 'pydantic<2', + 'pandas_market_calendars>=4.3.3' ], packages=find_packages(exclude=['ez_setup', 'tests*']), include_package_data=True diff --git a/tastytrade/__init__.py b/tastytrade/__init__.py index b731a2d..7297999 100644 --- a/tastytrade/__init__.py +++ b/tastytrade/__init__.py @@ -2,7 +2,7 @@ API_URL = 'https://api.tastyworks.com' CERT_URL = 'https://api.cert.tastyworks.com' -VERSION = '6.5' +VERSION = '6.6' logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) diff --git a/tastytrade/account.py b/tastytrade/account.py index 6b94f5c..ff5b04e 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -1002,7 +1002,7 @@ def place_order( self, session: Session, order: NewOrder, - dry_run=True + dry_run: bool = True ) -> PlacedOrderResponse: """ Place the given order. @@ -1032,7 +1032,7 @@ def place_complex_order( self, session: Session, order: NewComplexOrder, - dry_run=True + dry_run: bool = True ) -> PlacedOrderResponse: """ Place the given order. diff --git a/tastytrade/instruments.py b/tastytrade/instruments.py index f65c6c6..1fb2fe0 100644 --- a/tastytrade/instruments.py +++ b/tastytrade/instruments.py @@ -496,7 +496,7 @@ def streamer_symbol_to_occ(cls, streamer_symbol) -> str: if match is None: return '' symbol = match.group(1)[:6].ljust(6) - exp = datetime.strptime(match.group(2), '%y%m%d').strftime('%Y%m%d') + exp = match.group(2) option_type = match.group(3) strike = match.group(4).zfill(5) if match.group(6) is not None: diff --git a/tastytrade/order.py b/tastytrade/order.py index 1c97d5f..10600ec 100644 --- a/tastytrade/order.py +++ b/tastytrade/order.py @@ -346,7 +346,7 @@ class PlacedOrderResponse(TastytradeJsonDataclass): Dataclass grouping together information about a placed order. """ buying_power_effect: BuyingPowerEffect - fee_calculation: FeeCalculation + fee_calculation: Optional[FeeCalculation] = None order: Optional[PlacedOrder] = None complex_order: Optional[PlacedComplexOrder] = None warnings: Optional[List[Message]] = None diff --git a/tastytrade/utils.py b/tastytrade/utils.py index 3e39356..28deede 100644 --- a/tastytrade/utils.py +++ b/tastytrade/utils.py @@ -1,6 +1,129 @@ +from datetime import date, timedelta + +import pandas_market_calendars as mcal # type: ignore from pydantic import BaseModel from requests import Response +NYSE = mcal.get_calendar('NYSE') + + +def get_third_friday(day: date = date.today()) -> date: + """ + Gets the monthly expiration associated with the month of the given date, + or the monthly expiration associated with today's month. + + :param day: the date to check, defaults to today + + :return: the associated monthly + """ + day = day.replace(day=1) + day += timedelta(weeks=2) + while day.weekday() != 4: # Friday + day += timedelta(days=1) + return day + + +def get_tasty_monthly() -> date: + """ + Gets the monthly expiration closest to 45 days from the current date. + + :return: the closest to 45 DTE monthly expiration + """ + day = date.today() + exp1 = get_third_friday(day + timedelta(weeks=4)) + exp2 = get_third_friday(day + timedelta(weeks=8)) + day45 = day + timedelta(days=45) + return exp1 if day45 - exp2 < exp2 - day45 else exp2 + + +def _get_last_day_of_month(day: date) -> date: + if day.month == 12: + last = day.replace(day=1, month=1, year=day.year + 1) + else: + last = day.replace(day=1, month=day.month + 1) + return last - timedelta(days=1) + + +def get_future_fx_monthly(day: date = date.today()) -> date: + """ + Gets the monthly expiration associated with the FX futures: /6E, /6A, etc. + As far as I can tell, these expire on the first Friday prior to the second + Wednesday. + + :param day: the date to check, defaults to today + + :return: the associated monthly + """ + day = day.replace(day=1) + day += timedelta(weeks=1) + while day.weekday() != 2: # Wednesday + day += timedelta(days=1) + while day.weekday() != 4: # Friday + day -= timedelta(days=1) + return day + + +def get_future_treasury_monthly(day: date = date.today()) -> date: + """ + Gets the monthly expiration associated with the treasury futures: /ZN, + /ZB, etc. According to CME, these expire the Friday before the 2nd last + business day of the month. If this is not a business day, they expire 1 + business day prior. + + :param day: the date to check, defaults to today + + :return: the associated monthly + """ + last_day = _get_last_day_of_month(day) + first_day = last_day.replace(day=1) + valid_range = [d.date() for d in NYSE.valid_days(first_day, last_day)] + itr = valid_range[-2] - timedelta(days=1) + while itr.weekday() != 4: # Friday + itr -= timedelta(days=1) + if itr in valid_range: + return itr + return itr - timedelta(days=1) + + +def get_future_metal_monthly(day: date = date.today()) -> date: + """ + Gets the monthly expiration associated with the metals futures: /GC, /SI, + etc. According to CME, these expire on the 4th last business day of the + month, unless that day occurs on a Friday or the day before a holiday, in + which case they expire on the prior business day. + + :param day: the date to check, defaults to today + + :return: the associated monthly + """ + last_day = _get_last_day_of_month(day) + first_day = last_day.replace(day=1) + valid_range = [d.date() for d in NYSE.valid_days(first_day, last_day)] + itr = valid_range[-4] + next_day = itr + timedelta(days=1) + if itr.weekday() == 4 or next_day not in valid_range: + return valid_range[-5] + return itr + + +def get_future_grain_monthly(day: date = date.today()) -> date: + """ + Gets the monthly expiration associated with the grain futures: /ZC, /ZW, + etc. According to CME, these expire on the Friday which precedes, by at + least 2 business days, the last business day of the month. + + :param day: the date to check, defaults to today + + :return: the associated monthly + """ + last_day = _get_last_day_of_month(day) + first_day = last_day.replace(day=1) + valid_range = [d.date() for d in NYSE.valid_days(first_day, last_day)] + itr = valid_range[-3] + while itr.weekday() != 4: # Friday + itr -= timedelta(days=1) + return itr + class TastytradeError(Exception): """ @@ -30,7 +153,7 @@ class Config: allow_population_by_field_name = True -def validate_response(response: Response) -> None: # pragma: no cover +def validate_response(response: Response) -> None: """ Checks if the given code is an error; if so, raises an exception. diff --git a/tests/test_instruments.py b/tests/test_instruments.py index 71f48d5..92e7075 100644 --- a/tests/test_instruments.py +++ b/tests/test_instruments.py @@ -84,5 +84,5 @@ def test_get_future_option_chain(session): def test_streamer_symbol_to_occ(): dxf = '.SPY240324P480.5' - occ = 'SPY 20240324P00480500' + occ = 'SPY 240324P00480500' assert Option.streamer_symbol_to_occ(dxf) == occ diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..5c36b81 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,108 @@ +from datetime import date + +from tastytrade.utils import (get_future_fx_monthly, get_future_grain_monthly, + get_future_metal_monthly, + get_future_treasury_monthly, get_tasty_monthly, + get_third_friday) + + +def test_get_third_friday(): + assert get_third_friday(date(2024, 3, 2)) == date(2024, 3, 15) + + +def test_get_tasty_monthly(): + delta = (get_tasty_monthly() - date.today()).days + assert abs(45 - delta) <= 17 + + +def test_get_future_fx_monthly(): + exps = [ + date(2024, 2, 9), + date(2024, 3, 8), + date(2024, 4, 5), + date(2024, 5, 3), + date(2024, 6, 7), + date(2024, 7, 5), + date(2024, 8, 9), + date(2024, 9, 6), + date(2024, 10, 4), + date(2024, 11, 8), + date(2024, 12, 6), + date(2025, 1, 3), + date(2025, 2, 7), + date(2025, 3, 7), + date(2025, 6, 6), + date(2025, 9, 5), + date(2025, 12, 5) + ] + for exp in exps: + assert get_future_fx_monthly(exp) == exp + + +def test_get_future_treasury_monthly(): + exps = [ + date(2024, 2, 23), + date(2024, 3, 22), + date(2024, 4, 26), + date(2024, 5, 24), + date(2024, 6, 21), + date(2024, 8, 23) + ] + for exp in exps: + assert get_future_treasury_monthly(exp) == exp + + +def test_get_future_grain_monthly(): + exps = [ + date(2024, 2, 23), + date(2024, 3, 22), + date(2024, 4, 26), + date(2024, 5, 24), + date(2024, 6, 21), + date(2024, 8, 23), + date(2024, 11, 22), + date(2025, 2, 21), + date(2025, 4, 25), + date(2025, 6, 20), + date(2025, 11, 21), + date(2026, 6, 26), + date(2026, 11, 20) + ] + for exp in exps: + assert get_future_grain_monthly(exp) == exp + + +def test_get_future_metal_monthly(): + exps = [ + date(2024, 2, 26), + date(2024, 3, 25), + date(2024, 4, 25), + date(2024, 5, 28), + date(2024, 6, 25), + date(2024, 7, 25), + date(2024, 8, 27), + date(2024, 9, 25), + date(2024, 10, 28), + date(2024, 11, 25), + date(2024, 12, 26), + date(2025, 1, 28), + date(2025, 2, 25), + date(2025, 3, 26), + date(2025, 4, 24), + date(2025, 5, 27), + date(2025, 6, 25), + date(2025, 7, 28), + date(2025, 8, 26), + date(2025, 9, 25), + date(2025, 11, 24), + date(2026, 5, 26), + date(2026, 11, 24), + date(2027, 5, 25), + date(2027, 11, 23), + date(2028, 5, 25), + date(2028, 11, 27), + date(2029, 5, 24), + date(2029, 11, 27) + ] + for exp in exps: + assert get_future_metal_monthly(exp) == exp