django-cfg 1.2.27__py3-none-any.whl → 1.2.31__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.
- django_cfg/__init__.py +1 -1
- django_cfg/apps/payments/admin/__init__.py +3 -2
- django_cfg/apps/payments/admin/balance_admin.py +18 -18
- django_cfg/apps/payments/admin/currencies_admin.py +319 -131
- django_cfg/apps/payments/admin/payments_admin.py +15 -4
- django_cfg/apps/payments/config/module.py +2 -2
- django_cfg/apps/payments/config/utils.py +2 -2
- django_cfg/apps/payments/decorators.py +2 -2
- django_cfg/apps/payments/management/commands/README.md +95 -127
- django_cfg/apps/payments/management/commands/currency_stats.py +5 -24
- django_cfg/apps/payments/management/commands/manage_currencies.py +229 -0
- django_cfg/apps/payments/management/commands/manage_providers.py +235 -0
- django_cfg/apps/payments/managers/__init__.py +3 -2
- django_cfg/apps/payments/managers/balance_manager.py +2 -2
- django_cfg/apps/payments/managers/currency_manager.py +272 -49
- django_cfg/apps/payments/managers/payment_manager.py +161 -13
- django_cfg/apps/payments/middleware/api_access.py +2 -2
- django_cfg/apps/payments/middleware/rate_limiting.py +8 -18
- django_cfg/apps/payments/middleware/usage_tracking.py +20 -17
- django_cfg/apps/payments/migrations/0002_network_providercurrency_and_more.py +241 -0
- django_cfg/apps/payments/migrations/0003_add_usd_rate_cache.py +30 -0
- django_cfg/apps/payments/models/__init__.py +3 -2
- django_cfg/apps/payments/models/currencies.py +187 -71
- django_cfg/apps/payments/models/payments.py +3 -2
- django_cfg/apps/payments/serializers/__init__.py +3 -2
- django_cfg/apps/payments/serializers/currencies.py +20 -12
- django_cfg/apps/payments/services/cache/simple_cache.py +2 -2
- django_cfg/apps/payments/services/core/balance_service.py +2 -2
- django_cfg/apps/payments/services/core/fallback_service.py +2 -2
- django_cfg/apps/payments/services/core/payment_service.py +3 -6
- django_cfg/apps/payments/services/core/subscription_service.py +4 -7
- django_cfg/apps/payments/services/internal_types.py +171 -7
- django_cfg/apps/payments/services/monitoring/api_schemas.py +58 -204
- django_cfg/apps/payments/services/monitoring/provider_health.py +2 -2
- django_cfg/apps/payments/services/providers/base.py +144 -43
- django_cfg/apps/payments/services/providers/cryptapi/__init__.py +4 -0
- django_cfg/apps/payments/services/providers/cryptapi/config.py +8 -0
- django_cfg/apps/payments/services/providers/cryptapi/models.py +192 -0
- django_cfg/apps/payments/services/providers/cryptapi/provider.py +439 -0
- django_cfg/apps/payments/services/providers/cryptomus/__init__.py +4 -0
- django_cfg/apps/payments/services/providers/cryptomus/models.py +176 -0
- django_cfg/apps/payments/services/providers/cryptomus/provider.py +429 -0
- django_cfg/apps/payments/services/providers/cryptomus/provider_v2.py +564 -0
- django_cfg/apps/payments/services/providers/models/__init__.py +34 -0
- django_cfg/apps/payments/services/providers/models/currencies.py +190 -0
- django_cfg/apps/payments/services/providers/nowpayments/__init__.py +4 -0
- django_cfg/apps/payments/services/providers/nowpayments/models.py +196 -0
- django_cfg/apps/payments/services/providers/nowpayments/provider.py +380 -0
- django_cfg/apps/payments/services/providers/registry.py +294 -11
- django_cfg/apps/payments/services/providers/stripe/__init__.py +4 -0
- django_cfg/apps/payments/services/providers/stripe/models.py +184 -0
- django_cfg/apps/payments/services/providers/stripe/provider.py +109 -0
- django_cfg/apps/payments/services/security/error_handler.py +6 -8
- django_cfg/apps/payments/services/security/payment_notifications.py +2 -2
- django_cfg/apps/payments/services/security/webhook_validator.py +3 -4
- django_cfg/apps/payments/signals/api_key_signals.py +2 -2
- django_cfg/apps/payments/signals/payment_signals.py +11 -5
- django_cfg/apps/payments/signals/subscription_signals.py +2 -2
- django_cfg/apps/payments/static/payments/css/payments.css +340 -0
- django_cfg/apps/payments/static/payments/js/notifications.js +202 -0
- django_cfg/apps/payments/static/payments/js/payment-utils.js +318 -0
- django_cfg/apps/payments/static/payments/js/theme.js +86 -0
- django_cfg/apps/payments/tasks/webhook_processing.py +2 -2
- django_cfg/apps/payments/templates/admin/payments/currency/change_list.html +50 -0
- django_cfg/apps/payments/templates/payments/base.html +182 -0
- django_cfg/apps/payments/templates/payments/components/payment_card.html +201 -0
- django_cfg/apps/payments/templates/payments/components/payment_qr_code.html +109 -0
- django_cfg/apps/payments/templates/payments/components/progress_bar.html +43 -0
- django_cfg/apps/payments/templates/payments/components/provider_stats.html +40 -0
- django_cfg/apps/payments/templates/payments/components/status_badge.html +34 -0
- django_cfg/apps/payments/templates/payments/components/status_overview.html +148 -0
- django_cfg/apps/payments/templates/payments/dashboard.html +258 -0
- django_cfg/apps/payments/templates/payments/dashboard_simple_test.html +35 -0
- django_cfg/apps/payments/templates/payments/payment_create.html +579 -0
- django_cfg/apps/payments/templates/payments/payment_detail.html +373 -0
- django_cfg/apps/payments/templates/payments/payment_list.html +354 -0
- django_cfg/apps/payments/templates/payments/stats.html +261 -0
- django_cfg/apps/payments/templates/payments/test.html +213 -0
- django_cfg/apps/payments/templatetags/__init__.py +1 -0
- django_cfg/apps/payments/templatetags/payments_tags.py +315 -0
- django_cfg/apps/payments/urls.py +3 -1
- django_cfg/apps/payments/urls_admin.py +58 -0
- django_cfg/apps/payments/utils/__init__.py +1 -3
- django_cfg/apps/payments/utils/billing_utils.py +2 -2
- django_cfg/apps/payments/utils/config_utils.py +2 -8
- django_cfg/apps/payments/utils/validation_utils.py +2 -2
- django_cfg/apps/payments/views/__init__.py +3 -2
- django_cfg/apps/payments/views/currency_views.py +31 -20
- django_cfg/apps/payments/views/payment_views.py +2 -2
- django_cfg/apps/payments/views/templates/__init__.py +25 -0
- django_cfg/apps/payments/views/templates/ajax.py +451 -0
- django_cfg/apps/payments/views/templates/base.py +212 -0
- django_cfg/apps/payments/views/templates/dashboard.py +60 -0
- django_cfg/apps/payments/views/templates/payment_detail.py +102 -0
- django_cfg/apps/payments/views/templates/payment_management.py +158 -0
- django_cfg/apps/payments/views/templates/qr_code.py +174 -0
- django_cfg/apps/payments/views/templates/stats.py +244 -0
- django_cfg/apps/payments/views/templates/utils.py +181 -0
- django_cfg/apps/payments/views/webhook_views.py +2 -2
- django_cfg/apps/payments/viewsets.py +3 -2
- django_cfg/apps/tasks/urls.py +0 -2
- django_cfg/apps/tasks/urls_admin.py +14 -0
- django_cfg/apps/urls.py +6 -3
- django_cfg/core/config.py +35 -0
- django_cfg/models/payments.py +2 -8
- django_cfg/modules/django_currency/__init__.py +16 -11
- django_cfg/modules/django_currency/clients/__init__.py +4 -4
- django_cfg/modules/django_currency/clients/coinpaprika_client.py +289 -0
- django_cfg/modules/django_currency/clients/yahoo_client.py +157 -0
- django_cfg/modules/django_currency/core/__init__.py +1 -7
- django_cfg/modules/django_currency/core/converter.py +18 -23
- django_cfg/modules/django_currency/core/models.py +122 -11
- django_cfg/modules/django_currency/database/__init__.py +4 -4
- django_cfg/modules/django_currency/database/database_loader.py +190 -309
- django_cfg/modules/django_unfold/dashboard.py +7 -2
- django_cfg/registry/core.py +1 -0
- django_cfg/template_archive/.gitignore +1 -0
- django_cfg/template_archive/django_sample.zip +0 -0
- django_cfg/templates/admin/components/action_grid.html +9 -9
- django_cfg/templates/admin/components/metric_card.html +5 -5
- django_cfg/templates/admin/components/status_badge.html +2 -2
- django_cfg/templates/admin/layouts/dashboard_with_tabs.html +152 -24
- django_cfg/templates/admin/snippets/components/quick_actions.html +3 -3
- django_cfg/templates/admin/snippets/components/system_health.html +1 -1
- django_cfg/templates/admin/snippets/tabs/overview_tab.html +49 -52
- {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/METADATA +13 -18
- {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/RECORD +130 -83
- django_cfg/apps/payments/management/commands/populate_currencies.py +0 -246
- django_cfg/apps/payments/management/commands/update_currencies.py +0 -336
- django_cfg/apps/payments/services/providers/cryptapi.py +0 -273
- django_cfg/apps/payments/services/providers/cryptomus.py +0 -310
- django_cfg/apps/payments/services/providers/nowpayments.py +0 -293
- django_cfg/apps/payments/services/validators/__init__.py +0 -8
- django_cfg/modules/django_currency/clients/coingecko_client.py +0 -257
- django_cfg/modules/django_currency/clients/yfinance_client.py +0 -246
- {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/licenses/LICENSE +0 -0
@@ -1,293 +0,0 @@
|
|
1
|
-
"""
|
2
|
-
NowPayments provider implementation.
|
3
|
-
|
4
|
-
Enhanced crypto payment provider with minimal typing.
|
5
|
-
"""
|
6
|
-
|
7
|
-
import logging
|
8
|
-
import requests
|
9
|
-
import hashlib
|
10
|
-
import hmac
|
11
|
-
from typing import Optional, List
|
12
|
-
from decimal import Decimal
|
13
|
-
from pydantic import BaseModel, Field
|
14
|
-
|
15
|
-
from .base import PaymentProvider
|
16
|
-
from ..internal_types import ProviderResponse, WebhookData
|
17
|
-
|
18
|
-
logger = logging.getLogger(__name__)
|
19
|
-
|
20
|
-
|
21
|
-
class NowPaymentsConfig(BaseModel):
|
22
|
-
"""NowPayments provider configuration."""
|
23
|
-
api_key: str = Field(..., description="NowPayments API key")
|
24
|
-
sandbox: bool = Field(default=False, description="Use sandbox mode")
|
25
|
-
ipn_secret: Optional[str] = Field(default=None, description="IPN secret for webhook validation")
|
26
|
-
callback_url: Optional[str] = Field(default=None, description="Webhook callback URL")
|
27
|
-
success_url: Optional[str] = Field(default=None, description="Payment success redirect URL")
|
28
|
-
cancel_url: Optional[str] = Field(default=None, description="Payment cancel redirect URL")
|
29
|
-
enabled: bool = Field(default=True, description="Provider enabled")
|
30
|
-
|
31
|
-
|
32
|
-
class NowPaymentsProvider(PaymentProvider):
|
33
|
-
"""NowPayments cryptocurrency payment provider."""
|
34
|
-
|
35
|
-
def __init__(self, config: NowPaymentsConfig):
|
36
|
-
"""Initialize NowPayments provider."""
|
37
|
-
super().__init__(config.dict())
|
38
|
-
self.config = config
|
39
|
-
self.api_key = config.api_key
|
40
|
-
self.sandbox = config.sandbox
|
41
|
-
self.ipn_secret = config.ipn_secret or ''
|
42
|
-
self.base_url = self._get_base_url()
|
43
|
-
|
44
|
-
# Configurable URLs
|
45
|
-
self.callback_url = config.callback_url
|
46
|
-
self.success_url = config.success_url
|
47
|
-
self.cancel_url = config.cancel_url
|
48
|
-
|
49
|
-
self.headers = {
|
50
|
-
'x-api-key': self.api_key,
|
51
|
-
'Content-Type': 'application/json'
|
52
|
-
}
|
53
|
-
|
54
|
-
def _get_base_url(self) -> str:
|
55
|
-
"""Get base URL based on sandbox mode."""
|
56
|
-
if self.sandbox:
|
57
|
-
return 'https://api-sandbox.nowpayments.io/v1'
|
58
|
-
return 'https://api.nowpayments.io/v1'
|
59
|
-
|
60
|
-
def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> Optional[dict]:
|
61
|
-
"""Make HTTP request to NowPayments API with error handling."""
|
62
|
-
try:
|
63
|
-
url = f"{self.base_url}/{endpoint}"
|
64
|
-
|
65
|
-
response = requests.request(
|
66
|
-
method=method,
|
67
|
-
url=url,
|
68
|
-
headers=self.headers,
|
69
|
-
json=data,
|
70
|
-
timeout=30
|
71
|
-
)
|
72
|
-
|
73
|
-
response.raise_for_status()
|
74
|
-
return response.json()
|
75
|
-
|
76
|
-
except requests.exceptions.RequestException as e:
|
77
|
-
logger.error(f"NowPayments API request failed: {e}")
|
78
|
-
return None
|
79
|
-
except Exception as e:
|
80
|
-
logger.error(f"Unexpected error in NowPayments request: {e}")
|
81
|
-
return None
|
82
|
-
|
83
|
-
def create_payment(self, payment_data: dict) -> ProviderResponse:
|
84
|
-
"""Create payment via NowPayments API."""
|
85
|
-
try:
|
86
|
-
amount = Decimal(str(payment_data['amount']))
|
87
|
-
currency = payment_data['currency']
|
88
|
-
order_id = payment_data.get('order_id', f'payment_{int(amount * 100)}_{currency}')
|
89
|
-
|
90
|
-
payment_request = {
|
91
|
-
'price_amount': float(amount),
|
92
|
-
'price_currency': 'usd', # Base currency
|
93
|
-
'pay_currency': currency,
|
94
|
-
'order_id': order_id,
|
95
|
-
'order_description': payment_data.get('description', f'Payment {order_id}'),
|
96
|
-
}
|
97
|
-
|
98
|
-
# Add optional URLs
|
99
|
-
if self.success_url:
|
100
|
-
payment_request['success_url'] = self.success_url
|
101
|
-
if self.cancel_url:
|
102
|
-
payment_request['cancel_url'] = self.cancel_url
|
103
|
-
if self.callback_url:
|
104
|
-
payment_request['ipn_callback_url'] = self.callback_url
|
105
|
-
|
106
|
-
response = self._make_request('POST', 'payment', payment_request)
|
107
|
-
|
108
|
-
if response:
|
109
|
-
return ProviderResponse(
|
110
|
-
success=True,
|
111
|
-
provider_payment_id=response.get('payment_id'),
|
112
|
-
payment_url=response.get('invoice_url'),
|
113
|
-
pay_address=response.get('pay_address'),
|
114
|
-
amount=Decimal(str(response.get('pay_amount', 0))),
|
115
|
-
currency=response.get('pay_currency'),
|
116
|
-
status='pending'
|
117
|
-
)
|
118
|
-
else:
|
119
|
-
return ProviderResponse(
|
120
|
-
success=False,
|
121
|
-
error_message='Failed to create payment'
|
122
|
-
)
|
123
|
-
|
124
|
-
except Exception as e:
|
125
|
-
logger.error(f"NowPayments create_payment error: {e}")
|
126
|
-
return ProviderResponse(
|
127
|
-
success=False,
|
128
|
-
error_message=str(e)
|
129
|
-
)
|
130
|
-
|
131
|
-
def check_payment_status(self, payment_id: str) -> ProviderResponse:
|
132
|
-
"""Check payment status via NowPayments API."""
|
133
|
-
try:
|
134
|
-
response = self._make_request('GET', f'payment/{payment_id}')
|
135
|
-
|
136
|
-
if response:
|
137
|
-
# Map NowPayments status to universal status
|
138
|
-
status_mapping = {
|
139
|
-
'waiting': 'pending',
|
140
|
-
'confirming': 'processing',
|
141
|
-
'confirmed': 'completed',
|
142
|
-
'sending': 'processing',
|
143
|
-
'partially_paid': 'pending',
|
144
|
-
'finished': 'completed',
|
145
|
-
'failed': 'failed',
|
146
|
-
'refunded': 'refunded',
|
147
|
-
'expired': 'expired'
|
148
|
-
}
|
149
|
-
|
150
|
-
provider_status = response.get('payment_status', 'unknown')
|
151
|
-
universal_status = status_mapping.get(provider_status, 'unknown')
|
152
|
-
|
153
|
-
return ProviderResponse(
|
154
|
-
success=True,
|
155
|
-
provider_payment_id=response.get('payment_id'),
|
156
|
-
status=universal_status,
|
157
|
-
pay_address=response.get('pay_address'),
|
158
|
-
amount=Decimal(str(response.get('pay_amount', 0))),
|
159
|
-
currency=response.get('pay_currency')
|
160
|
-
)
|
161
|
-
else:
|
162
|
-
return ProviderResponse(
|
163
|
-
success=False,
|
164
|
-
error_message='Payment not found'
|
165
|
-
)
|
166
|
-
|
167
|
-
except Exception as e:
|
168
|
-
logger.error(f"NowPayments check_payment_status error: {e}")
|
169
|
-
return ProviderResponse(
|
170
|
-
success=False,
|
171
|
-
error_message=str(e)
|
172
|
-
)
|
173
|
-
|
174
|
-
def process_webhook(self, payload: dict) -> WebhookData:
|
175
|
-
"""Process NowPayments webhook."""
|
176
|
-
try:
|
177
|
-
# Map status
|
178
|
-
status_mapping = {
|
179
|
-
'waiting': 'pending',
|
180
|
-
'confirming': 'processing',
|
181
|
-
'confirmed': 'completed',
|
182
|
-
'sending': 'processing',
|
183
|
-
'partially_paid': 'pending',
|
184
|
-
'finished': 'completed',
|
185
|
-
'failed': 'failed',
|
186
|
-
'refunded': 'refunded',
|
187
|
-
'expired': 'expired'
|
188
|
-
}
|
189
|
-
|
190
|
-
provider_status = payload.get('payment_status', 'unknown')
|
191
|
-
universal_status = status_mapping.get(provider_status, 'unknown')
|
192
|
-
|
193
|
-
return WebhookData(
|
194
|
-
provider_payment_id=str(payload.get('payment_id', '')),
|
195
|
-
status=universal_status,
|
196
|
-
pay_amount=Decimal(str(payload.get('pay_amount', 0))),
|
197
|
-
actually_paid=Decimal(str(payload.get('actually_paid', 0))),
|
198
|
-
order_id=payload.get('order_id'),
|
199
|
-
signature=payload.get('signature')
|
200
|
-
)
|
201
|
-
|
202
|
-
except Exception as e:
|
203
|
-
logger.error(f"NowPayments webhook processing error: {e}")
|
204
|
-
raise
|
205
|
-
|
206
|
-
def get_supported_currencies(self) -> List[str]:
|
207
|
-
"""Get list of supported currencies."""
|
208
|
-
try:
|
209
|
-
response = self._make_request('GET', 'currencies')
|
210
|
-
|
211
|
-
if response and 'currencies' in response:
|
212
|
-
return response['currencies']
|
213
|
-
else:
|
214
|
-
# Fallback currencies
|
215
|
-
return ['BTC', 'ETH', 'LTC', 'BCH', 'XMR', 'TRX', 'BNB']
|
216
|
-
|
217
|
-
except Exception as e:
|
218
|
-
logger.error(f"Error getting supported currencies: {e}")
|
219
|
-
return ['BTC', 'ETH', 'LTC'] # Minimal fallback
|
220
|
-
|
221
|
-
def get_minimum_payment_amount(self, currency_from: str, currency_to: str = 'usd') -> Optional[Decimal]:
|
222
|
-
"""Get minimum payment amount for currency pair."""
|
223
|
-
try:
|
224
|
-
response = self._make_request('GET', 'min-amount', {
|
225
|
-
'currency_from': currency_from,
|
226
|
-
'currency_to': currency_to
|
227
|
-
})
|
228
|
-
|
229
|
-
if response and 'min_amount' in response:
|
230
|
-
return Decimal(str(response['min_amount']))
|
231
|
-
|
232
|
-
return None
|
233
|
-
|
234
|
-
except Exception as e:
|
235
|
-
logger.error(f"Error getting minimum amount: {e}")
|
236
|
-
return None
|
237
|
-
|
238
|
-
def estimate_payment_amount(self, amount: Decimal, currency_code: str) -> Optional[dict]:
|
239
|
-
"""Estimate payment amount in target currency."""
|
240
|
-
try:
|
241
|
-
response = self._make_request('GET', 'estimate', {
|
242
|
-
'amount': float(amount),
|
243
|
-
'currency_from': 'usd',
|
244
|
-
'currency_to': currency_code
|
245
|
-
})
|
246
|
-
|
247
|
-
if response and 'estimated_amount' in response:
|
248
|
-
return {
|
249
|
-
'estimated_amount': Decimal(str(response['estimated_amount'])),
|
250
|
-
'currency_from': response.get('currency_from'),
|
251
|
-
'currency_to': response.get('currency_to'),
|
252
|
-
'fee_amount': Decimal(str(response.get('fee_amount', 0)))
|
253
|
-
}
|
254
|
-
|
255
|
-
return None
|
256
|
-
|
257
|
-
except Exception as e:
|
258
|
-
logger.error(f"Error estimating payment amount: {e}")
|
259
|
-
return None
|
260
|
-
|
261
|
-
def validate_webhook(self, payload: dict, headers: Optional[dict] = None) -> bool:
|
262
|
-
"""Validate NowPayments webhook signature."""
|
263
|
-
try:
|
264
|
-
if not self.ipn_secret:
|
265
|
-
logger.warning("IPN secret not configured, skipping webhook validation")
|
266
|
-
return True
|
267
|
-
|
268
|
-
if not headers:
|
269
|
-
logger.warning("No headers provided for webhook validation")
|
270
|
-
return False
|
271
|
-
|
272
|
-
# Get signature from headers
|
273
|
-
signature = headers.get('x-nowpayments-sig')
|
274
|
-
if not signature:
|
275
|
-
logger.warning("No signature found in webhook headers")
|
276
|
-
return False
|
277
|
-
|
278
|
-
# TODO: Implement proper HMAC signature validation
|
279
|
-
# This requires the raw payload body for proper validation
|
280
|
-
logger.info("Webhook signature validation placeholder")
|
281
|
-
return True
|
282
|
-
|
283
|
-
except Exception as e:
|
284
|
-
logger.error(f"Webhook validation error: {e}")
|
285
|
-
return False
|
286
|
-
|
287
|
-
def check_api_status(self) -> bool:
|
288
|
-
"""Check if NowPayments API is available."""
|
289
|
-
try:
|
290
|
-
response = self._make_request('GET', 'status')
|
291
|
-
return response is not None and response.get('message') == 'OK'
|
292
|
-
except:
|
293
|
-
return False
|
@@ -1,257 +0,0 @@
|
|
1
|
-
"""
|
2
|
-
CoinGecko client for crypto rates only.
|
3
|
-
"""
|
4
|
-
|
5
|
-
import logging
|
6
|
-
import time
|
7
|
-
from datetime import datetime
|
8
|
-
from typing import Dict, Set, Optional
|
9
|
-
from cachetools import TTLCache
|
10
|
-
from pycoingecko import CoinGeckoAPI
|
11
|
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
12
|
-
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
13
|
-
|
14
|
-
from ..core.models import Rate
|
15
|
-
from ..core.exceptions import RateFetchError
|
16
|
-
|
17
|
-
logger = logging.getLogger(__name__)
|
18
|
-
|
19
|
-
|
20
|
-
class CoinGeckoClient:
|
21
|
-
"""Client for fetching crypto rates from CoinGecko."""
|
22
|
-
|
23
|
-
def __init__(self, cache_ttl: int = 3600, rate_limit_delay: float = 1.2):
|
24
|
-
"""Initialize CoinGecko client with TTL cache and rate limiting."""
|
25
|
-
self.client = CoinGeckoAPI()
|
26
|
-
self._crypto_cache = TTLCache(maxsize=2, ttl=cache_ttl) # Cache crypto data for 1 hour
|
27
|
-
self._rate_cache = TTLCache(maxsize=1000, ttl=600) # Cache rates for 10 minutes
|
28
|
-
self._last_request_time = 0.0
|
29
|
-
self._rate_limit_delay = rate_limit_delay # Delay between requests to avoid 429
|
30
|
-
|
31
|
-
def fetch_rate(self, base: str, quote: str) -> Rate:
|
32
|
-
"""
|
33
|
-
Fetch crypto exchange rate from CoinGecko with caching.
|
34
|
-
|
35
|
-
Args:
|
36
|
-
base: Base currency code (crypto)
|
37
|
-
quote: Quote currency code
|
38
|
-
|
39
|
-
Returns:
|
40
|
-
Rate object with exchange rate data
|
41
|
-
|
42
|
-
Raises:
|
43
|
-
RateFetchError: If rate fetch fails
|
44
|
-
"""
|
45
|
-
cache_key = f"{base}_{quote}"
|
46
|
-
|
47
|
-
# Try cache first
|
48
|
-
if cache_key in self._rate_cache:
|
49
|
-
logger.debug(f"Retrieved rate {base}/{quote} from cache")
|
50
|
-
return self._rate_cache[cache_key]
|
51
|
-
|
52
|
-
try:
|
53
|
-
rate = self._fetch_rate_with_retry(base, quote)
|
54
|
-
|
55
|
-
# Cache the result
|
56
|
-
self._rate_cache[cache_key] = rate
|
57
|
-
|
58
|
-
return rate
|
59
|
-
|
60
|
-
except Exception as e:
|
61
|
-
logger.error(f"Failed to fetch rate for {base}/{quote}: {e}")
|
62
|
-
raise RateFetchError(f"CoinGecko fetch failed: {e}")
|
63
|
-
|
64
|
-
@retry(
|
65
|
-
stop=stop_after_attempt(4), # More retries for CoinGecko due to rate limits
|
66
|
-
wait=wait_exponential(multiplier=2, min=2, max=30), # Longer waits for rate-limited API
|
67
|
-
retry=retry_if_exception_type((ConnectionError, TimeoutError, Exception)),
|
68
|
-
reraise=True
|
69
|
-
)
|
70
|
-
def _fetch_rate_with_retry(self, base: str, quote: str) -> Rate:
|
71
|
-
"""
|
72
|
-
Fetch rate with retry logic and exponential backoff.
|
73
|
-
|
74
|
-
Args:
|
75
|
-
base: Base currency code (crypto)
|
76
|
-
quote: Quote currency code
|
77
|
-
|
78
|
-
Returns:
|
79
|
-
Rate object with exchange rate data
|
80
|
-
"""
|
81
|
-
base_id = self._get_crypto_id(base)
|
82
|
-
quote_currency = quote.lower()
|
83
|
-
|
84
|
-
vs_currencies = self.get_vs_currencies()
|
85
|
-
if quote_currency not in vs_currencies:
|
86
|
-
raise RateFetchError(f"Quote currency {quote} not supported by CoinGecko")
|
87
|
-
|
88
|
-
logger.debug(f"Fetching rate for {base_id} vs {quote_currency}")
|
89
|
-
|
90
|
-
# Fetch price from CoinGecko with rate limiting
|
91
|
-
self._rate_limit()
|
92
|
-
price_data = self.client.get_price(
|
93
|
-
ids=base_id,
|
94
|
-
vs_currencies=quote_currency,
|
95
|
-
include_last_updated_at=True
|
96
|
-
)
|
97
|
-
|
98
|
-
if base_id not in price_data:
|
99
|
-
raise RateFetchError(f"No data for {base}")
|
100
|
-
|
101
|
-
rate_value = price_data[base_id][quote_currency]
|
102
|
-
|
103
|
-
return Rate(
|
104
|
-
source="coingecko",
|
105
|
-
base_currency=base.upper(),
|
106
|
-
quote_currency=quote.upper(),
|
107
|
-
rate=float(rate_value),
|
108
|
-
timestamp=datetime.now()
|
109
|
-
)
|
110
|
-
|
111
|
-
def get_crypto_ids(self) -> Dict[str, str]:
|
112
|
-
"""Get all supported cryptocurrencies dynamically with caching."""
|
113
|
-
cache_key = "crypto_ids"
|
114
|
-
|
115
|
-
# Try cache first
|
116
|
-
if cache_key in self._crypto_cache:
|
117
|
-
logger.debug("Retrieved crypto IDs from cache")
|
118
|
-
return self._crypto_cache[cache_key]
|
119
|
-
|
120
|
-
try:
|
121
|
-
crypto_ids = self._get_coins_list_with_retry()
|
122
|
-
|
123
|
-
# Cache the result
|
124
|
-
self._crypto_cache[cache_key] = crypto_ids
|
125
|
-
logger.info(f"Loaded and cached {len(crypto_ids)} cryptocurrencies from CoinGecko")
|
126
|
-
|
127
|
-
return crypto_ids
|
128
|
-
|
129
|
-
except Exception as e:
|
130
|
-
logger.error(f"Failed to load cryptocurrencies: {e}")
|
131
|
-
raise RateFetchError(f"Failed to load cryptocurrencies from CoinGecko: {e}")
|
132
|
-
|
133
|
-
def get_vs_currencies(self) -> Set[str]:
|
134
|
-
"""Get all supported quote currencies dynamically with caching."""
|
135
|
-
cache_key = "vs_currencies"
|
136
|
-
|
137
|
-
# Try cache first
|
138
|
-
if cache_key in self._crypto_cache:
|
139
|
-
logger.debug("Retrieved vs_currencies from cache")
|
140
|
-
return self._crypto_cache[cache_key]
|
141
|
-
|
142
|
-
try:
|
143
|
-
vs_currencies_set = self._get_vs_currencies_with_retry()
|
144
|
-
|
145
|
-
# Cache the result
|
146
|
-
self._crypto_cache[cache_key] = vs_currencies_set
|
147
|
-
logger.info(f"Loaded and cached {len(vs_currencies_set)} vs_currencies from CoinGecko")
|
148
|
-
|
149
|
-
return vs_currencies_set
|
150
|
-
|
151
|
-
except Exception as e:
|
152
|
-
logger.error(f"Failed to load vs_currencies: {e}")
|
153
|
-
raise RateFetchError(f"Failed to load vs_currencies from CoinGecko: {e}")
|
154
|
-
|
155
|
-
def _get_crypto_id(self, currency: str) -> str:
|
156
|
-
"""Get CoinGecko crypto ID from currency code."""
|
157
|
-
currency = currency.upper()
|
158
|
-
crypto_ids = self.get_crypto_ids()
|
159
|
-
|
160
|
-
if currency in crypto_ids:
|
161
|
-
return crypto_ids[currency]
|
162
|
-
|
163
|
-
raise RateFetchError(f"Unknown cryptocurrency: {currency}")
|
164
|
-
|
165
|
-
def _rate_limit(self):
|
166
|
-
"""Enforce rate limiting to prevent API throttling."""
|
167
|
-
current_time = time.time()
|
168
|
-
time_since_last = current_time - self._last_request_time
|
169
|
-
|
170
|
-
if time_since_last < self._rate_limit_delay:
|
171
|
-
sleep_time = self._rate_limit_delay - time_since_last
|
172
|
-
logger.debug(f"Rate limiting: sleeping for {sleep_time:.2f}s")
|
173
|
-
time.sleep(sleep_time)
|
174
|
-
|
175
|
-
self._last_request_time = time.time()
|
176
|
-
|
177
|
-
@retry(
|
178
|
-
stop=stop_after_attempt(3),
|
179
|
-
wait=wait_exponential(multiplier=2, min=2, max=15),
|
180
|
-
retry=retry_if_exception_type((ConnectionError, TimeoutError, Exception)),
|
181
|
-
reraise=True
|
182
|
-
)
|
183
|
-
def _get_coins_list_with_retry(self) -> Dict[str, str]:
|
184
|
-
"""Get coins list with retry logic."""
|
185
|
-
self._rate_limit()
|
186
|
-
coins_list = self.client.get_coins_list()
|
187
|
-
crypto_ids = {}
|
188
|
-
|
189
|
-
for coin in coins_list:
|
190
|
-
symbol = coin['symbol'].upper()
|
191
|
-
crypto_ids[symbol] = coin['id']
|
192
|
-
|
193
|
-
return crypto_ids
|
194
|
-
|
195
|
-
@retry(
|
196
|
-
stop=stop_after_attempt(3),
|
197
|
-
wait=wait_exponential(multiplier=2, min=2, max=15),
|
198
|
-
retry=retry_if_exception_type((ConnectionError, TimeoutError, Exception)),
|
199
|
-
reraise=True
|
200
|
-
)
|
201
|
-
def _get_vs_currencies_with_retry(self) -> Set[str]:
|
202
|
-
"""Get vs currencies with retry logic."""
|
203
|
-
self._rate_limit()
|
204
|
-
vs_currencies = self.client.get_supported_vs_currencies()
|
205
|
-
return set(vs_currencies)
|
206
|
-
|
207
|
-
def fetch_multiple_rates(self, pairs: list) -> Dict[str, Rate]:
|
208
|
-
"""
|
209
|
-
Fetch multiple currency rates in parallel.
|
210
|
-
|
211
|
-
Args:
|
212
|
-
pairs: List of tuples (base, quote) to fetch
|
213
|
-
|
214
|
-
Returns:
|
215
|
-
Dictionary mapping "BASE_QUOTE" to Rate objects
|
216
|
-
"""
|
217
|
-
results = {}
|
218
|
-
|
219
|
-
def fetch_single_rate(pair):
|
220
|
-
base, quote = pair
|
221
|
-
try:
|
222
|
-
rate = self.fetch_rate(base, quote)
|
223
|
-
return f"{base}_{quote}", rate
|
224
|
-
except Exception as e:
|
225
|
-
logger.warning(f"Failed to fetch {base}/{quote}: {e}")
|
226
|
-
return f"{base}_{quote}", None
|
227
|
-
|
228
|
-
# Use ThreadPoolExecutor for parallel fetching with rate limiting
|
229
|
-
with ThreadPoolExecutor(max_workers=3) as executor: # Limited workers to respect rate limits
|
230
|
-
future_to_pair = {executor.submit(fetch_single_rate, pair): pair for pair in pairs}
|
231
|
-
|
232
|
-
for future in as_completed(future_to_pair):
|
233
|
-
try:
|
234
|
-
key, rate = future.result(timeout=30)
|
235
|
-
if rate:
|
236
|
-
results[key] = rate
|
237
|
-
except Exception as e:
|
238
|
-
pair = future_to_pair[future]
|
239
|
-
logger.error(f"Failed to fetch rate for {pair}: {e}")
|
240
|
-
|
241
|
-
logger.info(f"Successfully fetched {len(results)}/{len(pairs)} rates")
|
242
|
-
return results
|
243
|
-
|
244
|
-
def supports_pair(self, base: str, quote: str) -> bool:
|
245
|
-
"""Check if crypto currency pair is supported."""
|
246
|
-
try:
|
247
|
-
# Base must be a crypto
|
248
|
-
crypto_ids = self.get_crypto_ids()
|
249
|
-
if base.upper() not in crypto_ids:
|
250
|
-
return False
|
251
|
-
|
252
|
-
# Quote must be a supported vs_currency
|
253
|
-
vs_currencies = self.get_vs_currencies()
|
254
|
-
return quote.lower() in vs_currencies
|
255
|
-
|
256
|
-
except Exception:
|
257
|
-
return False
|