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.
Files changed (138) hide show
  1. django_cfg/__init__.py +1 -1
  2. django_cfg/apps/payments/admin/__init__.py +3 -2
  3. django_cfg/apps/payments/admin/balance_admin.py +18 -18
  4. django_cfg/apps/payments/admin/currencies_admin.py +319 -131
  5. django_cfg/apps/payments/admin/payments_admin.py +15 -4
  6. django_cfg/apps/payments/config/module.py +2 -2
  7. django_cfg/apps/payments/config/utils.py +2 -2
  8. django_cfg/apps/payments/decorators.py +2 -2
  9. django_cfg/apps/payments/management/commands/README.md +95 -127
  10. django_cfg/apps/payments/management/commands/currency_stats.py +5 -24
  11. django_cfg/apps/payments/management/commands/manage_currencies.py +229 -0
  12. django_cfg/apps/payments/management/commands/manage_providers.py +235 -0
  13. django_cfg/apps/payments/managers/__init__.py +3 -2
  14. django_cfg/apps/payments/managers/balance_manager.py +2 -2
  15. django_cfg/apps/payments/managers/currency_manager.py +272 -49
  16. django_cfg/apps/payments/managers/payment_manager.py +161 -13
  17. django_cfg/apps/payments/middleware/api_access.py +2 -2
  18. django_cfg/apps/payments/middleware/rate_limiting.py +8 -18
  19. django_cfg/apps/payments/middleware/usage_tracking.py +20 -17
  20. django_cfg/apps/payments/migrations/0002_network_providercurrency_and_more.py +241 -0
  21. django_cfg/apps/payments/migrations/0003_add_usd_rate_cache.py +30 -0
  22. django_cfg/apps/payments/models/__init__.py +3 -2
  23. django_cfg/apps/payments/models/currencies.py +187 -71
  24. django_cfg/apps/payments/models/payments.py +3 -2
  25. django_cfg/apps/payments/serializers/__init__.py +3 -2
  26. django_cfg/apps/payments/serializers/currencies.py +20 -12
  27. django_cfg/apps/payments/services/cache/simple_cache.py +2 -2
  28. django_cfg/apps/payments/services/core/balance_service.py +2 -2
  29. django_cfg/apps/payments/services/core/fallback_service.py +2 -2
  30. django_cfg/apps/payments/services/core/payment_service.py +3 -6
  31. django_cfg/apps/payments/services/core/subscription_service.py +4 -7
  32. django_cfg/apps/payments/services/internal_types.py +171 -7
  33. django_cfg/apps/payments/services/monitoring/api_schemas.py +58 -204
  34. django_cfg/apps/payments/services/monitoring/provider_health.py +2 -2
  35. django_cfg/apps/payments/services/providers/base.py +144 -43
  36. django_cfg/apps/payments/services/providers/cryptapi/__init__.py +4 -0
  37. django_cfg/apps/payments/services/providers/cryptapi/config.py +8 -0
  38. django_cfg/apps/payments/services/providers/cryptapi/models.py +192 -0
  39. django_cfg/apps/payments/services/providers/cryptapi/provider.py +439 -0
  40. django_cfg/apps/payments/services/providers/cryptomus/__init__.py +4 -0
  41. django_cfg/apps/payments/services/providers/cryptomus/models.py +176 -0
  42. django_cfg/apps/payments/services/providers/cryptomus/provider.py +429 -0
  43. django_cfg/apps/payments/services/providers/cryptomus/provider_v2.py +564 -0
  44. django_cfg/apps/payments/services/providers/models/__init__.py +34 -0
  45. django_cfg/apps/payments/services/providers/models/currencies.py +190 -0
  46. django_cfg/apps/payments/services/providers/nowpayments/__init__.py +4 -0
  47. django_cfg/apps/payments/services/providers/nowpayments/models.py +196 -0
  48. django_cfg/apps/payments/services/providers/nowpayments/provider.py +380 -0
  49. django_cfg/apps/payments/services/providers/registry.py +294 -11
  50. django_cfg/apps/payments/services/providers/stripe/__init__.py +4 -0
  51. django_cfg/apps/payments/services/providers/stripe/models.py +184 -0
  52. django_cfg/apps/payments/services/providers/stripe/provider.py +109 -0
  53. django_cfg/apps/payments/services/security/error_handler.py +6 -8
  54. django_cfg/apps/payments/services/security/payment_notifications.py +2 -2
  55. django_cfg/apps/payments/services/security/webhook_validator.py +3 -4
  56. django_cfg/apps/payments/signals/api_key_signals.py +2 -2
  57. django_cfg/apps/payments/signals/payment_signals.py +11 -5
  58. django_cfg/apps/payments/signals/subscription_signals.py +2 -2
  59. django_cfg/apps/payments/static/payments/css/payments.css +340 -0
  60. django_cfg/apps/payments/static/payments/js/notifications.js +202 -0
  61. django_cfg/apps/payments/static/payments/js/payment-utils.js +318 -0
  62. django_cfg/apps/payments/static/payments/js/theme.js +86 -0
  63. django_cfg/apps/payments/tasks/webhook_processing.py +2 -2
  64. django_cfg/apps/payments/templates/admin/payments/currency/change_list.html +50 -0
  65. django_cfg/apps/payments/templates/payments/base.html +182 -0
  66. django_cfg/apps/payments/templates/payments/components/payment_card.html +201 -0
  67. django_cfg/apps/payments/templates/payments/components/payment_qr_code.html +109 -0
  68. django_cfg/apps/payments/templates/payments/components/progress_bar.html +43 -0
  69. django_cfg/apps/payments/templates/payments/components/provider_stats.html +40 -0
  70. django_cfg/apps/payments/templates/payments/components/status_badge.html +34 -0
  71. django_cfg/apps/payments/templates/payments/components/status_overview.html +148 -0
  72. django_cfg/apps/payments/templates/payments/dashboard.html +258 -0
  73. django_cfg/apps/payments/templates/payments/dashboard_simple_test.html +35 -0
  74. django_cfg/apps/payments/templates/payments/payment_create.html +579 -0
  75. django_cfg/apps/payments/templates/payments/payment_detail.html +373 -0
  76. django_cfg/apps/payments/templates/payments/payment_list.html +354 -0
  77. django_cfg/apps/payments/templates/payments/stats.html +261 -0
  78. django_cfg/apps/payments/templates/payments/test.html +213 -0
  79. django_cfg/apps/payments/templatetags/__init__.py +1 -0
  80. django_cfg/apps/payments/templatetags/payments_tags.py +315 -0
  81. django_cfg/apps/payments/urls.py +3 -1
  82. django_cfg/apps/payments/urls_admin.py +58 -0
  83. django_cfg/apps/payments/utils/__init__.py +1 -3
  84. django_cfg/apps/payments/utils/billing_utils.py +2 -2
  85. django_cfg/apps/payments/utils/config_utils.py +2 -8
  86. django_cfg/apps/payments/utils/validation_utils.py +2 -2
  87. django_cfg/apps/payments/views/__init__.py +3 -2
  88. django_cfg/apps/payments/views/currency_views.py +31 -20
  89. django_cfg/apps/payments/views/payment_views.py +2 -2
  90. django_cfg/apps/payments/views/templates/__init__.py +25 -0
  91. django_cfg/apps/payments/views/templates/ajax.py +451 -0
  92. django_cfg/apps/payments/views/templates/base.py +212 -0
  93. django_cfg/apps/payments/views/templates/dashboard.py +60 -0
  94. django_cfg/apps/payments/views/templates/payment_detail.py +102 -0
  95. django_cfg/apps/payments/views/templates/payment_management.py +158 -0
  96. django_cfg/apps/payments/views/templates/qr_code.py +174 -0
  97. django_cfg/apps/payments/views/templates/stats.py +244 -0
  98. django_cfg/apps/payments/views/templates/utils.py +181 -0
  99. django_cfg/apps/payments/views/webhook_views.py +2 -2
  100. django_cfg/apps/payments/viewsets.py +3 -2
  101. django_cfg/apps/tasks/urls.py +0 -2
  102. django_cfg/apps/tasks/urls_admin.py +14 -0
  103. django_cfg/apps/urls.py +6 -3
  104. django_cfg/core/config.py +35 -0
  105. django_cfg/models/payments.py +2 -8
  106. django_cfg/modules/django_currency/__init__.py +16 -11
  107. django_cfg/modules/django_currency/clients/__init__.py +4 -4
  108. django_cfg/modules/django_currency/clients/coinpaprika_client.py +289 -0
  109. django_cfg/modules/django_currency/clients/yahoo_client.py +157 -0
  110. django_cfg/modules/django_currency/core/__init__.py +1 -7
  111. django_cfg/modules/django_currency/core/converter.py +18 -23
  112. django_cfg/modules/django_currency/core/models.py +122 -11
  113. django_cfg/modules/django_currency/database/__init__.py +4 -4
  114. django_cfg/modules/django_currency/database/database_loader.py +190 -309
  115. django_cfg/modules/django_unfold/dashboard.py +7 -2
  116. django_cfg/registry/core.py +1 -0
  117. django_cfg/template_archive/.gitignore +1 -0
  118. django_cfg/template_archive/django_sample.zip +0 -0
  119. django_cfg/templates/admin/components/action_grid.html +9 -9
  120. django_cfg/templates/admin/components/metric_card.html +5 -5
  121. django_cfg/templates/admin/components/status_badge.html +2 -2
  122. django_cfg/templates/admin/layouts/dashboard_with_tabs.html +152 -24
  123. django_cfg/templates/admin/snippets/components/quick_actions.html +3 -3
  124. django_cfg/templates/admin/snippets/components/system_health.html +1 -1
  125. django_cfg/templates/admin/snippets/tabs/overview_tab.html +49 -52
  126. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/METADATA +13 -18
  127. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/RECORD +130 -83
  128. django_cfg/apps/payments/management/commands/populate_currencies.py +0 -246
  129. django_cfg/apps/payments/management/commands/update_currencies.py +0 -336
  130. django_cfg/apps/payments/services/providers/cryptapi.py +0 -273
  131. django_cfg/apps/payments/services/providers/cryptomus.py +0 -310
  132. django_cfg/apps/payments/services/providers/nowpayments.py +0 -293
  133. django_cfg/apps/payments/services/validators/__init__.py +0 -8
  134. django_cfg/modules/django_currency/clients/coingecko_client.py +0 -257
  135. django_cfg/modules/django_currency/clients/yfinance_client.py +0 -246
  136. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/WHEEL +0 -0
  137. {django_cfg-1.2.27.dist-info → django_cfg-1.2.31.dist-info}/entry_points.txt +0 -0
  138. {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
@@ -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 YFinanceClient, CoinGeckoClient
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 = CurrencyConverter()
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 = CurrencyConverter()
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
- "YFinanceClient",
94
- "CoinGeckoClient",
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 .yfinance_client import YFinanceClient
6
- from .coingecko_client import CoinGeckoClient
5
+ from .yahoo_client import YahooFinanceClient
6
+ from .coinpaprika_client import CoinPaprikaClient
7
7
 
8
8
  __all__ = [
9
- 'YFinanceClient',
10
- 'CoinGeckoClient'
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',