Skip to content

Commit

Permalink
add utility functions for options/futures options exp dates (tastywar…
Browse files Browse the repository at this point in the history
  • Loading branch information
Graeme22 authored Feb 7, 2024
1 parent 8ebabad commit 27a3869
Show file tree
Hide file tree
Showing 10 changed files with 243 additions and 10 deletions.
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tastytrade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tastytrade/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -1002,7 +1002,7 @@ def place_order(
self,
session: Session,
order: NewOrder,
dry_run=True
dry_run: bool = True
) -> PlacedOrderResponse:
"""
Place the given order.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tastytrade/instruments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tastytrade/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 124 additions & 1 deletion tastytrade/utils.py
Original file line number Diff line number Diff line change
@@ -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):
"""
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tests/test_instruments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
108 changes: 108 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 27a3869

Please sign in to comment.