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.
@@ -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
+ [![Docs Build Status](https://readthedocs.org/projects/cryptocom-exchange/badge/?version=latest&style=flat)](https://readthedocs.org/projects/cryptocom-exchange)
30
+ ![Test workflow](https://github.com/goincrypto/cryptocom-exchange/actions/workflows/test_release.yml/badge.svg)
31
+ [![PyPI implementation](https://img.shields.io/pypi/implementation/cryptocom-exchange.svg)](https://pypi.python.org/pypi/cryptocom-exchange/)
32
+ [![PyPI pyversions](https://img.shields.io/pypi/pyversions/cryptocom-exchange.svg)](https://pypi.python.org/pypi/cryptocom-exchange/)
33
+ [![PyPI license](https://img.shields.io/pypi/l/cryptocom-exchange.svg)](https://pypi.python.org/pypi/cryptocom-exchange/)
34
+ [![PyPI version fury.io](https://badge.fury.io/py/cryptocom-exchange.svg)](https://badge.fury.io/py/cryptocom-exchange/)
35
+ [![PyPI download month](https://img.shields.io/pypi/dm/cryptocom-exchange.svg)](https://pypi.python.org/pypi/cryptocom-exchange/)
36
+ [![Gitter](https://badges.gitter.im/goincrypto/cryptocom-exchange.svg)](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
+ [![Docs Build Status](https://readthedocs.org/projects/cryptocom-exchange/badge/?version=latest&style=flat)](https://readthedocs.org/projects/cryptocom-exchange)
4
+ ![Test workflow](https://github.com/goincrypto/cryptocom-exchange/actions/workflows/test_release.yml/badge.svg)
5
+ [![PyPI implementation](https://img.shields.io/pypi/implementation/cryptocom-exchange.svg)](https://pypi.python.org/pypi/cryptocom-exchange/)
6
+ [![PyPI pyversions](https://img.shields.io/pypi/pyversions/cryptocom-exchange.svg)](https://pypi.python.org/pypi/cryptocom-exchange/)
7
+ [![PyPI license](https://img.shields.io/pypi/l/cryptocom-exchange.svg)](https://pypi.python.org/pypi/cryptocom-exchange/)
8
+ [![PyPI version fury.io](https://badge.fury.io/py/cryptocom-exchange.svg)](https://badge.fury.io/py/cryptocom-exchange/)
9
+ [![PyPI download month](https://img.shields.io/pypi/dm/cryptocom-exchange.svg)](https://pypi.python.org/pypi/cryptocom-exchange/)
10
+ [![Gitter](https://badges.gitter.im/goincrypto/cryptocom-exchange.svg)](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`.
@@ -7,7 +7,7 @@ packages = [
7
7
  {include = "cryptocom", from = "src"},
8
8
  ]
9
9
  exclude = ["openapi_docs/"]
10
- version = "2.1.2"
10
+ version = "2.2.0"
11
11
  readme = "README.md"
12
12
  classifiers = [
13
13
  "Operating System :: OS Independent",
@@ -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
  ]