cryptocom-exchange 2.1.2__tar.gz → 2.2.0__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.
- cryptocom_exchange-2.2.0/PKG-INFO +272 -0
- cryptocom_exchange-2.2.0/README.md +245 -0
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/pyproject.toml +1 -1
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/exchange/__init__.py +5 -2
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/exchange/account.py +263 -25
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/exchange/api.py +163 -49
- cryptocom_exchange-2.2.0/src/cryptocom/exchange/exceptions.py +443 -0
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/exchange/instruments.py +5 -15
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/exchange/market.py +2 -2
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/exchange/pairs.py +132 -297
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/exchange/structs.py +83 -5
- cryptocom_exchange-2.1.2/PKG-INFO +0 -50
- cryptocom_exchange-2.1.2/README.md +0 -24
- cryptocom_exchange-2.1.2/src/cryptocom/exchange/exceptions.py +0 -169
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/LICENSE +0 -0
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/__init__.py +0 -0
- {cryptocom_exchange-2.1.2 → cryptocom_exchange-2.2.0}/src/cryptocom/exchange/helpers.py +0 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cryptocom-exchange
|
|
3
|
+
Version: 2.2.0
|
|
4
|
+
Summary: Python 3.11+ async library for crypto.com/exchange API using httpx and websockets
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Morty Space
|
|
8
|
+
Author-email: morty.space@gmail.com
|
|
9
|
+
Requires-Python: >=3.11,<4.0
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
18
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Dist: aiolimiter (>=1.2.1,<2.0.0)
|
|
21
|
+
Requires-Dist: async-timeout (>=5.0.1,<6.0.0)
|
|
22
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
23
|
+
Requires-Dist: typing-extensions (>=4.0.0,<5.0.0)
|
|
24
|
+
Requires-Dist: websockets (>=15.0.1,<16.0.0)
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
## Python 3.11+ async library for crypto.com/exchange API using httpx and websockets
|
|
28
|
+
|
|
29
|
+
[](https://readthedocs.org/projects/cryptocom-exchange)
|
|
30
|
+

|
|
31
|
+
[](https://pypi.python.org/pypi/cryptocom-exchange/)
|
|
32
|
+
[](https://pypi.python.org/pypi/cryptocom-exchange/)
|
|
33
|
+
[](https://pypi.python.org/pypi/cryptocom-exchange/)
|
|
34
|
+
[](https://badge.fury.io/py/cryptocom-exchange/)
|
|
35
|
+
[](https://pypi.python.org/pypi/cryptocom-exchange/)
|
|
36
|
+
[](https://gitter.im/goincrypto/cryptocom-exchange?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
|
37
|
+
|
|
38
|
+
Documentation: [https://cryptocom-exchange.rtfd.io](https://cryptocom-exchange.rtfd.io)
|
|
39
|
+
|
|
40
|
+
Exchange original API docs: [https://exchange-docs.crypto.com](https://exchange-docs.crypto.com)
|
|
41
|
+
|
|
42
|
+
### Description
|
|
43
|
+
|
|
44
|
+
`pip install cryptocom-exchange` or `poetry add cryptocom-exchange`
|
|
45
|
+
|
|
46
|
+
- fully operational v1 exchange API, including websocket streams
|
|
47
|
+
- typed error taxonomy with an explicit indeterminate/deterministic contract
|
|
48
|
+
- `Account` order cache and cancel/wait primitives that never assume an outcome
|
|
49
|
+
- per-endpoint rate limits, bounded retries with optional backoff
|
|
50
|
+
- offline test suite replaying captured exchange responses, coverage gated at 90%
|
|
51
|
+
|
|
52
|
+
### Quick start
|
|
53
|
+
|
|
54
|
+
`Market` covers public data and needs no keys; `Account` covers trading.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
|
|
59
|
+
import cryptocom.exchange as cro
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def main():
|
|
63
|
+
# public endpoints need no keys
|
|
64
|
+
market = cro.Market()
|
|
65
|
+
print(f"CRO price: {await market.get_price(cro.pairs.CRO_USD)}")
|
|
66
|
+
|
|
67
|
+
# reads CRYPTOCOM_API_KEY / CRYPTOCOM_API_SECRET
|
|
68
|
+
account = cro.Account(from_env=True)
|
|
69
|
+
balance = await account.get_balance() # dict[Instrument, InstrumentBalance]
|
|
70
|
+
print(f"USD available: {balance[cro.instruments.USD].available}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
asyncio.run(main())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Pairs ship with the package as `cro.pairs.CRO_USD` and friends. Call
|
|
77
|
+
`await market.sync_pairs()` (or `await account.sync_pairs()`) to replace them
|
|
78
|
+
with the live instrument list.
|
|
79
|
+
|
|
80
|
+
### Error handling
|
|
81
|
+
|
|
82
|
+
Errors live in `cryptocom.exchange.exceptions`: named subclasses are mapped
|
|
83
|
+
from exchange error codes by `api_error_from_response`, and everything derives
|
|
84
|
+
from `ApiError`. The property that drives safe orchestration is
|
|
85
|
+
`is_indeterminate`.
|
|
86
|
+
|
|
87
|
+
- **Deterministic** (`is_indeterminate` is `False`): the exchange explicitly
|
|
88
|
+
rejected the request with a known code — `ExchangeError` and its subclasses
|
|
89
|
+
(`NotEnoughBalanceError`, `OrderNotFoundError`, `PostOnlyRejectedError`,
|
|
90
|
+
`InvalidInstrumentError`, ...). The order was not created and nothing
|
|
91
|
+
changed, so it is safe to act on that answer.
|
|
92
|
+
- **Indeterminate** (`True`): the outcome is unknown — `ApiError` with
|
|
93
|
+
`code=None` (transport failure, timeout, malformed reply, HTTP 5xx without
|
|
94
|
+
an API code) plus the known-code-but-unknowable `RequestTimeoutError`
|
|
95
|
+
(40801) and `InternalError` (50001, 120009). Never read it as "did not
|
|
96
|
+
happen" and never resend blindly: re-query by `client_oid`.
|
|
97
|
+
|
|
98
|
+
Branch on `is_indeterminate`, not on the exception class:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
try:
|
|
102
|
+
keys = await account.create_order(
|
|
103
|
+
pair,
|
|
104
|
+
cro.OrderSide.BUY,
|
|
105
|
+
cro.OrderType.LIMIT,
|
|
106
|
+
quantity=100,
|
|
107
|
+
price=0.05,
|
|
108
|
+
client_id=client_id,
|
|
109
|
+
)
|
|
110
|
+
except cro.ApiError as exc:
|
|
111
|
+
if exc.is_indeterminate:
|
|
112
|
+
# Unknown outcome: reconcile by client_oid, never resend blindly.
|
|
113
|
+
order = await account.get_order(client_order_id=client_id, ignore_cache=True)
|
|
114
|
+
else:
|
|
115
|
+
# Deterministic rejection: exc.code is set, the order does not exist.
|
|
116
|
+
log.warning("rejected %s: %s", exc.code, exc)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
HTTP statuses route through the same taxonomy in `ApiProvider`: 401/403 →
|
|
120
|
+
`ApiAuthError`; a body carrying an API code → the taxonomy; 5xx without a code
|
|
121
|
+
→ indeterminate `ApiError`; any other 4xx → deterministic `ExchangeError`
|
|
122
|
+
carrying the HTTP status.
|
|
123
|
+
|
|
124
|
+
`retries` counts retries *after* the first attempt and is one budget for every
|
|
125
|
+
failure kind, transport errors included; `retry_backoff` sleeps between
|
|
126
|
+
attempts. Only transient failures retry: `RETRYABLE_ERROR_CODES` (40801,
|
|
127
|
+
42901, 10006, 50001, 120009, 9010001) and HTTP 408/429/5xx, testable with
|
|
128
|
+
`is_retryable_error_code`. A deterministic rejection raises on the first
|
|
129
|
+
response.
|
|
130
|
+
|
|
131
|
+
### Orders
|
|
132
|
+
|
|
133
|
+
`Account` caches orders by `client_oid` in `Account.order_cache` (the snippets
|
|
134
|
+
here use `from cryptocom.exchange import exceptions`). Terminal
|
|
135
|
+
entries (`Order.is_terminal`, `TERMINAL_ORDER_STATUSES`) are final and live
|
|
136
|
+
forever; live entries expire after `orders_ttl` seconds (default 5, `0`
|
|
137
|
+
disables live caching). The cache is only written from observed answers: cancel
|
|
138
|
+
and amend invalidate, they never write an assumed status.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# Cache-backed by client_oid: terminal orders are cached forever, live ones
|
|
142
|
+
# for orders_ttl seconds. ignore_cache=True queries the exchange.
|
|
143
|
+
order = await account.get_order(client_order_id=client_id, ignore_cache=True)
|
|
144
|
+
|
|
145
|
+
# Poll to a status. Confirmed-absent keeps the wait going; only the deadline
|
|
146
|
+
# ends it, with an indeterminate error.
|
|
147
|
+
order = await account.wait_for_order(
|
|
148
|
+
(cro.OrderStatus.FILLED, cro.OrderStatus.CANCELED),
|
|
149
|
+
client_order_id=client_id,
|
|
150
|
+
timeout=5.0,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Cancel, then poll to a terminal status. Returns the terminal order, or None
|
|
154
|
+
# when the exchange confirms the order is gone.
|
|
155
|
+
try:
|
|
156
|
+
order = await account.cancel_order_and_wait(pair, client_id, timeout=5.0)
|
|
157
|
+
except exceptions.CancelNotConfirmedError:
|
|
158
|
+
await reconcile_later(client_id) # cancel may still land: reconcile
|
|
159
|
+
else:
|
|
160
|
+
print("terminal:", order.status if order else "absent")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A websocket feed can answer lookups directly: `updates` wins over cache and
|
|
164
|
+
API, and populates the cache:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# A websocket snapshot beats cache and API, and populates the cache.
|
|
168
|
+
order = await account.get_order(client_order_id=client_id, updates={client_id: snapshot})
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Two guards worth knowing:
|
|
172
|
+
|
|
173
|
+
- A `client_oid` created moments ago that reads back empty raises an
|
|
174
|
+
indeterminate `ApiError`, not `OrderNotFoundError`, for `create_race_guard`
|
|
175
|
+
seconds (default 2). Absence right after create is not proof of absence.
|
|
176
|
+
- `wait_for_order` raises `exceptions.WaitNotConfirmedError` (indeterminate) on
|
|
177
|
+
timeout instead of returning `None`. `wait_for_status` is deprecated in its
|
|
178
|
+
favor.
|
|
179
|
+
|
|
180
|
+
### Order book
|
|
181
|
+
|
|
182
|
+
`OrderBook` normalizes both sides to best-first in `__post_init__`, regardless
|
|
183
|
+
of the order the caller or the API provides: `buys` by price descending,
|
|
184
|
+
`sells` by price ascending. Use the properties; an empty book raises
|
|
185
|
+
`ValueError`.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
import cryptocom.exchange as cro
|
|
189
|
+
from cryptocom.exchange.structs import OrderBook, OrderInBook
|
|
190
|
+
|
|
191
|
+
pair = cro.pairs.CRO_USD
|
|
192
|
+
book = OrderBook(
|
|
193
|
+
buys=[
|
|
194
|
+
OrderInBook(0.150, 100, 0, pair, cro.OrderSide.BUY),
|
|
195
|
+
OrderInBook(0.152, 100, 0, pair, cro.OrderSide.BUY),
|
|
196
|
+
],
|
|
197
|
+
sells=[
|
|
198
|
+
OrderInBook(0.155, 100, 0, pair, cro.OrderSide.SELL),
|
|
199
|
+
OrderInBook(0.153, 100, 0, pair, cro.OrderSide.SELL),
|
|
200
|
+
],
|
|
201
|
+
pair=pair,
|
|
202
|
+
)
|
|
203
|
+
assert book.best_bid.price == 0.152 # buys: price DESC
|
|
204
|
+
assert book.best_ask.price == 0.153 # sells: price ASC
|
|
205
|
+
print(book.mid, book.spread)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Snapshots and streams share the type:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
book = await market.get_orderbook(pair)
|
|
212
|
+
print(book.best_bid.price, book.best_ask.price, book.mid, book.spread)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Instruments and balances
|
|
216
|
+
|
|
217
|
+
Instrument identity is `exchange_name`: `__eq__`/`__hash__` are defined on it
|
|
218
|
+
for `Instrument` and `InstrumentBalance`, so the two interoperate as dict
|
|
219
|
+
keys and `get_balance()` is keyed by plain `Instrument`s.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
balance = await account.get_balance() # dict[Instrument, InstrumentBalance]
|
|
223
|
+
usd = balance[cro.instruments.USD] # keys are plain Instruments
|
|
224
|
+
|
|
225
|
+
snapshot = await anext(account.listen_balances()) # a structs.Balance
|
|
226
|
+
snapshot.get("USD") is snapshot.get(cro.instruments.USD) # str, Instrument
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Scope note: `get_orders_history()` and `get_balance_history()` work with trade
|
|
230
|
+
API keys; `get_deposit_history()` and `get_withdrawal_history()` are
|
|
231
|
+
wallet/custody-side and answer HTTP 401 `USER_NOT_FOUND` for trade keys.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
orders = await account.get_orders_history(pair)
|
|
235
|
+
balances = await account.get_balance_history()
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Testing
|
|
239
|
+
|
|
240
|
+
The suite replays recorded exchange responses and never opens a socket, so CI
|
|
241
|
+
is deterministic and offline (it passes with no network and no keys).
|
|
242
|
+
|
|
243
|
+
- Captures live in `tests/captured/<test_file>/<test_name>.json`, keyed by
|
|
244
|
+
`method path` plus sorted request arguments, and consumed in order.
|
|
245
|
+
13-digit epoch-millis are normalized to `<ts>` so time windows match, and
|
|
246
|
+
auto-generated `client_oid`s are recorded under `"__uuids__"` and replayed in
|
|
247
|
+
sequence.
|
|
248
|
+
- Replay is strict: a miss raises `ApiError` listing the arguments that were
|
|
249
|
+
captured. There is no silent live fallback, so a passing CI run cannot drift
|
|
250
|
+
from reality unnoticed. Code that changes arguments, call order, or the
|
|
251
|
+
number of generated client ids must be re-recorded.
|
|
252
|
+
- Re-record with a funded account (this spends real money):
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
API_CAPTURE=true CRYPTOCOM_API_KEY=*** CRYPTOCOM_API_SECRET=*** \
|
|
256
|
+
poetry run pytest tests/test_account.py
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
then commit the regenerated capture files.
|
|
260
|
+
- Write a live test as an ordinary test using the `api`, `market` and
|
|
261
|
+
`account` fixtures in `tests/conftest.py`. The `account` fixture rebalances
|
|
262
|
+
USD/CRO 50/50 on the feeless `CRO_USD` pair and cancels its open orders on
|
|
263
|
+
teardown; size orders from pair rules via `tests/helpers.py`
|
|
264
|
+
(`min_order_quantity`, `resting_buy_price`, `resting_sell_price`) instead of
|
|
265
|
+
magic numbers, and assert on shapes rather than exchange-side values so the
|
|
266
|
+
capture replays.
|
|
267
|
+
- Transport behavior (retry accounting, status routing, taxonomy) is tested
|
|
268
|
+
offline by overriding `ApiProvider._http_request()`, the single outbound HTTP
|
|
269
|
+
call — see `tests/test_api_request.py`.
|
|
270
|
+
- CI command: `poetry run pytest -v --cov . --cov-report xml:coverage.xml
|
|
271
|
+
--cov-fail-under=90`.
|
|
272
|
+
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
## Python 3.11+ async library for crypto.com/exchange API using httpx and websockets
|
|
2
|
+
|
|
3
|
+
[](https://readthedocs.org/projects/cryptocom-exchange)
|
|
4
|
+

|
|
5
|
+
[](https://pypi.python.org/pypi/cryptocom-exchange/)
|
|
6
|
+
[](https://pypi.python.org/pypi/cryptocom-exchange/)
|
|
7
|
+
[](https://pypi.python.org/pypi/cryptocom-exchange/)
|
|
8
|
+
[](https://badge.fury.io/py/cryptocom-exchange/)
|
|
9
|
+
[](https://pypi.python.org/pypi/cryptocom-exchange/)
|
|
10
|
+
[](https://gitter.im/goincrypto/cryptocom-exchange?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
|
11
|
+
|
|
12
|
+
Documentation: [https://cryptocom-exchange.rtfd.io](https://cryptocom-exchange.rtfd.io)
|
|
13
|
+
|
|
14
|
+
Exchange original API docs: [https://exchange-docs.crypto.com](https://exchange-docs.crypto.com)
|
|
15
|
+
|
|
16
|
+
### Description
|
|
17
|
+
|
|
18
|
+
`pip install cryptocom-exchange` or `poetry add cryptocom-exchange`
|
|
19
|
+
|
|
20
|
+
- fully operational v1 exchange API, including websocket streams
|
|
21
|
+
- typed error taxonomy with an explicit indeterminate/deterministic contract
|
|
22
|
+
- `Account` order cache and cancel/wait primitives that never assume an outcome
|
|
23
|
+
- per-endpoint rate limits, bounded retries with optional backoff
|
|
24
|
+
- offline test suite replaying captured exchange responses, coverage gated at 90%
|
|
25
|
+
|
|
26
|
+
### Quick start
|
|
27
|
+
|
|
28
|
+
`Market` covers public data and needs no keys; `Account` covers trading.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import asyncio
|
|
32
|
+
|
|
33
|
+
import cryptocom.exchange as cro
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
# public endpoints need no keys
|
|
38
|
+
market = cro.Market()
|
|
39
|
+
print(f"CRO price: {await market.get_price(cro.pairs.CRO_USD)}")
|
|
40
|
+
|
|
41
|
+
# reads CRYPTOCOM_API_KEY / CRYPTOCOM_API_SECRET
|
|
42
|
+
account = cro.Account(from_env=True)
|
|
43
|
+
balance = await account.get_balance() # dict[Instrument, InstrumentBalance]
|
|
44
|
+
print(f"USD available: {balance[cro.instruments.USD].available}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
asyncio.run(main())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Pairs ship with the package as `cro.pairs.CRO_USD` and friends. Call
|
|
51
|
+
`await market.sync_pairs()` (or `await account.sync_pairs()`) to replace them
|
|
52
|
+
with the live instrument list.
|
|
53
|
+
|
|
54
|
+
### Error handling
|
|
55
|
+
|
|
56
|
+
Errors live in `cryptocom.exchange.exceptions`: named subclasses are mapped
|
|
57
|
+
from exchange error codes by `api_error_from_response`, and everything derives
|
|
58
|
+
from `ApiError`. The property that drives safe orchestration is
|
|
59
|
+
`is_indeterminate`.
|
|
60
|
+
|
|
61
|
+
- **Deterministic** (`is_indeterminate` is `False`): the exchange explicitly
|
|
62
|
+
rejected the request with a known code — `ExchangeError` and its subclasses
|
|
63
|
+
(`NotEnoughBalanceError`, `OrderNotFoundError`, `PostOnlyRejectedError`,
|
|
64
|
+
`InvalidInstrumentError`, ...). The order was not created and nothing
|
|
65
|
+
changed, so it is safe to act on that answer.
|
|
66
|
+
- **Indeterminate** (`True`): the outcome is unknown — `ApiError` with
|
|
67
|
+
`code=None` (transport failure, timeout, malformed reply, HTTP 5xx without
|
|
68
|
+
an API code) plus the known-code-but-unknowable `RequestTimeoutError`
|
|
69
|
+
(40801) and `InternalError` (50001, 120009). Never read it as "did not
|
|
70
|
+
happen" and never resend blindly: re-query by `client_oid`.
|
|
71
|
+
|
|
72
|
+
Branch on `is_indeterminate`, not on the exception class:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
try:
|
|
76
|
+
keys = await account.create_order(
|
|
77
|
+
pair,
|
|
78
|
+
cro.OrderSide.BUY,
|
|
79
|
+
cro.OrderType.LIMIT,
|
|
80
|
+
quantity=100,
|
|
81
|
+
price=0.05,
|
|
82
|
+
client_id=client_id,
|
|
83
|
+
)
|
|
84
|
+
except cro.ApiError as exc:
|
|
85
|
+
if exc.is_indeterminate:
|
|
86
|
+
# Unknown outcome: reconcile by client_oid, never resend blindly.
|
|
87
|
+
order = await account.get_order(client_order_id=client_id, ignore_cache=True)
|
|
88
|
+
else:
|
|
89
|
+
# Deterministic rejection: exc.code is set, the order does not exist.
|
|
90
|
+
log.warning("rejected %s: %s", exc.code, exc)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
HTTP statuses route through the same taxonomy in `ApiProvider`: 401/403 →
|
|
94
|
+
`ApiAuthError`; a body carrying an API code → the taxonomy; 5xx without a code
|
|
95
|
+
→ indeterminate `ApiError`; any other 4xx → deterministic `ExchangeError`
|
|
96
|
+
carrying the HTTP status.
|
|
97
|
+
|
|
98
|
+
`retries` counts retries *after* the first attempt and is one budget for every
|
|
99
|
+
failure kind, transport errors included; `retry_backoff` sleeps between
|
|
100
|
+
attempts. Only transient failures retry: `RETRYABLE_ERROR_CODES` (40801,
|
|
101
|
+
42901, 10006, 50001, 120009, 9010001) and HTTP 408/429/5xx, testable with
|
|
102
|
+
`is_retryable_error_code`. A deterministic rejection raises on the first
|
|
103
|
+
response.
|
|
104
|
+
|
|
105
|
+
### Orders
|
|
106
|
+
|
|
107
|
+
`Account` caches orders by `client_oid` in `Account.order_cache` (the snippets
|
|
108
|
+
here use `from cryptocom.exchange import exceptions`). Terminal
|
|
109
|
+
entries (`Order.is_terminal`, `TERMINAL_ORDER_STATUSES`) are final and live
|
|
110
|
+
forever; live entries expire after `orders_ttl` seconds (default 5, `0`
|
|
111
|
+
disables live caching). The cache is only written from observed answers: cancel
|
|
112
|
+
and amend invalidate, they never write an assumed status.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# Cache-backed by client_oid: terminal orders are cached forever, live ones
|
|
116
|
+
# for orders_ttl seconds. ignore_cache=True queries the exchange.
|
|
117
|
+
order = await account.get_order(client_order_id=client_id, ignore_cache=True)
|
|
118
|
+
|
|
119
|
+
# Poll to a status. Confirmed-absent keeps the wait going; only the deadline
|
|
120
|
+
# ends it, with an indeterminate error.
|
|
121
|
+
order = await account.wait_for_order(
|
|
122
|
+
(cro.OrderStatus.FILLED, cro.OrderStatus.CANCELED),
|
|
123
|
+
client_order_id=client_id,
|
|
124
|
+
timeout=5.0,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Cancel, then poll to a terminal status. Returns the terminal order, or None
|
|
128
|
+
# when the exchange confirms the order is gone.
|
|
129
|
+
try:
|
|
130
|
+
order = await account.cancel_order_and_wait(pair, client_id, timeout=5.0)
|
|
131
|
+
except exceptions.CancelNotConfirmedError:
|
|
132
|
+
await reconcile_later(client_id) # cancel may still land: reconcile
|
|
133
|
+
else:
|
|
134
|
+
print("terminal:", order.status if order else "absent")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
A websocket feed can answer lookups directly: `updates` wins over cache and
|
|
138
|
+
API, and populates the cache:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
# A websocket snapshot beats cache and API, and populates the cache.
|
|
142
|
+
order = await account.get_order(client_order_id=client_id, updates={client_id: snapshot})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Two guards worth knowing:
|
|
146
|
+
|
|
147
|
+
- A `client_oid` created moments ago that reads back empty raises an
|
|
148
|
+
indeterminate `ApiError`, not `OrderNotFoundError`, for `create_race_guard`
|
|
149
|
+
seconds (default 2). Absence right after create is not proof of absence.
|
|
150
|
+
- `wait_for_order` raises `exceptions.WaitNotConfirmedError` (indeterminate) on
|
|
151
|
+
timeout instead of returning `None`. `wait_for_status` is deprecated in its
|
|
152
|
+
favor.
|
|
153
|
+
|
|
154
|
+
### Order book
|
|
155
|
+
|
|
156
|
+
`OrderBook` normalizes both sides to best-first in `__post_init__`, regardless
|
|
157
|
+
of the order the caller or the API provides: `buys` by price descending,
|
|
158
|
+
`sells` by price ascending. Use the properties; an empty book raises
|
|
159
|
+
`ValueError`.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
import cryptocom.exchange as cro
|
|
163
|
+
from cryptocom.exchange.structs import OrderBook, OrderInBook
|
|
164
|
+
|
|
165
|
+
pair = cro.pairs.CRO_USD
|
|
166
|
+
book = OrderBook(
|
|
167
|
+
buys=[
|
|
168
|
+
OrderInBook(0.150, 100, 0, pair, cro.OrderSide.BUY),
|
|
169
|
+
OrderInBook(0.152, 100, 0, pair, cro.OrderSide.BUY),
|
|
170
|
+
],
|
|
171
|
+
sells=[
|
|
172
|
+
OrderInBook(0.155, 100, 0, pair, cro.OrderSide.SELL),
|
|
173
|
+
OrderInBook(0.153, 100, 0, pair, cro.OrderSide.SELL),
|
|
174
|
+
],
|
|
175
|
+
pair=pair,
|
|
176
|
+
)
|
|
177
|
+
assert book.best_bid.price == 0.152 # buys: price DESC
|
|
178
|
+
assert book.best_ask.price == 0.153 # sells: price ASC
|
|
179
|
+
print(book.mid, book.spread)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Snapshots and streams share the type:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
book = await market.get_orderbook(pair)
|
|
186
|
+
print(book.best_bid.price, book.best_ask.price, book.mid, book.spread)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Instruments and balances
|
|
190
|
+
|
|
191
|
+
Instrument identity is `exchange_name`: `__eq__`/`__hash__` are defined on it
|
|
192
|
+
for `Instrument` and `InstrumentBalance`, so the two interoperate as dict
|
|
193
|
+
keys and `get_balance()` is keyed by plain `Instrument`s.
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
balance = await account.get_balance() # dict[Instrument, InstrumentBalance]
|
|
197
|
+
usd = balance[cro.instruments.USD] # keys are plain Instruments
|
|
198
|
+
|
|
199
|
+
snapshot = await anext(account.listen_balances()) # a structs.Balance
|
|
200
|
+
snapshot.get("USD") is snapshot.get(cro.instruments.USD) # str, Instrument
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Scope note: `get_orders_history()` and `get_balance_history()` work with trade
|
|
204
|
+
API keys; `get_deposit_history()` and `get_withdrawal_history()` are
|
|
205
|
+
wallet/custody-side and answer HTTP 401 `USER_NOT_FOUND` for trade keys.
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
orders = await account.get_orders_history(pair)
|
|
209
|
+
balances = await account.get_balance_history()
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Testing
|
|
213
|
+
|
|
214
|
+
The suite replays recorded exchange responses and never opens a socket, so CI
|
|
215
|
+
is deterministic and offline (it passes with no network and no keys).
|
|
216
|
+
|
|
217
|
+
- Captures live in `tests/captured/<test_file>/<test_name>.json`, keyed by
|
|
218
|
+
`method path` plus sorted request arguments, and consumed in order.
|
|
219
|
+
13-digit epoch-millis are normalized to `<ts>` so time windows match, and
|
|
220
|
+
auto-generated `client_oid`s are recorded under `"__uuids__"` and replayed in
|
|
221
|
+
sequence.
|
|
222
|
+
- Replay is strict: a miss raises `ApiError` listing the arguments that were
|
|
223
|
+
captured. There is no silent live fallback, so a passing CI run cannot drift
|
|
224
|
+
from reality unnoticed. Code that changes arguments, call order, or the
|
|
225
|
+
number of generated client ids must be re-recorded.
|
|
226
|
+
- Re-record with a funded account (this spends real money):
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
API_CAPTURE=true CRYPTOCOM_API_KEY=*** CRYPTOCOM_API_SECRET=*** \
|
|
230
|
+
poetry run pytest tests/test_account.py
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
then commit the regenerated capture files.
|
|
234
|
+
- Write a live test as an ordinary test using the `api`, `market` and
|
|
235
|
+
`account` fixtures in `tests/conftest.py`. The `account` fixture rebalances
|
|
236
|
+
USD/CRO 50/50 on the feeless `CRO_USD` pair and cancels its open orders on
|
|
237
|
+
teardown; size orders from pair rules via `tests/helpers.py`
|
|
238
|
+
(`min_order_quantity`, `resting_buy_price`, `resting_sell_price`) instead of
|
|
239
|
+
magic numbers, and assert on shapes rather than exchange-side values so the
|
|
240
|
+
capture replays.
|
|
241
|
+
- Transport behavior (retry accounting, status routing, taxonomy) is tested
|
|
242
|
+
offline by overriding `ApiProvider._http_request()`, the single outbound HTTP
|
|
243
|
+
call — see `tests/test_api_request.py`.
|
|
244
|
+
- CI command: `poetry run pytest -v --cov . --cov-report xml:coverage.xml
|
|
245
|
+
--cov-fail-under=90`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from . import instruments, pairs
|
|
1
|
+
from . import exceptions, instruments, pairs
|
|
2
2
|
from .api import ApiError, ApiProvider, RecordApiProvider
|
|
3
3
|
from .market import Market
|
|
4
4
|
from .account import (
|
|
@@ -7,7 +7,7 @@ from .account import (
|
|
|
7
7
|
MAX_OPEN_ORDERS_PER_ACCOUNT,
|
|
8
8
|
MAX_OPEN_ORDERS_PER_PAIR,
|
|
9
9
|
)
|
|
10
|
-
from .exceptions import ApiErrorCode
|
|
10
|
+
from .exceptions import ApiAuthError, ApiErrorCode, ExchangeError
|
|
11
11
|
from .structs import (
|
|
12
12
|
Candle,
|
|
13
13
|
Deposit,
|
|
@@ -38,6 +38,7 @@ __all__ = [
|
|
|
38
38
|
"OrderForceType",
|
|
39
39
|
"OrderExecFlag",
|
|
40
40
|
"OrderKeys",
|
|
41
|
+
"exceptions",
|
|
41
42
|
"pairs",
|
|
42
43
|
"Pair",
|
|
43
44
|
"instruments",
|
|
@@ -57,7 +58,9 @@ __all__ = [
|
|
|
57
58
|
"ApiError",
|
|
58
59
|
"ApiProvider",
|
|
59
60
|
"RecordApiProvider",
|
|
61
|
+
"ApiAuthError",
|
|
60
62
|
"ApiErrorCode",
|
|
63
|
+
"ExchangeError",
|
|
61
64
|
"MAX_OPEN_ORDERS_PER_PAIR",
|
|
62
65
|
"MAX_OPEN_ORDERS_PER_ACCOUNT",
|
|
63
66
|
]
|