Sadeep Sachintha commited on
Commit ·
f53ddba
1
Parent(s): 09f1e96
feat: implement key-less FX service with native CBSL web scraping and fallback cache logic
Browse files- README.md +7 -9
- main.py +1 -1
- rate_cache.json +20 -20
- scratch/test_fx_service.py +1 -12
- services/fx_service.py +21 -69
README.md
CHANGED
|
@@ -21,9 +21,9 @@ FlyRates is a high-availability Telegram bot designed for real-time currency exc
|
|
| 21 |
- **Scheduled Background Tasks:** Powered by APScheduler for reliable background task execution.
|
| 22 |
- **Database Flexibility:** Uses SQLAlchemy (supports both SQLite and PostgreSQL).
|
| 23 |
|
| 24 |
-
##
|
| 25 |
|
| 26 |
-
To permanently resolve external API rate-limiting errors (`status 429`) and guarantee 100% uptime for rate tracking, FlyRates incorporates a custom **
|
| 27 |
|
| 28 |
1. **Official CBSL Web Scraper (Preferred Source for LKR):**
|
| 29 |
- **How it Works:** Scrapes daily exchange rates directly from the official Central Bank of Sri Lanka website iframe endpoint (`/cbsl_custom/exrates/exrates.php`) using Python's built-in, lightweight `html.parser.HTMLParser`.
|
|
@@ -32,19 +32,17 @@ To permanently resolve external API rate-limiting errors (`status 429`) and guar
|
|
| 32 |
|
| 33 |
2. **Persistent Disk Caching (`rate_cache.json`):**
|
| 34 |
- Saves exchange rates to a local `rate_cache.json` file in the workspace root, preserving the cache across container redeployments and restarts.
|
| 35 |
-
- Using a **1-hour TTL**, it completely bypasses
|
| 36 |
|
| 37 |
3. **Bridge-Rate Derivation:**
|
| 38 |
- Cross-currency rates (e.g. `USD` to `EUR`) that do not directly involve LKR are mathematically derived on the fly using LKR as a bridge from the official CBSL rates:
|
| 39 |
$$\text{rate}(\text{USD} \rightarrow \text{EUR}) = \frac{\text{rate}(\text{USD} \rightarrow \text{LKR})}{\text{rate}(\text{EUR} \rightarrow \text{LKR})}$$
|
| 40 |
-
- This makes the entire bot and dashboard **100% functional
|
| 41 |
|
| 42 |
-
4. **
|
| 43 |
- **Tier 1:** Valid Cache (Memory/Disk `rate_cache.json`)
|
| 44 |
-
- **Tier 2:** Official CBSL Scraper (
|
| 45 |
-
- **Tier 3:**
|
| 46 |
-
- **Tier 4:** Key-less Free Fallback API (`open.er-api.com`)
|
| 47 |
-
- **Tier 5:** Stale Cache Fallback (serves expired cached rates if all networks/APIs are down)
|
| 48 |
|
| 49 |
5. **Asynchronous Concurrency:**
|
| 50 |
- The `/api/stats` endpoint uses `asyncio.gather` to retrieve live exchange rates concurrently, boosting cold-cache performance by over **5x**.
|
|
|
|
| 21 |
- **Scheduled Background Tasks:** Powered by APScheduler for reliable background task execution.
|
| 22 |
- **Database Flexibility:** Uses SQLAlchemy (supports both SQLite and PostgreSQL).
|
| 23 |
|
| 24 |
+
## Key-less Web-Scraping FX Service & CBSL Web Scraper 💱
|
| 25 |
|
| 26 |
+
To permanently resolve external API rate-limiting errors (`status 429`) and guarantee 100% uptime for rate tracking, FlyRates incorporates a custom **100% Web-Scraping FX Rates Architecture**:
|
| 27 |
|
| 28 |
1. **Official CBSL Web Scraper (Preferred Source for LKR):**
|
| 29 |
- **How it Works:** Scrapes daily exchange rates directly from the official Central Bank of Sri Lanka website iframe endpoint (`/cbsl_custom/exrates/exrates.php`) using Python's built-in, lightweight `html.parser.HTMLParser`.
|
|
|
|
| 32 |
|
| 33 |
2. **Persistent Disk Caching (`rate_cache.json`):**
|
| 34 |
- Saves exchange rates to a local `rate_cache.json` file in the workspace root, preserving the cache across container redeployments and restarts.
|
| 35 |
+
- Using a **1-hour TTL**, it completely bypasses external dependencies, serving subsequent dashboard and bot queries instantaneously in **under 0.03 milliseconds** from memory.
|
| 36 |
|
| 37 |
3. **Bridge-Rate Derivation:**
|
| 38 |
- Cross-currency rates (e.g. `USD` to `EUR`) that do not directly involve LKR are mathematically derived on the fly using LKR as a bridge from the official CBSL rates:
|
| 39 |
$$\text{rate}(\text{USD} \rightarrow \text{EUR}) = \frac{\text{rate}(\text{USD} \rightarrow \text{LKR})}{\text{rate}(\text{EUR} \rightarrow \text{LKR})}$$
|
| 40 |
+
- This makes the entire bot and dashboard **100% functional, key-less, and free**!
|
| 41 |
|
| 42 |
+
4. **Robust Fallback Hierarchy:**
|
| 43 |
- **Tier 1:** Valid Cache (Memory/Disk `rate_cache.json`)
|
| 44 |
+
- **Tier 2:** Official CBSL Scraper (real-time scraping and dynamic bridging)
|
| 45 |
+
- **Tier 3:** Stale Cache Fallback (serves expired cached rates or mathematically derives stale bridge rates if CBSL is unreachable)
|
|
|
|
|
|
|
| 46 |
|
| 47 |
5. **Asynchronous Concurrency:**
|
| 48 |
- The `/api/stats` endpoint uses `asyncio.gather` to retrieve live exchange rates concurrently, boosting cold-cache performance by over **5x**.
|
main.py
CHANGED
|
@@ -58,7 +58,7 @@ async def lifespan(app: FastAPI):
|
|
| 58 |
diagnostic_results.append("Starting resolution & connection diagnostics...")
|
| 59 |
|
| 60 |
# Check DNS resolution
|
| 61 |
-
for host in ["api.telegram.org", "
|
| 62 |
try:
|
| 63 |
ips = socket.getaddrinfo(host, 443)
|
| 64 |
diagnostic_results.append(f"DNS Resolution for {host}: {ips}")
|
|
|
|
| 58 |
diagnostic_results.append("Starting resolution & connection diagnostics...")
|
| 59 |
|
| 60 |
# Check DNS resolution
|
| 61 |
+
for host in ["api.telegram.org", "www.cbsl.gov.lk"]:
|
| 62 |
try:
|
| 63 |
ips = socket.getaddrinfo(host, 443)
|
| 64 |
diagnostic_results.append(f"DNS Resolution for {host}: {ips}")
|
rate_cache.json
CHANGED
|
@@ -1,82 +1,82 @@
|
|
| 1 |
{
|
| 2 |
"USD_LKR": {
|
| 3 |
"rate": 324.7184,
|
| 4 |
-
"timestamp":
|
| 5 |
},
|
| 6 |
"LKR_USD": {
|
| 7 |
"rate": 0.003079591424446536,
|
| 8 |
-
"timestamp":
|
| 9 |
},
|
| 10 |
"EUR_LKR": {
|
| 11 |
"rate": 378.3294,
|
| 12 |
-
"timestamp":
|
| 13 |
},
|
| 14 |
"LKR_EUR": {
|
| 15 |
"rate": 0.0026431992861247365,
|
| 16 |
-
"timestamp":
|
| 17 |
},
|
| 18 |
"GBP_LKR": {
|
| 19 |
"rate": 434.116,
|
| 20 |
-
"timestamp":
|
| 21 |
},
|
| 22 |
"LKR_GBP": {
|
| 23 |
"rate": 0.0023035317749173032,
|
| 24 |
-
"timestamp":
|
| 25 |
},
|
| 26 |
"AUD_LKR": {
|
| 27 |
"rate": 233.4401,
|
| 28 |
-
"timestamp":
|
| 29 |
},
|
| 30 |
"LKR_AUD": {
|
| 31 |
"rate": 0.0042837541622026375,
|
| 32 |
-
"timestamp":
|
| 33 |
},
|
| 34 |
"JPY_LKR": {
|
| 35 |
"rate": 2.0486,
|
| 36 |
-
"timestamp":
|
| 37 |
},
|
| 38 |
"LKR_JPY": {
|
| 39 |
"rate": 0.48813824074978035,
|
| 40 |
-
"timestamp":
|
| 41 |
},
|
| 42 |
"AED_LKR": {
|
| 43 |
"rate": 88.4045,
|
| 44 |
-
"timestamp":
|
| 45 |
},
|
| 46 |
"LKR_AED": {
|
| 47 |
"rate": 0.011311641375721824,
|
| 48 |
-
"timestamp":
|
| 49 |
},
|
| 50 |
"SAR_LKR": {
|
| 51 |
"rate": 86.5316,
|
| 52 |
-
"timestamp":
|
| 53 |
},
|
| 54 |
"LKR_SAR": {
|
| 55 |
"rate": 0.011556471855368443,
|
| 56 |
-
"timestamp":
|
| 57 |
},
|
| 58 |
"INR_LKR": {
|
| 59 |
"rate": 3.3908,
|
| 60 |
-
"timestamp":
|
| 61 |
},
|
| 62 |
"LKR_INR": {
|
| 63 |
"rate": 0.29491565412292087,
|
| 64 |
-
"timestamp":
|
| 65 |
},
|
| 66 |
"CNY_LKR": {
|
| 67 |
"rate": 47.7682,
|
| 68 |
-
"timestamp":
|
| 69 |
},
|
| 70 |
"LKR_CNY": {
|
| 71 |
"rate": 0.020934429180919523,
|
| 72 |
-
"timestamp":
|
| 73 |
},
|
| 74 |
"QAR_LKR": {
|
| 75 |
"rate": 89.0738,
|
| 76 |
-
"timestamp":
|
| 77 |
},
|
| 78 |
"LKR_QAR": {
|
| 79 |
"rate": 0.011226645770136672,
|
| 80 |
-
"timestamp":
|
| 81 |
}
|
| 82 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"USD_LKR": {
|
| 3 |
"rate": 324.7184,
|
| 4 |
+
"timestamp": 1779042576.8926218
|
| 5 |
},
|
| 6 |
"LKR_USD": {
|
| 7 |
"rate": 0.003079591424446536,
|
| 8 |
+
"timestamp": 1779042576.8926272
|
| 9 |
},
|
| 10 |
"EUR_LKR": {
|
| 11 |
"rate": 378.3294,
|
| 12 |
+
"timestamp": 1779042576.8926556
|
| 13 |
},
|
| 14 |
"LKR_EUR": {
|
| 15 |
"rate": 0.0026431992861247365,
|
| 16 |
+
"timestamp": 1779042576.8926578
|
| 17 |
},
|
| 18 |
"GBP_LKR": {
|
| 19 |
"rate": 434.116,
|
| 20 |
+
"timestamp": 1779042576.8926651
|
| 21 |
},
|
| 22 |
"LKR_GBP": {
|
| 23 |
"rate": 0.0023035317749173032,
|
| 24 |
+
"timestamp": 1779042576.8926666
|
| 25 |
},
|
| 26 |
"AUD_LKR": {
|
| 27 |
"rate": 233.4401,
|
| 28 |
+
"timestamp": 1779042576.8926735
|
| 29 |
},
|
| 30 |
"LKR_AUD": {
|
| 31 |
"rate": 0.0042837541622026375,
|
| 32 |
+
"timestamp": 1779042576.892675
|
| 33 |
},
|
| 34 |
"JPY_LKR": {
|
| 35 |
"rate": 2.0486,
|
| 36 |
+
"timestamp": 1779042576.892681
|
| 37 |
},
|
| 38 |
"LKR_JPY": {
|
| 39 |
"rate": 0.48813824074978035,
|
| 40 |
+
"timestamp": 1779042576.8926826
|
| 41 |
},
|
| 42 |
"AED_LKR": {
|
| 43 |
"rate": 88.4045,
|
| 44 |
+
"timestamp": 1779042576.8926892
|
| 45 |
},
|
| 46 |
"LKR_AED": {
|
| 47 |
"rate": 0.011311641375721824,
|
| 48 |
+
"timestamp": 1779042576.892691
|
| 49 |
},
|
| 50 |
"SAR_LKR": {
|
| 51 |
"rate": 86.5316,
|
| 52 |
+
"timestamp": 1779042576.8926976
|
| 53 |
},
|
| 54 |
"LKR_SAR": {
|
| 55 |
"rate": 0.011556471855368443,
|
| 56 |
+
"timestamp": 1779042576.8926988
|
| 57 |
},
|
| 58 |
"INR_LKR": {
|
| 59 |
"rate": 3.3908,
|
| 60 |
+
"timestamp": 1779042576.8927054
|
| 61 |
},
|
| 62 |
"LKR_INR": {
|
| 63 |
"rate": 0.29491565412292087,
|
| 64 |
+
"timestamp": 1779042576.8927069
|
| 65 |
},
|
| 66 |
"CNY_LKR": {
|
| 67 |
"rate": 47.7682,
|
| 68 |
+
"timestamp": 1779042576.8927135
|
| 69 |
},
|
| 70 |
"LKR_CNY": {
|
| 71 |
"rate": 0.020934429180919523,
|
| 72 |
+
"timestamp": 1779042576.892715
|
| 73 |
},
|
| 74 |
"QAR_LKR": {
|
| 75 |
"rate": 89.0738,
|
| 76 |
+
"timestamp": 1779042576.8927214
|
| 77 |
},
|
| 78 |
"LKR_QAR": {
|
| 79 |
"rate": 0.011226645770136672,
|
| 80 |
+
"timestamp": 1779042576.8927226
|
| 81 |
}
|
| 82 |
}
|
scratch/test_fx_service.py
CHANGED
|
@@ -74,17 +74,8 @@ async def test_fx_service():
|
|
| 74 |
print(" [Simulated Scraper Failure]")
|
| 75 |
return False
|
| 76 |
fx_service.fetch_cbsl_rates = broken_fetch
|
| 77 |
-
|
| 78 |
-
# We will also temporarily unset API key and break ER-API call
|
| 79 |
-
original_api_key = fx_service.api_key
|
| 80 |
-
fx_service.api_key = "" # Unset key
|
| 81 |
-
original_fetch_api = fx_service.fetch_exchangerate_api
|
| 82 |
-
async def broken_api(base):
|
| 83 |
-
print(" [Simulated API Failure]")
|
| 84 |
-
return False
|
| 85 |
-
fx_service.fetch_exchangerate_api = broken_api
|
| 86 |
|
| 87 |
-
print("Cache is expired. External
|
| 88 |
start_time = time.time()
|
| 89 |
stale_rate = await fx_service.get_rate("USD", "LKR")
|
| 90 |
elapsed = time.time() - start_time
|
|
@@ -94,8 +85,6 @@ async def test_fx_service():
|
|
| 94 |
|
| 95 |
# Restore original methods
|
| 96 |
fx_service.fetch_cbsl_rates = original_fetch
|
| 97 |
-
fx_service.api_key = original_api_key
|
| 98 |
-
fx_service.fetch_exchangerate_api = original_fetch_api
|
| 99 |
|
| 100 |
print("==================================================")
|
| 101 |
print(" ALL FXSERVICE TESTS PASSED!")
|
|
|
|
| 74 |
print(" [Simulated Scraper Failure]")
|
| 75 |
return False
|
| 76 |
fx_service.fetch_cbsl_rates = broken_fetch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
+
print("Cache is expired. External scraper is simulated as down.")
|
| 79 |
start_time = time.time()
|
| 80 |
stale_rate = await fx_service.get_rate("USD", "LKR")
|
| 81 |
elapsed = time.time() - start_time
|
|
|
|
| 85 |
|
| 86 |
# Restore original methods
|
| 87 |
fx_service.fetch_cbsl_rates = original_fetch
|
|
|
|
|
|
|
| 88 |
|
| 89 |
print("==================================================")
|
| 90 |
print(" ALL FXSERVICE TESTS PASSED!")
|
services/fx_service.py
CHANGED
|
@@ -63,9 +63,6 @@ class CBSLTableParser(HTMLParser):
|
|
| 63 |
|
| 64 |
class FXService:
|
| 65 |
def __init__(self):
|
| 66 |
-
self.api_key = settings.fx_api_key
|
| 67 |
-
# Premium exchangerate-api URL
|
| 68 |
-
self.base_url = f"https://v6.exchangerate-api.com/v6/{self.api_key}"
|
| 69 |
self.cache: Dict[str, Dict] = {}
|
| 70 |
self.load_cache()
|
| 71 |
|
|
@@ -227,50 +224,6 @@ class FXService:
|
|
| 227 |
logger.error(f"Global exception in fetch_cbsl_rates: {e}")
|
| 228 |
return False
|
| 229 |
|
| 230 |
-
async def fetch_exchangerate_api(self, base_currency: str) -> bool:
|
| 231 |
-
"""
|
| 232 |
-
Fetches latest exchange rates from ExchangeRate-API.
|
| 233 |
-
First tries the user's custom premium key. If rate-limited or fails, automatically
|
| 234 |
-
falls back to the free, key-less open endpoint (open.er-api.com).
|
| 235 |
-
"""
|
| 236 |
-
base_currency = base_currency.upper()
|
| 237 |
-
connector = aiohttp.TCPConnector(family=socket.AF_INET)
|
| 238 |
-
|
| 239 |
-
# 1. Custom premium key URL
|
| 240 |
-
premium_url = f"{self.base_url}/latest/{base_currency}"
|
| 241 |
-
# 2. Free open fallback URL
|
| 242 |
-
fallback_url = f"https://open.er-api.com/v6/latest/{base_currency}"
|
| 243 |
-
|
| 244 |
-
endpoints = []
|
| 245 |
-
if self.api_key:
|
| 246 |
-
endpoints.append(("Premium Key", premium_url, "conversion_rates"))
|
| 247 |
-
endpoints.append(("Free Open Fallback", fallback_url, "rates"))
|
| 248 |
-
|
| 249 |
-
async with aiohttp.ClientSession(connector=connector) as session:
|
| 250 |
-
for name, url, rates_key in endpoints:
|
| 251 |
-
logger.info(f"Fetching rates using {name} from {url}...")
|
| 252 |
-
try:
|
| 253 |
-
async with session.get(url, timeout=10) as response:
|
| 254 |
-
if response.status == 200:
|
| 255 |
-
data = await response.json()
|
| 256 |
-
rates = data.get(rates_key, {})
|
| 257 |
-
if rates:
|
| 258 |
-
logger.info(f"Successfully fetched {len(rates)} rates using {name} for base {base_currency}")
|
| 259 |
-
for target, val in rates.items():
|
| 260 |
-
target = target.upper()
|
| 261 |
-
if target in ALLOWED_CURRENCIES:
|
| 262 |
-
self.update_cache_entry(base_currency, target, val)
|
| 263 |
-
self.save_cache()
|
| 264 |
-
return True
|
| 265 |
-
elif response.status == 429:
|
| 266 |
-
logger.error(f"{name} rate-limited! Status: 429 Too Many Requests.")
|
| 267 |
-
else:
|
| 268 |
-
logger.error(f"{name} returned error status: {response.status}")
|
| 269 |
-
except Exception as e:
|
| 270 |
-
logger.error(f"Exception fetching {name} for base {base_currency}: {e}")
|
| 271 |
-
|
| 272 |
-
return False
|
| 273 |
-
|
| 274 |
async def get_rate(self, base_currency: str, target_currency: str) -> Optional[float]:
|
| 275 |
"""
|
| 276 |
Retrieves real-time exchange rate with support for in-memory caching,
|
|
@@ -304,29 +257,17 @@ class FXService:
|
|
| 304 |
return derived
|
| 305 |
|
| 306 |
# 4. Cache MISS: Retrieve fresh rates.
|
| 307 |
-
#
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
if res is not None:
|
| 314 |
-
return res
|
| 315 |
-
|
| 316 |
-
# 5. Fallback/Alternative: If CBSL failed or we need a non-LKR rate and bridge was not in cache,
|
| 317 |
-
# fetch from ExchangeRate-API.
|
| 318 |
-
logger.info(f"Fetching from ExchangeRate-API for {base}...")
|
| 319 |
-
api_success = await self.fetch_exchangerate_api(base)
|
| 320 |
-
if api_success:
|
| 321 |
res = self.get_cached_rate(base, target)
|
| 322 |
if res is not None:
|
| 323 |
return res
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
if base != "LKR" and target != "LKR":
|
| 327 |
-
logger.info("Attempting CBSL scrape as fallback to calculate cross rate...")
|
| 328 |
-
cbsl_success = await self.fetch_cbsl_rates()
|
| 329 |
-
if cbsl_success:
|
| 330 |
base_lkr = self.get_cached_rate(base, "LKR")
|
| 331 |
target_lkr = self.get_cached_rate(target, "LKR")
|
| 332 |
if base_lkr is not None and target_lkr is not None and target_lkr > 0:
|
|
@@ -336,13 +277,24 @@ class FXService:
|
|
| 336 |
logger.info(f"Derived cross rate {base}->{target} after CBSL scrape: {derived}")
|
| 337 |
return derived
|
| 338 |
|
| 339 |
-
#
|
| 340 |
logger.warning(f"All live FX sources failed for {base}->{target}. Attempting stale cache fallback...")
|
| 341 |
stale_rate = self.get_stale_rate(base, target)
|
| 342 |
if stale_rate is not None:
|
| 343 |
return stale_rate
|
| 344 |
|
| 345 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
logger.critical(f"No rates available in memory, disk, or APIs for {base}->{target}!")
|
| 347 |
return None
|
| 348 |
|
|
|
|
| 63 |
|
| 64 |
class FXService:
|
| 65 |
def __init__(self):
|
|
|
|
|
|
|
|
|
|
| 66 |
self.cache: Dict[str, Dict] = {}
|
| 67 |
self.load_cache()
|
| 68 |
|
|
|
|
| 224 |
logger.error(f"Global exception in fetch_cbsl_rates: {e}")
|
| 225 |
return False
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
async def get_rate(self, base_currency: str, target_currency: str) -> Optional[float]:
|
| 228 |
"""
|
| 229 |
Retrieves real-time exchange rate with support for in-memory caching,
|
|
|
|
| 257 |
return derived
|
| 258 |
|
| 259 |
# 4. Cache MISS: Retrieve fresh rates.
|
| 260 |
+
# Since we are fully using web scraping, any cache miss should trigger CBSL scraper.
|
| 261 |
+
# CBSL scraper fetches all rates (against LKR) and populates them into the cache.
|
| 262 |
+
logger.info(f"Cache miss for {base}->{target}. Launching CBSL Web Scraper...")
|
| 263 |
+
cbsl_success = await self.fetch_cbsl_rates()
|
| 264 |
+
if cbsl_success:
|
| 265 |
+
# Try to get rate directly from cache (if either is LKR)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
res = self.get_cached_rate(base, target)
|
| 267 |
if res is not None:
|
| 268 |
return res
|
| 269 |
+
# Or try to derive cross rate mathematically if both are non-LKR
|
| 270 |
+
if base != "LKR" and target != "LKR":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
base_lkr = self.get_cached_rate(base, "LKR")
|
| 272 |
target_lkr = self.get_cached_rate(target, "LKR")
|
| 273 |
if base_lkr is not None and target_lkr is not None and target_lkr > 0:
|
|
|
|
| 277 |
logger.info(f"Derived cross rate {base}->{target} after CBSL scrape: {derived}")
|
| 278 |
return derived
|
| 279 |
|
| 280 |
+
# 5. Total Failure: Fallback to stale/expired cached rate if it exists
|
| 281 |
logger.warning(f"All live FX sources failed for {base}->{target}. Attempting stale cache fallback...")
|
| 282 |
stale_rate = self.get_stale_rate(base, target)
|
| 283 |
if stale_rate is not None:
|
| 284 |
return stale_rate
|
| 285 |
|
| 286 |
+
# If we failed to get a direct stale rate, try to compute it from stale LKR rates
|
| 287 |
+
if base != "LKR" and target != "LKR":
|
| 288 |
+
base_lkr_stale = self.get_stale_rate(base, "LKR")
|
| 289 |
+
target_lkr_stale = self.get_stale_rate(target, "LKR")
|
| 290 |
+
if base_lkr_stale is not None and target_lkr_stale is not None and target_lkr_stale > 0:
|
| 291 |
+
derived = base_lkr_stale / target_lkr_stale
|
| 292 |
+
self.update_cache_entry(base, target, derived)
|
| 293 |
+
self.save_cache()
|
| 294 |
+
logger.warning(f"Derived stale cross rate {base}->{target} from stale LKR rates: {derived}")
|
| 295 |
+
return derived
|
| 296 |
+
|
| 297 |
+
# 6. Absolute Failure: No rates available
|
| 298 |
logger.critical(f"No rates available in memory, disk, or APIs for {base}->{target}!")
|
| 299 |
return None
|
| 300 |
|