From c5b5c1e8301feddaac6fba57b52b703e8c4b02f6 Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:32:45 +0300 Subject: [PATCH 01/10] Rebuild the client against the published DXtrade REST/Push spec The README said official docs were not accessible, so every request shape was inferred. They are public (demo.dx.trade/developers, OpenAPI at /dxsca-web/swagger/openapi.json). Checked against them, most calls were still wrong after the 2026 fixes: - SL/TP were silently never placed: they waited for a positionCode in the order response, which only has orderId/updateOrderId. Now one atomic IF-THEN order group, so entry and protections succeed or fail together. - get_balance read equity/margin from /portfolio (they live in /metrics) and returned Balance(0,0,0,0) on any parse problem. Now /metrics, strict. - Positions/orders parsed invented names (id, entryPrice, stopLoss) from a bare list; the API wraps {"positions": [...]} with positionCode, symbol, openPrice, stopLossPrice, legs[0].price... Parsers are now strict. - History hit /accounts/{a}/history; it is /accounts/{a}/orders/history. - modify_order PATCHed /orders/{id} with price/qty; the spec is PUT /accounts/{a}/orders with the complete order and If-Match. cancel_order lacked If-Match (403 per spec). Both now read the ETag and retry on 412. - close_position / modify_position_sl_tp called /positions/{id}, which does not exist. Now a linked CLOSE market order, and protection orders modified via PUT or placed via POST. New close_all() (bulk close). - ping was GET (spec: POST); a renewed sessionToken is adopted. - Push API used made-up 'subscribe' messages, Atmosphere headers and the token in the URL (logged at INFO). Now the spec envelope (type/session/ requestId), PingRequest->Ping replies, /md channel for quotes, portfolio and metrics subscriptions, reconnect that really replays subscriptions. Also: one _request() path with typed exceptions (status, errorCode, description), a single re-login on 401, account discovery via /users when none is configured (never invented), URL-encoded account/order codes, OrderPlacementError.ambiguous for timeouts after sending, no logging config in the library, credentials never logged. Module moved into a package (dxtrade_wrapper/); `from dxtrade_wrapper import ...` is unchanged. The old file is in git history at dxtrade_wrapper.py. Co-Authored-By: Claude Opus 5.5 --- dxtrade_wrapper.py | 1799 --------------------- dxtrade_wrapper/__init__.py | 53 + dxtrade_wrapper/client.py | 1257 ++++++++++++++ dxtrade_wrapper/exceptions.py | 107 ++ dxtrade_wrapper/models.py | 208 +++ dxtrade_wrapper/py.typed | 0 tests/conftest.py | 65 + tests/fixtures/error_bad_credentials.json | 4 + tests/fixtures/group_response.json | 7 + tests/fixtures/history.json | 24 + tests/fixtures/login_no_account.json | 3 - tests/fixtures/login_success.json | 2 +- tests/fixtures/metrics.json | 20 + tests/fixtures/order_accepted.json | 6 - tests/fixtures/order_response.json | 4 + tests/fixtures/orders.json | 63 + tests/fixtures/portfolio.json | 13 - tests/fixtures/positions.json | 17 + tests/fixtures/users.json | 23 + tests/test_account_data.py | 161 ++ tests/test_orders.py | 282 ++++ tests/test_protocol.py | 442 +---- tests/test_push.py | 172 ++ tests/test_session.py | 191 +++ 24 files changed, 2717 insertions(+), 2206 deletions(-) delete mode 100644 dxtrade_wrapper.py create mode 100644 dxtrade_wrapper/__init__.py create mode 100644 dxtrade_wrapper/client.py create mode 100644 dxtrade_wrapper/exceptions.py create mode 100644 dxtrade_wrapper/models.py create mode 100644 dxtrade_wrapper/py.typed create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/error_bad_credentials.json create mode 100644 tests/fixtures/group_response.json create mode 100644 tests/fixtures/history.json delete mode 100644 tests/fixtures/login_no_account.json create mode 100644 tests/fixtures/metrics.json delete mode 100644 tests/fixtures/order_accepted.json create mode 100644 tests/fixtures/order_response.json create mode 100644 tests/fixtures/orders.json delete mode 100644 tests/fixtures/portfolio.json create mode 100644 tests/fixtures/positions.json create mode 100644 tests/fixtures/users.json create mode 100644 tests/test_account_data.py create mode 100644 tests/test_orders.py create mode 100644 tests/test_push.py create mode 100644 tests/test_session.py diff --git a/dxtrade_wrapper.py b/dxtrade_wrapper.py deleted file mode 100644 index 4944ad2..0000000 --- a/dxtrade_wrapper.py +++ /dev/null @@ -1,1799 +0,0 @@ -""" -DXTradeDashboardWrapper: A Python wrapper for DXTRADE API trading dashboard functionalities. - -This wrapper abstracts the complexities of direct API interaction for common dashboard-related tasks, -providing a simple interface for authentication, account data retrieval, order management, -and real-time data handling via WebSockets. - -============================================================================= -UNVERIFIED - THIS CODE HAS NEVER SUCCESSFULLY TALKED TO A DXTRADE SERVER -============================================================================= - -Every request shape in this module (URL paths, the authorization scheme, the -order payload, the WebSocket handshake and message formats) was inferred from -community examples and a third-party development guide, not from official -Devexperts documentation and not from a captured session. - -Several protocol bugs were found and fixed by code review in 2026 - the -missing ``/dxsca-web`` prefix on every non-login path, a ``Bearer`` -authorization scheme where DXtrade SCA expects ``DXAPI``, and an order payload -missing its ``account`` and ``orderCode`` fields. Those fixes are also -UNVERIFIED: confirming them requires a live account at a broker that still -enables SCA REST access, which the author does not have. Anything in this file -marked "UNVERIFIED" is a best-effort inference that may still be wrong. - -Do not use this against a funded account. See the README's "Project Status" -section before you spend time here. -""" - -import json -import logging -import queue -import threading -import time -import uuid -from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from urllib.parse import urlencode - -import requests -import websocket - - -# Custom exceptions -class DXTradeWrapperError(Exception): - """Base exception for all DXTradeDashboardWrapper errors.""" - pass - - -class DXTradeAPIError(DXTradeWrapperError): - """Exception raised for errors returned by the DXTRADE API.""" - pass - - -class AuthenticationError(DXTradeWrapperError): - """Exception raised for authentication failures.""" - pass - - -class ConnectionError(DXTradeWrapperError): - """Exception raised for connection failures.""" - pass - - -class OrderPlacementError(DXTradeWrapperError): - """Exception raised for order placement failures.""" - pass - - -class WebSocketError(DXTradeWrapperError): - """Exception raised for WebSocket related errors.""" - pass - - -# Data classes for structured responses -@dataclass -class Balance: - """Represents account balance information.""" - equity: float - balance: float - margin: float - free_margin: float - margin_level: Optional[float] = None - currency: Optional[str] = None - - -@dataclass -class Position: - """Represents an open position.""" - position_id: str - instrument: str - quantity: float - side: str # 'BUY' or 'SELL' - entry_price: float - current_price: Optional[float] = None - pnl: Optional[float] = None - open_time: Optional[str] = None - stop_loss: Optional[float] = None - take_profit: Optional[float] = None - - -@dataclass -class Order: - """Represents a pending order.""" - order_id: str - instrument: str - quantity: float - side: str # 'BUY' or 'SELL' - order_type: str # 'MARKET', 'LIMIT', 'STOP' - status: str # 'PENDING', 'FILLED', 'CANCELLED', etc. - price: Optional[float] = None # Limit price or stop price - stop_loss: Optional[float] = None - take_profit: Optional[float] = None - created_time: Optional[str] = None - position_effect: Optional[str] = None # 'OPEN' or 'CLOSE' - tif: Optional[str] = None # Time In Force, e.g., 'GTC' - position_code: Optional[str] = None # For linked orders - - -class DXTradeDashboardWrapper: - """ - A high-level, user-friendly wrapper for the DXTRADE trading platform API, - specifically targeting functionalities essential for trading dashboards. - - This wrapper abstracts the complexities of direct API interaction and provides - a simple interface for authentication, account data retrieval, order management, - and real-time data handling via WebSockets. - - UNVERIFIED: see the module docstring. No call in this class has ever been - confirmed against a live DXtrade server. - """ - - #: Authorization scheme for authenticated REST calls. DXtrade SCA uses the - #: custom "DXAPI" scheme rather than "Bearer" (UNVERIFIED). - AUTH_SCHEME = "DXAPI" - - def __init__( - self, - base_url: str, - username: str, - password: str, - domain_or_vendor: str = "default", - api_prefix: str = "/dxsca-web", - login_path: str = "/login", - websocket_path: str = "/websocket", - account: Optional[str] = None, - keepalive_interval: Optional[float] = 60.0, - logger: Optional[logging.Logger] = None - ): - """ - Initialize the DXTradeDashboardWrapper. - - Args: - base_url: The base URL for the broker's DXTRADE REST API (e.g., "https://dxtrade.broker.com"). - username: User's trading account username. - password: User's trading account password. - domain_or_vendor: The domain or vendor identifier required for login (defaults to "default"). - api_prefix: Path prefix shared by every REST endpoint, including login. - Defaults to "/dxsca-web", the DXtrade SCA prefix. Pass "" for a - deployment that serves the API at the root. Every URL this class - builds goes through `_url()`, so this is the single place to change - it - see the note below. - login_path: The login endpoint path, relative to `api_prefix`. A value that - already includes the prefix (e.g. "/dxsca-web/login") is accepted and - normalised, so older configs and .env files keep working. - websocket_path: The specific path for the WebSocket connection. This is NOT - under `api_prefix` - it is appended to the host directly. - account: Account code used in account-scoped URLs and order payloads. The - login response does not reliably carry one; if it does not and this is - unset, account-scoped calls raise rather than guessing. - keepalive_interval: Seconds between `/ping` calls after a successful login, - to stop the session expiring silently. Pass None to disable. - logger: Optional standard Python logger instance for internal logging. - - Note on `api_prefix` (UNVERIFIED): - Before 2026 this class hardcoded "/dxsca-web/login" as the login path and - then built every *other* URL straight off `base_url`, so every call after - login was missing the prefix and would 404 at any broker. The prefix is now - a single constructor argument applied by `_url()`, so that class of bug - cannot recur silently. The prefix VALUE is still unverified against a live - server. - """ - # Configuration - self._base_url = base_url.rstrip('/') - self._username = username - self._password = password - self._domain_or_vendor = domain_or_vendor - - # Normalise the prefix to either "" or "/something" (no trailing slash). - prefix = (api_prefix or "").strip('/') - self._api_prefix = f"/{prefix}" if prefix else "" - - # Accept a login_path that already carries the prefix, so existing configs - # such as DXTRADE_LOGIN_PATH=/dxsca-web/login do not produce a doubled - # "/dxsca-web/dxsca-web/login". - login_path = (login_path or "login").strip('/') - if prefix and (login_path == prefix or login_path.startswith(f"{prefix}/")): - login_path = login_path[len(prefix):].strip('/') - self._login_path = login_path or "login" - - self._websocket_path = websocket_path.lstrip('/') - - # Use provided logger or create a default one - self._logger = logger or logging.getLogger("DXTradeWrapper") - if not self._logger.handlers: - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) - self._logger.addHandler(handler) - self._logger.setLevel(logging.INFO) - - # Authentication state - self._session = requests.Session() - self._auth_token = None - self._is_authenticated = False - # Set from the constructor if supplied, otherwise from the login response. - # There is deliberately no default - see _get_account_id(). - self._configured_account = account - self._account_id = account - - # Session keepalive state - self._keepalive_interval = keepalive_interval - self._keepalive_thread = None - self._keepalive_stop = threading.Event() - - # WebSocket state - self._ws_client = None - self._ws_thread = None - self._ws_connected = False - self._ws_reconnect = True # Flag to control reconnection attempts - - # Thread safety - self._lock = threading.Lock() - - # Queues for asynchronous WebSocket data - self.price_update_queue = queue.Queue() - self.order_update_queue = queue.Queue() - self.account_update_queue = queue.Queue() - - # Tracking subscribed instruments - self._subscribed_instruments = set() - self._account_updates_subscribed = False - - # URL construction - def _url(self, path: str) -> str: - """ - Build a full REST URL for a path relative to the configured API prefix. - - Every REST call in this class MUST go through this method. Building URLs - inline is what produced the original bug where login used "/dxsca-web/login" - but all eight other endpoints hit the bare host and would 404 everywhere. - - Args: - path: Endpoint path relative to the API prefix, with or without a - leading slash (e.g. "login", "/accounts/ACC/orders"). - - Returns: - str: The absolute URL, e.g. "https://host/dxsca-web/accounts/ACC/orders". - """ - return f"{self._base_url}{self._api_prefix}/{path.lstrip('/')}" - - # Authentication methods - def login(self) -> None: - """ - Authenticate with the DXTRADE API. - - UNVERIFIED: the payload shape, the auth parameter name and the token field - names below are all inferred. This has never returned a token from a real - server. - - Raises: - AuthenticationError: If authentication fails due to invalid credentials, - network issues, or unexpected response format. - """ - login_url = self._url(self._login_path) - - # Prepare request payload - payload = { - "username": self._username, - "password": self._password, - # Some brokers use 'domain', others might use 'vendor' - self._get_auth_param_name(): self._domain_or_vendor - } - - headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - - try: - self._logger.debug(f"Attempting login to {login_url}") - response = self._session.post( - login_url, - headers=headers, - json=payload, - timeout=10 - ) - response.raise_for_status() - - # Parse response data - response_data = response.json() - - # Extract auth token - adjust key based on actual response structure - self._auth_token = self._extract_auth_token(response_data) - - if self._auth_token: - self._is_authenticated = True - self._configure_auth_headers() - - # Try to extract account ID if available. An account supplied via the - # constructor wins only if the response carries nothing usable. - self._account_id = self._extract_account_id(response_data) or self._account_id - - # Keep the session alive; DXtrade sessions expire on idle. - self._start_keepalive() - - self._logger.info("Authentication successful") - return - else: - self._is_authenticated = False - self._logger.error("No authentication token found in login response") - raise AuthenticationError("Login successful but no token received") - - except requests.exceptions.RequestException as e: - self._is_authenticated = False - self._logger.error(f"Login network error: {e}") - raise AuthenticationError(f"Network error during login: {e}") - except json.JSONDecodeError as e: - self._is_authenticated = False - self._logger.error(f"Failed to decode login response: {e}") - raise AuthenticationError(f"Failed to decode login response: {e}") - except Exception as e: - self._is_authenticated = False - self._logger.error(f"Unexpected error during login: {e}") - raise AuthenticationError(f"Unexpected error during login: {e}") - - def logout(self) -> None: - """ - Log out from the DXTRADE API and invalidate the current session. - - UNVERIFIED: the logout endpoint is assumed to be POST {api_prefix}/logout. - A failure here is logged and swallowed - local state is always cleared, so - logout() is safe to call even if the server rejects or does not implement - the request. - """ - # Disconnect WebSocket if connected - if self._ws_connected: - self.disconnect_websocket() - - self._stop_keepalive() - - # Best-effort server-side session invalidation. Previously this call was - # commented out entirely, so logout() only ever cleared local state and - # left the session alive server-side until it timed out. - if self._is_authenticated: - try: - self._session.post(self._url("logout"), timeout=10) - except Exception as e: - self._logger.warning(f"Error during logout request (ignored): {e}") - - # Reset session and authentication state - self._session = requests.Session() - self._auth_token = None - self._is_authenticated = False - # Fall back to the constructor-supplied account, if any, rather than - # discarding it - it is configuration, not session state. - self._account_id = self._configured_account - self._logger.info("Logged out successfully") - - @property - def is_authenticated(self) -> bool: - """ - Check if the wrapper is currently authenticated. - - Returns: - bool: True if authenticated, False otherwise. - """ - return self._is_authenticated - - # Session keepalive - def ping(self) -> bool: - """ - Send a session keepalive to the server. - - DXtrade SCA sessions expire after a period of inactivity. Before 2026 this - class had no renewal mechanism at all, so a long-lived process would simply - start getting 401s partway through a session with no indication why. - - UNVERIFIED: the endpoint is assumed to be GET {api_prefix}/ping. Neither the - path, the method, nor the idle timeout it is meant to defeat has been - confirmed against a live server. - - Returns: - bool: True if the server accepted the ping, False otherwise. This does - not raise - a failed keepalive is reported to the caller and logged, so - the background thread cannot kill the process. - """ - if not self._is_authenticated: - return False - - try: - response = self._session.get(self._url("ping"), timeout=10) - if response.status_code == 200: - self._logger.debug("Keepalive ping accepted") - return True - self._logger.warning( - f"Keepalive ping rejected with status {response.status_code}" - ) - return False - except requests.exceptions.RequestException as e: - self._logger.warning(f"Keepalive ping failed: {e}") - return False - - def _start_keepalive(self) -> None: - """ - Start the background thread that pings the server periodically. - - No-op if `keepalive_interval` was None, or if a thread is already running. - """ - if not self._keepalive_interval or self._keepalive_interval <= 0: - return - if self._keepalive_thread and self._keepalive_thread.is_alive(): - return - - self._keepalive_stop.clear() - self._keepalive_thread = threading.Thread( - target=self._keepalive_loop, - name="DXTradeKeepalive", - daemon=True, - ) - self._keepalive_thread.start() - self._logger.debug( - f"Keepalive thread started (every {self._keepalive_interval}s)" - ) - - def _stop_keepalive(self) -> None: - """ - Signal the keepalive thread to stop and wait briefly for it to exit. - """ - self._keepalive_stop.set() - thread = self._keepalive_thread - if thread and thread.is_alive() and thread is not threading.current_thread(): - thread.join(timeout=5) - self._keepalive_thread = None - - def _keepalive_loop(self) -> None: - """ - Body of the keepalive thread: ping until told to stop. - - Uses Event.wait() rather than sleep() so that _stop_keepalive() returns - promptly instead of blocking for up to a full interval. - """ - while not self._keepalive_stop.wait(self._keepalive_interval): - if not self._is_authenticated: - break - self.ping() - - # Account data methods - def get_balance(self) -> Balance: - """ - Retrieve the current account balance. - - Returns: - Balance: A Balance object containing account balance details. - - Raises: - AuthenticationError: If not authenticated. - DXTradeAPIError: If the API request fails. - """ - self._check_authenticated() - - account_id = self._get_account_id() - balance_url = self._url(f"accounts/{account_id}/portfolio") - - try: - response = self._session.get(balance_url, timeout=10) - response.raise_for_status() - - balance_data = response.json() - return self._parse_balance_data(balance_data) - - except requests.exceptions.RequestException as e: - self._logger.error(f"Get balance network error: {e}") - raise DXTradeAPIError(f"Network error getting balance: {e}") - except json.JSONDecodeError as e: - self._logger.error(f"Failed to decode balance response: {e}") - raise DXTradeAPIError(f"Failed to decode balance response: {e}") - except Exception as e: - self._logger.error(f"Unexpected error getting balance: {e}") - raise DXTradeAPIError(f"Unexpected error getting balance: {e}") - - def get_positions(self) -> List[Position]: - """ - Retrieve a list of currently open positions. - - Returns: - List[Position]: A list of Position objects representing open positions. - - Raises: - AuthenticationError: If not authenticated. - DXTradeAPIError: If the API request fails. - """ - self._check_authenticated() - - account_id = self._get_account_id() - positions_url = self._url(f"accounts/{account_id}/positions") - - try: - response = self._session.get(positions_url, timeout=10) - response.raise_for_status() - - positions_data = response.json() - return self._parse_positions_data(positions_data) - - except requests.exceptions.RequestException as e: - self._logger.error(f"Get positions network error: {e}") - raise DXTradeAPIError(f"Network error getting positions: {e}") - except json.JSONDecodeError as e: - self._logger.error(f"Failed to decode positions response: {e}") - raise DXTradeAPIError(f"Failed to decode positions response: {e}") - except Exception as e: - self._logger.error(f"Unexpected error getting positions: {e}") - raise DXTradeAPIError(f"Unexpected error getting positions: {e}") - - def get_orders(self) -> List[Order]: - """ - Retrieve a list of active pending orders. - - Returns: - List[Order]: A list of Order objects representing pending orders. - - Raises: - AuthenticationError: If not authenticated. - DXTradeAPIError: If the API request fails. - """ - self._check_authenticated() - - account_id = self._get_account_id() - orders_url = self._url(f"accounts/{account_id}/orders") - - try: - response = self._session.get(orders_url, timeout=10) - response.raise_for_status() - - orders_data = response.json() - return self._parse_orders_data(orders_data) - - except requests.exceptions.RequestException as e: - self._logger.error(f"Get orders network error: {e}") - raise DXTradeAPIError(f"Network error getting orders: {e}") - except json.JSONDecodeError as e: - self._logger.error(f"Failed to decode orders response: {e}") - raise DXTradeAPIError(f"Failed to decode orders response: {e}") - except Exception as e: - self._logger.error(f"Unexpected error getting orders: {e}") - raise DXTradeAPIError(f"Unexpected error getting orders: {e}") - - def get_order_history(self, limit: int = 100) -> List[Dict[str, Any]]: - """ - Retrieve historical orders/trades. - - Args: - limit: Maximum number of records to return. - - Returns: - List[Dict[str, Any]]: A list of dictionaries representing historical orders/trades. - - Raises: - AuthenticationError: If not authenticated. - DXTradeAPIError: If the API request fails. - """ - self._check_authenticated() - - account_id = self._get_account_id() - history_url = self._url(f"accounts/{account_id}/history") - params = {"limit": limit} - - try: - response = self._session.get(history_url, params=params, timeout=10) - response.raise_for_status() - - history_data = response.json() - # Return raw history data as structure might vary - return history_data - - except requests.exceptions.RequestException as e: - self._logger.error(f"Get order history network error: {e}") - raise DXTradeAPIError(f"Network error getting order history: {e}") - except json.JSONDecodeError as e: - self._logger.error(f"Failed to decode order history response: {e}") - raise DXTradeAPIError(f"Failed to decode order history response: {e}") - except Exception as e: - self._logger.error(f"Unexpected error getting order history: {e}") - raise DXTradeAPIError(f"Unexpected error getting order history: {e}") - - # Order management methods - def place_order( - self, - instrument: str, - side: str, - quantity: float, - order_type: str, - price: Optional[float] = None, - stop_loss: Optional[float] = None, - take_profit: Optional[float] = None, - position_effect: str = "OPEN", - tif: str = "GTC", - **kwargs - ) -> Dict[str, Any]: - """ - Place a new trading order. - - Args: - instrument: The instrument to trade (symbol). - side: 'BUY' or 'SELL'. - quantity: Order quantity. - order_type: 'MARKET', 'LIMIT', or 'STOP'. - price: Required for 'LIMIT' or 'STOP' orders. - stop_loss: Optional stop loss price. - take_profit: Optional take profit price. - position_effect: 'OPEN' (new position) or 'CLOSE' (close existing). - tif: Time In Force, typically 'GTC' (Good Till Cancel). - **kwargs: Additional parameters for the order. Pass `orderCode` to supply - your own idempotency key instead of a generated one. - - Returns: - Dict[str, Any]: Details of the placed order, including order_id. - - Raises: - AuthenticationError: If not authenticated. - OrderPlacementError: If order placement fails. - DXTradeAPIError: If no account code is known (see `_get_account_id`). - ValueError: If required parameters are missing or invalid. - - UNVERIFIED: the payload below adds the `account` and `orderCode` fields that - DXtrade SCA requires and this class previously omitted - orders would have - been rejected at validation even had they reached the right URL. The rest of - the field names are inferred and unconfirmed. - """ - self._check_authenticated() - - # Validate inputs - side = side.upper() - order_type = order_type.upper() - position_effect = position_effect.upper() - - if side not in ['BUY', 'SELL']: - raise ValueError("Side must be 'BUY' or 'SELL'") - - if order_type not in ['MARKET', 'LIMIT', 'STOP']: - raise ValueError("Order type must be 'MARKET', 'LIMIT', or 'STOP'") - - if order_type in ['LIMIT', 'STOP'] and price is None: - raise ValueError(f"Price is required for {order_type} orders") - - account_id = self._get_account_id() - orders_url = self._url(f"accounts/{account_id}/orders") - - # Construct order payload. - # `account` and `orderCode` are required by DXtrade SCA and were both missing - # before 2026. `orderCode` is a client-generated idempotency key: resending the - # same code must not open a second position, which is what makes a retry after - # a timeout safe. Callers can pass their own via kwargs. - payload = { - "account": account_id, - "orderCode": kwargs.pop("orderCode", None) or self._generate_order_code(), - "instrument": instrument, - "side": side, - "quantity": quantity, - "type": order_type, - "positionEffect": position_effect, - "tif": tif - } - - # Add price for LIMIT or STOP orders - if order_type == 'LIMIT': - payload["limitPrice"] = price - elif order_type == 'STOP': - payload["stopPrice"] = price - - # Add any additional parameters - payload.update(kwargs) - - try: - self._logger.debug(f"Placing order: {payload}") - response = self._session.post(orders_url, json=payload, timeout=10) - response.raise_for_status() - - order_response = response.json() - - # If SL/TP are provided, handle them appropriately - if (stop_loss or take_profit) and position_effect == "OPEN": - position_code = self._extract_position_code(order_response) - if position_code: - self._place_linked_sl_tp_orders( - position_code=position_code, - quantity=quantity, - original_side=side, - stop_loss_price=stop_loss, - take_profit_price=take_profit - ) - - return order_response - - except requests.exceptions.RequestException as e: - self._logger.error(f"Order placement network error: {e}") - raise OrderPlacementError(f"Network error placing order: {e}") - except json.JSONDecodeError as e: - self._logger.error(f"Failed to decode order placement response: {e}") - raise OrderPlacementError(f"Failed to decode order placement response: {e}") - except Exception as e: - self._logger.error(f"Unexpected error placing order: {e}") - raise OrderPlacementError(f"Unexpected error placing order: {e}") - - def modify_order( - self, - order_id: str, - new_price: Optional[float] = None, - new_quantity: Optional[float] = None, - **kwargs - ) -> Dict[str, Any]: - """ - Modify an existing pending order. - - Args: - order_id: The ID of the order to modify. - new_price: New price for the order (for LIMIT or STOP orders). - new_quantity: New quantity for the order. - **kwargs: Additional parameters to modify. - - Returns: - Dict[str, Any]: Details of the modified order. - - Raises: - AuthenticationError: If not authenticated. - DXTradeAPIError: If the API request fails. - """ - self._check_authenticated() - - account_id = self._get_account_id() - modify_url = self._url(f"accounts/{account_id}/orders/{order_id}") - - # Construct payload with only the fields to modify - payload = {} - if new_price is not None: - payload["price"] = new_price - if new_quantity is not None: - payload["qty"] = new_quantity - - # Add any additional parameters - payload.update(kwargs) - - if not payload: - raise ValueError("At least one parameter to modify must be provided") - - try: - self._logger.debug(f"Modifying order {order_id}: {payload}") - response = self._session.patch(modify_url, json=payload, timeout=10) - response.raise_for_status() - - return response.json() - - except requests.exceptions.RequestException as e: - self._logger.error(f"Order modification network error: {e}") - raise DXTradeAPIError(f"Network error modifying order: {e}") - except json.JSONDecodeError as e: - self._logger.error(f"Failed to decode order modification response: {e}") - raise DXTradeAPIError(f"Failed to decode order modification response: {e}") - except Exception as e: - self._logger.error(f"Unexpected error modifying order: {e}") - raise DXTradeAPIError(f"Unexpected error modifying order: {e}") - - def cancel_order(self, order_id: str) -> Dict[str, Any]: - """ - Cancel an existing pending order. - - Args: - order_id: The ID of the order to cancel. - - Returns: - Dict[str, Any]: Confirmation of cancellation. - - Raises: - AuthenticationError: If not authenticated. - DXTradeAPIError: If the API request fails. - """ - self._check_authenticated() - - account_id = self._get_account_id() - cancel_url = self._url(f"accounts/{account_id}/orders/{order_id}") - - try: - self._logger.debug(f"Canceling order {order_id}") - response = self._session.delete(cancel_url, timeout=10) - response.raise_for_status() - - # Return confirmation (might be empty for DELETE requests) - try: - return response.json() - except json.JSONDecodeError: - return {"success": True, "order_id": order_id} - - except requests.exceptions.RequestException as e: - self._logger.error(f"Order cancellation network error: {e}") - raise DXTradeAPIError(f"Network error canceling order: {e}") - except Exception as e: - self._logger.error(f"Unexpected error canceling order: {e}") - raise DXTradeAPIError(f"Unexpected error canceling order: {e}") - - def close_position( - self, - position_id: str, - quantity: Optional[float] = None - ) -> Dict[str, Any]: - """ - Close an open position, either fully or partially. - - Args: - position_id: The ID of the position to close. - quantity: Quantity to close. If None, the entire position is closed. - - Returns: - Dict[str, Any]: Confirmation of position closure. - - Raises: - AuthenticationError: If not authenticated. - DXTradeAPIError: If the API request fails. - """ - self._check_authenticated() - - account_id = self._get_account_id() - close_url = self._url(f"positions/{position_id}") - - payload = {} - if quantity is not None: - payload["qty"] = quantity - - try: - self._logger.debug(f"Closing position {position_id}") - if payload: - # Partial close with PATCH - response = self._session.patch(close_url, json=payload, timeout=10) - else: - # Full close with DELETE - response = self._session.delete(close_url, timeout=10) - - response.raise_for_status() - - # Return confirmation (might be empty for DELETE requests) - try: - return response.json() - except json.JSONDecodeError: - return {"success": True, "position_id": position_id} - - except requests.exceptions.RequestException as e: - self._logger.error(f"Position close network error: {e}") - raise DXTradeAPIError(f"Network error closing position: {e}") - except Exception as e: - self._logger.error(f"Unexpected error closing position: {e}") - raise DXTradeAPIError(f"Unexpected error closing position: {e}") - - def modify_position_sl_tp( - self, - position_id: str, - stop_loss: Optional[float] = None, - take_profit: Optional[float] = None - ) -> Dict[str, Any]: - """ - Modify the Stop Loss and/or Take Profit levels of an open position. - - Args: - position_id: The ID of the position to modify. - stop_loss: New Stop Loss price. None to leave unchanged. - take_profit: New Take Profit price. None to leave unchanged. - - Returns: - Dict[str, Any]: Details of the modified position. - - Raises: - AuthenticationError: If not authenticated. - DXTradeAPIError: If the API request fails. - ValueError: If neither stop_loss nor take_profit is provided. - """ - self._check_authenticated() - - if stop_loss is None and take_profit is None: - raise ValueError("At least one of stop_loss or take_profit must be provided") - - modify_url = self._url(f"positions/{position_id}") - - # Construct payload with only the fields to modify - payload = {} - if stop_loss is not None: - payload["stopLoss"] = stop_loss - if take_profit is not None: - payload["takeProfit"] = take_profit - - try: - self._logger.debug(f"Modifying position {position_id} SL/TP: {payload}") - response = self._session.patch(modify_url, json=payload, timeout=10) - response.raise_for_status() - - return response.json() - - except requests.exceptions.RequestException as e: - self._logger.error(f"Position SL/TP modification network error: {e}") - raise DXTradeAPIError(f"Network error modifying position SL/TP: {e}") - except json.JSONDecodeError as e: - self._logger.error(f"Failed to decode position SL/TP modification response: {e}") - raise DXTradeAPIError(f"Failed to decode position SL/TP modification response: {e}") - except Exception as e: - self._logger.error(f"Unexpected error modifying position SL/TP: {e}") - raise DXTradeAPIError(f"Unexpected error modifying position SL/TP: {e}") - - # WebSocket methods - def connect_websocket(self) -> None: - """ - Establish a WebSocket connection for receiving real-time updates. - - Raises: - AuthenticationError: If not authenticated. - WebSocketError: If WebSocket connection fails. - """ - self._check_authenticated() - - if self._ws_connected or self._ws_thread is not None: - self._logger.info("WebSocket already connected") - return - - # Prepare WebSocket thread - self._ws_reconnect = True - self._ws_thread = threading.Thread( - target=self._websocket_listener_thread, - daemon=True - ) - self._ws_thread.start() - - # Wait briefly to allow connection to establish - time.sleep(0.5) - - if not self._ws_connected: - self._logger.warning("WebSocket connection not established immediately") - - def disconnect_websocket(self) -> None: - """ - Disconnect the WebSocket connection. - """ - self._ws_reconnect = False - - if self._ws_client: - try: - self._ws_client.close() - except Exception as e: - self._logger.warning(f"Error closing WebSocket: {e}") - - self._ws_client = None - self._ws_connected = False - - # Reset subscriptions - self._subscribed_instruments = set() - self._account_updates_subscribed = False - - if self._ws_thread: - # Don't wait indefinitely - self._ws_thread.join(timeout=1.0) - self._ws_thread = None - - def subscribe_market_data(self, instruments: List[str]) -> None: - """ - Subscribe to real-time market data for specified instruments. - - Args: - instruments: List of instrument symbols to subscribe to. - - Raises: - WebSocketError: If WebSocket is not connected. - """ - if not self._ws_connected: - raise WebSocketError("WebSocket not connected. Call connect_websocket() first.") - - # Filter out already subscribed instruments - new_instruments = [i for i in instruments if i not in self._subscribed_instruments] - if not new_instruments: - return - - # Construct subscription message - subscription_message = { - "type": "subscribe", - "instruments": new_instruments - } - - try: - self._ws_client.send(json.dumps(subscription_message)) - self._subscribed_instruments.update(new_instruments) - self._logger.info(f"Subscribed to market data for: {new_instruments}") - except Exception as e: - self._logger.error(f"Error subscribing to market data: {e}") - raise WebSocketError(f"Failed to subscribe to market data: {e}") - - def unsubscribe_market_data(self, instruments: List[str]) -> None: - """ - Unsubscribe from real-time market data for specified instruments. - - Args: - instruments: List of instrument symbols to unsubscribe from. - - Raises: - WebSocketError: If WebSocket is not connected. - """ - if not self._ws_connected: - raise WebSocketError("WebSocket not connected. Call connect_websocket() first.") - - # Filter only currently subscribed instruments - to_unsubscribe = [i for i in instruments if i in self._subscribed_instruments] - if not to_unsubscribe: - return - - # Construct unsubscribe message - unsubscribe_message = { - "type": "unsubscribe", - "instruments": to_unsubscribe - } - - try: - self._ws_client.send(json.dumps(unsubscribe_message)) - self._subscribed_instruments.difference_update(to_unsubscribe) - self._logger.info(f"Unsubscribed from market data for: {to_unsubscribe}") - except Exception as e: - self._logger.error(f"Error unsubscribing from market data: {e}") - raise WebSocketError(f"Failed to unsubscribe from market data: {e}") - - def subscribe_account_updates(self) -> None: - """ - Subscribe to real-time account updates (orders, positions, balance). - - Raises: - WebSocketError: If WebSocket is not connected. - """ - if not self._ws_connected: - raise WebSocketError("WebSocket not connected. Call connect_websocket() first.") - - if self._account_updates_subscribed: - return - - # Construct subscription message - subscription_message = { - "type": "subscribe", - "channel": "account" - } - - try: - self._ws_client.send(json.dumps(subscription_message)) - self._account_updates_subscribed = True - self._logger.info("Subscribed to account updates") - except Exception as e: - self._logger.error(f"Error subscribing to account updates: {e}") - raise WebSocketError(f"Failed to subscribe to account updates: {e}") - - # Internal helper methods - def _check_authenticated(self) -> None: - """ - Check if the wrapper is authenticated and raise an exception if not. - - Raises: - AuthenticationError: If not authenticated. - """ - if not self._is_authenticated: - raise AuthenticationError("Not authenticated. Call login() first.") - - def _get_account_id(self) -> str: - """ - Get the account code for account-scoped URLs and payloads. - - Returns: - str: The account code. - - Raises: - DXTradeAPIError: If no account code is known. - - Note: - This used to `return self._account_id or "primary"`. No such account - exists at any broker - "primary" was invented, so whenever the login - response carried no account code every downstream call silently built a - URL for a nonexistent account and 404'd. Failing loudly with an - actionable message is strictly better than fabricating an identifier, - especially for a method that also names the account an *order* is - placed against. - """ - if not self._account_id: - raise DXTradeAPIError( - "No account code available. The login response did not contain one. " - "Pass account=... to the constructor (or set DXTRADE_ACCOUNT in your " - ".env) with the account code shown in your broker's web terminal." - ) - return self._account_id - - def _generate_order_code(self) -> str: - """ - Generate a client-side idempotency key for an order. - - DXtrade SCA requires every order to carry a client-generated `orderCode`. - It is the server's deduplication key: resending an order with a code the - server has already seen must not open a second position, which is what - makes retrying after a network timeout safe rather than dangerous. - - UNVERIFIED: the field name and the accepted format are inferred. Brokers may - impose a length or character-set limit this does not respect. - - Returns: - str: A unique order code. - """ - return f"dxpw-{uuid.uuid4().hex[:24]}" - - def _get_auth_param_name(self) -> str: - """ - Get the appropriate authentication parameter name (domain or vendor). - - Returns: - str: The parameter name. - """ - # Default to 'domain' but could be overridden based on broker - return "domain" # or "vendor" for some brokers - - def _extract_auth_token(self, response_data: Dict[str, Any]) -> Optional[str]: - """ - Extract the authentication token from the login response. - - Args: - response_data: The parsed JSON response from login. - - Returns: - Optional[str]: The authentication token, or None if not found. - """ - # Try common token field names - for key in ["token", "access_token", "authToken", "sessionToken"]: - if key in response_data: - return response_data[key] - - # Handle nested structures (adjust based on actual response format) - if "data" in response_data and isinstance(response_data["data"], dict): - for key in ["token", "access_token", "authToken", "sessionToken"]: - if key in response_data["data"]: - return response_data["data"][key] - - return None - - def _extract_account_id(self, response_data: Dict[str, Any]) -> Optional[str]: - """ - Extract the account ID from the login response. - - Args: - response_data: The parsed JSON response from login. - - Returns: - Optional[str]: The account ID, or None if not found. - """ - # Try common account ID field names - for key in ["accountId", "account_id", "accountCode"]: - if key in response_data: - return response_data[key] - - # Handle nested structures (adjust based on actual response format) - if "data" in response_data and isinstance(response_data["data"], dict): - for key in ["accountId", "account_id", "accountCode"]: - if key in response_data["data"]: - return response_data["data"][key] - - return None - - def _configure_auth_headers(self) -> None: - """ - Configure authentication headers for subsequent requests. - - UNVERIFIED: DXtrade SCA uses the custom scheme `Authorization: DXAPI `, - not the OAuth-style `Bearer ` this class sent until 2026. Every - authenticated request would have been rejected with the old scheme, on top of - already being sent to a prefix-less URL. Neither the scheme nor the header name - has been confirmed against a live server. - """ - if self._auth_token: - self._session.headers.update({ - "Authorization": f"{self.AUTH_SCHEME} {self._auth_token}" - }) - - def _extract_position_code(self, order_response: Dict[str, Any]) -> Optional[str]: - """ - Extract the position code from an order response for linked SL/TP orders. - - Args: - order_response: The parsed JSON response from a place_order call. - - Returns: - Optional[str]: The position code, or None if not found. - """ - # Try common position code field names - for key in ["positionCode", "position_code", "positionId", "position_id"]: - if key in order_response: - return order_response[key] - - return None - - def _place_linked_sl_tp_orders( - self, - position_code: str, - quantity: float, - original_side: str, - stop_loss_price: Optional[float] = None, - take_profit_price: Optional[float] = None - ) -> None: - """ - Place linked Stop Loss and/or Take Profit orders for a position. - - Args: - position_code: The position code to link the orders to. - quantity: Order quantity. - original_side: The side of the original order ('BUY' or 'SELL'). - stop_loss_price: Stop Loss price. - take_profit_price: Take Profit price. - """ - account_id = self._get_account_id() - orders_url = self._url(f"accounts/{account_id}/orders") - - # Determine opposite side for closing orders - close_side = "SELL" if original_side == "BUY" else "BUY" - - try: - # Place Stop Loss order if price provided - if stop_loss_price is not None: - sl_payload = { - "account": account_id, - "orderCode": self._generate_order_code(), - "type": "STOP", - "positionEffect": "CLOSE", - "positionCode": position_code, - "quantity": quantity, - "side": close_side, - "stopPrice": stop_loss_price, - "tif": "GTC" - } - - self._logger.debug(f"Placing Stop Loss order: {sl_payload}") - sl_response = self._session.post(orders_url, json=sl_payload, timeout=10) - sl_response.raise_for_status() - self._logger.info("Stop Loss order placed successfully") - - # Place Take Profit order if price provided - if take_profit_price is not None: - tp_payload = { - "account": account_id, - "orderCode": self._generate_order_code(), - "type": "LIMIT", - "positionEffect": "CLOSE", - "positionCode": position_code, - "quantity": quantity, - "side": close_side, - "limitPrice": take_profit_price, - "tif": "GTC" - } - - self._logger.debug(f"Placing Take Profit order: {tp_payload}") - tp_response = self._session.post(orders_url, json=tp_payload, timeout=10) - tp_response.raise_for_status() - self._logger.info("Take Profit order placed successfully") - - except Exception as e: - self._logger.error(f"Error placing linked SL/TP orders: {e}") - # We don't raise here to avoid affecting the main order - # But we log the error for debugging - - def _parse_balance_data(self, balance_data: Dict[str, Any]) -> Balance: - """ - Parse the balance data from the API response. - - Args: - balance_data: The parsed JSON response from get_balance. - - Returns: - Balance: A Balance object containing account balance details. - """ - # This needs to be adjusted based on actual response format - try: - # Example parsing - adjust based on actual API response - equity = float(balance_data.get("equity", 0)) - balance = float(balance_data.get("balance", 0)) - margin = float(balance_data.get("margin", 0)) - free_margin = float(balance_data.get("freeMargin", 0)) - margin_level = float(balance_data.get("marginLevel", 0)) if "marginLevel" in balance_data else None - currency = balance_data.get("currency") - - return Balance( - equity=equity, - balance=balance, - margin=margin, - free_margin=free_margin, - margin_level=margin_level, - currency=currency - ) - except (KeyError, ValueError) as e: - self._logger.error(f"Error parsing balance data: {e}") - # Return default values if parsing fails - return Balance( - equity=0.0, - balance=0.0, - margin=0.0, - free_margin=0.0 - ) - - def _parse_positions_data(self, positions_data: List[Dict[str, Any]]) -> List[Position]: - """ - Parse the positions data from the API response. - - Args: - positions_data: The parsed JSON response from get_positions. - - Returns: - List[Position]: A list of Position objects. - """ - # This needs to be adjusted based on actual response format - positions = [] - - try: - for position_data in positions_data: - position = Position( - position_id=str(position_data.get("id", "")), - instrument=position_data.get("instrument", ""), - quantity=float(position_data.get("quantity", 0)), - side=position_data.get("side", ""), - entry_price=float(position_data.get("entryPrice", 0)), - current_price=float(position_data.get("currentPrice", 0)) if "currentPrice" in position_data else None, - pnl=float(position_data.get("pnl", 0)) if "pnl" in position_data else None, - open_time=position_data.get("openTime"), - stop_loss=float(position_data.get("stopLoss", 0)) if "stopLoss" in position_data else None, - take_profit=float(position_data.get("takeProfit", 0)) if "takeProfit" in position_data else None - ) - positions.append(position) - - except (KeyError, ValueError) as e: - self._logger.error(f"Error parsing positions data: {e}") - - return positions - - def _parse_orders_data(self, orders_data: List[Dict[str, Any]]) -> List[Order]: - """ - Parse the orders data from the API response. - - Args: - orders_data: The parsed JSON response from get_orders. - - Returns: - List[Order]: A list of Order objects. - """ - # This needs to be adjusted based on actual response format - orders = [] - - try: - for order_data in orders_data: - order = Order( - order_id=str(order_data.get("id", "")), - instrument=order_data.get("instrument", ""), - quantity=float(order_data.get("quantity", 0)), - side=order_data.get("side", ""), - order_type=order_data.get("type", ""), - status=order_data.get("status", ""), - price=float(order_data.get("price", 0)) if "price" in order_data else None, - stop_loss=float(order_data.get("stopLoss", 0)) if "stopLoss" in order_data else None, - take_profit=float(order_data.get("takeProfit", 0)) if "takeProfit" in order_data else None, - created_time=order_data.get("createdTime"), - position_effect=order_data.get("positionEffect"), - tif=order_data.get("tif"), - position_code=order_data.get("positionCode") - ) - orders.append(order) - - except (KeyError, ValueError) as e: - self._logger.error(f"Error parsing orders data: {e}") - - return orders - - def _build_websocket_url(self) -> str: - """ - Build the WebSocket connection URL. - - UNVERIFIED: passing the session token as a `token` query parameter is - inferred from community examples, not documented. Note that query strings - commonly end up in server access logs, so this is a poor place for a - credential; if a broker accepts the token in a header instead, prefer that. - - Returns: - str: The full WebSocket URL. - - Note: - The query string is now assembled with urlencode() from a dict. The - previous version appended "?token=..." only when a token existed but - then appended "&account=..." unconditionally, so with an account and no - token it emitted a malformed "...&account=X" with no "?" at all - the - first parameter introduced by an ampersand. It also interpolated both - values raw, so any reserved character in a token or account code - corrupted the query. - """ - # Convert HTTP(S) to WS(S) - ws_protocol = "wss://" if self._base_url.startswith("https://") else "ws://" - base_without_protocol = self._base_url.split("://")[1] - - ws_url = f"{ws_protocol}{base_without_protocol}/{self._websocket_path}" - - # Collect query parameters, then encode them in one place. - params: Dict[str, str] = {} - if self._auth_token: - params["token"] = self._auth_token - if self._account_id: - params["account"] = self._account_id - - if params: - ws_url = f"{ws_url}?{urlencode(params)}" - - return ws_url - - def _build_websocket_headers(self) -> List[str]: - """ - Build the WebSocket connection headers. - - Returns: - List[str]: A list of header strings in the format "Header: Value". - """ - headers = [] - - # Add standard headers (adjust based on actual requirements) - headers.append("User-Agent: DXTradeDashboardWrapper/1.0") - - # Add content type - headers.append("Content-Type: application/json") - - # Add any framework-specific headers if needed (adjust based on research findings) - headers.append("X-Atmosphere-tracking-id: 0") - headers.append("X-Atmosphere-Framework: 1.0") - headers.append("X-atmo-protocol: true") - - # Add the authorization header whenever a token exists. - # - # This was previously guarded by `"token=" not in self._build_websocket_url()`, - # which could never be satisfied: _build_websocket_url() adds "token=" exactly - # when self._auth_token is truthy, so the left operand implied the negation of - # the right and the header was never once sent. Sending the token in both the - # header and the query string is harmless and lets a broker accept either. - # - # UNVERIFIED: like the REST scheme, this uses DXAPI rather than the Bearer - # that was here before. Neither has been confirmed for the WebSocket handshake. - if self._auth_token: - headers.append(f"Authorization: {self.AUTH_SCHEME} {self._auth_token}") - - return headers - - def _websocket_listener_thread(self) -> None: - """ - Target function for the WebSocket listener thread. - """ - ws_url = self._build_websocket_url() - headers = self._build_websocket_headers() - - self._logger.info(f"Connecting to WebSocket: {ws_url}") - - # Create WebSocket client - self._ws_client = websocket.WebSocketApp( - ws_url, - header=headers, - on_open=self._on_open, - on_message=self._on_message, - on_error=self._on_error, - on_close=self._on_close - ) - - # Run WebSocket connection with ping/pong for keep-alive - retry_count = 0 - while self._ws_reconnect: - try: - # Run the WebSocket client - self._ws_client.run_forever(ping_interval=30, ping_timeout=10) - - # If we get here, the connection was closed - if not self._ws_reconnect: - break - - # Calculate backoff time (exponential with max) - backoff_time = min(2 ** retry_count, 30) - retry_count += 1 - - self._logger.warning(f"WebSocket disconnected. Reconnecting in {backoff_time} seconds...") - time.sleep(backoff_time) - - except Exception as e: - self._logger.error(f"WebSocket thread error: {e}") - time.sleep(5) # Brief delay before retry - - def _on_open(self, ws) -> None: - """ - Callback when WebSocket connection is established. - - Args: - ws: The WebSocket client instance. - """ - self._ws_connected = True - self._logger.info("WebSocket connection established") - - # Re-subscribe to previously subscribed data if any - if self._subscribed_instruments: - self.subscribe_market_data(list(self._subscribed_instruments)) - - if self._account_updates_subscribed: - self.subscribe_account_updates() - - def _on_message(self, ws, message_str: str) -> None: - """ - Callback when a message is received on the WebSocket. - - Args: - ws: The WebSocket client instance. - message_str: The message string received. - """ - try: - message = json.loads(message_str) - message_type = self._determine_message_type(message) - - if message_type == "price_update": - parsed_data = self._parse_price_update(message) - self.price_update_queue.put(parsed_data) - - elif message_type == "order_update": - parsed_data = self._parse_order_update(message) - self.order_update_queue.put(parsed_data) - - elif message_type == "account_update": - parsed_data = self._parse_account_update(message) - self.account_update_queue.put(parsed_data) - - elif message_type == "position_update": - parsed_data = self._parse_position_update(message) - # Add to account updates as it's account-related - self.account_update_queue.put(parsed_data) - - elif message_type == "error": - self._logger.error(f"WebSocket error message: {message}") - - else: - self._logger.debug(f"Received unhandled message type: {message_type}") - - except json.JSONDecodeError: - self._logger.error(f"Failed to decode WebSocket message: {message_str}") - except Exception as e: - self._logger.error(f"Error processing WebSocket message: {e}") - - def _on_error(self, ws, error) -> None: - """ - Callback when a WebSocket error occurs. - - Args: - ws: The WebSocket client instance. - error: The error that occurred. - """ - self._logger.error(f"WebSocket error: {error}") - self._ws_connected = False - - def _on_close(self, ws, close_status_code, close_msg) -> None: - """ - Callback when WebSocket connection is closed. - - Args: - ws: The WebSocket client instance. - close_status_code: The status code for the closure. - close_msg: The closure message. - """ - self._logger.info(f"WebSocket closed. Status code: {close_status_code}, Message: {close_msg}") - self._ws_connected = False - - def _determine_message_type(self, message: Dict[str, Any]) -> str: - """ - Determine the type of a WebSocket message. - - Args: - message: The parsed message. - - Returns: - str: The message type ("price_update", "order_update", "account_update", "error", "unknown"). - """ - # First check for explicit type field - if "type" in message: - msg_type = message["type"].lower() - - if "price" in msg_type or "quote" in msg_type: - return "price_update" - elif "order" in msg_type: - return "order_update" - elif "account" in msg_type or "balance" in msg_type: - return "account_update" - elif "position" in msg_type: - return "position_update" - elif "error" in msg_type: - return "error" - else: - return msg_type - - # If no type field, infer from content - if "symbol" in message and ("bid" in message or "ask" in message): - return "price_update" - elif "orderId" in message or "order_id" in message: - return "order_update" - elif "balance" in message or "equity" in message: - return "account_update" - elif "positionId" in message or "position_id" in message: - return "position_update" - elif "error" in message or "errorMessage" in message: - return "error" - - return "unknown" - - def _parse_price_update(self, message: Dict[str, Any]) -> Dict[str, Any]: - """ - Parse a price update message. - - Args: - message: The parsed message. - - Returns: - Dict[str, Any]: Structured price update data. - """ - # Create a standardized format, adjusting field names as needed - update = { - "type": "price_update", - "timestamp": message.get("timestamp", time.time() * 1000) - } - - # Extract instrument/symbol - for key in ["symbol", "instrument", "instrumentId"]: - if key in message: - update["instrument"] = message[key] - break - - # Extract price data - for key in ["bid", "bidPrice", "bid_price"]: - if key in message: - update["bid"] = float(message[key]) - break - - for key in ["ask", "askPrice", "ask_price"]: - if key in message: - update["ask"] = float(message[key]) - break - - for key in ["last", "lastPrice", "last_price"]: - if key in message: - update["last"] = float(message[key]) - break - - # Calculate spread if bid and ask are available - if "bid" in update and "ask" in update: - update["spread"] = update["ask"] - update["bid"] - - return update - - def _parse_order_update(self, message: Dict[str, Any]) -> Dict[str, Any]: - """ - Parse an order update message. - - Args: - message: The parsed message. - - Returns: - Dict[str, Any]: Structured order update data. - """ - # Create a standardized format, adjusting field names as needed - update = { - "type": "order_update", - "timestamp": message.get("timestamp", time.time() * 1000) - } - - # Extract order identifier - for key in ["orderId", "order_id", "id"]: - if key in message: - update["order_id"] = str(message[key]) - break - - # Extract other order details - field_mappings = { - "instrument": ["instrument", "symbol", "instrumentId"], - "status": ["status", "orderStatus", "order_status"], - "side": ["side", "direction"], - "order_type": ["type", "orderType", "order_type"], - "quantity": ["quantity", "qty", "size"], - "filled_quantity": ["filledQuantity", "filled_qty", "executed"], - "price": ["price", "limitPrice", "limit_price", "stopPrice", "stop_price"], - "position_id": ["positionId", "position_id", "positionCode"], - "created_time": ["createdTime", "created_time", "timestamp"] - } - - for target_field, source_fields in field_mappings.items(): - for source_field in source_fields: - if source_field in message: - update[target_field] = message[source_field] - break - - # Convert numeric fields - for field in ["quantity", "filled_quantity", "price"]: - if field in update and update[field] is not None: - try: - update[field] = float(update[field]) - except (ValueError, TypeError): - pass - - return update - - def _parse_account_update(self, message: Dict[str, Any]) -> Dict[str, Any]: - """ - Parse an account update message. - - Args: - message: The parsed message. - - Returns: - Dict[str, Any]: Structured account update data. - """ - # Create a standardized format, adjusting field names as needed - update = { - "type": "account_update", - "timestamp": message.get("timestamp", time.time() * 1000) - } - - # Extract account data - field_mappings = { - "balance": ["balance", "accountBalance", "account_balance"], - "equity": ["equity", "accountEquity", "account_equity"], - "margin": ["margin", "usedMargin", "used_margin"], - "free_margin": ["freeMargin", "free_margin", "availableMargin"], - "margin_level": ["marginLevel", "margin_level"], - "currency": ["currency", "accountCurrency"] - } - - for target_field, source_fields in field_mappings.items(): - for source_field in source_fields: - if source_field in message: - update[target_field] = message[source_field] - break - - # Convert numeric fields - for field in ["balance", "equity", "margin", "free_margin", "margin_level"]: - if field in update and update[field] is not None: - try: - update[field] = float(update[field]) - except (ValueError, TypeError): - pass - - return update - - def _parse_position_update(self, message: Dict[str, Any]) -> Dict[str, Any]: - """ - Parse a position update message. - - Args: - message: The parsed message. - - Returns: - Dict[str, Any]: Structured position update data. - """ - # Create a standardized format, adjusting field names as needed - update = { - "type": "position_update", - "timestamp": message.get("timestamp", time.time() * 1000) - } - - # Extract position identifier - for key in ["positionId", "position_id", "id"]: - if key in message: - update["position_id"] = str(message[key]) - break - - # Extract other position details - field_mappings = { - "instrument": ["instrument", "symbol", "instrumentId"], - "side": ["side", "direction"], - "quantity": ["quantity", "qty", "size"], - "entry_price": ["entryPrice", "entry_price", "openPrice"], - "current_price": ["currentPrice", "current_price", "marketPrice"], - "pnl": ["pnl", "profit", "profit_loss", "unrealizedPnL"], - "stop_loss": ["stopLoss", "stop_loss", "sl"], - "take_profit": ["takeProfit", "take_profit", "tp"], - "open_time": ["openTime", "open_time", "opened"] - } - - for target_field, source_fields in field_mappings.items(): - for source_field in source_fields: - if source_field in message: - update[target_field] = message[source_field] - break - - # Convert numeric fields - for field in ["quantity", "entry_price", "current_price", "pnl", "stop_loss", "take_profit"]: - if field in update and update[field] is not None: - try: - update[field] = float(update[field]) - except (ValueError, TypeError): - pass - - return update \ No newline at end of file diff --git a/dxtrade_wrapper/__init__.py b/dxtrade_wrapper/__init__.py new file mode 100644 index 0000000..e913644 --- /dev/null +++ b/dxtrade_wrapper/__init__.py @@ -0,0 +1,53 @@ +"""Python client for the DXtrade REST and Push APIs. + +Spec-conformant (checked against Devexperts' public OpenAPI document), not yet +verified against a live broker. See the README before trading real money. +""" + +import logging + +from .client import DXTradeDashboardWrapper +from .exceptions import ( + AuthenticationError, + ConflictError, + ConnectionError, + DXTradeAPIError, + DXTradeConnectionError, + DXTradeWrapperError, + NotFoundError, + OrderPlacementError, + PreconditionFailedError, + RateLimitError, + ServerError, + WebSocketError, +) +from .models import Balance, Order, Position + +__version__ = "0.2.0" + +#: Shorter alias for the client class. +DXTradeClient = DXTradeDashboardWrapper + +# Libraries must not configure logging; applications opt in with logging.basicConfig(). +logging.getLogger("dxtrade_wrapper").addHandler(logging.NullHandler()) + +__all__ = [ + "AuthenticationError", + "Balance", + "ConflictError", + "ConnectionError", + "DXTradeAPIError", + "DXTradeClient", + "DXTradeConnectionError", + "DXTradeDashboardWrapper", + "DXTradeWrapperError", + "NotFoundError", + "Order", + "OrderPlacementError", + "Position", + "PreconditionFailedError", + "RateLimitError", + "ServerError", + "WebSocketError", + "__version__", +] diff --git a/dxtrade_wrapper/client.py b/dxtrade_wrapper/client.py new file mode 100644 index 0000000..5a9d7fa --- /dev/null +++ b/dxtrade_wrapper/client.py @@ -0,0 +1,1257 @@ +""" +DXTradeDashboardWrapper: a Python client for the DXtrade REST and Push APIs. + +It covers authentication and session upkeep, account data, order management and +real-time updates for a trading dashboard or bot. + +============================================================================= +SPEC-CONFORMANT, NOT LIVE-VERIFIED +============================================================================= + +Every request and response shape here follows Devexperts' public DXtrade +documentation: + +* REST API: https://demo.dx.trade/developers/#/DXtrade-REST-API + (OpenAPI: https://demo.dx.trade/dxsca-web/swagger/openapi.json) +* Push API: https://demo.dx.trade/developers/#/DXtrade-Push-API + +The test suite checks the requests this client sends against that OpenAPI +document. Nothing has been run against a live broker yet, and brokers can switch +REST access off entirely (FTMO did in April 2024). Try it on a demo account +before you trust it with money. +""" + +import itertools +import json +import logging +import queue +import re +import threading +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple +from urllib.parse import quote, urlencode + +import requests +import websocket + +from .exceptions import ( + AuthenticationError, + ConflictError, + ConnectionError, + DXTradeAPIError, + NotFoundError, + OrderPlacementError, + PreconditionFailedError, + RateLimitError, + ServerError, + WebSocketError, +) +from .models import ( + Balance, + Order, + Position, + account_code, + order_leg, + parse_balance, + parse_order, + parse_position, + unwrap_list, +) + +JSON = Dict[str, Any] + +_ORDER_TYPES = ("MARKET", "LIMIT", "STOP") +_SIDES = ("BUY", "SELL") +_TIFS = ("GTC", "DAY", "GTD") + +_LOG = logging.getLogger("dxtrade_wrapper") + + +def _opposite(side: str) -> str: + return "SELL" if side.upper() == "BUY" else "BUY" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def parse_interval(value: Any) -> Optional[float]: + """Parses a session ``timeout`` ("00:30:00", "PT30M" or seconds) to seconds.""" + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + match = re.fullmatch(r"(\d+):(\d{1,2}):(\d{1,2}(?:\.\d+)?)", text) + if match: + hours, minutes, seconds = match.groups() + return int(hours) * 3600 + int(minutes) * 60 + float(seconds) + match = re.fullmatch(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?", text, re.IGNORECASE) + if match and any(match.groups()): + hours, minutes, seconds = (float(g) if g else 0.0 for g in match.groups()) + return hours * 3600 + minutes * 60 + seconds + if re.fullmatch(r"\d+(?:\.\d+)?", text): + return float(text) + return None + + +class DXTradeDashboardWrapper: + """ + A client for the DXtrade REST API (token authentication) and Push API. + + Typical use:: + + with DXTradeDashboardWrapper(base_url, username, password, "default") as dx: + print(dx.get_balance()) + dx.place_order("EUR/USD", "BUY", 1000, "MARKET", stop_loss=1.05, take_profit=1.2) + + Spec-conformant but not verified against a live broker: see the module docstring. + """ + + #: Authorization scheme for token-authenticated REST calls (REST spec, "Token Authentication"). + AUTH_SCHEME = "DXAPI" + + def __init__( + self, + base_url: str, + username: str, + password: str, + domain_or_vendor: str = "default", + api_prefix: str = "/dxsca-web", + login_path: str = "/login", + websocket_path: str = "/websocket", + account: Optional[str] = None, + keepalive_interval: Optional[float] = 60.0, + logger: Optional[logging.Logger] = None, + timeout: float = 10.0, + auto_relogin: bool = True, + websocket_url: Optional[str] = None, + ): + """ + Args: + base_url: The broker's DXtrade host, e.g. "https://dxtrade.broker.com". + username: Login, or a full "login@domain" username. + password: Password. Never logged. + domain_or_vendor: The user's DXtrade domain (usually "default"). + api_prefix: Path prefix of every REST endpoint. "/dxsca-web" per the spec. + login_path: Login path relative to `api_prefix`. A value that already + includes the prefix (e.g. "/dxsca-web/login") is normalised. + websocket_path: Path of the Push API websocket on the host (not under + `api_prefix`). The Push spec leaves this URL to each deployment; ask + your broker. Market data uses the same URL plus "/md". + websocket_url: Full Push API URL; overrides `websocket_path`. + account: Account code ("clearing:account"). If omitted it is looked up + via GET /users/{username} on first use; if the user has several + accounts you must choose one here. + keepalive_interval: Seconds between POST /ping calls after login (capped + at half the session timeout the server reports). None disables it. + logger: Logger to use instead of the "dxtrade_wrapper" logger. + timeout: Per-request HTTP timeout in seconds. + auto_relogin: Log in again once and retry when a request gets 401 + because the session expired. + """ + self._base_url = base_url.rstrip("/") + self._username = username + self._password = password + self._domain_or_vendor = domain_or_vendor + self._timeout = timeout + self._auto_relogin = auto_relogin + + prefix = (api_prefix or "").strip("/") + self._api_prefix = f"/{prefix}" if prefix else "" + login_path = (login_path or "login").strip("/") + if prefix and (login_path == prefix or login_path.startswith(f"{prefix}/")): + login_path = login_path[len(prefix):].strip("/") + self._login_path = login_path or "login" + + self._websocket_path = websocket_path.lstrip("/") + self._websocket_url = websocket_url + + # A library must not configure logging; the package adds a NullHandler. + self._logger = logger or _LOG + + self._session = requests.Session() + self._session.headers.update({"Accept": "application/json"}) + self._auth_token: Optional[str] = None + self._is_authenticated = False + self._session_timeout: Optional[float] = None + self._auth_lock = threading.RLock() + + self._configured_account = account + self._account_id = account + self._account_currency: Dict[str, str] = {} + + self._keepalive_interval = keepalive_interval + self._keepalive_thread: Optional[threading.Thread] = None + self._keepalive_stop = threading.Event() + + # Push API state + self._channels: Dict[str, "_PushChannel"] = {} + self._subscriptions: Dict[str, Tuple[str, str, JSON]] = {} + self._subscribed_instruments: set = set() + self._account_updates_subscribed = False + self._request_ids = itertools.count(1) + self._lock = threading.Lock() + + self.price_update_queue: "queue.Queue[JSON]" = queue.Queue() + self.order_update_queue: "queue.Queue[JSON]" = queue.Queue() + self.account_update_queue: "queue.Queue[JSON]" = queue.Queue() + + def __repr__(self) -> str: + # Never include the password or the session token. + return ( + f"{type(self).__name__}(base_url={self._base_url!r}, username={self._username!r}, " + f"domain={self._domain_or_vendor!r}, account={self._account_id!r}, " + f"authenticated={self._is_authenticated})" + ) + + def __enter__(self) -> "DXTradeDashboardWrapper": + self.login() + return self + + def __exit__(self, *exc_info: Any) -> None: + self.logout() + + # ------------------------------------------------------------------ + # HTTP plumbing + # ------------------------------------------------------------------ + + def _url(self, path: str) -> str: + """Absolute REST URL for a path relative to the API prefix. + + Every REST call goes through here, so the prefix cannot be forgotten on + one endpoint (the original bug: login had it, eight other calls did not). + """ + return f"{self._base_url}{self._api_prefix}/{path.lstrip('/')}" + + @staticmethod + def _seg(value: str) -> str: + """URL-encodes one path segment. Account codes contain ':' and client + order ids may contain '/', '?', '#' - the spec requires encoding both.""" + return quote(str(value), safe="") + + def _raise_for_response(self, response: requests.Response, context: str) -> None: + if response.status_code < 400: + return + body: Any + try: + body = response.json() + except ValueError: + body = response.text[:500] or None + error_code = description = None + if isinstance(body, Mapping): + error_code = body.get("errorCode") + description = body.get("description") + status = response.status_code + detail = description or (body if isinstance(body, str) else None) or response.reason + message = f"{context} failed: HTTP {status}" + if error_code is not None: + message += f" (error {error_code})" + if detail: + message += f": {detail}" + kwargs = dict(status_code=status, error_code=error_code, description=description, body=body) + if status == 401: + raise AuthenticationError(message, **kwargs) + if status == 404: + raise NotFoundError(message, **kwargs) + if status == 409: + raise ConflictError(message, **kwargs) + if status == 412: + raise PreconditionFailedError(message, **kwargs) + if status == 429: + retry_after = response.headers.get("Retry-After") + try: + retry = float(retry_after) if retry_after else None + except ValueError: + retry = None + raise RateLimitError(message, retry_after=retry, **kwargs) + if status >= 500: + raise ServerError(message, **kwargs) + raise DXTradeAPIError(message, **kwargs) + + def _request( + self, + method: str, + path: str, + context: str, + *, + json_body: Any = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + retry_auth: bool = True, + ) -> requests.Response: + """Sends one REST request and maps failures to typed exceptions. + + On 401 with an established session it logs in again once and retries: + the spec says an expired token gets 401 and the client "is expected to + repeat the authentication procedure". Retrying an order POST is safe + because a 401 means it was not processed, and the retry carries the same + orderCode. + """ + url = self._url(path) + try: + response = self._session.request( + method, url, json=json_body, params=params, headers=headers, timeout=self._timeout + ) + except requests.exceptions.RequestException as exc: + # str(exc) carries the URL but never the body or auth header. + raise ConnectionError(f"{context}: network error: {exc}") from exc + + if ( + response.status_code == 401 + and retry_auth + and self._auto_relogin + and self._is_authenticated + ): + self._logger.info("%s got 401; session expired, logging in again", context) + self._relogin() + return self._request( + method, path, context, + json_body=json_body, params=params, headers=headers, retry_auth=False, + ) + + self._raise_for_response(response, context) + return response + + @staticmethod + def _json(response: requests.Response, context: str) -> Any: + if not response.content: + return None + try: + return response.json() + except ValueError: + raise DXTradeAPIError( + f"{context}: response is not JSON", status_code=response.status_code, + body=response.text[:500], + ) from None + + # ------------------------------------------------------------------ + # Authentication and session + # ------------------------------------------------------------------ + + def login(self) -> None: + """POST /login with username, domain and password; stores the session token. + + Raises: + AuthenticationError: Wrong credentials (401) or API access refused (403). + ConnectionError: The server could not be reached. + """ + with self._auth_lock: + payload = { + "username": self._username, + "domain": self._domain_or_vendor, + "password": self._password, + } + self._logger.debug("Logging in to %s", self._url(self._login_path)) + try: + response = self._session.post( + self._url(self._login_path), + json=payload, + headers={"Authorization": None}, # never send a stale token to /login + timeout=self._timeout, + ) + except requests.exceptions.RequestException as exc: + self._is_authenticated = False + raise ConnectionError(f"Login: network error: {exc}") from exc + + if response.status_code == 403: + self._is_authenticated = False + raise AuthenticationError( + "Login refused with HTTP 403: this broker has not enabled DXtrade REST " + "API access for your account (FTMO, for example, disabled it in April 2024).", + status_code=403, + ) + try: + self._raise_for_response(response, "Login") + except DXTradeAPIError as exc: + self._is_authenticated = False + if isinstance(exc, AuthenticationError): + raise + raise AuthenticationError(str(exc), status_code=exc.status_code, + error_code=exc.error_code, + description=exc.description) from exc + + data = self._json(response, "Login") or {} + token = data.get("sessionToken") if isinstance(data, Mapping) else None + if not token: + self._is_authenticated = False + raise AuthenticationError("Login response contained no sessionToken") + + self._auth_token = token + self._session_timeout = parse_interval(data.get("timeout")) + self._configure_auth_headers() + self._is_authenticated = True + self._start_keepalive() + self._logger.info("Authenticated as %s", self._username) + + def _relogin(self) -> None: + with self._auth_lock: + self._is_authenticated = False + self.login() + + def logout(self) -> None: + """Closes websockets, stops the keepalive and POSTs /logout (best effort).""" + self.disconnect_websocket() + self._stop_keepalive() + if self._is_authenticated: + try: + self._request("POST", "logout", "Logout", retry_auth=False) + except Exception as exc: # noqa: BLE001 - logout must always clear local state + self._logger.warning("Logout request failed (ignored): %s", exc) + self._session = requests.Session() + self._session.headers.update({"Accept": "application/json"}) + self._auth_token = None + self._is_authenticated = False + self._account_id = self._configured_account + self._logger.info("Logged out") + + @property + def is_authenticated(self) -> bool: + return self._is_authenticated + + def ping(self) -> bool: + """POST /ping to extend the session. Returns False instead of raising. + + If the server returns a new sessionToken, it replaces the old one. + """ + if not self._is_authenticated: + return False + try: + response = self._request("POST", "ping", "Ping", retry_auth=False) + except Exception as exc: # noqa: BLE001 - never kill the keepalive thread + self._logger.warning("Keepalive ping failed: %s", exc) + return False + try: + data = self._json(response, "Ping") + except DXTradeAPIError: + data = None + if isinstance(data, Mapping) and data.get("sessionToken"): + with self._auth_lock: + self._auth_token = data["sessionToken"] + self._configure_auth_headers() + self._session_timeout = parse_interval(data.get("timeout")) or self._session_timeout + return True + + def _effective_keepalive(self) -> Optional[float]: + interval = self._keepalive_interval + if not interval or interval <= 0: + return None + if self._session_timeout: + interval = min(interval, max(self._session_timeout / 2, 1.0)) + return interval + + def _start_keepalive(self) -> None: + if not self._effective_keepalive(): + return + if self._keepalive_thread and self._keepalive_thread.is_alive(): + return + self._keepalive_stop.clear() + self._keepalive_thread = threading.Thread( + target=self._keepalive_loop, name="DXTradeKeepalive", daemon=True + ) + self._keepalive_thread.start() + + def _stop_keepalive(self) -> None: + self._keepalive_stop.set() + thread = self._keepalive_thread + if thread and thread.is_alive() and thread is not threading.current_thread(): + thread.join(timeout=5) + self._keepalive_thread = None + + def _keepalive_loop(self) -> None: + while not self._keepalive_stop.wait(self._effective_keepalive() or 60.0): + if not self._is_authenticated: + break + if not self.ping() and self._auto_relogin: + try: + self._relogin() + except Exception as exc: # noqa: BLE001 - keep the thread alive + self._logger.warning("Re-login after failed ping failed: %s", exc) + + def _configure_auth_headers(self) -> None: + if self._auth_token: + self._session.headers["Authorization"] = f"{self.AUTH_SCHEME} {self._auth_token}" + + def _check_authenticated(self) -> None: + if not self._is_authenticated: + raise AuthenticationError("Not authenticated. Call login() first.") + + # ------------------------------------------------------------------ + # Accounts + # ------------------------------------------------------------------ + + def _full_username(self) -> str: + if "@" in self._username: + return self._username + return f"{self._username}@{self._domain_or_vendor}" + + def get_accounts(self) -> List[JSON]: + """GET /users/{login@domain}: the user's accounts. + + Returns a list of dicts with ``account`` (code), ``base_currency``, + ``status`` and ``position_based``. + """ + self._check_authenticated() + response = self._request( + "GET", f"users/{self._seg(self._full_username())}", "Get user accounts" + ) + users = unwrap_list(self._json(response, "Get user accounts"), "userDetails") + accounts = [] + for user in users: + for details in user.get("accounts") or []: + code = account_code(details.get("account")) + accounts.append({ + "account": code, + "base_currency": details.get("baseCurrency"), + "status": details.get("accountStatus"), + "position_based": details.get("positionBased", details.get("isPositionBased")), + }) + if code and details.get("baseCurrency"): + self._account_currency[code] = details["baseCurrency"] + return accounts + + def _get_account_id(self) -> str: + """The account code for account-scoped calls, discovering it if needed. + + Never invents one: with zero or several accounts it raises and says how + to choose. + """ + if self._account_id: + return self._account_id + accounts = [a["account"] for a in self.get_accounts() if a["account"]] + if len(accounts) == 1: + self._account_id = accounts[0] + self._logger.info("Using account %s", self._account_id) + return self._account_id + if not accounts: + raise DXTradeAPIError( + "No account code available: the user has no accounts. Pass account=... " + "(DXTRADE_ACCOUNT) with the code shown in your broker's web terminal." + ) + raise DXTradeAPIError( + f"User has {len(accounts)} accounts ({', '.join(accounts)}); pass account=... " + "(DXTRADE_ACCOUNT) to choose one." + ) + + def _account_path(self, suffix: str) -> str: + return f"accounts/{self._seg(self._get_account_id())}/{suffix}" + + # ------------------------------------------------------------------ + # Account data + # ------------------------------------------------------------------ + + def get_account_metrics(self, include_positions: bool = False) -> JSON: + """GET /accounts/{account}/metrics: the raw AccountMetrics object.""" + self._check_authenticated() + params = {"include-positions": "true"} if include_positions else None + response = self._request("GET", self._account_path("metrics"), "Get account metrics", + params=params) + metrics = unwrap_list(self._json(response, "Get account metrics"), "metrics") + account = self._get_account_id() + for entry in metrics: + if account_code(entry.get("account")) == account: + return entry + if len(metrics) == 1: + return metrics[0] + raise DXTradeAPIError(f"No metrics returned for account {account}") + + def get_balance(self) -> Balance: + """Equity, balance, margin and P/L from GET /accounts/{account}/metrics.""" + metrics = self.get_account_metrics() + return parse_balance(metrics, currency=self._account_currency.get(self._get_account_id())) + + def get_portfolio(self) -> JSON: + """GET /accounts/{account}/portfolio: raw balances, positions and orders.""" + self._check_authenticated() + response = self._request("GET", self._account_path("portfolio"), "Get portfolio") + portfolios = unwrap_list(self._json(response, "Get portfolio"), "portfolios") + if not portfolios: + raise DXTradeAPIError("Portfolio response was empty") + return portfolios[0] + + def get_positions(self) -> List[Position]: + """Open positions from GET /accounts/{account}/positions.""" + self._check_authenticated() + response = self._request("GET", self._account_path("positions"), "Get positions") + return [parse_position(p) + for p in unwrap_list(self._json(response, "Get positions"), "positions")] + + def _open_orders_raw(self) -> Tuple[List[JSON], Optional[str]]: + """Open orders plus the ETag the server sent with them (for If-Match).""" + self._check_authenticated() + response = self._request("GET", self._account_path("orders"), "Get orders") + orders = unwrap_list(self._json(response, "Get orders"), "orders") + return orders, response.headers.get("ETag") + + def get_orders(self) -> List[Order]: + """Working orders from GET /accounts/{account}/orders.""" + orders, _ = self._open_orders_raw() + return [parse_order(o) for o in orders] + + def get_order_history(self, limit: int = 100, **filters: Any) -> List[JSON]: + """GET /accounts/{account}/orders/history. + + Args: + limit: Maximum orders to return (the server may cap it). + **filters: Any documented query filter, with underscores for dashes: + ``in_status="COMPLETED"``, ``period="today"``, ``for_instrument="EUR/USD"``, + ``transaction_to=...`` (for paging via nextPageTransactionTime). + + Returns: + The raw ``Order`` dicts, most recent first. + """ + self._check_authenticated() + params = {"limit": limit} + params.update({k.replace("_", "-"): v for k, v in filters.items()}) + response = self._request("GET", self._account_path("orders/history"), + "Get order history", params=params) + return unwrap_list(self._json(response, "Get order history"), "orders") + + # ------------------------------------------------------------------ + # Orders + # ------------------------------------------------------------------ + + def _generate_order_code(self) -> str: + """Client order id: unique per account, <= 64 chars of the allowed set.""" + return f"dxpw-{uuid.uuid4().hex[:24]}" + + def _post_order(self, body: JSON, order_code: str, context: str) -> Any: + """POSTs an order or order group, raising OrderPlacementError on failure.""" + path = self._account_path("orders") + self._logger.debug("%s: %s", context, body) + try: + response = self._request("POST", path, context, json_body=body) + except ConnectionError as exc: + cause = exc.__cause__ + sent = not isinstance(cause, requests.exceptions.ConnectTimeout) + raise OrderPlacementError( + f"{context}: {exc}. " + ( + f"The order may have been placed: check for orderCode {order_code!r} " + "before retrying, or resend with the same orderCode." + if sent else "The request was not sent." + ), + order_code=order_code, ambiguous=sent, + ) from exc + except DXTradeAPIError as exc: + if isinstance(exc, AuthenticationError) and not self._is_authenticated: + raise + raise OrderPlacementError( + str(exc), order_code=order_code, status_code=exc.status_code, + error_code=exc.error_code, description=exc.description, body=exc.body, + ) from exc + return self._json(response, context) + + def place_order( + self, + instrument: str, + side: str, + quantity: float, + order_type: str, + price: Optional[float] = None, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None, + position_effect: str = "OPEN", + tif: str = "GTC", + **kwargs: Any, + ) -> Any: + """Places an order; with stop_loss / take_profit, atomically. + + Args: + instrument: Symbol, e.g. "EUR/USD". + side: "BUY" or "SELL". + quantity: Units (not lots). + order_type: "MARKET", "LIMIT" or "STOP". + price: Limit price (LIMIT) or stop price (STOP). + stop_loss, take_profit: Protection prices. They are sent in the same + request as an IF-THEN order group, so the entry and its protections + are accepted or rejected together - there is no window with an + unprotected position. The spec allows order groups only on + position-based accounts; elsewhere the server rejects the request + and nothing is placed. + position_effect: "OPEN" or "CLOSE" (CLOSE needs positionCode=...). + tif: "GTC", "DAY" or "GTD" (GTD needs expireDate=...; MARKET only GTC). + **kwargs: Extra SingleOrderRequest fields (positionCode, expireDate, + metadata, ...). ``orderCode=`` supplies your own idempotency key. + + Returns: + The server response: ``{"orderId", "updateOrderId"}`` for one order, or + ``{"orderResponses": [...]}`` for an order group. + + Raises: + ValueError: Invalid arguments (nothing is sent). + OrderPlacementError: The server rejected it, or the outcome is unknown + (see ``OrderPlacementError.ambiguous``). + """ + self._check_authenticated() + side, order_type = side.upper(), order_type.upper() + position_effect, tif = position_effect.upper(), tif.upper() + if side not in _SIDES: + raise ValueError("Side must be 'BUY' or 'SELL'") + if order_type not in _ORDER_TYPES: + raise ValueError("Order type must be 'MARKET', 'LIMIT', or 'STOP'") + if tif not in _TIFS: + raise ValueError("tif must be 'GTC', 'DAY' or 'GTD'") + if order_type == "MARKET" and tif != "GTC": + raise ValueError("MARKET orders only support tif='GTC'") + if tif == "GTD" and not kwargs.get("expireDate"): + raise ValueError("tif='GTD' requires expireDate=...") + if order_type in ("LIMIT", "STOP") and price is None: + raise ValueError(f"Price is required for {order_type} orders") + if position_effect not in ("OPEN", "CLOSE"): + raise ValueError("position_effect must be 'OPEN' or 'CLOSE'") + if position_effect == "CLOSE" and not kwargs.get("positionCode"): + raise ValueError("position_effect='CLOSE' requires positionCode=...") + protected = stop_loss is not None or take_profit is not None + if protected and position_effect != "OPEN": + raise ValueError( + "stop_loss/take_profit can only be attached to an OPEN order; " + "use modify_position_sl_tp() for an existing position" + ) + if quantity is None or quantity <= 0: + raise ValueError("quantity must be positive") + + account = self._get_account_id() + order_code = kwargs.pop("orderCode", None) or self._generate_order_code() + primary: JSON = { + "account": account, + "orderCode": order_code, + "type": order_type, + "instrument": instrument, + "quantity": quantity, + "positionEffect": position_effect, + "side": side, + "tif": tif, + } + if order_type == "LIMIT": + primary["limitPrice"] = price + elif order_type == "STOP": + primary["stopPrice"] = price + primary.update(kwargs) + + if not protected: + return self._post_order(primary, order_code, "Place order") + + # IF-THEN group (REST spec, "Order Group Request"): THEN orders close the + # position the IF order opens, have the opposite side, no quantity and GTC. + group_orders = [primary] + for kind, value in (("STOP", stop_loss), ("LIMIT", take_profit)): + if value is None: + continue + leg: JSON = { + "account": account, + "orderCode": self._generate_order_code(), + "type": kind, + "instrument": instrument, + "quantity": 0, + "positionEffect": "CLOSE", + "side": _opposite(side), + "tif": "GTC", + ("stopPrice" if kind == "STOP" else "limitPrice"): value, + } + group_orders.append(leg) + body = {"orders": group_orders, "contingencyType": "IF-THEN"} + return self._post_order(body, order_code, "Place order with protection") + + def _find_open_order(self, orders: List[JSON], order_id: str) -> JSON: + for order in orders: + ids = {str(order.get("orderCode")), str(order.get("clientOrderId")), + str(order.get("orderId"))} + if str(order_id) in ids: + return order + raise NotFoundError(f"No working order {order_id!r} on account {self._get_account_id()}") + + def _replace_request( + self, + order: Mapping[str, Any], + price: Optional[float] = None, + quantity: Optional[float] = None, + extra: Optional[Mapping[str, Any]] = None, + ) -> JSON: + """A complete SingleOrderRequest for PUT (Modify Order). + + The spec requires the whole order to be resubmitted and the ``type`` to be + omitted from replace requests. + """ + leg = order_leg(order) + body: JSON = { + "account": account_code(order.get("account")) or self._get_account_id(), + "orderCode": order.get("clientOrderId") or order.get("orderCode"), + "instrument": order.get("instrument") or leg.get("instrument"), + "positionEffect": leg.get("positionEffect"), + "side": order.get("side") or leg.get("side"), + "tif": order.get("tif"), + } + if leg.get("positionCode"): + body["positionCode"] = leg["positionCode"] + if order.get("expireDate"): + body["expireDate"] = order["expireDate"] + attached = leg.get("positionEffect") == "CLOSE" and leg.get("positionCode") + if quantity is not None: + body["quantity"] = quantity + elif not attached: + body["quantity"] = leg.get("quantity") + order_type = str(order.get("type", "")).upper() + current_price = leg.get("price") + if order_type == "LIMIT": + body["limitPrice"] = price if price is not None else current_price + elif order_type == "STOP": + body["stopPrice"] = price if price is not None else current_price + elif price is not None: + raise ValueError("A MARKET order has no price to modify") + if extra: + body.update(extra) + return {k: v for k, v in body.items() if v is not None} + + def _conditional(self, method: str, path: str, context: str, body: Optional[JSON], + etag: Optional[str], refetch: Callable[[], Optional[str]], + fetched: Optional[str] = None) -> Any: + """PUT/DELETE with If-Match; on 412 re-reads the ETag and retries once. + + ``etag`` is a caller-supplied value (never retried: the caller asked for + that exact version). ``fetched`` is one this client just read. + """ + auto = etag is None + tag = etag or fetched or refetch() + for attempt in (1, 2): + if not tag: + raise DXTradeAPIError( + f"{context}: the server sent no ETag with the order list, and the spec " + "requires If-Match for this request. Pass etag=... explicitly." + ) + try: + response = self._request(method, path, context, json_body=body, + headers={"If-Match": tag}) + return self._json(response, context) + except PreconditionFailedError: + if not auto or attempt == 2: + raise + self._logger.info("%s: ETag changed, re-reading and retrying", context) + tag = refetch() + return None # pragma: no cover + + def modify_order( + self, + order_id: str, + new_price: Optional[float] = None, + new_quantity: Optional[float] = None, + etag: Optional[str] = None, + **kwargs: Any, + ) -> Any: + """Modifies a working order: PUT /accounts/{account}/orders with If-Match. + + The current order is read first, because the spec requires the complete + order in a modify request; only the given fields change. + + Args: + order_id: Server orderCode, client orderCode, or numeric orderId. + new_price: New limit/stop price. + new_quantity: New quantity in units. + etag: If-Match value; read from GET orders when omitted. + **kwargs: Other SingleOrderRequest fields to change (e.g. tif, expireDate). + """ + if new_price is None and new_quantity is None and not kwargs: + raise ValueError("At least one parameter to modify must be provided") + orders, fetched_tag = self._open_orders_raw() + order = self._find_open_order(orders, order_id) + body = self._replace_request(order, new_price, new_quantity, kwargs) + return self._conditional( + "PUT", self._account_path("orders"), "Modify order", body, + etag, lambda: self._open_orders_raw()[1], fetched=fetched_tag, + ) + + def cancel_order(self, order_id: str, etag: Optional[str] = None) -> Any: + """Cancels a working order: DELETE /accounts/{account}/orders/{code} with If-Match.""" + self._check_authenticated() + path = self._account_path(f"orders/{self._seg(order_id)}") + result = self._conditional( + "DELETE", path, "Cancel order", None, etag, lambda: self._open_orders_raw()[1] + ) + return result if result is not None else {"success": True, "order_id": order_id} + + def _find_position(self, position_id: str) -> Position: + for position in self.get_positions(): + if position.position_id == str(position_id): + return position + raise NotFoundError(f"No open position {position_id!r}") + + def close_position(self, position_id: str, quantity: Optional[float] = None) -> Any: + """Closes a position fully or partially with a MARKET order linked to it. + + DXtrade has no DELETE-position endpoint: closing is a MARKET order with + positionEffect=CLOSE and the position's code, on the opposite side. + """ + self._check_authenticated() + position = self._find_position(position_id) + qty = position.quantity if quantity is None else quantity + if qty <= 0 or qty > position.quantity: + raise ValueError(f"quantity must be in (0, {position.quantity}]") + return self.place_order( + instrument=position.instrument, + side=_opposite(position.side), + quantity=qty, + order_type="MARKET", + position_effect="CLOSE", + positionCode=position.position_id, + ) + + def close_all( + self, + instrument: Optional[str] = None, + close_positions: bool = True, + cancel_orders: bool = True, + comment: Optional[str] = None, + ) -> Any: + """Bulk close: POST /accounts/{account}/close. + + Flattens the account (or one instrument) in a single request, e.g. before + a news event or a prop-firm cut-off. + """ + self._check_authenticated() + if not (close_positions or cancel_orders): + raise ValueError("Set close_positions and/or cancel_orders") + body: JSON = {"closePositions": close_positions, "cancelOrders": cancel_orders} + if instrument: + body["instrument"] = instrument + if comment: + body["comment"] = comment + response = self._request("POST", self._account_path("close"), "Bulk close", + json_body=body) + return self._json(response, "Bulk close") + + def modify_position_sl_tp( + self, + position_id: str, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None, + ) -> JSON: + """Sets or moves a position's stop loss and/or take profit. + + Per the spec, protections are CLOSE orders linked by positionCode: an + existing STOP (SL) or LIMIT (TP) is modified via PUT, a missing one is + placed via POST. + + Returns: + ``{"stop_loss": response, "take_profit": response}`` for what changed. + """ + self._check_authenticated() + if stop_loss is None and take_profit is None: + raise ValueError("At least one of stop_loss or take_profit must be provided") + position = self._find_position(position_id) + results: JSON = {} + for key, kind, value in (("stop_loss", "STOP", stop_loss), + ("take_profit", "LIMIT", take_profit)): + if value is None: + continue + orders, tag = self._open_orders_raw() + existing = next( + (o for o in orders + if str(o.get("type", "")).upper() == kind + and order_leg(o).get("positionCode") == position.position_id + and order_leg(o).get("positionEffect") == "CLOSE"), + None, + ) + if existing is not None: + body = self._replace_request(existing, price=value) + results[key] = self._conditional( + "PUT", self._account_path("orders"), f"Modify {key}", body, None, + lambda: self._open_orders_raw()[1], fetched=tag, + ) + else: + order_code = self._generate_order_code() + body = { + "account": self._get_account_id(), + "orderCode": order_code, + "type": kind, + "instrument": position.instrument, + "positionEffect": "CLOSE", + "positionCode": position.position_id, + "side": _opposite(position.side), + "tif": "GTC", + ("stopPrice" if kind == "STOP" else "limitPrice"): value, + } + results[key] = self._post_order(body, order_code, f"Place {key}") + return results + + # ------------------------------------------------------------------ + # Push API (WebSocket) + # ------------------------------------------------------------------ + + def _build_websocket_url(self, market_data: bool = False) -> str: + """Push API URL. The session token is NOT in the URL: per the Push spec it + travels in each message's ``session`` field (and URLs end up in logs).""" + if self._websocket_url: + base = self._websocket_url.split("?")[0].rstrip("/") + else: + scheme = "wss://" if self._base_url.startswith("https://") else "ws://" + host = self._base_url.split("://", 1)[1] + base = f"{scheme}{host}/{self._websocket_path}".rstrip("/") + if market_data: + base += "/md" + return f"{base}?{urlencode({'format': 'JSON'})}" + + def _build_websocket_headers(self) -> List[str]: + return [f"User-Agent: dxtrade-wrapper/{_version()}"] + + def _envelope(self, msg_type: str, payload: Optional[JSON] = None, + request_id: Optional[str] = None, **extra: Any) -> JSON: + message: JSON = { + "type": msg_type, + "requestId": request_id or f"dxpw-{next(self._request_ids)}", + "timestamp": _utc_now(), + "session": self._auth_token, + } + if payload is not None: + message["payload"] = payload + message.update(extra) + return message + + def connect_websocket(self) -> None: + """Opens the Push API business-events and market-data websockets. + + Each channel reconnects with exponential backoff and replays its + subscriptions after reconnecting. + """ + self._check_authenticated() + with self._lock: + for name, md in (("events", False), ("md", True)): + channel = self._channels.get(name) + if channel is None or not channel.running: + channel = _PushChannel(self, name, self._build_websocket_url(market_data=md)) + self._channels[name] = channel + channel.start() + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline and not self._ws_connected: + time.sleep(0.05) + if not self._ws_connected: + self._logger.warning("Push API connection not established yet; still retrying") + + @property + def _ws_connected(self) -> bool: + channel = self._channels.get("events") + return bool(channel and channel.connected) + + def disconnect_websocket(self) -> None: + with self._lock: + channels, self._channels = list(self._channels.values()), {} + for channel in channels: + channel.stop() + self._subscriptions.clear() + self._subscribed_instruments = set() + self._account_updates_subscribed = False + + def _subscribe(self, key: str, channel: str, msg_type: str, payload: JSON) -> None: + self._subscriptions[key] = (channel, msg_type, payload) + chan = self._channels.get(channel) + if chan is None: + raise WebSocketError("WebSocket not connected. Call connect_websocket() first.") + if chan.connected: + chan.send_subscription(key) + + def _unsubscribe(self, key: str, close_type: str) -> None: + entry = self._subscriptions.pop(key, None) + if entry is None: + return + chan = self._channels.get(entry[0]) + request_id = chan.request_ids.pop(key, None) if chan else None + if chan and chan.connected and request_id: + chan.send(self._envelope(close_type, refRequestId=request_id)) + + def subscribe_market_data(self, instruments: List[str]) -> None: + """Quotes for the given symbols (MarketDataSubscriptionRequest on /md). + + Each quote arrives on ``price_update_queue`` as + ``{"type": "price_update", "instrument", "bid", "ask", "spread", "time"}``. + """ + if not self._channels: + raise WebSocketError("WebSocket not connected. Call connect_websocket() first.") + for symbol in instruments: + if symbol in self._subscribed_instruments: + continue + payload: JSON = {"symbols": [symbol], + "eventTypes": [{"type": "Quote", "format": "COMPACT"}]} + if self._account_id: + payload["account"] = self._account_id + self._subscribe(f"md:{symbol}", "md", "MarketDataSubscriptionRequest", payload) + self._subscribed_instruments.add(symbol) + + def unsubscribe_market_data(self, instruments: List[str]) -> None: + if not self._channels: + raise WebSocketError("WebSocket not connected. Call connect_websocket() first.") + for symbol in instruments: + if symbol in self._subscribed_instruments: + self._unsubscribe(f"md:{symbol}", "MarketDataCloseSubscriptionRequest") + self._subscribed_instruments.discard(symbol) + + def subscribe_account_updates(self) -> None: + """Portfolio and metrics updates for the account. + + ``AccountPortfolios`` messages put each order on ``order_update_queue`` and + the portfolio on ``account_update_queue``; ``AccountMetrics`` messages put + equity/margin updates on ``account_update_queue``. + """ + if not self._channels: + raise WebSocketError("WebSocket not connected. Call connect_websocket() first.") + if self._account_updates_subscribed: + return + accounts = [self._get_account_id()] + self._subscribe("portfolio", "events", "AccountPortfoliosSubscriptionRequest", + {"requestType": "LIST", "accounts": accounts}) + self._subscribe("metrics", "events", "AccountMetricsSubscriptionRequest", + {"requestType": "LIST", "accounts": accounts, "includePositions": "true"}) + self._account_updates_subscribed = True + + def _handle_push_message(self, channel: "_PushChannel", raw: str) -> None: + try: + message = json.loads(raw) + except ValueError: + self._logger.error("Push API sent non-JSON data (%d bytes)", len(raw)) + return + msg_type = message.get("type") + payload = message.get("payload") or {} + if msg_type == "PingRequest": + # The server closes the session if it gets no Ping back. + channel.send({"type": "Ping", "timestamp": _utc_now(), "session": self._auth_token}) + elif msg_type == "MarketData": + for event in payload.get("events") or []: + if event.get("type", "Quote") != "Quote": + continue + update: JSON = {"type": "price_update", "instrument": event.get("symbol"), + "time": event.get("time")} + for field in ("bid", "ask"): + if event.get(field) is not None: + update[field] = float(event[field]) + if "bid" in update and "ask" in update: + update["spread"] = update["ask"] - update["bid"] + self.price_update_queue.put(update) + elif msg_type == "AccountPortfolios": + for portfolio in payload.get("portfolios") or []: + self.account_update_queue.put({"type": "portfolio", **portfolio}) + for order in portfolio.get("orders") or []: + try: + parsed = parse_order(order).__dict__ + except DXTradeAPIError: + parsed = {} + self.order_update_queue.put({"type": "order_update", **parsed, "raw": order}) + elif msg_type == "AccountMetrics": + for metrics in payload.get("metrics") or []: + try: + balance = parse_balance(metrics).__dict__ + except DXTradeAPIError: + balance = {} + self.account_update_queue.put({"type": "account_update", **balance, + "raw": metrics}) + elif msg_type == "Reject": + self._logger.error( + "Push API rejected request %s: error %s %s", message.get("inReplyTo"), + payload.get("errorCode"), payload.get("description"), + ) + if str(payload.get("errorCode")) == "1" and self._auto_relogin: + # Session expired: log in again and resubscribe on the new token. + try: + self._relogin() + channel.resubscribe() + except Exception as exc: # noqa: BLE001 + self._logger.error("Re-login after Push reject failed: %s", exc) + else: + self._logger.debug("Unhandled Push API message type %s", msg_type) + + +class _PushChannel: + """One Push API websocket with reconnect/backoff and subscription replay.""" + + def __init__(self, client: DXTradeDashboardWrapper, name: str, url: str): + self.client = client + self.name = name + self.url = url + self.connected = False + self.running = False + self.request_ids: Dict[str, str] = {} + self._ws: Optional[websocket.WebSocketApp] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + + def start(self) -> None: + self.running = True + self._stop.clear() + self._thread = threading.Thread(target=self._run, name=f"DXTradePush-{self.name}", + daemon=True) + self._thread.start() + + def stop(self) -> None: + self.running = False + self._stop.set() + if self._ws is not None: + try: + self._ws.close() + except Exception: # noqa: BLE001, S110 - closing a dead socket + pass + if self._thread and self._thread is not threading.current_thread(): + self._thread.join(timeout=2) + self.connected = False + + def _run(self) -> None: + failures = 0 + log = self.client._logger + while not self._stop.is_set(): + opened = threading.Event() + + def on_open(ws: websocket.WebSocketApp, opened: threading.Event = opened) -> None: + opened.set() + self._on_open() + + self._ws = websocket.WebSocketApp( + self.url, + header=self.client._build_websocket_headers(), + on_open=on_open, + on_message=lambda ws, msg: self.client._handle_push_message(self, msg), + on_error=lambda ws, err: log.warning("Push %s error: %s", self.name, err), + on_close=lambda ws, code, msg: self._on_close(code, msg), + ) + log.info("Connecting Push API %s channel: %s", self.name, self.url) + try: + self._ws.run_forever(ping_interval=30, ping_timeout=10) + except Exception as exc: # noqa: BLE001 - keep reconnecting + log.error("Push %s channel crashed: %s", self.name, exc) + self.connected = False + if self._stop.is_set(): + break + failures = 0 if opened.is_set() else failures + 1 + delay = min(2 ** failures, 30) + log.warning("Push %s channel closed; reconnecting in %ss", self.name, delay) + self._stop.wait(delay) + + def _on_open(self) -> None: + self.connected = True + self.client._logger.info("Push API %s channel connected", self.name) + self.resubscribe() + + def _on_close(self, code: Any, msg: Any) -> None: + self.connected = False + self.client._logger.info("Push %s channel closed (%s %s)", self.name, code, msg) + + def resubscribe(self) -> None: + for key, (channel, _, _) in list(self.client._subscriptions.items()): + if channel == self.name: + self.send_subscription(key) + + def send_subscription(self, key: str) -> None: + channel, msg_type, payload = self.client._subscriptions[key] + message = self.client._envelope(msg_type, payload) + self.request_ids[key] = message["requestId"] + self.send(message) + + def send(self, message: JSON) -> None: + if self._ws is None: + raise WebSocketError(f"Push {self.name} channel is not open") + try: + self._ws.send(json.dumps(message)) + except Exception as exc: # noqa: BLE001 + raise WebSocketError(f"Failed to send {message.get('type')}: {exc}") from exc + + +def _version() -> str: + from . import __version__ + + return __version__ diff --git a/dxtrade_wrapper/exceptions.py b/dxtrade_wrapper/exceptions.py new file mode 100644 index 0000000..fb2e327 --- /dev/null +++ b/dxtrade_wrapper/exceptions.py @@ -0,0 +1,107 @@ +"""Exception hierarchy for dxtrade_wrapper. + +Every error the client raises derives from :class:`DXTradeWrapperError`. +Errors that come back from the server derive from :class:`DXTradeAPIError` and +carry the HTTP status plus the DXtrade application error (``errorCode`` and +``description`` from the REST API's error body), so callers can branch on +``exc.error_code`` instead of parsing messages. + +Messages never contain credentials or session tokens. +""" + +from typing import Any, Optional + + +class DXTradeWrapperError(Exception): + """Base exception for all DXTradeDashboardWrapper errors.""" + + +class DXTradeAPIError(DXTradeWrapperError): + """An error returned by (or about a response from) the DXtrade REST API. + + Attributes: + status_code: HTTP status, or None when no response was involved. + error_code: DXtrade ``errorCode`` from the error body, if any. + description: DXtrade ``description`` from the error body, if any. + body: The decoded error body (dict) or raw text, if any. + """ + + def __init__( + self, + message: str, + *, + status_code: Optional[int] = None, + error_code: Optional[str] = None, + description: Optional[str] = None, + body: Any = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.error_code = error_code + self.description = description + self.body = body + + +class AuthenticationError(DXTradeAPIError): + """Not logged in, bad credentials (401), or API access not enabled (403).""" + + +class NotFoundError(DXTradeAPIError): + """404. DXtrade also uses this for 'not permitted' (see the REST spec).""" + + +class ConflictError(DXTradeAPIError): + """409: the request was understood but rejected by business rules.""" + + +class PreconditionFailedError(DXTradeAPIError): + """412: the If-Match ETag no longer matches; re-read and retry.""" + + +class RateLimitError(DXTradeAPIError): + """429: too many requests. ``retry_after`` is seconds, if the server said.""" + + def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwargs: Any): + super().__init__(message, **kwargs) + self.retry_after = retry_after + + +class ServerError(DXTradeAPIError): + """5xx from the server.""" + + +class OrderPlacementError(DXTradeAPIError): + """Placing an order failed. + + Attributes: + order_code: The client ``orderCode`` that was sent. When ``ambiguous`` is + True (timeout / connection drop after sending) the order may or may + not exist on the server: look it up by this code, or resend with the + same ``orderCode`` - DXtrade rejects a duplicate code with 409 / + error 100 rather than opening a second position. + ambiguous: True when the outcome is unknown. + """ + + def __init__( + self, + message: str, + *, + order_code: Optional[str] = None, + ambiguous: bool = False, + **kwargs: Any, + ) -> None: + super().__init__(message, **kwargs) + self.order_code = order_code + self.ambiguous = ambiguous + + +class ConnectionError(DXTradeWrapperError): # noqa: A001 - kept for backward compatibility + """Transport failure: DNS, TCP, TLS or timeout. No server response.""" + + +#: Alias that does not shadow the builtin ``ConnectionError``. +DXTradeConnectionError = ConnectionError + + +class WebSocketError(DXTradeWrapperError): + """Push API (WebSocket) errors.""" diff --git a/dxtrade_wrapper/models.py b/dxtrade_wrapper/models.py new file mode 100644 index 0000000..1611443 --- /dev/null +++ b/dxtrade_wrapper/models.py @@ -0,0 +1,208 @@ +"""Typed results and parsers for DXtrade REST payloads. + +Field mappings follow the public DXtrade REST API specification +(https://demo.dx.trade/developers/#/DXtrade-REST-API) and its OpenAPI document +(https://demo.dx.trade/dxsca-web/swagger/openapi.json). They have not been +checked against a live broker session. + +Parsers are strict: a payload that does not have the documented shape raises +:class:`~dxtrade_wrapper.exceptions.DXTradeAPIError` instead of quietly +returning zeros, because a trader acting on a fake zero balance is worse off +than one who sees an error. +""" + +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Union + +from .exceptions import DXTradeAPIError + +JSON = Dict[str, Any] + + +@dataclass +class Balance: + """Account balance and margin figures (from ``/accounts/{account}/metrics``).""" + + equity: float + balance: float + margin: float + free_margin: float + #: equity / margin * 100, computed client-side; None when margin is zero. + margin_level: Optional[float] = None + currency: Optional[str] = None + available_funds: Optional[float] = None + open_pl: Optional[float] = None + total_pl: Optional[float] = None + account: Optional[str] = None + + +@dataclass +class Position: + """An open position (``Position`` in the REST spec).""" + + position_id: str + instrument: str + quantity: float + side: str # 'BUY' or 'SELL' + entry_price: float + current_price: Optional[float] = None + pnl: Optional[float] = None + open_time: Optional[str] = None + stop_loss: Optional[float] = None + take_profit: Optional[float] = None + account: Optional[str] = None + + +@dataclass +class Order: + """A working order (``Order`` in the REST spec). + + ``order_id`` is the server's order code, which ``cancel_order`` and + ``modify_order`` accept. ``client_order_id`` is the ``orderCode`` the client + sent when placing it; those methods accept it too. + """ + + order_id: str + instrument: str + quantity: float + side: str # 'BUY' or 'SELL' + order_type: str # 'MARKET', 'LIMIT', 'STOP' + status: str # 'ACCEPTED', 'WORKING', 'COMPLETED', 'CANCELED', 'EXPIRED', 'REJECTED' + price: Optional[float] = None # limit or stop price + stop_loss: Optional[float] = None + take_profit: Optional[float] = None + created_time: Optional[str] = None + position_effect: Optional[str] = None # 'OPEN' or 'CLOSE' + tif: Optional[str] = None + position_code: Optional[str] = None + client_order_id: Optional[str] = None + server_order_id: Optional[int] = None + final_status: Optional[bool] = None + account: Optional[str] = None + + +def account_code(value: Union[str, Mapping[str, Any], None]) -> Optional[str]: + """Normalises an account code to the documented ``clearing:account`` string. + + The prose spec says account codes are strings; the OpenAPI schema models + them as ``{"clearing": ..., "account": ...}``. Both are accepted. + """ + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, Mapping) and "account" in value: + clearing = value.get("clearing") + return f"{clearing}:{value['account']}" if clearing else str(value["account"]) + raise DXTradeAPIError(f"Unrecognised account code: {value!r}") + + +def _opt_num(value: Any, field: str) -> Optional[float]: + """DXtrade numbers may arrive as JSON numbers or as strings ("1.5", "+INF").""" + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + raise DXTradeAPIError(f"Field {field!r} is not a number: {value!r}") from None + + +def _num(value: Any, field: str) -> float: + number = _opt_num(value, field) + if number is None: + raise DXTradeAPIError(f"Missing numeric field {field!r} in response") + return number + + +def _bool(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + return str(value).lower() == "true" + + +def unwrap_list(data: Any, key: str) -> List[JSON]: + """Returns ``data[key]`` for a ``{"key": [...]}`` envelope. + + Every DXtrade list endpoint wraps its items (``{"positions": [...]}``, + ``{"orders": [...]}``...). A bare list is tolerated as well. + """ + if isinstance(data, list): + items = data + elif isinstance(data, Mapping) and isinstance(data.get(key, []), list): + items = data.get(key, []) + else: + raise DXTradeAPIError(f"Expected a {{{key!r}: [...]}} object, got {type(data).__name__}") + for item in items: + if not isinstance(item, Mapping): + raise DXTradeAPIError(f"Expected objects in {key!r}, got {type(item).__name__}") + return list(items) + + +def parse_balance(metrics: Mapping[str, Any], currency: Optional[str] = None) -> Balance: + """Builds a Balance from one ``AccountMetrics`` object.""" + equity = _num(metrics.get("equity"), "equity") + margin = _num(metrics.get("margin"), "margin") + # The prose spec spells these openPl/totalPl, the OpenAPI schema openPL/totalPL. + open_pl = metrics.get("openPL", metrics.get("openPl")) + total_pl = metrics.get("totalPL", metrics.get("totalPl")) + return Balance( + equity=equity, + balance=_num(metrics.get("balance"), "balance"), + margin=margin, + free_margin=_num(metrics.get("marginFree"), "marginFree"), + margin_level=(equity / margin * 100.0) if margin else None, + currency=currency, + available_funds=_opt_num(metrics.get("availableFunds"), "availableFunds"), + open_pl=_opt_num(open_pl, "openPL"), + total_pl=_opt_num(total_pl, "totalPL"), + account=account_code(metrics.get("account")), + ) + + +def parse_position(data: Mapping[str, Any]) -> Position: + try: + return Position( + position_id=str(data["positionCode"]), + instrument=str(data["symbol"]), + quantity=_num(data.get("quantity"), "quantity"), + side=str(data["side"]), + entry_price=_num(data.get("openPrice"), "openPrice"), + open_time=data.get("openTime"), + stop_loss=_opt_num(data.get("stopLossPrice"), "stopLossPrice"), + take_profit=_opt_num(data.get("takeProfitPrice"), "takeProfitPrice"), + account=account_code(data.get("account")), + ) + except KeyError as exc: + raise DXTradeAPIError(f"Position is missing field {exc.args[0]!r}") from None + + +def order_leg(data: Mapping[str, Any]) -> Mapping[str, Any]: + """The single leg of an order (the spec says ``legs`` has exactly one).""" + legs = data.get("legs") or [{}] + return legs[0] if isinstance(legs[0], Mapping) else {} + + +def parse_order(data: Mapping[str, Any]) -> Order: + leg = order_leg(data) + try: + return Order( + order_id=str(data["orderCode"]), + instrument=str(data.get("instrument") or leg.get("instrument", "")), + quantity=_num(leg.get("quantity", 0), "legs[0].quantity"), + side=str(data.get("side") or leg.get("side", "")), + order_type=str(data["type"]), + status=str(data["status"]), + price=_opt_num(leg.get("price"), "legs[0].price"), + created_time=data.get("issueTime"), + position_effect=leg.get("positionEffect"), + tif=data.get("tif"), + position_code=leg.get("positionCode"), + client_order_id=data.get("clientOrderId"), + server_order_id=data.get("orderId"), + final_status=_bool(data.get("finalStatus")), + account=account_code(data.get("account")), + ) + except KeyError as exc: + raise DXTradeAPIError(f"Order is missing field {exc.args[0]!r}") from None diff --git a/dxtrade_wrapper/py.typed b/dxtrade_wrapper/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4e182d2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +"""Shared fixtures. All HTTP is mocked with `responses`; nothing touches a broker. + +Fixture JSON in tests/fixtures/ follows the shapes in Devexperts' public +DXtrade REST spec / OpenAPI document. test_spec_conformance.py (opt-in, needs +network) validates both these fixtures and the requests the client sends +against that OpenAPI document. +""" + +import json +import pathlib +import sys + +import pytest +import responses + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from dxtrade_wrapper import DXTradeDashboardWrapper # noqa: E402 + +BASE_URL = "https://dxtrade.example-broker.com" +PREFIX = "/dxsca-web" +API = f"{BASE_URL}{PREFIX}" +ACCOUNT = "default:ACC12345" +ACCOUNT_ENC = "default%3AACC12345" +ACC = f"{API}/accounts/{ACCOUNT_ENC}" +FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" +PASSWORD = "s3cret-Pa55word" +TOKEN = "b3f1c2d4-0000-4a1b-9c8d-EXAMPLE-TOKEN" + + +def fixture(name): + return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) + + +def make_wrapper(**kwargs): + """A client with keepalive disabled so tests spawn no threads.""" + params = dict( + base_url=BASE_URL, + username="user", + password=PASSWORD, + domain_or_vendor="default", + keepalive_interval=None, + ) + params.update(kwargs) + return DXTradeDashboardWrapper(**params) + + +def body(call): + return json.loads(call.request.body) if call.request.body else None + + +@pytest.fixture +def api(): + """A responses mock that fails the test on any unmocked request.""" + with responses.RequestsMock(assert_all_requests_are_fired=False) as mock: + yield mock + + +@pytest.fixture +def client(api): + """A logged-in client with the account already known.""" + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) + wrapper = make_wrapper(account=ACCOUNT) + wrapper.login() + return wrapper diff --git a/tests/fixtures/error_bad_credentials.json b/tests/fixtures/error_bad_credentials.json new file mode 100644 index 0000000..e6528e2 --- /dev/null +++ b/tests/fixtures/error_bad_credentials.json @@ -0,0 +1,4 @@ +{ + "errorCode": "3", + "description": "Incorrect username or password" +} diff --git a/tests/fixtures/group_response.json b/tests/fixtures/group_response.json new file mode 100644 index 0000000..dd61d70 --- /dev/null +++ b/tests/fixtures/group_response.json @@ -0,0 +1,7 @@ +{ + "orderResponses": [ + {"orderId": 63649, "updateOrderId": 63649}, + {"orderId": 63650, "updateOrderId": 63650}, + {"orderId": 63651, "updateOrderId": 63651} + ] +} diff --git a/tests/fixtures/history.json b/tests/fixtures/history.json new file mode 100644 index 0000000..8cab447 --- /dev/null +++ b/tests/fixtures/history.json @@ -0,0 +1,24 @@ +{ + "orders": [ + { + "account": "default:ACC12345", + "orderId": 63649, + "orderCode": "3a4-XYZ", + "version": 41, + "clientOrderId": "dxpw-entry-1", + "actionCode": "act-0", + "legCount": 1, + "type": "MARKET", + "instrument": "EUR/USD", + "status": "COMPLETED", + "finalStatus": true, + "legs": [{"instrument": "EUR/USD", "positionEffect": "OPEN", "side": "BUY", + "quantity": 100000, "filledQuantity": 100000, "averagePrice": 1.0850}], + "side": "BUY", + "tif": "GTC", + "issueTime": "2026-09-30T08:00:00.000Z", + "transactionTime": "2026-09-30T08:00:00.100Z" + } + ], + "nextPageTransactionTime": "2026-09-30T07:00:00.000Z" +} diff --git a/tests/fixtures/login_no_account.json b/tests/fixtures/login_no_account.json deleted file mode 100644 index 896c14d..0000000 --- a/tests/fixtures/login_no_account.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "sessionToken": "b3f1c2d4-0000-4a1b-9c8d-EXAMPLE-TOKEN" -} diff --git a/tests/fixtures/login_success.json b/tests/fixtures/login_success.json index 6f936df..57c0da1 100644 --- a/tests/fixtures/login_success.json +++ b/tests/fixtures/login_success.json @@ -1,4 +1,4 @@ { "sessionToken": "b3f1c2d4-0000-4a1b-9c8d-EXAMPLE-TOKEN", - "accountCode": "default:ACC12345" + "timeout": "00:30:00" } diff --git a/tests/fixtures/metrics.json b/tests/fixtures/metrics.json new file mode 100644 index 0000000..5e245b7 --- /dev/null +++ b/tests/fixtures/metrics.json @@ -0,0 +1,20 @@ +{ + "metrics": [ + { + "account": "default:ACC12345", + "version": 42, + "equity": 100250.0, + "balance": 100000.0, + "availableBalance": 98500.0, + "availableFunds": 98750.0, + "allocatedFunds": 1500.0, + "credit": 0, + "marginFree": 98750.0, + "openPL": 250.0, + "totalPL": 250.0, + "margin": 1500.0, + "openPositionsCount": 1, + "openOrdersCount": 2 + } + ] +} diff --git a/tests/fixtures/order_accepted.json b/tests/fixtures/order_accepted.json deleted file mode 100644 index 69d395b..0000000 --- a/tests/fixtures/order_accepted.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "orderId": "ORD-0001", - "orderCode": "dxpw-EXAMPLE", - "status": "ACCEPTED", - "positionCode": "POS-0001" -} diff --git a/tests/fixtures/order_response.json b/tests/fixtures/order_response.json new file mode 100644 index 0000000..85930d0 --- /dev/null +++ b/tests/fixtures/order_response.json @@ -0,0 +1,4 @@ +{ + "orderId": 63649, + "updateOrderId": 63649 +} diff --git a/tests/fixtures/orders.json b/tests/fixtures/orders.json new file mode 100644 index 0000000..4b45437 --- /dev/null +++ b/tests/fixtures/orders.json @@ -0,0 +1,63 @@ +{ + "orders": [ + { + "account": "default:ACC12345", + "orderId": 63655, + "orderCode": "3a4-ABC", + "version": 42, + "clientOrderId": "dxpw-tp-1", + "actionCode": "act-1", + "legCount": 1, + "type": "LIMIT", + "instrument": "EUR/USD", + "status": "WORKING", + "finalStatus": false, + "legs": [ + { + "instrument": "EUR/USD", + "positionEffect": "CLOSE", + "positionCode": "63649", + "side": "SELL", + "price": 1.0950, + "legRatio": 1, + "quantity": 0, + "filledQuantity": 0, + "remainingQuantity": 0 + } + ], + "side": "SELL", + "tif": "GTC", + "issueTime": "2026-09-30T08:00:01.000Z", + "transactionTime": "2026-09-30T08:00:01.000Z" + }, + { + "account": "default:ACC12345", + "orderId": 63700, + "orderCode": "3a4-DEF", + "version": 42, + "clientOrderId": "dxpw-entry-2", + "actionCode": "act-2", + "legCount": 1, + "type": "STOP", + "instrument": "GBP/USD", + "status": "WORKING", + "finalStatus": false, + "legs": [ + { + "instrument": "GBP/USD", + "positionEffect": "OPEN", + "side": "BUY", + "price": 1.3100, + "legRatio": 1, + "quantity": 5000, + "filledQuantity": 0, + "remainingQuantity": 5000 + } + ], + "side": "BUY", + "tif": "GTC", + "issueTime": "2026-09-30T08:05:00.000Z", + "transactionTime": "2026-09-30T08:05:00.000Z" + } + ] +} diff --git a/tests/fixtures/portfolio.json b/tests/fixtures/portfolio.json deleted file mode 100644 index 0c53af5..0000000 --- a/tests/fixtures/portfolio.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "balances": [ - { - "currency": "USD", - "value": 100000.0, - "availableFunds": 98500.0, - "equity": 100250.0, - "margin": 1500.0, - "marginUsed": 1500.0, - "unrealizedPnl": 250.0 - } - ] -} diff --git a/tests/fixtures/positions.json b/tests/fixtures/positions.json new file mode 100644 index 0000000..b2db272 --- /dev/null +++ b/tests/fixtures/positions.json @@ -0,0 +1,17 @@ +{ + "positions": [ + { + "account": "default:ACC12345", + "version": 42, + "positionCode": "63649", + "symbol": "EUR/USD", + "quantity": 100000, + "quantityNotional": 100000, + "side": "BUY", + "openTime": "2026-09-30T08:00:00.000Z", + "openPrice": 1.0850, + "takeProfitPrice": 1.0950, + "lastUpdateTime": "2026-09-30T08:00:01.000Z" + } + ] +} diff --git a/tests/fixtures/users.json b/tests/fixtures/users.json new file mode 100644 index 0000000..55255f6 --- /dev/null +++ b/tests/fixtures/users.json @@ -0,0 +1,23 @@ +{ + "userDetails": [ + { + "login": "user", + "domain": "default", + "username": "user@default", + "version": 7, + "fullName": "Test User", + "accounts": [ + { + "owner": "user@default", + "account": "default:ACC12345", + "version": 7, + "baseCurrency": "USD", + "registrationTime": "2026-01-02T10:00:00.000Z", + "accountStatus": "FULL_TRADING", + "pricingStream": "default", + "positionBased": true + } + ] + } + ] +} diff --git a/tests/test_account_data.py b/tests/test_account_data.py new file mode 100644 index 0000000..6086d44 --- /dev/null +++ b/tests/test_account_data.py @@ -0,0 +1,161 @@ +"""Account discovery and read endpoints, parsed per the spec's field names.""" + +import pytest +import responses + +from conftest import ACC, ACCOUNT, API, fixture, make_wrapper +from dxtrade_wrapper import DXTradeAPIError + + +@pytest.fixture +def anon_client(api): + """Logged in, but no account configured: it must be discovered.""" + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) + wrapper = make_wrapper() + wrapper.login() + return wrapper + + +def test_single_account_is_discovered_from_users_endpoint(anon_client, api): + api.add(responses.GET, f"{API}/users/user%40default", json=fixture("users")) + api.add(responses.GET, f"{ACC}/metrics", json=fixture("metrics")) + + balance = anon_client.get_balance() + + assert anon_client._account_id == ACCOUNT + assert balance.currency == "USD" # from the account's baseCurrency + + +def test_full_username_is_used_as_is(api): + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) + api.add(responses.GET, f"{API}/users/trader%40prop", json=fixture("users")) + wrapper = make_wrapper(username="trader@prop") + wrapper.login() + assert wrapper.get_accounts()[0]["account"] == ACCOUNT + + +def test_several_accounts_require_an_explicit_choice(anon_client, api): + users = fixture("users") + second = dict(users["userDetails"][0]["accounts"][0], account="default:ACC99999") + users["userDetails"][0]["accounts"].append(second) + api.add(responses.GET, f"{API}/users/user%40default", json=users) + + with pytest.raises(DXTradeAPIError) as excinfo: + anon_client.get_positions() + message = str(excinfo.value) + assert "default:ACC12345" in message and "default:ACC99999" in message + assert "account=" in message + + +def test_no_accounts_raises_instead_of_inventing_one(anon_client, api): + users = fixture("users") + users["userDetails"][0]["accounts"] = [] + api.add(responses.GET, f"{API}/users/user%40default", json=users) + with pytest.raises(DXTradeAPIError) as excinfo: + anon_client.get_positions() + assert "primary" not in str(excinfo.value) + + +def test_object_shaped_account_codes_are_normalised(anon_client, api): + """The OpenAPI schema models AccountCode as {clearing, account}.""" + users = fixture("users") + users["userDetails"][0]["accounts"][0]["account"] = {"clearing": "default", + "account": "ACC12345"} + api.add(responses.GET, f"{API}/users/user%40default", json=users) + assert anon_client.get_accounts()[0]["account"] == ACCOUNT + + +def test_balance_comes_from_account_metrics(client, api): + api.add(responses.GET, f"{ACC}/metrics", json=fixture("metrics")) + balance = client.get_balance() + + assert balance.equity == 100250.0 + assert balance.balance == 100000.0 + assert balance.margin == 1500.0 + assert balance.free_margin == 98750.0 + assert balance.available_funds == 98750.0 + assert balance.open_pl == 250.0 + assert balance.margin_level == pytest.approx(100250.0 / 1500.0 * 100) + assert balance.account == ACCOUNT + + +def test_zero_margin_gives_no_margin_level(client, api): + metrics = fixture("metrics") + metrics["metrics"][0]["margin"] = 0 + api.add(responses.GET, f"{ACC}/metrics", json=metrics) + assert client.get_balance().margin_level is None + + +def test_malformed_metrics_raise_instead_of_returning_zeros(client, api): + """The old parser returned Balance(0, 0, 0, 0) on any parse problem.""" + api.add(responses.GET, f"{ACC}/metrics", json={"metrics": [{"account": ACCOUNT}]}) + with pytest.raises(DXTradeAPIError, match="equity"): + client.get_balance() + + +def test_numbers_sent_as_strings_are_accepted(client, api): + metrics = fixture("metrics") + metrics["metrics"][0].update(equity="100250.5", margin="1500", balance="1e5", + marginFree="98750") + api.add(responses.GET, f"{ACC}/metrics", json=metrics) + assert client.get_balance().equity == 100250.5 + + +def test_positions_are_parsed_from_the_documented_fields(client, api): + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + [position] = client.get_positions() + + assert position.position_id == "63649" + assert position.instrument == "EUR/USD" + assert position.quantity == 100000 + assert position.side == "BUY" + assert position.entry_price == 1.0850 + assert position.take_profit == 1.0950 + assert position.stop_loss is None + assert position.open_time == "2026-09-30T08:00:00.000Z" + + +def test_position_missing_required_field_raises(client, api): + api.add(responses.GET, f"{ACC}/positions", json={"positions": [{"symbol": "EUR/USD"}]}) + with pytest.raises(DXTradeAPIError, match="positionCode"): + client.get_positions() + + +def test_orders_are_parsed_from_order_and_leg(client, api): + api.add(responses.GET, f"{ACC}/orders", json=fixture("orders")) + tp, entry = client.get_orders() + + assert tp.order_id == "3a4-ABC" + assert tp.client_order_id == "dxpw-tp-1" + assert tp.server_order_id == 63655 + assert tp.order_type == "LIMIT" + assert tp.price == 1.0950 + assert tp.position_effect == "CLOSE" + assert tp.position_code == "63649" + assert tp.final_status is False + assert entry.quantity == 5000 + assert entry.side == "BUY" + assert entry.status == "WORKING" + + +def test_non_envelope_payload_is_rejected(client, api): + api.add(responses.GET, f"{ACC}/orders", json={"orders": "nope"}) + with pytest.raises(DXTradeAPIError): + client.get_orders() + + +def test_order_history_uses_documented_path_and_filters(client, api): + api.add(responses.GET, f"{ACC}/orders/history", json=fixture("history")) + history = client.get_order_history(limit=50, in_status="COMPLETED", period="today") + + assert history[0]["status"] == "COMPLETED" + url = api.calls[-1].request.url + assert "/orders/history?" in url + assert "limit=50" in url and "in-status=COMPLETED" in url and "period=today" in url + + +def test_portfolio_is_unwrapped(client, api): + api.add(responses.GET, f"{ACC}/portfolio", + json={"portfolios": [{"account": ACCOUNT, "version": 1, "balances": [], + "positions": [], "orders": []}]}) + assert client.get_portfolio()["account"] == ACCOUNT diff --git a/tests/test_orders.py b/tests/test_orders.py new file mode 100644 index 0000000..6012fe6 --- /dev/null +++ b/tests/test_orders.py @@ -0,0 +1,282 @@ +"""Order management per the REST spec: placement, protections, modify, cancel, close.""" + +import pytest +import requests +import responses +from responses import matchers + +from conftest import ACC, ACCOUNT, API, body, fixture +from dxtrade_wrapper import ( + ConflictError, + DXTradeAPIError, + NotFoundError, + OrderPlacementError, + PreconditionFailedError, +) + + +# --- validation: nothing is sent for bad input -------------------------------- + +@pytest.mark.parametrize( + "kwargs, match", + [ + (dict(side="HOLD"), "Side"), + (dict(order_type="TRAILING"), "Order type"), + (dict(order_type="LIMIT"), "Price is required"), + (dict(tif="DAY"), "MARKET orders only support"), + (dict(order_type="LIMIT", price=1.1, tif="GTD"), "expireDate"), + (dict(position_effect="CLOSE"), "positionCode"), + (dict(position_effect="CLOSE", positionCode="1", stop_loss=1.0), "OPEN order"), + (dict(quantity=0), "positive"), + ], +) +def test_invalid_orders_are_rejected_locally(client, api, kwargs, match): + args = dict(instrument="EUR/USD", side="BUY", quantity=1000, order_type="MARKET") + args.update(kwargs) + with pytest.raises(ValueError, match=match): + client.place_order(**args) + assert len(api.calls) == 1 # only the login + + +def test_limit_and_stop_prices_use_the_documented_fields(client, api): + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + client.place_order("EUR/USD", "SELL", 1000, "LIMIT", price=1.2) + client.place_order("EUR/USD", "SELL", 1000, "STOP", price=1.0) + limit, stop = body(api.calls[-2]), body(api.calls[-1]) + assert limit["limitPrice"] == 1.2 and "stopPrice" not in limit + assert stop["stopPrice"] == 1.0 and "limitPrice" not in stop + + +def test_stop_loss_and_take_profit_are_one_atomic_if_then_group(client, api): + """The old code posted SL/TP as separate orders only if the order response + carried a positionCode. The spec's OrderResponse has only orderId and + updateOrderId, so protections were silently never placed.""" + api.add(responses.POST, f"{ACC}/orders", json=fixture("group_response")) + result = client.place_order("EUR/USD", "BUY", 100000, "MARKET", + stop_loss=1.05, take_profit=1.15) + + assert len(api.calls) == 2 # login + ONE order request + group = body(api.calls[-1]) + assert group["contingencyType"] == "IF-THEN" + entry, sl, tp = group["orders"] + assert entry["positionEffect"] == "OPEN" and entry["quantity"] == 100000 + for leg, kind, field, price in ((sl, "STOP", "stopPrice", 1.05), + (tp, "LIMIT", "limitPrice", 1.15)): + assert leg["type"] == kind and leg[field] == price + assert leg["positionEffect"] == "CLOSE" + assert leg["side"] == "SELL" + assert leg["quantity"] == 0 + assert leg["tif"] == "GTC" + assert leg["instrument"] == "EUR/USD" and leg["account"] == ACCOUNT + assert len({o["orderCode"] for o in group["orders"]}) == 3 + assert len(result["orderResponses"]) == 3 + + +def test_stop_loss_only_group_has_two_orders(client, api): + api.add(responses.POST, f"{ACC}/orders", json=fixture("group_response")) + client.place_order("EUR/USD", "SELL", 1000, "MARKET", stop_loss=1.2) + entry, sl = body(api.calls[-1])["orders"] + assert sl["type"] == "STOP" and sl["side"] == "BUY" + + +def test_rejected_protected_order_raises_and_places_nothing_else(client, api): + api.add(responses.POST, f"{ACC}/orders", status=400, + json={"errorCode": "33", "description": "Incorrect request (groups need a " + "position-based account)"}) + with pytest.raises(OrderPlacementError) as excinfo: + client.place_order("EUR/USD", "BUY", 1000, "MARKET", stop_loss=1.05) + assert excinfo.value.error_code == "33" + assert excinfo.value.ambiguous is False + assert len(api.calls) == 2 + + +def test_business_rejection_carries_status_and_code(client, api): + api.add(responses.POST, f"{ACC}/orders", status=409, + json={"errorCode": "100", "description": "Order with this id already exists"}) + with pytest.raises(OrderPlacementError) as excinfo: + client.place_order("EUR/USD", "BUY", 1000, "MARKET", orderCode="dup-1") + exc = excinfo.value + assert exc.status_code == 409 and exc.error_code == "100" + assert exc.order_code == "dup-1" + + +def test_read_timeout_on_placement_is_flagged_ambiguous(client, api): + api.add(responses.POST, f"{ACC}/orders", body=requests.exceptions.ReadTimeout("slow")) + with pytest.raises(OrderPlacementError) as excinfo: + client.place_order("EUR/USD", "BUY", 1000, "MARKET", orderCode="k-1") + assert excinfo.value.ambiguous is True + assert "k-1" in str(excinfo.value) + + +def test_connect_timeout_on_placement_is_not_ambiguous(client, api): + api.add(responses.POST, f"{ACC}/orders", body=requests.exceptions.ConnectTimeout("down")) + with pytest.raises(OrderPlacementError) as excinfo: + client.place_order("EUR/USD", "BUY", 1000, "MARKET") + assert excinfo.value.ambiguous is False + + +def test_order_retried_after_session_expiry_keeps_its_order_code(client, api): + api.add(responses.POST, f"{ACC}/orders", status=401, json={"errorCode": "1"}) + api.add(responses.POST, f"{API}/login", json={"sessionToken": "NEW"}) + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + client.place_order("EUR/USD", "BUY", 1000, "MARKET") + first, second = body(api.calls[1]), body(api.calls[3]) + assert first["orderCode"] == second["orderCode"] + + +# --- modify / cancel: PUT and DELETE need If-Match ------------------------------ + +def orders_with_etag(api, etag='"v42"'): + api.add(responses.GET, f"{ACC}/orders", json=fixture("orders"), headers={"ETag": etag}) + + +def test_modify_order_puts_the_complete_order_with_if_match(client, api): + orders_with_etag(api) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("order_response"), + match=[matchers.header_matcher({"If-Match": '"v42"'})]) + + client.modify_order("3a4-DEF", new_price=1.305) + + put = body(api.calls[-1]) + assert api.calls[-1].request.method == "PUT" + assert put == { + "account": ACCOUNT, + "orderCode": "dxpw-entry-2", + "instrument": "GBP/USD", + "positionEffect": "OPEN", + "side": "BUY", + "tif": "GTC", + "quantity": 5000, + "stopPrice": 1.305, + } + assert "type" not in put # the spec says omit type in replace requests + + +@pytest.mark.parametrize("identifier", ["3a4-DEF", "dxpw-entry-2", "63700"]) +def test_modify_accepts_any_order_identifier(client, api, identifier): + orders_with_etag(api) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("order_response")) + client.modify_order(identifier, new_quantity=6000) + assert body(api.calls[-1])["quantity"] == 6000 + + +def test_modify_unknown_order_raises_not_found(client, api): + orders_with_etag(api) + with pytest.raises(NotFoundError): + client.modify_order("nope", new_price=1.0) + + +def test_modify_with_nothing_to_change_is_rejected(client): + with pytest.raises(ValueError): + client.modify_order("3a4-DEF") + + +def test_stale_etag_is_refreshed_and_retried_once(client, api): + orders_with_etag(api, '"v42"') + api.add(responses.PUT, f"{ACC}/orders", status=412, + match=[matchers.header_matcher({"If-Match": '"v42"'})]) + orders_with_etag(api, '"v43"') + api.add(responses.PUT, f"{ACC}/orders", json=fixture("order_response"), + match=[matchers.header_matcher({"If-Match": '"v43"'})]) + client.modify_order("3a4-DEF", new_price=1.3) + assert api.calls[-1].response.status_code == 200 + + +def test_explicit_stale_etag_is_not_retried(client, api): + orders_with_etag(api) + api.add(responses.PUT, f"{ACC}/orders", status=412) + with pytest.raises(PreconditionFailedError): + client.modify_order("3a4-DEF", new_price=1.3, etag='"old"') + + +def test_missing_etag_is_a_clear_error(client, api): + api.add(responses.GET, f"{ACC}/orders", json=fixture("orders")) + with pytest.raises(DXTradeAPIError, match="ETag"): + client.modify_order("3a4-DEF", new_price=1.3) + + +def test_cancel_order_deletes_encoded_code_with_if_match(client, api): + orders_with_etag(api) + api.add(responses.DELETE, f"{ACC}/orders/dxpw%2Fweird%23id", + json=fixture("order_response"), + match=[matchers.header_matcher({"If-Match": '"v42"'})]) + client.cancel_order("dxpw/weird#id") + assert api.calls[-1].request.method == "DELETE" + + +def test_cancel_with_caller_etag_skips_the_lookup(client, api): + api.add(responses.DELETE, f"{ACC}/orders/3a4-DEF", body="") + assert client.cancel_order("3a4-DEF", etag='"v9"') == {"success": True, + "order_id": "3a4-DEF"} + assert len(api.calls) == 2 + + +def test_cancel_of_final_order_surfaces_conflict(client, api): + orders_with_etag(api) + api.add(responses.DELETE, f"{ACC}/orders/3a4-DEF", status=409, + json={"errorCode": "1005", "description": "Reference order is closed"}) + with pytest.raises(ConflictError, match="Reference order is closed"): + client.cancel_order("3a4-DEF") + + +# --- positions ------------------------------------------------------------------- + +def test_close_position_sends_linked_market_close(client, api): + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + client.close_position("63649") + order = body(api.calls[-1]) + assert order["type"] == "MARKET" + assert order["positionEffect"] == "CLOSE" + assert order["positionCode"] == "63649" + assert order["side"] == "SELL" + assert order["quantity"] == 100000 + assert order["instrument"] == "EUR/USD" + + +def test_partial_close_and_bounds(client, api): + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + client.close_position("63649", quantity=40000) + assert body(api.calls[-1])["quantity"] == 40000 + with pytest.raises(ValueError): + client.close_position("63649", quantity=200000) + + +def test_close_unknown_position_raises(client, api): + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + with pytest.raises(NotFoundError): + client.close_position("nope") + + +def test_close_all_uses_bulk_close(client, api): + api.add(responses.POST, f"{ACC}/close", body="") + client.close_all(instrument="EUR/USD", comment="flat before NFP") + assert body(api.calls[-1]) == {"closePositions": True, "cancelOrders": True, + "instrument": "EUR/USD", "comment": "flat before NFP"} + with pytest.raises(ValueError): + client.close_all(close_positions=False, cancel_orders=False) + + +def test_modify_sl_tp_moves_existing_tp_and_adds_missing_sl(client, api): + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + orders_with_etag(api) + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("order_response")) + + result = client.modify_position_sl_tp("63649", stop_loss=1.08, take_profit=1.10) + + assert set(result) == {"stop_loss", "take_profit"} + posted = [body(c) for c in api.calls + if c.request.method == "POST" and "orders" in c.request.url] + put = [body(c) for c in api.calls if c.request.method == "PUT"] + assert posted[0]["type"] == "STOP" and posted[0]["stopPrice"] == 1.08 + assert posted[0]["positionCode"] == "63649" and posted[0]["side"] == "SELL" + assert "quantity" not in posted[0] + assert put[0]["orderCode"] == "dxpw-tp-1" and put[0]["limitPrice"] == 1.10 + assert "quantity" not in put[0] # position-attached protection keeps no quantity + + +def test_modify_sl_tp_requires_a_value(client): + with pytest.raises(ValueError): + client.modify_position_sl_tp("63649") diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 8bda299..a999d55 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1,425 +1,99 @@ -""" -Fixture-based tests for DXTradeDashboardWrapper. - -WHAT THESE TESTS DO AND DO NOT PROVE ------------------------------------- -These tests run entirely against `responses`-mocked HTTP. They prove that the -wrapper *builds the requests it intends to build*: the right URL, the right -authorization scheme, the right payload fields. - -They prove NOTHING about whether those requests are what a DXtrade server -actually accepts. Every fixture in `tests/fixtures/` is hand-written from the -same community examples and third-party guide the wrapper itself was inferred -from - none is a recording of a real broker response, because no live session -has ever succeeded. If the inferred protocol is wrong, these tests will pass -happily while the wrapper remains broken against a real server. - -Their real purpose is regression-locking the three blocking bugs found by code -review in 2026, so they cannot silently return: +"""Regression locks for the blocking protocol bugs found in 2026. - * `test_login_uses_dxsca_web_prefix` and `test_login_sets_dxapi_auth_header` - together would have caught two of the three blockers on the first run. - * `test_every_rest_call_carries_the_api_prefix` covers the eight endpoints - that were missing the prefix entirely. - * `test_order_payload_includes_account_and_order_code` covers the third. - -Run with: python -m pytest tests/ -v +Each of these would have broken the client at every broker: the missing +/dxsca-web prefix, the Bearer scheme instead of DXAPI, and orders missing +account/orderCode. They now also pin the spec-conformant URL encoding. """ -import json -import pathlib -import sys - -import pytest import responses -sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) - -from dxtrade_wrapper import ( # noqa: E402 - DXTradeAPIError, - DXTradeDashboardWrapper, -) - -BASE_URL = "https://dxtrade.example-broker.com" -PREFIX = "/dxsca-web" -FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures" - - -def fixture(name): - """Load a recorded-shape JSON fixture by filename stem.""" - return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8")) - - -def make_wrapper(**kwargs): - """Build a wrapper with keepalive disabled so tests spawn no threads.""" - params = dict( - base_url=BASE_URL, - username="user", - password="pass", - domain_or_vendor="default", - keepalive_interval=None, - ) - params.update(kwargs) - return DXTradeDashboardWrapper(**params) - - -def register_login(body=None, status=200): - responses.add( - responses.POST, - f"{BASE_URL}{PREFIX}/login", - json=body if body is not None else fixture("login_success"), - status=status, - ) - - -@pytest.fixture -def logged_in(): - """ - A wrapper that has completed a mocked login. +from conftest import ACC, ACCOUNT, API, BASE_URL, PREFIX, body, fixture, make_wrapper - This fixture owns the responses mock itself rather than relying on a - @responses.activate decorator on the test: pytest sets fixtures up before the - decorator's context is entered, so a decorated test would let the fixture's - own login escape to the real network. Tests using this fixture must NOT also - be decorated with @responses.activate. - """ - responses.start() - try: - register_login() - wrapper = make_wrapper() - wrapper.login() - yield wrapper - finally: - responses.stop() - responses.reset() - -# -------------------------------------------------------------------------- -# Blocker 1: the /dxsca-web prefix -# -------------------------------------------------------------------------- - -@responses.activate -def test_login_uses_dxsca_web_prefix(): - """Login must POST to {base}/dxsca-web/login.""" - register_login() +def test_login_posts_documented_body_to_prefixed_url(api): + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) make_wrapper().login() - assert len(responses.calls) == 1 - assert responses.calls[0].request.url == f"{BASE_URL}{PREFIX}/login" + call = api.calls[0] + assert call.request.url == f"{API}/login" + assert body(call) == {"username": "user", "domain": "default", "password": "s3cret-Pa55word"} + assert "Authorization" not in call.request.headers -def test_every_rest_call_carries_the_api_prefix(logged_in): - """ - The regression test for the headline bug: login had the prefix and the eight - endpoints after it did not, so everything past authentication 404'd at any - broker. Each call below must land under /dxsca-web. - """ - account = "default:ACC12345" - # NOTE: the list endpoints are given bare JSON arrays because that is what - # this wrapper's parsers iterate directly - _parse_positions() and friends - # do `for x in data`, so an envelope like {"positions": [...]} would iterate - # the dict's keys and crash on `str.get`. Whether a real DXtrade server - # returns a bare array or an envelope is UNVERIFIED; if it returns an - # envelope, those parsers are a fourth bug that these tests do not catch, - # because the fixtures were written to match the wrapper's assumption. - endpoints = [ - (responses.GET, f"/accounts/{account}/portfolio", fixture("portfolio")), - (responses.GET, f"/accounts/{account}/positions", []), - (responses.GET, f"/accounts/{account}/orders", []), - (responses.GET, f"/accounts/{account}/history", []), - (responses.GET, "/ping", {}), - ] - for method, path, body in endpoints: - responses.add(method, f"{BASE_URL}{PREFIX}{path}", json=body, status=200) +def test_every_rest_call_carries_the_api_prefix(client, api): + api.add(responses.GET, f"{ACC}/metrics", json=fixture("metrics")) + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + api.add(responses.GET, f"{ACC}/orders", json=fixture("orders")) + api.add(responses.GET, f"{ACC}/orders/history", json=fixture("history")) + api.add(responses.POST, f"{API}/ping", json={}) - logged_in.get_balance() - logged_in.get_positions() - logged_in.get_orders() - logged_in.get_order_history() - logged_in.ping() + client.get_balance() + client.get_positions() + client.get_orders() + client.get_order_history() + client.ping() - # calls[0] is the login itself. - for call in responses.calls[1:]: - assert call.request.url.startswith(f"{BASE_URL}{PREFIX}/"), ( - f"{call.request.url} is missing the {PREFIX} prefix" - ) + for call in api.calls[1:]: + assert call.request.url.startswith(f"{BASE_URL}{PREFIX}/"), call.request.url -@responses.activate def test_url_helper_is_the_single_place_the_prefix_is_applied(): - """ - A custom prefix must flow to every endpoint, which is only true because all - URLs route through _url(). Eight independent f-strings could not do this. - """ wrapper = make_wrapper(api_prefix="/custom-api") assert wrapper._url("login") == f"{BASE_URL}/custom-api/login" assert wrapper._url("/accounts/A/orders") == f"{BASE_URL}/custom-api/accounts/A/orders" -@responses.activate def test_empty_prefix_serves_api_at_root(): - """A deployment with no prefix must not produce a doubled slash.""" - wrapper = make_wrapper(api_prefix="") - assert wrapper._url("login") == f"{BASE_URL}/login" + assert make_wrapper(api_prefix="")._url("login") == f"{BASE_URL}/login" def test_login_path_with_embedded_prefix_is_not_doubled(): - """ - Existing .env files say DXTRADE_LOGIN_PATH=/dxsca-web/login. That must not - become /dxsca-web/dxsca-web/login now that the prefix is applied separately. - """ wrapper = make_wrapper(login_path="/dxsca-web/login") - assert wrapper._url(wrapper._login_path) == f"{BASE_URL}{PREFIX}/login" - - -# -------------------------------------------------------------------------- -# Blocker 2: the DXAPI authorization scheme -# -------------------------------------------------------------------------- - -@responses.activate -def test_login_sets_dxapi_auth_header(): - """ - DXtrade SCA expects `Authorization: DXAPI `. The wrapper sent - `Bearer`, so every authenticated request would have 401'd. - """ - register_login() - wrapper = make_wrapper() - wrapper.login() - - auth = wrapper._session.headers["Authorization"] - assert auth.startswith("DXAPI "), f"expected DXAPI scheme, got {auth!r}" - assert "Bearer" not in auth - + assert wrapper._url(wrapper._login_path) == f"{API}/login" -def test_authenticated_requests_send_dxapi_header(logged_in): - """The scheme must actually reach the wire, not just the session object.""" - responses.add( - responses.GET, - f"{BASE_URL}{PREFIX}/accounts/default:ACC12345/portfolio", - json=fixture("portfolio"), - status=200, - ) - logged_in.get_balance() - sent = responses.calls[-1].request.headers["Authorization"] - assert sent.startswith("DXAPI ") +def test_authenticated_requests_send_dxapi_header(client, api): + api.add(responses.GET, f"{ACC}/metrics", json=fixture("metrics")) + client.get_balance() + sent = api.calls[-1].request.headers["Authorization"] + assert sent == f"DXAPI {fixture('login_success')['sessionToken']}" -# -------------------------------------------------------------------------- -# Blocker 3: order payload account + orderCode -# -------------------------------------------------------------------------- -def test_order_payload_includes_account_and_order_code(logged_in): - """Both fields are required by SCA and both were previously absent.""" - responses.add( - responses.POST, - f"{BASE_URL}{PREFIX}/accounts/default:ACC12345/orders", - json=fixture("order_accepted"), - status=200, - ) - logged_in.place_order( - instrument="EUR/USD", side="BUY", quantity=1.0, order_type="MARKET" - ) +def test_account_code_is_url_encoded_in_paths(client, api): + """The spec says account codes (clearing:account) must be URL-encoded.""" + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + client.get_positions() + assert "/accounts/default%3AACC12345/positions" in api.calls[-1].request.url - payload = json.loads(responses.calls[-1].request.body) - assert payload["account"] == "default:ACC12345" - assert payload["orderCode"], "orderCode must be a non-empty idempotency key" - assert payload["instrument"] == "EUR/USD" +def test_order_payload_includes_account_and_order_code(client, api): + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + client.place_order(instrument="EUR/USD", side="BUY", quantity=1000, order_type="MARKET") -def test_caller_supplied_order_code_is_respected(logged_in): - """A caller retrying a timed-out order must be able to reuse its own key.""" - responses.add( - responses.POST, - f"{BASE_URL}{PREFIX}/accounts/default:ACC12345/orders", - json=fixture("order_accepted"), - status=200, - ) - logged_in.place_order( - instrument="EUR/USD", - side="BUY", - quantity=1.0, - order_type="MARKET", - orderCode="my-key-1", - ) + payload = body(api.calls[-1]) + assert payload["account"] == ACCOUNT + assert payload["orderCode"] + assert payload == { + "account": ACCOUNT, + "orderCode": payload["orderCode"], + "type": "MARKET", + "instrument": "EUR/USD", + "quantity": 1000, + "positionEffect": "OPEN", + "side": "BUY", + "tif": "GTC", + } - payload = json.loads(responses.calls[-1].request.body) - assert payload["orderCode"] == "my-key-1" +def test_caller_supplied_order_code_is_respected(client, api): + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + client.place_order("EUR/USD", "BUY", 1000, "MARKET", orderCode="my-key-1") + assert body(api.calls[-1])["orderCode"] == "my-key-1" -def test_linked_sl_tp_orders_carry_account_and_order_code(logged_in): - """ - The account/orderCode fix must cover the linked SL and TP orders too, not - just the primary order. (Review of the 2026 fix found it had been applied to - the primary and Stop Loss payloads but not the Take Profit payload.) - """ - responses.add( - responses.POST, - f"{BASE_URL}{PREFIX}/accounts/default:ACC12345/orders", - json=fixture("order_accepted"), - status=200, - ) - logged_in.place_order( - instrument="EUR/USD", - side="BUY", - quantity=1.0, - order_type="MARKET", - stop_loss=1.05, - take_profit=1.15, - ) - # calls[0] is login; then primary order, SL order, TP order. - order_calls = responses.calls[1:] - assert len(order_calls) == 3, "expected primary + SL + TP orders" - codes = [] - for call in order_calls: - payload = json.loads(call.request.body) - assert payload["account"] == "default:ACC12345" - assert payload.get("orderCode"), f"orderCode missing from {payload}" - codes.append(payload["orderCode"]) - assert len(set(codes)) == 3, "each order must carry its own idempotency key" - - -def test_generated_order_codes_are_unique(): - """Idempotency keys that collided would suppress legitimate orders.""" +def test_generated_order_codes_are_unique_and_valid_client_ids(): wrapper = make_wrapper() codes = {wrapper._generate_order_code() for _ in range(500)} assert len(codes) == 500 - - -# -------------------------------------------------------------------------- -# Account code: raise instead of fabricating "primary" -# -------------------------------------------------------------------------- - -@responses.activate -def test_missing_account_raises_instead_of_guessing_primary(): - """ - The old fallback returned the invented code "primary", which exists at no - broker, so every downstream call 404'd with a misleading error. - """ - register_login(body=fixture("login_no_account")) - wrapper = make_wrapper() - wrapper.login() - - with pytest.raises(DXTradeAPIError) as excinfo: - wrapper.get_balance() - assert "account" in str(excinfo.value).lower() - assert "primary" not in str(excinfo.value) - - -@responses.activate -def test_constructor_account_used_when_login_omits_one(): - """The documented escape hatch for the case above must work.""" - register_login(body=fixture("login_no_account")) - responses.add( - responses.GET, - f"{BASE_URL}{PREFIX}/accounts/MYACC/portfolio", - json=fixture("portfolio"), - status=200, - ) - wrapper = make_wrapper(account="MYACC") - wrapper.login() - balance = wrapper.get_balance() - - assert balance is not None - assert responses.calls[-1].request.url.endswith("/accounts/MYACC/portfolio") - - -# -------------------------------------------------------------------------- -# Keepalive -# -------------------------------------------------------------------------- - -def test_ping_hits_prefixed_endpoint(logged_in): - responses.add(responses.GET, f"{BASE_URL}{PREFIX}/ping", json={}, status=200) - assert logged_in.ping() is True - assert responses.calls[-1].request.url == f"{BASE_URL}{PREFIX}/ping" - - -def test_ping_reports_failure_without_raising(logged_in): - """A failed keepalive must never take down the background thread.""" - responses.add(responses.GET, f"{BASE_URL}{PREFIX}/ping", json={}, status=401) - assert logged_in.ping() is False - - -def test_ping_is_noop_when_not_authenticated(): - assert make_wrapper().ping() is False - - -@responses.activate -def test_keepalive_thread_starts_and_stops(): - """Login starts the thread; logout must stop it rather than leak it.""" - register_login() - responses.add(responses.POST, f"{BASE_URL}{PREFIX}/logout", json={}, status=200) - responses.add(responses.GET, f"{BASE_URL}{PREFIX}/ping", json={}, status=200) - - wrapper = make_wrapper(keepalive_interval=30.0) - wrapper.login() - assert wrapper._keepalive_thread is not None - assert wrapper._keepalive_thread.is_alive() - assert wrapper._keepalive_thread.daemon - - wrapper.logout() - assert wrapper._keepalive_thread is None - - -# -------------------------------------------------------------------------- -# Logout -# -------------------------------------------------------------------------- - -def test_logout_calls_server_and_clears_state(logged_in): - """The HTTP call used to be commented out, leaving sessions alive server-side.""" - responses.add(responses.POST, f"{BASE_URL}{PREFIX}/logout", json={}, status=200) - logged_in.logout() - - assert responses.calls[-1].request.url == f"{BASE_URL}{PREFIX}/logout" - assert logged_in.is_authenticated is False - - -def test_logout_survives_server_rejection(logged_in): - """Local state must be cleared even if the server errors or 404s.""" - responses.add(responses.POST, f"{BASE_URL}{PREFIX}/logout", json={}, status=500) - logged_in.logout() - assert logged_in.is_authenticated is False - - -# -------------------------------------------------------------------------- -# WebSocket URL and headers -# -------------------------------------------------------------------------- - -def test_websocket_url_is_well_formed_with_account_and_no_token(): - """ - Regression: the old builder appended "&account=..." unconditionally but only - added "?token=..." when a token existed, emitting a query string whose first - parameter was introduced by "&". - """ - wrapper = make_wrapper(account="ACC1") - url = wrapper._build_websocket_url() - - assert "?" in url - assert "&account=" not in url.split("?")[0] - assert url.index("?") < url.index("account=") - assert url.startswith("wss://") - - -def test_websocket_url_encodes_reserved_characters(): - """Account codes contain ':' at real brokers; raw interpolation corrupts them.""" - wrapper = make_wrapper(account="default:ACC 12345") - url = wrapper._build_websocket_url() - assert " " not in url - assert "account=default%3AACC+12345" in url - - -def test_websocket_url_has_no_query_when_nothing_to_send(): - assert "?" not in make_wrapper()._build_websocket_url() - - -def test_websocket_auth_header_is_actually_emitted(logged_in): - """ - Regression: the header was guarded by `"token=" not in _build_websocket_url()`, - which is false exactly when a token exists - so it was never sent even once. - """ - headers = logged_in._build_websocket_headers() - auth = [h for h in headers if h.startswith("Authorization:")] - - assert len(auth) == 1, f"expected one Authorization header, got {auth}" - assert auth[0].startswith("Authorization: DXAPI ") + assert all(len(code) <= 64 and code.replace("-", "").isalnum() for code in codes) diff --git a/tests/test_push.py b/tests/test_push.py new file mode 100644 index 0000000..18c4303 --- /dev/null +++ b/tests/test_push.py @@ -0,0 +1,172 @@ +"""Push API (websocket) per the DXtrade Push spec, driven with fakes: no sockets.""" + +import json + +import pytest +import responses + +from conftest import ACCOUNT, API, TOKEN, fixture, make_wrapper +from dxtrade_wrapper import WebSocketError +from dxtrade_wrapper.client import _PushChannel + + +class FakeWS: + def __init__(self): + self.sent = [] + + def send(self, data): + self.sent.append(json.loads(data)) + + def close(self): + pass + + +def attach_channels(client): + """Registers connected channels backed by FakeWS, as connect_websocket() would.""" + channels = {} + for name, md in (("events", False), ("md", True)): + chan = _PushChannel(client, name, client._build_websocket_url(market_data=md)) + chan._ws = FakeWS() + chan.connected = True + channels[name] = chan + client._channels = channels + return channels + + +def test_urls_follow_the_spec_and_never_carry_the_token(client): + events = client._build_websocket_url() + md = client._build_websocket_url(market_data=True) + assert events == "wss://dxtrade.example-broker.com/websocket?format=JSON" + assert md == "wss://dxtrade.example-broker.com/websocket/md?format=JSON" + assert TOKEN not in events + md + assert all(TOKEN not in h for h in client._build_websocket_headers()) + + +def test_explicit_websocket_url_wins(): + wrapper = make_wrapper(websocket_url="wss://push.broker.test/dxsca-web/push?x=1") + assert wrapper._build_websocket_url() == "wss://push.broker.test/dxsca-web/push?format=JSON" + assert wrapper._build_websocket_url(True) == ( + "wss://push.broker.test/dxsca-web/push/md?format=JSON") + + +def test_subscribing_before_connect_raises(client): + with pytest.raises(WebSocketError): + client.subscribe_market_data(["EUR/USD"]) + with pytest.raises(WebSocketError): + client.subscribe_account_updates() + + +def test_market_data_subscription_envelope(client): + channels = attach_channels(client) + client.subscribe_market_data(["EUR/USD", "EUR/USD"]) + + [msg] = channels["md"]._ws.sent + assert msg["type"] == "MarketDataSubscriptionRequest" + assert msg["session"] == TOKEN + assert msg["requestId"] and msg["timestamp"].endswith("Z") + assert msg["payload"] == {"symbols": ["EUR/USD"], "account": ACCOUNT, + "eventTypes": [{"type": "Quote", "format": "COMPACT"}]} + assert channels["events"]._ws.sent == [] + + +def test_unsubscribe_references_the_original_request(client): + channels = attach_channels(client) + client.subscribe_market_data(["EUR/USD"]) + client.unsubscribe_market_data(["EUR/USD"]) + sub, close = channels["md"]._ws.sent + assert close["type"] == "MarketDataCloseSubscriptionRequest" + assert close["refRequestId"] == sub["requestId"] + + +def test_account_subscriptions_use_portfolio_and_metrics_requests(client): + channels = attach_channels(client) + client.subscribe_account_updates() + client.subscribe_account_updates() # idempotent + types = [m["type"] for m in channels["events"]._ws.sent] + assert types == ["AccountPortfoliosSubscriptionRequest", "AccountMetricsSubscriptionRequest"] + assert channels["events"]._ws.sent[0]["payload"] == {"requestType": "LIST", + "accounts": [ACCOUNT]} + + +def test_server_ping_request_gets_a_ping_reply(client): + channels = attach_channels(client) + client._handle_push_message(channels["events"], json.dumps( + {"type": "PingRequest", "session": TOKEN, "timestamp": "2026-09-30T10:00:00Z"})) + [reply] = channels["events"]._ws.sent + assert reply["type"] == "Ping" and reply["session"] == TOKEN + + +def test_quotes_land_on_the_price_queue(client): + channels = attach_channels(client) + client._handle_push_message(channels["md"], json.dumps({ + "type": "MarketData", "inReplyTo": "1", "session": TOKEN, + "payload": {"events": [{"symbol": "EUR/USD", "type": "Quote", "ask": 1.0555, + "bid": 1.0550, "time": "2026-09-30T10:00:00Z"}]}})) + update = client.price_update_queue.get_nowait() + assert update["instrument"] == "EUR/USD" + assert update["spread"] == pytest.approx(0.0005) + + +def test_portfolio_update_feeds_order_and_account_queues(client): + channels = attach_channels(client) + orders = fixture("orders")["orders"] + client._handle_push_message(channels["events"], json.dumps({ + "type": "AccountPortfolios", "payload": {"portfolios": [ + {"account": ACCOUNT, "version": 43, "balances": [], "positions": [], + "orders": orders}]}})) + assert client.account_update_queue.get_nowait()["type"] == "portfolio" + first = client.order_update_queue.get_nowait() + assert first["type"] == "order_update" and first["order_id"] == "3a4-ABC" + assert client.order_update_queue.qsize() == 1 + + +def test_metrics_update_feeds_account_queue(client): + channels = attach_channels(client) + client._handle_push_message(channels["events"], json.dumps({ + "type": "AccountMetrics", "payload": fixture("metrics")})) + update = client.account_update_queue.get_nowait() + assert update["type"] == "account_update" and update["equity"] == 100250.0 + + +def test_reconnect_replays_subscriptions_with_fresh_request_ids(client): + channels = attach_channels(client) + client.subscribe_market_data(["EUR/USD"]) + first = channels["md"]._ws.sent[0]["requestId"] + + channels["md"]._ws = FakeWS() # a new socket after a drop + channels["md"]._on_open() + + [replayed] = channels["md"]._ws.sent + assert replayed["type"] == "MarketDataSubscriptionRequest" + assert replayed["requestId"] != first + assert channels["md"].request_ids["md:EUR/USD"] == replayed["requestId"] + + +def test_expired_session_reject_triggers_relogin_and_resubscribe(client, api): + channels = attach_channels(client) + client.subscribe_account_updates() + api.add(responses.POST, f"{API}/login", json={"sessionToken": "NEW-TOKEN"}) + + client._handle_push_message(channels["events"], json.dumps({ + "type": "Reject", "inReplyTo": "x", + "payload": {"errorCode": "1", "description": "Authorization required"}})) + + resent = channels["events"]._ws.sent[2:] + assert [m["type"] for m in resent] == ["AccountPortfoliosSubscriptionRequest", + "AccountMetricsSubscriptionRequest"] + assert all(m["session"] == "NEW-TOKEN" for m in resent) + + +def test_garbage_messages_are_ignored(client): + channels = attach_channels(client) + client._handle_push_message(channels["events"], "not json") + client._handle_push_message(channels["events"], json.dumps({"type": "Mystery"})) + assert client.account_update_queue.empty() + + +def test_disconnect_clears_subscriptions(client): + attach_channels(client) + client.subscribe_market_data(["EUR/USD"]) + client.disconnect_websocket() + assert client._subscriptions == {} and client._channels == {} + assert not client._subscribed_instruments diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..b88cf9a --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,191 @@ +"""Login, typed errors, session refresh, keepalive, logout, credential hygiene.""" + +import logging + +import pytest +import requests +import responses + +from conftest import ACC, ACCOUNT, API, PASSWORD, TOKEN, fixture, make_wrapper +from dxtrade_wrapper import ( + AuthenticationError, + ConnectionError, + DXTradeAPIError, + NotFoundError, + RateLimitError, + ServerError, +) +from dxtrade_wrapper.client import parse_interval + + +def test_bad_credentials_raise_authentication_error_with_server_detail(api): + api.add(responses.POST, f"{API}/login", json=fixture("error_bad_credentials"), status=401) + wrapper = make_wrapper() + with pytest.raises(AuthenticationError) as excinfo: + wrapper.login() + exc = excinfo.value + assert exc.status_code == 401 + assert exc.error_code == "3" + assert "Incorrect username or password" in str(exc) + assert wrapper.is_authenticated is False + + +def test_403_login_explains_rest_access_is_disabled(api): + api.add(responses.POST, f"{API}/login", json={}, status=403) + with pytest.raises(AuthenticationError, match="not enabled DXtrade REST"): + make_wrapper().login() + + +def test_login_without_token_is_an_error(api): + api.add(responses.POST, f"{API}/login", json={"timeout": "00:30:00"}) + with pytest.raises(AuthenticationError, match="sessionToken"): + make_wrapper().login() + + +def test_network_failure_is_a_connection_error(api): + api.add(responses.POST, f"{API}/login", body=requests.exceptions.ConnectTimeout("boom")) + with pytest.raises(ConnectionError): + make_wrapper().login() + + +def test_calls_before_login_raise(): + with pytest.raises(AuthenticationError, match="login"): + make_wrapper(account=ACCOUNT).get_positions() + + +@pytest.mark.parametrize( + "status, exc_type", + [(404, NotFoundError), (429, RateLimitError), (500, ServerError), (400, DXTradeAPIError)], +) +def test_http_errors_map_to_typed_exceptions(client, api, status, exc_type): + api.add(responses.GET, f"{ACC}/positions", + json={"errorCode": "2", "description": "Entity not found at server"}, + status=status, headers={"Retry-After": "3"}) + with pytest.raises(exc_type) as excinfo: + client.get_positions() + assert excinfo.value.status_code == status + assert excinfo.value.error_code == "2" + if status == 429: + assert excinfo.value.retry_after == 3.0 + + +def test_expired_session_is_renewed_once_and_the_call_retried(client, api): + api.add(responses.GET, f"{ACC}/positions", json={"errorCode": "1"}, status=401) + api.add(responses.POST, f"{API}/login", json={"sessionToken": "NEW-TOKEN"}) + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + + positions = client.get_positions() + + assert len(positions) == 1 + assert [c.request.url.rsplit("/", 1)[-1] for c in api.calls[1:]] == [ + "positions", "login", "positions"] + assert api.calls[-1].request.headers["Authorization"] == "DXAPI NEW-TOKEN" + + +def test_relogin_is_attempted_only_once(client, api): + api.add(responses.GET, f"{ACC}/positions", json={"errorCode": "1"}, status=401) + api.add(responses.POST, f"{API}/login", json={"sessionToken": "NEW"}) + api.add(responses.GET, f"{ACC}/positions", json={"errorCode": "1"}, status=401) + with pytest.raises(AuthenticationError): + client.get_positions() + assert len(api.calls) == 4 # login, positions, login, positions + + +def test_auto_relogin_can_be_disabled(api): + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) + api.add(responses.GET, f"{ACC}/positions", json={"errorCode": "1"}, status=401) + wrapper = make_wrapper(account=ACCOUNT, auto_relogin=False) + wrapper.login() + with pytest.raises(AuthenticationError): + wrapper.get_positions() + assert len(api.calls) == 2 + + +def test_ping_is_a_post_and_adopts_a_renewed_token(client, api): + api.add(responses.POST, f"{API}/ping", json={"sessionToken": "RENEWED", "timeout": "00:10:00"}) + assert client.ping() is True + assert api.calls[-1].request.method == "POST" + assert client._session.headers["Authorization"] == "DXAPI RENEWED" + assert client._session_timeout == 600 + + +def test_ping_reports_failure_without_raising(client, api): + api.add(responses.POST, f"{API}/ping", json={}, status=401) + assert client.ping() is False + + +def test_ping_is_noop_when_not_authenticated(): + assert make_wrapper().ping() is False + + +def test_keepalive_interval_is_capped_by_session_timeout(api): + api.add(responses.POST, f"{API}/login", json={"sessionToken": "T", "timeout": "00:01:00"}) + api.add(responses.POST, f"{API}/logout") + wrapper = make_wrapper(keepalive_interval=300.0) + wrapper.login() + try: + assert wrapper._effective_keepalive() == 30.0 + assert wrapper._keepalive_thread.is_alive() and wrapper._keepalive_thread.daemon + finally: + wrapper.logout() + assert wrapper._keepalive_thread is None + + +@pytest.mark.parametrize( + "raw, seconds", + [("00:30:00", 1800), ("01:05:08", 3908), ("PT15M", 900), ("120", 120), (None, None), + ("soon", None)], +) +def test_parse_interval(raw, seconds): + assert parse_interval(raw) == seconds + + +def test_logout_posts_and_clears_state(client, api): + api.add(responses.POST, f"{API}/logout") + client.logout() + assert api.calls[-1].request.url == f"{API}/logout" + assert client.is_authenticated is False + assert "Authorization" not in client._session.headers + + +def test_logout_survives_server_rejection(client, api): + api.add(responses.POST, f"{API}/logout", status=500) + client.logout() + assert client.is_authenticated is False + + +def test_context_manager_logs_in_and_out(api): + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) + api.add(responses.POST, f"{API}/logout") + with make_wrapper() as wrapper: + assert wrapper.is_authenticated + assert not wrapper.is_authenticated + + +def test_repr_hides_password_and_token(client): + text = repr(client) + assert PASSWORD not in text and TOKEN not in text + + +def test_credentials_never_reach_the_logs(api, caplog): + """Password and session token must not appear at any log level.""" + caplog.set_level(logging.DEBUG, logger="dxtrade_wrapper") + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) + api.add(responses.GET, f"{ACC}/positions", json={"errorCode": "1"}, status=401) + api.add(responses.POST, f"{API}/login", json=fixture("error_bad_credentials"), status=401) + wrapper = make_wrapper(account=ACCOUNT) + wrapper.login() + wrapper._build_websocket_url() + with pytest.raises(AuthenticationError) as excinfo: + wrapper.get_positions() + + for text in (caplog.text, str(excinfo.value)): + assert PASSWORD not in text + assert TOKEN not in text + + +def test_library_does_not_configure_logging(): + """The old code attached a StreamHandler and forced INFO on import/construct.""" + make_wrapper() + handlers = logging.getLogger("dxtrade_wrapper").handlers + assert all(isinstance(h, logging.NullHandler) for h in handlers) From fca891ceca60f0db9669779cce218e2e2c5a2b15 Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:33:14 +0300 Subject: [PATCH 02/10] Package it: pyproject.toml, version 0.2.0, py.typed pip install . (or git+https://...) now works and ships type hints. Version lives in dxtrade_wrapper.__version__. Not published to PyPI; that stays the owner's call. Dev requirements gain jsonschema/websockets/ruff/mypy for the new conformance and live-socket tests. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 58 ++++++++++++++++++++++++++++++++++++++++++++ requirements-dev.txt | 17 +++++++------ requirements.txt | 17 ++++--------- 3 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fee0491 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "dxtrade-python-wrapper" +dynamic = ["version"] +description = "Python client for the DXtrade REST and Push APIs (spec-conformant, not live-verified)" +readme = "README.MD" +requires-python = ">=3.9" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Bogdan Truta" }] +keywords = ["dxtrade", "devexperts", "trading", "broker", "api", "prop-firm"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Financial and Insurance Industry", + "Programming Language :: Python :: 3", + "Topic :: Office/Business :: Financial :: Investment", + "Typing :: Typed", +] +dependencies = [ + "requests>=2.25", + "websocket-client>=1.4", +] + +[project.optional-dependencies] +test = ["pytest>=7", "responses>=0.23", "jsonschema>=4", "websockets>=12"] +examples = ["python-dotenv>=0.19"] + +[project.urls] +Homepage = "https://github.com/Bogzx/DXtrade-python-wrapper" +"DXtrade API docs" = "https://demo.dx.trade/developers/" + +[tool.setuptools] +packages = ["dxtrade_wrapper"] + +[tool.setuptools.dynamic] +version = { attr = "dxtrade_wrapper.__version__" } + +[tool.setuptools.package-data] +dxtrade_wrapper = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py39" +extend-exclude = [".venv"] + +[tool.ruff.lint] +select = ["E", "F", "W", "B"] + +[tool.mypy] +python_version = "3.10" +ignore_missing_imports = true diff --git a/requirements-dev.txt b/requirements-dev.txt index 87d47bc..6af413a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,10 @@ -# Development/test dependencies. -# -# The test suite is fixture-based and needs no broker account: -# python -m pytest tests/ -v - +# Development/test dependencies. No broker account needed: +# python -m pytest -r requirements.txt - -pytest>=7.0.0 -responses>=0.23.0 +pytest>=7 +responses>=0.23 +jsonschema>=4 +websockets>=12 +ruff>=0.6 +mypy>=1.8 +types-requests diff --git a/requirements.txt b/requirements.txt index b1641d3..b02b250 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,5 @@ -# Runtime dependencies. -# -# NOTE: installing these does not make this wrapper work. See the "Project -# Status" section of the README - the client is a non-functional prototype whose -# protocol has never been verified against a live DXtrade server. - -requests>=2.25.0 -websocket-client>=1.0.0 - -# Used by example.py to load .env. This was missing from the README's dependency -# list, so following the README broke the example at example.py:8. -python-dotenv>=0.19.0 +# Runtime dependencies (also declared in pyproject.toml). +requests>=2.25 +websocket-client>=1.4 +# Used by example.py to load .env. +python-dotenv>=0.19 From 56e2143293f2299dcbff4ba1a37d0206915138ca Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:33:26 +0300 Subject: [PATCH 03/10] Test against the official OpenAPI document and a real local websocket test_spec_conformance.py (opt-in: DXTRADE_SPEC_TESTS=1, downloads the public spec rather than vendoring it) drives every REST method and asserts each request hits a documented path/method/query param, sends only declared fields and validates against the schema, and that every fixture matches its response schema. Mutation-checked: renaming quantity->qty or reverting the history path makes it fail. test_push_live_socket.py runs the Push channels against a fake DXtrade server over real sockets: PingRequest/Ping on both channels, quote subscription on /md, and resubscription after the TCP connection drops. Co-Authored-By: Claude Opus 5.5 --- tests/test_push_live_socket.py | 127 +++++++++++++++++++++ tests/test_spec_conformance.py | 194 +++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 tests/test_push_live_socket.py create mode 100644 tests/test_spec_conformance.py diff --git a/tests/test_push_live_socket.py b/tests/test_push_live_socket.py new file mode 100644 index 0000000..e8e1780 --- /dev/null +++ b/tests/test_push_live_socket.py @@ -0,0 +1,127 @@ +"""End-to-end Push API test over a real local websocket (a fake DXtrade server). + +Exercises the channel threads, PingRequest/Ping, subscription and reconnect with +subscription replay over actual sockets. Needs the `websockets` package. +""" + +import asyncio +import json +import queue +import threading +import time + +import pytest + +from conftest import ACCOUNT, TOKEN + +websockets = pytest.importorskip("websockets") +from websockets.asyncio.server import serve # noqa: E402 + + +class FakePushServer: + """Speaks just enough of the Push API: PingRequest on connect, echoes quotes.""" + + def __init__(self): + self.received: "queue.Queue[tuple[str, dict]]" = queue.Queue() + self.connections = 0 + self.history = [] + self.port = None + self._loop = asyncio.new_event_loop() + self._ready = threading.Event() + self._server = None + self._thread = threading.Thread(target=self._run, daemon=True) + + async def _handler(self, ws): + self.connections += 1 + path = ws.request.path + await ws.send(json.dumps({"type": "PingRequest", "session": TOKEN, + "timestamp": "2026-09-30T10:00:00Z"})) + async for raw in ws: + msg = json.loads(raw) + self.received.put((path, msg)) + if msg["type"] == "MarketDataSubscriptionRequest": + await ws.send(json.dumps({ + "type": "MarketData", "inReplyTo": msg["requestId"], "session": TOKEN, + "payload": {"events": [{"symbol": msg["payload"]["symbols"][0], + "type": "Quote", "bid": 1.1, "ask": 1.1002}]}})) + + def _run(self): + asyncio.set_event_loop(self._loop) + + async def main(): + self._server = await serve(self._handler, "127.0.0.1", 0) + self.port = self._server.sockets[0].getsockname()[1] + self._ready.set() + await self._server.serve_forever() + + try: + self._loop.run_until_complete(main()) + except asyncio.CancelledError: + pass + + def start(self): + self._thread.start() + self._ready.wait(5) + return self + + def drop_all(self): + """Aborts every client TCP connection, as a network blip would.""" + def abort(): + for conn in list(self._server.connections): + conn.transport.abort() + self._loop.call_soon_threadsafe(abort) + + def stop(self): + self._loop.call_soon_threadsafe(self._server.close) + + def wait_for(self, predicate, timeout=5.0, after=0): + """Returns the first message (index >= after) matching predicate, with its index.""" + deadline = time.monotonic() + timeout + while True: + while True: + try: + self.history.append(self.received.get_nowait()) + except queue.Empty: + break + for index, item in enumerate(self.history[after:], start=after): + if predicate(item): + return index, item + if time.monotonic() > deadline: + raise AssertionError(f"not received; saw {self.history}") + time.sleep(0.05) + + +@pytest.fixture +def server(): + srv = FakePushServer().start() + yield srv + srv.stop() + + +def test_push_session_over_a_real_socket(client, server, monkeypatch): + monkeypatch.setattr(client, "_websocket_url", f"ws://127.0.0.1:{server.port}/push") + client.connect_websocket() + try: + assert client._ws_connected + + # The server's PingRequest is answered on both channels. + server.wait_for(lambda i: i[1]["type"] == "Ping" and i[0].startswith("/push?")) + server.wait_for(lambda i: i[1]["type"] == "Ping" and i[0].startswith("/push/md")) + + client.subscribe_market_data(["EUR/USD"]) + index, (path, sub) = server.wait_for( + lambda i: i[1]["type"] == "MarketDataSubscriptionRequest") + assert path.startswith("/push/md?format=JSON") + assert sub["session"] == TOKEN and sub["payload"]["account"] == ACCOUNT + quote = client.price_update_queue.get(timeout=5) + assert quote["instrument"] == "EUR/USD" and quote["bid"] == 1.1 + + # After a drop the channel reconnects and replays the subscription. + server.drop_all() + _, (_, replay) = server.wait_for( + lambda i: i[1]["type"] == "MarketDataSubscriptionRequest", timeout=8, + after=index + 1) + assert replay["requestId"] != sub["requestId"] + assert server.connections >= 4 + finally: + client.disconnect_websocket() diff --git a/tests/test_spec_conformance.py b/tests/test_spec_conformance.py new file mode 100644 index 0000000..a9b5389 --- /dev/null +++ b/tests/test_spec_conformance.py @@ -0,0 +1,194 @@ +"""Checks the client and the fixtures against Devexperts' published OpenAPI document. + +Opt-in because it downloads the spec (a public, read-only GET): + + DXTRADE_SPEC_TESTS=1 python -m pytest tests/test_spec_conformance.py -v + +The spec is not vendored into this repo. Set DXTRADE_OPENAPI_FILE to use a local +copy instead of downloading. + +What it proves: every request the client makes targets a documented path and +method, sends only declared fields, and validates against the schema; every +fixture the other tests rely on matches the documented response schema. +What it cannot prove: that a given broker's deployment behaves like the spec. + +Known spec quirks handled here (documented in the README): +* ``AccountCode`` is an object in the OpenAPI schema but a "clearing:account" + string in the prose spec and all its examples; both are accepted. +* ``SessionToken.timeout`` and the date/time types (``UTCDateTime``...) are + property-less ``object``s in the OpenAPI schema; the prose spec defines + strings. Strings are accepted. +* Order *groups* (IF-THEN / OCO) are documented in the prose spec but the + OpenAPI request body only models a single order, so each order in a group is + validated as a SingleOrderRequest. +""" + +import copy +import json +import os +import pathlib +import re +import urllib.request + +import pytest +import responses + +from conftest import ACC, ACCOUNT, API, body, fixture, make_wrapper + +SPEC_URL = "https://demo.dx.trade/dxsca-web/swagger/openapi.json" + +pytestmark = pytest.mark.skipif( + not (os.environ.get("DXTRADE_SPEC_TESTS") or os.environ.get("DXTRADE_OPENAPI_FILE")), + reason="set DXTRADE_SPEC_TESTS=1 to download and check against the official OpenAPI spec", +) + +jsonschema = pytest.importorskip("jsonschema") + + +@pytest.fixture(scope="module") +def spec(): + local = os.environ.get("DXTRADE_OPENAPI_FILE") + if local: + doc = json.loads(pathlib.Path(local).read_text(encoding="utf-8")) + else: + with urllib.request.urlopen(SPEC_URL, timeout=30) as resp: # noqa: S310 + doc = json.load(resp) + doc = copy.deepcopy(doc) + schemas = doc["components"]["schemas"] + schemas["AccountCode"] = {"anyOf": [{"type": "string"}, schemas["AccountCode"]]} + schemas["SessionToken"]["properties"]["timeout"] = {} + # Date/time types are property-less "object"s in the OpenAPI document (a Java + # type artefact); the prose spec defines them as RFC 3339 strings. + for name, schema in schemas.items(): + if schema.get("type") == "object" and not schema.get("properties"): + schemas[name] = {"anyOf": [{"type": "string"}, schema]} + return doc + + +def validator(spec, name): + schema = {"$ref": f"#/components/schemas/{name}", "components": spec["components"]} + return jsonschema.Draft7Validator(schema) + + +def declared(spec, name): + return set(spec["components"]["schemas"][name].get("properties", {})) + + +def find_operation(spec, method, url): + path = url.split("?")[0][len(API):] + for template, ops in spec["paths"].items(): + pattern = "^" + re.sub(r"\{[^/]+\}", "[^/]+", template) + "$" + if re.match(pattern, path) and method.lower() in ops: + return template, ops[method.lower()] + return None, None + + +def run_scenario(): + """Drives every REST method against mocks and returns the captured calls.""" + with responses.RequestsMock(assert_all_requests_are_fired=False) as api: + etag = {"ETag": '"v1"'} + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) + api.add(responses.GET, f"{API}/users/user%40default", json=fixture("users")) + api.add(responses.GET, f"{ACC}/metrics", json=fixture("metrics")) + api.add(responses.GET, f"{ACC}/portfolio", + json={"portfolios": [{"account": ACCOUNT, "version": 1, "balances": [], + "positions": [], "orders": []}]}) + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + api.add(responses.GET, f"{ACC}/orders", json=fixture("orders"), headers=etag) + api.add(responses.GET, f"{ACC}/orders/history", json=fixture("history")) + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("order_response")) + api.add(responses.DELETE, re.compile(rf"{re.escape(ACC)}/orders/.+"), body="") + api.add(responses.POST, f"{ACC}/close", body="") + api.add(responses.POST, f"{API}/ping", json={}) + api.add(responses.POST, f"{API}/logout", body="") + + client = make_wrapper() + client.login() + client.get_accounts() + client.get_balance() + client.get_portfolio() + client.get_positions() + client.get_orders() + client.get_order_history(limit=10, in_status="COMPLETED") + client.place_order("EUR/USD", "BUY", 1000, "MARKET") + client.place_order("EUR/USD", "BUY", 1000, "LIMIT", price=1.05) + client.place_order("EUR/USD", "BUY", 1000, "MARKET", stop_loss=1.0, take_profit=1.2) + client.modify_order("3a4-DEF", new_price=1.3) + client.cancel_order("3a4-DEF") + client.close_position("63649") + client.modify_position_sl_tp("63649", stop_loss=1.08, take_profit=1.1) + client.close_all() + client.ping() + client.logout() + return list(api.calls) + + +def test_every_request_targets_a_documented_operation(spec): + for call in run_scenario(): + template, op = find_operation(spec, call.request.method, call.request.url) + assert op is not None, f"{call.request.method} {call.request.url} not in the spec" + documented = {p["name"] for p in op.get("parameters", []) if p.get("in") == "query"} + query = call.request.url.split("?")[1] if "?" in call.request.url else "" + for pair in filter(None, query.split("&")): + name = pair.split("=")[0] + assert name in documented, f"undocumented query param {name} on {template}" + + +def test_every_request_body_matches_its_schema(spec): + checked = 0 + for call in run_scenario(): + payload = body(call) + if payload is None: + continue + template, op = find_operation(spec, call.request.method, call.request.url) + schema_ref = op["requestBody"]["content"]["application/json"]["schema"]["$ref"] + name = schema_ref.split("/")[-1] + members = payload["orders"] if "contingencyType" in payload else [payload] + if "contingencyType" in payload: + assert set(payload) == {"orders", "contingencyType"} + assert payload["contingencyType"] in ("IF-THEN", "OCO") + for member in members: + validator(spec, name).validate(member) + unknown = set(member) - declared(spec, name) + assert not unknown, f"{template}: fields not in {name}: {unknown}" + checked += 1 + assert checked >= 8 + + +@pytest.mark.parametrize( + "fixture_name, schema", + [ + ("login_success", "SessionToken"), + ("users", "UserDetailsList"), + ("metrics", "AccountMetricsList"), + ("positions", "PositionList"), + ("orders", "OrderList"), + ("history", "OrderList"), + ("order_response", "OrderResponse"), + ("group_response", "OrderResponseList"), + ("error_bad_credentials", "ApiError"), + ], +) +def test_fixtures_match_documented_response_schemas(spec, fixture_name, schema): + data = fixture(fixture_name) + validator(spec, schema).validate(data) + assert not set(data) - declared(spec, schema) + + +def test_parsers_only_read_documented_fields(spec): + """Guards against the old bug class: parsing invented names (entryPrice, id...).""" + import inspect + + from dxtrade_wrapper import models + + checks = ( + ("Position", models.parse_position, r'data(?:\.get\(|\[)"(\w+)"'), + ("AccountMetrics", models.parse_balance, r'metrics\.get\("(\w+)"'), + ) + for schema, parser, pattern in checks: + fields = set(re.findall(pattern, inspect.getsource(parser))) + assert fields, f"no fields found for {schema}" + # openPl/totalPl are the prose-spec spellings, read as fallbacks. + extra = fields - declared(spec, schema) - {"openPl", "totalPl"} + assert not extra, f"{schema} parser reads undocumented fields {extra}" From 15ab55b7677aefebe86a6cda34318c98cd028aa3 Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:35:58 +0300 Subject: [PATCH 04/10] Reuse the fetched account list instead of calling /users twice Co-Authored-By: Claude Opus 5.5 --- dxtrade_wrapper/client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dxtrade_wrapper/client.py b/dxtrade_wrapper/client.py index 5a9d7fa..1529d03 100644 --- a/dxtrade_wrapper/client.py +++ b/dxtrade_wrapper/client.py @@ -182,6 +182,7 @@ def __init__( self._configured_account = account self._account_id = account self._account_currency: Dict[str, str] = {} + self._accounts_cache: Optional[List[JSON]] = None self._keepalive_interval = keepalive_interval self._keepalive_thread: Optional[threading.Thread] = None @@ -405,6 +406,7 @@ def logout(self) -> None: self._auth_token = None self._is_authenticated = False self._account_id = self._configured_account + self._accounts_cache = None self._logger.info("Logged out") @property @@ -510,6 +512,7 @@ def get_accounts(self) -> List[JSON]: }) if code and details.get("baseCurrency"): self._account_currency[code] = details["baseCurrency"] + self._accounts_cache = accounts return accounts def _get_account_id(self) -> str: @@ -520,7 +523,8 @@ def _get_account_id(self) -> str: """ if self._account_id: return self._account_id - accounts = [a["account"] for a in self.get_accounts() if a["account"]] + known = self._accounts_cache if self._accounts_cache is not None else self.get_accounts() + accounts = [a["account"] for a in known if a["account"]] if len(accounts) == 1: self._account_id = accounts[0] self._logger.info("Using account %s", self._account_id) From ba0921f3a9b3855c4e9e49f1342d4ba9d6e234d8 Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:35:58 +0300 Subject: [PATCH 05/10] README, example, offline demo, CHANGELOG and CI - README: status is now 'spec-conformant, not live-verified' with links to the public docs. The quick start runs with no broker account (verified from a fresh clone), plus an API table mapping each method to its endpoint, and sections on errors, sessions and security. - Drops the 'Use this instead: pip install dxtrade-sdk' advice: no such package exists on PyPI (404). - example.py: read-only tour of a real account; orders only with --demo-order. examples/offline_demo.py prints every request it sends. - CI: ruff + pytest on 3.9/3.11/3.13/3.14, mypy + spec-conformance job, wheel build and install. Co-Authored-By: Claude Opus 5.5 --- .env.example | 28 +-- .github/workflows/ci.yml | 48 +++++ CHANGELOG.md | 46 +++++ README.MD | 411 ++++++++++++++------------------------- example.py | 309 ++++++++--------------------- examples/offline_demo.py | 89 +++++++++ 6 files changed, 422 insertions(+), 509 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 examples/offline_demo.py diff --git a/.env.example b/.env.example index 3efb866..081397f 100644 --- a/.env.example +++ b/.env.example @@ -1,30 +1,30 @@ # Copy this file to `.env` and fill in your own values. # `.env` is gitignored - never commit real credentials. # -# NOTE: none of these settings have been validated against a live DXtrade -# server. See the "Project Status" section of the README before spending time -# here. FTMO in particular disabled DXtrade REST access for clients in April -# 2024, so no combination of values below will work against FTMO. +# The client follows the public DXtrade spec but has not been verified against a +# live broker. Use a DEMO account. FTMO disabled DXtrade REST access for clients +# in April 2024, so no values below will work against FTMO. # Base URL of your broker's DXtrade deployment (no trailing slash). DXTRADE_BASE_URL=https://dxtrade.your-broker.com -# Credentials for the trading account. +# Credentials. The password is never logged by the client. DXTRADE_USERNAME= DXTRADE_PASSWORD= -# The broker's domain/vendor identifier, sent alongside the credentials. +# Your DXtrade domain, sent with the credentials (usually "default"). DXTRADE_DOMAIN_VENDOR=default -# REST prefix for the DXtrade SCA API. Every REST path is built under this. +# REST prefix; "/dxsca-web" per the DXtrade REST spec. DXTRADE_API_PREFIX=/dxsca-web -# Login path, relative to DXTRADE_API_PREFIX. -DXTRADE_LOGIN_PATH=/login - -# Account code / id. The login response does not reliably contain one, and the -# wrapper now raises instead of guessing, so set it explicitly if you have it. +# Account code in "clearing:account" form, e.g. default:ACC12345. Optional: if +# empty it is looked up via GET /users/{username}; required if you have several. DXTRADE_ACCOUNT= -# WebSocket path. Unverified - differs between broker deployments. -DXTRADE_WEBSOCKET_PATH=/websocket/events +# Push API websocket URL. The spec leaves this to each deployment - ask your +# broker. Leave empty to skip streaming in example.py. +DXTRADE_WEBSOCKET_URL= + +# Symbols example.py streams quotes for. +DXTRADE_SYMBOLS=EUR/USD,GBP/USD diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..925a541 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[test]" ruff + - run: ruff check . + - run: python -m pytest -q + + spec-conformance: + # Downloads Devexperts' public OpenAPI document (read-only) and checks the + # client's requests and the test fixtures against it. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install -e ".[test]" mypy types-requests + - run: mypy dxtrade_wrapper + - run: DXTRADE_SPEC_TESTS=1 python -m pytest tests/test_spec_conformance.py -v + + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install build && python -m build + - run: pip install dist/*.whl && cd /tmp && python -c "import dxtrade_wrapper; print(dxtrade_wrapper.__version__)" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9fc49a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +## 0.2.0 (unreleased) + +Rebuilt against the public DXtrade REST/Push specification +(https://demo.dx.trade/developers/, OpenAPI at /dxsca-web/swagger/openapi.json). +Spec-conformant; not yet verified against a live broker. + +### Fixed (each would have failed at any broker) +- Stop loss / take profit were never placed: they waited for a `positionCode` the + order response does not contain. Now one atomic IF-THEN order group. +- `get_balance()` read `/portfolio` for equity/margin (they are in `/metrics`) and + returned all-zero balances on any parse error. Now `/metrics`, and errors raise. +- Position/order parsers read invented fields (`id`, `entryPrice`, `stopLoss`...) + from a bare list; responses are `{"positions": [...]}` etc. with `positionCode`, + `symbol`, `openPrice`, `stopLossPrice`, `legs[0].price`... +- Order history used `/accounts/{a}/history`; the endpoint is `/orders/history`. +- `modify_order` sent `PATCH /orders/{id}` with `price`/`qty`; the spec requires + `PUT /accounts/{a}/orders` with the full order and `If-Match`. +- `cancel_order` sent no `If-Match` (403 "Conditional request required"). +- `close_position` / `modify_position_sl_tp` called `/positions/{id}`, which does not + exist; closing is a linked CLOSE order, protections are linked STOP/LIMIT orders. +- `ping()` used GET; the spec says POST. A renewed `sessionToken` is now adopted. +- Push API: replaced invented `subscribe` messages, Atmosphere headers and + token-in-URL (logged at INFO) with the spec envelope, `PingRequest`→`Ping` + replies, a separate `/md` market-data channel, and reconnects that actually + replay subscriptions. + +### Added +- Typed exceptions with `status_code`, `error_code`, `description` + (`NotFoundError`, `ConflictError`, `PreconditionFailedError`, `RateLimitError`, + `ServerError`); `OrderPlacementError.ambiguous` / `.order_code`. +- One automatic re-login on 401; keepalive capped by the server's session timeout. +- Account discovery via `GET /users/{login@domain}`; `get_accounts()`, + `get_account_metrics()`, `get_portfolio()`, `close_all()` (bulk close). +- Context manager, `DXTradeClient` alias, `pyproject.toml`, `py.typed`. +- Conformance tests against the official OpenAPI document; Push tests over a real + local websocket; offline demo. + +### Changed +- Module moved into the `dxtrade_wrapper` package (imports unchanged). +- The library no longer attaches log handlers or sets log levels. +- `Order.order_id` is the server `orderCode`; the client id is `client_order_id`. + +## 0.1.0 (2025-04) +- Initial reverse-engineered prototype (never completed a call). diff --git a/README.MD b/README.MD index a61746f..cf26858 100644 --- a/README.MD +++ b/README.MD @@ -1,293 +1,164 @@ -# 📊 DXTrade Python API Wrapper +# 📊 DXtrade Python Wrapper -![Python](https://img.shields.io/badge/python-3.7+-blue.svg) -![Status](https://img.shields.io/badge/status-not%20functional%20%7C%20untested%20prototype-red) -![Protocol](https://img.shields.io/badge/protocol-unverified-lightgrey) +![Python](https://img.shields.io/badge/python-3.9+-blue.svg) +![Status](https://img.shields.io/badge/status-spec--conformant%20%7C%20not%20live--verified-orange) +[![CI](https://github.com/Bogzx/DXtrade-python-wrapper/actions/workflows/ci.yml/badge.svg)](https://github.com/Bogzx/DXtrade-python-wrapper/actions/workflows/ci.yml) ![License](https://img.shields.io/badge/license-MIT-green) -> **Read this first:** this wrapper has never successfully completed a single call -> against a real DXtrade server. It is published as a **writeup of a failed -> reverse-engineering attempt**, not as a usable client. If you need a working -> DXtrade client, see [Use this instead](#-use-this-instead). +A typed Python client for the [DXtrade](https://dx.trade) broker platform's REST and +Push (WebSocket) APIs. It covers login and session upkeep, balance, positions, orders, +placing orders with stop loss / take profit, modify, cancel, close, and live quotes and +account updates. Prop-firm and retail traders use DXtrade. -## 📋 Table of Contents +> **Status: spec-conformant, not live-verified.** Every request follows Devexperts' +> public [DXtrade API documentation](https://demo.dx.trade/developers/), and the test +> suite checks the client against their published +> [OpenAPI document](https://demo.dx.trade/dxsca-web/swagger/openapi.json). It has +> **not** yet been run against a live broker. Try it on a **demo account** first. Brokers +> decide whether to enable REST access at all (FTMO turned it off in April 2024). -- [Project Status](#-project-status-not-functional--untested-prototype) -- [Use this instead](#-use-this-instead) -- [What this repo is actually good for](#-what-this-repo-is-actually-good-for) -- [What was learned](#-what-was-learned) -- [Introduction](#-introduction) -- [Features (as designed, none verified)](#-features-as-designed-none-verified) -- [Alternative Approaches](#-alternative-approaches) -- [Installation](#-installation) -- [Tests](#-tests) -- [Packaging](#-packaging) -- [Disclaimer](#-disclaimer) - -## ⚠️ Project Status: Not Functional — Untested Prototype - -This code has **demonstrably never been executed successfully against a live -DXtrade server.** An earlier version of this README said "Not Currently -Functional with FTMO," which implied the wrapper worked elsewhere and that FTMO -was the only obstacle. That was wrong, and it wasted readers' time. There are -**four** reasons this does not work, and **three of them are mine.** - -### Cause 1 — FTMO discontinued REST access (vendor policy, not fixable) - -As of late April 2024, **FTMO discontinued REST API access to the DXtrade -platform for its clients.** See [Trading Update – 25 Apr 2024](https://ftmo.com/en/trading-updates/trading-update-25-apr-2024/). -Authenticating against FTMO returns `403 Forbidden` and no code change will -alter that. FTMO was the headline use case for this repo, and it is closed -permanently. - -**This is the cause the old README named — and stopping there is what hid the -next three for two years.** A `403` at login is indistinguishable from a -policy block, so the author attributed the failure to FTMO and never looked -further. The bugs below were found by a code review in 2026, not by running -anything. - -### Cause 2 — every REST path after login omitted the `/dxsca-web` prefix (mine) - -`login()` correctly used `/dxsca-web/login`. **Every single other endpoint** — -portfolio, positions, orders, history, order placement, modification, -cancellation, position close — was built straight off the bare host, with no -prefix. All eight would have returned `404` **at any broker on earth**, not -just FTMO. Even if FTMO had kept REST access enabled, nothing past -authentication would have worked. - -Because login was never reached at FTMO, this bug never got the chance to -surface. - -**Fixed (UNVERIFIED):** the prefix is now a constructor argument (`api_prefix`, -default `/dxsca-web`) and every URL is built by a single `_url(path)` helper. -The point of routing through one helper rather than patching eight f-strings is -that this class of bug cannot silently recur. - -### Cause 3 — wrong authorization scheme (mine) - -The wrapper sent `Authorization: Bearer `. DXtrade SCA uses a custom -scheme: `Authorization: DXAPI `. Every authenticated request -would have been rejected with `401`, on top of already being sent to a -prefix-less URL. - -**Fixed (UNVERIFIED):** now sends `DXAPI`, via the `AUTH_SCHEME` class -attribute. - -### Cause 4 — order payload missing required fields (mine) - -The order payload omitted both: - -* **`account`** — the account the order belongs to, and -* **`orderCode`** — a client-generated idempotency key that DXtrade requires. - -Orders would have been rejected at validation even had they reached the correct -URL with a valid session. `orderCode` is the field that makes retrying a -timed-out order safe rather than a way to accidentally open a second position. - -**Fixed (UNVERIFIED):** both fields are now populated; -`orderCode` is auto-generated per order and can be overridden by the caller. - -### ⚠️ What "Fixed (UNVERIFIED)" means here - -It means **fixed by reading, not by running.** Confirming any of these requires -a funded or demo account at a broker that still enables DXtrade SCA REST -access. I do not have one. SCA access is provisioned per firm, so there is no -universal endpoint set to test against either. - -Everything in this repository — URL paths, the authorization scheme, payload -field names, the login response shape, the WebSocket handshake, the `/ping` -keepalive endpoint — remains **inferred from community examples and a -third-party development guide.** Some of it is probably still wrong. The -included tests assert that the wrapper *builds the requests it intends to -build*; they cannot and do not assert that a DXtrade server accepts them. - -**Do not point this at a funded account.** - -### Other known defects, fixed but likewise unverified - -| Defect | Was | -| --- | --- | -| Account-code fallback | Returned the invented code `"primary"` when login carried no account, guaranteeing a `404` at a nonexistent account. Now raises `DXTradeAPIError` with an actionable message. | -| No session keepalive | Sessions expired silently with no renewal. Now has a `ping()` method and an optional background keepalive thread. | -| `logout()` was a no-op | Its HTTP call was commented out "pending documentation," so sessions were left alive server-side. Now issues the request, best-effort. | -| Dead WebSocket auth header | Guarded by `"token=" not in self._build_websocket_url()` — tautologically false whenever a token existed, so the header was never sent once. | -| Malformed WebSocket URL | Appended `&account=` unconditionally but `?token=` only when a token existed, producing a query string whose first parameter began with `&`. Now built with `urlencode()`. | - -## ✅ Use this instead - -If you want to actually trade on DXtrade from Python, use -**[`dxtrade-sdk`](https://pypi.org/project/dxtrade-sdk/)** — it is -pip-installable and tested, which this is not. +## ⚡ Quick start (no broker account needed) ```bash -pip install dxtrade-sdk +git clone https://github.com/Bogzx/DXtrade-python-wrapper.git +cd DXtrade-python-wrapper +python -m venv .venv && source .venv/bin/activate +pip install -e ".[test]" +python examples/offline_demo.py ``` -This repository is not a competitor to it and should not be treated as one. - -## 🔍 What this repo is actually good for - -Not as a library. As a **case study in reverse-engineering an undocumented -broker API from community examples, and getting it wrong.** - -The useful content is: - -* A complete, commented model of what the DXtrade SCA REST/WebSocket surface - was *believed* to look like from public information alone (`dxtrade_wrapper.py`). -* A concrete record of **how a vendor-side block masked three independent - client-side bugs for two years.** The `403` was real, it was correctly - diagnosed, and diagnosing it correctly was exactly what stopped anyone from - looking any further. -* A demonstration of why "it fails at step 1 for a known external reason" is a - dangerous place to stop debugging. - -## 🎓 What was learned - -1. **A correct diagnosis can still be an incomplete one.** FTMO really did - disable REST access. That true fact absorbed all the suspicion a `403` - generates and no one looked past it. -2. **Test at the boundary you control.** Not one line of this needed a broker to - verify the URL it constructs. The single test asserting that login hits - `/dxsca-web/login` with a `DXAPI` header would have caught two of the three - blockers, offline, in the first hour. It now exists — two years late. -3. **One code path, not eight.** The prefix bug was possible only because URL - construction was duplicated across eight f-strings. One `_url()` helper makes - the whole class of bug structurally impossible. -4. **Don't ship a plausible fallback for missing data.** The `"primary"` account - code turned a clear "I don't know the account" into a misleading `404` from - the server. -5. **Publishing an honest failure beats publishing a hopeful half-success.** A - README that says "works, mostly, probably" costs every reader the time to - discover otherwise. - -## 🔍 Introduction - -`DXTradeDashboardWrapper` was intended to simplify interaction with the DXtrade -trading platform's API, abstracting REST and WebSocket calls behind a dashboard- -oriented interface: authentication, account data, order management, and -real-time streams. - -It was written from a Development Guide that itself relied on publicly available -information and community examples, because official, comprehensive DXtrade API -documentation was not accessible. That inference chain is the root cause of -causes 2 through 4 above. - -## ✨ Features (as designed, none verified) - -Everything below describes **intent**. No item has been confirmed to work. - -### 🔐 Authentication -* Login with username, password, and broker domain/vendor -* Session token handling, `DXAPI` scheme *(unverified)* -* `ping()` keepalive plus optional background thread *(unverified endpoint)* - -### 📈 Account Data Access -* `get_balance()`, `get_positions()`, `get_orders()`, `get_order_history()` -* Structured returns via dataclasses - -### 🛒 Order Management -* `place_order()` — Market, Limit, Stop -* `modify_order()`, `cancel_order()` -* `close_position()`, `modify_position_sl_tp()` -* Linked Stop Loss / Take Profit handling - -### 🔄 Real-Time Data -* `connect_websocket()` / `disconnect_websocket()` -* `subscribe_market_data()`, `subscribe_account_updates()` -* Thread-safe queues: `price_update_queue`, `order_update_queue`, - `account_update_queue` - -### 🚦 Error Handling -* Custom exception hierarchy, logging throughout - -## 🔄 Alternative Approaches - -Some community implementations interact with DXtrade by mimicking the browser: - -1. Authenticating via internal endpoints (e.g. `/api/auth/login`) using **session - cookies** rather than API tokens -2. Scraping a **CSRF token** from the web application page -3. Using the web app's internal WebSocket endpoint (e.g. `/client/connector`) -4. Posting orders to internal endpoints (e.g. `/api/orders/single`) with cookies - and CSRF token - -> **⚠️ Caution:** this is reverse-engineering the web platform. It is **highly -> fragile** and **may violate your broker's Terms of Service**. Not recommended. - -## 📦 Installation - -There is no package to install — see [Packaging](#-packaging). Clone the repo and -install the dependencies. - -### Dependencies - -* `requests` — HTTP REST calls -* `websocket-client` — WebSocket connections -* `python-dotenv` — used by `example.py` to load `.env` - *(previously missing from this list, so following the README broke the - example at `example.py:8`)* - -```bash -pip install requests websocket-client python-dotenv -``` - -Or: - -```bash -pip install -r requirements.txt +The demo runs the client against a simulated DXtrade server: it logs in, reads the +balance, positions and orders, places an entry with SL/TP, modifies, cancels, and logs +out. Then it prints every HTTP request that would have gone to a real broker. + +## 🔌 With a real (demo) account + +```python +from dxtrade_wrapper import DXTradeClient, OrderPlacementError + +with DXTradeClient( + base_url="https://dxtrade.your-broker.com", + username="your-login", + password="...", + domain_or_vendor="default", + account="default:ACC12345", # optional if you have exactly one account +) as dx: + print(dx.get_balance()) # equity, balance, margin, free margin, P/L + print(dx.get_positions()) + print(dx.get_orders()) + + try: + # Entry + stop loss + take profit in ONE request (an IF-THEN order group): + # either all three are accepted or none is. + dx.place_order("EUR/USD", "BUY", 10_000, "MARKET", stop_loss=1.0800, take_profit=1.0950) + except OrderPlacementError as exc: + print(exc.status_code, exc.error_code, exc.description, exc.ambiguous) ``` -### Configuration - -Copy `.env.example` to `.env` and fill it in. `.env` is gitignored; never commit -real credentials. - -```bash -cp .env.example .env -``` +Or put your settings in `.env` (see `.env.example`), `pip install -e ".[examples]"` and +run `python example.py`. It is read-only unless you pass `--demo-order`. + +## 📚 API + +| Method | DXtrade endpoint | +|---|---| +| `login()` / `logout()` / `ping()` | `POST /login`, `/logout`, `/ping` (token auth, `Authorization: DXAPI `) | +| `get_accounts()` | `GET /users/{login@domain}` | +| `get_balance()` → `Balance` | `GET /accounts/{account}/metrics` | +| `get_account_metrics()`, `get_portfolio()` | raw metrics / portfolio | +| `get_positions()` → `[Position]` | `GET /accounts/{account}/positions` | +| `get_orders()` → `[Order]` | `GET /accounts/{account}/orders` | +| `get_order_history(limit, **filters)` | `GET /accounts/{account}/orders/history` (e.g. `in_status="COMPLETED"`, `period="today"`) | +| `place_order(...)` | `POST /accounts/{account}/orders`, as a single order or an IF-THEN group when SL/TP are given | +| `modify_order(order_id, new_price, new_quantity)` | `PUT /accounts/{account}/orders` with `If-Match` | +| `cancel_order(order_id)` | `DELETE /accounts/{account}/orders/{code}` with `If-Match` | +| `close_position(position_id, quantity=None)` | MARKET order with `positionEffect=CLOSE` | +| `modify_position_sl_tp(position_id, stop_loss, take_profit)` | moves existing protection orders (PUT) or adds them (POST) | +| `close_all(instrument=None)` | `POST /accounts/{account}/close` (bulk close) | +| `connect_websocket()`, `subscribe_market_data([...])`, `subscribe_account_updates()` | Push API: quotes → `price_update_queue`, orders → `order_update_queue`, portfolio/metrics → `account_update_queue` | + +**Sessions.** A background thread pings every `keepalive_interval` seconds (capped at +half the session timeout the server reports). If a request still gets `401` because +the session expired, the client logs in again once and retries. Retrying an order is +safe: it keeps its `orderCode`, and DXtrade rejects a duplicate code (409, error 100) +instead of opening a second position. + +**Errors.** Everything derives from `DXTradeWrapperError`. Server errors are +`DXTradeAPIError` subclasses carrying `status_code`, `error_code` and `description` from +DXtrade's error body: `AuthenticationError` (401/403), `NotFoundError`, `ConflictError` +(409 business rejection), `PreconditionFailedError` (412), `RateLimitError` (429, +`retry_after`) and `ServerError`. `OrderPlacementError` adds `order_code` and +`ambiguous`. `ambiguous=True` means the request timed out after it was sent, so check +for that `orderCode` before sending again. `ConnectionError` (alias +`DXTradeConnectionError`) covers network failures. + +**Security.** The password and session token are never logged, and the token is not +put in websocket URLs. The library does not configure logging; call +`logging.basicConfig()` in your app to see its logs. + +**Push API URL.** The Push spec leaves the websocket address to each deployment. Pass +`websocket_url=` (ask your broker). Market data uses the same URL with `/md` appended, +as the spec describes. ## 🧪 Tests -Tests use [`responses`](https://github.com/getsentry/responses) with hand-written -JSON fixtures, so **no broker account is required** to run them — previously the -single largest contributor blocker. - ```bash -pip install -r requirements-dev.txt -python -m pytest tests/ -v +pip install -e ".[test]" +python -m pytest # offline, ~100 tests +DXTRADE_SPEC_TESTS=1 python -m pytest tests/test_spec_conformance.py -v ``` -**What they prove:** that the wrapper builds the URLs, headers, and payloads it -intends to. They regression-lock all three blocking bugs. - -**What they do not prove:** that any DXtrade server accepts those requests. The -fixtures in `tests/fixtures/` are hand-written from the same inferred protocol as -the wrapper — **not recorded from a live session**, because no live session has -ever succeeded. If the inference is wrong, these tests pass while the wrapper -stays broken. Replacing the fixtures with real recorded traffic is the single -most valuable contribution anyone with broker access could make. +* The main suite mocks HTTP with [`responses`](https://github.com/getsentry/responses), + using fixtures shaped like the spec's schemas, and runs the Push channels against a + local websocket server. +* `test_spec_conformance.py` downloads the official OpenAPI document (public, read-only) + and checks two things. First, every request the client sends uses a documented path, + method, query parameter and body fields, and the body validates against the schema. + Second, every fixture matches its response schema. It is opt-in because it needs + network access. The spec is not vendored. + +Known quirks in the OpenAPI document, which the conformance test tolerates: +`AccountCode` and the date/time types are modelled as objects while the prose spec and +its examples use strings, and order groups (IF-THEN/OCO) are documented in prose but +missing from the OpenAPI request body. + +**What this does not prove:** that your broker's deployment behaves like the spec. +Recorded traffic from a real demo account, with credentials and tokens removed, is the +most valuable contribution anyone with access could make. + +## 🗂 History: why this was broken for two years + +The first version was reverse-engineered from community examples. It never completed a +call. FTMO's `403` (REST access disabled for clients in April 2024) looked like the only +problem and hid the others. A 2026 review found the client-side bugs: a missing +`/dxsca-web` prefix on every call after login, `Bearer` instead of `DXAPI`, and orders +missing `account`/`orderCode`. A later check against the public spec found more: +invented field names in every parser, stop loss / take profit silently never placed, +wrong endpoints and methods for history, modify, close and SL/TP, missing `If-Match` +headers, a Push protocol that did not exist, and a balance that turned any parse error +into zeros. [`CHANGELOG.md`](CHANGELOG.md) has the details. + +Lessons that still apply: + +1. **A correct diagnosis can still be an incomplete one.** The 403 was real, and it + stopped anyone looking further. +2. **Check whether the docs exist before inferring.** The official spec had been public + the whole time. +3. **Test at the boundary you control.** Validating outgoing requests against a schema + needs no broker. +4. **Don't fabricate fallbacks.** An invented account code, zeros on parse failure, and + SL/TP skipped with only a log line all turn a clear error into a silent wrong answer. ## 📦 Packaging -**Deliberately not published to PyPI, and there is no `pyproject.toml`.** - -Publishing a non-functional package next to the working, tested `dxtrade-sdk` -would be net-negative: it would compete for installs on name recognition while -being unable to place an order. Packaging becomes worth doing if and only if the -protocol fixes above are verified against a live broker. Until then, the honest -distribution channel for this repo is the README you are reading. +Installable with `pip install .` or +`pip install git+https://github.com/Bogzx/DXtrade-python-wrapper`. It ships type hints +(`py.typed`). It is not published to PyPI. There is currently no package called +`dxtrade-sdk` on PyPI (an earlier version of this README recommended one). Community +SDKs exist on GitHub; they have not been evaluated here. ## ⚖️ Disclaimer -This wrapper was built from inferred information and community examples. It is -**not functional**, for the four independent reasons documented above, and its -fixes are unverified. - -**Use at your own risk.** Automated trading carries significant financial risk. -The authors and contributors are not responsible for any financial losses or -other damages. Never point unverified trading code at a funded account. - ---- - -*This project is not affiliated with, endorsed by, or connected to Devexperts, -DXTRADE, or FTMO.* +Not affiliated with Devexperts, DXtrade or any broker. Automated trading carries +significant financial risk. This client has not been verified against a live server: +use a demo account, and never point untested trading code at a funded account. diff --git a/example.py b/example.py index dc8977e..357a208 100644 --- a/example.py +++ b/example.py @@ -1,237 +1,96 @@ -# main.py +"""Read-only tour of a real DXtrade account: balance, positions, orders, live quotes. + + pip install -e ".[examples]" + cp .env.example .env # fill in your broker URL and credentials + python example.py + +It places no orders. Pass --demo-order to place (and immediately cancel) one far +from the market; use a DEMO account for that. This client follows the public +DXtrade spec but has not been verified against a live broker. + +No broker account? Run examples/offline_demo.py instead. +""" import logging import os import queue +import sys import time -import threading -from dotenv import load_dotenv - -# Import your wrapper class (assuming it's in a file named dxtrade_wrapper.py) -from dxtrade_wrapper import ( - DXTradeDashboardWrapper, - AuthenticationError, - DXTradeAPIError, - OrderPlacementError, - WebSocketError -) - -# ============================================================================= -# THIS EXAMPLE HAS NEVER RUN SUCCESSFULLY. -# -# It is kept as an illustration of the intended API, not as a working demo. The -# wrapper is a non-functional prototype: FTMO disabled DXtrade REST access for -# clients in April 2024, and three further blocking bugs (missing /dxsca-web -# prefix, wrong Bearer authorization scheme, order payload missing account and -# orderCode) would have broken it against any other broker. Those three are -# fixed but UNVERIFIED - see the README's "Project Status" section. -# -# Expect this to fail. Do not point it at a funded account. -# -# Requires: pip install -r requirements.txt (python-dotenv is needed for the -# load_dotenv() call below - it was missing from the README's dependency list.) -# ============================================================================= - -# --- Configuration --- -# Load environment variables from .env file if it exists -load_dotenv() +from dotenv import load_dotenv -DXTRADE_BASE_URL = os.getenv("DXTRADE_BASE_URL", "https://dxtrade.ftmo.com") # Replace with your broker's URL -DXTRADE_USERNAME = os.getenv("DXTRADE_USERNAME", "YOUR_USERNAME") -DXTRADE_PASSWORD = os.getenv("DXTRADE_PASSWORD", "YOUR_PASSWORD") -# For FTMO, this should likely be "ftmo" -DXTRADE_DOMAIN_VENDOR = os.getenv("DXTRADE_DOMAIN_VENDOR", "ftmo") -# Shared prefix for every REST endpoint, login included. Applied by the wrapper's -# _url() helper - see the README, cause 2. -DXTRADE_API_PREFIX = os.getenv("DXTRADE_API_PREFIX", "/dxsca-web") -# Login path, relative to the prefix. A value that still carries the prefix -# (e.g. "/dxsca-web/login") is normalised by the wrapper, so old .env files work. -DXTRADE_LOGIN_PATH = os.getenv("DXTRADE_LOGIN_PATH", "/login") -# Account code. The login response does not reliably carry one, and the wrapper -# now raises rather than guessing, so set this if you know it. -DXTRADE_ACCOUNT = os.getenv("DXTRADE_ACCOUNT") or None -# WebSocket path needs verification for your broker -DXTRADE_WEBSOCKET_PATH = os.getenv("DXTRADE_WEBSOCKET_PATH", "/websocket/events") # Adjust as needed! - -# Example symbols to subscribe to -SYMBOLS_TO_SUBSCRIBE = ["EURUSD", "GBPUSD", "USDJPY"] # Adjust as needed - -# --- Logging Setup --- -logging.basicConfig( - level=logging.INFO, # Set to DEBUG for more detailed wrapper logs - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger("main_test") - - -# --- Main Test Function --- -def main(): - logger.info("Starting DXTrade Wrapper Test...") - - # --- Input Validation --- - if "YOUR_USERNAME" == DXTRADE_USERNAME or "YOUR_PASSWORD" == DXTRADE_PASSWORD: - logger.error("Please replace placeholder credentials or set environment variables.") - return - if "https://" not in DXTRADE_BASE_URL: - logger.error(f"Invalid DXTRADE_BASE_URL: {DXTRADE_BASE_URL}") - return - - # --- Instantiate Wrapper --- - # Pass necessary configuration parameters - wrapper = DXTradeDashboardWrapper( - base_url=DXTRADE_BASE_URL, - username=DXTRADE_USERNAME, - password=DXTRADE_PASSWORD, - domain_or_vendor=DXTRADE_DOMAIN_VENDOR, - api_prefix=DXTRADE_API_PREFIX, - login_path=DXTRADE_LOGIN_PATH, - websocket_path=DXTRADE_WEBSOCKET_PATH, - account=DXTRADE_ACCOUNT, - logger=logging.getLogger("DXTradeWrapper") # Pass specific logger - ) - logger.info("DXTradeDashboardWrapper instantiated.") +from dxtrade_wrapper import DXTradeDashboardWrapper, DXTradeWrapperError +load_dotenv() +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +log = logging.getLogger("example") + +SYMBOLS = os.getenv("DXTRADE_SYMBOLS", "EUR/USD,GBP/USD").split(",") + + +def main() -> int: + base_url = os.getenv("DXTRADE_BASE_URL", "") + if not base_url.startswith("https://") or not os.getenv("DXTRADE_USERNAME"): + log.error("Set DXTRADE_BASE_URL (https://...), DXTRADE_USERNAME and DXTRADE_PASSWORD " + "in .env (see .env.example).") + return 2 + + client = DXTradeDashboardWrapper( + base_url=base_url, + username=os.environ["DXTRADE_USERNAME"], + password=os.getenv("DXTRADE_PASSWORD", ""), + domain_or_vendor=os.getenv("DXTRADE_DOMAIN_VENDOR", "default"), + api_prefix=os.getenv("DXTRADE_API_PREFIX", "/dxsca-web"), + account=os.getenv("DXTRADE_ACCOUNT") or None, + websocket_url=os.getenv("DXTRADE_WEBSOCKET_URL") or None, + ) try: - # --- Login --- - logger.info("Attempting login...") - wrapper.login() - if wrapper.is_authenticated: - logger.info(f"Login successful! Account ID: {wrapper._account_id}") # Accessing internal for demo - else: - logger.error("Login reported success but wrapper state is not authenticated.") - return # Exit if login fails internally - - # --- Test REST API Calls (Authenticated) --- - try: - logger.info("Fetching account balance...") - balance = wrapper.get_balance() - logger.info(f"Balance: {balance}") - - logger.info("Fetching open positions...") - positions = wrapper.get_positions() - if positions: - logger.info(f"Found {len(positions)} open positions:") - for pos in positions: - logger.info(f" - {pos}") - else: - logger.info("No open positions found.") - - logger.info("Fetching pending orders...") - orders = wrapper.get_orders() - if orders: - logger.info(f"Found {len(orders)} pending orders:") - for order in orders: - logger.info(f" - {order}") + with client: + log.info("Accounts: %s", client.get_accounts()) + log.info("Balance: %s", client.get_balance()) + for position in client.get_positions(): + log.info("Position: %s", position) + for order in client.get_orders(): + log.info("Working order: %s", order) + + if "--demo-order" in sys.argv: + demo_order(client) + + if os.getenv("DXTRADE_WEBSOCKET_URL"): + stream_quotes(client, seconds=15) else: - logger.info("No pending orders found.") - - except DXTradeAPIError as api_err: - logger.error(f"API Error during data fetch: {api_err}") - except Exception as e: - logger.error(f"Unexpected error during data fetch: {e}", exc_info=True) - - - # --- Test WebSocket --- - try: - logger.info("Connecting to WebSocket...") - wrapper.connect_websocket() - # Give it a moment to potentially connect - time.sleep(2) - - if wrapper._ws_connected: # Accessing internal for demo check - logger.info("WebSocket connection initiated (check logs for confirmation).") - - logger.info(f"Subscribing to market data for: {SYMBOLS_TO_SUBSCRIBE}") - wrapper.subscribe_market_data(SYMBOLS_TO_SUBSCRIBE) - - logger.info("Subscribing to account updates...") - wrapper.subscribe_account_updates() - - # --- Process WebSocket Messages Loop --- - logger.info("Starting WebSocket message processing loop (Press Ctrl+C to stop)...") - running = True - while running: - try: - # Check Price Queue (Non-blocking) - try: - price_update = wrapper.price_update_queue.get_nowait() - logger.info(f"WebSocket << Price Update: {price_update}") - except queue.Empty: - pass # No message - - # Check Order Queue (Non-blocking) - try: - order_update = wrapper.order_update_queue.get_nowait() - logger.info(f"WebSocket << Order Update: {order_update}") - except queue.Empty: - pass # No message - - # Check Account Queue (Non-blocking) - try: - account_update = wrapper.account_update_queue.get_nowait() - logger.info(f"WebSocket << Account Update: {account_update}") - except queue.Empty: - pass # No message - - # --- !! Optional: Place a Test Order (USE DEMO ACCOUNT ONLY) !! --- - # Uncomment carefully for testing order placement - # try: - # logger.info("Attempting to place a small test market order...") - # order_result = wrapper.place_order( - # instrument="EURUSD", # Or another valid symbol - # side="BUY", - # quantity=0.01, # Minimum allowed quantity - # order_type="MARKET" - # ) - # logger.info(f"Test order placement result: {order_result}") - # # Prevent placing multiple orders in the loop - # # You'd typically place orders based on external triggers - # except OrderPlacementError as ord_err: - # logger.error(f"Test order placement failed: {ord_err}") - # except Exception as ord_exc: - # logger.error(f"Unexpected error during test order: {ord_exc}", exc_info=True) - # # --- End Optional Test Order --- - - # Sleep briefly to avoid busy-waiting - time.sleep(0.1) - - except KeyboardInterrupt: - logger.info("KeyboardInterrupt received, stopping WebSocket processing.") - running = False - except Exception as loop_err: - logger.error(f"Error in WebSocket processing loop: {loop_err}", exc_info=True) - # Avoid continuous error loops - time.sleep(1) + log.info("Set DXTRADE_WEBSOCKET_URL (ask your broker) to stream quotes.") + except DXTradeWrapperError as exc: + log.error("%s: %s", type(exc).__name__, exc) + return 1 + return 0 + + +def demo_order(client: DXTradeDashboardWrapper) -> None: + """A tiny LIMIT buy at DXTRADE_DEMO_PRICE (default 0.5, far below market), then cancelled.""" + symbol = SYMBOLS[0] + price = float(os.getenv("DXTRADE_DEMO_PRICE", "0.5")) + code = client._generate_order_code() + log.info("Placing demo LIMIT BUY %s @ %s (orderCode %s)", symbol, price, code) + log.info("Response: %s", client.place_order(symbol, "BUY", 1000, "LIMIT", price=price, + orderCode=code)) + log.info("Cancel: %s", client.cancel_order(code)) + + +def stream_quotes(client: DXTradeDashboardWrapper, seconds: int) -> None: + client.connect_websocket() + client.subscribe_market_data(SYMBOLS) + client.subscribe_account_updates() + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + for name, q in (("quote", client.price_update_queue), + ("order", client.order_update_queue), + ("account", client.account_update_queue)): + try: + log.info("%s: %s", name, q.get(timeout=0.2)) + except queue.Empty: + pass + - else: - logger.error("WebSocket failed to connect.") - - except WebSocketError as ws_err: - logger.error(f"WebSocket Error: {ws_err}") - except Exception as e: - logger.error(f"Unexpected error during WebSocket setup: {e}", exc_info=True) - - - except AuthenticationError as auth_err: - logger.error(f"Authentication Failed: {auth_err}") - - except Exception as e: - logger.error(f"An unexpected error occurred in main: {e}", exc_info=True) - - finally: - # --- Cleanup --- - logger.info("Cleaning up...") - if wrapper: # Check if wrapper was instantiated - if wrapper._ws_connected: - logger.info("Disconnecting WebSocket...") - wrapper.disconnect_websocket() - if wrapper.is_authenticated: - logger.info("Logging out...") - wrapper.logout() - logger.info("Test finished.") - - -# --- Run the main function --- if __name__ == "__main__": - main() \ No newline at end of file + sys.exit(main()) diff --git a/examples/offline_demo.py b/examples/offline_demo.py new file mode 100644 index 0000000..4580eaf --- /dev/null +++ b/examples/offline_demo.py @@ -0,0 +1,89 @@ +"""Runs the client end to end against a simulated DXtrade server. No account needed. + + pip install -e ".[test]" + python examples/offline_demo.py + +The simulated responses follow the public DXtrade REST spec. Every request the +client sends is printed, so you can see exactly what would go over the wire. +""" + +import json +import re + +import responses + +from dxtrade_wrapper import DXTradeDashboardWrapper, OrderPlacementError + +BASE = "https://dxtrade.example-broker.com" +API = f"{BASE}/dxsca-web" +ACC = f"{API}/accounts/default%3ADEMO1" + +POSITION = {"account": "default:DEMO1", "version": 5, "positionCode": "1001", + "symbol": "EUR/USD", "quantity": 10000, "quantityNotional": 10000, "side": "BUY", + "openTime": "2026-09-30T08:00:00Z", "openPrice": 1.0850, + "lastUpdateTime": "2026-09-30T08:00:00Z"} +ORDER = {"account": "default:DEMO1", "orderId": 2002, "orderCode": "srv-2002", "version": 5, + "clientOrderId": "my-limit-1", "actionCode": "a1", "legCount": 1, "type": "LIMIT", + "instrument": "EUR/USD", "status": "WORKING", "finalStatus": False, + "legs": [{"instrument": "EUR/USD", "positionEffect": "OPEN", "side": "BUY", + "price": 1.0500, "quantity": 5000}], + "side": "BUY", "tif": "GTC", "issueTime": "2026-09-30T08:05:00Z", + "transactionTime": "2026-09-30T08:05:00Z"} + + +def simulate(api: responses.RequestsMock) -> None: + api.add(responses.POST, f"{API}/login", json={"sessionToken": "demo-token", + "timeout": "00:30:00"}) + api.add(responses.GET, f"{API}/users/demo%40default", json={"userDetails": [{ + "login": "demo", "domain": "default", "username": "demo@default", "version": 1, + "fullName": "Demo", "accounts": [{"account": "default:DEMO1", "baseCurrency": "USD", + "accountStatus": "FULL_TRADING", + "positionBased": True}]}]}) + api.add(responses.GET, f"{ACC}/metrics", json={"metrics": [{ + "account": "default:DEMO1", "equity": 10050.0, "balance": 10000.0, "margin": 350.0, + "marginFree": 9700.0, "availableFunds": 9700.0, "openPL": 50.0, "totalPL": 50.0}]}) + api.add(responses.GET, f"{ACC}/positions", json={"positions": [POSITION]}) + api.add(responses.GET, f"{ACC}/orders", json={"orders": [ORDER]}, headers={"ETag": '"v5"'}) + api.add(responses.POST, f"{ACC}/orders", json={"orderResponses": [ + {"orderId": 3003, "updateOrderId": 3003}, {"orderId": 3004, "updateOrderId": 3004}, + {"orderId": 3005, "updateOrderId": 3005}]}) + api.add(responses.PUT, f"{ACC}/orders", json={"orderId": 2002, "updateOrderId": 2010}) + api.add(responses.DELETE, re.compile(rf"{re.escape(ACC)}/orders/.+"), + json={"orderId": 2002, "updateOrderId": 2011}) + api.add(responses.POST, f"{API}/logout", body="") + + +def main() -> None: + with responses.RequestsMock(assert_all_requests_are_fired=False) as api: + simulate(api) + with DXTradeDashboardWrapper(BASE, "demo", "demo-password", "default", + keepalive_interval=None) as dx: + print("accounts :", dx.get_accounts()) + print("balance :", dx.get_balance()) + print("positions:", dx.get_positions()) + print("orders :", dx.get_orders()) + print("entry+SL+TP:", dx.place_order("EUR/USD", "BUY", 10000, "MARKET", + stop_loss=1.0800, take_profit=1.0950)) + print("modify :", dx.modify_order("my-limit-1", new_price=1.0520)) + print("cancel :", dx.cancel_order("my-limit-1")) + try: + dx.place_order("EUR/USD", "BUY", 1000, "LIMIT") # no price + except ValueError as exc: + print("rejected locally:", exc) + except OrderPlacementError as exc: # pragma: no cover + print("rejected by server:", exc) + + print("\nRequests sent:") + for call in api.calls: + req = call.request + headers = {k: v for k, v in req.headers.items() if k in ("Authorization", "If-Match")} + payload = json.loads(req.body) if req.body else None + if payload and "password" in payload: + payload["password"] = "***" + print(f" {req.method:6} {req.url.replace(BASE, '')} {headers or ''}") + if payload: + print(" ", json.dumps(payload)) + + +if __name__ == "__main__": + main() From e55565d3b5391df152fa79f9d7f5fb8e00a74b33 Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:37:10 +0300 Subject: [PATCH 06/10] Position P/L on request; retry rate-limited reads once - get_positions(include_pnl=True) fills Position.pnl from the per-position fpl in /metrics?include-positions=true (one extra request). - A GET that gets 429 waits out Retry-After (up to 5s, 1s if absent) and retries once; the spec's default read limit is 10/s, easy to hit from a dashboard poll loop. Orders are never retried on 429. Co-Authored-By: Claude Opus 5.5 --- dxtrade_wrapper/client.py | 68 +++++++++++++++++++++++++++++--------- tests/test_account_data.py | 20 +++++++++++ tests/test_session.py | 31 ++++++++++++++++- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/dxtrade_wrapper/client.py b/dxtrade_wrapper/client.py index 1529d03..ff8246f 100644 --- a/dxtrade_wrapper/client.py +++ b/dxtrade_wrapper/client.py @@ -262,11 +262,7 @@ def _raise_for_response(self, response: requests.Response, context: str) -> None if status == 412: raise PreconditionFailedError(message, **kwargs) if status == 429: - retry_after = response.headers.get("Retry-After") - try: - retry = float(retry_after) if retry_after else None - except ValueError: - retry = None + retry = self._retry_after(response) if "Retry-After" in response.headers else None raise RateLimitError(message, retry_after=retry, **kwargs) if status >= 500: raise ServerError(message, **kwargs) @@ -292,13 +288,14 @@ def _request( orderCode. """ url = self._url(path) - try: - response = self._session.request( - method, url, json=json_body, params=params, headers=headers, timeout=self._timeout - ) - except requests.exceptions.RequestException as exc: - # str(exc) carries the URL but never the body or auth header. - raise ConnectionError(f"{context}: network error: {exc}") from exc + response = self._send(method, url, context, json_body, params, headers) + if response.status_code == 429 and method == "GET": + # Reads are idempotent: wait as asked (briefly) and try once more. + delay = self._retry_after(response) + if delay is not None and delay <= self.MAX_RATE_LIMIT_WAIT: + self._logger.info("%s rate-limited; retrying in %.1fs", context, delay) + time.sleep(delay) + response = self._send(method, url, context, json_body, params, headers) if ( response.status_code == 401 @@ -316,6 +313,31 @@ def _request( self._raise_for_response(response, context) return response + #: Longest Retry-After (seconds) a GET waits out before one automatic retry. + MAX_RATE_LIMIT_WAIT = 5.0 + + def _send(self, method: str, url: str, context: str, json_body: Any, + params: Optional[Mapping[str, Any]], + headers: Optional[Mapping[str, str]]) -> requests.Response: + try: + return self._session.request( + method, url, json=json_body, params=params, headers=headers, timeout=self._timeout + ) + except requests.exceptions.RequestException as exc: + # str(exc) carries the URL but never the body or auth header. + raise ConnectionError(f"{context}: network error: {exc}") from exc + + @staticmethod + def _retry_after(response: requests.Response) -> Optional[float]: + """Retry-After in seconds; 1s when the server sent none.""" + raw = response.headers.get("Retry-After") + if raw is None: + return 1.0 + try: + return max(float(raw), 0.0) + except ValueError: + return None + @staticmethod def _json(response: requests.Response, context: str) -> Any: if not response.content: @@ -575,12 +597,26 @@ def get_portfolio(self) -> JSON: raise DXTradeAPIError("Portfolio response was empty") return portfolios[0] - def get_positions(self) -> List[Position]: - """Open positions from GET /accounts/{account}/positions.""" + def get_positions(self, include_pnl: bool = False) -> List[Position]: + """Open positions from GET /accounts/{account}/positions. + + Args: + include_pnl: Also fetch per-position metrics and fill ``Position.pnl`` + with the floating P/L (``fpl``, account currency). One extra request. + """ self._check_authenticated() response = self._request("GET", self._account_path("positions"), "Get positions") - return [parse_position(p) - for p in unwrap_list(self._json(response, "Get positions"), "positions")] + positions = [parse_position(p) + for p in unwrap_list(self._json(response, "Get positions"), "positions")] + if include_pnl and positions: + metrics = self.get_account_metrics(include_positions=True) + fpl = {str(m.get("positionCode")): m.get("fpl") + for m in metrics.get("positions") or [] if isinstance(m, Mapping)} + for position in positions: + value = fpl.get(position.position_id) + if value is not None: + position.pnl = float(value) + return positions def _open_orders_raw(self) -> Tuple[List[JSON], Optional[str]]: """Open orders plus the ETag the server sent with them (for If-Match).""" diff --git a/tests/test_account_data.py b/tests/test_account_data.py index 6086d44..8e0e844 100644 --- a/tests/test_account_data.py +++ b/tests/test_account_data.py @@ -159,3 +159,23 @@ def test_portfolio_is_unwrapped(client, api): json={"portfolios": [{"account": ACCOUNT, "version": 1, "balances": [], "positions": [], "orders": []}]}) assert client.get_portfolio()["account"] == ACCOUNT + + +def test_positions_can_include_floating_pnl(client, api): + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + metrics = fixture("metrics") + metrics["metrics"][0]["positions"] = [{"positionCode": "63649", "symbol": "EUR/USD", + "fpl": 125.5, "quantity": 100000}] + api.add(responses.GET, f"{ACC}/metrics", json=metrics) + + [position] = client.get_positions(include_pnl=True) + + assert position.pnl == 125.5 + assert "include-positions=true" in api.calls[-1].request.url + + +def test_positions_without_pnl_make_one_request(client, api): + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + [position] = client.get_positions() + assert position.pnl is None + assert len(api.calls) == 2 diff --git a/tests/test_session.py b/tests/test_session.py index b88cf9a..09e821b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -57,7 +57,8 @@ def test_calls_before_login_raise(): "status, exc_type", [(404, NotFoundError), (429, RateLimitError), (500, ServerError), (400, DXTradeAPIError)], ) -def test_http_errors_map_to_typed_exceptions(client, api, status, exc_type): +def test_http_errors_map_to_typed_exceptions(client, api, status, exc_type, monkeypatch): + monkeypatch.setattr("dxtrade_wrapper.client.time.sleep", lambda seconds: None) api.add(responses.GET, f"{ACC}/positions", json={"errorCode": "2", "description": "Entity not found at server"}, status=status, headers={"Retry-After": "3"}) @@ -189,3 +190,31 @@ def test_library_does_not_configure_logging(): make_wrapper() handlers = logging.getLogger("dxtrade_wrapper").handlers assert all(isinstance(h, logging.NullHandler) for h in handlers) + + +def test_rate_limited_read_is_retried_once_after_retry_after(client, api, monkeypatch): + sleeps = [] + monkeypatch.setattr("dxtrade_wrapper.client.time.sleep", sleeps.append) + api.add(responses.GET, f"{ACC}/positions", status=429, headers={"Retry-After": "2"}) + api.add(responses.GET, f"{ACC}/positions", json=fixture("positions")) + assert len(client.get_positions()) == 1 + assert sleeps == [2.0] + + +def test_long_retry_after_is_not_waited_out(client, api, monkeypatch): + monkeypatch.setattr("dxtrade_wrapper.client.time.sleep", lambda s: pytest.fail("slept")) + api.add(responses.GET, f"{ACC}/positions", status=429, headers={"Retry-After": "60"}) + with pytest.raises(RateLimitError) as excinfo: + client.get_positions() + assert excinfo.value.retry_after == 60.0 + + +def test_rate_limited_order_is_never_retried_automatically(client, api, monkeypatch): + from dxtrade_wrapper import OrderPlacementError + + monkeypatch.setattr("dxtrade_wrapper.client.time.sleep", lambda s: pytest.fail("slept")) + api.add(responses.POST, f"{ACC}/orders", status=429, headers={"Retry-After": "1"}) + with pytest.raises(OrderPlacementError) as excinfo: + client.place_order("EUR/USD", "BUY", 1000, "MARKET") + assert excinfo.value.status_code == 429 + assert len(api.calls) == 2 From 92c597838eb057339e5945293fdcf898e118075b Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:37:18 +0300 Subject: [PATCH 07/10] Document include_pnl and the 429 retry Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ README.MD | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc49a2..538dfd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ Spec-conformant; not yet verified against a live broker. - One automatic re-login on 401; keepalive capped by the server's session timeout. - Account discovery via `GET /users/{login@domain}`; `get_accounts()`, `get_account_metrics()`, `get_portfolio()`, `close_all()` (bulk close). +- `get_positions(include_pnl=True)` (floating P/L from per-position metrics); + one automatic retry of rate-limited (429) GETs. - Context manager, `DXTradeClient` alias, `pyproject.toml`, `py.typed`. - Conformance tests against the official OpenAPI document; Push tests over a real local websocket; offline demo. diff --git a/README.MD b/README.MD index cf26858..d0c5b1b 100644 --- a/README.MD +++ b/README.MD @@ -66,7 +66,7 @@ run `python example.py`. It is read-only unless you pass `--demo-order`. | `get_accounts()` | `GET /users/{login@domain}` | | `get_balance()` → `Balance` | `GET /accounts/{account}/metrics` | | `get_account_metrics()`, `get_portfolio()` | raw metrics / portfolio | -| `get_positions()` → `[Position]` | `GET /accounts/{account}/positions` | +| `get_positions(include_pnl=False)` → `[Position]` | `GET /accounts/{account}/positions` (+ per-position `fpl` from metrics with `include_pnl=True`) | | `get_orders()` → `[Order]` | `GET /accounts/{account}/orders` | | `get_order_history(limit, **filters)` | `GET /accounts/{account}/orders/history` (e.g. `in_status="COMPLETED"`, `period="today"`) | | `place_order(...)` | `POST /accounts/{account}/orders`, as a single order or an IF-THEN group when SL/TP are given | @@ -81,7 +81,8 @@ run `python example.py`. It is read-only unless you pass `--demo-order`. half the session timeout the server reports). If a request still gets `401` because the session expired, the client logs in again once and retries. Retrying an order is safe: it keeps its `orderCode`, and DXtrade rejects a duplicate code (409, error 100) -instead of opening a second position. +instead of opening a second position. A read (GET) that is rate-limited (429) waits out +`Retry-After` (up to 5s) and retries once. Orders are never retried automatically on 429. **Errors.** Everything derives from `DXTradeWrapperError`. Server errors are `DXTradeAPIError` subclasses carrying `status_code`, `error_code` and `description` from From 6d22b6b022ad55f9e092ecac841c7e2a763144af Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:45:30 +0300 Subject: [PATCH 08/10] review: keep SL/TP on group modify; rebuild 412 retries; flag 5xx as ambiguous Independent review against the prose REST spec (demo.dx.trade/developers): - modify_order on the entry of an IF-THEN group sent a single-order PUT. Modify Order example 1 shows that converts the group to a single order, so the stop loss and take profit disappear. Members of a working group are now modified as the whole group (example 2b); OCO members and groups with a missing child are refused with nothing sent. - The Open Orders listing gives OPEN legs positionCode == orderId; the replace request copied it, which the spec says must be omitted unless positionEffect is CLOSE. The fixture now has it, as in the spec. - The 412 retry resent the body built from the stale read, silently undoing the change that caused the 412. It is rebuilt from the re-read. - 5xx on placement, and a group acknowledged with fewer OrderResponses than orders sent, are now OrderPlacementError(ambiguous=True). - Concurrent 401s on one stale token log in once; _relogin no longer flips is_authenticated off while logging in. - Conformance test also checks the prose rules (positionCode only on CLOSE, no type in PUT) and a group modify. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +++ README.MD | 9 +- dxtrade_wrapper/client.py | 210 ++++++++++++++++++++++++------- dxtrade_wrapper/exceptions.py | 3 +- tests/fixtures/orders.json | 1 + tests/fixtures/orders_group.json | 113 +++++++++++++++++ tests/test_orders.py | 125 ++++++++++++++++++ tests/test_session.py | 23 ++++ tests/test_spec_conformance.py | 16 ++- 9 files changed, 466 insertions(+), 48 deletions(-) create mode 100644 tests/fixtures/orders_group.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 538dfd1..72e4841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Rebuilt against the public DXtrade REST/Push specification (https://demo.dx.trade/developers/, OpenAPI at /dxsca-web/swagger/openapi.json). Spec-conformant; not yet verified against a live broker. +### Fixed in independent review +- `modify_order` on the entry (or a pending SL/TP) of an IF-THEN group sent a single + order, which per the spec turns the group into a single order and drops its stop + loss and take profit. Group members are now modified as the whole group; OCO members + and groups with a missing child are refused with nothing sent. +- Modify requests carried `positionCode` on OPEN orders (the order list includes it; + the spec says it must be omitted unless CLOSE). +- A 412 retry resent the body built from the stale order; it is now rebuilt from the + re-read order. +- A 5xx on order placement, or a group acknowledged with fewer order responses than + orders sent, is now `ambiguous=True`. +- Two threads hitting 401 on the same expired token no longer both log in. +- Build requirement raised to setuptools>=77 (needed for the SPDX `license` string). + ### Fixed (each would have failed at any broker) - Stop loss / take profit were never placed: they waited for a `positionCode` the order response does not contain. Now one atomic IF-THEN order group. diff --git a/README.MD b/README.MD index d0c5b1b..172991e 100644 --- a/README.MD +++ b/README.MD @@ -49,7 +49,7 @@ with DXTradeClient( try: # Entry + stop loss + take profit in ONE request (an IF-THEN order group): - # either all three are accepted or none is. + # a rejected request places nothing. SL/TP activate when the entry fills. dx.place_order("EUR/USD", "BUY", 10_000, "MARKET", stop_loss=1.0800, take_profit=1.0950) except OrderPlacementError as exc: print(exc.status_code, exc.error_code, exc.description, exc.ambiguous) @@ -70,7 +70,7 @@ run `python example.py`. It is read-only unless you pass `--demo-order`. | `get_orders()` → `[Order]` | `GET /accounts/{account}/orders` | | `get_order_history(limit, **filters)` | `GET /accounts/{account}/orders/history` (e.g. `in_status="COMPLETED"`, `period="today"`) | | `place_order(...)` | `POST /accounts/{account}/orders`, as a single order or an IF-THEN group when SL/TP are given | -| `modify_order(order_id, new_price, new_quantity)` | `PUT /accounts/{account}/orders` with `If-Match` | +| `modify_order(order_id, new_price, new_quantity)` | `PUT /accounts/{account}/orders` with `If-Match`; a member of a working IF-THEN group is sent as the whole group, so its SL/TP are kept | | `cancel_order(order_id)` | `DELETE /accounts/{account}/orders/{code}` with `If-Match` | | `close_position(position_id, quantity=None)` | MARKET order with `positionEffect=CLOSE` | | `modify_position_sl_tp(position_id, stop_loss, take_profit)` | moves existing protection orders (PUT) or adds them (POST) | @@ -89,8 +89,9 @@ instead of opening a second position. A read (GET) that is rate-limited (429) wa DXtrade's error body: `AuthenticationError` (401/403), `NotFoundError`, `ConflictError` (409 business rejection), `PreconditionFailedError` (412), `RateLimitError` (429, `retry_after`) and `ServerError`. `OrderPlacementError` adds `order_code` and -`ambiguous`. `ambiguous=True` means the request timed out after it was sent, so check -for that `orderCode` before sending again. `ConnectionError` (alias +`ambiguous`. `ambiguous=True` means the outcome is unknown (a timeout after sending, a +5xx, or an order group the server acknowledged only in part), so check for that +`orderCode` before sending again. `ConnectionError` (alias `DXTradeConnectionError`) covers network failures. **Security.** The password and session token are never logged, and the token is not diff --git a/dxtrade_wrapper/client.py b/dxtrade_wrapper/client.py index ff8246f..de107e6 100644 --- a/dxtrade_wrapper/client.py +++ b/dxtrade_wrapper/client.py @@ -288,6 +288,7 @@ def _request( orderCode. """ url = self._url(path) + token_used = self._auth_token response = self._send(method, url, context, json_body, params, headers) if response.status_code == 429 and method == "GET": # Reads are idempotent: wait as asked (briefly) and try once more. @@ -304,7 +305,7 @@ def _request( and self._is_authenticated ): self._logger.info("%s got 401; session expired, logging in again", context) - self._relogin() + self._relogin(stale_token=token_used) return self._request( method, path, context, json_body=json_body, params=params, headers=headers, retry_auth=False, @@ -409,9 +410,16 @@ def login(self) -> None: self._start_keepalive() self._logger.info("Authenticated as %s", self._username) - def _relogin(self) -> None: + def _relogin(self, stale_token: Optional[str] = None) -> None: + """Logs in again, unless another thread already replaced ``stale_token``. + + Two threads that both get 401 on the same expired token must not both log + in: the second login could invalidate the session the first just made. + """ with self._auth_lock: - self._is_authenticated = False + if (stale_token is not None and self._is_authenticated + and self._auth_token != stale_token): + return self.login() def logout(self) -> None: @@ -488,9 +496,10 @@ def _keepalive_loop(self) -> None: while not self._keepalive_stop.wait(self._effective_keepalive() or 60.0): if not self._is_authenticated: break + token = self._auth_token if not self.ping() and self._auto_relogin: try: - self._relogin() + self._relogin(stale_token=token) except Exception as exc: # noqa: BLE001 - keep the thread alive self._logger.warning("Re-login after failed ping failed: %s", exc) @@ -677,8 +686,15 @@ def _post_order(self, body: JSON, order_code: str, context: str) -> Any: except DXTradeAPIError as exc: if isinstance(exc, AuthenticationError) and not self._is_authenticated: raise + # A 5xx (often a gateway timeout) does not say whether the order reached + # the matching engine, so the outcome is unknown, like a read timeout. + unknown = isinstance(exc, ServerError) + message = str(exc) + if unknown: + message += (f". The order may have been placed: check for orderCode " + f"{order_code!r} before retrying, or resend with the same orderCode.") raise OrderPlacementError( - str(exc), order_code=order_code, status_code=exc.status_code, + message, order_code=order_code, ambiguous=unknown, status_code=exc.status_code, error_code=exc.error_code, description=exc.description, body=exc.body, ) from exc return self._json(response, context) @@ -705,11 +721,15 @@ def place_order( order_type: "MARKET", "LIMIT" or "STOP". price: Limit price (LIMIT) or stop price (STOP). stop_loss, take_profit: Protection prices. They are sent in the same - request as an IF-THEN order group, so the entry and its protections - are accepted or rejected together - there is no window with an - unprotected position. The spec allows order groups only on - position-based accounts; elsewhere the server rejects the request - and nothing is placed. + request as an IF-THEN order group, so there is no window where the + entry is live and its protections have not been sent yet. If the + request is rejected, none of it is placed. The protections only + become active when the entry fills; the spec does not say how a + protection rejected at that point (e.g. a stop already through the + market) is reported, so check get_orders() after placing. If the + server acknowledges fewer orders than were sent, this raises an + ambiguous OrderPlacementError. The spec allows order groups only on + position-based accounts; elsewhere the server rejects the request. position_effect: "OPEN" or "CLOSE" (CLOSE needs positionCode=...). tif: "GTC", "DAY" or "GTD" (GTD needs expireDate=...; MARKET only GTC). **kwargs: Extra SingleOrderRequest fields (positionCode, expireDate, @@ -792,7 +812,19 @@ def place_order( } group_orders.append(leg) body = {"orders": group_orders, "contingencyType": "IF-THEN"} - return self._post_order(body, order_code, "Place order with protection") + result = self._post_order(body, order_code, "Place order with protection") + responses = result.get("orderResponses") if isinstance(result, Mapping) else result + if isinstance(responses, list) and len(responses) < len(group_orders): + # The spec answers a group with one OrderResponse per order. Fewer means + # a protection may be missing while the entry stands: say so loudly. + raise OrderPlacementError( + f"Place order with protection: sent {len(group_orders)} orders but the " + f"server acknowledged {len(responses)}. The entry {order_code!r} may be " + "working or filled WITHOUT its stop loss / take profit: check get_orders() " + "and get_positions() now.", + order_code=order_code, ambiguous=True, body=result, + ) + return result def _find_open_order(self, orders: List[JSON], order_id: str) -> JSON: for order in orders: @@ -823,7 +855,9 @@ def _replace_request( "side": order.get("side") or leg.get("side"), "tif": order.get("tif"), } - if leg.get("positionCode"): + # The Open Orders listing gives OPEN legs a positionCode too (the order's + # own id), but the spec says it "must be omitted" unless the effect is CLOSE. + if leg.get("positionEffect") == "CLOSE" and leg.get("positionCode"): body["positionCode"] = leg["positionCode"] if order.get("expireDate"): body["expireDate"] = order["expireDate"] @@ -844,17 +878,31 @@ def _replace_request( body.update(extra) return {k: v for k, v in body.items() if v is not None} - def _conditional(self, method: str, path: str, context: str, body: Optional[JSON], - etag: Optional[str], refetch: Callable[[], Optional[str]], - fetched: Optional[str] = None) -> Any: - """PUT/DELETE with If-Match; on 412 re-reads the ETag and retries once. + def _conditional( + self, + method: str, + path: str, + context: str, + etag: Optional[str], + build: Optional[Callable[[List[JSON]], JSON]] = None, + prefetched: Optional[Tuple[List[JSON], Optional[str]]] = None, + ) -> Any: + """PUT/DELETE with If-Match; on 412 re-reads the orders and retries once. - ``etag`` is a caller-supplied value (never retried: the caller asked for - that exact version). ``fetched`` is one this client just read. + ``build(orders)`` makes the request body from the current open orders. It + runs again on the retry, so a 412 never resends a body built from the + stale state the server just refused. ``etag`` is a caller-supplied value + (never retried: the caller asked for that exact version). """ auto = etag is None - tag = etag or fetched or refetch() for attempt in (1, 2): + tag, body = etag, None + if auto or build is not None: + orders, fetched = prefetched or self._open_orders_raw() + prefetched = None + tag = etag or fetched + if build is not None: + body = build(orders) if not tag: raise DXTradeAPIError( f"{context}: the server sent no ETag with the order list, and the spec " @@ -867,10 +915,79 @@ def _conditional(self, method: str, path: str, context: str, body: Optional[JSON except PreconditionFailedError: if not auto or attempt == 2: raise - self._logger.info("%s: ETag changed, re-reading and retrying", context) - tag = refetch() + self._logger.info("%s: orders changed, re-reading and retrying", context) return None # pragma: no cover + def _find_linked(self, orders: List[JSON], link: Mapping[str, Any]) -> Optional[JSON]: + wanted = {str(link.get("linkedOrder")), str(link.get("linkedClientOrderId"))} - {"None"} + for order in orders: + if {str(order.get("orderCode")), str(order.get("clientOrderId"))} & wanted: + return order + return None + + def _working_group(self, orders: List[JSON], order: Mapping[str, Any]) -> Optional[List[JSON]]: + """The working IF-THEN group ``order`` belongs to, parent first, or None. + + Per the spec's Modify Order example 1, a single-order PUT for the parent of + an IF-THEN group turns the group into a single order: its stop loss and take + profit disappear. So group members are always modified as a whole group. + """ + links = order.get("links") or [] + if any(link.get("linkType") == "OCO" for link in links): + raise DXTradeAPIError( + "Order is part of an OCO group; modifying OCO groups is not supported " + "(a single-order PUT would break the group). Nothing was sent." + ) + parent: Optional[Mapping[str, Any]] = order + if not any(link.get("linkType") == "CHILD" for link in links): + parent_link = next((lk for lk in links if lk.get("linkType") == "PARENT"), None) + # A filled parent is no longer a working order: the children are then + # plain protections on the position and are modified on their own. + parent = self._find_linked(orders, parent_link) if parent_link else None + if parent is None: + return None + group = [dict(parent)] + for link in parent.get("links") or []: + if link.get("linkType") != "CHILD": + continue + child = self._find_linked(orders, link) + if child is None: + raise DXTradeAPIError( + f"Order group of {parent.get('clientOrderId') or parent.get('orderCode')!r} " + f"lists child {link.get('linkedClientOrderId') or link.get('linkedOrder')!r}, " + "which is not a working order; refusing to send a group PUT that would " + "drop it. Nothing was sent." + ) + group.append(child) + return group + + def _modify_body( + self, + orders: List[JSON], + order: Mapping[str, Any], + price: Optional[float] = None, + quantity: Optional[float] = None, + extra: Optional[Mapping[str, Any]] = None, + ) -> JSON: + """The PUT body changing ``order``: a single order, or its whole IF-THEN group + (REST spec, Modify Order example 2b) with only ``order`` changed.""" + group = self._working_group(orders, order) + if group is None: + return self._replace_request(order, price, quantity, extra) + target = str(order.get("orderCode")) + members = [] + for index, member in enumerate(group): + if str(member.get("orderCode")) == target: + body = self._replace_request(member, price, quantity, extra) + else: + body = self._replace_request(member) + if index > 0: + # THEN orders as in the spec's group examples: zero quantity, no positionCode. + body.pop("positionCode", None) + body["quantity"] = 0 + members.append(body) + return {"orders": members, "contingencyType": "IF-THEN"} + def modify_order( self, order_id: str, @@ -893,21 +1010,21 @@ def modify_order( """ if new_price is None and new_quantity is None and not kwargs: raise ValueError("At least one parameter to modify must be provided") - orders, fetched_tag = self._open_orders_raw() - order = self._find_open_order(orders, order_id) - body = self._replace_request(order, new_price, new_quantity, kwargs) - return self._conditional( - "PUT", self._account_path("orders"), "Modify order", body, - etag, lambda: self._open_orders_raw()[1], fetched=fetched_tag, - ) + prefetched = self._open_orders_raw() + self._find_open_order(prefetched[0], order_id) # fail fast on an unknown id + + def build(orders: List[JSON]) -> JSON: + order = self._find_open_order(orders, order_id) + return self._modify_body(orders, order, new_price, new_quantity, kwargs) + + return self._conditional("PUT", self._account_path("orders"), "Modify order", + etag, build, prefetched=prefetched) def cancel_order(self, order_id: str, etag: Optional[str] = None) -> Any: """Cancels a working order: DELETE /accounts/{account}/orders/{code} with If-Match.""" self._check_authenticated() path = self._account_path(f"orders/{self._seg(order_id)}") - result = self._conditional( - "DELETE", path, "Cancel order", None, etag, lambda: self._open_orders_raw()[1] - ) + result = self._conditional("DELETE", path, "Cancel order", etag) return result if result is not None else {"success": True, "order_id": order_id} def _find_position(self, position_id: str) -> Position: @@ -916,6 +1033,16 @@ def _find_position(self, position_id: str) -> Position: return position raise NotFoundError(f"No open position {position_id!r}") + @staticmethod + def _find_protection(orders: List[JSON], kind: str, position_code: str) -> Optional[JSON]: + return next( + (o for o in orders + if str(o.get("type", "")).upper() == kind + and order_leg(o).get("positionCode") == position_code + and order_leg(o).get("positionEffect") == "CLOSE"), + None, + ) + def close_position(self, position_id: str, quantity: Optional[float] = None) -> Any: """Closes a position fully or partially with a MARKET order linked to it. @@ -984,19 +1111,18 @@ def modify_position_sl_tp( ("take_profit", "LIMIT", take_profit)): if value is None: continue - orders, tag = self._open_orders_raw() - existing = next( - (o for o in orders - if str(o.get("type", "")).upper() == kind - and order_leg(o).get("positionCode") == position.position_id - and order_leg(o).get("positionEffect") == "CLOSE"), - None, - ) + prefetched = self._open_orders_raw() + existing = self._find_protection(prefetched[0], kind, position.position_id) if existing is not None: - body = self._replace_request(existing, price=value) + code = str(existing.get("orderCode")) + + def build(orders: List[JSON], code: str = code, value: float = value) -> JSON: + current = self._find_open_order(orders, code) + return self._modify_body(orders, current, price=value) + results[key] = self._conditional( - "PUT", self._account_path("orders"), f"Modify {key}", body, None, - lambda: self._open_orders_raw()[1], fetched=tag, + "PUT", self._account_path("orders"), f"Modify {key}", None, build, + prefetched=prefetched, ) else: order_code = self._generate_order_code() diff --git a/dxtrade_wrapper/exceptions.py b/dxtrade_wrapper/exceptions.py index fb2e327..9434cf5 100644 --- a/dxtrade_wrapper/exceptions.py +++ b/dxtrade_wrapper/exceptions.py @@ -75,7 +75,8 @@ class OrderPlacementError(DXTradeAPIError): Attributes: order_code: The client ``orderCode`` that was sent. When ``ambiguous`` is - True (timeout / connection drop after sending) the order may or may + True (timeout / connection drop after sending, 5xx, or a group + acknowledged only in part) the order may or may not exist on the server: look it up by this code, or resend with the same ``orderCode`` - DXtrade rejects a duplicate code with 409 / error 100 rather than opening a second position. diff --git a/tests/fixtures/orders.json b/tests/fixtures/orders.json index 4b45437..e3d8333 100644 --- a/tests/fixtures/orders.json +++ b/tests/fixtures/orders.json @@ -46,6 +46,7 @@ { "instrument": "GBP/USD", "positionEffect": "OPEN", + "positionCode": "63700", "side": "BUY", "price": 1.3100, "legRatio": 1, diff --git a/tests/fixtures/orders_group.json b/tests/fixtures/orders_group.json new file mode 100644 index 0000000..45fe9b8 --- /dev/null +++ b/tests/fixtures/orders_group.json @@ -0,0 +1,113 @@ +{ + "orders": [ + { + "account": "default:ACC12345", + "version": 447, + "orderId": 70001, + "orderCode": "sess:grp-entry", + "clientOrderId": "grp-entry", + "actionCode": "sess:grp-entry", + "legCount": 1, + "type": "LIMIT", + "instrument": "EUR/USD", + "status": "WORKING", + "finalStatus": false, + "legs": [ + { + "instrument": "EUR/USD", + "positionEffect": "OPEN", + "positionCode": "70001", + "price": 1.05, + "legRatio": 1, + "quantity": 200000, + "filledQuantity": 0, + "remainingQuantity": 200000 + } + ], + "side": "BUY", + "tif": "GTC", + "issueTime": "2026-09-30T09:00:00.000Z", + "transactionTime": "2026-09-30T09:00:00.000Z", + "links": [ + { + "linkType": "CHILD", + "linkedOrder": "sess:grp-sl", + "linkedClientOrderId": "grp-sl" + }, + { + "linkType": "CHILD", + "linkedOrder": "sess:grp-tp", + "linkedClientOrderId": "grp-tp" + } + ] + }, + { + "account": "default:ACC12345", + "version": 447, + "orderId": 70002, + "orderCode": "sess:grp-sl", + "clientOrderId": "grp-sl", + "actionCode": "sess:grp-sl", + "legCount": 1, + "type": "STOP", + "instrument": "EUR/USD", + "status": "WORKING", + "finalStatus": false, + "legs": [ + { + "instrument": "EUR/USD", + "positionEffect": "CLOSE", + "positionCode": "70001", + "price": 1.0, + "legRatio": 1, + "filledQuantity": 0 + } + ], + "side": "SELL", + "tif": "GTC", + "issueTime": "2026-09-30T09:00:00.000Z", + "transactionTime": "2026-09-30T09:00:00.000Z", + "links": [ + { + "linkType": "PARENT", + "linkedOrder": "sess:grp-entry", + "linkedClientOrderId": "grp-entry" + } + ] + }, + { + "account": "default:ACC12345", + "version": 447, + "orderId": 70003, + "orderCode": "sess:grp-tp", + "clientOrderId": "grp-tp", + "actionCode": "sess:grp-tp", + "legCount": 1, + "type": "LIMIT", + "instrument": "EUR/USD", + "status": "WORKING", + "finalStatus": false, + "legs": [ + { + "instrument": "EUR/USD", + "positionEffect": "CLOSE", + "positionCode": "70001", + "price": 1.1, + "legRatio": 1, + "filledQuantity": 0 + } + ], + "side": "SELL", + "tif": "GTC", + "issueTime": "2026-09-30T09:00:00.000Z", + "transactionTime": "2026-09-30T09:00:00.000Z", + "links": [ + { + "linkType": "PARENT", + "linkedOrder": "sess:grp-entry", + "linkedClientOrderId": "grp-entry" + } + ] + } + ] +} diff --git a/tests/test_orders.py b/tests/test_orders.py index 6012fe6..c0a36e8 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -280,3 +280,128 @@ def test_modify_sl_tp_moves_existing_tp_and_adds_missing_sl(client, api): def test_modify_sl_tp_requires_a_value(client): with pytest.raises(ValueError): client.modify_position_sl_tp("63649") + + +# --- review 2026-09-30: IF-THEN groups, stale 412 bodies, acknowledged legs ------- + +def group_orders_with_etag(api, data=None, etag='"g1"'): + api.add(responses.GET, f"{ACC}/orders", json=data or fixture("orders_group"), + headers={"ETag": etag}) + + +def test_modify_open_order_omits_its_own_position_code(client, api): + """The Open Orders listing gives an OPEN leg positionCode == its orderId; the + spec says positionCode must be omitted unless positionEffect is CLOSE.""" + orders_with_etag(api) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("order_response")) + client.modify_order("3a4-DEF", new_price=1.305) + assert "positionCode" not in body(api.calls[-1]) + + +def test_modifying_a_group_parent_resends_the_whole_group(client, api): + """Spec, Modify Order example 1: a single-order PUT for the parent of an IF-THEN + group turns it into a single order - the stop loss and take profit vanish.""" + group_orders_with_etag(api) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("group_response"), + match=[matchers.header_matcher({"If-Match": '"g1"'})]) + + client.modify_order("grp-entry", new_price=1.04) + + put = body(api.calls[-1]) + assert put["contingencyType"] == "IF-THEN" + entry, sl, tp = put["orders"] + assert entry == {"account": ACCOUNT, "orderCode": "grp-entry", "instrument": "EUR/USD", + "positionEffect": "OPEN", "side": "BUY", "tif": "GTC", + "quantity": 200000, "limitPrice": 1.04} + assert sl == {"account": ACCOUNT, "orderCode": "grp-sl", "instrument": "EUR/USD", + "positionEffect": "CLOSE", "side": "SELL", "tif": "GTC", + "quantity": 0, "stopPrice": 1.00} + assert tp["orderCode"] == "grp-tp" and tp["limitPrice"] == 1.10 and tp["quantity"] == 0 + assert all("type" not in o for o in put["orders"]) + + +def test_modifying_a_pending_groups_stop_loss_keeps_the_group(client, api): + group_orders_with_etag(api) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("group_response")) + client.modify_order("grp-sl", new_price=1.01) + entry, sl, tp = body(api.calls[-1])["orders"] + assert entry["orderCode"] == "grp-entry" and entry["limitPrice"] == 1.05 + assert sl["stopPrice"] == 1.01 and tp["limitPrice"] == 1.10 + + +def test_child_of_a_filled_parent_is_modified_on_its_own(client, api): + data = fixture("orders_group") + data["orders"] = data["orders"][1:] # entry filled: no longer a working order + group_orders_with_etag(api, data) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("order_response")) + client.modify_order("grp-sl", new_price=1.01) + put = body(api.calls[-1]) + assert put["orderCode"] == "grp-sl" and put["stopPrice"] == 1.01 + assert put["positionCode"] == "70001" and "quantity" not in put + + +def test_group_with_a_missing_child_is_not_modified(client, api): + data = fixture("orders_group") + del data["orders"][2] + group_orders_with_etag(api, data) + with pytest.raises(DXTradeAPIError, match="not a working order"): + client.modify_order("grp-entry", new_price=1.04) + assert not [c for c in api.calls if c.request.method == "PUT"] + + +def test_oco_member_is_not_modified_with_a_single_put(client, api): + data = fixture("orders") + data["orders"][1]["links"] = [{"linkType": "OCO", "linkedOrder": "3a4-ABC"}] + orders_with_etag(api) + api.replace(responses.GET, f"{ACC}/orders", json=data, headers={"ETag": '"v42"'}) + with pytest.raises(DXTradeAPIError, match="OCO"): + client.modify_order("3a4-DEF", new_price=1.3) + assert not [c for c in api.calls if c.request.method == "PUT"] + + +def test_412_retry_rebuilds_the_body_from_the_fresh_order(client, api): + """Retrying the body built from the stale read would silently undo whatever + change caused the 412 - exactly what If-Match exists to prevent.""" + orders_with_etag(api, '"v42"') + api.add(responses.PUT, f"{ACC}/orders", status=412) + changed = fixture("orders") + changed["orders"][1]["legs"][0]["quantity"] = 7000 + api.add(responses.GET, f"{ACC}/orders", json=changed, headers={"ETag": '"v43"'}) + api.add(responses.PUT, f"{ACC}/orders", json=fixture("order_response"), + match=[matchers.header_matcher({"If-Match": '"v43"'})]) + + client.modify_order("3a4-DEF", new_price=1.3) + + first, retry = [body(c) for c in api.calls if c.request.method == "PUT"] + assert first["quantity"] == 5000 + assert retry["quantity"] == 7000 and retry["stopPrice"] == 1.3 + + +def test_group_acknowledging_fewer_orders_than_sent_is_loud(client, api): + api.add(responses.POST, f"{ACC}/orders", + json={"orderResponses": [{"orderId": 1, "updateOrderId": 1}]}) + with pytest.raises(OrderPlacementError, match="WITHOUT its stop loss") as excinfo: + client.place_order("EUR/USD", "BUY", 1000, "MARKET", stop_loss=1.0, take_profit=1.2, + orderCode="entry-7") + assert excinfo.value.ambiguous is True and excinfo.value.order_code == "entry-7" + + +def test_close_short_position_buys_it_back(client, api): + data = fixture("positions") + data["positions"][0]["side"] = "SELL" + api.add(responses.GET, f"{ACC}/positions", json=data) + api.add(responses.POST, f"{ACC}/orders", json=fixture("order_response")) + client.close_position("63649", quantity=25000) + order = body(api.calls[-1]) + assert order["side"] == "BUY" and order["quantity"] == 25000 + assert order["positionEffect"] == "CLOSE" and order["positionCode"] == "63649" + + +@pytest.mark.parametrize("status", [500, 502, 504]) +def test_server_error_on_placement_is_flagged_ambiguous(client, api, status): + """A gateway timeout says nothing about whether the order was placed; calling + it unambiguous invites a resend with a new orderCode, i.e. a duplicate.""" + api.add(responses.POST, f"{ACC}/orders", status=status, body="upstream timed out") + with pytest.raises(OrderPlacementError) as excinfo: + client.place_order("EUR/USD", "BUY", 1000, "MARKET", orderCode="k-5") + assert excinfo.value.ambiguous is True and "k-5" in str(excinfo.value) diff --git a/tests/test_session.py b/tests/test_session.py index 09e821b..16e20c2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -218,3 +218,26 @@ def test_rate_limited_order_is_never_retried_automatically(client, api, monkeypa client.place_order("EUR/USD", "BUY", 1000, "MARKET") assert excinfo.value.status_code == 429 assert len(api.calls) == 2 + + +def test_concurrent_401s_on_one_stale_token_log_in_once(client, api): + """A thread whose request failed on a token another thread already replaced + must not log in again (that could invalidate the fresh session).""" + stale = client._auth_token + client._auth_token = "ALREADY-RENEWED" + client._relogin(stale_token=stale) + assert [c.request.url for c in api.calls].count(f"{API}/login") == 1 # the fixture's + + +def test_relogin_keeps_the_client_authenticated_while_logging_in(client, api): + seen = [] + original = client.login + + def spy(): + seen.append(client.is_authenticated) + original() + + client.login = spy + api.add(responses.POST, f"{API}/login", json={"sessionToken": "NEW"}) + client._relogin(stale_token=client._auth_token) + assert seen == [True] and client._auth_token == "NEW" diff --git a/tests/test_spec_conformance.py b/tests/test_spec_conformance.py index a9b5389..7e8a4f1 100644 --- a/tests/test_spec_conformance.py +++ b/tests/test_spec_conformance.py @@ -115,6 +115,9 @@ def run_scenario(): client.place_order("EUR/USD", "BUY", 1000, "LIMIT", price=1.05) client.place_order("EUR/USD", "BUY", 1000, "MARKET", stop_loss=1.0, take_profit=1.2) client.modify_order("3a4-DEF", new_price=1.3) + api.replace(responses.GET, f"{ACC}/orders", json=fixture("orders_group"), headers=etag) + client.modify_order("grp-entry", new_price=1.04) + api.replace(responses.GET, f"{ACC}/orders", json=fixture("orders"), headers=etag) client.cancel_order("3a4-DEF") client.close_position("63649") client.modify_position_sl_tp("63649", stop_loss=1.08, take_profit=1.1) @@ -148,8 +151,18 @@ def test_every_request_body_matches_its_schema(spec): if "contingencyType" in payload: assert set(payload) == {"orders", "contingencyType"} assert payload["contingencyType"] in ("IF-THEN", "OCO") - for member in members: + for index, member in enumerate(members): validator(spec, name).validate(member) + # Prose rules the schema cannot express (Single Order Request): + # positionCode only with CLOSE (IF-THEN children carry neither), and + # no order type in replace (PUT) requests. + is_then_child = payload.get("contingencyType") == "IF-THEN" and index > 0 + if "positionCode" in member: + assert member.get("positionEffect") == "CLOSE", f"{template}: {member}" + elif member.get("positionEffect") == "CLOSE": + assert is_then_child, f"{template}: CLOSE without positionCode: {member}" + if call.request.method == "PUT": + assert "type" not in member, f"{template}: type in a replace request" unknown = set(member) - declared(spec, name) assert not unknown, f"{template}: fields not in {name}: {unknown}" checked += 1 @@ -164,6 +177,7 @@ def test_every_request_body_matches_its_schema(spec): ("metrics", "AccountMetricsList"), ("positions", "PositionList"), ("orders", "OrderList"), + ("orders_group", "OrderList"), ("history", "OrderList"), ("order_response", "OrderResponse"), ("group_response", "OrderResponseList"), From 6e1f32d707c63ed9e4ae972c0a6181e40da473a8 Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:45:30 +0300 Subject: [PATCH 09/10] review: require setuptools>=77 for the SPDX license string license = "MIT" (PEP 639) fails validation on setuptools 76 and older ("project.license must be ... {text: ...}"), so the declared >=69 floor did not build. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fee0491..4c1fc50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=69", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] From a2743a58f1d20ebffee66d4a679caf88aaac2a6f Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Wed, 30 Sep 2026 12:46:35 +0300 Subject: [PATCH 10/10] review: example.py honours DXTRADE_LOGIN_PATH / DXTRADE_WEBSOCKET_PATH again Both were in main's .env.example; the rewritten example silently ignored them, so an existing .env for a broker with non-default paths would hit the wrong URLs. Co-Authored-By: Claude Opus 5.5 --- .env.example | 5 +++++ example.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 081397f..a0872c2 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,8 @@ DXTRADE_WEBSOCKET_URL= # Symbols example.py streams quotes for. DXTRADE_SYMBOLS=EUR/USD,GBP/USD + +# Older (0.1) settings, still read by example.py: login path relative to the API +# prefix, and the websocket path on the host (used when DXTRADE_WEBSOCKET_URL is empty). +# DXTRADE_LOGIN_PATH=/login +# DXTRADE_WEBSOCKET_PATH=/websocket diff --git a/example.py b/example.py index 357a208..f4a742f 100644 --- a/example.py +++ b/example.py @@ -41,6 +41,9 @@ def main() -> int: password=os.getenv("DXTRADE_PASSWORD", ""), domain_or_vendor=os.getenv("DXTRADE_DOMAIN_VENDOR", "default"), api_prefix=os.getenv("DXTRADE_API_PREFIX", "/dxsca-web"), + # Still honoured so .env files written for 0.1 keep working. + login_path=os.getenv("DXTRADE_LOGIN_PATH", "/login"), + websocket_path=os.getenv("DXTRADE_WEBSOCKET_PATH", "/websocket"), account=os.getenv("DXTRADE_ACCOUNT") or None, websocket_url=os.getenv("DXTRADE_WEBSOCKET_URL") or None, ) @@ -56,7 +59,7 @@ def main() -> int: if "--demo-order" in sys.argv: demo_order(client) - if os.getenv("DXTRADE_WEBSOCKET_URL"): + if os.getenv("DXTRADE_WEBSOCKET_URL") or os.getenv("DXTRADE_WEBSOCKET_PATH"): stream_quotes(client, seconds=15) else: log.info("Set DXTRADE_WEBSOCKET_URL (ask your broker) to stream quotes.")