Python 3.11+ async library for crypto.com/exchange API using httpx and websockets
Documentation: https://cryptocom-exchange.rtfd.io
Exchange original API docs: https://exchange-docs.crypto.com
Description
pip install cryptocom-exchange or poetry add cryptocom-exchange
- fully operational v1 exchange API, including websocket streams
- typed error taxonomy with an explicit indeterminate/deterministic contract
Accountorder cache and cancel/wait primitives that never assume an outcome- per-endpoint rate limits, bounded retries with optional backoff
- offline test suite replaying captured exchange responses, coverage gated at 90%
Quick start
Market covers public data and needs no keys; Account covers trading.
import asyncio
import cryptocom.exchange as cro
async def main():
# public endpoints need no keys
market = cro.Market()
print(f"CRO price: {await market.get_price(cro.pairs.CRO_USD)}")
# reads CRYPTOCOM_API_KEY / CRYPTOCOM_API_SECRET
account = cro.Account(from_env=True)
balance = await account.get_balance() # dict[Instrument, InstrumentBalance]
print(f"USD available: {balance[cro.instruments.USD].available}")
asyncio.run(main())
Pairs ship with the package as cro.pairs.CRO_USD and friends. Call
await market.sync_pairs() (or await account.sync_pairs()) to replace them
with the live instrument list.
Error handling
Errors live in cryptocom.exchange.exceptions: named subclasses are mapped
from exchange error codes by api_error_from_response, and everything derives
from ApiError. The property that drives safe orchestration is
is_indeterminate.
- Deterministic (
is_indeterminateisFalse): the exchange explicitly rejected the request with a known code —ExchangeErrorand its subclasses (NotEnoughBalanceError,OrderNotFoundError,PostOnlyRejectedError,InvalidInstrumentError, ...). The order was not created and nothing changed, so it is safe to act on that answer. - Indeterminate (
True): the outcome is unknown —ApiErrorwithcode=None(transport failure, timeout, malformed reply, HTTP 5xx without an API code) plus the known-code-but-unknowableRequestTimeoutError(40801) andInternalError(50001, 120009). Never read it as "did not happen" and never resend blindly: re-query byclient_oid.
Branch on is_indeterminate, not on the exception class:
try:
keys = await account.create_order(
pair,
cro.OrderSide.BUY,
cro.OrderType.LIMIT,
quantity=100,
price=0.05,
client_id=client_id,
)
except cro.ApiError as exc:
if exc.is_indeterminate:
# Unknown outcome: reconcile by client_oid, never resend blindly.
order = await account.get_order(client_order_id=client_id, ignore_cache=True)
else:
# Deterministic rejection: exc.code is set, the order does not exist.
log.warning("rejected %s: %s", exc.code, exc)
HTTP statuses route through the same taxonomy in ApiProvider: 401/403 →
ApiAuthError; a body carrying an API code → the taxonomy; 5xx without a code
→ indeterminate ApiError; any other 4xx → deterministic ExchangeError
carrying the HTTP status.
Client order ids have a local pre-flight contract: 1–36 characters, checked
by helpers.validate_client_id before any request is sent (empty or too-long
ids raise ClientIdError, a ValueError — no request, nothing to
reconcile). It also validates derived id schemes: pass the largest id your
pattern can produce, e.g. cro.helpers.validate_client_id(f"{prefix}-buy-999999999").
retries counts retries after the first attempt and is one budget for every
failure kind, transport errors included; retry_backoff sleeps between
attempts. Only transient failures retry: RETRYABLE_ERROR_CODES (40801,
42901, 10006, 50001, 120009, 9010001) and HTTP 408/429/5xx, testable with
is_retryable_error_code. A deterministic rejection raises on the first
response.
Orders
Account caches orders by client_oid in Account.order_cache (the snippets
here use from cryptocom.exchange import exceptions). get_order returns
None when the exchange CONFIRMS absence (empty result or code 40401) — a
definite answer; unknown outcomes raise ApiError. (OrderNotFoundError
lives on for endpoints where absence IS the error reply: cancel, amend.)
Terminal
entries (Order.is_terminal, TERMINAL_ORDER_STATUSES) are final and live
forever; live entries expire after orders_ttl seconds (default 5, 0
disables live caching). The cache is only written from observed answers: cancel
and amend invalidate, they never write an assumed status.
# Cache-backed by client_oid: terminal orders are cached forever, live ones
# for orders_ttl seconds. ignore_cache=True queries the exchange.
order = await account.get_order(client_order_id=client_id, ignore_cache=True)
# Poll to a status. Confirmed-absent keeps the wait going; only the deadline
# ends it, with an indeterminate error.
order = await account.wait_for_order(
(cro.OrderStatus.FILLED, cro.OrderStatus.CANCELED),
client_order_id=client_id,
timeout=5.0,
)
# Cancel, then poll to terminal. True = CONFIRMED not live (terminal or
# absent); False = not confirmed (timed out or still live) - never treat
# False as cancelled. Indeterminate cancel/lookup failures raise ApiError.
cancelled = await account.cancel_order_and_wait(pair, client_id, timeout=5.0)
if not cancelled:
await reconcile_later(client_id) # may still land after the last poll
A websocket feed can answer lookups directly: updates wins over cache and
API, and populates the cache:
# A websocket snapshot beats cache and API, and populates the cache.
order = await account.get_order(client_order_id=client_id, updates={client_id: snapshot})
Two guards worth knowing:
- A
client_oidcreated moments ago that reads back empty raises an indeterminateApiErrorinstead of returningNone, forcreate_race_guardseconds (default 2). Absence right after create is not proof of absence. wait_for_orderraisesexceptions.WaitNotConfirmedError(indeterminate) on timeout instead of returningNone.wait_for_statusis deprecated in its favor.
Order book
OrderBook normalizes both sides to best-first in __post_init__, regardless
of the order the caller or the API provides: buys by price descending,
sells by price ascending. Use the properties; an empty book raises
ValueError.
import cryptocom.exchange as cro
from cryptocom.exchange.structs import OrderBook, OrderInBook
pair = cro.pairs.CRO_USD
book = OrderBook(
buys=[
OrderInBook(0.150, 100, 0, pair, cro.OrderSide.BUY),
OrderInBook(0.152, 100, 0, pair, cro.OrderSide.BUY),
],
sells=[
OrderInBook(0.155, 100, 0, pair, cro.OrderSide.SELL),
OrderInBook(0.153, 100, 0, pair, cro.OrderSide.SELL),
],
pair=pair,
)
assert book.best_bid.price == 0.152 # buys: price DESC
assert book.best_ask.price == 0.153 # sells: price ASC
print(book.mid, book.spread)
Snapshots and streams share the type:
book = await market.get_orderbook(pair)
print(book.best_bid.price, book.best_ask.price, book.mid, book.spread)
Instruments and balances
Instrument identity is exchange_name: __eq__/__hash__ are defined on it
for Instrument and InstrumentBalance, so the two interoperate as dict
keys and get_balance() is keyed by plain Instruments.
balance = await account.get_balance() # dict[Instrument, InstrumentBalance]
usd = balance[cro.instruments.USD] # keys are plain Instruments
snapshot = await anext(account.listen_balances()) # a structs.Balance
snapshot.get("USD") is snapshot.get(cro.instruments.USD) # str, Instrument
Scope note: get_orders_history() and get_balance_history() work with trade
API keys; get_deposit_history() and get_withdrawal_history() are
wallet/custody-side and answer HTTP 401 USER_NOT_FOUND for trade keys.
orders = await account.get_orders_history(pair)
balances = await account.get_balance_history()
Testing
The suite replays recorded exchange responses and never opens a socket, so CI is deterministic and offline (it passes with no network and no keys).
-
Captures live in
tests/captured/<test_file>/<test_name>.json, keyed bymethod pathplus sorted request arguments, and consumed in order. 13-digit epoch-millis are normalized to<ts>so time windows match, and auto-generatedclient_oids are recorded under"__uuids__"and replayed in sequence. -
Replay is strict: a miss raises
ApiErrorlisting the arguments that were captured. There is no silent live fallback, so a passing CI run cannot drift from reality unnoticed. Code that changes arguments, call order, or the number of generated client ids must be re-recorded. -
Re-record with a funded account (this spends real money):
API_CAPTURE=true CRYPTOCOM_API_KEY=*** CRYPTOCOM_API_SECRET=*** \ poetry run pytest tests/test_account.py
then commit the regenerated capture files.
-
Write a live test as an ordinary test using the
api,marketandaccountfixtures intests/conftest.py. Theaccountfixture rebalances USD/CRO 50/50 on the feelessCRO_USDpair and cancels its open orders on teardown; size orders from pair rules viatests/helpers.py(min_order_quantity,resting_buy_price,resting_sell_price) instead of magic numbers, and assert on shapes rather than exchange-side values so the capture replays. -
Transport behavior (retry accounting, status routing, taxonomy) is tested offline by overriding
ApiProvider._http_request(), the single outbound HTTP call — seetests/test_api_request.py. -
CI command:
poetry run pytest -v --cov . --cov-report xml:coverage.xml --cov-fail-under=90.
Metadata
Release files for cryptocom-exchange 2.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| cryptocom_exchange-2.7.0.tar.gz | 55.5 kB | Without provenance | Details for cryptocom_exchange-2.7.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| cryptocom_exchange-2.7.0-py3-none-any.whl | Python 3 | none | any | Details for cryptocom_exchange-2.7.0-py3-none-any.whl |
Total release size: 109.4 kB
Release files / cryptocom_exchange-2.7.0.tar.gz
| Download URL | cryptocom_exchange-2.7.0.tar.gz |
|---|---|
| Size | 55.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bd4d7f4836e8eed1971702d7365fbc127bac3c57ae41ba3e75337283e794d5a2
|
|
BLAKE2b-256 checksum How to use checksums |
9605cfec0e53f6034b2943435c50877accee356ae06057f461806d52ea321f71
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Release files / cryptocom_exchange-2.7.0-py3-none-any.whl
| Download URL | cryptocom_exchange-2.7.0-py3-none-any.whl |
|---|---|
| Size | 53.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2a25f5eff779718e882f8baf99e7255e91ea6a77fd994cace60c97498f67de8e
|
|
BLAKE2b-256 checksum How to use checksums |
822803eacf353c1e489152d2af6d761c7b9486ef8967086fb0bd86e73510f0c6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Security for cryptocom-exchange 2.7.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.