Files
sauc a3817dc462 Initial commit: FX Risk Terminal
Multi-currency FX risk engine + browser dashboard:
- Live USD valuation of a multi-currency equity book (ECB rates, no API key)
- Value-at-Risk by 3 methods (parametric, historical, Monte Carlo)
- Expected Shortfall, component VaR, diversification ratio
- Monte Carlo via from-scratch Cholesky (pure Python, no numpy)
- Historical stress testing + minimum-variance hedge search
- Interactive in-browser portfolio builder (stateless, localStorage)
- 20 offline unit tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:12:00 -04:00

312 lines
12 KiB
Python

"""
FX data fetching and portfolio analytics — multi-currency.
The set of currencies the dashboard tracks is derived automatically from
positions.json (every non-USD currency that appears). Rates are quoted as
CCY/USD (USD value of one unit of the currency).
Data sources (no API key required):
- Live / historical rates: frankfurter.app (ECB reference rates, daily)
- Fallback for live: open.er-api.com (updates ~hourly)
"""
import os
import json
import math
import time
import logging
from datetime import date, timedelta
from pathlib import Path
from typing import Any
import requests
log = logging.getLogger(__name__)
FRANKFURTER = "https://api.frankfurter.app"
OPEN_ER = "https://open.er-api.com/v6/latest"
SESSION = requests.Session()
SESSION.headers.update({"User-Agent": "fx-dashboard/2.0"})
TIMEOUT = 10
POSITIONS_FILE = Path(__file__).parent / "positions.json"
EXAMPLE_FILE = Path(__file__).parent / "positions.json.example"
# Currencies available from the ECB reference set (frankfurter). Used only to
# warn the user if positions.json references something unsupported.
SUPPORTED = {
"EUR", "SEK", "GBP", "CHF", "NOK", "DKK", "JPY", "BRL", "MXN", "KRW",
"AUD", "CAD", "CNY", "CZK", "HKD", "HUF", "ILS", "INR", "NZD", "PLN",
"RON", "SGD", "THB", "TRY", "ZAR", "BGN", "IDR", "ISK", "MYR", "PHP",
}
# ── In-process cache ──────────────────────────────────────────────────────────
_cache: dict[str, tuple[Any, float]] = {}
def _cached(key: str, ttl: int, fn):
entry = _cache.get(key)
if entry and time.monotonic() - entry[1] < ttl:
return entry[0]
value = fn()
_cache[key] = (value, time.monotonic())
return value
def invalidate(key: str | None = None) -> None:
_cache.clear() if key is None else _cache.pop(key, None)
# ── Date helpers ──────────────────────────────────────────────────────────────
def _prev_business_day(d: date) -> date:
d -= timedelta(days=1)
while d.weekday() >= 5:
d -= timedelta(days=1)
return d
def _business_days_ago(n: int) -> date:
d, count = date.today(), 0
while count < n:
d -= timedelta(days=1)
if d.weekday() < 5:
count += 1
return d
# ── Positions & currency universe ─────────────────────────────────────────────
def load_positions() -> list[dict]:
# FX_DEMO=1 forces the committed example book (used for public screenshots
# and for anyone cloning the repo) without touching a private positions.json.
demo = os.environ.get("FX_DEMO") in ("1", "true", "yes")
path = EXAMPLE_FILE if demo or not POSITIONS_FILE.exists() else POSITIONS_FILE
if path is EXAMPLE_FILE and not demo:
log.warning("positions.json not found — loading example (demo) data")
return json.loads(path.read_text()) if path.exists() else []
def sanitize_positions(raw: list) -> list[dict]:
"""Validate and coerce a user-supplied book (from the browser builder)."""
clean = []
if not isinstance(raw, list):
return clean
for p in raw[:50]: # hard cap
if not isinstance(p, dict):
continue
ccy = str(p.get("currency", "")).upper().strip()
try:
shares = float(p.get("shares", 0))
avg = float(p.get("avg_cost", 0))
except (TypeError, ValueError):
continue
if ccy not in SUPPORTED or shares <= 0 or avg <= 0:
continue
clean.append({
"ticker": str(p.get("ticker", "?"))[:16] or "?",
"shares": shares,
"avg_cost": avg,
"currency": ccy,
"exchange": str(p.get("exchange", ""))[:24],
})
return clean
def currency_universe(positions: list[dict] | None = None) -> list[str]:
"""Sorted unique non-USD currencies present in the book."""
positions = load_positions() if positions is None else positions
ccys = {p["currency"] for p in positions if p.get("currency") != "USD"}
unsupported = ccys - SUPPORTED
if unsupported:
log.warning("unsupported currencies (no free ECB feed): %s", unsupported)
return sorted(ccys & SUPPORTED)
def _to_param(ccys: list[str]) -> str:
return ",".join(ccys)
# ── Live rates ────────────────────────────────────────────────────────────────
def _fetch_live_frankfurter(ccys: list[str]) -> dict:
r = SESSION.get(f"{FRANKFURTER}/latest?from=USD&to={_to_param(ccys)}", timeout=TIMEOUT)
r.raise_for_status()
data = r.json()
return {
"rates": {c: 1 / data["rates"][c] for c in ccys if c in data["rates"]},
"date": data["date"],
"source": "frankfurter.app",
}
def _fetch_live_open_er(ccys: list[str]) -> dict:
r = SESSION.get(f"{OPEN_ER}/USD", timeout=TIMEOUT)
r.raise_for_status()
data = r.json()
return {
"rates": {c: 1 / data["rates"][c] for c in ccys if c in data["rates"]},
"date": date.today().isoformat(),
"source": "open.er-api.com",
}
def fetch_live_rates(currencies: list[str] | None = None) -> dict:
ccys = currency_universe() if currencies is None else sorted(set(currencies) & SUPPORTED)
key = "live:" + _to_param(ccys)
def _fetch():
try:
return _fetch_live_frankfurter(ccys)
except Exception as e:
log.warning("frankfurter live failed (%s) — falling back", e)
return _fetch_live_open_er(ccys)
return _cached(key, 60, _fetch)
# ── Previous close ────────────────────────────────────────────────────────────
def fetch_prev_close(currencies: list[str] | None = None) -> dict:
ccys = currency_universe() if currencies is None else sorted(set(currencies) & SUPPORTED)
key = "prev:" + _to_param(ccys)
def _fetch():
prev = _prev_business_day(date.today()).isoformat()
try:
r = SESSION.get(f"{FRANKFURTER}/{prev}?from=USD&to={_to_param(ccys)}", timeout=TIMEOUT)
r.raise_for_status()
data = r.json()
return {"rates": {c: 1 / data["rates"][c] for c in ccys if c in data["rates"]},
"date": data["date"]}
except Exception as e:
log.warning("prev close failed (%s) — using live", e)
live = fetch_live_rates()
return {"rates": dict(live["rates"]), "date": "—"}
return _cached(key, 3600, _fetch)
# ── Historical series ─────────────────────────────────────────────────────────
def fetch_historical(days: int = 30, currencies: list[str] | None = None) -> dict:
"""{dates, rates:{ccy:[...]}} for the past `days` business days."""
ccys = currency_universe() if currencies is None else sorted(set(currencies) & SUPPORTED)
key = f"hist:{days}:" + _to_param(ccys)
def _fetch():
start = _business_days_ago(days).isoformat()
end = date.today().isoformat()
try:
r = SESSION.get(f"{FRANKFURTER}/{start}..{end}?from=USD&to={_to_param(ccys)}", timeout=TIMEOUT)
r.raise_for_status()
data = r.json()
dates = sorted(data["rates"])
series = {c: [] for c in ccys}
kept = []
for d in dates:
row = data["rates"][d]
if all(c in row for c in ccys):
kept.append(d)
for c in ccys:
series[c].append(round(1 / row[c], 6))
return {"dates": kept, "rates": series}
except Exception as e:
log.error("historical fetch failed: %s", e)
return {"dates": [], "rates": {c: [] for c in ccys}}
return _cached(key, 3600, _fetch)
# ── Volatility / correlation helpers ──────────────────────────────────────────
def log_returns(series: list[float]) -> list[float]:
return [math.log(series[i] / series[i - 1])
for i in range(1, len(series))
if series[i - 1] > 0 and series[i] > 0]
def _mean(xs): return sum(xs) / len(xs) if xs else 0.0
def _std(xs):
if len(xs) < 2:
return 0.0
m = _mean(xs)
return math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1))
def annualized_volatility(series: list[float]) -> float | None:
if len(series) < 3:
return None
return _std(log_returns(series)) * math.sqrt(252) * 100
def vol_color(v: float | None) -> str:
if v is None: return "neutral"
if v < 6: return "green"
if v < 10: return "yellow"
return "red"
# ── Positions enrichment ──────────────────────────────────────────────────────
def enrich_positions(live: dict, prev: dict, positions: list[dict] | None = None) -> dict:
rate_map = live["rates"]
prev_map = prev["rates"]
enriched = []
for p in (load_positions() if positions is None else positions):
ccy = p["currency"]
rate = rate_map.get(ccy)
p_rate = prev_map.get(ccy)
cost_local = round(p["shares"] * p["avg_cost"], 2)
cost_usd = round(cost_local * rate, 2) if rate else None
fx_pnl_usd = round(cost_local * (rate - p_rate), 2) if rate and p_rate else None
fx_pct = round((rate - p_rate) / p_rate * 100, 3) if rate and p_rate else None
enriched.append({**p,
"cost_local": cost_local, "cost_usd": cost_usd,
"fx_pnl_usd": fx_pnl_usd, "fx_pct": fx_pct,
"rate": round(rate, 6) if rate else None,
"flagged": abs(fx_pct) >= 0.5 if fx_pct is not None else False})
total_cost = sum(p["cost_usd"] for p in enriched if p["cost_usd"])
total_pnl = round(sum(p["fx_pnl_usd"] for p in enriched if p["fx_pnl_usd"] is not None), 2)
for p in enriched:
p["weight_pct"] = round(p["cost_usd"] / total_cost * 100, 1) if total_cost and p["cost_usd"] else None
exposure: dict[str, float] = {}
for p in enriched:
if p["cost_usd"]:
exposure[p["currency"]] = round(exposure.get(p["currency"], 0) + p["cost_usd"], 2)
exposure_pct = {c: round(v / total_cost * 100, 1) if total_cost else None
for c, v in exposure.items()}
return {
"positions": enriched,
"total_cost_usd": round(total_cost, 2),
"total_fx_pnl_usd": total_pnl,
"exposure_usd": exposure,
"exposure_pct": exposure_pct,
"currencies": sorted(exposure.keys()),
}
# ── Scenario calculator ───────────────────────────────────────────────────────
def scenario_impact(portfolio: dict, moves_pct: dict[str, float]) -> dict:
"""moves_pct: {ccy: pct_move}. Returns per-position and total USD impact."""
total_cost = portfolio["total_cost_usd"]
items, total = [], 0.0
for p in portfolio["positions"]:
if p.get("cost_usd") is None:
continue
move = moves_pct.get(p["currency"], 0) / 100
impact = round(p["cost_usd"] * move, 2)
total += impact
items.append({"ticker": p["ticker"], "currency": p["currency"],
"impact_usd": impact, "weight_pct": p.get("weight_pct")})
total = round(total, 2)
return {"positions": items, "total_usd": total,
"pct_of_portfolio": round(total / total_cost * 100, 3) if total_cost else None}