Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add utility functions for options/futures options exp dates #123

Merged
merged 1 commit into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading