cryptocom-exchange 2.2.0__tar.gz → 2.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cryptocom-exchange
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Python 3.11+ async library for crypto.com/exchange API using httpx and websockets
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -131,7 +131,11 @@ response.
131
131
  ### Orders
132
132
 
133
133
  `Account` caches orders by `client_oid` in `Account.order_cache` (the snippets
134
- here use `from cryptocom.exchange import exceptions`). Terminal
134
+ here use `from cryptocom.exchange import exceptions`). `get_order` returns
135
+ `None` when the exchange CONFIRMS absence (empty result or code 40401) — a
136
+ definite answer; unknown outcomes raise `ApiError`. (`OrderNotFoundError`
137
+ lives on for endpoints where absence IS the error reply: cancel, amend.)
138
+ Terminal
135
139
  entries (`Order.is_terminal`, `TERMINAL_ORDER_STATUSES`) are final and live
136
140
  forever; live entries expire after `orders_ttl` seconds (default 5, `0`
137
141
  disables live caching). The cache is only written from observed answers: cancel
@@ -150,14 +154,12 @@ order = await account.wait_for_order(
150
154
  timeout=5.0,
151
155
  )
152
156
 
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")
157
+ # Cancel, then poll to terminal. True = CONFIRMED not live (terminal or
158
+ # absent); False = not confirmed (timed out or still live) - never treat
159
+ # False as cancelled. Indeterminate cancel/lookup failures raise ApiError.
160
+ cancelled = await account.cancel_order_and_wait(pair, client_id, timeout=5.0)
161
+ if not cancelled:
162
+ await reconcile_later(client_id) # may still land after the last poll
161
163
  ```
162
164
 
163
165
  A websocket feed can answer lookups directly: `updates` wins over cache and
@@ -171,7 +173,7 @@ order = await account.get_order(client_order_id=client_id, updates={client_id: s
171
173
  Two guards worth knowing:
172
174
 
173
175
  - A `client_oid` created moments ago that reads back empty raises an
174
- indeterminate `ApiError`, not `OrderNotFoundError`, for `create_race_guard`
176
+ indeterminate `ApiError` instead of returning `None`, for `create_race_guard`
175
177
  seconds (default 2). Absence right after create is not proof of absence.
176
178
  - `wait_for_order` raises `exceptions.WaitNotConfirmedError` (indeterminate) on
177
179
  timeout instead of returning `None`. `wait_for_status` is deprecated in its
@@ -105,7 +105,11 @@ response.
105
105
  ### Orders
106
106
 
107
107
  `Account` caches orders by `client_oid` in `Account.order_cache` (the snippets
108
- here use `from cryptocom.exchange import exceptions`). Terminal
108
+ here use `from cryptocom.exchange import exceptions`). `get_order` returns
109
+ `None` when the exchange CONFIRMS absence (empty result or code 40401) — a
110
+ definite answer; unknown outcomes raise `ApiError`. (`OrderNotFoundError`
111
+ lives on for endpoints where absence IS the error reply: cancel, amend.)
112
+ Terminal
109
113
  entries (`Order.is_terminal`, `TERMINAL_ORDER_STATUSES`) are final and live
110
114
  forever; live entries expire after `orders_ttl` seconds (default 5, `0`
111
115
  disables live caching). The cache is only written from observed answers: cancel
@@ -124,14 +128,12 @@ order = await account.wait_for_order(
124
128
  timeout=5.0,
125
129
  )
126
130
 
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")
131
+ # Cancel, then poll to terminal. True = CONFIRMED not live (terminal or
132
+ # absent); False = not confirmed (timed out or still live) - never treat
133
+ # False as cancelled. Indeterminate cancel/lookup failures raise ApiError.
134
+ cancelled = await account.cancel_order_and_wait(pair, client_id, timeout=5.0)
135
+ if not cancelled:
136
+ await reconcile_later(client_id) # may still land after the last poll
135
137
  ```
136
138
 
137
139
  A websocket feed can answer lookups directly: `updates` wins over cache and
@@ -145,7 +147,7 @@ order = await account.get_order(client_order_id=client_id, updates={client_id: s
145
147
  Two guards worth knowing:
146
148
 
147
149
  - A `client_oid` created moments ago that reads back empty raises an
148
- indeterminate `ApiError`, not `OrderNotFoundError`, for `create_race_guard`
150
+ indeterminate `ApiError` instead of returning `None`, for `create_race_guard`
149
151
  seconds (default 2). Absence right after create is not proof of absence.
150
152
  - `wait_for_order` raises `exceptions.WaitNotConfirmedError` (indeterminate) on
151
153
  timeout instead of returning `None`. `wait_for_status` is deprecated in its
@@ -7,7 +7,7 @@ packages = [
7
7
  {include = "cryptocom", from = "src"},
8
8
  ]
9
9
  exclude = ["openapi_docs/"]
10
- version = "2.2.0"
10
+ version = "2.3.0"
11
11
  readme = "README.md"
12
12
  classifiers = [
13
13
  "Operating System :: OS Independent",
@@ -1,13 +1,13 @@
1
1
  from . import exceptions, instruments, pairs
2
- from .api import ApiError, ApiProvider, RecordApiProvider
3
- from .market import Market
4
2
  from .account import (
5
- Account,
6
- OrderKeys,
7
3
  MAX_OPEN_ORDERS_PER_ACCOUNT,
8
4
  MAX_OPEN_ORDERS_PER_PAIR,
5
+ Account,
6
+ OrderKeys,
9
7
  )
8
+ from .api import ApiError, ApiProvider, RecordApiProvider
10
9
  from .exceptions import ApiAuthError, ApiErrorCode, ExchangeError
10
+ from .market import Market
11
11
  from .structs import (
12
12
  Candle,
13
13
  Deposit,
@@ -29,38 +29,37 @@ from .structs import (
29
29
  WithdrawalStatus,
30
30
  )
31
31
 
32
-
33
32
  __all__ = [
33
+ "MAX_OPEN_ORDERS_PER_ACCOUNT",
34
+ "MAX_OPEN_ORDERS_PER_PAIR",
35
+ "Account",
36
+ "ApiAuthError",
37
+ "ApiError",
38
+ "ApiErrorCode",
39
+ "ApiProvider",
40
+ "Candle",
41
+ "Deposit",
42
+ "DepositStatus",
43
+ "ExchangeError",
44
+ "Instrument",
45
+ "InstrumentType",
46
+ "Market",
47
+ "MarketTrade",
34
48
  "Order",
49
+ "OrderExecFlag",
50
+ "OrderForceType",
51
+ "OrderKeys",
35
52
  "OrderSide",
36
53
  "OrderStatus",
37
54
  "OrderType",
38
- "OrderForceType",
39
- "OrderExecFlag",
40
- "OrderKeys",
41
- "exceptions",
42
- "pairs",
43
55
  "Pair",
44
- "instruments",
45
- "Instrument",
46
- "InstrumentType",
47
- "Timeframe",
48
- "Candle",
49
- "MarketTrade",
50
56
  "PrivateTrade",
57
+ "RecordApiProvider",
51
58
  "TimeDelta",
52
- "Deposit",
59
+ "Timeframe",
53
60
  "Withdrawal",
54
- "DepositStatus",
55
61
  "WithdrawalStatus",
56
- "Market",
57
- "Account",
58
- "ApiError",
59
- "ApiProvider",
60
- "RecordApiProvider",
61
- "ApiAuthError",
62
- "ApiErrorCode",
63
- "ExchangeError",
64
- "MAX_OPEN_ORDERS_PER_PAIR",
65
- "MAX_OPEN_ORDERS_PER_ACCOUNT",
62
+ "exceptions",
63
+ "instruments",
64
+ "pairs",
66
65
  ]
@@ -3,12 +3,11 @@ import logging
3
3
  import time
4
4
  import warnings
5
5
  from dataclasses import dataclass
6
- from uuid import uuid4
7
6
  from typing import Any
7
+ from uuid import uuid4
8
8
 
9
9
  from .api import ApiError, ApiProvider
10
10
  from .exceptions import (
11
- CancelNotConfirmedError,
12
11
  OrderNotActiveError,
13
12
  OrderNotFoundError,
14
13
  WaitNotConfirmedError,
@@ -459,16 +458,14 @@ class Account:
459
458
  deadline = loop.time() + timeout
460
459
  last = "unknown"
461
460
  while True:
462
- try:
463
- order = await self.get_order(
464
- order_id=order_id,
465
- client_order_id=client_order_id,
466
- ignore_cache=True,
467
- )
468
- except OrderNotFoundError:
469
- order = None
461
+ order = await self.get_order(
462
+ order_id=order_id,
463
+ client_order_id=client_order_id,
464
+ ignore_cache=True,
465
+ )
466
+ if order is None:
470
467
  last = "absent"
471
- if order is not None:
468
+ else:
472
469
  last = str(order.status)
473
470
  if order.status in statuses:
474
471
  return order
@@ -593,7 +590,7 @@ class Account:
593
590
  client_order_id: str | None = None,
594
591
  updates: dict[str, Order] | None = None,
595
592
  ignore_cache: bool = False,
596
- ) -> Order:
593
+ ) -> Order | None:
597
594
  """Get order info by order_id or client_order_id.
598
595
 
599
596
  Cache-backed for client_order_id lookups. Resolution priority:
@@ -601,16 +598,14 @@ class Account:
601
598
  cache > API. Terminal orders are cached forever; live orders for
602
599
  ``orders_ttl`` seconds (0 disables caching).
603
600
 
604
- Args:
605
- order_id: Exchange order ID
606
- client_order_id: Client order ID (client_oid)
607
- updates: Live order snapshot keyed by client_oid (highest priority,
608
- populates the cache)
609
- ignore_cache: If True, query the exchange directly
601
+ Returns:
602
+ The Order, or None if the exchange CONFIRMS the order is absent
603
+ (empty result / code 40401). None is a definite answer; unknown
604
+ outcomes raise. Endpoints where "not found" is an error reply
605
+ (cancel, amend) still raise OrderNotFoundError.
610
606
 
611
607
  Raises:
612
608
  ValueError: If neither order_id nor client_order_id is provided
613
- OrderNotFoundError: The order is confirmed absent (deterministic)
614
609
  ApiError: Just-created order unreadable, or lookup failed -
615
610
  outcome UNKNOWN, must be reconciled later (indeterminate)
616
611
  """
@@ -633,10 +628,15 @@ class Account:
633
628
  if client_order_id:
634
629
  params["client_oid"] = client_order_id
635
630
 
636
- data = await self.api.post(
637
- "private/get-order-detail",
638
- {"params": params},
639
- )
631
+ try:
632
+ data = await self.api.post(
633
+ "private/get-order-detail",
634
+ {"params": params},
635
+ )
636
+ except OrderNotFoundError:
637
+ # Coded 40401 is the same confirmed-absent answer as an empty
638
+ # result; get_order reports absence as None.
639
+ data = None
640
640
  if not data:
641
641
  # Structured success with empty result - normally "not there".
642
642
  # Exception: an order created moments ago may not be readable
@@ -654,9 +654,7 @@ class Account:
654
654
  "creation - state unknown, reconcile later",
655
655
  method="private/get-order-detail",
656
656
  )
657
- raise OrderNotFoundError(
658
- "No order data", code=40401, method="private/get-order-detail"
659
- )
657
+ return None
660
658
 
661
659
  order = Order.create_from_api(
662
660
  self.pairs[data["instrument_name"]],
@@ -729,70 +727,82 @@ class Account:
729
727
  client_order_id: str,
730
728
  timeout: float = 30.0,
731
729
  poll_interval: float = 0.5,
732
- ) -> Order | None:
733
- """Cancel an order and poll until it reaches a terminal status.
730
+ ) -> bool:
731
+ """Cancel an order and confirm it is no longer live.
734
732
 
735
- Absent and already-terminal orders are treated as confirmed success
736
- (nothing to cancel). A cancel rejected as not-active re-queries once:
737
- a race with FILLED/CANCELED is a valid terminal outcome, not an error.
738
-
739
- Args:
740
- pair: Trading pair
741
- client_order_id: Client order ID (client_oid) of the order
742
- timeout: Max seconds to wait for a terminal status
743
- poll_interval: Seconds between status polls
733
+ All exchange-side races are resolved here: an already-terminal or
734
+ confirmed-absent order needs no cancel; a cancel rejected as
735
+ not-active (316) re-queries once, because racing into
736
+ FILLED/CANCELED/absent is a confirmed outcome, not an error.
744
737
 
745
738
  Returns:
746
- The order in terminal status, or None if the order is confirmed
747
- absent from the exchange.
739
+ True: CONFIRMED not live - cancel reached terminal status, the
740
+ order was already terminal, or the exchange confirmed it
741
+ absent. Safe to treat as cancelled.
742
+ False: cancellation NOT confirmed - cancel timed out (it may
743
+ still have landed after the last poll) or the order is
744
+ confirmed still live. Never read False as "cancelled".
748
745
 
749
746
  Raises:
750
- CancelNotConfirmedError: Terminal status not confirmed in time -
751
- the cancel may have landed after the last poll.
752
- ApiError: Cancel or lookup failed with an indeterminate error -
753
- outcome unknown, the caller must reconcile later.
747
+ ApiError: Cancel or lookup failed indeterminately - outcome
748
+ unknown, reconcile later.
754
749
  """
755
- try:
756
- order = await self.get_order(
757
- client_order_id=client_order_id, ignore_cache=True
758
- )
759
- except OrderNotFoundError:
760
- return None
761
- if order.is_terminal:
762
- return order
750
+ order = await self.get_order(client_order_id=client_order_id, ignore_cache=True)
751
+ if order is None or order.is_terminal:
752
+ return True
763
753
 
764
754
  try:
765
755
  await self.cancel_order(pair, client_order_id=client_order_id)
766
756
  except OrderNotActiveError:
767
757
  # Cancel raced a status change - re-query to learn the outcome.
768
- try:
769
- order = await self.get_order(
770
- client_order_id=client_order_id, ignore_cache=True
771
- )
772
- except OrderNotFoundError:
773
- return None
774
- if order.is_terminal:
775
- return order
776
- raise
758
+ order = await self.get_order(
759
+ client_order_id=client_order_id, ignore_cache=True
760
+ )
761
+ return order is None or order.is_terminal
777
762
  except OrderNotFoundError:
778
- return None
763
+ return True
779
764
 
780
765
  loop = asyncio.get_running_loop()
781
766
  deadline = loop.time() + timeout
782
767
  while loop.time() < deadline:
783
- try:
784
- order = await self.get_order(
785
- client_order_id=client_order_id, ignore_cache=True
786
- )
787
- except OrderNotFoundError:
788
- return None
789
- if order.is_terminal:
790
- return order
768
+ order = await self.get_order(
769
+ client_order_id=client_order_id, ignore_cache=True
770
+ )
771
+ if order is None or order.is_terminal:
772
+ return True
791
773
  await asyncio.sleep(poll_interval)
774
+ return False
775
+
776
+ async def resolve_stuck_order(
777
+ self,
778
+ pair: Pair,
779
+ order: Order,
780
+ stuck_after: float = 300.0,
781
+ timeout: float = 30.0,
782
+ poll_interval: float = 0.5,
783
+ ) -> bool:
784
+ """Resolve a possibly-stuck resting order (shadow-order mechanic).
785
+
786
+ An order is stuck when it sits in NEW (never activated) for longer
787
+ than ``stuck_after`` seconds AND cancel-and-wait cannot confirm its
788
+ removal. Anything else (healthy, terminal, NEW but still fresh, or
789
+ successfully cancelled) is clear to proceed.
790
+
791
+ Returns:
792
+ True: the order is STUCK (cancel attempted, not confirmed).
793
+ False: clear to proceed - no cancellation was needed or the
794
+ stuck order was successfully removed.
792
795
 
793
- raise CancelNotConfirmedError(
794
- f"Order {client_order_id!r} not terminal within {timeout}s "
795
- f"(last status: {order.status})"
796
+ Raises:
797
+ ApiError: cancel or lookup failed indeterminately - unknown is
798
+ never reported as stuck or clear; reconcile later.
799
+ """
800
+ if order.status != OrderStatus.NEW:
801
+ return False
802
+ if time.time() - order.created_at <= stuck_after:
803
+ return False
804
+ return not await self.cancel_order_and_wait(
805
+ pair, order.client_id, timeout, poll_interval
796
806
  )
797
807
 
798
808
  async def update_order(
@@ -193,9 +193,9 @@ class ApiProvider:
193
193
 
194
194
  self.rate_limiters = {}
195
195
 
196
- for urls in RATE_LIMITS:
196
+ for urls, limit in RATE_LIMITS.items():
197
197
  for url in urls:
198
- self.rate_limiters[url] = aiolimiter.AsyncLimiter(*RATE_LIMITS[urls])
198
+ self.rate_limiters[url] = aiolimiter.AsyncLimiter(*limit)
199
199
 
200
200
  # limits for not matched methods
201
201
  # Private APIs: 3 requests per 100ms (REST API - authenticated)
@@ -326,14 +326,6 @@ class MaintenanceError(ExchangeError):
326
326
  """Exchange under maintenance (code 9010001). Transient."""
327
327
 
328
328
 
329
- class CancelNotConfirmedError(ApiError):
330
- """Cancel was not confirmed terminal before the timeout.
331
-
332
- Indeterminate (code is None): the cancel may still have landed after
333
- the last poll - the caller must reconcile the order later.
334
- """
335
-
336
-
337
329
  class WaitNotConfirmedError(ApiError):
338
330
  """An order did not reach a required status before the timeout.
339
331
 
@@ -921,15 +921,15 @@ class Transaction:
921
921
 
922
922
  @staticmethod
923
923
  def _prepare(data):
924
- return dict(
925
- id=data["id"],
926
- instrument=Instrument(data["currency"]),
927
- fee=float(data["fee"]),
928
- create_time=datetime.fromtimestamp(int(data["create_time"]) / 1000),
929
- update_time=datetime.fromtimestamp(int(data["update_time"]) / 1000),
930
- amount=float(data["amount"]),
931
- address=data["address"],
932
- )
924
+ return {
925
+ "id": data["id"],
926
+ "instrument": Instrument(data["currency"]),
927
+ "fee": float(data["fee"]),
928
+ "create_time": datetime.fromtimestamp(int(data["create_time"]) / 1000),
929
+ "update_time": datetime.fromtimestamp(int(data["update_time"]) / 1000),
930
+ "amount": float(data["amount"]),
931
+ "address": data["address"],
932
+ }
933
933
 
934
934
 
935
935
  @dataclass