From e63d718308dcc54267aa3ca9ab67dd4dc21786c0 Mon Sep 17 00:00:00 2001 From: lancasterg Date: Fri, 29 May 2026 22:16:38 +0100 Subject: [PATCH] feat(orders): implement limit, market, stop, and stop-limit placement methods intent(orders): allow developers to place limit, market, stop, and stop-limit orders and cancel pending ones programmatically intent(client-architecture): generic put and delete methods are needed in AsyncTrading212Client to support HTTP PUT and DELETE operations cleanly decision(endpoints): implemented place_limit_order, place_market_order, place_stop_order, place_stop_limit_order, and cancel_pending_order using custom request models decision(helpers): added put and delete helpers to AsyncTrading212Client and refactored delete_pie, update_pie, and cancel_order to consume them learned(orders-api): stop orders use StopRequest with stopPrice and stopTimeValidity, while stop-limit orders use StopLimitRequest with limitPrice, stopPrice, and stopLimitTimeValidity --- t212/async_client.py | 160 ++++++++++++------ .../mappings/place_limit_order.json | 37 ++++ .../mappings/place_market_order.json | 37 ++++ .../mappings/place_stop_limit_order.json | 37 ++++ .../mappings/place_stop_order.json | 37 ++++ tests/test_async_client/test_place_orders.py | 95 +++++++++++ 6 files changed, 350 insertions(+), 53 deletions(-) create mode 100644 t212_mock_server/mappings/place_limit_order.json create mode 100644 t212_mock_server/mappings/place_market_order.json create mode 100644 t212_mock_server/mappings/place_stop_limit_order.json create mode 100644 t212_mock_server/mappings/place_stop_order.json create mode 100644 tests/test_async_client/test_place_orders.py diff --git a/t212/async_client.py b/t212/async_client.py index d11d881..a12fef7 100644 --- a/t212/async_client.py +++ b/t212/async_client.py @@ -23,6 +23,12 @@ PublicReportRequest, ReportResponse, EnqueuedReportResponse, + LimitRequest, + MarketRequest, + StopRequest, + StopLimitRequest, + StopRequestTimeValidity, + StopLimitRequestTimeValidity, ) T = TypeVar("T") @@ -98,6 +104,58 @@ async def post( response.raise_for_status() return response_type.model_validate(await response.json()) + @classmethod + async def put( + cls, + url_suffix: str, + data: dict[str, str | float | int | bool | None] | None, + response_type: type[T], + ) -> T: + """ + Generic PUT request to the Trading212 API. Used by the client for all PUT requests + to ensure consistent error handling and response validation. + + Args: + url_suffix (str): The endpoint suffix to append to the base URL. + data (dict[str, str | float | int | bool | None] | None): JSON body data for the request. + response_type (type[T]): The Pydantic model class to validate the response against. + + Returns: + T: An instance of the specified response_type. + + """ + client = cls.init_client() + url = f"{cls.base_url}/{url_suffix}" + + async with client.put(f"{url}", json=data, headers=cls.headers) as response: + response.raise_for_status() + return response_type.model_validate(await response.json()) + + @classmethod + async def delete( + cls, url_suffix: str, response_type: type[T] | None = None + ) -> T | None: + """ + Generic DELETE request to the Trading212 API. Used by the client for all DELETE requests + to ensure consistent error handling and response validation. + + Args: + url_suffix (str): The endpoint suffix to append to the base URL. + response_type (type[T] | None): The Pydantic model class to validate the response against. + + Returns: + T | None: An instance of the specified response_type if provided, else None. + + """ + client = cls.init_client() + url = f"{cls.base_url}/{url_suffix}" + + async with client.delete(f"{url}", headers=cls.headers) as response: + response.raise_for_status() + if response_type is not None: + return response_type.model_validate(await response.json()) + return None + @classmethod async def exchange_list(cls) -> ExchangeResponse: url = "metadata/exchanges" @@ -154,10 +212,11 @@ async def search_position_by_ticker( @classmethod async def cancel_order(cls, order_id: int) -> None: - client = cls.init_client() - url = f"{cls.base_url}/orders/{order_id}" - async with client.delete(url, headers=cls.headers) as response: - response.raise_for_status() + await cls.delete(f"orders/{order_id}") + + @classmethod + async def cancel_pending_order(cls, order_id: int) -> None: + await cls.delete(f"orders/{order_id}") @classmethod async def create_pie(cls, pie_request: PieRequest) -> AccountBucketDetailedResponse: @@ -170,24 +229,18 @@ async def create_pie(cls, pie_request: PieRequest) -> AccountBucketDetailedRespo @classmethod async def delete_pie(cls, pie_id: int) -> None: - client = cls.init_client() - url = f"{cls.base_url}/pies/{pie_id}" - async with client.delete(url, headers=cls.headers) as response: - response.raise_for_status() + await cls.delete(f"pies/{pie_id}") @classmethod async def update_pie( cls, pie_id: int, pie_request: PieRequest ) -> AccountBucketDetailedResponse: - client = cls.init_client() - url = f"{cls.base_url}/pies/{pie_id}" - async with client.put( + url = f"pies/{pie_id}" + return await cls.put( url, - json=pie_request.model_dump(mode="json", exclude_none=True), - headers=cls.headers, - ) as response: - response.raise_for_status() - return AccountBucketDetailedResponse.model_validate(await response.json()) + pie_request.model_dump(mode="json", exclude_none=True), + AccountBucketDetailedResponse, + ) @classmethod async def duplicate_pie( @@ -256,70 +309,71 @@ async def place_limit_order( ticker: str, time_validity: LimitRequestTimeValidity, ) -> Order: - """Returns 403 forbidden""" url = "orders/limit" - json_data = { - "limitPrice": limit_price, - "quantity": quantity, - "ticker": ticker, - "timeValidity": time_validity, - } - return await cls.post(url, json_data, Order) + req = LimitRequest( + limitPrice=limit_price, + quantity=quantity, + ticker=ticker, + timeValidity=time_validity, + ) + return await cls.post( + url, req.model_dump(mode="json", exclude_none=True), Order + ) @classmethod - @not_implemented_api_field async def place_market_order( cls, quantity: float, ticker: str, ) -> Order: - """Returns 403 forbidden""" url = "orders/market" - json_data = { - "quantity": quantity, - "ticker": ticker, - } - return await cls.post(url, json_data, Order) + req = MarketRequest( + quantity=quantity, + ticker=ticker, + ) + return await cls.post( + url, req.model_dump(mode="json", exclude_none=True), Order + ) @classmethod - @not_implemented_api_field async def place_stop_order( cls, - limit_price: float, quantity: float, + stop_price: float, ticker: str, - time_validity: LimitRequestTimeValidity, + time_validity: StopRequestTimeValidity, ) -> Order: - """Returns 403 forbidden""" url = "orders/stop" - json_data = { - "limitPrice": limit_price, - "quantity": quantity, - "ticker": ticker, - "timeValidity": time_validity, - } - return await cls.post(url, json_data, Order) + req = StopRequest( + quantity=quantity, + stopPrice=stop_price, + ticker=ticker, + timeValidity=time_validity, + ) + return await cls.post( + url, req.model_dump(mode="json", exclude_none=True), Order + ) @classmethod - @not_implemented_api_field async def place_stop_limit_order( cls, limit_price: float, quantity: float, stop_price: float, ticker: str, - time_validity: LimitRequestTimeValidity, + time_validity: StopLimitRequestTimeValidity, ) -> Order: - """Returns 403 forbidden""" url = "orders/stop_limit" - json_data = { - "limitPrice": limit_price, - "quantity": quantity, - "stopPrice": stop_price, - "ticker": ticker, - "timeValidity": time_validity, - } - return await cls.post(url, json_data, Order) + req = StopLimitRequest( + limitPrice=limit_price, + quantity=quantity, + stopPrice=stop_price, + ticker=ticker, + timeValidity=time_validity, + ) + return await cls.post( + url, req.model_dump(mode="json", exclude_none=True), Order + ) if __name__ == "__main__": diff --git a/t212_mock_server/mappings/place_limit_order.json b/t212_mock_server/mappings/place_limit_order.json new file mode 100644 index 0000000..49abec1 --- /dev/null +++ b/t212_mock_server/mappings/place_limit_order.json @@ -0,0 +1,37 @@ +{ + "request": { + "method": "POST", + "url": "/orders/limit" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "createdAt": "2019-08-24T14:15:22Z", + "currency": "USD", + "extendedHours": true, + "filledQuantity": 0, + "filledValue": 0, + "id": 101, + "initiatedFrom": "API", + "instrument": { + "currency": "USD", + "isin": "string", + "name": "string", + "ticker": "AAPL_US_EQ" + }, + "limitPrice": 100.23, + "quantity": 10.0, + "side": "BUY", + "status": "LOCAL", + "stopPrice": 0.0, + "strategy": "QUANTITY", + "ticker": "AAPL_US_EQ", + "timeInForce": "DAY", + "type": "LIMIT", + "value": 1002.3 + } + } +} diff --git a/t212_mock_server/mappings/place_market_order.json b/t212_mock_server/mappings/place_market_order.json new file mode 100644 index 0000000..b0791d2 --- /dev/null +++ b/t212_mock_server/mappings/place_market_order.json @@ -0,0 +1,37 @@ +{ + "request": { + "method": "POST", + "url": "/orders/market" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "createdAt": "2019-08-24T14:15:22Z", + "currency": "USD", + "extendedHours": false, + "filledQuantity": 0, + "filledValue": 0, + "id": 102, + "initiatedFrom": "API", + "instrument": { + "currency": "USD", + "isin": "string", + "name": "string", + "ticker": "AAPL_US_EQ" + }, + "limitPrice": 0.0, + "quantity": 10.0, + "side": "BUY", + "status": "LOCAL", + "stopPrice": 0.0, + "strategy": "QUANTITY", + "ticker": "AAPL_US_EQ", + "timeInForce": "DAY", + "type": "MARKET", + "value": 1000.0 + } + } +} diff --git a/t212_mock_server/mappings/place_stop_limit_order.json b/t212_mock_server/mappings/place_stop_limit_order.json new file mode 100644 index 0000000..0b87151 --- /dev/null +++ b/t212_mock_server/mappings/place_stop_limit_order.json @@ -0,0 +1,37 @@ +{ + "request": { + "method": "POST", + "url": "/orders/stop_limit" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "createdAt": "2019-08-24T14:15:22Z", + "currency": "USD", + "extendedHours": false, + "filledQuantity": 0, + "filledValue": 0, + "id": 104, + "initiatedFrom": "API", + "instrument": { + "currency": "USD", + "isin": "string", + "name": "string", + "ticker": "AAPL_US_EQ" + }, + "limitPrice": 100.23, + "quantity": 10.0, + "side": "BUY", + "status": "LOCAL", + "stopPrice": 100.23, + "strategy": "QUANTITY", + "ticker": "AAPL_US_EQ", + "timeInForce": "DAY", + "type": "STOP_LIMIT", + "value": 1002.3 + } + } +} diff --git a/t212_mock_server/mappings/place_stop_order.json b/t212_mock_server/mappings/place_stop_order.json new file mode 100644 index 0000000..a935dfa --- /dev/null +++ b/t212_mock_server/mappings/place_stop_order.json @@ -0,0 +1,37 @@ +{ + "request": { + "method": "POST", + "url": "/orders/stop" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "createdAt": "2019-08-24T14:15:22Z", + "currency": "USD", + "extendedHours": false, + "filledQuantity": 0, + "filledValue": 0, + "id": 103, + "initiatedFrom": "API", + "instrument": { + "currency": "USD", + "isin": "string", + "name": "string", + "ticker": "AAPL_US_EQ" + }, + "limitPrice": 0.0, + "quantity": 10.0, + "side": "BUY", + "status": "LOCAL", + "stopPrice": 100.23, + "strategy": "QUANTITY", + "ticker": "AAPL_US_EQ", + "timeInForce": "DAY", + "type": "STOP", + "value": 1002.3 + } + } +} diff --git a/tests/test_async_client/test_place_orders.py b/tests/test_async_client/test_place_orders.py new file mode 100644 index 0000000..1ddc743 --- /dev/null +++ b/tests/test_async_client/test_place_orders.py @@ -0,0 +1,95 @@ +import pytest +from t212.async_client import AsyncTrading212Client +from t212.models import ( + Order, + LimitRequestTimeValidity, + StopRequestTimeValidity, + StopLimitRequestTimeValidity, +) + + +@pytest.mark.asyncio +async def test_place_limit_order_200(async_client_fixture: AsyncTrading212Client): + """ + Tests placing a limit order. + """ + response = await async_client_fixture.place_limit_order( + limit_price=100.23, + quantity=10.0, + ticker="AAPL_US_EQ", + time_validity=LimitRequestTimeValidity.DAY, + ) + + assert response is not None + assert response.id == 101 + assert response.limit_price == 100.23 + assert response.quantity == 10.0 + assert response.ticker == "AAPL_US_EQ" + assert response.type.value == "LIMIT" + + +@pytest.mark.asyncio +async def test_place_market_order_200(async_client_fixture: AsyncTrading212Client): + """ + Tests placing a market order. + """ + response = await async_client_fixture.place_market_order( + quantity=10.0, + ticker="AAPL_US_EQ", + ) + + assert response is not None + assert response.id == 102 + assert response.quantity == 10.0 + assert response.ticker == "AAPL_US_EQ" + assert response.type.value == "MARKET" + + +@pytest.mark.asyncio +async def test_place_stop_order_200(async_client_fixture: AsyncTrading212Client): + """ + Tests placing a stop order. + """ + response = await async_client_fixture.place_stop_order( + quantity=10.0, + stop_price=100.23, + ticker="AAPL_US_EQ", + time_validity=StopRequestTimeValidity.DAY, + ) + + assert response is not None + assert response.id == 103 + assert response.stop_price == 100.23 + assert response.quantity == 10.0 + assert response.ticker == "AAPL_US_EQ" + assert response.type.value == "STOP" + + +@pytest.mark.asyncio +async def test_place_stop_limit_order_200(async_client_fixture: AsyncTrading212Client): + """ + Tests placing a stop-limit order. + """ + response = await async_client_fixture.place_stop_limit_order( + limit_price=100.23, + quantity=10.0, + stop_price=100.23, + ticker="AAPL_US_EQ", + time_validity=StopLimitRequestTimeValidity.DAY, + ) + + assert response is not None + assert response.id == 104 + assert response.limit_price == 100.23 + assert response.stop_price == 100.23 + assert response.quantity == 10.0 + assert response.ticker == "AAPL_US_EQ" + assert response.type.value == "STOP_LIMIT" + + +@pytest.mark.asyncio +async def test_cancel_pending_order_200(async_client_fixture: AsyncTrading212Client): + """ + Tests cancelling a pending order. + """ + await async_client_fixture.cancel_pending_order(order_id=123)