Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,22 @@ jobs:
- 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__)"
- run: python scripts/check_release.py
- run: pip install build twine && python -m build
- run: twine check --strict dist/*
# The sdist must be self-contained: unpack it and run its own test suite.
- name: Test the sdist
run: |
mkdir /tmp/sdist && tar xzf dist/*.tar.gz -C /tmp/sdist
cd /tmp/sdist/*/
python -m venv /tmp/sdist-venv
/tmp/sdist-venv/bin/pip install -q ".[test]"
/tmp/sdist-venv/bin/python -m pytest -q
- name: Install the wheel in a clean venv
run: |
python -m venv /tmp/wheel-venv
/tmp/wheel-venv/bin/pip install -q dist/*.whl
cd /tmp && /tmp/wheel-venv/bin/python -c "
import importlib.resources, dxtrade_wrapper
assert importlib.resources.files('dxtrade_wrapper').joinpath('py.typed').is_file()
print(dxtrade_wrapper.__version__, dxtrade_wrapper.DXTradeClient)"
88 changes: 88 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Release

# Runs when a GitHub release is published (e.g. `gh release create v0.2.0`).
# It always builds, checks and attaches the sdist and wheel to the release.
# Uploading to TestPyPI and then PyPI happens only once the repository variable
# PYPI_PUBLISH is "true", i.e. after Trusted Publishing has been configured on
# test.pypi.org and pypi.org for this workflow (see README "Packaging").

on:
release:
types: [published]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Tag matches __version__ and has a dated CHANGELOG entry
run: python scripts/check_release.py "${{ github.event.release.tag_name }}"
- run: pip install build twine && python -m build
- run: twine check --strict dist/*
- name: Test the sdist
run: |
mkdir /tmp/sdist && tar xzf dist/*.tar.gz -C /tmp/sdist
cd /tmp/sdist/*/
python -m venv /tmp/sdist-venv
/tmp/sdist-venv/bin/pip install -q ".[test]"
/tmp/sdist-venv/bin/python -m pytest -q
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

attach:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Attach the sdist and wheel to the release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${{ github.event.release.tag_name }}" dist/* --repo "${{ github.repository }}"

publish-testpypi:
needs: build
if: vars.PYPI_PUBLISH == 'true'
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/dxtrade-python-wrapper
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true # a re-run must not fail on the version it already uploaded

publish-pypi:
needs: publish-testpypi
if: vars.PYPI_PUBLISH == 'true'
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/dxtrade-python-wrapper
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
# Changelog

## 0.2.0 (unreleased)
## 0.2.0 (2026-10-01)

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.
Spec-conformant; not yet verified against a live broker. First release prepared
for PyPI.

