django-cfg 1.2.23__py3-none-any.whl → 1.2.25__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/knowbase/tasks/archive_tasks.py +6 -6
- django_cfg/apps/knowbase/tasks/document_processing.py +3 -3
- django_cfg/apps/knowbase/tasks/external_data_tasks.py +2 -2
- django_cfg/apps/knowbase/tasks/maintenance.py +3 -3
- django_cfg/apps/payments/config/__init__.py +15 -37
- django_cfg/apps/payments/config/module.py +30 -122
- django_cfg/apps/payments/config/providers.py +22 -0
- django_cfg/apps/payments/config/settings.py +53 -93
- django_cfg/apps/payments/config/utils.py +10 -156
- django_cfg/apps/payments/management/__init__.py +3 -0
- django_cfg/apps/payments/management/commands/README.md +178 -0
- django_cfg/apps/payments/management/commands/__init__.py +3 -0
- django_cfg/apps/payments/management/commands/currency_stats.py +323 -0
- django_cfg/apps/payments/management/commands/populate_currencies.py +246 -0
- django_cfg/apps/payments/management/commands/update_currencies.py +336 -0
- django_cfg/apps/payments/managers/currency_manager.py +65 -14
- django_cfg/apps/payments/middleware/api_access.py +33 -0
- django_cfg/apps/payments/migrations/0001_initial.py +94 -1
- django_cfg/apps/payments/models/payments.py +110 -0
- django_cfg/apps/payments/services/__init__.py +7 -1
- django_cfg/apps/payments/services/core/balance_service.py +14 -16
- django_cfg/apps/payments/services/core/fallback_service.py +432 -0
- django_cfg/apps/payments/services/core/payment_service.py +212 -29
- django_cfg/apps/payments/services/core/subscription_service.py +15 -17
- django_cfg/apps/payments/services/internal_types.py +31 -0
- django_cfg/apps/payments/services/monitoring/__init__.py +22 -0
- django_cfg/apps/payments/services/monitoring/api_schemas.py +222 -0
- django_cfg/apps/payments/services/monitoring/provider_health.py +372 -0
- django_cfg/apps/payments/services/providers/__init__.py +3 -0
- django_cfg/apps/payments/services/providers/cryptapi.py +14 -3
- django_cfg/apps/payments/services/providers/cryptomus.py +310 -0
- django_cfg/apps/payments/services/providers/registry.py +4 -0
- django_cfg/apps/payments/services/security/__init__.py +34 -0
- django_cfg/apps/payments/services/security/error_handler.py +637 -0
- django_cfg/apps/payments/services/security/payment_notifications.py +342 -0
- django_cfg/apps/payments/services/security/webhook_validator.py +475 -0
- django_cfg/apps/payments/signals/api_key_signals.py +10 -0
- django_cfg/apps/payments/signals/payment_signals.py +3 -2
- django_cfg/apps/payments/tasks/__init__.py +12 -0
- django_cfg/apps/payments/tasks/webhook_processing.py +177 -0
- django_cfg/apps/payments/utils/__init__.py +7 -4
- django_cfg/apps/payments/utils/billing_utils.py +342 -0
- django_cfg/apps/payments/utils/config_utils.py +2 -0
- django_cfg/apps/payments/views/payment_views.py +40 -2
- django_cfg/apps/payments/views/webhook_views.py +266 -0
- django_cfg/apps/payments/viewsets.py +65 -0
- django_cfg/cli/README.md +2 -2
- django_cfg/cli/commands/create_project.py +1 -1
- django_cfg/cli/commands/info.py +1 -1
- django_cfg/cli/main.py +1 -1
- django_cfg/cli/utils.py +5 -5
- django_cfg/core/config.py +18 -4
- django_cfg/models/payments.py +546 -0
- django_cfg/models/tasks.py +51 -2
- django_cfg/modules/base.py +11 -5
- django_cfg/modules/django_currency/README.md +104 -269
- django_cfg/modules/django_currency/__init__.py +99 -41
- django_cfg/modules/django_currency/clients/__init__.py +11 -0
- django_cfg/modules/django_currency/clients/coingecko_client.py +257 -0
- django_cfg/modules/django_currency/clients/yfinance_client.py +246 -0
- django_cfg/modules/django_currency/core/__init__.py +42 -0
- django_cfg/modules/django_currency/core/converter.py +169 -0
- django_cfg/modules/django_currency/core/exceptions.py +28 -0
- django_cfg/modules/django_currency/core/models.py +54 -0
- django_cfg/modules/django_currency/database/__init__.py +25 -0
- django_cfg/modules/django_currency/database/database_loader.py +507 -0
- django_cfg/modules/django_currency/utils/__init__.py +9 -0
- django_cfg/modules/django_currency/utils/cache.py +92 -0
- django_cfg/registry/core.py +10 -0
- django_cfg/template_archive/__init__.py +0 -0
- django_cfg/template_archive/django_sample.zip +0 -0
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/METADATA +10 -6
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/RECORD +77 -51
- django_cfg/apps/agents/examples/__init__.py +0 -3
- django_cfg/apps/agents/examples/simple_example.py +0 -161
- django_cfg/apps/knowbase/examples/__init__.py +0 -3
- django_cfg/apps/knowbase/examples/external_data_usage.py +0 -191
- django_cfg/apps/knowbase/mixins/examples/vehicle_model_example.py +0 -199
- django_cfg/modules/django_currency/cache.py +0 -430
- django_cfg/modules/django_currency/converter.py +0 -324
- django_cfg/modules/django_currency/service.py +0 -277
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/WHEEL +0 -0
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/entry_points.txt +0 -0
- {django_cfg-1.2.23.dist-info → django_cfg-1.2.25.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,257 @@
|
|
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
|
@@ -0,0 +1,246 @@
|
|
1
|
+
"""
|
2
|
+
YFinance client for fiat currencies only.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import logging
|
6
|
+
import yfinance as yf
|
7
|
+
from datetime import datetime
|
8
|
+
from typing import Set, Optional
|
9
|
+
from cachetools import TTLCache
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
11
|
+
import time
|
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 YFinanceClient:
|
21
|
+
"""Client for fetching fiat currency rates from Yahoo Finance."""
|
22
|
+
|
23
|
+
def __init__(self, cache_ttl: int = 3600):
|
24
|
+
"""Initialize YFinance client with TTL cache."""
|
25
|
+
self._currency_cache = TTLCache(maxsize=1, ttl=cache_ttl) # Cache currencies for 1 hour
|
26
|
+
self._rate_cache = TTLCache(maxsize=1000, ttl=300) # Cache rates for 5 minutes
|
27
|
+
|
28
|
+
def fetch_rate(self, base: str, quote: str) -> Rate:
|
29
|
+
"""
|
30
|
+
Fetch exchange rate from YFinance with caching.
|
31
|
+
|
32
|
+
Args:
|
33
|
+
base: Base currency code
|
34
|
+
quote: Quote currency code
|
35
|
+
|
36
|
+
Returns:
|
37
|
+
Rate object with exchange rate data
|
38
|
+
|
39
|
+
Raises:
|
40
|
+
RateFetchError: If rate fetch fails
|
41
|
+
"""
|
42
|
+
cache_key = f"{base}_{quote}"
|
43
|
+
|
44
|
+
# Try cache first
|
45
|
+
if cache_key in self._rate_cache:
|
46
|
+
logger.debug(f"Retrieved rate {base}/{quote} from cache")
|
47
|
+
return self._rate_cache[cache_key]
|
48
|
+
|
49
|
+
try:
|
50
|
+
rate = self._fetch_rate_with_retry(base, quote)
|
51
|
+
|
52
|
+
# Cache the result
|
53
|
+
self._rate_cache[cache_key] = rate
|
54
|
+
|
55
|
+
return rate
|
56
|
+
|
57
|
+
except Exception as e:
|
58
|
+
logger.error(f"Failed to fetch rate for {base}/{quote}: {e}")
|
59
|
+
raise RateFetchError(f"YFinance fetch failed: {e}")
|
60
|
+
|
61
|
+
@retry(
|
62
|
+
stop=stop_after_attempt(3),
|
63
|
+
wait=wait_exponential(multiplier=1, min=1, max=10),
|
64
|
+
retry=retry_if_exception_type((ConnectionError, TimeoutError, Exception)),
|
65
|
+
reraise=True
|
66
|
+
)
|
67
|
+
def _fetch_rate_with_retry(self, base: str, quote: str) -> Rate:
|
68
|
+
"""
|
69
|
+
Fetch rate with retry logic and exponential backoff.
|
70
|
+
|
71
|
+
Args:
|
72
|
+
base: Base currency code
|
73
|
+
quote: Quote currency code
|
74
|
+
|
75
|
+
Returns:
|
76
|
+
Rate object with exchange rate data
|
77
|
+
"""
|
78
|
+
symbol = self._build_symbol(base, quote)
|
79
|
+
logger.debug(f"Fetching rate for {symbol}")
|
80
|
+
|
81
|
+
ticker = yf.Ticker(symbol)
|
82
|
+
|
83
|
+
# Try to get current price from info
|
84
|
+
info = ticker.info
|
85
|
+
if 'regularMarketPrice' in info and info['regularMarketPrice']:
|
86
|
+
rate_value = float(info['regularMarketPrice'])
|
87
|
+
logger.debug(f"Got rate from info: {rate_value}")
|
88
|
+
else:
|
89
|
+
# Fallback to history
|
90
|
+
hist = ticker.history(period="1d")
|
91
|
+
if hist.empty:
|
92
|
+
raise RateFetchError(f"No data available for {symbol}")
|
93
|
+
rate_value = float(hist['Close'].iloc[-1])
|
94
|
+
logger.debug(f"Got rate from history: {rate_value}")
|
95
|
+
|
96
|
+
return Rate(
|
97
|
+
source="yfinance",
|
98
|
+
base_currency=base,
|
99
|
+
quote_currency=quote,
|
100
|
+
rate=rate_value,
|
101
|
+
timestamp=datetime.now()
|
102
|
+
)
|
103
|
+
|
104
|
+
def get_fiat_currencies(self) -> Set[str]:
|
105
|
+
"""Get all supported fiat currencies dynamically with caching."""
|
106
|
+
cache_key = "fiat_currencies"
|
107
|
+
|
108
|
+
# Try cache first
|
109
|
+
if cache_key in self._currency_cache:
|
110
|
+
logger.debug("Retrieved fiat currencies from cache")
|
111
|
+
return self._currency_cache[cache_key]
|
112
|
+
|
113
|
+
# Load currencies dynamically
|
114
|
+
currencies = self._discover_fiat_currencies()
|
115
|
+
|
116
|
+
# Cache the result
|
117
|
+
self._currency_cache[cache_key] = currencies
|
118
|
+
logger.info(f"Loaded and cached {len(currencies)} fiat currencies from YFinance")
|
119
|
+
|
120
|
+
return currencies
|
121
|
+
|
122
|
+
def _discover_fiat_currencies(self) -> Set[str]:
|
123
|
+
"""Discover available fiat currencies dynamically using YFinance with multithreading."""
|
124
|
+
currencies = set()
|
125
|
+
|
126
|
+
try:
|
127
|
+
# Known major currencies to test efficiently
|
128
|
+
test_currencies = [
|
129
|
+
"USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "KRW", "RUB",
|
130
|
+
"SGD", "HKD", "INR", "THB", "MYR", "PHP", "IDR", "VND", "BRL", "MXN",
|
131
|
+
"ZAR", "TRY", "PLN", "CZK", "HUF", "DKK", "SEK", "NOK", "NZD", "TWD"
|
132
|
+
]
|
133
|
+
|
134
|
+
logger.debug(f"Testing {len(test_currencies)} currency pairs with multithreading...")
|
135
|
+
|
136
|
+
@retry(
|
137
|
+
stop=stop_after_attempt(2),
|
138
|
+
wait=wait_exponential(multiplier=1, min=1, max=5),
|
139
|
+
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
|
140
|
+
reraise=False # Don't reraise for currency discovery
|
141
|
+
)
|
142
|
+
def test_currency(base_currency):
|
143
|
+
"""Test a single currency pair with retry logic."""
|
144
|
+
try:
|
145
|
+
# Test against USD
|
146
|
+
symbol = f"{base_currency}USD=X" if base_currency != "USD" else "EURUSD=X"
|
147
|
+
ticker = yf.Ticker(symbol)
|
148
|
+
info = ticker.info
|
149
|
+
|
150
|
+
# If ticker has valid data, return the currencies
|
151
|
+
if info and 'symbol' in info:
|
152
|
+
result_currencies = set()
|
153
|
+
if base_currency != "USD":
|
154
|
+
result_currencies.add(base_currency)
|
155
|
+
result_currencies.add("USD")
|
156
|
+
logger.debug(f"Verified: {symbol}")
|
157
|
+
return result_currencies
|
158
|
+
return set()
|
159
|
+
|
160
|
+
except Exception as e:
|
161
|
+
logger.debug(f"Failed to verify {base_currency}: {e}")
|
162
|
+
return set()
|
163
|
+
|
164
|
+
# Use ThreadPoolExecutor for parallel testing
|
165
|
+
with ThreadPoolExecutor(max_workers=5) as executor:
|
166
|
+
# Submit all tasks
|
167
|
+
future_to_currency = {
|
168
|
+
executor.submit(test_currency, currency): currency
|
169
|
+
for currency in test_currencies
|
170
|
+
}
|
171
|
+
|
172
|
+
# Collect results as they complete
|
173
|
+
for future in as_completed(future_to_currency):
|
174
|
+
try:
|
175
|
+
result = future.result(timeout=10) # 10 second timeout per request
|
176
|
+
currencies.update(result)
|
177
|
+
except Exception as e:
|
178
|
+
currency = future_to_currency[future]
|
179
|
+
logger.debug(f"Future failed for {currency}: {e}")
|
180
|
+
|
181
|
+
logger.info(f"Discovered {len(currencies)} fiat currencies dynamically with multithreading")
|
182
|
+
return currencies if currencies else {"USD", "EUR", "GBP", "JPY"} # Fallback to major currencies
|
183
|
+
|
184
|
+
except Exception as e:
|
185
|
+
logger.warning(f"Failed to discover currencies dynamically: {e}")
|
186
|
+
# Return minimal set as fallback
|
187
|
+
return {"USD", "EUR", "GBP", "JPY", "CAD", "AUD"}
|
188
|
+
|
189
|
+
def fetch_multiple_rates(self, pairs: list) -> dict:
|
190
|
+
"""
|
191
|
+
Fetch multiple currency rates in parallel.
|
192
|
+
|
193
|
+
Args:
|
194
|
+
pairs: List of tuples (base, quote) to fetch
|
195
|
+
|
196
|
+
Returns:
|
197
|
+
Dictionary mapping "BASE_QUOTE" to Rate objects
|
198
|
+
"""
|
199
|
+
results = {}
|
200
|
+
|
201
|
+
def fetch_single_rate(pair):
|
202
|
+
base, quote = pair
|
203
|
+
try:
|
204
|
+
rate = self.fetch_rate(base, quote)
|
205
|
+
return f"{base}_{quote}", rate
|
206
|
+
except Exception as e:
|
207
|
+
logger.warning(f"Failed to fetch {base}/{quote}: {e}")
|
208
|
+
return f"{base}_{quote}", None
|
209
|
+
|
210
|
+
# Use ThreadPoolExecutor for parallel fetching
|
211
|
+
with ThreadPoolExecutor(max_workers=8) as executor: # YFinance can handle more parallel requests
|
212
|
+
future_to_pair = {executor.submit(fetch_single_rate, pair): pair for pair in pairs}
|
213
|
+
|
214
|
+
for future in as_completed(future_to_pair):
|
215
|
+
try:
|
216
|
+
key, rate = future.result(timeout=15)
|
217
|
+
if rate:
|
218
|
+
results[key] = rate
|
219
|
+
except Exception as e:
|
220
|
+
pair = future_to_pair[future]
|
221
|
+
logger.error(f"Failed to fetch rate for {pair}: {e}")
|
222
|
+
|
223
|
+
logger.info(f"Successfully fetched {len(results)}/{len(pairs)} rates")
|
224
|
+
return results
|
225
|
+
|
226
|
+
def _build_symbol(self, base: str, quote: str) -> str:
|
227
|
+
"""Build YFinance symbol from currency pair."""
|
228
|
+
base = base.upper()
|
229
|
+
quote = quote.upper()
|
230
|
+
|
231
|
+
# Only handle fiat pairs
|
232
|
+
fiat_currencies = self.get_fiat_currencies()
|
233
|
+
if base in fiat_currencies and quote in fiat_currencies:
|
234
|
+
if base == quote:
|
235
|
+
raise RateFetchError("Same currency conversion not needed")
|
236
|
+
return f"{base}{quote}=X"
|
237
|
+
|
238
|
+
raise RateFetchError(f"Unsupported fiat currency pair: {base}/{quote}")
|
239
|
+
|
240
|
+
def supports_pair(self, base: str, quote: str) -> bool:
|
241
|
+
"""Check if fiat currency pair is supported."""
|
242
|
+
base = base.upper()
|
243
|
+
quote = quote.upper()
|
244
|
+
|
245
|
+
fiat_currencies = self.get_fiat_currencies()
|
246
|
+
return base in fiat_currencies and quote in fiat_currencies and base != quote
|
@@ -0,0 +1,42 @@
|
|
1
|
+
"""
|
2
|
+
Core currency conversion functionality.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from .models import (
|
6
|
+
Rate,
|
7
|
+
ConversionRequest,
|
8
|
+
ConversionResult,
|
9
|
+
YFinanceCurrencies,
|
10
|
+
CoinGeckoCurrencies,
|
11
|
+
SupportedCurrencies
|
12
|
+
)
|
13
|
+
|
14
|
+
from .exceptions import (
|
15
|
+
CurrencyError,
|
16
|
+
CurrencyNotFoundError,
|
17
|
+
RateFetchError,
|
18
|
+
ConversionError,
|
19
|
+
CacheError
|
20
|
+
)
|
21
|
+
|
22
|
+
from .converter import CurrencyConverter
|
23
|
+
|
24
|
+
__all__ = [
|
25
|
+
# Models
|
26
|
+
'Rate',
|
27
|
+
'ConversionRequest',
|
28
|
+
'ConversionResult',
|
29
|
+
'YFinanceCurrencies',
|
30
|
+
'CoinGeckoCurrencies',
|
31
|
+
'SupportedCurrencies',
|
32
|
+
|
33
|
+
# Exceptions
|
34
|
+
'CurrencyError',
|
35
|
+
'CurrencyNotFoundError',
|
36
|
+
'RateFetchError',
|
37
|
+
'ConversionError',
|
38
|
+
'CacheError',
|
39
|
+
|
40
|
+
# Main converter
|
41
|
+
'CurrencyConverter'
|
42
|
+
]
|