Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.5 KiB
Python
106 lines
3.5 KiB
Python
"""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<ccy>=X` returns units of <ccy> 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}
|