Metadata-Version: 2.5
Name: nexus-exchange
Version: 0.4.0
Summary: Official Python SDK for the Nexus Exchange API (experimental)
Project-URL: Homepage, https://github.com/nexus-xyz/nexus-exchange-py
Project-URL: Repository, https://github.com/nexus-xyz/nexus-exchange-py
Project-URL: API spec, https://github.com/nexus-xyz/nexus-exchange-api
Author: Nexus
License-Expression: MIT OR Apache-2.0
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Keywords: crypto,exchange,nexus,perpetuals,trading
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Investment
Requires-Python: >=3.10
Requires-Dist: eth-account>=0.11
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
Description-Content-Type: text/markdown

# nexus-exchange (Python)

[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](#license)

Official Python SDK for the [Nexus Exchange](https://exchange.nexus.xyz) API — a
thin, typed wrapper over the public REST API.

> **⚠️ Experimental / in development.** This is an early skeleton. The surface is
> small and may change without notice; only the endpoints in the table below are
> implemented. For the complete, ahead-of-this surface use the
> [Rust SDK](https://github.com/nexus-xyz/nexus-exchange-rs). This SDK exists so
> agents and bots can be written in **Python or Rust** depending on the
> libraries they need.

## Install

```bash
pip install nexus-exchange   # once published; for now, install from source:
pip install git+https://github.com/nexus-xyz/nexus-exchange-py
```

Requires Python **3.10+**. Depends only on [`httpx`](https://www.python-httpx.org/).

## Quick start

```python
from nexus_exchange import Client

with Client() as client:  # defaults to the public gateway
    for market in client.fetch_markets():
        print(market.market_id)

    ticker = client.fetch_ticker("BTC-USDX-PERP")
    print(ticker.last, ticker.mark_price)
```

No credentials are needed for market data. See `examples/public_market_data.py`.

## What's supported

| Area | Status |
|---|---|
| Markets — `GET /markets`, `/markets/summary`, `/tickers` | ✅ implemented |
| Ticker — `GET /markets/{id}/ticker` | ✅ implemented |
| Order book — `GET /markets/{id}/orderbook` | ✅ implemented |
| Trades — `GET /markets/{id}/trades` | ✅ implemented |
| OHLCV candles — `GET /markets/{id}/candles` | ✅ implemented |
| Funding / mark price / status — `GET /markets/{id}/{funding,mark-price,status}` | ✅ implemented |
| ADL events — `GET /markets/{id}/adl-events`, `/account/{addr}/adl-history` | ✅ implemented |
| Health — `GET /health` | ✅ implemented |
| HMAC request signing (the plumbing for authed calls) | ✅ implemented |
| Wallet-signed auth — `sign_in` (EIP-191) + `register_agent` (EIP-712) | ✅ implemented |
| CCXT-compatible adapter — public market data | ✅ implemented |
| Error taxonomy (terminal vs transient, incl. the jurisdiction `403`) | ✅ implemented |
| Typed money — `Decimal` prices/sizes (full payload still on `.raw` / `.info`) | ✅ implemented |
| Account reads — `GET /account`, `/positions`, `/positions/closed`, `/fills`, `/withdrawals`, `/account/rate-limit` | ✅ implemented |
| Portfolio — `GET /account/state` (summary + positions, incl. `withdrawable`), `/account/summary`, `/account/fees`, `/account/portfolio-history`, `/account/equity-history` | ✅ implemented |
| Trading — `POST /orders`, `/orders/batch`; `GET /orders`, `/orders/{id}`, `/orders/history`; `DELETE /orders`, `/orders/{id}` | ✅ implemented |
| Funds — `POST /account/deposit`, `/account/credit` | ✅ implemented |
| Bridge — `GET /bridge/assets`, `/bridge/deposits`(`/{id}`); `POST`/`GET /bridge/deposit-addresses` | ✅ implemented |
| Keys / agents / WS token — `/keys`, `/agents`, `POST /ws-tokens` | ✅ implemented |
| Admin tiers — `GET`/`PUT`/`DELETE /admin/tiers` | ✅ implemented |
| Cursor pagination — `cursor` + `X-Next-Cursor` on all five paginated GETs | ✅ implemented |
| WebSocket streaming | ❌ not yet |
| Rate-limit-aware retry (`429` / `Retry-After`, token bucket) | ❌ not yet |
| OAuth auth | ❌ not yet |

The hand-maintained coverage source of truth is [`endpoints.txt`](./endpoints.txt).
Anything not listed there is not wrapped yet — contributions welcome.

### Networks

`Network` is the **network** axis — which chain, and whose money:

| `Network` | Funds | Faucet | Notes |
| --- | --- | --- | --- |
| `Network.TESTNET` | Play (synthetic USDX) | Yes | **Default.** The safe target for integration work and CI. |
| `Network.MAINNET` | **Real** | No | Collateral is USDX bridged from Ethereum Mainnet. |
| `Network.LOCAL` | Play | Yes | A locally run indexer. Not a public network. |

Each member bundles its config — REST bases, both WebSocket bases, funds
semantics and the EIP-712 signing domain:

```python
from nexus_exchange import Client, Funds, Network

Network.TESTNET.ws_market_data_url  # 'wss://api.testnet.nexus.xyz/stream'
Network.TESTNET.ws_authenticated_url  # 'wss://api.testnet.nexus.xyz/ws'
Network.MAINNET.funds  # Funds.REAL — branch on this, never on the host string

with Client(Network.TESTNET) as client:
    ...
```

`funds` is a **tri-state**, not a boolean: `Funds.REAL`, `Funds.PLAY`, or
`Funds.UNKNOWN` for a target whose funds were never declared. Guard on `PLAY`
**positively** so an undeclared target fails closed:

```python
if client.network.funds is not Funds.PLAY:  # correct — UNKNOWN is refused
    refuse()
if client.network.funds is Funds.REAL:  # WRONG — UNKNOWN slips through
    refuse()
```

Whether a faucet exists is tracked separately (`has_faucet`): "not real money"
does not imply "can mint more of it".

The two WebSocket bases and `published_rest_base` are the spec's **durable**
per-network values, recorded here so they live in one place. The hosted ones do
not resolve yet (DNS is not configured), and this SDK ships no WebSocket client, so
treat them as published targets rather than something to connect to today. What
the client actually sends to is `base_url` / `direct_base_url`.

Three things worth knowing before you pick one:

- **Mainnet has no default base URL yet.** Its host (`api.nexus.xyz`) is
  published but DNS is not live, so `Client(Network.MAINNET)` raises rather than
  guessing a real-funds target or quietly falling back to testnet. Pass
  `base_url=…` explicitly to target it.
- **Credentials never cross networks.** Session tokens, HMAC keys and agent keys
  are minted per network and are invalid on any other. Switching network means a
  new client *and* new credentials — never carry a signature, nonce or agent
  registration across.
- **The signing domain's `chain_id` is not published statically.** Read it from
  the edge's `/metadata` for the network you are on. `register_agent` refuses to
  sign without one rather than defaulting: a wrong domain either fails
  verification or produces a signature valid on a *different* network.

The retired `stable` / `beta` release channels were never networks. `stable`
became `Network.TESTNET` (same target); `beta` is now a custom target:

```python
Client(
    NetworkConfig.custom(
        label="beta",
        funds=Funds.UNKNOWN,  # that deploy's funds are not ours to assert
        base_url="https://beta.exchange.nexus.xyz/api/exchange",
        # direct_base_url defaults to base_url, which is the right shape for a
        # gateway deploy: the /api/v1 surface is mounted under the prefix, not at
        # the host root. Only set it if you have measured that deploy serving the
        # two surfaces apart.
    )
)
```

#### Custom targets

For a deployment this SDK ships no hostname for, build the config yourself and
pass it wherever a `Network` goes. It carries the **whole bundle**, not just a
URL — that is what stops a client reporting play-funds guardrails while pointed
somewhere else:

```python
from nexus_exchange import Client, Funds, NetworkConfig

config = NetworkConfig.custom(
    label="dev",  # required
    funds=Funds.PLAY,  # required — no default
    base_url="https://exchange.example.com",
    direct_base_url="https://exchange.example.com",  # optional; defaults to base_url
    has_faucet=False,  # absent until declared
    chain_id=None,  # unknown ⇒ signing refuses
)

with Client(config) as client:
    ...
```

`label` and `funds` are required and have no defaults. There is no safe default
for `funds`: assuming play makes every guardrail lie on a real-funds deployment,
and assuming real makes a dev target unusable. Pass `Funds.UNKNOWN` when it truly
is unknown — the guards treat that as unsafe, which is the honest answer.

`label` is validated (`[A-Za-z0-9._-]`, max 64, no `.` or `..`) because it is a
**key**: it is what stored credentials are namespaced under across these SDKs, so
a label that can escape a directory or split a keyring entry would let one target
address another's credentials.

Both base URLs are validated for scheme and host, and refused if they carry
**userinfo** or a query or fragment. The request path is appended to the base, so
`https://host?a=1` would be sent *and signed* as
`https://host?a=1/api/v1/orders`, and `https://api.nexus.xyz@evil.com` reads as
the published host to anyone skimming a config file while the requests — and the
API keys — go to `evil.com`. A **path** is accepted: a base under
`/api/exchange` is a real, working topology. The same checks apply to a raw
`base_url` / `direct_base_url` override, including the one mainnet requires.

**A bare `base_url` with no network named is deprecated** ([#61][pr61]) — build
the config instead. Both reach the same host; only the config says what is
behind it, so the bare form yields `Funds.UNKNOWN` and no faucet, and every
guard treats that as unsafe:

```python
Client(base_url="https://exchange.example.com")  # deprecated: UNKNOWN, no faucet
Client(NetworkConfig.custom(label="dev", funds=Funds.PLAY, base_url="https://exchange.example.com"))
```

It still works, unchanged, and **does not warn at runtime** — the marker each
SDK carries was chosen per ecosystem, and Python's is prose. So if
you do not read this section you get no signal at all, which is exactly why **a
release that warns has to come before one that removes it**: a real
`DeprecationWarning`, which Python shows by default when the caller is
`__main__`, i.e. in the local scripts and notebooks this form exists for.
Nothing is removed here, and nothing is removed before that runway has shipped.

What is deprecated is the *selector* — a URL that picks the target on its own.
`direct_base_url` is a modifier and stays, and so does a URL passed alongside a
named network, which keeps that network's semantics because the caller has
declared them:

```python
Client(Network.LOCAL, base_url="http://127.0.0.1:8080")  # stays play funds + faucet
Client(Network.MAINNET, base_url="https://api.nexus.xyz")  # stays real funds
```

Custom configs are never added to the network map and are not addressable by
name — `Network("dev")` still raises.

### Routing: direct `/api/v1` service vs. legacy gateway

As the REST gateway is retired, backend services expose their own
REST API under an **`/api/v1`** prefix. That prefix is a *path*, not a host: it
is mounted wherever the deployment serves the direct service, which on the
hosted deploy is under the `…/api/exchange` gateway prefix
(`https://exchange.nexus.xyz/api/exchange/api/v1/…`) and on a direct indexer
host is the bare origin. The client appends `/api/v1` to `direct_base_url`, so
that field carries whichever base applies. The migrated market-data and
account/trading routes now target this direct service; the HMAC signature covers
the full path (e.g. `/api/v1/orders`), independent of the base. Routes with no
`/api/v1` equivalent yet — `GET /markets`, `/health`, ADL history, `GET
/orders/{id}`, deposits, keys/agents, WS tokens and admin tiers — stay on the
legacy gateway. This split is internal; method names and signatures are
unchanged. A custom `base_url` overrides both bases; pass `direct_base_url`
alongside it to target a deploy that serves the two surfaces apart.

**Either topology is accepted.** A gateway-prefixed `direct_base_url` used to be
rejected at construction, on the premise that `/api/v1` is served only at the
host root. Production measurement says otherwise ([rs#131][rs131]):
`…/api/exchange/api/v1/markets/summary` answers `200 application/json` while
`…/api/v1/markets/summary` answers `404 text/html`, and junk segments under the
gateway prefix answer a JSON `NOT_FOUND` — so the gateway mounts `/api/v1`
specifically rather than routing permissively. A direct indexer host plausibly
serves it at the root too, so both are real and which applies is a property of
the URL, not something this client can assert. The rejection made the working
configuration unreachable on the deploy targeted by default, so it is gone
([#60][pr60]).

[rs131]: https://github.com/nexus-xyz/nexus-exchange-rs/pull/131
[pr60]: https://github.com/nexus-xyz/nexus-exchange-py/pull/60
[pr61]: https://github.com/nexus-xyz/nexus-exchange-py/pull/61

#### If you are coming from another Nexus SDK

The field names differ, so line them up before copying a base URL across — the
two-URL split here is one field in the TypeScript client. Every field below
holds a **deployment base with no `/api/v1`** — Python, TypeScript and Rust all
append that prefix themselves, on the direct routes only:

| Surface | Python | TypeScript | Base value (testnet) | Composed URL |
| --- | --- | --- | --- | --- |
| Direct `/api/v1` service | `direct_base_url` | `baseUrl` | `https://exchange.nexus.xyz/api/exchange` | `…/api/exchange/api/v1/orders` |
| Legacy `/api/exchange` gateway | `base_url` | *not modelled* | `https://exchange.nexus.xyz/api/exchange` | `…/api/exchange/ws/token` |

On this deploy all of these hold the **same string**, because the direct surface
is mounted under the gateway prefix — so copying a base across the three SDKs
gives the right answer today, and Python's two fields stay separate only so a
deploy that *does* serve the surfaces apart can still say so.

What does not survive the copy is a base that already carries **`/api/v1`** —
including the value `Network.TESTNET.direct_base_url` composes to, and TypeScript
`baseUrl`'s own pre-0.3 default. Every SDK appends the prefix itself, so such a
base sends `/api/v1/api/v1/orders` while signing the correct `/api/v1/orders`:
a routing failure whose signature looks fine. TypeScript rejects it at
construction; Python does not check, so strip the prefix before pasting a URL
into `base_url` or `direct_base_url`.

## Authentication

Signed requests use the canonical HMAC-SHA256 scheme the exchange verifies:

```text
<timestamp>\n<METHOD>\n<path>\n<query>\n<sha256hex(body)>
```

signed with the hex-decoded secret, sent as `x-signature` with `x-api-key` and
`x-timestamp`. Pass `api_key` / `api_secret` to `Client`. Note the default public
gateway proxies signed calls to the *site* account; to act as a specific account,
point `base_url` (or `Network.LOCAL`) at a direct gateway that verifies client
HMAC. Typed authed methods are not built yet — `Client._request(..., signed=True)`
is the low-level escape hatch in the meantime.

### Wallet-signed auth

The HMAC scheme above signs *requests* with an API key. The two wallet-authorized
flows are different: an EVM wallet key authorizes a **session** or an **agent
key**, with the signature carried in the request *body* (these POSTs are
themselves unauthenticated). This mirrors the
[Rust SDK](https://github.com/nexus-xyz/nexus-exchange-rs)'s `EthSigner` and the
digests are cross-checked, byte-for-byte, against the server's known-answer
vectors.

`EthSigner` is a pure signer — the caller supplies the private key (a library
pattern; there is no key prompt or file handling). It needs the
[`eth-account`](https://pypi.org/project/eth-account/) dependency, which ships
with the SDK.

```python
from nexus_exchange import Client, EthSigner

signer = EthSigner.from_hex("0x<wallet-private-key>")  # you own the key

with Client() as client:
    # EIP-191 personal_sign → POST /auth/login → session token.
    session = client.sign_in(signer)
    print(session.address, session.token)  # token is a secret

    # EIP-712 → POST /agents/register. expires_at_ms / nonce / chain_id are
    # caller-supplied; expiry must fall in [now + 1d, now + 90d].
    registration = signer.register_agent(
        agent="0x<agent-address>",
        expires_at_ms=1_782_000_000_000,
        nonce=1,
        chain_id=393,
        label="my-bot",
    )
    registered = client.register_agent(registration)
    print(registered.agent_address, registered.expires_at)
```

## Bridge

Deposit funds across chains via the `/bridge` surface (USDC/USDX in Phase A).
Get a deposit address (idempotent per account + chain), send funds, then poll a
deposit until `status` is `credited`:

```python
assets = client.fetch_bridge_assets()
addr = client.create_bridge_deposit_address(assets.chains[0].chain)
print(f"send USDC/USDX to {addr.address} on {addr.chain}")

deposits = client.fetch_bridge_deposits(limit=1, chain=addr.chain)
# deposits[0].status: "detected" | "confirming" | "credited" | "failed"
```

See [`examples/bridge_deposit.py`](./examples/bridge_deposit.py).

## Portfolio

One signed call returns the whole account state — summary aggregates plus every
open position, from a single coherent read:

```python
from nexus_exchange import PortfolioWindow

state = client.fetch_account_state()
print(state.summary.total_equity, state.summary.withdrawable)  # None if unreported
for pos in state.positions:
    # Enriched risk detail; None + a `*_error` reason when not derivable.
    print(pos.market_id, pos.notional_value, pos.roe, pos.funding_paid)
    print(pos.leverage, pos.leverage_error)  # None, "margin_state_not_mirrored"

summary = client.fetch_account_summary()  # the aggregates alone, no positions
print(summary.withdrawable)

fees = client.fetch_account_fees()
print(fees.maker_fee_bps, fees.taker_fee_bps)  # maker may be negative (a rebate)

history = client.fetch_portfolio_history(PortfolioWindow.WEEK, limit=100)
for point in history.points:  # oldest first
    print(point.timestamp_ms, point.equity, point.pnl, point.volume)
```

`withdrawable` is engine-authoritative free margin floored at zero. The
endpoints serving it fail closed with `502 authoritative_margin_unavailable`
(an `ApiError`) rather than returning a local estimate — that is transient, so
retry rather than substituting a self-computed figure.

Every money field is a `Decimal`, and decoding never invents one. A field the
spec marks optional decodes to `None` when unreported, never a defaulted `0`
that would read as a real balance — while a reported `"0"` stays `Decimal(0)`. A
field the spec marks **required** decodes strictly: if it is absent, `null` or
malformed, the call raises `DecodeError` (a `NexusExchangeError`, and a
`ValueError`) rather than handing back a plausible figure the server never sent.
That extends to lists — a malformed point or position raises instead of silently
dropping out of the series.

One 4xx is worth catching by itself. A jurisdiction control refuses
state-changing calls — and, on the sanctions list, reads too — with a `403` that
is **permanent for your origin**, so retrying it never helps:

```python
from nexus_exchange import RestrictedJurisdictionError

try:
    client.create_order(order)
except RestrictedJurisdictionError as err:
    # US_RESTRICTED | GEO_UNRESOLVED | RESTRICTED_JURISDICTION — and the list is
    # open, so treat anything unrecognized as permanent too. Never match on
    # `err.message`; the spec marks its wording unstable.
    print("refused:", err.block_reason)
```

It subclasses `ApiError`, so an existing `except ApiError` still catches it.

## Pagination

The list endpoints return a page of results plus an opaque cursor for the next
page, carried in the **`X-Next-Cursor`** response header (spec v0.7.2). All five
paginated endpoints are wrapped, each with a flat `fetch_*` (first page), an
`iter_*` (every page) and a `fetch_*_page` (one page + its cursor):

| endpoint | methods | `limit` max |
| --- | --- | --- |
| `GET /markets/{id}/trades` | `fetch_trades` / `iter_trades` / `fetch_trades_page` | 1000 |
| `GET /fills` | `fetch_my_trades` / `iter_my_trades` / `fetch_my_trades_page` | 1000 |
| `GET /orders/history` | `fetch_order_history` / `iter_order_history` / `fetch_order_history_page` | 500 |
| `GET /positions/closed` | `fetch_closed_positions` / `iter_closed_positions` / `fetch_closed_positions_page` | 200 |
| `GET /account/equity-history` | `fetch_equity_history` / `iter_equity_history` / `fetch_equity_history_page` | 720 (also the default) |

`iter_*` walks every page for you, lazily — one request per page, driven by the
cursor:

```python
for fill in client.iter_my_trades(limit=500):  # limit = page size, not a total
    print(fill.id, fill.price, fill.size)

# Stop early and the requests stop with you.
for trade in client.iter_trades("BTC-USDX-PERP", limit=100, max_pages=5):
    ...
```

`fetch_*_page` is the manual form, for when the cursor has to outlive the
process (a resumable backfill):

```python
page = client.fetch_my_trades_page(limit=500)
save_checkpoint(page.next_cursor)  # None once page.is_last
page = client.fetch_my_trades_page(limit=500, cursor=load_checkpoint())
```

Cursors are opaque — never parse one. Termination rules:

- **No `X-Next-Cursor` header ⇒ the last page.** Not an error, and not a reason
  to retry.
- An **empty page that still carries a cursor is not the end** — a sparse window
  keeps paging.
- A server that hands back the **same** cursor it was given cannot advance, so
  the walk raises `PaginationError` instead of re-requesting one page forever. A
  silent stop would report a truncated history as complete.
- Nothing else bounds how far back a walk goes; pass `max_pages` when that
  matters.

`limit` sets the page size and is validated against **that endpoint's** spec
maximum before the request (`TRADES_LIMIT_MAX`, `FILLS_LIMIT_MAX`,
`ORDER_HISTORY_LIMIT_MAX`, `CLOSED_POSITIONS_LIMIT_MAX`,
`EQUITY_HISTORY_LIMIT_MAX` — see the table above). They are **not**
interchangeable: 500 is valid on `/orders/history` and out of range on
`/positions/closed`. On `/account/equity-history` the maximum is also the
server's default, so one page normally covers the whole ~1h window.

In particular the `366` bound belongs only to `/account/portfolio-history`
(`PORTFOLIO_LIMIT_MAX`), which has no `cursor` parameter at all — applying it to
a paginated endpoint would reject valid requests, and on `/account/equity-history`
it sits *below* the server's own default of 720.

## CCXT compatibility

[CCXT](https://github.com/ccxt/ccxt) is the unified API the Python quant/retail
stack (freqtrade, hummingbot, bots) speaks. `nexus_exchange.ccxt_adapter`
exposes the exchange under CCXT's unified method names and return shapes, so
CCXT-shaped code can talk to Nexus with minimal changes.

This first increment covers `describe()` and public market data —
`fetch_markets`, `fetch_ticker`, `fetch_tickers`, `fetch_order_book`,
`fetch_ohlcv`, `fetch_trades`, plus `load_markets`. Private / trading methods
are a follow-up.

```python
from nexus_exchange.ccxt_adapter import NexusExchange

with NexusExchange() as ex:
    ex.load_markets()
    ticker = ex.fetch_ticker("BTC-USDX-PERP")  # unified ticker dict
    book = ex.fetch_order_book("BTC-USDX-PERP", limit=10)  # [price, amount] levels
    candles = ex.fetch_ohlcv("BTC-USDX-PERP", "1m", limit=100)  # [ts,o,h,l,c,v]
    trades = ex.fetch_trades("BTC-USDX-PERP", limit=50)
```

The adapter returns plain CCXT-shaped `dict`/`list` structures and does **not**
import or subclass `ccxt` — it follows CCXT's conventions without taking the
dependency. See `examples/ccxt_market_data.py`.

## API version

<!-- api-version-sync:start -->

Currently targets Exchange API spec **`v0.8.1`**.

<!-- api-version-sync:end -->

The pinned version lives in [`.api-version`](./.api-version); the spec itself is
published by
[`nexus-xyz/nexus-exchange-api`](https://github.com/nexus-xyz/nexus-exchange-api).
This repo does not vendor a copy. Two CI checks keep the pin honest, answering
different questions:

- **`spec-drift`** — does the SDK still match the spec it *pins*? It fetches the
  pinned release and enforces, both ways, that every operation in
  [`endpoints.txt`](./endpoints.txt) exists in that spec and that the operations
  the client code requests are exactly that list.
- **`drift`** — is the pin still the *latest* release? It compares `.api-version`
  against the spec repo's newest tag.

The `spec-autobump` workflow opens the bump PR when a newer spec releases,
labelling it breaking or non-breaking from an
[oasdiff](https://github.com/oasdiff/oasdiff) classification; `spec-drift` runs on
that PR too, so a bump that would require SDK changes cannot land quietly. The
line above is bot-managed; the table below is maintained by hand when an SDK
release ships a new pin.

Every request advertises the pinned tag in an `X-Nexus-Api-Version` header (and
identifies itself with a `User-Agent: nexus-exchange-py/<version>`). Override the
advertised tag per client with `Client(api_version="vX.Y.Z")` if you need to
target a specific contract version.

| SDK version | API spec |
|---|---|
| `0.1.x` | `v0.4.0` |
| `0.2.x` | `v0.6.2` |
| `0.3.x` | `v0.7.1` |

## Development

```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest          # tests — unit (mocked httpx) + an integration smoke over a
                # real loopback socket; both run offline, no network
ruff check .    # lint
mypy src        # types
```

`tests/test_integration_smoke.py` stands up a real local HTTP server and drives
a real `Client` against it (`fetch_markets` / `fetch_ticker` / `health_check`),
mirroring the Rust SDK's wiremock tests — so the transport, URL building, and
JSON decoding are exercised end to end, not just the mock layer.

For an opt-in round-trip against a **live** gateway (read-only, unauthenticated;
not run in CI), use the smoke script:

```bash
python scripts/smoke.py                     # testnet (default; play funds)
python scripts/smoke.py --network local
python scripts/smoke.py --base-url http://localhost:9090
```

## License

Dual-licensed under [MIT](./LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE), at
your option — same as the other Nexus Exchange SDKs.
