From 649087798235c6e0d72ef4dca28a8b649662e850 Mon Sep 17 00:00:00 2001 From: Graeme22 Date: Thu, 16 Nov 2023 20:21:16 -0500 Subject: [PATCH 1/4] streamers use async context managers; prod session in testing --- .github/CONTRIBUTING.md | 13 ++ .github/pull_request_template.md | 2 + .github/workflows/python-app.yml | 11 +- README.rst | 23 ++-- docs/data-streamer.rst | 58 +++++--- tastytrade/account.py | 8 +- tastytrade/instruments.py | 4 +- tastytrade/metrics.py | 6 +- tastytrade/session.py | 2 +- tastytrade/streamer.py | 223 ++++++++++++++++--------------- tests/conftest.py | 7 +- tests/test_account.py | 29 ++-- tests/test_instruments.py | 36 +++-- tests/test_metrics.py | 16 ++- tests/test_session.py | 24 +++- tests/test_streamer.py | 40 ++++-- 16 files changed, 304 insertions(+), 198 deletions(-) create mode 100644 .github/CONTRIBUTING.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..0de36ee --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributions + +Since Tastytrade certification sessions are severely limited in capabilities, the test suite for this SDK requires the usage of your own Tastytrade credentials. In order to run the tests, you'll need to set up your Tastytrade credentials as repository secrets on your local fork. + +Secrets are protected by Github and are not visible to anyone. You can read more about repository secrets [here](https://docs.github.com/en/actions/reference/encrypted-secrets). + +## Steps to follow to contribute + +1. Fork the repository to your personal Github account, NOT to an organization where others may be able to indirectly access your secrets. +2. Make your changes on the forked repository. +3. Navigate to the forked repository's settings page and click on "Secrets and variables" > "Actions". +4. Click on "New repository secret" to add your Tastytrade username named `TT_USERNAME`. +5. Finally, do the same with your password, naming it `TT_PASSWORD`. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 96b4be6..b6ab314 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,3 +6,5 @@ Fixes ... ## Pre-merge checklist - [ ] Passing tests - [ ] New tests added (if applicable) + +Please note that, in order to pass the tests, you'll need to set up your Tastytrade credentials as repository secrets on your local fork. Read more at CONTRIBUTING.md. diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 0ed02ac..dd188fd 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -10,16 +10,13 @@ jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python 3.8 uses: actions/setup-python@v2 with: - python-version: ${{ matrix.python-version }} + python-version: '3.8' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -33,5 +30,5 @@ jobs: run: | python -m pytest --cov=tastytrade --cov-report=term-missing tests/ --cov-fail-under=95 env: - TT_USERNAME: tastyware - TT_PASSWORD: :4s-S9/9L&Q~C]@v + TT_USERNAME: ${{ secrets.TT_USERNAME }} + TT_PASSWORD: ${{ secrets.TT_PASSWORD }} diff --git a/README.rst b/README.rst index dbc9090..1aa5d05 100644 --- a/README.rst +++ b/README.rst @@ -40,20 +40,19 @@ The streamer is a websocket connection to dxfeed (the Tastytrade data provider) .. code-block:: python - from tastytrade import DXFeedStreamer + from tastytrade import DXLinkStreamer from tastytrade.dxfeed import EventType - streamer = await DXFeedStreamer.create(session) - subs_list = ['SPY', 'SPX'] - - await streamer.subscribe(EventType.QUOTE, subs_list) - # this example fetches quotes once, then exits - quotes = [] - async for quote in streamer.listen(EventType.QUOTE): - quotes.append(quote) - if len(quotes) >= len(subs_list): - break - print(quotes) + async with DXLinkStreamer(session) as streamer: + subs_list = ['SPY', 'GLD'] # list of symbols to subscribe to + await streamer.subscribe(EventType.QUOTE, subs_list) + # this example fetches quotes once, then exits + quotes = [] + async for quote in streamer.listen(EventType.QUOTE): + quotes.append(quote) + if len(quotes) >= len(subs_list): + break + print(quotes) >>> [Quote(eventSymbol='SPY', eventTime=0, sequence=0, timeNanoPart=0, bidTime=0, bidExchangeCode='Q', bidPrice=411.58, bidSize=400.0, askTime=0, askExchangeCode='Q', askPrice=411.6, askSize=1313.0), Quote(eventSymbol='SPX', eventTime=0, sequence=0, timeNanoPart=0, bidTime=0, bidExchangeCode='\x00', bidPrice=4122.49, bidSize='NaN', askTime=0, askExchangeCode='\x00', askPrice=4123.65, askSize='NaN')] diff --git a/docs/data-streamer.rst b/docs/data-streamer.rst index 5b75efb..06619f9 100644 --- a/docs/data-streamer.rst +++ b/docs/data-streamer.rst @@ -9,9 +9,18 @@ You can create a streamer using an active production session: .. code-block:: python - from tastytrade import DXFeedStreamer - streamer = await DXFeedStreamer.create(session) + from tastytrade import DXLinkStreamer + streamer = await DXLinkStreamer.create(session) + +Or, you can create a streamer using an asynchronous context manager: + +.. code-block:: python + + from tastytrade import DXLinkStreamer + async with DXLinkStreamer(session) as streamer: + pass +There are two kinds of streamers: ``DXLinkStreamer`` and ``DXFeedStreamer``. ``DXFeedStreamer`` is older, but has been kept around for compatibility reasons. It supports more event types, but it's now deprecated as it will probably be moved to delayed quotes at some point. Once you've created the streamer, you can subscribe/unsubscribe to events, like ``Quote``: .. code-block:: python @@ -19,13 +28,28 @@ Once you've created the streamer, you can subscribe/unsubscribe to events, like from tastytrade.dxfeed import EventType subs_list = ['SPY', 'SPX'] - await streamer.subscribe(EventType.QUOTE, subs_list) - quotes = [] - async for quote in streamer.listen(EventType.QUOTE): - quotes.append(quote) - if len(quotes) >= len(subs_list): - break - print(quotes) + async with DXFeedStreamer(session) as streamer: + await streamer.subscribe(EventType.QUOTE, subs_list) + quotes = [] + async for quote in streamer.listen(EventType.QUOTE): + quotes.append(quote) + if len(quotes) >= len(subs_list): + break + print(quotes) + +>>> [Quote(eventSymbol='SPY', eventTime=0, sequence=0, timeNanoPart=0, bidTime=0, bidExchangeCode='Q', bidPrice=411.58, bidSize=400.0, askTime=0, askExchangeCode='Q', askPrice=411.6, askSize=1313.0), Quote(eventSymbol='SPX', eventTime=0, sequence=0, timeNanoPart=0, bidTime=0, bidExchangeCode='\x00', bidPrice=4122.49, bidSize='NaN', askTime=0, askExchangeCode='\x00', askPrice=4123.65, askSize='NaN')] + +Note that these are ``asyncio`` calls, so you'll need to run this code asynchronously. Alternatively, you can do testing in a Jupyter notebook, which allows you to make async calls directly. Here's an example: + +.. code-block:: python + + async def main(): + async with DXLinkStreamer(session) as streamer: + await streamer.subscribe(EventType.QUOTE, subs_list) + quote = await streamer.get_event(EventType.QUOTE) + print(quote) + + asyncio.run(main()) >>> [Quote(eventSymbol='SPY', eventTime=0, sequence=0, timeNanoPart=0, bidTime=0, bidExchangeCode='Q', bidPrice=411.58, bidSize=400.0, askTime=0, askExchangeCode='Q', askPrice=411.6, askSize=1313.0), Quote(eventSymbol='SPX', eventTime=0, sequence=0, timeNanoPart=0, bidTime=0, bidExchangeCode='\x00', bidPrice=4122.49, bidSize='NaN', askTime=0, askExchangeCode='\x00', askPrice=4123.65, askSize='NaN')] @@ -39,13 +63,14 @@ We can also use the streamer to stream greeks for options symbols: chain = get_option_chain(session, 'SPLG') subs_list = [chain[date(2023, 6, 16)][0].streamer_symbol] - await streamer.subscribe(EventType.GREEKS, subs_list) - greeks = [] - async for greek in streamer.listen(EventType.GREEKS): - greeks.append(greek) - if len(greeks) >= len(subs_list): - break - print(greeks) + async with DXFeedStreamer(session) as streamer: + await streamer.subscribe(EventType.GREEKS, subs_list) + greeks = [] + async for greek in streamer.listen(EventType.GREEKS): + greeks.append(greek) + if len(greeks) >= len(subs_list): + break + print(greeks) >>> [Greeks(eventSymbol='.SPLG230616C23', eventTime=0, eventFlags=0, index=7235129486797176832, time=1684559855338, sequence=0, price=26.3380972233688, volatility=0.396983376650804, delta=0.999999999996191, gamma=4.81989763184255e-12, theta=-2.5212017514875e-12, rho=0.01834504287973133, vega=3.7003015672215e-12)] @@ -60,6 +85,7 @@ For example, we can use the streamer to create an option chain that will continu import asyncio from datetime import date from dataclasses import dataclass + from tastytrade import DXFeedStreamer from tastytrade.instruments import get_option_chain from tastytrade.dxfeed import Greeks, Quote diff --git a/tastytrade/account.py b/tastytrade/account.py index b5697d2..e0ca58e 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -295,7 +295,6 @@ class TradingStatus(TastytradeJsonDataclass): is_in_margin_call: bool is_pattern_day_trader: bool is_portfolio_margin_enabled: bool - is_risk_reducing_only: bool is_small_notional_futures_intra_day_enabled: bool is_roll_the_day_forward_enabled: bool are_far_otm_net_options_restricted: bool @@ -305,6 +304,7 @@ class TradingStatus(TastytradeJsonDataclass): is_equity_offering_enabled: bool is_equity_offering_closing_only: bool updated_at: datetime + is_risk_reducing_only: Optional[bool] = None day_trade_count: Optional[int] = None autotrade_account_type: Optional[str] = None clearing_account_number: Optional[str] = None @@ -668,7 +668,7 @@ def get_history( return [Transaction(**entry) for entry in results] - def get_transaction(self, session: Session, id: int) -> Transaction: + def get_transaction(self, session: Session, id: int) -> Transaction: # pragma: no cover """ Get a single transaction by ID. @@ -715,7 +715,7 @@ def get_net_liquidating_value_history( session: ProductionSession, time_back: Optional[str] = None, start_time: Optional[datetime] = None - ) -> List[NetLiqOhlc]: # pragma: no cover + ) -> List[NetLiqOhlc]: """ Returns a list of account net liquidating value snapshots over the specified time period. @@ -775,7 +775,7 @@ def get_effective_margin_requirements( self, session: ProductionSession, symbol: str - ) -> MarginRequirement: # pragma: no cover + ) -> MarginRequirement: """ Get the effective margin requirements for a given symbol. diff --git a/tastytrade/instruments.py b/tastytrade/instruments.py index 79f7626..d18e5fe 100644 --- a/tastytrade/instruments.py +++ b/tastytrade/instruments.py @@ -412,7 +412,7 @@ def get_options( symbols: Optional[List[str]] = None, active: Optional[bool] = None, with_expired: Optional[bool] = None - ) -> List['Option']: + ) -> List['Option']: # pragma: no cover """ Returns a list of :class:`Option` objects from the given symbols. @@ -1062,7 +1062,7 @@ def get_option_chain( def get_future_option_chain( session: ProductionSession, symbol: str -) -> Dict[date, List[FutureOption]]: # pragma: no cover +) -> Dict[date, List[FutureOption]]: """ Returns a mapping of expiration date to a list of futures options objects representing the options chain for the given symbol. diff --git a/tastytrade/metrics.py b/tastytrade/metrics.py index 9c59a7b..aa094dd 100644 --- a/tastytrade/metrics.py +++ b/tastytrade/metrics.py @@ -93,7 +93,7 @@ class MarketMetricInfo(TastytradeJsonDataclass): def get_market_metrics( session: ProductionSession, symbols: List[str] -) -> List[MarketMetricInfo]: # pragma: no cover +) -> List[MarketMetricInfo]: """ Retrieves market metrics for the given symbols. @@ -117,7 +117,7 @@ def get_market_metrics( def get_dividends( session: ProductionSession, symbol: str -) -> List[DividendInfo]: # pragma: no cover +) -> List[DividendInfo]: """ Retrieves dividend information for the given symbol. @@ -142,7 +142,7 @@ def get_earnings( session: ProductionSession, symbol: str, start_date: date -) -> List[EarningsInfo]: # pragma: no cover +) -> List[EarningsInfo]: """ Retrieves earnings information for the given symbol. diff --git a/tastytrade/session.py b/tastytrade/session.py index db0ec1b..4f100ac 100644 --- a/tastytrade/session.py +++ b/tastytrade/session.py @@ -113,7 +113,7 @@ def __init__( self.validate() -class ProductionSession(Session): # pragma: no cover +class ProductionSession(Session): """ Contains a local user login which can then be used to interact with the remote API. diff --git a/tastytrade/streamer.py b/tastytrade/streamer.py index e992a9e..38ddbe0 100644 --- a/tastytrade/streamer.py +++ b/tastytrade/streamer.py @@ -78,25 +78,24 @@ class AccountStreamer: """ Used to subscribe to account-level updates (balances, orders, positions), public watchlist updates, quote alerts, and user-level messages. It should - always be initialized using the :meth:`create` function, since the object - cannot be fully instantiated without using async. + always be initialized as an async context manager, or with the `create` + function, since the object cannot be fully instantiated without async. Example usage:: from tastytrade import Account, AccountStreamer - streamer = await AccountStreamer.create(session) - accounts = Account.get_accounts(session) + async with AccountStreamer(session) as streamer: + accounts = Account.get_accounts(session) - await streamer.account_subscribe(accounts) - await streamer.public_watchlists_subscribe() - await streamer.quote_alerts_subscribe() + await streamer.account_subscribe(accounts) + await streamer.public_watchlists_subscribe() + await streamer.quote_alerts_subscribe() - async for data in streamer.listen(): - print(data) + async for data in streamer.listen(): + print(data) """ - def __init__(self, session: Session): #: The active session used to initiate the streamer or make requests self.token: str = session.session_token @@ -107,29 +106,33 @@ def __init__(self, session: Session): self._queue: Queue = Queue() self._websocket = None - self._connect_task = asyncio.create_task(self._connect()) - @classmethod - async def create(cls, session: Session) -> 'AccountStreamer': - """ - Factory method for the :class:`AccountStreamer` object. Simply calls - the constructor and performs the asynchronous setup tasks. This should - be used instead of the constructor. - - :param session: active user session to use - """ - self = cls(session) - await self._wait_for_authentication() - return self - - async def _wait_for_authentication(self, time_out=100): + async def __aenter__(self): + time_out = 100 while not self._websocket: await asyncio.sleep(0.1) time_out -= 1 if time_out < 0: raise TastytradeError('Connection timed out') + return self + + @classmethod + async def create(cls, session: Session) -> 'AccountStreamer': + self = cls(session) + return await self.__aenter__() + + async def __aexit__(self, exc_type, exc, tb): + await self.close() + + async def close(self): + """ + Closes the websocket connection and cancels the heartbeat task. + """ + self._connect_task.cancel() + self._heartbeat_task.cancel() + async def _connect(self) -> None: """ Connect to the websocket server using the URL and authorization @@ -137,8 +140,8 @@ async def _connect(self) -> None: """ headers = {'Authorization': f'Bearer {self.token}'} async with websockets.connect( # type: ignore - self.base_url, - extra_headers=headers + self.base_url, + extra_headers=headers ) as websocket: self._websocket = websocket self._heartbeat_task = asyncio.create_task(self._heartbeat()) @@ -151,20 +154,18 @@ async def _connect(self) -> None: async def listen(self) -> AsyncIterator[TastytradeJsonDataclass]: """ Iterate over non-heartbeat messages received from the streamer, - mapping them to their appropriate data class. + mapping them to their appropriate data class and yielding them. """ while True: data = await self._queue.get() type_str = data.get('type') if type_str is not None: yield self._map_message(type_str, data['data']) - elif data.get('action') != 'heartbeat': - logger.debug('subscription message: %s', data) def _map_message( - self, - type_str: str, - data: dict + self, + type_str: str, + data: dict ) -> TastytradeJsonDataclass: """ I'm not sure what the user-status messages look like, @@ -197,7 +198,7 @@ async def account_subscribe(self, accounts: List[Account]) -> None: """ await self._subscribe( SubscriptionType.ACCOUNT, - [acc.account_number for acc in accounts] + [a.account_number for a in accounts] ) async def public_watchlists_subscribe(self) -> None: @@ -219,13 +220,6 @@ async def user_message_subscribe(self, session: Session) -> None: external_id = session.user['external-id'] await self._subscribe(SubscriptionType.USER_MESSAGE, value=external_id) - def close(self) -> None: - """ - Closes the websocket connection and cancels the heartbeat task. - """ - self._connect_task.cancel() - self._heartbeat_task.cancel() - async def _heartbeat(self) -> None: """ Sends a heartbeat message every 10 seconds to keep the connection @@ -237,9 +231,9 @@ async def _heartbeat(self) -> None: await asyncio.sleep(10) async def _subscribe( - self, - subscription: SubscriptionType, - value: Union[Optional[str], List[str]] = '' + self, + subscription: SubscriptionType, + value: Union[Optional[str], List[str]] = '' ) -> None: """ Subscribes to a :class:`SubscriptionType`. Depending on the kind of @@ -257,10 +251,10 @@ async def _subscribe( class DXFeedStreamer: # pragma: no cover """ - A :class:`DXFeedStreamer` object is used to fetch quotes or greeks - for a given symbol or list of symbols. It should always be - initialized using the :meth:`create` function, since the object - cannot be fully instantiated without using async. + A :class:`DXFeedStreamer` object is used to fetch quotes or greeks for a + given symbol or list of symbols. It should always be initialized as an + async context manager, or with the `create` function, since the object + cannot be fully instantiated without async. Example usage:: @@ -268,15 +262,11 @@ class DXFeedStreamer: # pragma: no cover from tastytrade.dxfeed import EventType # must be a production session - streamer = await DXFeedStreamer.create(session) - - subs = ['SPY', 'GLD'] # list of quotes to fetch - await streamer.subscribe(EventType.QUOTE, subs) - quotes = [] - async for quote in streamer.listen(EventType.QUOTE): - quotes.append(quote) - if len(quotes) >= len(subs): - break + async with DXFeedStreamer(session) as streamer: + subs = ['SPY', 'GLD'] # list of quotes to fetch + await streamer.subscribe(EventType.QUOTE, subs) + quote = await streamer.get_event(EventType.QUOTE) + print(quote) """ def __init__(self, session: ProductionSession): @@ -291,26 +281,31 @@ def __init__(self, session: ProductionSession): self._connect_task = asyncio.create_task(self._connect()) - @classmethod - async def create(cls, session: ProductionSession) -> 'DXFeedStreamer': - """ - Factory method for the :class:`DXFeedStreamer` object. - Simply calls the constructor and performs the asynchronous - setup tasks. This should be used instead of the constructor. - - :param session: active user session to use - """ - self = cls(session) - await self._wait_for_authentication() - return self - - async def _wait_for_authentication(self, time_out=100): + async def __aenter__(self): + time_out = 100 while not self.client_id: await asyncio.sleep(0.1) time_out -= 1 if time_out < 0: raise TastytradeError('Connection timed out') + return self + + @classmethod + async def create(cls, session: ProductionSession) -> 'DXFeedStreamer': + self = cls(session) + return await self.__aenter__() + + async def __aexit__(self, exc_type, exc, tb): + await self.close() + + async def close(self): + """ + Closes the websocket connection and cancels the heartbeat task. + """ + self._connect_task.cancel() + self._heartbeat_task.cancel() + async def _next_id(self): async with self._lock: self._counter += 1 @@ -324,8 +319,8 @@ async def _connect(self) -> None: headers = {'Authorization': f'Bearer {self._auth_token}'} async with websockets.connect( # type: ignore - self._wss_url, - extra_headers=headers + self._wss_url, + extra_headers=headers ) as websocket: self._websocket = websocket await self._handshake() @@ -392,12 +387,15 @@ async def listen(self, event_type: EventType) -> AsyncIterator[Event]: while True: yield await self._queues[event_type].get() - def close(self) -> None: + async def get_event(self, event_type: EventType) -> Event: """ - Closes the websocket connection and cancels the heartbeat task. + Using the existing subscription , pulls an event of the given type and + returns it. + + :param event_type: the type of event to get """ - self._connect_task.cancel() - self._heartbeat_task.cancel() + while True: + return await self._queues[event_type].get() async def _heartbeat(self) -> None: """ @@ -601,12 +599,12 @@ async def _map_message(self, message) -> None: raise TastytradeError(f'Unknown message type received: {message}') -class DXLinkStreamer: # pragma: no cover +class DXLinkStreamer: """ - A :class:`DXLinkStreamer` object is used to fetch quotes or greeks - for a given symbol or list of symbols. It should always be - initialized using the :meth:`create` function, since the object - cannot be fully instantiated without using async. + A :class:`DXLinkStreamer` object is used to fetch quotes or greeks for a + given symbol or list of symbols. It should always be initialized as an + async context manager, or with the `create` function, since the object + cannot be fully instantiated without async. Example usage:: @@ -614,18 +612,13 @@ class DXLinkStreamer: # pragma: no cover from tastytrade.dxfeed import EventType # must be a production session - streamer = await DXLinkStreamer.create(session) - - subs = ['SPY', 'GLD'] # list of quotes to fetch - await streamer.subscribe(EventType.QUOTE, subs) - quotes = [] - async for quote in streamer.listen(EventType.QUOTE): - quotes.append(quote) - if len(quotes) >= len(subs): - break + async with DXLinkStreamer(session) as streamer: + subs = ['SPY'] # list of quotes to subscribe to + await streamer.subscribe(EventType.QUOTE, subs) + quote = await streamer.get_event(EventType.QUOTE) + print(quote) """ - def __init__(self, session: ProductionSession): self._counter = 0 self._lock: Lock = Lock() @@ -652,18 +645,30 @@ def __init__(self, session: ProductionSession): self._connect_task = asyncio.create_task(self._connect()) + async def __aenter__(self): + time_out = 100 + while not self._authenticated: + await asyncio.sleep(0.1) + time_out -= 1 + if time_out < 0: + raise TastytradeError('Connection timed out') + + return self + @classmethod async def create(cls, session: ProductionSession) -> 'DXLinkStreamer': - """ - Factory method for the :class:`DXLinkStreamer` object. - Simply calls the constructor and performs the asynchronous - setup tasks. This should be used instead of the constructor. + self = cls(session) + return await self.__aenter__() + + async def __aexit__(self, exc_type, exc, tb): + await self.close() - :param session: active user session to use + async def close(self): """ - self = cls(session) - await self._wait_for_authentication() - return self + Closes the websocket connection and cancels the heartbeat task. + """ + self._connect_task.cancel() + self._heartbeat_task.cancel() async def _connect(self) -> None: """ @@ -722,13 +727,6 @@ async def _authenticate_connection(self): } await self._websocket.send(json.dumps(message)) - async def _wait_for_authentication(self, time_out=100): - while not self._authenticated: - await asyncio.sleep(0.1) - time_out -= 1 - if time_out < 0: - raise TastytradeError('Connection timed out') - async def listen(self, event_type: EventType) -> AsyncIterator[Event]: """ Using the existing subscriptions, pulls events of the given type and @@ -740,12 +738,15 @@ async def listen(self, event_type: EventType) -> AsyncIterator[Event]: while True: yield await self._queues[event_type].get() - def close(self) -> None: + async def get_event(self, event_type: EventType) -> Event: """ - Closes the websocket connection and cancels the keepalive task. + Using the existing subscription, pulls an event of the given type and + returns it. + + :param event_type: the type of event to get """ - self._heartbeat_task.cancel() - self._connect_task.cancel() + while True: + return await self._queues[event_type].get() async def _heartbeat(self) -> None: """ diff --git a/tests/conftest.py b/tests/conftest.py index 6dff9a2..9a74b7e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ import pytest -from tastytrade import CertificationSession +from tastytrade import ProductionSession @pytest.fixture(scope='session') @@ -10,7 +10,10 @@ def session(): username = os.environ.get('TT_USERNAME', None) password = os.environ.get('TT_PASSWORD', None) - session = CertificationSession(username, password) + assert username is not None + assert password is not None + + session = ProductionSession(username, password) yield session session.destroy() diff --git a/tests/test_account.py b/tests/test_account.py index 9f4a99a..e91df12 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -35,12 +35,7 @@ def test_get_positions(session, account): def test_get_history(session, account): - account.get_history(session) - - -def test_get_transaction(session, account): - TX_ID = 42961 # opening deposit - account.get_transaction(session, TX_ID) + account.get_history(session, page_offset=0) def test_get_total_fees(session, account): @@ -55,6 +50,14 @@ def test_get_margin_requirements(session, account): account.get_margin_requirements(session) +def test_get_net_liquidating_value_history(session, account): + account.get_net_liquidating_value_history(session, time_back='1y') + + +def test_get_effective_margin_requirements(session, account): + account.get_effective_margin_requirements(session, 'SPY') + + @pytest.fixture(scope='session') def new_order(session): symbol = Equity.get_equity(session, 'SPY') @@ -64,7 +67,7 @@ def new_order(session): time_in_force=OrderTimeInForce.DAY, order_type=OrderType.LIMIT, legs=[leg], - price=Decimal(420), # over $3 so will never fill + price=Decimal(42), # if this fills the US has crumbled price_effect=PriceEffect.DEBIT ) @@ -79,15 +82,17 @@ def test_place_and_delete_order(session, account, new_order): account.delete_order(session, order.id) -def test_replace_and_delete_order(session, account, new_order, placed_order): - replaced = account.replace_order(session, placed_order.id, new_order) - account.delete_order(session, replaced.id) - - def test_get_order(session, account, placed_order): assert account.get_order(session, placed_order.id).id == placed_order.id +def test_replace_and_delete_order(session, account, new_order, placed_order): + modified_order = new_order.copy() + modified_order.price = Decimal(40) + replaced = account.replace_order(session, placed_order.id, modified_order) + account.delete_order(session, replaced.id) + + def test_get_order_history(session, account): account.get_order_history(session, page_offset=0) diff --git a/tests/test_instruments.py b/tests/test_instruments.py index b942f35..caa9444 100644 --- a/tests/test_instruments.py +++ b/tests/test_instruments.py @@ -1,7 +1,9 @@ -from tastytrade.instruments import (Cryptocurrency, Equity, - FutureOptionProduct, FutureProduct, - NestedOptionChain, Option, Warrant, - get_option_chain, +from tastytrade.instruments import (Cryptocurrency, Equity, Future, + FutureOption, FutureOptionProduct, + FutureProduct, NestedOptionChain, + NestedFutureOptionChain, Option, + Warrant, get_option_chain, + get_future_option_chain, get_quantity_decimal_precisions) @@ -25,6 +27,11 @@ def test_get_equity(session): Equity.get_equity(session, 'AAPL') +def test_get_futures(session): + futures = Future.get_futures(session, product_codes=['ES']) + Future.get_future(session, futures[0].symbol) + + def test_get_future_product(session): FutureProduct.get_future_product(session, 'ZN') @@ -45,19 +52,32 @@ def test_get_nested_option_chain(session): NestedOptionChain.get_chain(session, 'SPY') +def test_get_nested_future_option_chain(session): + NestedFutureOptionChain.get_chain(session, 'ES') + + def test_get_warrants(session): Warrant.get_warrants(session) +def test_get_warrant(session): + Warrant.get_warrant(session, 'NKLAW') + + def test_get_quantity_decimal_precisions(session): get_quantity_decimal_precisions(session) def test_get_option_chain(session): chain = get_option_chain(session, 'SPY') - symbols = [] for options in chain.values(): - symbols.extend([o.symbol for o in options]) + Option.get_option(session, options[0].symbol) + break + + +def test_get_future_option_chain(session): + chain = get_future_option_chain(session, 'ES') + for options in chain.values(): + FutureOption.get_future_option(session, options[0].symbol) + FutureOption.get_future_options(session, options[:4]) break - Option.get_option(session, symbols[0]) - Option.get_options(session, symbols) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 7a6f9bf..7ae89fa 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,4 +1,18 @@ -from tastytrade.metrics import get_risk_free_rate +from datetime import date + +from tastytrade.metrics import get_dividends, get_earnings, get_market_metrics, get_risk_free_rate + + +def test_get_dividends(session): + get_dividends(session, 'SPY') + + +def test_get_earnings(session): + get_earnings(session, 'AAPL', date.today()) + + +def test_get_market_metrics(session): + get_market_metrics(session, ['SPY', 'AAPL']) def test_get_risk_free_rate(session): diff --git a/tests/test_session.py b/tests/test_session.py index 813d1aa..9702c5a 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,16 +1,28 @@ import os +from datetime import datetime, timedelta -from tastytrade import CertificationSession +from tastytrade import CertificationSession, ProductionSession +from tastytrade.dxfeed import EventType def test_get_customer(session): assert session.get_customer() != {} -def test_destroy(): - # here we create a new session to avoid destroying the active one - username = os.environ.get('TT_USERNAME', None) - password = os.environ.get('TT_PASSWORD', None) +def test_get_event(session): + session.get_event(EventType.QUOTE, ['SPY', 'AAPL']) + + +def test_get_time_and_sale(session): + start_date = datetime.today() - timedelta(days=30) + session.get_time_and_sale(['SPY', 'AAPL'], start_date) + - session = CertificationSession(username, password) +def test_get_candle(session): + start_date = datetime.today() - timedelta(days=30) + session.get_candle(['SPY', 'AAPL'], '1d', start_date) + + +def test_destroy(): + session = CertificationSession('tastyware', ':4s-S9/9L&Q~C]@v') assert session.destroy() diff --git a/tests/test_streamer.py b/tests/test_streamer.py index ba79342..c8ff4b1 100644 --- a/tests/test_streamer.py +++ b/tests/test_streamer.py @@ -1,22 +1,36 @@ +from datetime import datetime, timedelta + import pytest -import pytest_asyncio -from tastytrade import Account, AccountStreamer +from tastytrade import Account, AccountStreamer, DXLinkStreamer +from tastytrade.dxfeed import EventType pytest_plugins = ('pytest_asyncio',) -@pytest_asyncio.fixture -async def streamer(session): - streamer = await AccountStreamer.create(session) - yield streamer - streamer.close() +@pytest.mark.asyncio +async def test_account_streamer(session): + async with AccountStreamer(session) as streamer: + await streamer.public_watchlists_subscribe() + await streamer.quote_alerts_subscribe() + await streamer.user_message_subscribe(session) + accounts = Account.get_accounts(session) + await streamer.account_subscribe(accounts) @pytest.mark.asyncio -async def test_subscribe_all(session, streamer): - await streamer.public_watchlists_subscribe() - await streamer.quote_alerts_subscribe() - await streamer.user_message_subscribe(session) - accounts = Account.get_accounts(session) - await streamer.account_subscribe(accounts) +async def test_dxlink_streamer(session): + message = "[{'eventType': 'Quote', 'eventSymbol': 'SPY', 'eventTime': 0, 'sequence': 0, 'timeNanoPart': 0, 'bidTime': 0, 'bidExchangeCode': 'Q', 'bidPrice': 450.5, 'bidSize': 796.0, 'askTime': 0, 'askExchangeCode': 'Q', 'askPrice': 450.55, 'askSize': 1100.0}, {'eventType': 'Quote', 'eventSymbol': 'AAPL', 'eventTime': 0, 'sequence': 0, 'timeNanoPart': 0, 'bidTime': 0, 'bidExchangeCode': 'Q', 'bidPrice': 190.39, 'bidSize': 1.0, 'askTime': 0, 'askExchangeCode': 'Q', 'askPrice': 190.44, 'askSize': 3.0}]" + + async with DXLinkStreamer(session) as streamer: + subs = ['SPY', 'AAPL'] + await streamer.subscribe(EventType.QUOTE, subs) + start_date = datetime.today() - timedelta(days=30) + await streamer.subscribe_candle(subs, '1d', start_date) + _ = await streamer.get_event(EventType.CANDLE) + async for _ in streamer.listen(EventType.QUOTE): + break + await streamer.unsubscribe_candle(subs[0], '1d') + await streamer.unsubscribe(EventType.QUOTE, subs[1]) + + streamer._map_message(message) From 565235c58f895d285080eed0de0aa32857a4fba1 Mon Sep 17 00:00:00 2001 From: Graeme22 Date: Thu, 16 Nov 2023 20:25:55 -0500 Subject: [PATCH 2/4] fix lint --- tastytrade/account.py | 6 +++++- tests/test_instruments.py | 7 +++---- tests/test_metrics.py | 3 ++- tests/test_session.py | 3 +-- tests/test_streamer.py | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tastytrade/account.py b/tastytrade/account.py index e0ca58e..ef37c26 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -668,7 +668,11 @@ def get_history( return [Transaction(**entry) for entry in results] - def get_transaction(self, session: Session, id: int) -> Transaction: # pragma: no cover + def get_transaction( + self, + session: Session, + id: int + ) -> Transaction: # pragma: no cover """ Get a single transaction by ID. diff --git a/tests/test_instruments.py b/tests/test_instruments.py index caa9444..8f32a98 100644 --- a/tests/test_instruments.py +++ b/tests/test_instruments.py @@ -1,9 +1,8 @@ from tastytrade.instruments import (Cryptocurrency, Equity, Future, FutureOption, FutureOptionProduct, - FutureProduct, NestedOptionChain, - NestedFutureOptionChain, Option, - Warrant, get_option_chain, - get_future_option_chain, + FutureProduct, NestedFutureOptionChain, + NestedOptionChain, Option, Warrant, + get_future_option_chain, get_option_chain, get_quantity_decimal_precisions) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 7ae89fa..fe43822 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,6 +1,7 @@ from datetime import date -from tastytrade.metrics import get_dividends, get_earnings, get_market_metrics, get_risk_free_rate +from tastytrade.metrics import (get_dividends, get_earnings, + get_market_metrics, get_risk_free_rate) def test_get_dividends(session): diff --git a/tests/test_session.py b/tests/test_session.py index 9702c5a..43bebc4 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,7 +1,6 @@ -import os from datetime import datetime, timedelta -from tastytrade import CertificationSession, ProductionSession +from tastytrade import CertificationSession from tastytrade.dxfeed import EventType diff --git a/tests/test_streamer.py b/tests/test_streamer.py index c8ff4b1..9289120 100644 --- a/tests/test_streamer.py +++ b/tests/test_streamer.py @@ -20,7 +20,7 @@ async def test_account_streamer(session): @pytest.mark.asyncio async def test_dxlink_streamer(session): - message = "[{'eventType': 'Quote', 'eventSymbol': 'SPY', 'eventTime': 0, 'sequence': 0, 'timeNanoPart': 0, 'bidTime': 0, 'bidExchangeCode': 'Q', 'bidPrice': 450.5, 'bidSize': 796.0, 'askTime': 0, 'askExchangeCode': 'Q', 'askPrice': 450.55, 'askSize': 1100.0}, {'eventType': 'Quote', 'eventSymbol': 'AAPL', 'eventTime': 0, 'sequence': 0, 'timeNanoPart': 0, 'bidTime': 0, 'bidExchangeCode': 'Q', 'bidPrice': 190.39, 'bidSize': 1.0, 'askTime': 0, 'askExchangeCode': 'Q', 'askPrice': 190.44, 'askSize': 3.0}]" + message = "[{'eventType': 'Quote', 'eventSymbol': 'SPY', 'eventTime': 0, 'sequence': 0, 'timeNanoPart': 0, 'bidTime': 0, 'bidExchangeCode': 'Q', 'bidPrice': 450.5, 'bidSize': 796.0, 'askTime': 0, 'askExchangeCode': 'Q', 'askPrice': 450.55, 'askSize': 1100.0}, {'eventType': 'Quote', 'eventSymbol': 'AAPL', 'eventTime': 0, 'sequence': 0, 'timeNanoPart': 0, 'bidTime': 0, 'bidExchangeCode': 'Q', 'bidPrice': 190.39, 'bidSize': 1.0, 'askTime': 0, 'askExchangeCode': 'Q', 'askPrice': 190.44, 'askSize': 3.0}]" # noqa: E501 async with DXLinkStreamer(session) as streamer: subs = ['SPY', 'AAPL'] From 44ba8ad84ce095b6b55f8c4a1fd0bea71fba15c0 Mon Sep 17 00:00:00 2001 From: Graeme Holliday Date: Thu, 16 Nov 2023 20:30:58 -0500 Subject: [PATCH 3/4] Update CONTRIBUTING.md --- .github/CONTRIBUTING.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 0de36ee..7f93a20 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,6 +8,7 @@ Secrets are protected by Github and are not visible to anyone. You can read more 1. Fork the repository to your personal Github account, NOT to an organization where others may be able to indirectly access your secrets. 2. Make your changes on the forked repository. -3. Navigate to the forked repository's settings page and click on "Secrets and variables" > "Actions". -4. Click on "New repository secret" to add your Tastytrade username named `TT_USERNAME`. -5. Finally, do the same with your password, naming it `TT_PASSWORD`. +3. Go to the "Actions" page on the forked repository and enable actions. +4. Navigate to the forked repository's settings page and click on "Secrets and variables" > "Actions". +5. Click on "New repository secret" to add your Tastytrade username named `TT_USERNAME`. +6. Finally, do the same with your password, naming it `TT_PASSWORD`. From c8e898077b9d9ae6b4a961e6b69ade2b2426d3c2 Mon Sep 17 00:00:00 2001 From: Graeme22 Date: Thu, 16 Nov 2023 20:34:47 -0500 Subject: [PATCH 4/4] no tests on main branch --- .github/pull_request_template.md | 2 +- .github/workflows/python-app.yml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b6ab314..5dc604a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,7 +4,7 @@ Fixes ... ## Pre-merge checklist -- [ ] Passing tests +- [ ] Passing tests LOCALLY - [ ] New tests added (if applicable) Please note that, in order to pass the tests, you'll need to set up your Tastytrade credentials as repository secrets on your local fork. Read more at CONTRIBUTING.md. diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index dd188fd..1ce64e3 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -3,8 +3,6 @@ name: Python application on: push: branches: [ master ] - pull_request: - branches: [ master ] jobs: build: