crypticorn 2.5.0rc4__py3-none-any.whl → 2.5.1__py3-none-any.whl

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.
Files changed (83) hide show
  1. crypticorn/auth/main.py +2 -0
  2. crypticorn/client.py +60 -69
  3. crypticorn/common/__init__.py +2 -1
  4. crypticorn/common/auth.py +38 -20
  5. crypticorn/common/enums.py +5 -34
  6. crypticorn/common/errors.py +33 -14
  7. crypticorn/common/exceptions.py +42 -25
  8. crypticorn/common/mixins.py +36 -0
  9. crypticorn/common/urls.py +2 -1
  10. crypticorn/common/utils.py +4 -2
  11. crypticorn/hive/main.py +2 -0
  12. crypticorn/klines/client/__init__.py +14 -42
  13. crypticorn/klines/client/api/__init__.py +1 -1
  14. crypticorn/klines/client/api/change_in_timeframe_api.py +8 -22
  15. crypticorn/klines/client/api/funding_rates_api.py +8 -22
  16. crypticorn/klines/client/api/ohlcv_data_api.py +13 -33
  17. crypticorn/klines/client/api/status_api.py +260 -0
  18. crypticorn/klines/client/api/symbols_api.py +14 -29
  19. crypticorn/klines/client/api/udf_api.py +48 -59
  20. crypticorn/klines/client/api_client.py +1 -1
  21. crypticorn/klines/client/configuration.py +1 -1
  22. crypticorn/klines/client/exceptions.py +1 -1
  23. crypticorn/klines/client/models/__init__.py +13 -41
  24. crypticorn/klines/client/models/api_error_identifier.py +108 -0
  25. crypticorn/klines/client/models/api_error_level.py +37 -0
  26. crypticorn/klines/client/models/api_error_type.py +37 -0
  27. crypticorn/klines/client/models/change_in_timeframe.py +86 -0
  28. crypticorn/klines/client/models/exception_detail.py +117 -0
  29. crypticorn/klines/client/models/funding_rate.py +92 -0
  30. crypticorn/klines/client/models/internal_exchange.py +39 -0
  31. crypticorn/klines/client/models/market_type.py +1 -1
  32. crypticorn/klines/client/models/ohlcv_history.py +105 -0
  33. crypticorn/klines/client/models/resolution.py +1 -1
  34. crypticorn/klines/client/models/search_symbol.py +94 -0
  35. crypticorn/klines/client/models/sort_direction.py +1 -1
  36. crypticorn/klines/client/models/symbol_group.py +83 -0
  37. crypticorn/klines/client/models/symbol_info.py +131 -0
  38. crypticorn/klines/client/models/symbol_type.py +1 -1
  39. crypticorn/klines/client/models/timeframe.py +1 -1
  40. crypticorn/klines/client/models/udf_config.py +149 -0
  41. crypticorn/klines/client/rest.py +1 -1
  42. crypticorn/klines/main.py +40 -23
  43. crypticorn/metrics/client/__init__.py +7 -21
  44. crypticorn/metrics/client/api/__init__.py +1 -1
  45. crypticorn/metrics/client/api/exchanges_api.py +36 -78
  46. crypticorn/metrics/client/api/indicators_api.py +12 -37
  47. crypticorn/metrics/client/api/logs_api.py +8 -23
  48. crypticorn/metrics/client/api/marketcap_api.py +22 -73
  49. crypticorn/metrics/client/api/markets_api.py +12 -40
  50. crypticorn/metrics/client/api/status_api.py +260 -0
  51. crypticorn/metrics/client/api/tokens_api.py +7 -21
  52. crypticorn/metrics/client/api_client.py +1 -1
  53. crypticorn/metrics/client/configuration.py +5 -3
  54. crypticorn/metrics/client/exceptions.py +1 -1
  55. crypticorn/metrics/client/models/__init__.py +6 -20
  56. crypticorn/{trade → metrics}/client/models/api_error_identifier.py +6 -2
  57. crypticorn/{trade → metrics}/client/models/api_error_level.py +2 -2
  58. crypticorn/{trade → metrics}/client/models/api_error_type.py +2 -2
  59. crypticorn/metrics/client/models/exception_detail.py +117 -0
  60. crypticorn/metrics/client/models/internal_exchange.py +39 -0
  61. crypticorn/metrics/client/models/market_type.py +1 -1
  62. crypticorn/metrics/client/models/severity.py +1 -1
  63. crypticorn/metrics/client/models/time_interval.py +1 -1
  64. crypticorn/metrics/client/models/trading_status.py +1 -1
  65. crypticorn/metrics/client/rest.py +1 -1
  66. crypticorn/metrics/main.py +51 -43
  67. crypticorn/pay/main.py +2 -0
  68. crypticorn/trade/client/__init__.py +0 -3
  69. crypticorn/trade/client/configuration.py +2 -2
  70. crypticorn/trade/client/models/__init__.py +0 -3
  71. crypticorn/trade/client/models/bot_model.py +3 -7
  72. crypticorn/trade/client/models/execution_ids.py +1 -1
  73. crypticorn/trade/client/models/notification_model.py +3 -12
  74. crypticorn/trade/client/models/order_model.py +3 -7
  75. crypticorn/trade/client/models/spot_trading_action.py +231 -0
  76. crypticorn/trade/main.py +2 -0
  77. {crypticorn-2.5.0rc4.dist-info → crypticorn-2.5.1.dist-info}/METADATA +7 -5
  78. {crypticorn-2.5.0rc4.dist-info → crypticorn-2.5.1.dist-info}/RECORD +82 -65
  79. {crypticorn-2.5.0rc4.dist-info → crypticorn-2.5.1.dist-info}/WHEEL +1 -1
  80. crypticorn/common/sorter.py +0 -40
  81. /crypticorn/common/{pydantic.py → decorators.py} +0 -0
  82. {crypticorn-2.5.0rc4.dist-info → crypticorn-2.5.1.dist-info}/entry_points.txt +0 -0
  83. {crypticorn-2.5.0rc4.dist-info → crypticorn-2.5.1.dist-info}/top_level.txt +0 -0
crypticorn/auth/main.py CHANGED
@@ -14,6 +14,8 @@ class AuthClient:
14
14
  A client for interacting with the Crypticorn Auth API.
15
15
  """
16
16
 
17
+ config_class = Configuration
18
+
17
19
  def __init__(
18
20
  self,
19
21
  config: Configuration,
crypticorn/client.py CHANGED
@@ -1,13 +1,14 @@
1
- from typing import Union
2
-
3
- from crypticorn.hive import HiveClient, Configuration as HiveConfig
4
- from crypticorn.klines import KlinesClient, Configuration as KlinesConfig
5
- from crypticorn.pay import PayClient, Configuration as PayConfig
6
- from crypticorn.trade import TradeClient, Configuration as TradeConfig
7
- from crypticorn.metrics import MetricsClient, Configuration as MetricsConfig
8
- from crypticorn.auth import AuthClient, Configuration as AuthConfig
1
+ from typing import TypeVar
2
+ from crypticorn.hive import HiveClient
3
+ from crypticorn.klines import KlinesClient
4
+ from crypticorn.pay import PayClient
5
+ from crypticorn.trade import TradeClient
6
+ from crypticorn.metrics import MetricsClient
7
+ from crypticorn.auth import AuthClient
9
8
  from crypticorn.common import BaseUrl, ApiVersion, Service, apikey_header as aph
10
- import warnings
9
+
10
+ ConfigT = TypeVar("ConfigT")
11
+ SubClient = TypeVar("SubClient")
11
12
 
12
13
 
13
14
  class ApiClient:
@@ -30,35 +31,49 @@ class ApiClient:
30
31
  self.jwt = jwt
31
32
  """The JWT to use for authentication."""
32
33
 
33
- self.hive = HiveClient(self._get_default_config(Service.HIVE))
34
- self.trade = TradeClient(self._get_default_config(Service.TRADE))
35
- self.klines = KlinesClient(self._get_default_config(Service.KLINES))
36
- self.pay = PayClient(self._get_default_config(Service.PAY))
37
- self.metrics = MetricsClient(self._get_default_config(Service.METRICS))
38
- self.auth = AuthClient(self._get_default_config(Service.AUTH))
39
-
40
- def __new__(cls, *args, **kwargs):
41
- if kwargs.get("api_key") and not kwargs.get("jwt"):
42
- # auth-service does not allow api_key
43
- warnings.warn(
44
- "The auth module does only accept JWT to be used to authenticate. If you use this module, you need to provide a JWT."
45
- )
46
- return super().__new__(cls)
34
+ self.service_classes: dict[Service, type[SubClient]] = {
35
+ Service.HIVE: HiveClient,
36
+ Service.TRADE: TradeClient,
37
+ Service.KLINES: KlinesClient,
38
+ Service.PAY: PayClient,
39
+ Service.METRICS: MetricsClient,
40
+ Service.AUTH: AuthClient,
41
+ }
42
+
43
+ self.services: dict[Service, SubClient] = {
44
+ service: client_class(self._get_default_config(service))
45
+ for service, client_class in self.service_classes.items()
46
+ }
47
+
48
+ @property
49
+ def hive(self) -> HiveClient:
50
+ return self.services[Service.HIVE]
51
+
52
+ @property
53
+ def trade(self) -> TradeClient:
54
+ return self.services[Service.TRADE]
55
+
56
+ @property
57
+ def klines(self) -> KlinesClient:
58
+ return self.services[Service.KLINES]
59
+
60
+ @property
61
+ def metrics(self) -> MetricsClient:
62
+ return self.services[Service.METRICS]
63
+
64
+ @property
65
+ def pay(self) -> PayClient:
66
+ return self.services[Service.PAY]
67
+
68
+ @property
69
+ def auth(self) -> AuthClient:
70
+ return self.services[Service.AUTH]
47
71
 
48
72
  async def close(self):
49
73
  """Close all client sessions."""
50
- clients = [
51
- self.hive.base_client,
52
- self.trade.base_client,
53
- self.klines.base_client,
54
- self.pay.base_client,
55
- self.metrics.base_client,
56
- self.auth.base_client,
57
- ]
58
-
59
- for client in clients:
60
- if hasattr(client, "close"):
61
- await client.close()
74
+ for service in self.services.values():
75
+ if hasattr(service.base_client, "close"):
76
+ await service.base_client.close()
62
77
 
63
78
  def _get_default_config(
64
79
  self, service: Service, version: ApiVersion = ApiVersion.V1
@@ -66,63 +81,39 @@ class ApiClient:
66
81
  """
67
82
  Get the default configuration for a given service.
68
83
  """
69
- config_class = {
70
- Service.HIVE: HiveConfig,
71
- Service.TRADE: TradeConfig,
72
- Service.KLINES: KlinesConfig,
73
- Service.PAY: PayConfig,
74
- Service.METRICS: MetricsConfig,
75
- Service.AUTH: AuthConfig,
76
- }[service]
84
+ config_class = self.service_classes[service].config_class
77
85
  return config_class(
78
86
  host=f"{self.base_url}/{version}/{service}",
79
87
  access_token=self.jwt,
80
88
  api_key={aph.scheme_name: self.api_key} if self.api_key else None,
81
- # not necessary
82
- # api_key_prefix=(
83
- # {aph.scheme_name: aph.model.name} if self.api_key else None
84
- # ),
85
89
  )
86
90
 
87
91
  def configure(
88
92
  self,
89
- config: Union[
90
- HiveConfig, TradeConfig, KlinesConfig, PayConfig, MetricsConfig, AuthConfig
91
- ],
92
- sub_client: any,
93
+ config: ConfigT,
94
+ service: Service,
93
95
  ):
94
96
  """
95
97
  Update a sub-client's configuration by overriding with the values set in the new config.
96
98
  Useful for testing a specific service against a local server instead of the default proxy.
97
99
 
98
100
  :param config: The new configuration to use for the sub-client.
99
- :param sub_client: The sub-client to configure.
101
+ :param service: The service to configure.
100
102
 
101
103
  Example:
102
- This will override the host for the Hive client to connect to http://localhost:8000 instead of the default proxy:
103
104
  >>> async with ApiClient(base_url=BaseUrl.DEV, jwt=jwt) as client:
104
- >>> client.configure(config=HiveConfig(host="http://localhost:8000"), sub_client=client.hive)
105
+ >>> client.configure(config=HiveConfig(host="http://localhost:8000"), client=client.hive)
105
106
  """
106
- new_config = sub_client.config
107
+ assert Service.validate(service), f"Invalid service: {service}"
108
+ client = self.services[service]
109
+ new_config = client.config
110
+
107
111
  for attr in vars(config):
108
112
  new_value = getattr(config, attr)
109
113
  if new_value:
110
114
  setattr(new_config, attr, new_value)
111
115
 
112
- if sub_client == self.hive:
113
- self.hive = HiveClient(new_config)
114
- elif sub_client == self.trade:
115
- self.trade = TradeClient(new_config)
116
- elif sub_client == self.klines:
117
- self.klines = KlinesClient(new_config)
118
- elif sub_client == self.pay:
119
- self.pay = PayClient(new_config)
120
- elif sub_client == self.metrics:
121
- self.metrics = MetricsClient(new_config)
122
- elif sub_client == self.auth:
123
- self.auth = AuthClient(new_config)
124
- else:
125
- raise ValueError(f"Unknown sub-client: {sub_client}")
116
+ self.services[service] = type(client)(new_config)
126
117
 
127
118
  async def __aenter__(self):
128
119
  return self
@@ -1,7 +1,8 @@
1
1
  from crypticorn.common.errors import *
2
2
  from crypticorn.common.scopes import *
3
3
  from crypticorn.common.urls import *
4
- from crypticorn.common.pydantic import *
4
+ from crypticorn.common.decorators import *
5
+ from crypticorn.common.mixins import *
5
6
  from crypticorn.common.auth import *
6
7
  from crypticorn.common.enums import *
7
8
  from crypticorn.common.utils import *
crypticorn/common/auth.py CHANGED
@@ -2,14 +2,10 @@ import json
2
2
 
3
3
  from crypticorn.auth import Verify200Response, AuthClient, Configuration
4
4
  from crypticorn.auth.client.exceptions import ApiException
5
- from crypticorn.common import (
6
- ApiError,
7
- ApiVersion,
8
- BaseUrl,
9
- Scope,
10
- Service,
11
- )
12
- from fastapi import Depends, HTTPException, Query, status, WebSocketException
5
+ from crypticorn.common.scopes import Scope
6
+ from crypticorn.common.exceptions import ApiError, HTTPException, ExceptionContent
7
+ from crypticorn.common.urls import BaseUrl, Service, ApiVersion
8
+ from fastapi import Depends, Query, status, WebSocketException
13
9
  from fastapi.security import (
14
10
  HTTPAuthorizationCredentials,
15
11
  SecurityScopes,
@@ -48,11 +44,6 @@ class AuthHandler:
48
44
  self.url = f"{base_url}/{ApiVersion.V1}/{Service.AUTH}"
49
45
  self.client = AuthClient(Configuration(host=self.url))
50
46
 
51
- self.no_credentials_exception = HTTPException(
52
- status_code=status.HTTP_401_UNAUTHORIZED,
53
- detail=ApiError.NO_CREDENTIALS.identifier,
54
- )
55
-
56
47
  async def _verify_api_key(self, api_key: str) -> Verify200Response:
57
48
  """
58
49
  Verifies the API key.
@@ -76,8 +67,10 @@ class AuthHandler:
76
67
  """
77
68
  if not set(api_scopes).issubset(user_scopes):
78
69
  raise HTTPException(
79
- status_code=status.HTTP_403_FORBIDDEN,
80
- detail=ApiError.INSUFFICIENT_SCOPES.identifier,
70
+ content=ExceptionContent(
71
+ error=ApiError.INSUFFICIENT_SCOPES,
72
+ message="Insufficient scopes to access this resource",
73
+ ),
81
74
  )
82
75
 
83
76
  async def _extract_message(self, e: ApiException) -> str:
@@ -100,16 +93,33 @@ class AuthHandler:
100
93
  Handles exceptions and returns a HTTPException with the appropriate status code and detail.
101
94
  """
102
95
  if isinstance(e, ApiException):
96
+ # handle the TRPC Zod errors from auth-service
97
+ # Unfortunately, we cannot share the error messages defined in python/crypticorn/common/errors.py with the typescript client
98
+ message = await self._extract_message(e)
99
+ if message == "Invalid API key":
100
+ error = ApiError.INVALID_API_KEY
101
+ elif message == "API key expired":
102
+ error = ApiError.EXPIRED_API_KEY
103
+ elif message == "jwt expired":
104
+ error = ApiError.EXPIRED_BEARER
105
+ else:
106
+ error = (
107
+ ApiError.INVALID_BEARER
108
+ ) # jwt malformed, jwt not active (https://www.npmjs.com/package/jsonwebtoken#errors--codes)
103
109
  return HTTPException(
104
- status_code=e.status,
105
- detail=await self._extract_message(e),
110
+ content=ExceptionContent(
111
+ error=error,
112
+ message=message,
113
+ ),
106
114
  )
107
115
  elif isinstance(e, HTTPException):
108
116
  return e
109
117
  else:
110
118
  return HTTPException(
111
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
112
- detail=str(e),
119
+ content=ExceptionContent(
120
+ error=ApiError.UNKNOWN_ERROR,
121
+ message=str(e),
122
+ ),
113
123
  )
114
124
 
115
125
  async def api_key_auth(
@@ -172,7 +182,15 @@ class AuthHandler:
172
182
  last_error = await self._handle_exception(e)
173
183
  continue
174
184
 
175
- raise last_error or self.no_credentials_exception
185
+ if last_error:
186
+ raise last_error
187
+ else:
188
+ raise HTTPException(
189
+ content=ExceptionContent(
190
+ error=ApiError.NO_CREDENTIALS,
191
+ message="No credentials provided",
192
+ ),
193
+ )
176
194
 
177
195
  async def ws_api_key_auth(
178
196
  self,
@@ -1,42 +1,13 @@
1
1
  from enum import StrEnum
2
-
3
-
4
- class ValidateEnumMixin:
5
- """
6
- Mixin for validating enum values manually.
7
-
8
- ⚠️ Note:
9
- This does NOT enforce validation automatically on enum creation.
10
- It's up to the developer to call `Class.validate(value)` where needed.
11
-
12
- Usage:
13
- >>> class Color(ValidateEnumMixin, StrEnum):
14
- >>> RED = "red"
15
- >>> GREEN = "green"
16
-
17
- >>> Color.validate("red") # True
18
- >>> Color.validate("yellow") # False
19
-
20
- Order of inheritance matters — the mixin must come first.
21
- """
22
-
23
- @classmethod
24
- def validate(cls, value) -> bool:
25
- try:
26
- cls(value)
27
- return True
28
- except ValueError:
29
- return False
30
-
31
-
32
- class Exchange(ValidateEnumMixin, StrEnum):
2
+ from crypticorn.common.mixins import ValidateEnumMixin, ExcludeEnumMixin
3
+ class Exchange(ValidateEnumMixin, ExcludeEnumMixin, StrEnum):
33
4
  """Supported exchanges for trading"""
34
5
 
35
6
  KUCOIN = "kucoin"
36
7
  BINGX = "bingx"
37
8
 
38
9
 
39
- class InternalExchange(ValidateEnumMixin, StrEnum):
10
+ class InternalExchange(ValidateEnumMixin, ExcludeEnumMixin, StrEnum):
40
11
  """All exchanges we are using, including public (Exchange)"""
41
12
 
42
13
  KUCOIN = "kucoin"
@@ -47,10 +18,10 @@ class InternalExchange(ValidateEnumMixin, StrEnum):
47
18
  BITGET = "bitget"
48
19
 
49
20
 
50
- class MarketType(ValidateEnumMixin, StrEnum):
21
+ class MarketType(ValidateEnumMixin, ExcludeEnumMixin, StrEnum):
51
22
  """
52
23
  Market types
53
24
  """
54
25
 
55
26
  SPOT = "spot"
56
- FUTURES = "futures"
27
+ FUTURES = "futures"
@@ -1,6 +1,7 @@
1
1
  from enum import Enum, EnumMeta, StrEnum
2
2
  import logging
3
3
  from fastapi import status
4
+ from crypticorn.common.mixins import ExcludeEnumMixin
4
5
 
5
6
  logger = logging.getLogger(__name__)
6
7
 
@@ -18,7 +19,7 @@ class Fallback(EnumMeta):
18
19
  return cls.UNKNOWN_ERROR
19
20
 
20
21
 
21
- class ApiErrorType(StrEnum):
22
+ class ApiErrorType(ExcludeEnumMixin, StrEnum):
22
23
  """Type of API error"""
23
24
 
24
25
  USER_ERROR = "user error"
@@ -31,11 +32,12 @@ class ApiErrorType(StrEnum):
31
32
  """error that does not need to be handled or does not affect the program or is a placeholder."""
32
33
 
33
34
 
34
- class ApiErrorIdentifier(StrEnum):
35
+ class ApiErrorIdentifier(ExcludeEnumMixin, StrEnum):
35
36
  """API error identifiers"""
36
37
 
37
38
  ALLOCATION_BELOW_EXPOSURE = "allocation_below_current_exposure"
38
39
  ALLOCATION_BELOW_MINIMUM = "allocation_below_min_amount"
40
+ ALPHANUMERIC_CHARACTERS_ONLY = "alphanumeric_characters_only"
39
41
  BLACK_SWAN = "black_swan"
40
42
  BOT_ALREADY_DELETED = "bot_already_deleted"
41
43
  BOT_DISABLED = "bot_disabled"
@@ -56,6 +58,9 @@ class ApiErrorIdentifier(StrEnum):
56
58
  EXCHANGE_SYSTEM_CONFIG_ERROR = "exchange_system_configuration_error"
57
59
  EXCHANGE_SYSTEM_ERROR = "exchange_internal_system_error"
58
60
  EXCHANGE_USER_FROZEN = "exchange_user_account_is_frozen"
61
+ EXPIRED_API_KEY = "api_key_expired"
62
+ EXPIRED_BEARER = "bearer_token_expired"
63
+ FORBIDDEN = "forbidden"
59
64
  HEDGE_MODE_NOT_ACTIVE = "hedge_mode_not_active"
60
65
  HTTP_ERROR = "http_request_error"
61
66
  INSUFFICIENT_BALANCE = "insufficient_balance"
@@ -68,7 +73,6 @@ class ApiErrorIdentifier(StrEnum):
68
73
  INVALID_EXCHANGE_KEY = "invalid_exchange_key"
69
74
  INVALID_MARGIN_MODE = "invalid_margin_mode"
70
75
  INVALID_PARAMETER = "invalid_parameter_provided"
71
- JWT_EXPIRED = "jwt_expired"
72
76
  LEVERAGE_EXCEEDED = "leverage_limit_exceeded"
73
77
  LIQUIDATION_PRICE_VIOLATION = "order_violates_liquidation_price_constraints"
74
78
  NO_CREDENTIALS = "no_credentials"
@@ -108,7 +112,7 @@ class ApiErrorIdentifier(StrEnum):
108
112
  return ApiError[self.value]
109
113
 
110
114
 
111
- class ApiErrorLevel(StrEnum):
115
+ class ApiErrorLevel(ExcludeEnumMixin, StrEnum):
112
116
  """API error levels"""
113
117
 
114
118
  ERROR = "error"
@@ -117,7 +121,7 @@ class ApiErrorLevel(StrEnum):
117
121
  WARNING = "warning"
118
122
 
119
123
 
120
- class ApiError(Enum, metaclass=Fallback):
124
+ class ApiError(ExcludeEnumMixin, Enum, metaclass=Fallback):
121
125
  """API error codes. Fallback to UNKNOWN_ERROR for error codes not yet published to PyPI."""
122
126
 
123
127
  ALLOCATION_BELOW_EXPOSURE = (
@@ -130,6 +134,11 @@ class ApiError(Enum, metaclass=Fallback):
130
134
  ApiErrorType.USER_ERROR,
131
135
  ApiErrorLevel.ERROR,
132
136
  )
137
+ ALPHANUMERIC_CHARACTERS_ONLY = (
138
+ ApiErrorIdentifier.ALPHANUMERIC_CHARACTERS_ONLY,
139
+ ApiErrorType.USER_ERROR,
140
+ ApiErrorLevel.ERROR,
141
+ )
133
142
  BLACK_SWAN = (
134
143
  ApiErrorIdentifier.BLACK_SWAN,
135
144
  ApiErrorType.USER_ERROR,
@@ -230,6 +239,21 @@ class ApiError(Enum, metaclass=Fallback):
230
239
  ApiErrorType.USER_ERROR,
231
240
  ApiErrorLevel.ERROR,
232
241
  )
242
+ EXPIRED_API_KEY = (
243
+ ApiErrorIdentifier.EXPIRED_API_KEY,
244
+ ApiErrorType.USER_ERROR,
245
+ ApiErrorLevel.ERROR,
246
+ )
247
+ EXPIRED_BEARER = (
248
+ ApiErrorIdentifier.EXPIRED_BEARER,
249
+ ApiErrorType.USER_ERROR,
250
+ ApiErrorLevel.ERROR,
251
+ )
252
+ FORBIDDEN = (
253
+ ApiErrorIdentifier.FORBIDDEN,
254
+ ApiErrorType.USER_ERROR,
255
+ ApiErrorLevel.ERROR,
256
+ )
233
257
  HEDGE_MODE_NOT_ACTIVE = (
234
258
  ApiErrorIdentifier.HEDGE_MODE_NOT_ACTIVE,
235
259
  ApiErrorType.USER_ERROR,
@@ -290,11 +314,6 @@ class ApiError(Enum, metaclass=Fallback):
290
314
  ApiErrorType.SERVER_ERROR,
291
315
  ApiErrorLevel.ERROR,
292
316
  )
293
- JWT_EXPIRED = (
294
- ApiErrorIdentifier.JWT_EXPIRED,
295
- ApiErrorType.SERVER_ERROR,
296
- ApiErrorLevel.ERROR,
297
- )
298
317
  LEVERAGE_EXCEEDED = (
299
318
  ApiErrorIdentifier.LEVERAGE_EXCEEDED,
300
319
  ApiErrorType.SERVER_ERROR,
@@ -475,19 +494,18 @@ class ApiError(Enum, metaclass=Fallback):
475
494
 
476
495
  class HttpStatusMapper:
477
496
  """Map API errors to HTTP status codes."""
478
-
479
- # TODO: decide if we need all of these mappings, since most errors are not exposed to the client via HTTP
480
- # in case we remove some, update the pytest length check
481
497
  _mapping = {
482
498
  # Authentication/Authorization
483
- ApiError.JWT_EXPIRED: status.HTTP_401_UNAUTHORIZED,
499
+ ApiError.EXPIRED_BEARER: status.HTTP_401_UNAUTHORIZED,
484
500
  ApiError.INVALID_BEARER: status.HTTP_401_UNAUTHORIZED,
501
+ ApiError.EXPIRED_API_KEY: status.HTTP_401_UNAUTHORIZED,
485
502
  ApiError.INVALID_API_KEY: status.HTTP_401_UNAUTHORIZED,
486
503
  ApiError.NO_CREDENTIALS: status.HTTP_401_UNAUTHORIZED,
487
504
  ApiError.INSUFFICIENT_SCOPES: status.HTTP_403_FORBIDDEN,
488
505
  ApiError.EXCHANGE_PERMISSION_DENIED: status.HTTP_403_FORBIDDEN,
489
506
  ApiError.EXCHANGE_USER_FROZEN: status.HTTP_403_FORBIDDEN,
490
507
  ApiError.TRADING_LOCKED: status.HTTP_403_FORBIDDEN,
508
+ ApiError.FORBIDDEN: status.HTTP_403_FORBIDDEN,
491
509
  # Not Found
492
510
  ApiError.URL_NOT_FOUND: status.HTTP_404_NOT_FOUND,
493
511
  ApiError.OBJECT_NOT_FOUND: status.HTTP_404_NOT_FOUND,
@@ -519,6 +537,7 @@ class HttpStatusMapper:
519
537
  ApiError.POSITION_SUSPENDED: status.HTTP_503_SERVICE_UNAVAILABLE,
520
538
  ApiError.TRADING_SUSPENDED: status.HTTP_503_SERVICE_UNAVAILABLE,
521
539
  # Bad Requests (400) - Invalid parameters or states
540
+ ApiError.ALPHANUMERIC_CHARACTERS_ONLY: status.HTTP_400_BAD_REQUEST,
522
541
  ApiError.ALLOCATION_BELOW_EXPOSURE: status.HTTP_400_BAD_REQUEST,
523
542
  ApiError.ALLOCATION_BELOW_MINIMUM: status.HTTP_400_BAD_REQUEST,
524
543
  ApiError.BLACK_SWAN: status.HTTP_400_BAD_REQUEST,
@@ -1,35 +1,40 @@
1
- from enum import Enum
2
- from typing import Optional, Dict, Type
1
+ from typing import Optional, Dict, Any
3
2
  from pydantic import BaseModel, Field
4
3
  from fastapi import HTTPException as FastAPIHTTPException, Request, FastAPI
5
4
  from fastapi.exceptions import RequestValidationError, ResponseValidationError
6
- from fastapi.openapi.utils import get_openapi
7
5
  from fastapi.responses import JSONResponse
8
6
  from crypticorn.common import ApiError, ApiErrorIdentifier, ApiErrorType, ApiErrorLevel
9
7
 
10
- class EnrichedDetail(BaseModel):
11
- '''This is the detail of the exception. It is used to enrich the exception with additional information by unwrapping the ApiError into its components.'''
8
+
9
+ class ExceptionDetail(BaseModel):
10
+ """This is the detail of the exception. It is used to enrich the exception with additional information by unwrapping the ApiError into its components."""
11
+
12
12
  message: Optional[str] = Field(None, description="An additional error message")
13
13
  code: ApiErrorIdentifier = Field(..., description="The unique error code")
14
14
  type: ApiErrorType = Field(..., description="The type of error")
15
15
  level: ApiErrorLevel = Field(..., description="The level of the error")
16
16
  status_code: int = Field(..., description="The HTTP status code")
17
+ details: Any = Field(None, description="Additional details about the error")
17
18
 
18
19
 
19
- class ExceptionDetail(BaseModel):
20
- '''This is the detail of the exception. Pass an ApiError to the constructor and an optional human readable message.'''
20
+ class ExceptionContent(BaseModel):
21
+ """This is the detail of the exception. Pass an ApiError to the constructor and an optional human readable message."""
22
+
21
23
  error: ApiError = Field(..., description="The unique error code")
22
24
  message: Optional[str] = Field(None, description="An additional error message")
25
+ details: Any = Field(None, description="Additional details about the error")
23
26
 
24
- def enrich(self) -> EnrichedDetail:
25
- return EnrichedDetail(
27
+ def enrich(self) -> ExceptionDetail:
28
+ return ExceptionDetail(
26
29
  message=self.message,
27
30
  code=self.error.identifier,
28
31
  type=self.error.type,
29
32
  level=self.error.level,
30
33
  status_code=self.error.status_code,
34
+ details=self.details,
31
35
  )
32
36
 
37
+
33
38
  class HTTPException(FastAPIHTTPException):
34
39
  """A custom HTTP exception wrapper around FastAPI's HTTPException.
35
40
  It allows for a more structured way to handle errors, with a message and an error code. The status code is being derived from the detail's error.
@@ -38,43 +43,55 @@ class HTTPException(FastAPIHTTPException):
38
43
 
39
44
  def __init__(
40
45
  self,
41
- detail: ExceptionDetail,
46
+ content: ExceptionContent,
42
47
  headers: Optional[Dict[str, str]] = None,
43
48
  ):
44
- assert isinstance(detail, ExceptionDetail)
45
- body = detail.enrich()
49
+ assert isinstance(content, ExceptionContent)
50
+ body = content.enrich()
46
51
  super().__init__(
47
52
  status_code=body.status_code,
48
53
  detail=body.model_dump(mode="json"),
49
54
  headers=headers,
50
55
  )
51
56
 
57
+
52
58
  async def general_handler(request: Request, exc: Exception):
53
- '''This is the default exception handler for all exceptions.'''
54
- body = ExceptionDetail(message=str(exc), error=ApiError.UNKNOWN_ERROR)
55
- return JSONResponse(status_code=body.error.status_code, content=HTTPException(detail=body).detail)
59
+ """This is the default exception handler for all exceptions."""
60
+ body = ExceptionContent(message=str(exc), error=ApiError.UNKNOWN_ERROR)
61
+ return JSONResponse(
62
+ status_code=body.error.status_code, content=HTTPException(content=body).detail
63
+ )
64
+
56
65
 
57
66
  async def request_validation_handler(request: Request, exc: RequestValidationError):
58
- '''This is the exception handler for all request validation errors.'''
59
- body = ExceptionDetail(message=str(exc), error=ApiError.INVALID_DATA_REQUEST)
60
- return JSONResponse(status_code=body.error.status_code, content=HTTPException(detail=body).detail)
67
+ """This is the exception handler for all request validation errors."""
68
+ body = ExceptionContent(message=str(exc), error=ApiError.INVALID_DATA_REQUEST)
69
+ return JSONResponse(
70
+ status_code=body.error.status_code, content=HTTPException(content=body).detail
71
+ )
72
+
61
73
 
62
74
  async def response_validation_handler(request: Request, exc: ResponseValidationError):
63
- '''This is the exception handler for all response validation errors.'''
64
- body = ExceptionDetail(message=str(exc), error=ApiError.INVALID_DATA_RESPONSE)
65
- return JSONResponse(status_code=body.error.status_code, content=HTTPException(detail=body).detail)
75
+ """This is the exception handler for all response validation errors."""
76
+ body = ExceptionContent(message=str(exc), error=ApiError.INVALID_DATA_RESPONSE)
77
+ return JSONResponse(
78
+ status_code=body.error.status_code, content=HTTPException(content=body).detail
79
+ )
80
+
66
81
 
67
82
  async def http_handler(request: Request, exc: HTTPException):
68
- '''This is the exception handler for HTTPExceptions. It unwraps the HTTPException and returns the detail in a flat JSON response.'''
83
+ """This is the exception handler for HTTPExceptions. It unwraps the HTTPException and returns the detail in a flat JSON response."""
69
84
  return JSONResponse(status_code=exc.status_code, content=exc.detail)
70
85
 
86
+
71
87
  def register_exception_handlers(app: FastAPI):
72
- '''Utility to register serveral exception handlers in one go. Catches Exception, HTTPException and Data Validation errors and responds with a unified json body.'''
88
+ """Utility to register serveral exception handlers in one go. Catches Exception, HTTPException and Data Validation errors and responds with a unified json body."""
73
89
  app.add_exception_handler(Exception, general_handler)
74
90
  app.add_exception_handler(FastAPIHTTPException, http_handler)
75
91
  app.add_exception_handler(RequestValidationError, request_validation_handler)
76
92
  app.add_exception_handler(ResponseValidationError, response_validation_handler)
77
93
 
94
+
78
95
  exception_response = {
79
- "default": {"model": EnrichedDetail, "description": "Error response"}
80
- }
96
+ "default": {"model": ExceptionDetail, "description": "Error response"}
97
+ }
@@ -0,0 +1,36 @@
1
+ class ValidateEnumMixin:
2
+ """
3
+ Mixin for validating enum values manually.
4
+
5
+ ⚠️ Note:
6
+ This does NOT enforce validation automatically on enum creation.
7
+ It's up to the developer to call `Class.validate(value)` where needed.
8
+
9
+ Usage:
10
+ >>> class Color(ValidateEnumMixin, StrEnum):
11
+ >>> RED = "red"
12
+ >>> GREEN = "green"
13
+
14
+ >>> Color.validate("red") # True
15
+ >>> Color.validate("yellow") # False
16
+
17
+ Order of inheritance matters — the mixin must come first.
18
+ """
19
+
20
+ @classmethod
21
+ def validate(cls, value) -> bool:
22
+ """Validate if a value is in the enum. True if so, False otherwise."""
23
+ try:
24
+ cls(value)
25
+ return True
26
+ except ValueError:
27
+ return False
28
+
29
+ class ExcludeEnumMixin:
30
+ """Mixin to exclude enum from OpenAPI schema. We use this to avoid duplicating enums when generating client code from the openapi spec."""
31
+
32
+ @classmethod
33
+ def __get_pydantic_json_schema__(cls, core_schema, handler):
34
+ schema = handler(core_schema)
35
+ schema.pop("enum", None)
36
+ return schema
crypticorn/common/urls.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from enum import StrEnum
2
+ from crypticorn.common.enums import ValidateEnumMixin
2
3
 
3
4
 
4
5
  class ApiEnv(StrEnum):
@@ -30,7 +31,7 @@ class ApiVersion(StrEnum):
30
31
  V1 = "v1"
31
32
 
32
33
 
33
- class Service(StrEnum):
34
+ class Service(ValidateEnumMixin, StrEnum):
34
35
  HIVE = "hive"
35
36
  KLINES = "klines"
36
37
  PAY = "pay"