diff --git a/tastytrade/account.py b/tastytrade/account.py index ef37c26..09a3789 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -5,8 +5,9 @@ import requests from pydantic import BaseModel -from tastytrade.order import (InstrumentType, 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) @@ -456,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 @@ -511,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} ) @@ -650,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} ) @@ -682,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) @@ -706,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 ) @@ -747,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 ) @@ -766,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) @@ -792,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) @@ -811,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) @@ -838,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) @@ -856,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. @@ -864,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) @@ -978,6 +1028,37 @@ def place_order( return PlacedOrderResponse(**data) + def place_complex_order( + self, + session: Session, + order: NewComplexOrder, + 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) + + 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, @@ -998,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 a17a355..1c97d5f 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 @@ -103,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 @@ -118,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 @@ -221,6 +229,22 @@ class NewOrder(TastytradeJsonDataclass): rules: Optional[OrderRule] = None +class NewComplexOrder(TastytradeJsonDataclass): + """ + Dataclass containing information about a new OTOCO order. + Also used for modifying existing orders. + """ + orders: List[NewOrder] + source: str = f'tastyware/tastytrade:v{VERSION}' + trigger_order: Optional[NewOrder] = None + type: ComplexOrderType = ComplexOrderType.OCO + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.trigger_order is not None: + self.type = ComplexOrderType.OTOCO + + class PlacedOrder(TastytradeJsonDataclass): """ Dataclass containing information about an existing order, whether it's @@ -229,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 @@ -238,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 @@ -265,20 +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. """ - 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 + 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): @@ -324,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/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', 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()