cexy 0.1.0.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ dist/
9
+ build/
10
+ .coverage
11
+ htmlcov/
12
+ # never commit credentials
13
+ .env
14
+ *.env
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The SDK stays at 0.x until API
4
+ request signing (HMAC) ships; see "Versioning" in README.md.
5
+
6
+ ## 0.1.0.dev1 (unreleased)
7
+
8
+ First published pre-release. 0.1.0.dev0 was tagged but never reached PyPI: the pinned publish
9
+ action (v1.12.4, twine 6.1.0) rejected wheel metadata version 2.5. The publish action is now
10
+ v1.14.2 (twine 7.0.0), and the build job runs `twine check --strict` with the same twine.
11
+
12
+ ## 0.1.0.dev0 (not published)
13
+
14
+ Built from the `cexy-api-spec` snapshot (implementation notes stripped from descriptions) `spec/openapi.sdk.json` (public spec `info.version`
15
+ 1.0.0, 40 allowlisted operations).
16
+
17
+ - Sync `Client` and asyncio `AsyncClient` covering all 40 SDK operations: public market
18
+ data, account, exports, wallet reads, trading and liquidity pools.
19
+ - Typed pydantic v2 models generated from the spec; every amount is a `decimal.Decimal`,
20
+ requests send amounts as strings and reject floats.
21
+ - API-key header authentication behind an `Authenticator` extension point (for HMAC
22
+ signing later); secrets redacted from reprs, logs and exceptions.
23
+ - Error hierarchy from `errors.yaml` (provisional), unknown codes tolerated.
24
+ - Retries with exponential backoff and jitter and `Retry-After` handling. Order safety
25
+ rests on `client_order_id` plus a by-client-id lookup (the server ignores
26
+ `Idempotency-Key` on order endpoints). A cancel retried into `INVALID_STATE` returns the
27
+ order's current state. Pool join/exit send an automatic `Idempotency-Key`.
28
+ - Client-side token-bucket rate limiter (100/min without a key, 300/min with one) adapting
29
+ to `X-RateLimit-*` (`X-RateLimit-Reset` in seconds).
30
+ - Cursor pagination with `auto_paging_iter()`.
31
+ - WebSocket client (`cexy.ws`) with id-correlated acknowledgements (auth waits for
32
+ `authenticated`), heartbeat, reconnect/resubscribe and an `OrderBook`
33
+ helper that follows the snapshot/sequence rules.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CEXY.io
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,331 @@
1
+ Metadata-Version: 2.5
2
+ Name: cexy
3
+ Version: 0.1.0.dev1
4
+ Summary: Python SDK for the CEXY.io exchange REST and WebSocket API
5
+ Project-URL: Homepage, https://cexy.io
6
+ Project-URL: Source, https://github.com/cexyio/cexy-python
7
+ Author: CEXY.io
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Office/Business :: Financial
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx<1,>=0.25
23
+ Requires-Dist: pydantic<3,>=2.5
24
+ Requires-Dist: websockets<17,>=13
25
+ Provides-Extra: codegen
26
+ Requires-Dist: datamodel-code-generator==0.83.0; extra == 'codegen'
27
+ Requires-Dist: ruff==0.16.9; extra == 'codegen'
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.10; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
31
+ Requires-Dist: pytest>=8; extra == 'dev'
32
+ Requires-Dist: pyyaml>=6; extra == 'dev'
33
+ Requires-Dist: respx>=0.21; extra == 'dev'
34
+ Requires-Dist: ruff==0.16.9; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # cexy: Python SDK for CEXY.io
38
+
39
+ Typed Python client for the [CEXY.io](https://cexy.io) exchange REST and WebSocket API.
40
+
41
+ - Sync (`cexy.Client`) and asyncio (`cexy.AsyncClient`) clients
42
+ - Pydantic v2 models generated from the public OpenAPI spec; every amount is a `Decimal`
43
+ - Safe retries (backoff, `Retry-After`, `client_order_id` for orders, idempotency keys for pools)
44
+ - Client-side rate limiting, cursor pagination
45
+ - WebSocket client with heartbeat, reconnect and a self-syncing order book
46
+
47
+ > Status: **0.1.0.dev1, pre-release.** The API may change before 1.0 (see [Versioning](#versioning)).
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install cexy # Python 3.9+
53
+ ```
54
+
55
+ ## Quickstart: public market data
56
+
57
+ No API key is needed for market data.
58
+
59
+ ```python
60
+ from cexy import Client
61
+
62
+ with Client() as client:
63
+ print(client.time().iso)
64
+ for market in client.markets.list():
65
+ print(market.symbol, market.last_price, market.status)
66
+
67
+ book = client.markets.orderbook("BTC/USDT", depth=10)
68
+ best_bid_price, best_bid_qty = book.bids[0] # Decimal, Decimal
69
+ candles = client.markets.candles("BTC/USDT", "1h", limit=24)
70
+ ```
71
+
72
+ Also available: `client.markets.get(symbol)`, `client.markets.trades(symbol)`,
73
+ `client.assets.list()/get()`, `client.networks.list()`, `client.fees.list()`,
74
+ `client.config()`, `client.pools.list()/get()`.
75
+
76
+ ## Quickstart: your account
77
+
78
+ Create an API key in your account settings. Keep the secret out of source code:
79
+
80
+ ```bash
81
+ export CEXY_API_KEY=ak_your_key_here
82
+ export CEXY_API_SECRET=your_secret_here
83
+ ```
84
+
85
+ ```python
86
+ import os
87
+ from decimal import Decimal
88
+ from cexy import Client
89
+
90
+ client = Client(api_key=os.environ["CEXY_API_KEY"], api_secret=os.environ["CEXY_API_SECRET"])
91
+
92
+ for balance in client.account.balances():
93
+ print(balance.asset, balance.available)
94
+
95
+ # Places a REAL order (needs the `trade` scope). Amounts: Decimal or str, never float.
96
+ placed = client.trading.place_order(
97
+ "BTC/USDT", "buy", "limit", quantity=Decimal("0.001"), price="30000", time_in_force="post_only"
98
+ )
99
+ client.trading.cancel_order(placed.order.id)
100
+ ```
101
+
102
+ Both `api_key` and `api_secret` are required together; passing only one raises
103
+ `ValueError` immediately.
104
+
105
+ | Resource | Methods |
106
+ |---|---|
107
+ | `client.account` | `balances()`, `balance(asset)`, `ledger()`, `notifications()`, `sub_accounts()`, `api_keys()` |
108
+ | `client.wallet` | `deposits()`, `deposit(id)`, `withdrawals()`, `withdrawal(id)`, `withdrawal_addresses()`, `deposit_address(asset, network)` |
109
+ | `client.trading` | `open_orders()`, `order(id)`, `order_by_client_id(id)`, `order_history()`, `trades()`, `place_order(...)`, `cancel_order(id)`, `cancel_all(symbol=...)` |
110
+ | `client.exports` | `deposits()`, `ledger()`, `orders()`, `trades()`, `withdrawals()` (CSV bytes) |
111
+ | `client.pools` | `join(symbol, base_amount=, quote_amount=)`, `exit(symbol, shares=)` |
112
+
113
+ Note: `wallet.deposit_address()` **creates** a deposit address the first time it is called
114
+ for an asset/network pair, then returns the same address on later calls.
115
+
116
+ `cancel_all` requires the `symbol` keyword; pass `symbol=None` explicitly to cancel orders
117
+ in every market. The server allows 30 cancel-all calls per minute per account.
118
+
119
+ The async client has the same methods:
120
+
121
+ ```python
122
+ import asyncio
123
+ from cexy import AsyncClient
124
+
125
+ async def main() -> None:
126
+ async with AsyncClient() as client:
127
+ markets = await client.markets.list()
128
+
129
+ asyncio.run(main())
130
+ ```
131
+
132
+ ## Amounts
133
+
134
+ The API sends every amount as a decimal string. The SDK parses them into
135
+ `decimal.Decimal`, and sends amounts as strings. Passing a `float` raises `TypeError`
136
+ before anything is sent, because a float cannot represent most decimal amounts exactly.
137
+
138
+ ## Errors
139
+
140
+ Every API error raises `cexy.CexyApiError` (or a subclass) with `code`, `message`,
141
+ `details`, `fields`, `request_id`, `retryable` and `status`. Branch on `code`, never on the
142
+ message.
143
+
144
+ | Exception | When |
145
+ |---|---|
146
+ | `ValidationError` | 400, e.g. `VALIDATION_FAILED`, `PRECISION_EXCEEDED` (see `fields`) |
147
+ | `AuthenticationError` | 401, e.g. `UNAUTHENTICATED`, `INVALID_CREDENTIALS` |
148
+ | `ForbiddenError` | 403: `FORBIDDEN` (key lacks a scope), `API_KEY_NOT_ALLOWED` (session-only endpoint) |
149
+ | `NotFoundError` | 404 |
150
+ | `ConflictError` | 409, e.g. `ALREADY_EXISTS`, `IDEMPOTENCY_KEY_CONFLICT` |
151
+ | `UnprocessableError` | 422, e.g. `INSUFFICIENT_FUNDS`, `MARKET_UNAVAILABLE` |
152
+ | `RateLimitError` | 429; `.retry_after` gives the server's requested wait |
153
+ | `ServerError` | 5xx, e.g. `UNDER_MAINTENANCE`, `ENGINE_OVERLOADED` |
154
+
155
+ An error code this SDK version does not know is raised as the base `CexyApiError`: it never
156
+ crashes the client. Network failures after retries raise `CexyConnectionError`. Calling a
157
+ private endpoint on a client without keys raises `MissingCredentialsError` locally.
158
+
159
+ ```python
160
+ from cexy import UnprocessableError
161
+
162
+ try:
163
+ client.trading.place_order("BTC/USDT", "buy", "market", quote_quantity="50")
164
+ except UnprocessableError as err:
165
+ if err.code == "INSUFFICIENT_FUNDS":
166
+ print("need", err.details.get("required"))
167
+ ```
168
+
169
+ ## Retries and idempotency
170
+
171
+ `Client(max_retries=3, timeout=10)` retries with exponential backoff and full jitter:
172
+
173
+ - **GET** requests retry on network errors, 429, 502/503/504 and any error with
174
+ `retryable: true`.
175
+ - **429** waits for the larger of the `Retry-After` header and `details.retry_after_seconds`.
176
+ - **Orders: safety rests on `client_order_id`, not on `Idempotency-Key`.** The server
177
+ does not honour `Idempotency-Key` on `POST /trading/orders`, `DELETE /trading/orders/{id}`
178
+ or cancel-all, so the SDK does not send one there.
179
+ - **`place_order`** always sends a `client_order_id` (a UUID unless you pass one). It is
180
+ unique per account, and a repeat is refused before any funds move. After an ambiguous
181
+ failure (timeout, dropped connection, 500/502/504) the SDK first looks the order up with
182
+ `trading.order_by_client_id()`. If the order exists, it is returned. Otherwise the order
183
+ is resent with the same `client_order_id`. If a retry is refused as a duplicate
184
+ (`ALREADY_EXISTS`), the existing order is fetched and returned.
185
+ - **`cancel_order`** is naturally repeatable and is retried. If a retry is refused with
186
+ `INVALID_STATE` (an earlier attempt already cancelled the order, or it filled
187
+ meanwhile), the SDK fetches and returns the order's current state, so check `status`.
188
+ - **`cancel_all`** is retried: repeating it only cancels whatever is still open.
189
+ - **Pool join/exit** carry an automatically generated `Idempotency-Key` header, reused on
190
+ every retry of the same call. A `409 CONCURRENT_MODIFICATION` (the same key still in
191
+ flight) is retried with the same key. Pass `idempotency_key=` to use your own.
192
+
193
+ ## Pagination
194
+
195
+ History endpoints return a `Page` with `items`, `next_cursor` and `has_more`:
196
+
197
+ ```python
198
+ page = client.trading.order_history(symbol="BTC/USDT", limit=100)
199
+ for order in page.auto_paging_iter(): # fetches later pages lazily
200
+ print(order.id, order.status)
201
+
202
+ # or one page at a time
203
+ next_page = page.next_page() # None on the last page
204
+ ```
205
+
206
+ With `AsyncClient`: `async for order in page.auto_paging_iter(): ...`.
207
+
208
+ ## Rate limits
209
+
210
+ Server-side limits:
211
+
212
+ - **Anonymous (public) requests:** about **120 requests/minute per IP address**.
213
+ - **API-key requests:** each key has its own limit, about **600 requests/minute per key**
214
+ (configurable server-side; this applies after the upcoming key release).
215
+
216
+ The client keeps itself below these with a token bucket: **100/min by default without a
217
+ key, 300/min with a key**. Override it with `Client(rate_limit_per_minute=...)`. When
218
+ responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`
219
+ (the number of **seconds** until the window resets), the client slows down to match them. A 429
220
+ is retried after the server's `Retry-After`.
221
+
222
+ ## WebSocket
223
+
224
+ ```python
225
+ import asyncio
226
+ from cexy.ws import WebSocketClient, BOOK_STALE, RECONNECTED
227
+
228
+ async def main() -> None:
229
+ async with WebSocketClient() as ws: # wss://api.cexy.io/api/v1/ws
230
+ await ws.subscribe("ticker:BTC/USDT", "trades:BTC/USDT")
231
+ book = await ws.order_book("BTC/USDT")
232
+ async for event in ws:
233
+ if event.type == "orderbook.update":
234
+ print(book.sequence, book.best_bid(), book.best_ask())
235
+ elif event.type == BOOK_STALE:
236
+ print("gap detected; book is stale until the next update")
237
+
238
+ asyncio.run(main())
239
+ ```
240
+
241
+ The client handles the protocol rules for you:
242
+
243
+ - It sends `{"op":"ping"}` every 30 s (required: the server closes idle connections), accepts
244
+ the server's unsolicited `pong` frames, and reconnects if nothing arrives for 75 s.
245
+ - It reconnects with exponential backoff and jitter, then re-authenticates, re-subscribes
246
+ and emits a `reconnected` event.
247
+ - It refuses more than 100 subscriptions locally (reported in `SubscribeResult.refused`)
248
+ and keeps its own send rate under 200 messages per minute.
249
+ - Every request carries an `id` and waits for the server's acknowledgement with that id
250
+ (`authenticated`, `subscribed`, `unsubscribed`, `pong`). An `error` acknowledgement, or none
251
+ within `request_timeout`, raises `cexy.ws.WebSocketError`. `await ws.ping()` returns the
252
+ round-trip time.
253
+ - Unknown event types are ignored; an unknown `protocol_version` logs one warning.
254
+ - You can register callbacks with `ws.on("trade.new", handler)` instead of iterating.
255
+
256
+ **Order-book rules** (implemented by `ws.order_book()`):
257
+
258
+ 1. Subscribe to `orderbook:{symbol}` first, then take a REST snapshot at sequence `S`.
259
+ 2. Drop updates with `sequence <= S`.
260
+ 3. Every `orderbook.update` carries the complete top 50 of both sides (`"full": true`) and
261
+ replaces the book outright; there are no deltas. REST levels deeper than 50 are never
262
+ merged into the live book.
263
+ 4. A sequence gap marks the book `stale` (and emits `book_stale`) until the next update heals
264
+ it. There is no forced resync.
265
+ 5. After every reconnect a fresh snapshot is taken: sequences reset when the server
266
+ restarts and are never compared across connections.
267
+ 6. A `CONCURRENT_MODIFICATION` error frame (messages were dropped) triggers a fresh snapshot
268
+ of every book and a `resync` event, so you can refresh other state too.
269
+
270
+ **Private channels** (`orders`, `balances`, `deposits`, `withdrawals`, `account`) need
271
+ `await ws.auth(token)` with a session access token; it returns once the server sends
272
+ `authenticated`. **API-key authentication on the
273
+ WebSocket is not available yet**; with an API key, poll the REST endpoints for private
274
+ state. If the session is revoked, the `account` channel delivers `session.revoked` and the
275
+ client emits `auth_lost`; the socket stays open for public channels.
276
+
277
+ ## Security notes
278
+
279
+ - **API keys can never withdraw or transfer funds**, whatever their scopes.
280
+ - Use a `read`-only key unless you trade, and restrict keys to your IPs with `allowed_ips`.
281
+ - Keys are sent only as headers, only to endpoints that need them, and never in a URL.
282
+ Never put secrets in URLs or source code.
283
+ - The SDK redacts keys from `repr()`, logs and exception messages, and never sends an
284
+ `Authorization` header. If a server response echoes the key or secret, it is replaced
285
+ with `***` in the exception's message, details, fields and request id.
286
+ - Connections use `https://` and `wss://` only. `allow_insecure=True` permits `http://` or
287
+ `ws://` for a loopback host (localhost, 127.0.0.1, ::1) only, for local testing.
288
+ - Report vulnerabilities as described in [SECURITY.md](SECURITY.md).
289
+
290
+ ## Authentication extension point
291
+
292
+ Credentials are applied by an `cexy.auth.Authenticator` (`apply(method, url, headers, body)`).
293
+ Today's implementation, `HeaderKeyAuth`, sends the static key headers. HMAC request signing
294
+ is planned before 1.0 and will plug in as another authenticator without changing the
295
+ resource API.
296
+
297
+ ## Versioning
298
+
299
+ The SDK stays at **0.x** until API request signing ships, then moves to 1.x, which targets
300
+ `/api/v1`. Additive API changes produce minor releases. Each release notes the spec snapshot
301
+ it was built from in [CHANGELOG.md](CHANGELOG.md).
302
+
303
+ ## Development
304
+
305
+ ```bash
306
+ python -m venv .venv && . .venv/bin/activate
307
+ pip install -e ".[dev,codegen]"
308
+ pytest # offline: mocked HTTP and a local fake WebSocket server
309
+ CEXY_LIVE_TESTS=1 pytest -m live # opt-in: 3 public, unauthenticated GETs to api.cexy.io
310
+ ruff check . && ruff format --check . && mypy
311
+ scripts/generate.sh # regenerate models (cexy/_generated) and sync code (cexy/_sync)
312
+ scripts/sync_spec.sh ../cexy-api-spec # refresh the vendored spec and conformance fixtures
313
+ python tools/scan_internal.py . # extra local patterns: $CEXY_SCAN_PATTERNS_FILE
314
+ ```
315
+
316
+ `tools/scan_internal.py` is a verbatim copy from cexy-api-spec. Infrastructure-specific
317
+ patterns are not in the repository: they are read from the file named by
318
+ `CEXY_SCAN_PATTERNS_FILE` (default `~/.config/cexy/scan-patterns.txt`), which CI writes from
319
+ a repository secret.
320
+
321
+ - `spec/openapi.sdk.json` and `tests/fixtures/conformance/` are vendored from
322
+ [cexy-api-spec](https://github.com/cexyio/cexy-api-spec); CI checks they are in sync.
323
+ - Type checking: mypy runs with `python_version = "3.10"` (current mypy no longer accepts
324
+ 3.9 as a target), while the CI test matrix still runs the suite on Python 3.9 to 3.13,
325
+ so 3.9 runtime compatibility is covered by the tests.
326
+ - `cexy/_generated/` is generated with datamodel-code-generator; `cexy/_sync/` is generated
327
+ from `cexy/_async/`. Edit the sources, then run `scripts/generate.sh`; CI fails on drift.
328
+
329
+ ## License
330
+
331
+ MIT, see [LICENSE](LICENSE).