From 009177786367528620fbdc1a6320ff71082036c2 Mon Sep 17 00:00:00 2001 From: Graeme Holliday Date: Wed, 29 Jan 2025 13:43:45 -0500 Subject: [PATCH] add notional orders (#207) --- docs/orders.rst | 18 ++++++++++++++++++ tastytrade/account.py | 22 ++++++++++------------ tastytrade/order.py | 23 ++++++++++++----------- tastytrade/session.py | 16 ++++++++-------- tastytrade/streamer.py | 4 ++-- tastytrade/utils.py | 10 +++++----- tests/test_account.py | 18 ++++++++++++++++++ 7 files changed, 73 insertions(+), 38 deletions(-) diff --git a/docs/orders.rst b/docs/orders.rst index 931901d..a173902 100644 --- a/docs/orders.rst +++ b/docs/orders.rst @@ -127,3 +127,21 @@ An OCO order is similar, but has no trigger order. It's used to add a profit-tak resp = account.place_complex_order(session, oco, dry_run=False) Note that to cancel complex orders, you need to use the ``delete_complex_order`` function, NOT ``delete_order``. + +Notional market orders +---------------------- + +Notional orders are slightly different from normal orders. Since the market will determine both the quantity and the price for you, you need to pass `value` instead of price, and pass `None` for the `quantity` parameter to ``build_leg``. + +.. code-block:: python + + symbol = Equity.get_equity(session, 'AAPL') + order = NewOrder( + time_in_force=OrderTimeInForce.DAY, + order_type=OrderType.NOTIONAL_MARKET, + value=Decimal(-10), # $10 debit, this will result in fractional shares + legs=[ + symbol.build_leg(None, OrderAction.BUY_TO_OPEN), + ] + ) + resp = account.place_order(session, order, dry_run=False) diff --git a/tastytrade/account.py b/tastytrade/account.py index b2916d1..f52c1c4 100644 --- a/tastytrade/account.py +++ b/tastytrade/account.py @@ -24,7 +24,7 @@ PriceEffect, TastytradeError, TastytradeJsonDataclass, - _set_sign_for, + set_sign_for, today_in_new_york, validate_response, ) @@ -97,7 +97,7 @@ def validate_price_effects(cls, data: Any) -> Any: effect = data.get("unsettled-cryptocurrency-fiat-effect") if effect == PriceEffect.DEBIT: data[key] = -abs(Decimal(data[key])) - return _set_sign_for(data, ["pending_cash", "buying_power_adjustment"]) + return set_sign_for(data, ["pending_cash", "buying_power_adjustment"]) class AccountBalanceSnapshot(TastytradeJsonDataclass): @@ -151,7 +151,7 @@ def validate_price_effects(cls, data: Any) -> Any: effect = data.get("unsettled-cryptocurrency-fiat-effect") if effect == PriceEffect.DEBIT: data[key] = -abs(Decimal(data[key])) - return _set_sign_for(data, ["pending_cash"]) + return set_sign_for(data, ["pending_cash"]) class CurrentPosition(TastytradeJsonDataclass): @@ -190,7 +190,7 @@ class CurrentPosition(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for(data, ["realized_day_gain", "realized_today"]) + return set_sign_for(data, ["realized_day_gain", "realized_today"]) class FeesInfo(TastytradeJsonDataclass): @@ -199,7 +199,7 @@ class FeesInfo(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for(data, ["total_fees"]) + return set_sign_for(data, ["total_fees"]) class Lot(TastytradeJsonDataclass): @@ -241,7 +241,7 @@ class MarginReportEntry(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for( + return set_sign_for( data, [ "buying_power", @@ -275,7 +275,7 @@ class MarginReport(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for( + return set_sign_for( data, [ "maintenance_requirement", @@ -437,7 +437,7 @@ class Transaction(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for( + return set_sign_for( data, [ "value", @@ -1132,8 +1132,7 @@ async def a_get_effective_margin_requirements( if symbol: symbol = symbol.replace("/", "%2F") data = await session._a_get( - f"/accounts/{self.account_number}/margin-" - f"requirements/{symbol}/effective" + f"/accounts/{self.account_number}/margin-requirements/{symbol}/effective" ) return MarginRequirement(**data) @@ -1150,8 +1149,7 @@ def get_effective_margin_requirements( if symbol: symbol = symbol.replace("/", "%2F") data = session._get( - f"/accounts/{self.account_number}/margin-" - f"requirements/{symbol}/effective" + f"/accounts/{self.account_number}/margin-requirements/{symbol}/effective" ) return MarginRequirement(**data) diff --git a/tastytrade/order.py b/tastytrade/order.py index b3028b6..fe3d064 100644 --- a/tastytrade/order.py +++ b/tastytrade/order.py @@ -9,8 +9,8 @@ from tastytrade.utils import ( PriceEffect, TastytradeJsonDataclass, - _get_sign, - _set_sign_for, + get_sign, + set_sign_for, ) @@ -149,11 +149,12 @@ class TradeableTastytradeJsonDataclass(TastytradeJsonDataclass): instrument_type: InstrumentType symbol: str - def build_leg(self, quantity: Decimal, action: OrderAction) -> Leg: + def build_leg(self, quantity: Optional[Decimal], action: OrderAction) -> Leg: """ Builds an order :class:`Leg` from the dataclass. - :param quantity: the quantity of the symbol to trade + :param quantity: + the quantity of the symbol to trade, set this as `None` for notional orders :param action: :class:`OrderAction` to perform, e.g. BUY_TO_OPEN :return: a :class:`Leg` object @@ -257,12 +258,12 @@ class NewOrder(TastytradeJsonDataclass): @computed_field @property def price_effect(self) -> Optional[PriceEffect]: - return _get_sign(self.price) + return get_sign(self.price) @computed_field @property def value_effect(self) -> Optional[PriceEffect]: - return _get_sign(self.value) + return get_sign(self.value) @field_serializer("price", "value") def serialize_fields(self, field: Optional[Decimal]) -> Optional[Decimal]: @@ -333,7 +334,7 @@ class PlacedOrder(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for(data, ["price", "value"]) + return set_sign_for(data, ["price", "value"]) class PlacedComplexOrder(TastytradeJsonDataclass): @@ -372,7 +373,7 @@ class BuyingPowerEffect(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for( + return set_sign_for( data, [ "change_in_margin_requirement", @@ -398,7 +399,7 @@ class FeeCalculation(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for( + return set_sign_for( data, [ "regulatory_fees", @@ -480,7 +481,7 @@ class OrderChainNode(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for( + return set_sign_for( data, [ "total_fees", @@ -520,7 +521,7 @@ class ComputedData(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for( + return set_sign_for( data, [ "total_fees", diff --git a/tastytrade/session.py b/tastytrade/session.py index 0233a43..caf7dcf 100644 --- a/tastytrade/session.py +++ b/tastytrade/session.py @@ -9,7 +9,7 @@ from tastytrade.utils import ( TastytradeError, TastytradeJsonDataclass, - _validate_and_parse, + validate_and_parse, validate_response, ) @@ -320,7 +320,7 @@ def __init__( ) else: response = self.sync_client.post("/sessions", json=body) - data = _validate_and_parse(response) + data = validate_and_parse(response) #: The user dict returned by the API; contains basic user information self.user = User(**data["user"]) #: The session token used to authenticate requests @@ -347,11 +347,11 @@ def __init__( async def _a_get(self, url, **kwargs) -> dict[str, Any]: response = await self.async_client.get(url, timeout=30, **kwargs) - return _validate_and_parse(response) + return validate_and_parse(response) def _get(self, url, **kwargs) -> dict[str, Any]: response = self.sync_client.get(url, timeout=30, **kwargs) - return _validate_and_parse(response) + return validate_and_parse(response) async def _a_delete(self, url, **kwargs) -> None: response = await self.async_client.delete(url, **kwargs) @@ -363,19 +363,19 @@ def _delete(self, url, **kwargs) -> None: async def _a_post(self, url, **kwargs) -> dict[str, Any]: response = await self.async_client.post(url, **kwargs) - return _validate_and_parse(response) + return validate_and_parse(response) def _post(self, url, **kwargs) -> dict[str, Any]: response = self.sync_client.post(url, **kwargs) - return _validate_and_parse(response) + return validate_and_parse(response) async def _a_put(self, url, **kwargs) -> dict[str, Any]: response = await self.async_client.put(url, **kwargs) - return _validate_and_parse(response) + return validate_and_parse(response) def _put(self, url, **kwargs) -> dict[str, Any]: response = self.sync_client.put(url, **kwargs) - return _validate_and_parse(response) + return validate_and_parse(response) async def a_validate(self) -> bool: """ diff --git a/tastytrade/streamer.py b/tastytrade/streamer.py index 1960e69..d7c55f4 100644 --- a/tastytrade/streamer.py +++ b/tastytrade/streamer.py @@ -41,7 +41,7 @@ PlacedOrder, ) from tastytrade.session import Session -from tastytrade.utils import TastytradeError, TastytradeJsonDataclass, _set_sign_for +from tastytrade.utils import TastytradeError, TastytradeJsonDataclass, set_sign_for from tastytrade.watchlists import Watchlist CERT_STREAMER_URL = "wss://streamer.cert.tastyworks.com" @@ -87,7 +87,7 @@ class UnderlyingYearGainSummary(TastytradeJsonDataclass): @model_validator(mode="before") @classmethod def validate_price_effects(cls, data: Any) -> Any: - return _set_sign_for( + return set_sign_for( data, [ "fees", diff --git a/tastytrade/utils.py b/tastytrade/utils.py index 477801b..b3bea82 100644 --- a/tastytrade/utils.py +++ b/tastytrade/utils.py @@ -4,11 +4,11 @@ from typing import Any, Optional from zoneinfo import ZoneInfo -import pandas_market_calendars as mcal # type: ignore from httpx._models import Response +from pandas_market_calendars import get_calendar from pydantic import BaseModel, ConfigDict -NYSE = mcal.get_calendar("NYSE") +NYSE = get_calendar("NYSE") TZ = ZoneInfo("US/Eastern") @@ -269,18 +269,18 @@ def validate_response(response: Response) -> None: raise TastytradeError(error_message) -def _validate_and_parse(response: Response) -> dict[str, Any]: +def validate_and_parse(response: Response) -> dict[str, Any]: validate_response(response) return response.json()["data"] -def _get_sign(value: Optional[Decimal]) -> Optional[PriceEffect]: +def get_sign(value: Optional[Decimal]) -> Optional[PriceEffect]: if not value: return None return PriceEffect.DEBIT if value < 0 else PriceEffect.CREDIT -def _set_sign_for(data: Any, properties: list[str]) -> Any: +def set_sign_for(data: Any, properties: list[str]) -> Any: """ Handles setting the sign of a number using the associated "-effect" field. diff --git a/tests/test_account.py b/tests/test_account.py index cc04c58..8e30245 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -180,6 +180,18 @@ def new_order(session: Session) -> NewOrder: ) +@fixture(scope="module") +def notional_order(session: Session) -> NewOrder: + symbol = Equity.get_equity(session, "AAPL") + leg = symbol.build_leg(None, OrderAction.BUY_TO_OPEN) + return NewOrder( + time_in_force=OrderTimeInForce.DAY, + order_type=OrderType.NOTIONAL_MARKET, + legs=[leg], + value=Decimal(-5), + ) + + @fixture(scope="module") def placed_order( session: Session, account: Account, new_order: NewOrder @@ -191,6 +203,12 @@ def test_place_order(placed_order: PlacedOrder): pass +def test_place_notional_order( + session: Session, account: Account, notional_order: NewOrder +): + account.place_order(session, notional_order, dry_run=True) + + def test_get_order(session: Session, account: Account, placed_order: PlacedOrder): sleep(3) assert account.get_order(session, placed_order.id).id == placed_order.id