From 3d9e4dbe8fadf0377184f63b7229fb5d79d194e1 Mon Sep 17 00:00:00 2001 From: Graeme Holliday Date: Fri, 9 Feb 2024 16:28:12 -0500 Subject: [PATCH] fix #126, use pydantic w/ dxfeed, don't raise for orders, add more futures exp utils --- docs/conf.py | 2 +- docs/tastytrade.rst | 11 +------ setup.py | 2 +- tastytrade/__init__.py | 2 +- tastytrade/account.py | 7 ++++- tastytrade/dxfeed/candle.py | 24 ++++++++-------- tastytrade/dxfeed/event.py | 17 ++++++++--- tastytrade/dxfeed/greeks.py | 17 ++++++----- tastytrade/dxfeed/profile.py | 40 +++++++++++++------------- tastytrade/dxfeed/quote.py | 16 +++++------ tastytrade/dxfeed/summary.py | 28 +++++++++--------- tastytrade/dxfeed/theoprice.py | 15 +++++----- tastytrade/dxfeed/timeandsale.py | 9 +++--- tastytrade/dxfeed/trade.py | 24 ++++++++-------- tastytrade/dxfeed/underlying.py | 11 ++++--- tastytrade/instruments.py | 4 +-- tastytrade/streamer.py | 7 ++--- tastytrade/utils.py | 33 +++++++++++++++++++++ tests/test_utils.py | 49 ++++++++++++++++++++++++++++++-- 19 files changed, 198 insertions(+), 120 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 2815f02..222bc48 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,7 +9,7 @@ project = 'tastytrade' copyright = '2023, Graeme Holliday' author = 'Graeme Holliday' -release = '6.6' +release = '6.7' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/tastytrade.rst b/docs/tastytrade.rst index 4a13744..5815d5a 100644 --- a/docs/tastytrade.rst +++ b/docs/tastytrade.rst @@ -45,18 +45,9 @@ Streamer Utils ----- -.. module:: tastytrade.utils - -.. autoclass:: TastytradeError - -.. autofunction:: _dasherize - -.. autopydantic_model:: TastytradeJsonDataclass +.. automodule:: tastytrade.utils :members: :inherited-members: - :model-show-config-summary: - -.. autofunction:: validate_response Watchlists ---------- diff --git a/setup.py b/setup.py index 757b4d5..f98fa96 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='tastytrade', - version='6.6', + version='6.7', description='An unofficial SDK for Tastytrade!', long_description=LONG_DESCRIPTION, long_description_content_type='text/x-rst', diff --git a/tastytrade/__init__.py b/tastytrade/__init__.py index 7297999..1bccd8a 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.6' +VERSION = '6.7' logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) diff --git a/tastytrade/account.py b/tastytrade/account.py index ff5b04e..db904b6 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -5,6 +5,7 @@ import requests from pydantic import BaseModel +from tastytrade import logger from tastytrade.order import (InstrumentType, NewComplexOrder, NewOrder, OrderStatus, PlacedComplexOrder, PlacedOrder, PlacedOrderResponse, PriceEffect) @@ -1022,7 +1023,11 @@ def place_order( json = order.json(exclude_none=True, by_alias=True) response = requests.post(url, headers=session.headers, data=json) - validate_response(response) + # sometimes we just want to see BP usage for an invalid trade + try: + validate_response(response) + except TastytradeError as error: + logger.error(error) data = response.json()['data'] diff --git a/tastytrade/dxfeed/candle.py b/tastytrade/dxfeed/candle.py index 906e54c..cc42816 100644 --- a/tastytrade/dxfeed/candle.py +++ b/tastytrade/dxfeed/candle.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass +from decimal import Decimal +from typing import Optional from .event import Event -@dataclass class Candle(Event): """ A Candle event with open, high, low, close prices and other information @@ -25,22 +25,22 @@ class Candle(Event): #: total number of events in the candle count: int #: the first (open) price of the candle - open: float + open: Optional[Decimal] = None #: the maximal (high) price of the candle - high: float + high: Optional[Decimal] = None #: the minimal (low) price of the candle - low: float + low: Optional[Decimal] = None #: the last (close) price of the candle - close: float + close: Optional[Decimal] = None #: the total volume of the candle - volume: int + volume: Optional[int] = None #: volume-weighted average price - vwap: float + vwap: Optional[Decimal] = None #: bid volume in the candle - bidVolume: int + bidVolume: Optional[int] = None #: ask volume in the candle - askVolume: int + askVolume: Optional[int] = None #: implied volatility in the candle - impVolatility: float + impVolatility: Optional[Decimal] = None #: open interest in the candle - openInterest: int + openInterest: Optional[int] = None diff --git a/tastytrade/dxfeed/event.py b/tastytrade/dxfeed/event.py index 4358b41..f355834 100644 --- a/tastytrade/dxfeed/event.py +++ b/tastytrade/dxfeed/event.py @@ -1,7 +1,10 @@ -from abc import ABC from enum import Enum from typing import List +from pydantic import BaseModel, validator + +from tastytrade.utils import TastytradeError + class EventType(str, Enum): """ @@ -24,7 +27,13 @@ class EventType(str, Enum): UNDERLYING = 'Underlying' -class Event(ABC): +class Event(BaseModel): + @validator('*', pre=True) + def change_nan_to_none(cls, v): + if v == 'NaN': + return None + return v + @classmethod def from_stream(cls, data: list) -> List['Event']: # pragma: no cover """ @@ -36,11 +45,11 @@ def from_stream(cls, data: list) -> List['Event']: # pragma: no cover :return: list of event objects from data """ objs = [] - size = len(cls.__dataclass_fields__) # type: ignore + size = len(cls.__fields__) multiples = len(data) / size if not multiples.is_integer(): msg = 'Mapper data input values are not a multiple of the key size' - raise Exception(msg) + raise TastytradeError(msg) for i in range(int(multiples)): offset = i * size local_values = data[offset:(i + 1) * size] diff --git a/tastytrade/dxfeed/greeks.py b/tastytrade/dxfeed/greeks.py index f414199..a21e57b 100644 --- a/tastytrade/dxfeed/greeks.py +++ b/tastytrade/dxfeed/greeks.py @@ -1,9 +1,8 @@ -from dataclasses import dataclass +from decimal import Decimal from .event import Event -@dataclass class Greeks(Event): """ Greek ratios, or simply Greeks, are differential values that show how the @@ -26,16 +25,16 @@ class Greeks(Event): #: sequence number to distinguish events that have the same time sequence: int #: option market price - price: float + price: Decimal #: Black-Scholes implied volatility of the option - volatility: float + volatility: Decimal #: option delta - delta: float + delta: Decimal #: option gamma - gamma: float + gamma: Decimal #: option theta - theta: float + theta: Decimal #: option rho - rho: float + rho: Decimal #: option vega - vega: float + vega: Decimal diff --git a/tastytrade/dxfeed/profile.py b/tastytrade/dxfeed/profile.py index d8e4125..1c8f208 100644 --- a/tastytrade/dxfeed/profile.py +++ b/tastytrade/dxfeed/profile.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass +from decimal import Decimal +from typing import Optional from .event import Event -@dataclass class Profile(Event): """ A Profile event provides the security instrument description. It @@ -22,31 +22,31 @@ class Profile(Event): #: trading status of the security instrument #: possible values are ACTIVE | HALTED | UNDEFINED tradingStatus: str - #: description of the reason that trading was halted - statusReason: str #: starting time of the trading halt interval haltStartTime: int #: ending time of the trading halt interval haltEndTime: int - #: maximal (high) allowed price - highLimitPrice: float - #: minimal (low) allowed price - lowLimitPrice: float + #: identifier of the ex-dividend date + exDividendDayId: int + #: description of the reason that trading was halted + statusReason: Optional[str] = None #: maximal (high) price in last 52 weeks - high52WeekPrice: float + high52WeekPrice: Optional[Decimal] = None #: minimal (low) price in last 52 weeks - low52WeekPrice: float + low52WeekPrice: Optional[Decimal] = None #: the correlation coefficient of the instrument to the S&P500 index - beta: float + beta: Optional[Decimal] = None + #: shares outstanding + shares: Optional[Decimal] = None + #: maximal (high) allowed price + highLimitPrice: Optional[Decimal] = None + #: minimal (low) allowed price + lowLimitPrice: Optional[Decimal] = None #: earnings per share - earningsPerShare: float - #: frequency of cash dividends payments per year (calculated) - dividendFrequency: float + earningsPerShare: Optional[Decimal] = None #: the amount of the last paid dividend - exDividendAmount: float - #: identifier of the ex-dividend date - exDividendDayId: int - #: shares outstanding - shares: float + exDividendAmount: Optional[Decimal] = None + #: frequency of cash dividends payments per year (calculated) + dividendFrequency: Optional[Decimal] = None #: the number of shares that are available to the public for trade - freeFloat: float + freeFloat: Optional[Decimal] = None diff --git a/tastytrade/dxfeed/quote.py b/tastytrade/dxfeed/quote.py index 5d9a107..07e6e44 100644 --- a/tastytrade/dxfeed/quote.py +++ b/tastytrade/dxfeed/quote.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass +from decimal import Decimal +from typing import Optional from .event import Event -@dataclass class Quote(Event): """ A Quote event is a snapshot of the best bid and ask prices, and other @@ -21,15 +21,15 @@ class Quote(Event): bidTime: int #: bid exchange code bidExchangeCode: str - #: bid price - bidPrice: float - #: bid size as integer number (rounded toward zero) - bidSize: int #: time of the last ask change askTime: int #: ask exchange code askExchangeCode: str + #: bid price + bidPrice: Optional[Decimal] = None #: ask price - askPrice: float + askPrice: Optional[Decimal] = None + #: bid size as integer number (rounded toward zero) + bidSize: Optional[int] = None #: ask size as integer number (rounded toward zero) - askSize: int + askSize: Optional[int] = None diff --git a/tastytrade/dxfeed/summary.py b/tastytrade/dxfeed/summary.py index 7bbe617..e6d7508 100644 --- a/tastytrade/dxfeed/summary.py +++ b/tastytrade/dxfeed/summary.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass +from decimal import Decimal +from typing import Optional from .event import Event -@dataclass class Summary(Event): """ Summary is an information snapshot about the trading session including @@ -22,25 +22,25 @@ class Summary(Event): eventTime: int #: identifier of the day that this summary represents dayId: int - #: the first (open) price for the day - dayOpenPrice: float - #: the maximal (high) price for the day - dayHighPrice: float - #: the minimal (low) price for the day - dayLowPrice: float - #: the last (close) price for the day - dayClosePrice: float #: the price type of the last (close) price for the day #: possible values are FINAL | INDICATIVE | PRELIMINARY | REGULAR dayClosePriceType: str #: identifier of the previous day that this summary represents prevDayId: int - #: the last (close) price for the previous day - prevDayClosePrice: float #: the price type of the last (close) price for the previous day #: possible values are FINAL | INDICATIVE | PRELIMINARY | REGULAR prevDayClosePriceType: str - #: total volume traded for the previous day - prevDayVolume: float #: open interest of the symbol as the number of open contracts openInterest: int + #: the first (open) price for the day + dayOpenPrice: Optional[Decimal] = None + #: the maximal (high) price for the day + dayHighPrice: Optional[Decimal] = None + #: the minimal (low) price for the day + dayLowPrice: Optional[Decimal] = None + #: the last (close) price for the day + dayClosePrice: Optional[Decimal] = None + #: the last (close) price for the previous day + prevDayClosePrice: Optional[Decimal] = None + #: total volume traded for the previous day + prevDayVolume: Optional[Decimal] = None diff --git a/tastytrade/dxfeed/theoprice.py b/tastytrade/dxfeed/theoprice.py index bb52219..fc4c6e2 100644 --- a/tastytrade/dxfeed/theoprice.py +++ b/tastytrade/dxfeed/theoprice.py @@ -1,9 +1,8 @@ -from dataclasses import dataclass +from decimal import Decimal from .event import Event -@dataclass class TheoPrice(Event): """ Theo price is a snapshot of the theoretical option price computation that @@ -25,14 +24,14 @@ class TheoPrice(Event): #: sequence number to distinguish events that have the same time sequence: int #: theoretical price - price: float + price: Decimal #: underlying price at the time of theo price computation - underlyingPrice: float + underlyingPrice: Decimal #: delta of the theoretical price - delta: float + delta: Decimal #: gamma of the theoretical price - gamma: float + gamma: Decimal #: implied simple dividend return of the corresponding option series - dividend: float + dividend: Decimal #: implied simple interest return of the corresponding option series - interest: float + interest: Decimal diff --git a/tastytrade/dxfeed/timeandsale.py b/tastytrade/dxfeed/timeandsale.py index 2d27034..ae61353 100644 --- a/tastytrade/dxfeed/timeandsale.py +++ b/tastytrade/dxfeed/timeandsale.py @@ -1,9 +1,8 @@ -from dataclasses import dataclass +from decimal import Decimal from .event import Event -@dataclass class TimeAndSale(Event): """ TimeAndSale event represents a trade or other market event with a price, @@ -30,13 +29,13 @@ class TimeAndSale(Event): #: exchange code of this time and sale event exchangeCode: str #: price of this time and sale event - price: float + price: Decimal #: size of this time and sale event as integer number (rounded toward zero) size: int #: the bid price on the market when this time and sale event occured - bidPrice: float + bidPrice: Decimal #: the ask price on the market when this time and sale event occured - askPrice: float + askPrice: Decimal #: sale conditions provided for this event by data feed exchangeSaleConditions: str #: transaction is concluded by exempting from compliance with some rule diff --git a/tastytrade/dxfeed/trade.py b/tastytrade/dxfeed/trade.py index 33a3dd3..62accc7 100644 --- a/tastytrade/dxfeed/trade.py +++ b/tastytrade/dxfeed/trade.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass +from decimal import Decimal +from typing import Optional from .event import Event -@dataclass class Trade(Event): """ A Trade event provides prices and the volume of the last transaction in @@ -24,20 +24,20 @@ class Trade(Event): sequence: int #: exchange code of the last trade exchangeCode: str - #: price of the last trade - price: float - #: change of the last trade - change: float - #: size of the last trade as integer number (rounded toward zero) - size: int #: identifier of the current trading day dayId: int - #: total vlume traded for a day as integer number (rounded toward zero) - dayVolume: int - #: total turnover traded for a day - dayTurnover: float #: tick direction of the last trade #: possible values are DOWN | UNDEFINED | UP | ZERO | ZERO_DOWN | ZERO_UP tickDirection: str #: whether the last trade was in extended trading hours extendedTradingHours: bool + #: price of the last trade + price: Optional[Decimal] = None + #: change of the last trade + change: Optional[Decimal] = None + #: size of the last trade as integer number (rounded toward zero) + size: Optional[int] = None + #: total vlume traded for a day as integer number (rounded toward zero) + dayVolume: Optional[int] = None + #: total turnover traded for a day + dayTurnover: Optional[Decimal] = None diff --git a/tastytrade/dxfeed/underlying.py b/tastytrade/dxfeed/underlying.py index 81270ff..ac3da6c 100644 --- a/tastytrade/dxfeed/underlying.py +++ b/tastytrade/dxfeed/underlying.py @@ -1,9 +1,8 @@ -from dataclasses import dataclass +from decimal import Decimal from .event import Event -@dataclass class Underlying(Event): """ Underlying event is a snapshot of computed values that are available for @@ -24,11 +23,11 @@ class Underlying(Event): #: sequence number of this event to distinguish events with the same time sequence: int #: 30-day implied volatility for this underlying based on VIX methodology - volatility: float + volatility: Decimal #: front month implied volatility for the underlying using VIX methodology - frontVolatility: float + frontVolatility: Decimal #: back month implied volatility for the underlying using VIX methodology - backVolatility: float + backVolatility: Decimal #: call options traded volume for a day callVolume: int #: put options traded volume for a day @@ -36,4 +35,4 @@ class Underlying(Event): #: options traded volume for a day optionVolume: int #: ratio of put options volume to call options volume for a day - putCallRatio: float + putCallRatio: Decimal diff --git a/tastytrade/instruments.py b/tastytrade/instruments.py index abc0922..a679027 100644 --- a/tastytrade/instruments.py +++ b/tastytrade/instruments.py @@ -50,9 +50,9 @@ class Deliverable(TastytradeJsonDataclass): deliverable_type: str description: str amount: Decimal - symbol: str - instrument_type: InstrumentType percent: str + symbol: Optional[str] = None + instrument_type: Optional[InstrumentType] = None class DestinationVenueSymbol(TastytradeJsonDataclass): diff --git a/tastytrade/streamer.py b/tastytrade/streamer.py index 5967d26..9dd04d4 100644 --- a/tastytrade/streamer.py +++ b/tastytrade/streamer.py @@ -698,8 +698,8 @@ async def _connect(self) -> None: self._heartbeat_task = \ asyncio.create_task(self._heartbeat()) elif message['type'] == 'CHANNEL_OPENED': - channel = next((k for k, v in self._channels.items() - if v == message['channel'])) + channel = next(k for k, v in self._channels.items() + if v == message['channel']) self._subscription_state[channel] = message['type'] elif message['type'] == 'CHANNEL_CLOSED': pass @@ -748,8 +748,7 @@ async def get_event(self, event_type: EventType) -> Event: :param event_type: the type of event to get """ - while True: - return await self._queues[event_type].get() + return await self._queues[event_type].get() async def _heartbeat(self) -> None: """ diff --git a/tastytrade/utils.py b/tastytrade/utils.py index 28deede..3db009d 100644 --- a/tastytrade/utils.py +++ b/tastytrade/utils.py @@ -125,6 +125,39 @@ def get_future_grain_monthly(day: date = date.today()) -> date: return itr +def get_future_oil_monthly(day: date = date.today()) -> date: + """ + Gets the monthly expiration associated with the WTI oil futures: /CL and + /MCL. According to CME, these expire 6 business days before the 25th day + of the month, unless the 25th day is not a business day, in which case + they expire 7 business days prior to the 25th day of the month. + + :param day: the date to check, defaults to today + + :return: the associated monthly + """ + last_day = day.replace(day=25) + first_day = last_day.replace(day=1) + valid_range = [d.date() for d in NYSE.valid_days(first_day, last_day)] + return valid_range[-7] + + +def get_future_index_monthly(day: date = date.today()) -> date: + """ + Gets the monthly expiration associated with the index futures: /ES, /RTY, + /NQ, etc. According to CME, these expire on 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)] + return valid_range[-1] + + class TastytradeError(Exception): """ An internal error raised by the Tastytrade API. diff --git a/tests/test_utils.py b/tests/test_utils.py index 5c36b81..f6e9348 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,9 +1,9 @@ from datetime import date from tastytrade.utils import (get_future_fx_monthly, get_future_grain_monthly, - get_future_metal_monthly, + get_future_metal_monthly, get_future_index_monthly, get_future_treasury_monthly, get_tasty_monthly, - get_third_friday) + get_third_friday, get_future_oil_monthly) def test_get_third_friday(): @@ -106,3 +106,48 @@ def test_get_future_metal_monthly(): ] for exp in exps: assert get_future_metal_monthly(exp) == exp + + +def test_get_future_oil_monthly(): + exps = [ + date(2024, 2, 14), + date(2024, 3, 15), + date(2024, 4, 17), + date(2024, 5, 16), + date(2024, 6, 14), + date(2024, 7, 17), + date(2024, 8, 15), + date(2024, 9, 17), + date(2024, 10, 17), + date(2024, 11, 15), + date(2024, 12, 16), + date(2025, 10, 16), + date(2026, 4, 16), + date(2027, 7, 15), + date(2028, 1, 14), + date(2029, 5, 17), + date(2030, 11, 15), + date(2031, 8, 15), + date(2032, 2, 17), + date(2033, 4, 14), + date(2034, 1, 17) + ] + for exp in exps: + assert get_future_oil_monthly(exp) == exp + + +def test_get_future_index_monthly(): + exps = [ + date(2024, 2, 29), + date(2024, 3, 28), + date(2024, 4, 30), + date(2024, 5, 31), + date(2024, 6, 28), + date(2024, 7, 31), + date(2024, 9, 30), + date(2024, 12, 31), + date(2025, 3, 31), + date(2025, 6, 30) + ] + for exp in exps: + assert get_future_index_monthly(exp) == exp