From dd88001c5e141f9249dae015d1cd32c8ab924e67 Mon Sep 17 00:00:00 2001 From: Coen Kuijpers Date: Fri, 17 Nov 2023 12:48:46 +0100 Subject: [PATCH 1/7] Rebased to roll back changes --- tastytrade/streamer.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tastytrade/streamer.py b/tastytrade/streamer.py index 38ddbe0..6d4632d 100644 --- a/tastytrade/streamer.py +++ b/tastytrade/streamer.py @@ -632,7 +632,7 @@ def __init__(self, session: ProductionSession): EventType.THEO_PRICE: 11, EventType.TIME_AND_SALE: 13, EventType.TRADE: 15, - EventType.UNDERLYING: 17 + EventType.UNDERLYING: 17, } self._subscription_state: Dict[EventType, str] = \ defaultdict(lambda: 'CHANNEL_CLOSED') @@ -700,6 +700,8 @@ async def _connect(self) -> None: if v == message['channel'])) self._subscription_state[channel] \ = message['type'] + elif message['type'] == 'CHANNEL_CLOSED': + pass elif message['type'] == 'FEED_CONFIG': pass elif message['type'] == 'FEED_DATA': @@ -788,6 +790,19 @@ async def subscribe( logger.debug('sending subscription: %s', message) await self._websocket.send(json.dumps(message)) + async def cancel_channel(self, event_type: EventType) -> None: + """ + Cancels the channel for the belonging event_type + + :param event_type: cancel the channel for this event + """ + message = { + 'type': 'CHANNEL_CANCEL', + 'channel': self._channels[event_type], + } + logger.debug('sending channel cancel: %s', message) + await self._websocket.send(json.dumps(message)) + async def _channel_request(self, event_type: EventType) -> None: message = { 'type': 'CHANNEL_REQUEST', From 09b342806ae1d4244bc825e0b2eeebdbf3fb3871 Mon Sep 17 00:00:00 2001 From: Coen Kuijpers Date: Mon, 27 Nov 2023 13:22:48 +0100 Subject: [PATCH 2/7] Complex orders added --- tastytrade/account.py | 32 ++++++++++++++++++++++++++++++++ tastytrade/order.py | 34 +++++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/tastytrade/account.py b/tastytrade/account.py index ef37c26..7e04aa3 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from tastytrade.order import (InstrumentType, NewOrder, OrderStatus, + NewOCOOrder, PlacedOrder, PlacedOrderResponse, PriceEffect) from tastytrade.session import ProductionSession, Session from tastytrade.utils import (TastytradeError, TastytradeJsonDataclass, @@ -978,6 +979,37 @@ def place_order( return PlacedOrderResponse(**data) + def place_complex_orders( + self, + session: Session, + order: NewOCOOrder, + dry_run=True + ) -> PlacedOrderResponse: + """ + Place the given order. + + :param session: the session to use for the request. + :param order: the order to place. + :param dry_run: whether this is a test order or not. + + :return: a :class:`PlacedOrderResponse` object for the placed order. + """ + url = f'{session.base_url}/accounts/{self.account_number}/complex-orders' + if dry_run: + url += '/dry-run' + headers = session.headers + # required because we're passing the JSON as a string + headers['Content-Type'] = 'application/json' + json = order.json(exclude_none=True, by_alias=True) + json = json.replace('complex-order-type', 'type') + + response = requests.post(url, headers=session.headers, data=json) + validate_response(response) + + data = response.json()['data'] + + return PlacedOrderResponse(**data) + def replace_order( self, session: Session, diff --git a/tastytrade/order.py b/tastytrade/order.py index a17a355..3da0c28 100644 --- a/tastytrade/order.py +++ b/tastytrade/order.py @@ -85,6 +85,14 @@ class OrderType(str, Enum): NOTIONAL_MARKET = 'Notional Market' +class ComplexOrderType(str, Enum): + """ + This is an :class:`~enum.Enum` that contains the valid complex order types. + """ + OCO = 'OCO' + OTOCO = 'OTOCO' + + class PriceEffect(str, Enum): """ This is an :class:`~enum.Enum` that shows the sign of a price effect, since @@ -221,6 +229,24 @@ class NewOrder(TastytradeJsonDataclass): rules: Optional[OrderRule] = None +class NewOCOOrder(TastytradeJsonDataclass): + """ + Dataclass containing information about an OCO order. + Also used for modifying existing orders. + """ + complex_order_type: ComplexOrderType + source: str = f'tastyware/tastytrade:v{VERSION}' + orders: List[NewOrder] + + +class NewOTOCOOrder(NewOCOOrder): + """ + Dataclass containing information about a new OTOCO order. + Also used for modifying existing orders. + """ + trigger_order: NewOrder + + class PlacedOrder(TastytradeJsonDataclass): """ Dataclass containing information about an existing order, whether it's @@ -269,16 +295,10 @@ class ComplexOrder(TastytradeJsonDataclass): """ Dataclass containing information about a complex order. """ - id: str account_number: str type: str - terminal_at: str - ratio_price_threshold: Decimal - ratio_price_comparator: str - ratio_price_is_threshold_based_on_notional: bool - related_orders: List[Dict[str, str]] orders: List[PlacedOrder] - trigger_order: PlacedOrder + trigger_order: Optional[PlacedOrder] = None class BuyingPowerEffect(TastytradeJsonDataclass): From f891ee888a69841c97016186c074a84674814bbe Mon Sep 17 00:00:00 2001 From: Graeme Holliday Date: Fri, 24 Nov 2023 13:45:09 -0500 Subject: [PATCH 3/7] update tests (#106) * streamers use async context managers; prod session in testing * fix lint * Update CONTRIBUTING.md * no tests on main branch * update tests --- .github/CONTRIBUTING.md | 2 +- docs/data-streamer.rst | 4 +++- tests/test_account.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7f93a20..c4be608 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,6 +1,6 @@ # 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. +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 pass 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). diff --git a/docs/data-streamer.rst b/docs/data-streamer.rst index 06619f9..850948a 100644 --- a/docs/data-streamer.rst +++ b/docs/data-streamer.rst @@ -39,7 +39,7 @@ Once you've created the streamer, you can subscribe/unsubscribe to events, like >>> [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: +Note that these are ``asyncio`` calls, so you'll need to run this code asynchronously. Here's an example: .. code-block:: python @@ -53,6 +53,8 @@ Note that these are ``asyncio`` calls, so you'll need to run this code asynchron >>> [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')] +Alternatively, you can do testing in a Jupyter notebook, which allows you to make async calls directly. + We can also use the streamer to stream greeks for options symbols: .. code-block:: python diff --git a/tests/test_account.py b/tests/test_account.py index e91df12..0ace525 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -10,7 +10,7 @@ @pytest.fixture(scope='session') def account(session): - return Account.get_accounts(session)[1] + return Account.get_accounts(session)[0] def test_get_account(session, account): From 5b48f545a54bf38e4ea8c6c0ba4f5e29f4fa66e2 Mon Sep 17 00:00:00 2001 From: Mytak <32729599+Mytak@users.noreply.github.com> Date: Sat, 25 Nov 2023 00:32:06 +0100 Subject: [PATCH 4/7] Make additional market metrics optional (#105) * Make market metrics optional * Enable manual workflow triggering --- .github/workflows/python-app.yml | 6 ++++++ tastytrade/metrics.py | 26 +++++++++++++------------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1ce64e3..23395cc 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -3,6 +3,12 @@ name: Python application on: push: branches: [ master ] + + workflow_dispatch: + inputs: + branch: + description: 'The branch to build' + required: true jobs: build: diff --git a/tastytrade/metrics.py b/tastytrade/metrics.py index aa094dd..32a623b 100644 --- a/tastytrade/metrics.py +++ b/tastytrade/metrics.py @@ -52,23 +52,23 @@ class MarketMetricInfo(TastytradeJsonDataclass): Contains lots of useful information, like IV rank, IV percentile and beta. """ symbol: str - implied_volatility_index: Decimal - implied_volatility_index_5_day_change: Decimal + implied_volatility_index: Optional[Decimal] = None + implied_volatility_index_5_day_change: Optional[Decimal] = None implied_volatility_index_rank: Optional[str] = None - tos_implied_volatility_index_rank: Decimal - tw_implied_volatility_index_rank: Decimal - tos_implied_volatility_index_rank_updated_at: datetime - implied_volatility_index_rank_source: str + tos_implied_volatility_index_rank: Optional[Decimal] = None + tw_implied_volatility_index_rank: Optional[Decimal] = None + tos_implied_volatility_index_rank_updated_at: Optional[datetime] = None + implied_volatility_index_rank_source: Optional[str] = None implied_volatility_percentile: Optional[str] = None - implied_volatility_updated_at: datetime - liquidity_rating: int + implied_volatility_updated_at: Optional[datetime] = None + liquidity_rating: Optional[int] = None updated_at: datetime - option_expiration_implied_volatilities: List[OptionExpirationImpliedVolatility] # noqa: E501 - beta: Decimal - corr_spy_3month: Decimal + option_expiration_implied_volatilities: Optional[List[OptionExpirationImpliedVolatility]] = None # noqa: E501 + beta: Optional[Decimal] = None + corr_spy_3month: Optional[Decimal] = None market_cap: Decimal - price_earnings_ratio: Decimal - earnings_per_share: Decimal + price_earnings_ratio: Optional[Decimal] = None + earnings_per_share: Optional[Decimal] = None dividend_rate_per_share: Optional[Decimal] = None implied_volatility_30_day: Optional[Decimal] = None historical_volatility_30_day: Optional[Decimal] = None From 57d7b8337cd8b5ac4e86741d9277bb2903bd19d2 Mon Sep 17 00:00:00 2001 From: Coen Kuijpers Date: Mon, 27 Nov 2023 22:07:58 +0100 Subject: [PATCH 5/7] isorted --- tastytrade/account.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tastytrade/account.py b/tastytrade/account.py index 7e04aa3..15edd7e 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -5,9 +5,9 @@ import requests from pydantic import BaseModel -from tastytrade.order import (InstrumentType, NewOrder, OrderStatus, - NewOCOOrder, - PlacedOrder, PlacedOrderResponse, PriceEffect) +from tastytrade.order import (InstrumentType, NewOCOOrder, NewOrder, + OrderStatus, PlacedOrder, PlacedOrderResponse, + PriceEffect) from tastytrade.session import ProductionSession, Session from tastytrade.utils import (TastytradeError, TastytradeJsonDataclass, validate_response) From cb45c9c41359f34ea42bf07a4c0578f1172ad8c4 Mon Sep 17 00:00:00 2001 From: Coen Kuijpers Date: Mon, 27 Nov 2023 22:13:08 +0100 Subject: [PATCH 6/7] lint errors fixed --- tastytrade/account.py | 3 ++- tastytrade/order.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tastytrade/account.py b/tastytrade/account.py index 15edd7e..444654b 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -994,7 +994,8 @@ def place_complex_orders( :return: a :class:`PlacedOrderResponse` object for the placed order. """ - url = f'{session.base_url}/accounts/{self.account_number}/complex-orders' + url = (f'{session.base_url}/accounts/{self.account_number}' + f'/complex-orders') if dry_run: url += '/dry-run' headers = session.headers diff --git a/tastytrade/order.py b/tastytrade/order.py index 3da0c28..bcf0bd5 100644 --- a/tastytrade/order.py +++ b/tastytrade/order.py @@ -1,7 +1,7 @@ from datetime import date, datetime from decimal import Decimal from enum import Enum -from typing import Dict, List, Optional +from typing import List, Optional from tastytrade import VERSION from tastytrade.utils import TastytradeJsonDataclass From dc339ef7b4154568d13f25bfe6c400ed5addbf32 Mon Sep 17 00:00:00 2001 From: Graeme22 Date: Tue, 28 Nov 2023 13:11:21 -0500 Subject: [PATCH 7/7] add tests --- .github/workflows/python-app.yml | 2 +- tastytrade/account.py | 89 ++++++++++++++++++++------- tastytrade/instruments.py | 6 +- tastytrade/metrics.py | 6 +- tastytrade/order.py | 40 +++++++------ tests/conftest.py | 10 +++- tests/test_account.py | 100 ++++++++++++++++++++++++++++++- tests/test_session.py | 5 +- 8 files changed, 208 insertions(+), 50 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a7d2ec5..dd188fd 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -31,4 +31,4 @@ jobs: python -m pytest --cov=tastytrade --cov-report=term-missing tests/ --cov-fail-under=95 env: TT_USERNAME: ${{ secrets.TT_USERNAME }} - TT_PASSWORD: ${{ secrets.TT_PASSWORD }} \ No newline at end of file + TT_PASSWORD: ${{ secrets.TT_PASSWORD }} diff --git a/tastytrade/account.py b/tastytrade/account.py index 444654b..09a3789 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -5,9 +5,9 @@ import requests from pydantic import BaseModel -from tastytrade.order import (InstrumentType, NewOCOOrder, NewOrder, - OrderStatus, PlacedOrder, PlacedOrderResponse, - PriceEffect) +from tastytrade.order import (InstrumentType, NewComplexOrder, NewOrder, + OrderStatus, PlacedComplexOrder, PlacedOrder, + PlacedOrderResponse, PriceEffect) from tastytrade.session import ProductionSession, Session from tastytrade.utils import (TastytradeError, TastytradeJsonDataclass, validate_response) @@ -457,7 +457,8 @@ def get_trading_status(self, session: Session) -> TradingStatus: :return: a Tastytrade 'TradingStatus' object in JSON format. """ response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/trading-status', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/' + 'trading-status'), headers=session.headers ) validate_response(response) # throws exception if not 200 @@ -512,7 +513,8 @@ def get_balance_snapshots( } response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/balance-snapshots', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/balance-' + 'snapshots'), headers=session.headers, params={k: v for k, v in params.items() if v is not None} ) @@ -651,7 +653,8 @@ def get_history( results = [] while True: response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/transactions', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/' + 'transactions'), headers=session.headers, params={k: v for k, v in params.items() if v is not None} ) @@ -683,7 +686,8 @@ def get_transaction( :return: a Tastytrade 'Transaction' object in JSON format. """ response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/transactions/{id}', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/transactions' + f'/{id}'), headers=session.headers ) validate_response(response) @@ -707,7 +711,8 @@ def get_total_fees( """ params: Dict[str, Any] = {'date': date} response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/transactions/total-fees', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/transactions/' + 'total-fees'), headers=session.headers, params=params ) @@ -748,7 +753,8 @@ def get_net_liquidating_value_history( params = {'time-back': time_back} response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/net-liq/history', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/net-liq/' + 'history'), headers=session.headers, params=params ) @@ -767,7 +773,8 @@ def get_position_limit(self, session: Session) -> PositionLimit: :return: a Tastytrade 'PositionLimit' object in JSON format. """ response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/position-limit', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/position-' + 'limit'), headers=session.headers ) validate_response(response) @@ -793,7 +800,8 @@ def get_effective_margin_requirements( if symbol: symbol = symbol.replace('/', '%2F') response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/margin-requirements/{symbol}/effective', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/margin-' + f'requirements/{symbol}/effective'), headers=session.headers ) validate_response(response) @@ -812,7 +820,8 @@ def get_margin_requirements(self, session: Session) -> MarginReport: :return: a :class:`MarginReport` object. """ response = requests.get( - f'{session.base_url}/margin/accounts/{self.account_number}/requirements', # noqa: E501 + (f'{session.base_url}/margin/accounts/{self.account_number}/' + 'requirements'), headers=session.headers ) validate_response(response) @@ -839,16 +848,41 @@ def get_live_orders(self, session: Session) -> List[PlacedOrder]: return [PlacedOrder(**entry) for entry in data] + def get_complex_order( + self, + session: Session, + order_id: str + ) -> PlacedComplexOrder: + """ + Gets a complex order with the given ID. + + :param session: the session to use for the request. + + :return: + a :class:`PlacedComplexOrder` object corresponding to the given ID + """ + response = requests.get( + (f'{session.base_url}/accounts/{self.account_number}/complex-' + f'orders/{order_id}'), + headers=session.headers + ) + validate_response(response) + + data = response.json()['data'] + + return PlacedComplexOrder(**data) + def get_order(self, session: Session, order_id: str) -> PlacedOrder: """ Gets an order with the given ID. :param session: the session to use for the request. - :return: an :class:`Order` object corresponding to the given ID. + :return: a :class:`PlacedOrder` object corresponding to the given ID """ response = requests.get( - f'{session.base_url}/accounts/{self.account_number}/orders/{order_id}', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/orders' + f'/{order_id}'), headers=session.headers ) validate_response(response) @@ -857,6 +891,20 @@ def get_order(self, session: Session, order_id: str) -> PlacedOrder: return PlacedOrder(**data) + def delete_complex_order(self, session: Session, order_id: str) -> None: + """ + Delete a complex order by ID. + + :param session: the session to use for the request. + :param order_id: the ID of the order to delete. + """ + response = requests.delete( + (f'{session.base_url}/accounts/{self.account_number}/complex-' + f'orders/{order_id}'), + headers=session.headers + ) + validate_response(response) + def delete_order(self, session: Session, order_id: str) -> None: """ Delete an order by ID. @@ -865,7 +913,8 @@ def delete_order(self, session: Session, order_id: str) -> None: :param order_id: the ID of the order to delete. """ response = requests.delete( - f'{session.base_url}/accounts/{self.account_number}/orders/{order_id}', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/orders' + f'/{order_id}'), headers=session.headers ) validate_response(response) @@ -979,10 +1028,10 @@ def place_order( return PlacedOrderResponse(**data) - def place_complex_orders( + def place_complex_order( self, session: Session, - order: NewOCOOrder, + order: NewComplexOrder, dry_run=True ) -> PlacedOrderResponse: """ @@ -995,14 +1044,13 @@ def place_complex_orders( :return: a :class:`PlacedOrderResponse` object for the placed order. """ url = (f'{session.base_url}/accounts/{self.account_number}' - f'/complex-orders') + '/complex-orders') if dry_run: url += '/dry-run' headers = session.headers # required because we're passing the JSON as a string headers['Content-Type'] = 'application/json' json = order.json(exclude_none=True, by_alias=True) - json = json.replace('complex-order-type', 'type') response = requests.post(url, headers=session.headers, data=json) validate_response(response) @@ -1031,7 +1079,8 @@ def replace_order( # required because we're passing the JSON as a string headers['Content-Type'] = 'application/json' response = requests.put( - f'{session.base_url}/accounts/{self.account_number}/orders/{old_order_id}', # noqa: E501 + (f'{session.base_url}/accounts/{self.account_number}/orders' + f'/{old_order_id}'), headers=headers, data=new_order.json( exclude={'legs'}, diff --git a/tastytrade/instruments.py b/tastytrade/instruments.py index d18e5fe..e31bdf4 100644 --- a/tastytrade/instruments.py +++ b/tastytrade/instruments.py @@ -600,7 +600,8 @@ def get_future_product( """ code = code.replace('/', '') response = requests.get( - f'{session.base_url}/instruments/future-products/{exchange}/{code}', # noqa: E501 + (f'{session.base_url}/instruments/future-products/{exchange}/' + f'{code}'), headers=session.headers ) validate_response(response) @@ -768,7 +769,8 @@ def get_future_option_product( """ root_symbol = root_symbol.replace('/', '') response = requests.get( - f'{session.base_url}/instruments/future-option-products/{exchange}/{root_symbol}', # noqa: E501 + (f'{session.base_url}/instruments/future-option-products/' + f'{exchange}/{root_symbol}'), headers=session.headers ) validate_response(response) diff --git a/tastytrade/metrics.py b/tastytrade/metrics.py index 32a623b..77b3876 100644 --- a/tastytrade/metrics.py +++ b/tastytrade/metrics.py @@ -128,7 +128,8 @@ def get_dividends( """ symbol = symbol.replace('/', '%2F') response = requests.get( - f'{session.base_url}/market-metrics/historic-corporate-events/dividends/{symbol}', # noqa: E501 + (f'{session.base_url}/market-metrics/historic-corporate-events/' + f'dividends/{symbol}'), headers=session.headers ) validate_response(response) @@ -155,7 +156,8 @@ def get_earnings( symbol = symbol.replace('/', '%2F') params: Dict[str, Any] = {'start-date': start_date} response = requests.get( - f'{session.base_url}/market-metrics/historic-corporate-events/earnings-reports/{symbol}', # noqa: E501 + (f'{session.base_url}/market-metrics/historic-corporate-events/' + f'earnings-reports/{symbol}'), headers=session.headers, params=params ) diff --git a/tastytrade/order.py b/tastytrade/order.py index bcf0bd5..1c97d5f 100644 --- a/tastytrade/order.py +++ b/tastytrade/order.py @@ -1,7 +1,7 @@ from datetime import date, datetime from decimal import Decimal from enum import Enum -from typing import List, Optional +from typing import Dict, List, Optional from tastytrade import VERSION from tastytrade.utils import TastytradeJsonDataclass @@ -111,7 +111,7 @@ class FillInfo(TastytradeJsonDataclass): quantity: Decimal fill_price: Decimal filled_at: datetime - destination_venue: str + destination_venue: Optional[str] = None ext_group_fill_id: Optional[str] = None ext_exec_id: Optional[str] = None @@ -126,7 +126,7 @@ class Leg(TastytradeJsonDataclass): instrument_type: InstrumentType symbol: str action: OrderAction - quantity: Decimal + quantity: Optional[Decimal] = None remaining_quantity: Optional[Decimal] = None fills: Optional[List[FillInfo]] = None @@ -229,22 +229,20 @@ class NewOrder(TastytradeJsonDataclass): rules: Optional[OrderRule] = None -class NewOCOOrder(TastytradeJsonDataclass): +class NewComplexOrder(TastytradeJsonDataclass): """ - Dataclass containing information about an OCO order. + Dataclass containing information about a new OTOCO order. Also used for modifying existing orders. """ - complex_order_type: ComplexOrderType - source: str = f'tastyware/tastytrade:v{VERSION}' orders: List[NewOrder] + source: str = f'tastyware/tastytrade:v{VERSION}' + trigger_order: Optional[NewOrder] = None + type: ComplexOrderType = ComplexOrderType.OCO - -class NewOTOCOOrder(NewOCOOrder): - """ - Dataclass containing information about a new OTOCO order. - Also used for modifying existing orders. - """ - trigger_order: NewOrder + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.trigger_order is not None: + self.type = ComplexOrderType.OTOCO class PlacedOrder(TastytradeJsonDataclass): @@ -255,7 +253,6 @@ class PlacedOrder(TastytradeJsonDataclass): account_number: str time_in_force: OrderTimeInForce order_type: OrderType - size: str underlying_symbol: str underlying_instrument_type: InstrumentType status: OrderStatus @@ -264,6 +261,7 @@ class PlacedOrder(TastytradeJsonDataclass): edited: bool updated_at: datetime legs: List[Leg] + size: Optional[str] = None id: Optional[str] = None price: Optional[Decimal] = None price_effect: Optional[PriceEffect] = None @@ -291,14 +289,20 @@ class PlacedOrder(TastytradeJsonDataclass): order_rule: Optional[OrderRule] = None -class ComplexOrder(TastytradeJsonDataclass): +class PlacedComplexOrder(TastytradeJsonDataclass): """ - Dataclass containing information about a complex order. + Dataclass containing information about an already placed complex order. """ account_number: str type: str orders: List[PlacedOrder] + id: Optional[str] = None trigger_order: Optional[PlacedOrder] = None + terminal_at: Optional[str] = None + ratio_price_threshold: Optional[Decimal] = None + ratio_price_comparator: Optional[str] = None + ratio_price_is_threshold_based_on_notional: Optional[bool] = None + related_orders: Optional[List[Dict[str, str]]] = None class BuyingPowerEffect(TastytradeJsonDataclass): @@ -344,7 +348,7 @@ class PlacedOrderResponse(TastytradeJsonDataclass): buying_power_effect: BuyingPowerEffect fee_calculation: FeeCalculation order: Optional[PlacedOrder] = None - complex_order: Optional[ComplexOrder] = None + complex_order: Optional[PlacedComplexOrder] = None warnings: Optional[List[Message]] = None errors: Optional[List[Message]] = None diff --git a/tests/conftest.py b/tests/conftest.py index 9a74b7e..869bf6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,16 +4,22 @@ from tastytrade import ProductionSession +CERT_USERNAME = 'tastyware' +CERT_PASSWORD = ':4s-S9/9L&Q~C]@v' + + +@pytest.fixture(scope='session') +def get_cert_credentials(): + return CERT_USERNAME, CERT_PASSWORD + @pytest.fixture(scope='session') def session(): username = os.environ.get('TT_USERNAME', None) password = os.environ.get('TT_PASSWORD', None) - 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 0ace525..bfc6caf 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -1,11 +1,13 @@ from decimal import Decimal +from time import sleep import pytest -from tastytrade import Account +from tastytrade import Account, CertificationSession from tastytrade.instruments import Equity -from tastytrade.order import (NewOrder, OrderAction, OrderTimeInForce, - OrderType, PriceEffect) +from tastytrade.order import (NewComplexOrder, NewOrder, OrderAction, + OrderStatus, OrderTimeInForce, OrderType, + PriceEffect) @pytest.fixture(scope='session') @@ -13,6 +15,19 @@ def account(session): return Account.get_accounts(session)[0] +@pytest.fixture(scope='session') +def cert_session(get_cert_credentials): + usr, pwd = get_cert_credentials + session = CertificationSession(usr, pwd) + yield session + session.destroy() + + +@pytest.fixture(scope='session') +def cert_account(cert_session): + return Account.get_accounts(cert_session)[0] + + def test_get_account(session, account): acc = Account.get_account(session, account.account_number) assert acc == account @@ -79,6 +94,7 @@ def placed_order(session, account, new_order): def test_place_and_delete_order(session, account, new_order): order = account.place_order(session, new_order, dry_run=False).order + sleep(1) account.delete_order(session, order.id) @@ -90,6 +106,7 @@ 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) + sleep(1) account.delete_order(session, replaced.id) @@ -99,3 +116,80 @@ def test_get_order_history(session, account): def test_get_live_orders(session, account): account.get_live_orders(session) + + +def test_place_oco_order(cert_session, cert_account): + session = cert_session + account = cert_account + # first, buy share of SPY to set up the OCO order + symbol = Equity.get_equity(session, 'SPY') + opening = symbol.build_leg(Decimal(1), OrderAction.BUY_TO_OPEN) + resp1 = account.place_order(session, NewOrder( + time_in_force=OrderTimeInForce.DAY, + order_type=OrderType.LIMIT, + legs=[opening], + price=Decimal('2.5'), # should fill immediately for cert account + price_effect=PriceEffect.DEBIT + ), dry_run=False) + assert resp1.order.status != OrderStatus.REJECTED + + closing = symbol.build_leg(Decimal(1), OrderAction.SELL_TO_CLOSE) + oco = NewComplexOrder( + orders=[ + NewOrder( + time_in_force=OrderTimeInForce.GTC, + order_type=OrderType.LIMIT, + legs=[closing], + price=Decimal('2500'), # will never fill + price_effect=PriceEffect.CREDIT + ), + NewOrder( + time_in_force=OrderTimeInForce.GTC, + order_type=OrderType.STOP, + legs=[closing], + stop_trigger=Decimal('25'), # will never fill + price_effect=PriceEffect.CREDIT + ) + ] + ) + resp2 = account.place_complex_order(session, oco, dry_run=False) + sleep(1) + # test get complex order + _ = account.get_complex_order(session, resp2.complex_order.id) + account.delete_complex_order(session, resp2.complex_order.id) + + +def test_place_otoco_order(cert_session, cert_account): + session = cert_session + account = cert_account + symbol = Equity.get_equity(session, 'AAPL') + opening = symbol.build_leg(Decimal(1), OrderAction.BUY_TO_OPEN) + closing = symbol.build_leg(Decimal(1), OrderAction.SELL_TO_CLOSE) + otoco = NewComplexOrder( + trigger_order=NewOrder( + time_in_force=OrderTimeInForce.DAY, + order_type=OrderType.LIMIT, + legs=[opening], + price=Decimal('250'), # won't fill + price_effect=PriceEffect.DEBIT + ), + orders=[ + NewOrder( + time_in_force=OrderTimeInForce.GTC, + order_type=OrderType.LIMIT, + legs=[closing], + price=Decimal('2500'), # won't fill + price_effect=PriceEffect.CREDIT + ), + NewOrder( + time_in_force=OrderTimeInForce.GTC, + order_type=OrderType.STOP, + legs=[closing], + stop_trigger=Decimal('25'), # won't fill + price_effect=PriceEffect.CREDIT + ) + ] + ) + resp = account.place_complex_order(session, otoco, dry_run=False) + sleep(1) + account.delete_complex_order(session, resp.complex_order.id) diff --git a/tests/test_session.py b/tests/test_session.py index 43bebc4..e8d8406 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -22,6 +22,7 @@ def test_get_candle(session): session.get_candle(['SPY', 'AAPL'], '1d', start_date) -def test_destroy(): - session = CertificationSession('tastyware', ':4s-S9/9L&Q~C]@v') +def test_destroy(get_cert_credentials): + usr, pwd = get_cert_credentials + session = CertificationSession(usr, pwd) assert session.destroy()