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
django_cfg/core/config.py
CHANGED
@@ -11,6 +11,7 @@ Following CRITICAL_REQUIREMENTS.md:
|
|
11
11
|
from typing import Dict, List, Optional, Any, Union
|
12
12
|
from pathlib import Path
|
13
13
|
from pydantic import BaseModel, Field, field_validator, model_validator, PrivateAttr
|
14
|
+
from enum import Enum
|
14
15
|
import os
|
15
16
|
from pathlib import Path
|
16
17
|
from urllib.parse import urlparse
|
@@ -66,6 +67,18 @@ DEFAULT_APPS = [
|
|
66
67
|
]
|
67
68
|
|
68
69
|
|
70
|
+
class EnvironmentMode(str, Enum):
|
71
|
+
"""Environment mode enumeration."""
|
72
|
+
DEVELOPMENT = "development"
|
73
|
+
PRODUCTION = "production"
|
74
|
+
TEST = "test"
|
75
|
+
|
76
|
+
@classmethod
|
77
|
+
def from_debug(cls, debug: bool) -> "EnvironmentMode":
|
78
|
+
"""Get environment mode from debug flag."""
|
79
|
+
return cls.DEVELOPMENT if debug else cls.PRODUCTION
|
80
|
+
|
81
|
+
|
69
82
|
class DjangoConfig(BaseModel):
|
70
83
|
"""
|
71
84
|
Base configuration class for Django projects.
|
@@ -105,6 +118,12 @@ class DjangoConfig(BaseModel):
|
|
105
118
|
"str_strip_whitespace": True,
|
106
119
|
}
|
107
120
|
|
121
|
+
# === Environment Configuration ===
|
122
|
+
env_mode: EnvironmentMode = Field(
|
123
|
+
default=EnvironmentMode.PRODUCTION,
|
124
|
+
description="Environment mode: development, production, or test",
|
125
|
+
)
|
126
|
+
|
108
127
|
# === Project Information ===
|
109
128
|
project_name: str = Field(
|
110
129
|
...,
|
@@ -401,6 +420,22 @@ class DjangoConfig(BaseModel):
|
|
401
420
|
|
402
421
|
return self
|
403
422
|
|
423
|
+
# === Environment Mode Properties ===
|
424
|
+
@property
|
425
|
+
def is_development(self) -> bool:
|
426
|
+
"""Check if running in development mode."""
|
427
|
+
return self.env_mode == EnvironmentMode.DEVELOPMENT
|
428
|
+
|
429
|
+
@property
|
430
|
+
def is_production(self) -> bool:
|
431
|
+
"""Check if running in production mode."""
|
432
|
+
return self.env_mode == EnvironmentMode.PRODUCTION
|
433
|
+
|
434
|
+
@property
|
435
|
+
def is_test(self) -> bool:
|
436
|
+
"""Check if running in test mode."""
|
437
|
+
return self.env_mode == EnvironmentMode.TEST
|
438
|
+
|
404
439
|
def _detect_environment(self) -> None:
|
405
440
|
"""Detect current environment from various sources."""
|
406
441
|
from django_cfg.core.environment import EnvironmentDetector
|
django_cfg/models/payments.py
CHANGED
@@ -457,7 +457,6 @@ class PaymentsConfig(BaseModel):
|
|
457
457
|
# Helper function for easy provider configuration
|
458
458
|
def create_nowpayments_config(
|
459
459
|
api_key: str,
|
460
|
-
sandbox: bool = True,
|
461
460
|
ipn_secret: Optional[str] = None,
|
462
461
|
**kwargs
|
463
462
|
) -> NowPaymentsConfig:
|
@@ -465,7 +464,6 @@ def create_nowpayments_config(
|
|
465
464
|
return NowPaymentsConfig(
|
466
465
|
name="nowpayments",
|
467
466
|
api_key=SecretStr(api_key),
|
468
|
-
sandbox=sandbox,
|
469
467
|
ipn_secret=SecretStr(ipn_secret) if ipn_secret else None,
|
470
468
|
**kwargs
|
471
469
|
)
|
@@ -489,16 +487,14 @@ def create_stripe_config(
|
|
489
487
|
api_key: str,
|
490
488
|
publishable_key: Optional[str] = None,
|
491
489
|
webhook_endpoint_secret: Optional[str] = None,
|
492
|
-
sandbox: bool = True,
|
493
490
|
**kwargs
|
494
491
|
) -> StripeConfig:
|
495
|
-
"""Helper to create Stripe configuration."""
|
492
|
+
"""Helper to create Stripe configuration with automatic sandbox detection."""
|
496
493
|
return StripeConfig(
|
497
494
|
name="stripe",
|
498
495
|
api_key=SecretStr(api_key),
|
499
496
|
publishable_key=publishable_key,
|
500
497
|
webhook_endpoint_secret=SecretStr(webhook_endpoint_secret) if webhook_endpoint_secret else None,
|
501
|
-
sandbox=sandbox,
|
502
498
|
**kwargs
|
503
499
|
)
|
504
500
|
|
@@ -506,13 +502,12 @@ def create_stripe_config(
|
|
506
502
|
def create_cryptomus_config(
|
507
503
|
api_key: str,
|
508
504
|
merchant_uuid: str,
|
509
|
-
sandbox: bool = True,
|
510
505
|
callback_url: Optional[str] = None,
|
511
506
|
success_url: Optional[str] = None,
|
512
507
|
fail_url: Optional[str] = None,
|
513
508
|
**kwargs
|
514
509
|
):
|
515
|
-
"""Helper to create Cryptomus configuration."""
|
510
|
+
"""Helper to create Cryptomus configuration with automatic sandbox detection."""
|
516
511
|
# Import here to avoid circular imports
|
517
512
|
from django_cfg.apps.payments.config.providers import CryptomusConfig
|
518
513
|
|
@@ -520,7 +515,6 @@ def create_cryptomus_config(
|
|
520
515
|
name="cryptomus",
|
521
516
|
api_key=SecretStr(api_key),
|
522
517
|
merchant_uuid=merchant_uuid,
|
523
|
-
sandbox=sandbox,
|
524
518
|
callback_url=callback_url,
|
525
519
|
success_url=success_url,
|
526
520
|
fail_url=fail_url,
|
@@ -11,9 +11,6 @@ from .core import (
|
|
11
11
|
Rate,
|
12
12
|
ConversionRequest,
|
13
13
|
ConversionResult,
|
14
|
-
SupportedCurrencies,
|
15
|
-
YFinanceCurrencies,
|
16
|
-
CoinGeckoCurrencies,
|
17
14
|
CurrencyError,
|
18
15
|
CurrencyNotFoundError,
|
19
16
|
RateFetchError,
|
@@ -25,7 +22,7 @@ from .core import (
|
|
25
22
|
from .utils import CacheManager
|
26
23
|
|
27
24
|
# Clients
|
28
|
-
from .clients import
|
25
|
+
from .clients import YahooFinanceClient, CoinPaprikaClient
|
29
26
|
|
30
27
|
# Database tools
|
31
28
|
from .database import (
|
@@ -35,6 +32,17 @@ from .database import (
|
|
35
32
|
load_currencies_to_database_format
|
36
33
|
)
|
37
34
|
|
35
|
+
# Shared global converter instance for caching efficiency
|
36
|
+
_global_converter = None
|
37
|
+
|
38
|
+
def _get_converter() -> CurrencyConverter:
|
39
|
+
"""Get or create shared converter instance."""
|
40
|
+
global _global_converter
|
41
|
+
if _global_converter is None:
|
42
|
+
_global_converter = CurrencyConverter(cache_ttl=3600) # 1 hour cache
|
43
|
+
return _global_converter
|
44
|
+
|
45
|
+
|
38
46
|
# Simple public API
|
39
47
|
def convert_currency(amount: float, from_currency: str, to_currency: str) -> float:
|
40
48
|
"""
|
@@ -48,7 +56,7 @@ def convert_currency(amount: float, from_currency: str, to_currency: str) -> flo
|
|
48
56
|
Returns:
|
49
57
|
Converted amount
|
50
58
|
"""
|
51
|
-
converter =
|
59
|
+
converter = _get_converter()
|
52
60
|
result = converter.convert(amount, from_currency, to_currency)
|
53
61
|
return result.result
|
54
62
|
|
@@ -64,7 +72,7 @@ def get_exchange_rate(base: str, quote: str) -> float:
|
|
64
72
|
Returns:
|
65
73
|
Exchange rate
|
66
74
|
"""
|
67
|
-
converter =
|
75
|
+
converter = _get_converter()
|
68
76
|
result = converter.convert(1.0, base, quote)
|
69
77
|
return result.rate.rate
|
70
78
|
|
@@ -75,9 +83,6 @@ __all__ = [
|
|
75
83
|
"Rate",
|
76
84
|
"ConversionRequest",
|
77
85
|
"ConversionResult",
|
78
|
-
"SupportedCurrencies",
|
79
|
-
"YFinanceCurrencies",
|
80
|
-
"CoinGeckoCurrencies",
|
81
86
|
|
82
87
|
# Exceptions
|
83
88
|
"CurrencyError",
|
@@ -90,8 +95,8 @@ __all__ = [
|
|
90
95
|
"CacheManager",
|
91
96
|
|
92
97
|
# Clients
|
93
|
-
"
|
94
|
-
"
|
98
|
+
"YahooFinanceClient",
|
99
|
+
"CoinPaprikaClient",
|
95
100
|
|
96
101
|
# Database tools
|
97
102
|
"CurrencyDatabaseLoader",
|
@@ -2,10 +2,10 @@
|
|
2
2
|
Currency data clients for fetching rates from external APIs.
|
3
3
|
"""
|
4
4
|
|
5
|
-
from .
|
6
|
-
from .
|
5
|
+
from .yahoo_client import YahooFinanceClient
|
6
|
+
from .coinpaprika_client import CoinPaprikaClient
|
7
7
|
|
8
8
|
__all__ = [
|
9
|
-
'
|
10
|
-
'
|
9
|
+
'YahooFinanceClient',
|
10
|
+
'CoinPaprikaClient'
|
11
11
|
]
|
@@ -0,0 +1,289 @@
|
|
1
|
+
"""
|
2
|
+
CoinPaprika client for crypto rates - much simpler and more reliable than CoinGecko.
|
3
|
+
|
4
|
+
CoinPaprika API provides all crypto rates in a single request without rate limits.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import logging
|
8
|
+
import time
|
9
|
+
import requests
|
10
|
+
from datetime import datetime
|
11
|
+
from typing import Dict, Set, Optional, List
|
12
|
+
from cachetools import TTLCache
|
13
|
+
|
14
|
+
from ..core.models import Rate, CoinPaprikaTicker, CoinPaprikaTickersResponse
|
15
|
+
from ..core.exceptions import RateFetchError
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
class CoinPaprikaClient:
|
21
|
+
"""Client for fetching crypto rates from CoinPaprika API."""
|
22
|
+
|
23
|
+
def __init__(self, cache_ttl: int = 600):
|
24
|
+
"""Initialize CoinPaprika client with TTL cache."""
|
25
|
+
self.base_url = "https://api.coinpaprika.com/v1"
|
26
|
+
self._rate_cache = TTLCache(maxsize=5000, ttl=cache_ttl) # Cache rates for 10 minutes
|
27
|
+
self._all_rates_cache = TTLCache(maxsize=1, ttl=300) # Cache all rates for 5 minutes
|
28
|
+
self._session = requests.Session()
|
29
|
+
self._session.headers.update({
|
30
|
+
'User-Agent': 'django-cfg-currency-client/1.0',
|
31
|
+
'Accept': 'application/json'
|
32
|
+
})
|
33
|
+
|
34
|
+
def fetch_rate(self, base: str, quote: str) -> Rate:
|
35
|
+
"""
|
36
|
+
Fetch crypto exchange rate from CoinPaprika.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
base: Base currency code (crypto)
|
40
|
+
quote: Quote currency code (usually USD)
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
Rate object with exchange rate data
|
44
|
+
|
45
|
+
Raises:
|
46
|
+
RateFetchError: If rate fetch fails
|
47
|
+
"""
|
48
|
+
if quote.upper() != 'USD':
|
49
|
+
raise RateFetchError(f"CoinPaprika only supports USD quotes, got {quote}")
|
50
|
+
|
51
|
+
cache_key = f"{base.upper()}_{quote.upper()}"
|
52
|
+
|
53
|
+
# Try cache first
|
54
|
+
if cache_key in self._rate_cache:
|
55
|
+
logger.debug(f"Retrieved rate {base}/{quote} from cache")
|
56
|
+
return self._rate_cache[cache_key]
|
57
|
+
|
58
|
+
try:
|
59
|
+
# Get all rates and find our currency
|
60
|
+
all_rates = self._fetch_all_rates()
|
61
|
+
|
62
|
+
base_upper = base.upper()
|
63
|
+
for ticker in all_rates:
|
64
|
+
if ticker.symbol == base_upper:
|
65
|
+
price = ticker.quotes.USD.price
|
66
|
+
|
67
|
+
# Parse ISO format: 2021-01-01T00:00:00Z
|
68
|
+
timestamp = datetime.fromisoformat(ticker.last_updated.replace('Z', '+00:00'))
|
69
|
+
|
70
|
+
rate = Rate(
|
71
|
+
source="coinpaprika",
|
72
|
+
base_currency=base.upper(),
|
73
|
+
quote_currency="USD",
|
74
|
+
rate=float(price),
|
75
|
+
timestamp=timestamp
|
76
|
+
)
|
77
|
+
|
78
|
+
# Cache the result
|
79
|
+
self._rate_cache[cache_key] = rate
|
80
|
+
|
81
|
+
return rate
|
82
|
+
|
83
|
+
raise RateFetchError(f"Currency {base} not found in CoinPaprika data")
|
84
|
+
|
85
|
+
except Exception as e:
|
86
|
+
logger.error(f"Failed to fetch rate for {base}/{quote}: {e}")
|
87
|
+
raise RateFetchError(f"CoinPaprika fetch failed: {e}")
|
88
|
+
|
89
|
+
def _fetch_all_tickers(self) -> Dict[str, dict]:
|
90
|
+
"""
|
91
|
+
Fetch all tickers from CoinPaprika API.
|
92
|
+
|
93
|
+
Returns:
|
94
|
+
Dict with symbol as key and ticker data as value
|
95
|
+
"""
|
96
|
+
cache_key = "all_tickers"
|
97
|
+
if cache_key in self._all_rates_cache:
|
98
|
+
logger.debug("Retrieved all tickers from CoinPaprika cache")
|
99
|
+
return self._all_rates_cache[cache_key]
|
100
|
+
|
101
|
+
try:
|
102
|
+
response = requests.get(f"{self.base_url}/tickers")
|
103
|
+
response.raise_for_status()
|
104
|
+
tickers_data = response.json()
|
105
|
+
|
106
|
+
# Process data into a more accessible format: {symbol: {id: ..., price: ...}}
|
107
|
+
processed_tickers = {}
|
108
|
+
for ticker in tickers_data:
|
109
|
+
symbol = ticker['symbol'].upper()
|
110
|
+
processed_tickers[symbol] = {
|
111
|
+
'id': ticker['id'],
|
112
|
+
'name': ticker['name'],
|
113
|
+
'price_usd': ticker['quotes']['USD']['price'] if 'USD' in ticker['quotes'] else None,
|
114
|
+
'last_updated': ticker['last_updated']
|
115
|
+
}
|
116
|
+
|
117
|
+
self._all_rates_cache[cache_key] = processed_tickers
|
118
|
+
logger.info(f"Fetched and cached {len(processed_tickers)} tickers from CoinPaprika")
|
119
|
+
return processed_tickers
|
120
|
+
except requests.exceptions.RequestException as e:
|
121
|
+
logger.error(f"Failed to fetch all tickers from CoinPaprika: {e}")
|
122
|
+
raise RateFetchError(f"CoinPaprika API error: {e}")
|
123
|
+
|
124
|
+
def _fetch_all_rates(self) -> List[CoinPaprikaTicker]:
|
125
|
+
"""
|
126
|
+
Fetch all cryptocurrency rates from CoinPaprika.
|
127
|
+
|
128
|
+
Returns:
|
129
|
+
List of CoinPaprikaTicker objects
|
130
|
+
"""
|
131
|
+
cache_key = "all_rates"
|
132
|
+
|
133
|
+
# Try cache first
|
134
|
+
if cache_key in self._all_rates_cache:
|
135
|
+
logger.debug("Retrieved all rates from cache")
|
136
|
+
return self._all_rates_cache[cache_key]
|
137
|
+
|
138
|
+
try:
|
139
|
+
url = f"{self.base_url}/tickers"
|
140
|
+
logger.debug(f"Fetching all rates from {url}")
|
141
|
+
|
142
|
+
response = self._session.get(url, timeout=30)
|
143
|
+
response.raise_for_status()
|
144
|
+
|
145
|
+
raw_data = response.json()
|
146
|
+
|
147
|
+
# Validate response using Pydantic model
|
148
|
+
try:
|
149
|
+
tickers_response = CoinPaprikaTickersResponse(raw_data)
|
150
|
+
tickers = tickers_response.root
|
151
|
+
except Exception as e:
|
152
|
+
raise RateFetchError(f"Invalid CoinPaprika response format: {e}")
|
153
|
+
|
154
|
+
# Cache the result
|
155
|
+
self._all_rates_cache[cache_key] = tickers
|
156
|
+
logger.info(f"Fetched {len(tickers)} cryptocurrencies from CoinPaprika")
|
157
|
+
|
158
|
+
return tickers
|
159
|
+
|
160
|
+
except requests.RequestException as e:
|
161
|
+
logger.error(f"HTTP error fetching from CoinPaprika: {e}")
|
162
|
+
raise RateFetchError(f"Failed to fetch data from CoinPaprika: {e}")
|
163
|
+
except Exception as e:
|
164
|
+
logger.error(f"Unexpected error fetching from CoinPaprika: {e}")
|
165
|
+
raise RateFetchError(f"CoinPaprika fetch failed: {e}")
|
166
|
+
|
167
|
+
def get_supported_cryptocurrencies(self) -> Set[str]:
|
168
|
+
"""
|
169
|
+
Get all supported cryptocurrency symbols.
|
170
|
+
|
171
|
+
Returns:
|
172
|
+
Set of supported crypto symbols
|
173
|
+
"""
|
174
|
+
try:
|
175
|
+
all_rates = self._fetch_all_rates()
|
176
|
+
symbols = {ticker.symbol for ticker in all_rates}
|
177
|
+
logger.debug(f"Found {len(symbols)} supported cryptocurrencies")
|
178
|
+
return symbols
|
179
|
+
except Exception as e:
|
180
|
+
logger.error(f"Failed to get supported cryptocurrencies: {e}")
|
181
|
+
return set()
|
182
|
+
|
183
|
+
def get_all_supported_currencies(self) -> Dict[str, str]:
|
184
|
+
"""Get all supported cryptocurrencies from CoinPaprika."""
|
185
|
+
all_tickers = self._fetch_all_tickers()
|
186
|
+
return {symbol: data['name'] for symbol, data in all_tickers.items() if data['price_usd'] is not None}
|
187
|
+
|
188
|
+
def supports_pair(self, base: str, quote: str) -> bool:
|
189
|
+
"""
|
190
|
+
Check if a currency pair is supported.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
base: Base currency code
|
194
|
+
quote: Quote currency code
|
195
|
+
|
196
|
+
Returns:
|
197
|
+
True if supported, False otherwise
|
198
|
+
"""
|
199
|
+
if quote.upper() != 'USD':
|
200
|
+
return False
|
201
|
+
|
202
|
+
supported_cryptos = self.get_supported_cryptocurrencies()
|
203
|
+
return base.upper() in supported_cryptos
|
204
|
+
|
205
|
+
def fetch_multiple_rates(self, currency_codes: List[str], quote: str = 'USD') -> Dict[str, Rate]:
|
206
|
+
"""
|
207
|
+
Fetch multiple cryptocurrency rates efficiently.
|
208
|
+
|
209
|
+
Args:
|
210
|
+
currency_codes: List of crypto currency codes
|
211
|
+
quote: Quote currency (default: USD)
|
212
|
+
|
213
|
+
Returns:
|
214
|
+
Dictionary mapping currency codes to Rate objects
|
215
|
+
"""
|
216
|
+
if quote.upper() != 'USD':
|
217
|
+
raise RateFetchError(f"CoinPaprika only supports USD quotes, got {quote}")
|
218
|
+
|
219
|
+
results = {}
|
220
|
+
|
221
|
+
try:
|
222
|
+
# Fetch all rates once
|
223
|
+
all_rates = self._fetch_all_rates()
|
224
|
+
|
225
|
+
# Create lookup dictionary
|
226
|
+
rates_by_symbol = {ticker.symbol: ticker for ticker in all_rates}
|
227
|
+
|
228
|
+
# Process requested currencies
|
229
|
+
for currency_code in currency_codes:
|
230
|
+
currency_upper = currency_code.upper()
|
231
|
+
|
232
|
+
if currency_upper in rates_by_symbol:
|
233
|
+
ticker = rates_by_symbol[currency_upper]
|
234
|
+
price = ticker.quotes.USD.price
|
235
|
+
|
236
|
+
# Parse ISO format: 2021-01-01T00:00:00Z
|
237
|
+
timestamp = datetime.fromisoformat(ticker.last_updated.replace('Z', '+00:00'))
|
238
|
+
|
239
|
+
rate = Rate(
|
240
|
+
source="coinpaprika",
|
241
|
+
base_currency=currency_upper,
|
242
|
+
quote_currency="USD",
|
243
|
+
rate=float(price),
|
244
|
+
timestamp=timestamp
|
245
|
+
)
|
246
|
+
|
247
|
+
results[currency_upper] = rate
|
248
|
+
|
249
|
+
# Cache individual rate
|
250
|
+
cache_key = f"{currency_upper}_USD"
|
251
|
+
self._rate_cache[cache_key] = rate
|
252
|
+
else:
|
253
|
+
logger.warning(f"Currency {currency_code} not found in CoinPaprika data")
|
254
|
+
|
255
|
+
logger.info(f"Successfully fetched {len(results)} rates from CoinPaprika")
|
256
|
+
return results
|
257
|
+
|
258
|
+
except Exception as e:
|
259
|
+
logger.error(f"Failed to fetch multiple rates: {e}")
|
260
|
+
raise RateFetchError(f"CoinPaprika batch fetch failed: {e}")
|
261
|
+
|
262
|
+
def get_top_cryptocurrencies(self, limit: int = 100) -> List[Dict]:
|
263
|
+
"""
|
264
|
+
Get top cryptocurrencies by market cap rank.
|
265
|
+
|
266
|
+
Args:
|
267
|
+
limit: Maximum number of currencies to return
|
268
|
+
|
269
|
+
Returns:
|
270
|
+
List of cryptocurrency data dictionaries
|
271
|
+
"""
|
272
|
+
try:
|
273
|
+
all_rates = self._fetch_all_rates()
|
274
|
+
|
275
|
+
# Filter and sort by rank
|
276
|
+
valid_tickers = [
|
277
|
+
ticker for ticker in all_rates
|
278
|
+
if ticker.rank and ticker.quotes.USD.price
|
279
|
+
]
|
280
|
+
|
281
|
+
# Sort by rank and limit
|
282
|
+
top_tickers = sorted(valid_tickers, key=lambda x: x.rank)[:limit]
|
283
|
+
|
284
|
+
logger.info(f"Retrieved top {len(top_tickers)} cryptocurrencies")
|
285
|
+
return top_tickers
|
286
|
+
|
287
|
+
except Exception as e:
|
288
|
+
logger.error(f"Failed to get top cryptocurrencies: {e}")
|
289
|
+
return []
|
@@ -0,0 +1,157 @@
|
|
1
|
+
import logging
|
2
|
+
import requests
|
3
|
+
import time
|
4
|
+
from datetime import datetime
|
5
|
+
from typing import Dict, Set, Optional
|
6
|
+
from cachetools import TTLCache
|
7
|
+
|
8
|
+
from ..core.models import Rate, YahooFinanceResponse
|
9
|
+
from ..core.exceptions import RateFetchError
|
10
|
+
|
11
|
+
logger = logging.getLogger(__name__)
|
12
|
+
|
13
|
+
|
14
|
+
class YahooFinanceClient:
|
15
|
+
"""Simple Yahoo Finance client without yfinance dependency."""
|
16
|
+
|
17
|
+
BASE_URL = "https://query1.finance.yahoo.com/v8/finance/chart"
|
18
|
+
|
19
|
+
def __init__(self, cache_ttl: int = 3600):
|
20
|
+
"""Initialize Yahoo Finance client with TTL cache."""
|
21
|
+
self._rate_cache = TTLCache(maxsize=500, ttl=cache_ttl)
|
22
|
+
self._session = requests.Session()
|
23
|
+
self._session.headers.update({
|
24
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
25
|
+
})
|
26
|
+
self._last_request_time = 0
|
27
|
+
self._rate_limit_delay = 1.0 # 1 second between requests
|
28
|
+
|
29
|
+
def _get_yahoo_symbol(self, base: str, quote: str) -> str:
|
30
|
+
"""Convert currency pair to Yahoo Finance symbol format."""
|
31
|
+
# Yahoo uses format like EURUSD=X for forex pairs
|
32
|
+
return f"{base}{quote}=X"
|
33
|
+
|
34
|
+
def fetch_rate(self, base: str, quote: str) -> Rate:
|
35
|
+
"""
|
36
|
+
Fetch forex rate from Yahoo Finance with caching.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
base: Base currency code (e.g., EUR)
|
40
|
+
quote: Quote currency code (e.g., USD)
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
Rate object with exchange rate data
|
44
|
+
|
45
|
+
Raises:
|
46
|
+
RateFetchError: If rate fetch fails
|
47
|
+
"""
|
48
|
+
base = base.upper()
|
49
|
+
quote = quote.upper()
|
50
|
+
cache_key = f"{base}_{quote}"
|
51
|
+
|
52
|
+
# Try cache first
|
53
|
+
if cache_key in self._rate_cache:
|
54
|
+
logger.debug(f"Retrieved rate {base}/{quote} from Yahoo cache")
|
55
|
+
return self._rate_cache[cache_key]
|
56
|
+
|
57
|
+
symbol = self._get_yahoo_symbol(base, quote)
|
58
|
+
|
59
|
+
# Rate limiting
|
60
|
+
current_time = time.time()
|
61
|
+
time_since_last_request = current_time - self._last_request_time
|
62
|
+
if time_since_last_request < self._rate_limit_delay:
|
63
|
+
sleep_time = self._rate_limit_delay - time_since_last_request
|
64
|
+
logger.debug(f"Rate limiting: sleeping for {sleep_time:.2f}s")
|
65
|
+
time.sleep(sleep_time)
|
66
|
+
|
67
|
+
try:
|
68
|
+
response = self._session.get(f"{self.BASE_URL}/{symbol}")
|
69
|
+
self._last_request_time = time.time()
|
70
|
+
response.raise_for_status()
|
71
|
+
|
72
|
+
raw_data = response.json()
|
73
|
+
|
74
|
+
# Validate response using Pydantic model
|
75
|
+
try:
|
76
|
+
yahoo_response = YahooFinanceResponse(**raw_data)
|
77
|
+
except Exception as e:
|
78
|
+
raise RateFetchError(f"Invalid Yahoo Finance response format: {e}")
|
79
|
+
|
80
|
+
if not yahoo_response.chart.result:
|
81
|
+
raise RateFetchError(f"No data returned for {symbol}")
|
82
|
+
|
83
|
+
meta = yahoo_response.chart.result[0].meta
|
84
|
+
rate_value = meta.regularMarketPrice
|
85
|
+
timestamp = datetime.fromtimestamp(meta.regularMarketTime)
|
86
|
+
|
87
|
+
rate = Rate(
|
88
|
+
source="yahoo",
|
89
|
+
base_currency=base,
|
90
|
+
quote_currency=quote,
|
91
|
+
rate=float(rate_value),
|
92
|
+
timestamp=timestamp
|
93
|
+
)
|
94
|
+
|
95
|
+
self._rate_cache[cache_key] = rate
|
96
|
+
logger.info(f"Fetched rate {base}/{quote} = {rate_value} from Yahoo Finance")
|
97
|
+
return rate
|
98
|
+
|
99
|
+
except requests.exceptions.RequestException as e:
|
100
|
+
logger.error(f"Failed to fetch rate from Yahoo Finance: {e}")
|
101
|
+
raise RateFetchError(f"Yahoo Finance API error: {e}")
|
102
|
+
except (KeyError, TypeError, ValueError) as e:
|
103
|
+
logger.error(f"Failed to parse Yahoo Finance response: {e}")
|
104
|
+
raise RateFetchError(f"Invalid response format: {e}")
|
105
|
+
except Exception as e:
|
106
|
+
logger.error(f"Unexpected error fetching from Yahoo Finance: {e}")
|
107
|
+
raise RateFetchError(f"Yahoo Finance fetch failed: {e}")
|
108
|
+
|
109
|
+
def supports_pair(self, base: str, quote: str) -> bool:
|
110
|
+
"""
|
111
|
+
Check if Yahoo Finance supports the given currency pair.
|
112
|
+
|
113
|
+
Yahoo Finance primarily supports major forex pairs.
|
114
|
+
"""
|
115
|
+
base = base.upper()
|
116
|
+
quote = quote.upper()
|
117
|
+
|
118
|
+
# Major currencies supported by Yahoo Finance
|
119
|
+
major_currencies = {
|
120
|
+
'USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'NZD',
|
121
|
+
'SEK', 'NOK', 'DKK', 'PLN', 'CZK', 'HUF', 'RUB', 'CNY',
|
122
|
+
'INR', 'KRW', 'SGD', 'HKD', 'THB', 'MXN', 'BRL', 'ZAR',
|
123
|
+
'TRY', 'ILS'
|
124
|
+
}
|
125
|
+
|
126
|
+
return base in major_currencies and quote in major_currencies
|
127
|
+
|
128
|
+
def get_all_supported_currencies(self) -> Dict[str, str]:
|
129
|
+
"""Get all major currencies supported by Yahoo Finance."""
|
130
|
+
return {
|
131
|
+
'USD': 'US Dollar',
|
132
|
+
'EUR': 'Euro',
|
133
|
+
'GBP': 'British Pound',
|
134
|
+
'JPY': 'Japanese Yen',
|
135
|
+
'CHF': 'Swiss Franc',
|
136
|
+
'CAD': 'Canadian Dollar',
|
137
|
+
'AUD': 'Australian Dollar',
|
138
|
+
'NZD': 'New Zealand Dollar',
|
139
|
+
'SEK': 'Swedish Krona',
|
140
|
+
'NOK': 'Norwegian Krone',
|
141
|
+
'DKK': 'Danish Krone',
|
142
|
+
'PLN': 'Polish Zloty',
|
143
|
+
'CZK': 'Czech Koruna',
|
144
|
+
'HUF': 'Hungarian Forint',
|
145
|
+
'RUB': 'Russian Ruble',
|
146
|
+
'CNY': 'Chinese Yuan',
|
147
|
+
'INR': 'Indian Rupee',
|
148
|
+
'KRW': 'South Korean Won',
|
149
|
+
'SGD': 'Singapore Dollar',
|
150
|
+
'HKD': 'Hong Kong Dollar',
|
151
|
+
'THB': 'Thai Baht',
|
152
|
+
'MXN': 'Mexican Peso',
|
153
|
+
'BRL': 'Brazilian Real',
|
154
|
+
'ZAR': 'South African Rand',
|
155
|
+
'TRY': 'Turkish Lira',
|
156
|
+
'ILS': 'Israeli Shekel'
|
157
|
+
}
|
@@ -5,10 +5,7 @@ Core currency conversion functionality.
|
|
5
5
|
from .models import (
|
6
6
|
Rate,
|
7
7
|
ConversionRequest,
|
8
|
-
ConversionResult
|
9
|
-
YFinanceCurrencies,
|
10
|
-
CoinGeckoCurrencies,
|
11
|
-
SupportedCurrencies
|
8
|
+
ConversionResult
|
12
9
|
)
|
13
10
|
|
14
11
|
from .exceptions import (
|
@@ -26,9 +23,6 @@ __all__ = [
|
|
26
23
|
'Rate',
|
27
24
|
'ConversionRequest',
|
28
25
|
'ConversionResult',
|
29
|
-
'YFinanceCurrencies',
|
30
|
-
'CoinGeckoCurrencies',
|
31
|
-
'SupportedCurrencies',
|
32
26
|
|
33
27
|
# Exceptions
|
34
28
|
'CurrencyError',
|