"""FX rate fetcher with Redis-backed cache. The universe endpoint returns prices in each ticker's *local* currency (USD for NYSE, EUR for Paris, GBP for LSE-after-pence-normalisation, etc.). The browser needs FX rates to convert these into the pie's base currency for P/L computation. Rates are expressed against a USD pivot: `fx[CCY]` = "how many CCY for 1 USD". USD itself is always 1.0. To convert X-currency value to Y-currency: `value_y = value_x * fx[Y] / fx[X]`. Yahoo's `=X` symbols give the right shape: `USDGBP=X` returns GBP per USD. Rates are cached in Redis for 1 hour (FX doesn't move much for display-purpose P/L; intraday moves are noise at the second decimal). """ from __future__ import annotations import json from typing import Iterable import httpx from app.logging import get_logger from app.redis_client import get_redis from app.services.market import fetch_yahoo log = get_logger("fx") _CACHE_KEY = "fx:rates:v1" _CACHE_TTL_SECONDS = 3600 # 1 hour # Synonyms / shorthand currencies that should resolve to a canonical # code before lookup. "GBp" (pence) is normalised to GBP at the # universe endpoint, but we still set up the mapping defensively. _CANONICALISE = { "GBP.": "GBP", "GBX": "GBP", "GBp": "GBP", } def _canonical(ccy: str) -> str: return _CANONICALISE.get(ccy, ccy) async def _fetch_one(client: httpx.AsyncClient, ccy: str) -> float | None: """Yahoo: `USD=X` returns units of per 1 USD.""" q = await fetch_yahoo(client, f"USD{ccy}=X", ccy, "") if q.price is None or q.price <= 0: return None return float(q.price) async def get_rates(currencies: Iterable[str]) -> dict[str, float]: """Return `{ccy: units-per-USD}` for every currency requested. USD is always 1.0. Unknown / fetch-failed currencies are omitted rather than poisoned — callers must check membership before converting (browser falls back to "no conversion" for missing pairs, which keeps the panel readable even when FX is degraded). Cached in Redis for 1 hour; live fetches happen only on cache miss or when the cached set doesn't cover all needed currencies.""" wanted = {_canonical(c) for c in currencies if c} wanted.add("USD") # pivot — always present r = get_redis() cached_raw = await r.get(_CACHE_KEY) cached: dict[str, float] = {} if cached_raw: try: cached = json.loads(cached_raw) except Exception: cached = {} missing = wanted - set(cached.keys()) if not missing: return {c: cached[c] for c in wanted} # Fetch any missing rates in parallel. Keep the existing cache to # avoid re-fetching unchanged currencies. rates = dict(cached) rates["USD"] = 1.0 fetch_list = [c for c in missing if c != "USD"] if fetch_list: async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client: import asyncio results = await asyncio.gather( *(_fetch_one(client, c) for c in fetch_list), return_exceptions=True, ) for c, val in zip(fetch_list, results): if isinstance(val, Exception): log.warning("fx.fetch_failed", ccy=c, error=str(val)[:120]) continue if val is not None: rates[c] = val # Persist (merged) cache. await r.set(_CACHE_KEY, json.dumps(rates), ex=_CACHE_TTL_SECONDS) log.info("fx.cache_refreshed", count=len(rates)) return {c: rates[c] for c in wanted if c in rates}