### Changed before the first PyPI release
- The client class is now `DXTradeClient`; `DXTradeDashboardWrapper` remains as an
alias.
- The network-error class is now `DXTradeConnectionError`. The old name
`ConnectionError` still imports, but is no longer in `__all__`: a star import used to
replace the builtin `ConnectionError` in the caller's module, so `except
ConnectionError` stopped catching socket errors.
- `logout()` closes the HTTP session's connection pool before starting a new one.
- The sdist now ships `tests/conftest.py`, the JSON fixtures, the examples and this
changelog, so its test suite runs.

### Fixed in independent review
- `modify_order` on the entry (or a pending SL/TP) of an IF-THEN group sent a single
Expand Down
8 changes: 8 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# setuptools only picks up tests/test*.py on its own; without conftest.py and the
# JSON fixtures the sdist's test suite cannot even be collected.
include CHANGELOG.md
include example.py
include .env.example
recursive-include tests *.py *.json
recursive-include examples *.py
recursive-include scripts *.py
46 changes: 34 additions & 12 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ account updates. Prop-firm and retail traders use DXtrade.
> **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).

## 📥 Install

```bash
pip install dxtrade-python-wrapper # PyPI, once 0.2.0 is published
pip install git+https://github.com/Bogzx/DXtrade-python-wrapper # latest main
```

```python
import dxtrade_wrapper
```

Python 3.9+. Runtime dependencies: `requests` and `websocket-client`. Type hints ship with
the package (`py.typed`).

## ⚡ Quick start (no broker account needed)

```bash
Expand Down Expand Up @@ -55,8 +69,9 @@ with DXTradeClient(
print(exc.status_code, exc.error_code, exc.description, exc.ambiguous)
```

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`.
Or put your settings in `.env` (see [`.env.example`](https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/.env.example)),
`pip install -e ".[examples]"` and run [`python example.py`](https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/example.py). It is
read-only unless you pass `--demo-order`.

## 📚 API

Expand Down Expand Up @@ -86,13 +101,15 @@ instead of opening a second position. A read (GET) that is rate-limited (429) wa

**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`
DXtrade's error body: `AuthenticationError` (401, and a 403 at login, which means the
broker has not enabled REST access), `NotFoundError`, `ConflictError`
(409 business rejection), `PreconditionFailedError` (412), `RateLimitError` (429,
`retry_after`) and `ServerError`. `OrderPlacementError` adds `order_code` and
`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.
`orderCode` before sending again. `DXTradeConnectionError` covers network failures. Any
other HTTP error, such as a 403 "Conditional request required", is a plain
`DXTradeAPIError` with its `status_code`.

**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
Expand Down Expand Up @@ -138,7 +155,7 @@ 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.
into zeros. [`CHANGELOG.md`](https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/CHANGELOG.md) has the details.

Lessons that still apply:

Expand All @@ -151,13 +168,18 @@ Lessons that still apply:
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
## 📦 Packaging and releases

The distribution is `dxtrade-python-wrapper`; the import name is `dxtrade_wrapper`. The
sdist includes the tests and fixtures, so `pytest` runs from an unpacked sdist. Releases
are built by [`release.yml`](https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/.github/workflows/release.yml)
when a GitHub release is published. It checks that the tag matches `__version__` and has
a dated CHANGELOG entry, attaches the sdist and wheel to the release, and publishes them
with PyPI Trusted Publishing (TestPyPI first) once the repository variable
`PYPI_PUBLISH` is `true`.

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.
`DXTradeDashboardWrapper` (the 0.1 class name) and `ConnectionError` still import, as
aliases of `DXTradeClient` and `DXTradeConnectionError`.

## ⚖️ Disclaimer

Expand Down
8 changes: 2 additions & 6 deletions dxtrade_wrapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

import logging

from .client import DXTradeDashboardWrapper
from .client import DXTradeClient, DXTradeDashboardWrapper
from .exceptions import (
AuthenticationError,
ConflictError,
ConnectionError,
ConnectionError, # noqa: F401 - 0.1 name, importable but not exported by *
DXTradeAPIError,
DXTradeConnectionError,
DXTradeWrapperError,
Expand All @@ -25,17 +25,13 @@

__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",
Expand Down
27 changes: 17 additions & 10 deletions dxtrade_wrapper/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
DXTradeDashboardWrapper: a Python client for the DXtrade REST and Push APIs.
DXTradeClient: 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.
Expand Down Expand Up @@ -39,8 +39,8 @@
from .exceptions import (
AuthenticationError,
ConflictError,
ConnectionError,
DXTradeAPIError,
DXTradeConnectionError,
NotFoundError,
OrderPlacementError,
PreconditionFailedError,
Expand Down Expand Up @@ -97,13 +97,13 @@ def parse_interval(value: Any) -> Optional[float]:
return None


class DXTradeDashboardWrapper:
class DXTradeClient:
"""
A client for the DXtrade REST API (token authentication) and Push API.

Typical use::

with DXTradeDashboardWrapper(base_url, username, password, "default") as dx:
with DXTradeClient(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)

Expand Down Expand Up @@ -208,7 +208,7 @@ def __repr__(self) -> str:
f"authenticated={self._is_authenticated})"
)

def __enter__(self) -> "DXTradeDashboardWrapper":
def __enter__(self) -> "DXTradeClient":
self.login()
return self

Expand Down Expand Up @@ -326,7 +326,7 @@ def _send(self, method: str, url: str, context: str, json_body: Any,
)
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
raise DXTradeConnectionError(f"{context}: network error: {exc}") from exc

@staticmethod
def _retry_after(response: requests.Response) -> Optional[float]:
Expand Down Expand Up @@ -360,7 +360,7 @@ def login(self) -> None:

Raises:
AuthenticationError: Wrong credentials (401) or API access refused (403).
ConnectionError: The server could not be reached.
DXTradeConnectionError: The server could not be reached.
"""
with self._auth_lock:
payload = {
Expand All @@ -378,7 +378,7 @@ def login(self) -> None:
)
except requests.exceptions.RequestException as exc:
self._is_authenticated = False
raise ConnectionError(f"Login: network error: {exc}") from exc
raise DXTradeConnectionError(f"Login: network error: {exc}") from exc

if response.status_code == 403:
self._is_authenticated = False
Expand Down Expand Up @@ -431,6 +431,9 @@ def logout(self) -> None:
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)
# Close the old pool: a bot that logs out and in again for days would
# otherwise leak one connection pool per session.
self._session.close()
self._session = requests.Session()
self._session.headers.update({"Accept": "application/json"})
self._auth_token = None
Expand Down Expand Up @@ -672,7 +675,7 @@ def _post_order(self, body: JSON, order_code: str, context: str) -> Any:
self._logger.debug("%s: %s", context, body)
try:
response = self._request("POST", path, context, json_body=body)
except ConnectionError as exc:
except DXTradeConnectionError as exc:
cause = exc.__cause__
sent = not isinstance(cause, requests.exceptions.ConnectTimeout)
raise OrderPlacementError(
Expand Down Expand Up @@ -1327,7 +1330,7 @@ def _handle_push_message(self, channel: "_PushChannel", raw: str) -> None:
class _PushChannel:
"""One Push API websocket with reconnect/backoff and subscription replay."""

def __init__(self, client: DXTradeDashboardWrapper, name: str, url: str):
def __init__(self, client: DXTradeClient, name: str, url: str):
self.client = client
self.name = name
self.url = url
Expand Down Expand Up @@ -1417,6 +1420,10 @@ def send(self, message: JSON) -> None:
raise WebSocketError(f"Failed to send {message.get('type')}: {exc}") from exc


#: The 0.1 name of :class:`DXTradeClient`, kept as an alias.
DXTradeDashboardWrapper = DXTradeClient


def _version() -> str:
from . import __version__

Expand Down
Loading
Loading