mirror of
https://github.com/Saauc/fx-risk-terminal.git
synced 2026-07-27 18:47:52 +00:00
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>
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
positions.json
|
||||
.claude/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,132 @@
|
||||
# FX Risk Terminal
|
||||
|
||||
A real-time **foreign-exchange risk engine** for a multi-currency equity book, served as a clean browser dashboard. It values the portfolio in USD live, then runs the same risk analytics a desk would: **Value-at-Risk by three independent methods, Expected Shortfall, Monte-Carlo simulation, stress testing, and minimum-variance hedging** — with the entire quant core implemented in pure Python (no numpy, no pandas).
|
||||
|
||||
The engine is currency-agnostic: it reads whatever currencies appear in your positions and builds the full covariance/VaR/correlation pipeline around them. The committed demo book spans **8 currencies** — EUR, SEK, GBP, CHF, JPY, KRW, BRL, MXN — across European, Asian-tech and LatAm names.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
I hold equities denominated in several currencies but think in USD. The P&L on that book has two drivers — the stocks and the currency — and the FX leg is the one most retail tools ignore. This dashboard isolates and quantifies the **currency risk**: how much of the book is exposed to each pair, how much I can expect to lose on a bad day, where that risk actually concentrates, whether the mix is genuinely diversified, and how cheaply it can be hedged.
|
||||
|
||||
It's deliberately built from first principles. The covariance pipeline, the Cholesky decomposition that correlates the Monte-Carlo shocks, and all three VaR estimators are written out in plain Python so the mathematics is visible rather than hidden behind a library call.
|
||||
|
||||
---
|
||||
|
||||
## What it computes
|
||||
|
||||
| Capability | Method |
|
||||
|---|---|
|
||||
| **Live valuation** | Every position's CCY/USD rate pulled every 60 s (ECB via frankfurter.app), book marked to USD, FX P&L vs. previous close, >0.5% move flags |
|
||||
| **Value-at-Risk** | Three independent estimators — **parametric** (variance-covariance), **historical simulation**, and **Monte Carlo** (50k correlated paths) — at 95% and 99% |
|
||||
| **Expected Shortfall (CVaR)** | Mean loss in the tail beyond VaR, from both historical and simulated distributions |
|
||||
| **Risk decomposition** | **Component VaR** — each currency's additive contribution to total risk (sums to the parametric total), showing where risk lives vs. where the money sits |
|
||||
| **Diversification ratio** | Weighted-average sleeve vol ÷ portfolio vol; >1 means correlations are doing real work |
|
||||
| **Monte Carlo engine** | Correlated daily shocks via **Cholesky decomposition** of the N×N return covariance matrix; full P&L distribution rendered as a histogram with VaR/ES markers |
|
||||
| **Stress testing** | Six stylised historical regimes (GFC '08, Euro crisis '11, USD bull '14, COVID '20, USD surge '22, risk-on) with per-currency shocks — including JPY/CHF safe-haven behaviour — replayed against the live book |
|
||||
| **Minimum-variance hedge** | Searches every pair for the **single forward** that minimises residual variance, reporting its notional and hedge effectiveness (R² = variance reduction via cross-correlation) |
|
||||
| **Volatility & correlation** | 30-day annualised vol per pair (√252 scaling) and the full N×N rolling correlation matrix as a heatmap |
|
||||
| **Scenario calculator** | A slider per currency + regime presets → instant per-position and portfolio USD impact |
|
||||
| **Interactive portfolio builder** | Add/edit/remove positions in the browser and re-run the entire risk engine live. The book lives only in `localStorage` and is sent to a stateless endpoint — never persisted server-side |
|
||||
| **Indexed performance** | 30-day FX performance rebased to 100, so any number of pairs is comparable on one axis |
|
||||
| **Horizon scaling** | Toggle 1-day ↔ 10-day (Basel-style) VaR; everything scales by √h |
|
||||
|
||||
The three VaR methods are shown side by side on purpose: **convergence across them is the validation, and divergence flags non-normal (fat-tailed) risk** the parametric model would miss.
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Choice | Why |
|
||||
|---|---|---|
|
||||
| Backend | Python / Flask | Thin API layer; minimal and deployable |
|
||||
| Quant core | **Pure Python (stdlib only)** | Cholesky, covariance, VaR/ES, Monte Carlo all from scratch — no numpy/pandas |
|
||||
| FX data | frankfurter.app (ECB) | Free, reliable, no API key; open.er-api.com fallback |
|
||||
| Charts | Chart.js 4 (CDN) | No build step; custom plugin for VaR marker lines |
|
||||
| Frontend | Vanilla JS + CSS | No framework, no Bootstrap — fast and dependency-light |
|
||||
| Tests | `unittest` | 20 tests validating the maths offline (no network) |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd fx-dashboard
|
||||
|
||||
cp positions.json.example positions.json # then edit with your holdings
|
||||
pip install -r requirements.txt
|
||||
python app.py # → http://localhost:5050
|
||||
```
|
||||
|
||||
`positions.json` is git-ignored — your holdings never enter the repo. With no file present the app falls back to the committed example data, so it runs out of the box. To force the demo book even when a private file exists (e.g. for screenshots):
|
||||
|
||||
```bash
|
||||
FX_DEMO=1 python app.py
|
||||
```
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
python -m unittest -v # 20 tests, no network required
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Position file format
|
||||
|
||||
```json
|
||||
[
|
||||
{"ticker": "ASML", "shares": 40, "avg_cost": 620.00, "currency": "EUR", "exchange": "Amsterdam"},
|
||||
{"ticker": "000660","shares": 200, "avg_cost": 180000, "currency": "KRW", "exchange": "Seoul"}
|
||||
]
|
||||
```
|
||||
|
||||
The currency universe is derived automatically from the file — no code change needed to add a pair. Supported currencies are the ECB reference set (EUR, SEK, GBP, CHF, NOK, DKK, JPY, BRL, MXN, KRW, AUD, CAD, CNY, INR, ZAR, …). Currencies outside that set (e.g. ARS, TWD) have no free feed and are skipped with a warning.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
fx-dashboard/
|
||||
├── app.py ← Flask routes + JSON error handling
|
||||
├── fx_engine.py ← rate fetching, caching, positions, FX P&L
|
||||
├── risk_engine.py ← VaR · ES · Monte Carlo · Cholesky · stress · hedging
|
||||
├── test_risk_engine.py ← 20 offline unit tests
|
||||
├── templates/
|
||||
│ └── dashboard.html ← single-page risk terminal (Chart.js, vanilla CSS)
|
||||
├── positions.json ← git-ignored — real holdings
|
||||
├── positions.json.example ← committed — example data
|
||||
├── requirements.txt
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| Method | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/api/health` | liveness + data-source status |
|
||||
| GET | `/api/rates` | live rates, previous close, % change, flags |
|
||||
| GET | `/api/positions` | enriched positions: USD value, weights, FX P&L, exposure |
|
||||
| GET | `/api/historical` | 30-day series + per-currency vol + correlation matrix |
|
||||
| GET | `/api/risk?horizon=N` | VaR (3 methods), ES, component VaR, diversification ratio, MC distribution, best hedge |
|
||||
| GET | `/api/stress` | stress-scenario impacts |
|
||||
| POST | `/api/analyze` | `{positions, horizon}` → full bundle for a custom book (stateless; powers the in-browser builder) |
|
||||
| POST | `/api/scenario` | `{moves:{CCY:pct}, positions?}` → per-position and portfolio impact |
|
||||
|
||||
---
|
||||
|
||||
## Methodology notes
|
||||
|
||||
- **Returns** are daily log returns of the FX rate; VaR/ES are reported as positive loss numbers.
|
||||
- **Parametric VaR** = `z · √(eᵀ Σ e)` where `e` is the USD exposure vector and `Σ` the return covariance. Component VaR uses the marginal decomposition `eᵢ·(Σe)ᵢ / σ · z`, which sums to the total.
|
||||
- **Historical VaR/ES** re-prices the book under every observed daily return vector and reads the empirical tail.
|
||||
- **Monte Carlo** draws correlated shocks `L·z` (`L` = Cholesky of `Σ`, `z` ~ N(0,1)), re-prices, and takes the simulated tail. Seeded for reproducibility.
|
||||
- **Best single hedge** searches every pair for the forward that maximises variance reduction on the whole book: optimal short notional `H* = (Σe)ⱼ / Σⱼⱼ`, effectiveness = `Corr(book, rⱼ)²`.
|
||||
- **Diversification ratio** = `Σ|eᵢ|·σᵢ / √(eᵀΣe)`; 1.0 means the book moves as a single bet, higher means correlations are reducing risk.
|
||||
- Stress shocks are **stylised** representative magnitudes for scenario analysis, not exact historical replays. JPY and CHF appreciate in risk-off scenarios (safe-haven behaviour); EM currencies (BRL, MXN, KRW) carry the largest downside.
|
||||
- Rates are ECB daily fixings — this models **FX risk on the book**, not intraday stock P&L (no equity price feed).
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Flask server for the FX Risk Terminal (multi-currency).
|
||||
|
||||
Routes
|
||||
------
|
||||
GET / → dashboard UI
|
||||
GET /api/health → liveness + data-source status
|
||||
GET /api/rates → live rates, prev close, % change, flags (per currency)
|
||||
GET /api/positions → enriched positions (USD value, weights, FX P&L, exposure)
|
||||
GET /api/historical → 30-day rate series + per-currency vol + correlation
|
||||
GET /api/risk → VaR (3 methods), CVaR, component VaR, MC dist, hedge, diversification
|
||||
GET /api/stress → historical stress-scenario impacts
|
||||
POST /api/scenario → user-defined FX shocks {moves:{ccy:pct}} → portfolio impact
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
|
||||
import fx_engine as fx
|
||||
import risk_engine as risk
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("app")
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _pct_change(cur, pre):
|
||||
return round((cur - pre) / pre * 100, 4) if cur and pre else None
|
||||
|
||||
|
||||
def _market():
|
||||
return fx.fetch_live_rates(), fx.fetch_prev_close()
|
||||
|
||||
|
||||
def _returns(hist):
|
||||
"""Per-currency daily log-return series from a historical payload."""
|
||||
return {c: fx.log_returns(s) for c, s in hist["rates"].items()}
|
||||
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_error(err):
|
||||
log.exception("Unhandled error: %s", err)
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify({"error": str(err), "type": type(err).__name__}), 500
|
||||
raise err
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("dashboard.html")
|
||||
|
||||
|
||||
@app.route("/api/health")
|
||||
def api_health():
|
||||
live = fx.fetch_live_rates()
|
||||
return jsonify({"status": "ok", "source": live.get("source"),
|
||||
"rate_date": live.get("date"),
|
||||
"currencies": fx.currency_universe()})
|
||||
|
||||
|
||||
@app.route("/api/rates")
|
||||
def api_rates():
|
||||
live, prev = _market()
|
||||
change = {c: _pct_change(live["rates"].get(c), prev["rates"].get(c)) for c in live["rates"]}
|
||||
return jsonify({
|
||||
"live": live, "prev": prev, "change": change,
|
||||
"flagged": {c: abs(v or 0) >= 0.5 for c, v in change.items()},
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/positions")
|
||||
def api_positions():
|
||||
live, prev = _market()
|
||||
return jsonify(fx.enrich_positions(live, prev))
|
||||
|
||||
|
||||
@app.route("/api/historical")
|
||||
def api_historical():
|
||||
hist = fx.fetch_historical(30)
|
||||
rates = hist["rates"]
|
||||
analytics = {}
|
||||
for c, s in rates.items():
|
||||
v = fx.annualized_volatility(s)
|
||||
analytics[c] = {"vol": round(v, 2) if v else None, "vol_color": fx.vol_color(v)}
|
||||
currencies = sorted(rates.keys())
|
||||
cov = risk.covariance_matrix(_returns(hist), currencies)
|
||||
corr = risk.correlation_matrix(cov) if cov else []
|
||||
return jsonify({"series": hist, "currencies": currencies,
|
||||
"analytics": analytics, "correlation": corr})
|
||||
|
||||
|
||||
@app.route("/api/risk")
|
||||
def api_risk():
|
||||
live, prev = _market()
|
||||
portfolio = fx.enrich_positions(live, prev)
|
||||
hist = fx.fetch_historical(30)
|
||||
horizon = max(1, int(request.args.get("horizon", 1)))
|
||||
return jsonify(risk.full_risk_report(portfolio, _returns(hist), horizon))
|
||||
|
||||
|
||||
@app.route("/api/stress")
|
||||
def api_stress():
|
||||
live, prev = _market()
|
||||
portfolio = fx.enrich_positions(live, prev)
|
||||
currencies = portfolio["currencies"]
|
||||
exposure = risk.exposure_vector(portfolio, currencies)
|
||||
return jsonify({"total_exposure_usd": portfolio["total_cost_usd"],
|
||||
"currencies": currencies,
|
||||
"scenarios": risk.stress_test(exposure, currencies)})
|
||||
|
||||
|
||||
@app.route("/api/analyze", methods=["POST"])
|
||||
def api_analyze():
|
||||
"""
|
||||
Stateless full-bundle analysis of a user-supplied book (the in-browser
|
||||
portfolio builder). The book is never persisted server-side — it is
|
||||
sanitised, priced against live rates, and returned with every analytic so
|
||||
the frontend can render the whole dashboard from one round trip.
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
positions = fx.sanitize_positions(body.get("positions", []))
|
||||
horizon = max(1, int(body.get("horizon", 1)))
|
||||
if not positions:
|
||||
return jsonify({"error": "no valid positions", "type": "ValueError"}), 400
|
||||
|
||||
currencies = fx.currency_universe(positions)
|
||||
live = fx.fetch_live_rates(currencies)
|
||||
prev = fx.fetch_prev_close(currencies)
|
||||
hist = fx.fetch_historical(30, currencies)
|
||||
|
||||
portfolio = fx.enrich_positions(live, prev, positions)
|
||||
change = {c: _pct_change(live["rates"].get(c), prev["rates"].get(c)) for c in live["rates"]}
|
||||
analytics = {c: {"vol": (lambda v: round(v, 2) if v else None)(fx.annualized_volatility(s)),
|
||||
"vol_color": fx.vol_color(fx.annualized_volatility(s))}
|
||||
for c, s in hist["rates"].items()}
|
||||
cov = risk.covariance_matrix(_returns(hist), currencies)
|
||||
corr = risk.correlation_matrix(cov) if cov else []
|
||||
|
||||
return jsonify({
|
||||
"rates": {"live": live, "prev": prev, "change": change,
|
||||
"flagged": {c: abs(v or 0) >= 0.5 for c, v in change.items()}},
|
||||
"positions": portfolio,
|
||||
"risk": risk.full_risk_report(portfolio, _returns(hist), horizon),
|
||||
"stress": {"total_exposure_usd": portfolio["total_cost_usd"], "currencies": currencies,
|
||||
"scenarios": risk.stress_test(risk.exposure_vector(portfolio, currencies), currencies)},
|
||||
"historical": {"series": hist, "currencies": currencies,
|
||||
"analytics": analytics, "correlation": corr},
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/scenario", methods=["POST"])
|
||||
def api_scenario():
|
||||
body = request.get_json(silent=True) or {}
|
||||
moves = body.get("moves", {})
|
||||
# Backward-compatible with the old {eur_move, sek_move} shape.
|
||||
if "eur_move" in body: moves["EUR"] = body["eur_move"]
|
||||
if "sek_move" in body: moves["SEK"] = body["sek_move"]
|
||||
moves = {c: float(v) for c, v in moves.items()}
|
||||
|
||||
# Optional custom book from the in-browser builder; else the file book.
|
||||
custom = fx.sanitize_positions(body.get("positions", [])) if body.get("positions") else None
|
||||
currencies = fx.currency_universe(custom) if custom else None
|
||||
live, prev = (fx.fetch_live_rates(currencies), fx.fetch_prev_close(currencies)) if custom else _market()
|
||||
portfolio = fx.enrich_positions(live, prev, custom)
|
||||
return jsonify(fx.scenario_impact(portfolio, moves))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, port=5050)
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
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}
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{"ticker": "ASML", "shares": 40, "avg_cost": 620.00, "currency": "EUR", "exchange": "Amsterdam"},
|
||||
{"ticker": "LPKF", "shares": 300, "avg_cost": 9.50, "currency": "EUR", "exchange": "Frankfurt"},
|
||||
{"ticker": "SIVE", "shares": 200, "avg_cost": 58.00, "currency": "SEK", "exchange": "Stockholm"},
|
||||
{"ticker": "LOGN", "shares": 250, "avg_cost": 78.00, "currency": "CHF", "exchange": "Zurich"},
|
||||
{"ticker": "RR", "shares": 2000, "avg_cost": 4.20, "currency": "GBP", "exchange": "London"},
|
||||
{"ticker": "6758", "shares": 300, "avg_cost": 13000.0, "currency": "JPY", "exchange": "Tokyo"},
|
||||
{"ticker": "000660","shares": 200, "avg_cost": 180000.0,"currency": "KRW", "exchange": "Seoul"},
|
||||
{"ticker": "VALE3", "shares": 1500, "avg_cost": 62.00, "currency": "BRL", "exchange": "Sao Paulo"},
|
||||
{"ticker": "AMXL", "shares": 3000, "avg_cost": 16.00, "currency": "MXN", "exchange": "Mexico City"}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
flask>=3.0
|
||||
requests>=2.31
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
FX risk engine — Value-at-Risk, Expected Shortfall, Monte Carlo, stress
|
||||
testing and minimum-variance hedging. Generalised to N currencies.
|
||||
|
||||
Everything here is pure Python (stdlib only): the covariance pipeline, the
|
||||
Cholesky decomposition used to correlate Monte-Carlo shocks, and the three
|
||||
independent VaR methodologies. No numpy, no pandas — the maths is on show.
|
||||
|
||||
Conventions
|
||||
-----------
|
||||
* The currency order is fixed by the caller (a sorted list) and every vector /
|
||||
matrix is built in that order.
|
||||
* "Exposure" is the signed USD value of each currency sleeve. A +1% move in
|
||||
CCY/USD changes that sleeve's USD value by exposure * 0.01.
|
||||
* Returns are daily log returns. VaR / ES are positive loss numbers.
|
||||
* Horizon defaults to 1 trading day; multi-day scales by sqrt(h).
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
from typing import Sequence
|
||||
|
||||
# Standard-normal quantiles for the confidence levels we report.
|
||||
Z = {0.95: 1.6448536269514722, 0.99: 2.3263478740408408}
|
||||
|
||||
|
||||
# ── Linear algebra ────────────────────────────────────────────────────────────
|
||||
|
||||
def _mean(xs: Sequence[float]) -> float:
|
||||
return sum(xs) / len(xs) if xs else 0.0
|
||||
|
||||
|
||||
def covariance_matrix(returns: dict[str, list[float]], currencies: list[str]) -> list[list[float]]:
|
||||
series = [returns[c] for c in currencies]
|
||||
if not series:
|
||||
return []
|
||||
n = min(len(s) for s in series)
|
||||
series = [s[-n:] for s in series]
|
||||
means = [_mean(s) for s in series]
|
||||
k = len(series)
|
||||
cov = [[0.0] * k for _ in range(k)]
|
||||
if n < 2:
|
||||
return cov
|
||||
for i in range(k):
|
||||
for j in range(k):
|
||||
cov[i][j] = sum((series[i][t] - means[i]) * (series[j][t] - means[j])
|
||||
for t in range(n)) / (n - 1)
|
||||
return cov
|
||||
|
||||
|
||||
def correlation_matrix(cov: list[list[float]]) -> list[list[float]]:
|
||||
k = len(cov)
|
||||
corr = [[0.0] * k for _ in range(k)]
|
||||
for i in range(k):
|
||||
for j in range(k):
|
||||
d = math.sqrt(cov[i][i] * cov[j][j])
|
||||
corr[i][j] = cov[i][j] / d if d > 0 else 0.0
|
||||
return corr
|
||||
|
||||
|
||||
def cholesky(matrix: list[list[float]]) -> list[list[float]]:
|
||||
"""Lower-triangular L with L·Lᵀ = matrix. Jitters non-PSD diagonals."""
|
||||
n = len(matrix)
|
||||
L = [[0.0] * n for _ in range(n)]
|
||||
for i in range(n):
|
||||
for j in range(i + 1):
|
||||
s = sum(L[i][k] * L[j][k] for k in range(j))
|
||||
if i == j:
|
||||
diag = matrix[i][i] - s
|
||||
L[i][j] = math.sqrt(diag if diag > 0 else 1e-18)
|
||||
else:
|
||||
L[i][j] = (matrix[i][j] - s) / L[j][j] if L[j][j] else 0.0
|
||||
return L
|
||||
|
||||
|
||||
def _matvec(M, v):
|
||||
return [sum(M[i][k] * v[k] for k in range(len(v))) for i in range(len(M))]
|
||||
|
||||
|
||||
def _percentile(sorted_xs: list[float], q: float) -> float:
|
||||
if not sorted_xs:
|
||||
return 0.0
|
||||
if len(sorted_xs) == 1:
|
||||
return sorted_xs[0]
|
||||
pos = q * (len(sorted_xs) - 1)
|
||||
lo, hi = math.floor(pos), math.ceil(pos)
|
||||
if lo == hi:
|
||||
return sorted_xs[int(pos)]
|
||||
frac = pos - lo
|
||||
return sorted_xs[lo] * (1 - frac) + sorted_xs[hi] * frac
|
||||
|
||||
|
||||
# ── Exposure ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def exposure_vector(portfolio: dict, currencies: list[str]) -> list[float]:
|
||||
exp = portfolio.get("exposure_usd", {})
|
||||
return [float(exp.get(c, 0.0)) for c in currencies]
|
||||
|
||||
|
||||
# ── Parametric VaR + component VaR ────────────────────────────────────────────
|
||||
|
||||
def parametric_var(exposure, cov, currencies, horizon=1) -> dict:
|
||||
sig = _matvec(cov, exposure)
|
||||
var_usd = sum(exposure[i] * sig[i] for i in range(len(exposure)))
|
||||
sigma = math.sqrt(max(var_usd, 0.0)) * math.sqrt(horizon)
|
||||
|
||||
out = {"sigma_usd": round(sigma, 4), "levels": {}, "components": {}}
|
||||
for cl, z in Z.items():
|
||||
out["levels"][str(cl)] = round(z * sigma, 4)
|
||||
|
||||
z95 = Z[0.95]
|
||||
if sigma > 0 and var_usd > 0:
|
||||
denom = math.sqrt(var_usd)
|
||||
for i, c in enumerate(currencies):
|
||||
out["components"][c] = round(exposure[i] * sig[i] / denom * z95 * math.sqrt(horizon), 4)
|
||||
else:
|
||||
for c in currencies:
|
||||
out["components"][c] = 0.0
|
||||
return out
|
||||
|
||||
|
||||
# ── Historical-simulation VaR ─────────────────────────────────────────────────
|
||||
|
||||
def historical_var(exposure, returns, currencies, horizon=1) -> dict:
|
||||
series = [returns[c] for c in currencies]
|
||||
n = min((len(s) for s in series), default=0)
|
||||
series = [s[-n:] for s in series]
|
||||
|
||||
pnl = []
|
||||
for t in range(n):
|
||||
p = sum(exposure[i] * (math.exp(series[i][t]) - 1) for i in range(len(exposure)))
|
||||
pnl.append(p * math.sqrt(horizon))
|
||||
pnl.sort()
|
||||
|
||||
out = {"levels": {}, "es": {}}
|
||||
for cl in Z:
|
||||
vq = -_percentile(pnl, 1 - cl)
|
||||
tail = [x for x in pnl if x <= -vq]
|
||||
out["levels"][str(cl)] = round(vq, 4)
|
||||
out["es"][str(cl)] = round(-_mean(tail) if tail else vq, 4)
|
||||
return out
|
||||
|
||||
|
||||
# ── Monte Carlo VaR ───────────────────────────────────────────────────────────
|
||||
|
||||
def monte_carlo_var(exposure, cov, horizon=1, n_sims=50_000, seed=42, bins=41) -> dict:
|
||||
rng = random.Random(seed)
|
||||
L = cholesky(cov)
|
||||
k = len(exposure)
|
||||
h = math.sqrt(horizon)
|
||||
|
||||
pnl = []
|
||||
for _ in range(n_sims):
|
||||
z = [rng.gauss(0, 1) for _ in range(k)]
|
||||
shock = _matvec(L, z)
|
||||
pnl.append(sum(exposure[i] * (math.exp(shock[i] * h) - 1) for i in range(k)))
|
||||
pnl.sort()
|
||||
|
||||
out = {"levels": {}, "es": {}, "n_sims": n_sims}
|
||||
for cl in Z:
|
||||
vq = -_percentile(pnl, 1 - cl)
|
||||
tail = [x for x in pnl if x <= -vq]
|
||||
out["levels"][str(cl)] = round(vq, 4)
|
||||
out["es"][str(cl)] = round(-_mean(tail) if tail else vq, 4)
|
||||
|
||||
lo, hi = pnl[0], pnl[-1]
|
||||
width = (hi - lo) / bins if hi > lo else 1.0
|
||||
counts = [0] * bins
|
||||
for p in pnl:
|
||||
counts[min(int((p - lo) / width), bins - 1)] += 1
|
||||
out["histogram"] = {
|
||||
"centers": [round(lo + (i + 0.5) * width, 4) for i in range(bins)],
|
||||
"counts": counts,
|
||||
"var95": out["levels"]["0.95"], "var99": out["levels"]["0.99"],
|
||||
"mean": round(_mean(pnl), 4),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
# ── Hedging: best single-instrument min-variance hedge ────────────────────────
|
||||
|
||||
def best_hedge(exposure, cov, currencies) -> dict:
|
||||
"""
|
||||
Find the single FX forward that, sold against the book, minimises residual
|
||||
variance — and report its effectiveness (R² = variance reduction).
|
||||
|
||||
For instrument j the optimal short notional is
|
||||
H* = Cov(book, r_j) / Var(r_j) = (Σe)_j / Σ_jj
|
||||
and the variance reduction equals Corr(book, r_j)².
|
||||
"""
|
||||
sig = _matvec(cov, exposure) # Σe
|
||||
unhedged_var = sum(exposure[i] * sig[i] for i in range(len(exposure)))
|
||||
|
||||
best = None
|
||||
for j, c in enumerate(currencies):
|
||||
var_j = cov[j][j]
|
||||
if var_j <= 0:
|
||||
continue
|
||||
cov_bj = sig[j] # Cov(book, r_j)
|
||||
h_star = cov_bj / var_j
|
||||
residual_var = unhedged_var - cov_bj ** 2 / var_j
|
||||
eff = (1 - residual_var / unhedged_var) if unhedged_var > 0 else 0.0
|
||||
cand = {
|
||||
"instrument": c,
|
||||
"notional_short_usd": round(h_star, 2),
|
||||
"effectiveness_pct": round(eff * 100, 2),
|
||||
"residual_sigma": round(math.sqrt(max(residual_var, 0)), 4),
|
||||
}
|
||||
if best is None or cand["effectiveness_pct"] > best["effectiveness_pct"]:
|
||||
best = cand
|
||||
|
||||
# Direct full hedge: short each sleeve in its own pair → residual ≈ 0.
|
||||
direct = {c: round(exposure[i], 2) for i, c in enumerate(currencies)}
|
||||
|
||||
return {
|
||||
"unhedged_sigma": round(math.sqrt(max(unhedged_var, 0)), 4),
|
||||
"best_single": best or {},
|
||||
"direct_hedge": direct,
|
||||
}
|
||||
|
||||
|
||||
# ── Stress testing ────────────────────────────────────────────────────────────
|
||||
|
||||
# Stylised one-shot FX shocks (% move in each CCY/USD) drawn from notable
|
||||
# historical regimes. Note the safe-haven behaviour of JPY/CHF (they rally as
|
||||
# the dollar bid hits EM/high-beta currencies) — that is the educational point.
|
||||
STRESS_SCENARIOS = [
|
||||
{"name": "GFC — Oct 2008", "note": "Lehman aftermath, USD funding squeeze",
|
||||
"shocks": {"EUR": -8.5, "SEK": -13, "GBP": -10, "CHF": -2, "BRL": -20, "MXN": -15, "JPY": 12, "KRW": -25}},
|
||||
{"name": "Euro debt crisis 2011", "note": "Peripheral spreads blow out; SNB caps CHF",
|
||||
"shocks": {"EUR": -4.5, "SEK": -3, "GBP": -2, "CHF": 8, "BRL": -10, "MXN": -8, "JPY": 5, "KRW": -4}},
|
||||
{"name": "USD bull 2014–15", "note": "Fed exit vs. global easing; broad dollar rally",
|
||||
"shocks": {"EUR": -10, "SEK": -10, "GBP": -5, "CHF": -8, "BRL": -15, "MXN": -12, "JPY": -12, "KRW": -7}},
|
||||
{"name": "COVID crash — Mar 2020", "note": "Dash for dollars; EM liquidity drain",
|
||||
"shocks": {"EUR": -3, "SEK": -7.5, "GBP": -10, "CHF": -2, "BRL": -15, "MXN": -20, "JPY": 2, "KRW": -6}},
|
||||
{"name": "USD surge — Sep 2022", "note": "Fed hiking into energy shock; gilt crisis; super-peso",
|
||||
"shocks": {"EUR": -6.5, "SEK": -9, "GBP": -8, "CHF": -3, "BRL": 2, "MXN": -1, "JPY": -10, "KRW": -8}},
|
||||
{"name": "Risk-on rally", "note": "Soft landing / broad USD weakness",
|
||||
"shocks": {"EUR": 4, "SEK": 6, "GBP": 4, "CHF": -1, "BRL": 8, "MXN": 6, "JPY": -3, "KRW": 5}},
|
||||
]
|
||||
|
||||
|
||||
def stress_test(exposure, currencies) -> list[dict]:
|
||||
e = dict(zip(currencies, exposure))
|
||||
total = sum(e.values())
|
||||
out = []
|
||||
for sc in STRESS_SCENARIOS:
|
||||
impact = sum(e[c] * (sc["shocks"].get(c, 0) / 100) for c in currencies)
|
||||
out.append({
|
||||
"name": sc["name"], "note": sc["note"],
|
||||
"shocks": {c: sc["shocks"].get(c, 0) for c in currencies},
|
||||
"impact_usd": round(impact, 2),
|
||||
"impact_pct": round(impact / total * 100, 3) if total else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Orchestration ─────────────────────────────────────────────────────────────
|
||||
|
||||
def full_risk_report(portfolio: dict, returns: dict[str, list[float]], horizon: int = 1) -> dict:
|
||||
currencies = portfolio.get("currencies") or sorted(portfolio.get("exposure_usd", {}))
|
||||
exposure = exposure_vector(portfolio, currencies)
|
||||
cov = covariance_matrix(returns, currencies)
|
||||
corr = correlation_matrix(cov) if cov else []
|
||||
|
||||
total = sum(exposure)
|
||||
param = parametric_var(exposure, cov, currencies, horizon)
|
||||
hist = historical_var(exposure, returns, currencies, horizon)
|
||||
mc = monte_carlo_var(exposure, cov, horizon)
|
||||
hedge = best_hedge(exposure, cov, currencies)
|
||||
|
||||
port_vol_ann = param["sigma_usd"] * math.sqrt(252) / total * 100 if total else None
|
||||
|
||||
# Diversification ratio: weighted-avg sleeve vol / portfolio vol. >1 means
|
||||
# correlations are doing work; ~1 means the book moves as one bet.
|
||||
div_ratio = None
|
||||
if total and cov:
|
||||
w_sigma = sum(abs(exposure[i]) * math.sqrt(cov[i][i]) for i in range(len(exposure)))
|
||||
p_sigma = param["sigma_usd"]
|
||||
div_ratio = round(w_sigma / p_sigma, 3) if p_sigma > 0 else None
|
||||
|
||||
def pct(v): return round(v / total * 100, 3) if total else None
|
||||
|
||||
return {
|
||||
"horizon_days": horizon,
|
||||
"currencies": currencies,
|
||||
"total_exposure_usd": round(total, 2),
|
||||
"portfolio_vol_annual_pct": round(port_vol_ann, 2) if port_vol_ann else None,
|
||||
"diversification_ratio": div_ratio,
|
||||
"covariance": cov,
|
||||
"correlation": corr,
|
||||
"var": {
|
||||
"parametric": {
|
||||
"var95": param["levels"]["0.95"], "var99": param["levels"]["0.99"],
|
||||
"var95_pct": pct(param["levels"]["0.95"]), "var99_pct": pct(param["levels"]["0.99"]),
|
||||
},
|
||||
"historical": {
|
||||
"var95": hist["levels"]["0.95"], "var99": hist["levels"]["0.99"],
|
||||
"es95": hist["es"]["0.95"], "es99": hist["es"]["0.99"],
|
||||
"var95_pct": pct(hist["levels"]["0.95"]), "var99_pct": pct(hist["levels"]["0.99"]),
|
||||
},
|
||||
"monte_carlo": {
|
||||
"var95": mc["levels"]["0.95"], "var99": mc["levels"]["0.99"],
|
||||
"es95": mc["es"]["0.95"], "es99": mc["es"]["0.99"],
|
||||
"var95_pct": pct(mc["levels"]["0.95"]), "var99_pct": pct(mc["levels"]["0.99"]),
|
||||
"n_sims": mc["n_sims"],
|
||||
},
|
||||
},
|
||||
"component_var95": param["components"],
|
||||
"es95": mc["es"]["0.95"],
|
||||
"distribution": mc["histogram"],
|
||||
"hedge": hedge,
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FX Risk Terminal</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg:#090d14; --surface:#111824; --surface2:#18212f; --border:#1d2a40; --border2:#25344e;
|
||||
--text:#e2ecf7; --muted:#6b89ab; --dim:#41597a;
|
||||
--green:#34d399; --green-dim:rgba(52,211,153,.12);
|
||||
--red:#f0564f; --red-dim:rgba(240,86,79,.12);
|
||||
--yellow:#f0b541; --yellow-dim:rgba(240,181,65,.12);
|
||||
--blue:#56a5ec; --blue-dim:rgba(86,165,236,.12);
|
||||
--purple:#a98af0; --accent:#2680d4; --radius:9px;
|
||||
--shadow:0 1px 3px rgba(0,0,0,.5),0 6px 20px rgba(0,0,0,.28);
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:radial-gradient(1200px 600px at 80% -10%,rgba(38,128,212,.06),transparent 60%),var(--bg);
|
||||
color:var(--text);font-family:'SF Pro Text',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
||||
font-size:14px;line-height:1.5;min-height:100vh;-webkit-font-smoothing:antialiased}
|
||||
.mono{font-variant-numeric:tabular-nums;font-feature-settings:"tnum"}
|
||||
.page{max-width:1240px;margin:0 auto;padding:26px 22px 64px}
|
||||
|
||||
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px;gap:16px;flex-wrap:wrap}
|
||||
.brand{display:flex;align-items:center;gap:13px}
|
||||
.brand-mark{width:38px;height:38px;border-radius:9px;flex-shrink:0;
|
||||
background:linear-gradient(140deg,#2680d4,#56a5ec 55%,#34d399);display:grid;place-items:center;
|
||||
font-weight:800;font-size:17px;color:#06101e;box-shadow:0 4px 14px rgba(38,128,212,.4)}
|
||||
.header-left h1{font-size:19px;font-weight:700;letter-spacing:-.3px}
|
||||
.header-left p{font-size:12px;color:var(--muted);margin-top:1px}
|
||||
.header-right{display:flex;align-items:center;gap:14px;font-size:12px;color:var(--muted)}
|
||||
.live-pill{display:flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--border);padding:6px 11px;border-radius:20px}
|
||||
.live-dot{width:7px;height:7px;border-radius:50%;background:var(--muted);flex-shrink:0}
|
||||
.live-dot.active{background:var(--green);box-shadow:0 0 8px var(--green);animation:blink 2.4s ease-in-out infinite}
|
||||
.live-dot.error{background:var(--red);box-shadow:0 0 8px var(--red)}
|
||||
@keyframes blink{0%,100%{opacity:1}50%{opacity:.35}}
|
||||
|
||||
.row{display:grid;gap:14px;margin-bottom:14px}
|
||||
.g4{grid-template-columns:repeat(4,1fr)} .g3{grid-template-columns:repeat(3,1fr)}
|
||||
.g2{grid-template-columns:repeat(2,1fr)} .g23{grid-template-columns:1.7fr 1fr} .g32{grid-template-columns:1fr 1.4fr}
|
||||
@media(max-width:860px){.g4{grid-template-columns:repeat(2,1fr)}.g3,.g2,.g23,.g32{grid-template-columns:1fr}}
|
||||
@media(max-width:520px){.g4{grid-template-columns:1fr}}
|
||||
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
|
||||
.card-head{display:flex;justify-content:space-between;align-items:center;padding:13px 17px;border-bottom:1px solid var(--border)}
|
||||
.card-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.09em;color:var(--muted)}
|
||||
.card-sub{font-size:10.5px;color:var(--dim)}
|
||||
.card-body{padding:17px}
|
||||
|
||||
.kpi{padding:16px 18px;position:relative;overflow:hidden}
|
||||
.kpi-label{font-size:10.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:6px}
|
||||
.kpi-value{font-size:28px;font-weight:800;letter-spacing:-.03em;margin-top:8px;line-height:1}
|
||||
.kpi-sub{font-size:11.5px;color:var(--muted);margin-top:7px}
|
||||
.kpi-accent{position:absolute;left:0;top:0;bottom:0;width:3px}
|
||||
.kpi-accent.blue{background:var(--blue)}.kpi-accent.red{background:var(--red)}
|
||||
.kpi-accent.yellow{background:var(--yellow)}.kpi-accent.green{background:var(--green)}
|
||||
.tag{font-size:9.5px;font-weight:700;padding:1px 6px;border-radius:4px}
|
||||
.tag-red{background:var(--red-dim);color:var(--red)}.tag-green{background:var(--green-dim);color:var(--green)}.tag-yellow{background:var(--yellow-dim);color:var(--yellow)}
|
||||
|
||||
/* rate tiles */
|
||||
.rate-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(186px,1fr));gap:12px}
|
||||
.rate-tile{position:relative;overflow:hidden;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:13px 15px;box-shadow:var(--shadow)}
|
||||
.rate-pair{font-size:10px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);margin-bottom:5px}
|
||||
.rate-value{font-size:22px;font-weight:800;letter-spacing:-.02em;line-height:1}
|
||||
.rate-meta{display:flex;align-items:center;gap:7px;margin-top:7px;flex-wrap:wrap}
|
||||
.pill{font-size:10.5px;font-weight:700;padding:2px 6px;border-radius:4px}
|
||||
.pill.pos{background:var(--green-dim);color:var(--green)}.pill.neg{background:var(--red-dim);color:var(--red)}.pill.neu{background:var(--blue-dim);color:var(--muted)}
|
||||
.flag-tag{font-size:9px;font-weight:700;padding:1px 5px;border-radius:4px;background:var(--yellow-dim);color:var(--yellow);border:1px solid rgba(240,181,65,.3)}
|
||||
.rate-spark{position:absolute;right:0;bottom:0;opacity:.5}
|
||||
.rate-accent{position:absolute;bottom:0;left:0;height:2px;width:100%}
|
||||
|
||||
.table-wrap{overflow-x:auto}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
thead th{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);padding:11px 17px;text-align:right;white-space:nowrap;background:var(--surface2)}
|
||||
thead th:first-child{text-align:left}
|
||||
tbody tr{border-top:1px solid var(--border)}
|
||||
tbody tr:hover{background:rgba(255,255,255,.018)}
|
||||
tbody td{padding:12px 17px;text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
tbody td:first-child{text-align:left}
|
||||
.ticker-cell{display:flex;align-items:center;gap:8px}
|
||||
.ticker{font-weight:700;font-size:14px;color:var(--blue)}
|
||||
.badge{font-size:9px;font-weight:700;padding:2px 5px;border-radius:3px;letter-spacing:.04em;text-transform:uppercase}
|
||||
.badge-flag{background:var(--yellow-dim);color:var(--yellow);border:1px solid rgba(240,181,65,.3)}
|
||||
.pos{color:var(--green)}.neg{color:var(--red)}.dim{color:var(--muted);font-size:12px}
|
||||
.total-row td{font-weight:700;border-top:2px solid var(--border2);color:var(--text)}
|
||||
.var-table td.method{text-align:left;font-weight:600;color:var(--text)}
|
||||
|
||||
.chart-wrap{padding:14px 16px 16px}
|
||||
canvas{display:block}
|
||||
|
||||
.contrib-row{display:flex;align-items:center;gap:10px;margin-bottom:11px}
|
||||
.contrib-label{width:38px;font-size:12px;font-weight:700}
|
||||
.contrib-track{flex:1;height:20px;background:var(--surface2);border-radius:5px;overflow:hidden}
|
||||
.contrib-fill{height:100%;border-radius:5px;display:flex;align-items:center;padding-left:8px;font-size:10.5px;font-weight:700;color:#06101e;transition:width .5s cubic-bezier(.4,0,.2,1)}
|
||||
.contrib-val{width:66px;text-align:right;font-size:11.5px;font-variant-numeric:tabular-nums}
|
||||
|
||||
.heat{display:grid;gap:4px;font-size:11px}
|
||||
.heat-cell{padding:9px 4px;border-radius:5px;text-align:center;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.heat-hdr{color:var(--muted);font-weight:600;display:grid;place-items:center;font-size:10.5px}
|
||||
|
||||
.stress-bar{display:inline-block;height:7px;border-radius:4px;vertical-align:middle}
|
||||
|
||||
.scenario-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}
|
||||
@media(max-width:620px){.scenario-grid{grid-template-columns:1fr}}
|
||||
.slider-list{display:flex;flex-direction:column;gap:9px;max-height:230px;overflow-y:auto;padding-right:4px}
|
||||
.slider-row{display:flex;align-items:center;gap:9px}
|
||||
.slider-ccy{width:34px;font-size:11px;font-weight:700}
|
||||
input[type=range]{flex:1;-webkit-appearance:none;height:4px;background:var(--border2);border-radius:3px;outline:none}
|
||||
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--blue);cursor:pointer;border:2px solid #0b1320}
|
||||
.slider-val{font-size:11.5px;font-weight:700;font-variant-numeric:tabular-nums;min-width:46px;text-align:right}
|
||||
.scenario-output{display:flex;flex-direction:column;gap:8px;max-height:230px;overflow-y:auto}
|
||||
.scenario-row{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;background:var(--surface2);border-radius:6px;font-size:12.5px}
|
||||
.scenario-row .s-ticker{font-weight:600;color:var(--muted)}
|
||||
.scenario-total{display:flex;justify-content:space-between;align-items:center;padding:11px 12px;border-radius:6px;border:1px solid var(--border2);background:linear-gradient(180deg,var(--surface2),var(--surface));font-size:14px;font-weight:700;position:sticky;bottom:0}
|
||||
.scenario-total span:first-child{color:var(--muted)}
|
||||
.preset-btns{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}
|
||||
.preset{background:var(--surface2);border:1px solid var(--border);color:var(--muted);padding:5px 11px;border-radius:6px;font-size:11.5px;cursor:pointer;font-family:inherit;transition:all .12s}
|
||||
.preset:hover{color:var(--text);border-color:var(--blue)}
|
||||
|
||||
.hedge-stat{display:flex;justify-content:space-between;align-items:baseline;padding:9px 0;border-bottom:1px solid var(--border)}
|
||||
.hedge-stat:last-of-type{border-bottom:none}
|
||||
.hedge-k{font-size:12px;color:var(--muted)}
|
||||
.hedge-v{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.effbar{height:8px;border-radius:5px;background:var(--surface2);overflow:hidden;margin-top:10px}
|
||||
.effbar-fill{height:100%;background:linear-gradient(90deg,var(--green),var(--blue));border-radius:5px}
|
||||
|
||||
.volgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(74px,1fr));gap:10px}
|
||||
.vol-cell{text-align:center;background:var(--surface2);border-radius:7px;padding:10px 6px}
|
||||
.vol-ccy{font-size:10px;font-weight:700;color:var(--muted)}
|
||||
.vol-num{font-size:17px;font-weight:800;margin-top:4px;font-variant-numeric:tabular-nums}
|
||||
|
||||
.seg{display:flex;background:var(--surface);border:1px solid var(--border);border-radius:7px;overflow:hidden}
|
||||
.seg button{background:transparent;border:none;color:var(--muted);padding:5px 12px;font-size:11.5px;font-weight:600;cursor:pointer;font-family:inherit}
|
||||
.seg button.active{background:var(--accent);color:#fff}
|
||||
|
||||
.skel{display:inline-block;background:var(--border);border-radius:4px;animation:skp 1.4s ease-in-out infinite}
|
||||
@keyframes skp{0%,100%{opacity:.35}50%{opacity:.7}}
|
||||
.footer{margin-top:30px;display:flex;justify-content:space-between;font-size:11px;color:var(--dim);flex-wrap:wrap;gap:6px;line-height:1.6}
|
||||
.footer a{color:var(--dim);text-decoration:none}.footer a:hover{color:var(--muted)}
|
||||
.note{font-size:11px;color:var(--dim);margin-top:11px;line-height:1.5}
|
||||
|
||||
/* header buttons */
|
||||
.ghost-btn{background:var(--surface);border:1px solid var(--border);color:var(--muted);padding:6px 12px;border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;transition:all .12s}
|
||||
.ghost-btn:hover{color:var(--text);border-color:var(--blue)}
|
||||
.book-pill{display:inline-flex;align-items:center;gap:8px;background:var(--blue-dim);color:var(--blue);border:1px solid rgba(86,165,236,.35);padding:5px 10px;border-radius:20px;font-size:11.5px;font-weight:700}
|
||||
.book-pill #book-reset{cursor:pointer;font-weight:800;opacity:.8}
|
||||
.book-pill #book-reset:hover{opacity:1}
|
||||
|
||||
/* builder modal */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(4,7,12,.72);backdrop-filter:blur(3px);display:none;align-items:flex-start;justify-content:center;z-index:50;padding:40px 16px;overflow-y:auto}
|
||||
.modal-overlay.open{display:flex}
|
||||
.modal{background:var(--surface);border:1px solid var(--border2);border-radius:12px;width:100%;max-width:760px;box-shadow:0 20px 60px rgba(0,0,0,.6)}
|
||||
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--border)}
|
||||
.modal-head h2{font-size:15px;font-weight:700}
|
||||
.modal-head p{font-size:11.5px;color:var(--muted);margin-top:2px}
|
||||
.modal-x{background:none;border:none;color:var(--muted);font-size:22px;cursor:pointer;line-height:1}
|
||||
.modal-body{padding:18px 22px;max-height:60vh;overflow-y:auto}
|
||||
.builder-table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
.builder-table th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:0 8px 8px;font-weight:600}
|
||||
.builder-table td{padding:4px 6px}
|
||||
.builder-table input,.builder-table select{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:7px 9px;border-radius:6px;font-size:12.5px;font-family:inherit;outline:none}
|
||||
.builder-table input:focus,.builder-table select:focus{border-color:var(--blue)}
|
||||
.builder-table .num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.row-x{background:none;border:none;color:var(--dim);cursor:pointer;font-size:16px;padding:4px 6px}
|
||||
.row-x:hover{color:var(--red)}
|
||||
.add-row{margin-top:12px;background:var(--surface2);border:1px dashed var(--border2);color:var(--muted);padding:8px 14px;border-radius:7px;font-size:12px;cursor:pointer;font-family:inherit;width:100%}
|
||||
.add-row:hover{color:var(--text);border-color:var(--blue)}
|
||||
.modal-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:16px 22px;border-top:1px solid var(--border);flex-wrap:wrap}
|
||||
.modal-err{font-size:11.5px;color:var(--yellow)}
|
||||
.btn{padding:8px 16px;border-radius:7px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid var(--border)}
|
||||
.btn-ghost{background:var(--surface2);color:var(--muted)}
|
||||
.btn-ghost:hover{color:var(--text)}
|
||||
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
.btn-primary:hover{background:var(--blue)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<div class="header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">FX</div>
|
||||
<div class="header-left">
|
||||
<h1>FX Risk Terminal</h1>
|
||||
<p>Multi-currency equity book · live USD valuation · VaR · stress · hedging</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="book-pill" id="book-pill" style="display:none">Custom book<span id="book-reset" title="Reset to default">×</span></span>
|
||||
<button class="ghost-btn" id="edit-book-btn">✎ Edit book</button>
|
||||
<div class="seg" id="horizon-seg"><button data-h="1" class="active">1-Day</button><button data-h="10">10-Day</button></div>
|
||||
<div class="live-pill"><span class="live-dot" id="live-dot"></span><span id="last-updated">Loading…</span><span id="countdown" style="color:var(--dim)"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI hero -->
|
||||
<div class="row g4">
|
||||
<div class="card kpi"><div class="kpi-accent blue"></div>
|
||||
<div class="kpi-label">Book Value (USD)</div>
|
||||
<div class="kpi-value mono" id="kpi-value"><span class="skel" style="width:110px;height:28px"></span></div>
|
||||
<div class="kpi-sub" id="kpi-value-sub">FX P&L today —</div></div>
|
||||
<div class="card kpi"><div class="kpi-accent red"></div>
|
||||
<div class="kpi-label">1-Day VaR <span class="tag tag-red">95%</span></div>
|
||||
<div class="kpi-value mono" id="kpi-var"><span class="skel" style="width:90px;height:28px"></span></div>
|
||||
<div class="kpi-sub" id="kpi-var-sub">Monte Carlo · 50k paths</div></div>
|
||||
<div class="card kpi"><div class="kpi-accent yellow"></div>
|
||||
<div class="kpi-label">Expected Shortfall <span class="tag tag-yellow">95%</span></div>
|
||||
<div class="kpi-value mono" id="kpi-es"><span class="skel" style="width:90px;height:28px"></span></div>
|
||||
<div class="kpi-sub" id="kpi-es-sub">Mean loss beyond VaR</div></div>
|
||||
<div class="card kpi"><div class="kpi-accent green"></div>
|
||||
<div class="kpi-label">Portfolio Vol <span class="tag tag-green">ann.</span></div>
|
||||
<div class="kpi-value mono" id="kpi-vol"><span class="skel" style="width:70px;height:28px"></span></div>
|
||||
<div class="kpi-sub" id="kpi-vol-sub">diversification —</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Rates -->
|
||||
<div class="row"><div class="rate-grid" id="rate-grid">
|
||||
<div class="skel" style="height:88px;border-radius:9px"></div>
|
||||
<div class="skel" style="height:88px;border-radius:9px"></div>
|
||||
<div class="skel" style="height:88px;border-radius:9px"></div>
|
||||
<div class="skel" style="height:88px;border-radius:9px"></div>
|
||||
</div></div>
|
||||
|
||||
<!-- VaR + risk contribution -->
|
||||
<div class="row g23">
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="card-title">Value-at-Risk · 3 Methodologies</span><span class="card-sub" id="var-horizon-label">1-day horizon</span></div>
|
||||
<div class="table-wrap"><table class="var-table"><thead><tr><th>Method</th><th>95% VaR</th><th>99% VaR</th><th>95% ES</th><th>% of book</th></tr></thead>
|
||||
<tbody id="var-body"><tr><td colspan="5" style="text-align:center;padding:22px;color:var(--muted)">Computing VaR…</td></tr></tbody></table></div>
|
||||
<div class="note" style="padding:0 17px 15px">Three independent estimators on the same book. Convergence validates the model; divergence flags non-normal (fat-tailed) risk.</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><span class="card-title">Risk Contribution</span><span class="card-sub">component VaR · 95%</span></div>
|
||||
<div class="card-body"><div id="contrib-bars"><div class="dim">Loading…</div></div>
|
||||
<div class="note">Each currency's additive share of total VaR. This is where risk concentrates — often not where the money sits.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MC distribution + correlation -->
|
||||
<div class="row g23">
|
||||
<div class="card"><div class="card-head"><span class="card-title">Monte Carlo P&L Distribution</span><span class="card-sub" id="mc-sub">50,000 correlated paths · Cholesky</span></div>
|
||||
<div class="chart-wrap"><canvas id="mc-chart" height="120"></canvas></div></div>
|
||||
<div class="card"><div class="card-head"><span class="card-title">Correlation Matrix</span><span class="card-sub">30d log returns</span></div>
|
||||
<div class="card-body"><div class="heat" id="corr-heat"><div class="dim">Loading…</div></div></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Stress -->
|
||||
<div class="row"><div class="card">
|
||||
<div class="card-head"><span class="card-title">Historical Stress Scenarios</span><span class="card-sub">stylised FX shocks → live book impact</span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Scenario</th><th>Book Impact (USD)</th><th>% of Book</th><th style="width:42%">Severity</th></tr></thead>
|
||||
<tbody id="stress-body"><tr><td colspan="4" style="text-align:center;padding:22px;color:var(--muted)">Loading scenarios…</td></tr></tbody></table></div>
|
||||
</div></div>
|
||||
|
||||
<!-- Hedge + scenario -->
|
||||
<div class="row g32">
|
||||
<div class="card"><div class="card-head"><span class="card-title">Min-Variance Hedge</span><span class="card-sub">best single forward</span></div>
|
||||
<div class="card-body" id="hedge-body"><div class="dim">Loading…</div></div></div>
|
||||
<div class="card"><div class="card-head"><span class="card-title">FX Scenario Calculator</span><span class="card-sub">shock any currency</span></div>
|
||||
<div class="card-body"><div class="scenario-grid">
|
||||
<div><div class="slider-list" id="slider-list"><div class="dim">Loading…</div></div>
|
||||
<div class="preset-btns" id="preset-btns"></div></div>
|
||||
<div class="scenario-output" id="scenario-output"><div style="color:var(--muted);font-size:12px;padding:8px 0">Move a slider or pick a preset.</div></div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Indexed performance -->
|
||||
<div class="row"><div class="card">
|
||||
<div class="card-head"><span class="card-title">30-Day FX Performance</span><span class="card-sub">indexed to 100 · vs USD · ECB fixings</span></div>
|
||||
<div class="chart-wrap"><canvas id="perf-chart" height="76"></canvas></div>
|
||||
</div></div>
|
||||
|
||||
<!-- Vol -->
|
||||
<div class="row g2">
|
||||
<div class="card"><div class="card-head"><span class="card-title">Rolling Volatility</span><span class="card-sub">10-day window · annualised</span></div>
|
||||
<div class="chart-wrap"><canvas id="vol-chart" height="150"></canvas></div></div>
|
||||
<div class="card"><div class="card-head"><span class="card-title">Annualised Volatility</span><span class="card-sub">30-day · color = risk tier</span></div>
|
||||
<div class="card-body"><div class="volgrid" id="vol-grid"><div class="dim">Loading…</div></div></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Positions -->
|
||||
<div class="row"><div class="card">
|
||||
<div class="card-head"><span class="card-title">Positions</span><span class="card-sub" id="total-label">Total —</span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Ticker</th><th>Exch.</th><th>Shares</th><th>Avg Cost</th><th>Cost (local)</th><th>Value (USD)</th><th>Weight</th><th>FX P&L</th><th>FX Δ%</th></tr></thead>
|
||||
<tbody id="positions-body"><tr><td colspan="9" style="text-align:center;padding:22px;color:var(--muted)">Loading…</td></tr></tbody></table></div>
|
||||
</div></div>
|
||||
|
||||
<div class="footer">
|
||||
<span>Rates: <a href="https://www.frankfurter.app" target="_blank" rel="noopener">frankfurter.app</a> (ECB) · VaR/ES/Monte-Carlo/Cholesky in pure Python (no numpy) · auto-refresh 60 s</span>
|
||||
<span id="footer-ts">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Portfolio builder modal -->
|
||||
<div class="modal-overlay" id="builder-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<div><h2>Portfolio Builder</h2><p>Edit your book and re-run the full risk engine live. Stored only in this browser — nothing is sent to a server or saved to the repo.</p></div>
|
||||
<button class="modal-x" id="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="builder-table">
|
||||
<thead><tr><th style="width:22%">Ticker</th><th style="width:16%">Shares</th><th style="width:18%">Avg cost</th><th style="width:18%">Currency</th><th style="width:22%">Exchange</th><th></th></tr></thead>
|
||||
<tbody id="builder-rows"></tbody>
|
||||
</table>
|
||||
<button class="add-row" id="add-row">+ Add position</button>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<span class="modal-err" id="builder-err"></span>
|
||||
<div style="display:flex;gap:8px;margin-left:auto">
|
||||
<button class="btn btn-ghost" id="load-demo">Load demo book</button>
|
||||
<button class="btn btn-ghost" id="reset-default">Reset to default</button>
|
||||
<button class="btn btn-primary" id="apply-book">Apply & analyze</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const REFRESH_MS = 60_000;
|
||||
const C = { blue:'#56a5ec', green:'#34d399', red:'#f0564f', yellow:'#f0b541', purple:'#a98af0', muted:'#6b89ab', border:'#1d2a40', surface2:'#18212f' };
|
||||
|
||||
/* per-currency color palette */
|
||||
const PALETTE = { EUR:'#56a5ec', SEK:'#34d399', GBP:'#a98af0', CHF:'#f0564f', JPY:'#f0b541',
|
||||
KRW:'#2dd4bf', BRL:'#fb923c', MXN:'#f472b6', NOK:'#818cf8', DKK:'#4ade80', USD:'#94a3b8' };
|
||||
const FALLBACK = ['#60a5fa','#fbbf24','#34d399','#f472b6','#a78bfa','#fb923c','#2dd4bf','#f87171'];
|
||||
function ccyColor(c, i=0){ return PALETTE[c] || FALLBACK[i % FALLBACK.length]; }
|
||||
function decFor(v){ return v >= 10 ? 4 : v >= 0.1 ? 5 : 6; } // sig digits by magnitude
|
||||
|
||||
let horizon = 1, countdown = REFRESH_MS/1000;
|
||||
const charts = {};
|
||||
let CURRENCIES = [];
|
||||
|
||||
const fmtPct=(n,d=3)=>n!=null?(n>=0?'+':'')+n.toFixed(d)+'%':'—';
|
||||
const usd=(n,d=2)=>n!=null?new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d}).format(n):'—';
|
||||
const usd0=n=>n!=null?new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:0}).format(n):'—';
|
||||
const loc=(n,c)=>n!=null?new Intl.NumberFormat('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}).format(n)+' '+c:'—';
|
||||
const signedUsd=n=>n==null?'—':(n>=0?'+':'')+usd(n);
|
||||
const badge=(c)=>`<span class="badge" style="background:${ccyColor(c)}22;color:${ccyColor(c)}">${c}</span>`;
|
||||
|
||||
Chart.defaults.color=C.muted; Chart.defaults.borderColor=C.border;
|
||||
Chart.defaults.font.family="'SF Pro Text',-apple-system,sans-serif"; Chart.defaults.font.size=11;
|
||||
|
||||
const vlinePlugin={id:'vlines',afterDraw(chart){const lines=chart.options.plugins.vlines?.lines||[];
|
||||
const{ctx,chartArea:{top,bottom},scales:{x}}=chart;lines.forEach(l=>{const px=x.getPixelForValue(l.value);
|
||||
if(px<x.left||px>x.right)return;ctx.save();ctx.beginPath();ctx.moveTo(px,top);ctx.lineTo(px,bottom);
|
||||
ctx.lineWidth=1.5;ctx.strokeStyle=l.color;ctx.setLineDash(l.dash||[5,4]);ctx.stroke();ctx.setLineDash([]);
|
||||
ctx.fillStyle=l.color;ctx.font='600 10px sans-serif';ctx.textAlign=px>(x.left+x.right)/2?'right':'left';
|
||||
ctx.fillText(l.label,px+(px>(x.left+x.right)/2?-5:5),top+11);ctx.restore();});}};
|
||||
Chart.register(vlinePlugin);
|
||||
|
||||
async function getJSON(u,o){const r=await fetch(u,o);if(!r.ok)throw new Error(`${u} → ${r.status}`);return r.json();}
|
||||
|
||||
/* ── RATES ── */
|
||||
async function loadRates(){ renderRates(await getJSON('/api/rates')); }
|
||||
function renderRates(d){
|
||||
const ccys=Object.keys(d.live.rates);
|
||||
const grid=document.getElementById('rate-grid');
|
||||
grid.innerHTML=ccys.map((c,i)=>{
|
||||
const v=d.live.rates[c], chg=d.change[c], prev=d.prev.rates[c], col=ccyColor(c,i);
|
||||
const cls=chg==null?'neu':chg>0?'pos':chg<0?'neg':'neu';
|
||||
return `<div class="rate-tile">
|
||||
<div class="rate-pair">${c} / USD</div>
|
||||
<div class="rate-value mono">${v.toFixed(decFor(v))}</div>
|
||||
<div class="rate-meta"><span class="pill ${cls}">${fmtPct(chg)}</span>
|
||||
${d.flagged[c]?'<span class="flag-tag">⚡</span>':''}</div>
|
||||
<canvas class="rate-spark" id="spark-${c}" width="120" height="38"></canvas>
|
||||
<div class="rate-accent" style="background:linear-gradient(90deg,${col},transparent)"></div></div>`;
|
||||
}).join('');
|
||||
document.getElementById('last-updated').textContent=new Date().toLocaleTimeString();
|
||||
document.getElementById('footer-ts').textContent='Rate date '+(d.live.date||'—')+' · source '+(d.live.source||'—');
|
||||
document.getElementById('live-dot').className='live-dot active';
|
||||
}
|
||||
|
||||
/* ── POSITIONS ── */
|
||||
async function loadPositions(){ renderPositions(await getJSON('/api/positions')); }
|
||||
function renderPositions(d){
|
||||
const rows=d.positions.map(p=>{
|
||||
const pnlC=p.fx_pnl_usd==null?'dim':p.fx_pnl_usd>0?'pos':p.fx_pnl_usd<0?'neg':'dim';
|
||||
const pctC=p.fx_pct==null?'dim':p.fx_pct>0?'pos':p.fx_pct<0?'neg':'dim';
|
||||
return `<tr><td><div class="ticker-cell"><span class="ticker">${p.ticker}</span>${badge(p.currency)}
|
||||
${p.flagged?'<span class="badge badge-flag">⚡</span>':''}</div></td>
|
||||
<td class="dim">${p.exchange}</td><td>${p.shares}</td><td class="dim">${loc(p.avg_cost,p.currency)}</td>
|
||||
<td>${loc(p.cost_local,p.currency)}</td><td>${usd(p.cost_usd)}</td>
|
||||
<td class="dim">${p.weight_pct!=null?p.weight_pct.toFixed(1)+'%':'—'}</td>
|
||||
<td class="${pnlC}">${signedUsd(p.fx_pnl_usd)}</td><td class="${pctC}">${fmtPct(p.fx_pct)}</td></tr>`;
|
||||
});
|
||||
rows.push(`<tr class="total-row"><td colspan="5">Total book</td><td>${usd(d.total_cost_usd)}</td><td>100%</td>
|
||||
<td class="${d.total_fx_pnl_usd>=0?'pos':'neg'}">${signedUsd(d.total_fx_pnl_usd)}</td><td></td></tr>`);
|
||||
document.getElementById('positions-body').innerHTML=rows.join('');
|
||||
document.getElementById('kpi-value').textContent=usd0(d.total_cost_usd);
|
||||
document.getElementById('kpi-value-sub').innerHTML=`FX P&L today <span class="${d.total_fx_pnl_usd>=0?'pos':'neg'}">${signedUsd(d.total_fx_pnl_usd)}</span>`;
|
||||
const exp=Object.entries(d.exposure_pct).sort((a,b)=>b[1]-a[1]).slice(0,3).map(([c,v])=>`${c} ${v}%`).join(' · ');
|
||||
document.getElementById('total-label').textContent=`${usd0(d.total_cost_usd)} · ${exp}`;
|
||||
}
|
||||
|
||||
/* ── RISK ── */
|
||||
async function loadRisk(){
|
||||
if(customBook){ return refresh(); } // custom book recomputes the whole bundle
|
||||
renderRisk(await getJSON('/api/risk?horizon='+horizon));
|
||||
}
|
||||
function renderRisk(d){
|
||||
CURRENCIES=d.currencies;
|
||||
const v=d.var, book=d.total_exposure_usd, pctOf=x=>book?(x/book*100).toFixed(2)+'%':'—';
|
||||
document.getElementById('kpi-var').textContent=usd0(v.monte_carlo.var95);
|
||||
document.getElementById('kpi-var-sub').innerHTML=`<span class="neg">${pctOf(v.monte_carlo.var95)}</span> of book · ${horizon}-day · 50k paths`;
|
||||
document.getElementById('kpi-es').textContent=usd0(v.monte_carlo.es95);
|
||||
document.getElementById('kpi-es-sub').innerHTML=`<span class="tag tag-yellow">${(v.monte_carlo.es95/v.monte_carlo.var95).toFixed(2)}×</span> the 95% VaR`;
|
||||
document.getElementById('kpi-vol').textContent=d.portfolio_vol_annual_pct!=null?d.portfolio_vol_annual_pct.toFixed(1)+'%':'—';
|
||||
document.getElementById('kpi-vol-sub').textContent=d.diversification_ratio!=null?`diversification ratio ${d.diversification_ratio.toFixed(2)}`:'';
|
||||
|
||||
const methods=[['Parametric',v.parametric,'variance-covariance'],['Historical',v.historical,'empirical tail'],['Monte Carlo',v.monte_carlo,'50k Cholesky paths']];
|
||||
document.getElementById('var-body').innerHTML=methods.map(([n,m,s])=>`<tr>
|
||||
<td class="method">${n}<div class="card-sub" style="font-weight:400">${s}</div></td>
|
||||
<td class="mono neg">${usd(m.var95)}</td><td class="mono neg">${usd(m.var99)}</td>
|
||||
<td class="mono">${m.es95!=null?usd(m.es95):'<span class="dim">—</span>'}</td>
|
||||
<td class="mono">${m.var95_pct!=null?m.var95_pct.toFixed(2)+'%':'—'}</td></tr>`).join('');
|
||||
document.getElementById('var-horizon-label').textContent=horizon+'-day horizon';
|
||||
|
||||
const comp=d.component_var95, tot=Object.values(comp).reduce((a,b)=>a+Math.abs(b),0);
|
||||
document.getElementById('contrib-bars').innerHTML=Object.entries(comp).sort((a,b)=>Math.abs(b[1])-Math.abs(a[1])).map(([c,val],i)=>{
|
||||
const share=tot?Math.abs(val)/tot*100:0,col=ccyColor(c,i);
|
||||
return `<div class="contrib-row"><div class="contrib-label" style="color:${col}">${c}</div>
|
||||
<div class="contrib-track"><div class="contrib-fill" style="width:${share}%;background:${col}">${share>=8?share.toFixed(0)+'%':''}</div></div>
|
||||
<div class="contrib-val mono">${usd(val)}</div></div>`;
|
||||
}).join('');
|
||||
|
||||
renderHeat(d.currencies,d.correlation);
|
||||
renderMcChart(d.distribution);
|
||||
document.getElementById('mc-sub').textContent=`${v.monte_carlo.n_sims.toLocaleString()} paths · ${horizon}-day`;
|
||||
renderHedge(d.hedge);
|
||||
buildSliders(d.currencies);
|
||||
}
|
||||
|
||||
function renderHeat(curr,corr){
|
||||
const el=document.getElementById('corr-heat');
|
||||
el.style.gridTemplateColumns=`auto repeat(${curr.length},1fr)`;
|
||||
const color=r=>{const a=Math.abs(r),base=r>=0?'52,211,153':'240,86,79';return `rgba(${base},${(0.1+a*0.6).toFixed(2)})`};
|
||||
let html=`<div class="heat-hdr"></div>`+curr.map(c=>`<div class="heat-hdr">${c}</div>`).join('');
|
||||
curr.forEach((rc,i)=>{html+=`<div class="heat-hdr">${rc}</div>`;
|
||||
curr.forEach((cc,j)=>{const r=corr[i][j];html+=`<div class="heat-cell mono" style="background:${color(r)};color:${Math.abs(r)>0.55?'#06101e':'var(--text)'}">${r.toFixed(2)}</div>`;});});
|
||||
el.innerHTML=html;
|
||||
}
|
||||
|
||||
function renderMcChart(dist){
|
||||
const ctx=document.getElementById('mc-chart').getContext('2d');
|
||||
if(charts.mc)charts.mc.destroy();
|
||||
const v95=-dist.var95,v99=-dist.var99;
|
||||
const bg=dist.centers.map(c=>c<=v99?C.red:c<=v95?'rgba(240,86,79,.55)':'rgba(86,165,236,.55)');
|
||||
charts.mc=new Chart(ctx,{type:'bar',
|
||||
data:{labels:dist.centers,datasets:[{data:dist.counts,backgroundColor:bg,barPercentage:1,categoryPercentage:1}]},
|
||||
options:{responsive:true,plugins:{legend:{display:false},
|
||||
vlines:{lines:[{value:v95,color:C.yellow,label:'95% VaR'},{value:v99,color:C.red,label:'99% VaR'},{value:0,color:C.muted,label:'',dash:[2,3]}]},
|
||||
tooltip:{callbacks:{title:i=>'P&L ≈ '+usd0(parseFloat(i[0].label)),label:i=>i.parsed.y.toLocaleString()+' paths'}}},
|
||||
scales:{x:{type:'linear',grid:{display:false},ticks:{maxTicksLimit:7,callback:v=>usd0(v)}},
|
||||
y:{grid:{color:C.border},ticks:{maxTicksLimit:5},title:{display:true,text:'paths'}}}}});
|
||||
}
|
||||
|
||||
function renderHedge(h){
|
||||
const b=h.best_single||{};
|
||||
document.getElementById('hedge-body').innerHTML=`
|
||||
<div class="hedge-stat"><span class="hedge-k">Best single instrument</span><span class="hedge-v" style="color:${ccyColor(b.instrument)}">${b.instrument||'—'}/USD</span></div>
|
||||
<div class="hedge-stat"><span class="hedge-k">Short notional</span><span class="hedge-v mono">${usd0(b.notional_short_usd)}</span></div>
|
||||
<div class="hedge-stat"><span class="hedge-k">Daily σ (unhedged)</span><span class="hedge-v mono">${usd(h.unhedged_sigma)}</span></div>
|
||||
<div class="hedge-stat"><span class="hedge-k">Daily σ (hedged)</span><span class="hedge-v mono" style="color:var(--green)">${usd(b.residual_sigma)}</span></div>
|
||||
<div style="margin-top:12px"><div style="display:flex;justify-content:space-between;font-size:12px;color:var(--muted)">
|
||||
<span>Hedge effectiveness (variance ↓)</span><span class="mono" style="color:var(--green);font-weight:700">${(b.effectiveness_pct||0).toFixed(1)}%</span></div>
|
||||
<div class="effbar"><div class="effbar-fill" style="width:${b.effectiveness_pct||0}%"></div></div></div>
|
||||
<div class="note">A single ${b.instrument}/USD forward absorbs ${(b.effectiveness_pct||0).toFixed(0)}% of the book's FX variance via cross-correlation. Full neutralisation needs a forward per sleeve.</div>`;
|
||||
}
|
||||
|
||||
/* ── HISTORICAL / CHARTS ── */
|
||||
async function loadHistorical(){ renderHistorical(await getJSON('/api/historical')); }
|
||||
function renderHistorical(d){
|
||||
const curr=d.currencies, rates=d.series.rates;
|
||||
document.getElementById('vol-grid').innerHTML=curr.map((c,i)=>{
|
||||
const a=d.analytics[c],col={green:C.green,yellow:C.yellow,red:C.red,neutral:C.muted}[a.vol_color]||C.muted;
|
||||
return `<div class="vol-cell"><div class="vol-ccy" style="color:${ccyColor(c,i)}">${c}/USD</div>
|
||||
<div class="vol-num" style="color:${col}">${a.vol!=null?a.vol.toFixed(1)+'%':'—'}</div></div>`;}).join('');
|
||||
renderPerfChart(d.series,curr);
|
||||
renderVolChart(d.series,curr);
|
||||
curr.forEach((c,i)=>renderSpark('spark-'+c,rates[c],ccyColor(c,i)));
|
||||
}
|
||||
const shortDate=s=>{const[_,m,d]=s.split('-');return `${m}/${d}`};
|
||||
|
||||
function renderPerfChart(series,curr){
|
||||
const ctx=document.getElementById('perf-chart').getContext('2d');
|
||||
if(charts.perf)charts.perf.destroy();
|
||||
const ds=curr.map((c,i)=>{const s=series.rates[c],base=s[0]||1;
|
||||
return {label:c,data:s.map(v=>+(v/base*100).toFixed(3)),borderColor:ccyColor(c,i),borderWidth:1.8,pointRadius:0,tension:.3};});
|
||||
charts.perf=new Chart(ctx,{type:'line',data:{labels:series.dates.map(shortDate),datasets:ds},
|
||||
options:{responsive:true,interaction:{mode:'index',intersect:false},
|
||||
plugins:{legend:{labels:{boxWidth:11,padding:11}},
|
||||
tooltip:{backgroundColor:C.surface2,borderColor:C.border,borderWidth:1,callbacks:{label:c=>` ${c.dataset.label}: ${c.parsed.y.toFixed(2)} (${(c.parsed.y-100>=0?'+':'')+(c.parsed.y-100).toFixed(2)}%)`}}},
|
||||
scales:{x:{grid:{color:C.border},ticks:{maxTicksLimit:9}},y:{grid:{color:C.border},ticks:{callback:v=>v.toFixed(0)},title:{display:true,text:'index (start = 100)'}}}}});
|
||||
}
|
||||
|
||||
function renderVolChart(series,curr){
|
||||
const ctx=document.getElementById('vol-chart').getContext('2d');
|
||||
if(charts.vol)charts.vol.destroy();
|
||||
const rv=(p,w=10)=>{const o=[];for(let i=w;i<=p.length;i++){const sl=p.slice(i-w,i);
|
||||
const r=sl.slice(1).map((v,j)=>Math.log(v/sl[j]));const m=r.reduce((a,b)=>a+b,0)/r.length;
|
||||
const vv=r.reduce((a,b)=>a+(b-m)**2,0)/(r.length-1);o.push(+(Math.sqrt(vv)*Math.sqrt(252)*100).toFixed(3));}return o;};
|
||||
const ds=curr.map((c,i)=>({label:c,data:rv(series.rates[c]),borderColor:ccyColor(c,i),borderWidth:1.8,pointRadius:0,tension:.3}));
|
||||
charts.vol=new Chart(ctx,{type:'line',data:{labels:series.dates.slice(10).map(shortDate),datasets:ds},
|
||||
options:{responsive:true,interaction:{mode:'index',intersect:false},
|
||||
plugins:{legend:{labels:{boxWidth:11,padding:10}},
|
||||
tooltip:{backgroundColor:C.surface2,borderColor:C.border,borderWidth:1,callbacks:{label:c=>` ${c.dataset.label}: ${c.parsed.y.toFixed(2)}%`}}},
|
||||
scales:{x:{grid:{color:C.border},ticks:{maxTicksLimit:6}},y:{grid:{color:C.border},ticks:{callback:v=>v.toFixed(0)+'%'}}}}});
|
||||
}
|
||||
|
||||
function renderSpark(id,data,color){
|
||||
const cv=document.getElementById(id);if(!cv)return;const ctx=cv.getContext('2d');
|
||||
if(charts[id])charts[id].destroy();
|
||||
charts[id]=new Chart(ctx,{type:'line',data:{labels:data.map((_,i)=>i),datasets:[{data,borderColor:color,borderWidth:1.5,pointRadius:0,tension:.35,fill:true,
|
||||
backgroundColor:()=>{const g=ctx.createLinearGradient(0,0,0,38);g.addColorStop(0,color+'33');g.addColorStop(1,'transparent');return g;}}]},
|
||||
options:{responsive:false,plugins:{legend:{display:false},tooltip:{enabled:false}},scales:{x:{display:false},y:{display:false}}}});
|
||||
}
|
||||
|
||||
/* ── STRESS ── */
|
||||
async function loadStress(){ renderStress(await getJSON('/api/stress')); }
|
||||
function renderStress(d){
|
||||
const max=Math.max(...d.scenarios.map(s=>Math.abs(s.impact_usd)),.0001);
|
||||
document.getElementById('stress-body').innerHTML=d.scenarios.map(s=>{
|
||||
const w=Math.abs(s.impact_usd)/max*100,col=s.impact_usd>=0?C.green:C.red;
|
||||
return `<tr><td><div style="font-weight:600;color:var(--text)">${s.name}</div><div class="card-sub" style="font-weight:400">${s.note}</div></td>
|
||||
<td class="mono ${s.impact_usd>=0?'pos':'neg'}" style="font-weight:700">${signedUsd(s.impact_usd)}</td>
|
||||
<td class="mono ${s.impact_usd>=0?'pos':'neg'}">${fmtPct(s.impact_pct,2)}</td>
|
||||
<td><span class="stress-bar" style="width:${w}%;background:${col}"></span></td></tr>`;}).join('');
|
||||
}
|
||||
|
||||
/* ── SCENARIO CALC (dynamic sliders) ── */
|
||||
const PRESETS={
|
||||
'COVID':{EUR:-3,SEK:-7.5,GBP:-10,CHF:-2,BRL:-15,MXN:-20,JPY:2,KRW:-6},
|
||||
'USD surge':{EUR:-6.5,SEK:-9,GBP:-8,CHF:-3,BRL:2,MXN:-1,JPY:-10,KRW:-8},
|
||||
'Risk-on':{EUR:4,SEK:6,GBP:4,CHF:-1,BRL:8,MXN:6,JPY:-3,KRW:5},
|
||||
};
|
||||
let scTimer=null, sliderEls={};
|
||||
function buildSliders(curr){
|
||||
if(Object.keys(sliderEls).length===curr.length && curr.every(c=>sliderEls[c]))return; // already built
|
||||
const list=document.getElementById('slider-list');sliderEls={};
|
||||
list.innerHTML=curr.map((c,i)=>`<div class="slider-row"><span class="slider-ccy" style="color:${ccyColor(c,i)}">${c}</span>
|
||||
<input type="range" min="-15" max="15" step="0.5" value="0" data-ccy="${c}" id="sl-${c}"/>
|
||||
<span class="slider-val" id="slv-${c}">0.0%</span></div>`).join('');
|
||||
curr.forEach(c=>{const el=document.getElementById('sl-'+c);sliderEls[c]=el;el.oninput=updSliders;});
|
||||
document.getElementById('preset-btns').innerHTML=
|
||||
Object.keys(PRESETS).map(k=>`<button class="preset" data-preset="${k}">${k}</button>`).join('')+
|
||||
`<button class="preset" data-preset="reset">Reset</button>`;
|
||||
document.querySelectorAll('.preset').forEach(b=>b.onclick=()=>{
|
||||
const p=b.dataset.preset==='reset'?{}:PRESETS[b.dataset.preset]||{};
|
||||
curr.forEach(c=>{sliderEls[c].value=p[c]||0;});updSliders();});
|
||||
}
|
||||
function updSliders(){
|
||||
CURRENCIES.forEach(c=>{const v=parseFloat(sliderEls[c].value),el=document.getElementById('slv-'+c);
|
||||
el.textContent=(v>=0?'+':'')+v.toFixed(1)+'%';el.style.color=v>0?C.green:v<0?C.red:'var(--text)';});
|
||||
clearTimeout(scTimer);scTimer=setTimeout(runScenario,140);
|
||||
}
|
||||
async function runScenario(){
|
||||
const moves={};let any=false;
|
||||
CURRENCIES.forEach(c=>{const v=parseFloat(sliderEls[c].value);moves[c]=v;if(v!==0)any=true;});
|
||||
const out=document.getElementById('scenario-output');
|
||||
if(!any){out.innerHTML='<div style="color:var(--muted);font-size:12px;padding:8px 0">Move a slider or pick a preset.</div>';return;}
|
||||
const payload={moves}; if(customBook) payload.positions=customBook;
|
||||
const d=await getJSON('/api/scenario',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
const rows=d.positions.filter(p=>p.impact_usd!==0).sort((a,b)=>a.impact_usd-b.impact_usd).map(p=>{
|
||||
const cls=p.impact_usd>0?'pos':p.impact_usd<0?'neg':'dim';
|
||||
return `<div class="scenario-row"><span class="s-ticker">${p.ticker} ${badge(p.currency)}</span><span class="s-impact mono ${cls}">${signedUsd(p.impact_usd)}</span></div>`;}).join('');
|
||||
const tcls=d.total_usd>0?'pos':d.total_usd<0?'neg':'dim';
|
||||
out.innerHTML=rows+`<div class="scenario-total"><span>Portfolio impact</span><span class="${tcls}">${signedUsd(d.total_usd)} <span class="card-sub" style="font-weight:600">(${fmtPct(d.pct_of_portfolio,2)})</span></span></div>`;
|
||||
}
|
||||
|
||||
document.querySelectorAll('#horizon-seg button').forEach(b=>b.onclick=()=>{
|
||||
document.querySelectorAll('#horizon-seg button').forEach(x=>x.classList.remove('active'));
|
||||
b.classList.add('active');horizon=parseInt(b.dataset.h);loadRisk();});
|
||||
|
||||
setInterval(()=>{countdown=Math.max(0,countdown-1);document.getElementById('countdown').textContent=countdown>0?`· ${countdown}s`:'· refreshing';},1000);
|
||||
|
||||
async function refresh(){
|
||||
try{
|
||||
if(customBook){
|
||||
const d=await getJSON('/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({positions:customBook,horizon})});
|
||||
renderRates(d.rates); renderPositions(d.positions); renderRisk(d.risk);
|
||||
renderStress(d.stress); renderHistorical(d.historical);
|
||||
} else {
|
||||
await Promise.all([loadRates(),loadPositions(),loadRisk(),loadStress()]);
|
||||
await loadHistorical(); /* sparklines depend on rate tiles existing */
|
||||
}
|
||||
} catch(e){console.error(e);document.getElementById('live-dot').className='live-dot error';
|
||||
document.getElementById('last-updated').textContent='data error — retrying';}
|
||||
countdown=REFRESH_MS/1000;
|
||||
}
|
||||
|
||||
/* ── PORTFOLIO BUILDER ── */
|
||||
const SUPPORTED_CCY=['EUR','SEK','GBP','CHF','NOK','DKK','JPY','BRL','MXN','KRW','AUD','CAD','CNY','INR','ZAR','SGD','PLN','TRY','NZD','HKD'];
|
||||
const DEMO_BOOK=[
|
||||
{ticker:'ASML',shares:40,avg_cost:620,currency:'EUR',exchange:'Amsterdam'},
|
||||
{ticker:'LPKF',shares:300,avg_cost:9.5,currency:'EUR',exchange:'Frankfurt'},
|
||||
{ticker:'SIVE',shares:200,avg_cost:58,currency:'SEK',exchange:'Stockholm'},
|
||||
{ticker:'LOGN',shares:250,avg_cost:78,currency:'CHF',exchange:'Zurich'},
|
||||
{ticker:'RR',shares:2000,avg_cost:4.2,currency:'GBP',exchange:'London'},
|
||||
{ticker:'6758',shares:300,avg_cost:13000,currency:'JPY',exchange:'Tokyo'},
|
||||
{ticker:'000660',shares:200,avg_cost:180000,currency:'KRW',exchange:'Seoul'},
|
||||
{ticker:'VALE3',shares:1500,avg_cost:62,currency:'BRL',exchange:'Sao Paulo'},
|
||||
{ticker:'AMXL',shares:3000,avg_cost:16,currency:'MXN',exchange:'Mexico City'},
|
||||
];
|
||||
const modal=document.getElementById('builder-modal');
|
||||
function loadCustomBook(){ try{return JSON.parse(localStorage.getItem('fx_custom_book'))||null;}catch{return null;} }
|
||||
function setBookPill(){ document.getElementById('book-pill').style.display=customBook?'inline-flex':'none'; }
|
||||
|
||||
function ccyOptions(sel){ return SUPPORTED_CCY.map(c=>`<option ${c===sel?'selected':''}>${c}</option>`).join(''); }
|
||||
function builderRow(p={}){
|
||||
return `<tr>
|
||||
<td><input class="b-ticker" value="${p.ticker||''}" placeholder="ASML"/></td>
|
||||
<td><input class="b-shares num" type="number" step="any" value="${p.shares??''}" placeholder="100"/></td>
|
||||
<td><input class="b-avg num" type="number" step="any" value="${p.avg_cost??''}" placeholder="50.00"/></td>
|
||||
<td><select class="b-ccy">${ccyOptions(p.currency||'EUR')}</select></td>
|
||||
<td><input class="b-exch" value="${p.exchange||''}" placeholder="Exchange"/></td>
|
||||
<td><button class="row-x" title="Remove">×</button></td></tr>`;
|
||||
}
|
||||
function fillBuilder(book){ document.getElementById('builder-rows').innerHTML=book.map(builderRow).join('')||builderRow(); bindRowX(); }
|
||||
function bindRowX(){ document.querySelectorAll('#builder-rows .row-x').forEach(b=>b.onclick=()=>{b.closest('tr').remove();}); }
|
||||
function readBuilder(){
|
||||
return [...document.querySelectorAll('#builder-rows tr')].map(tr=>({
|
||||
ticker:tr.querySelector('.b-ticker').value.trim(),
|
||||
shares:parseFloat(tr.querySelector('.b-shares').value),
|
||||
avg_cost:parseFloat(tr.querySelector('.b-avg').value),
|
||||
currency:tr.querySelector('.b-ccy').value,
|
||||
exchange:tr.querySelector('.b-exch').value.trim(),
|
||||
})).filter(p=>p.ticker && p.shares>0 && p.avg_cost>0);
|
||||
}
|
||||
function openBuilder(){
|
||||
const book=customBook||DEMO_BOOK;
|
||||
fillBuilder(book);
|
||||
document.getElementById('builder-err').textContent='';
|
||||
modal.classList.add('open');
|
||||
}
|
||||
function closeBuilder(){ modal.classList.remove('open'); }
|
||||
|
||||
document.getElementById('edit-book-btn').onclick=openBuilder;
|
||||
document.getElementById('modal-close').onclick=closeBuilder;
|
||||
modal.onclick=e=>{ if(e.target===modal) closeBuilder(); };
|
||||
document.getElementById('add-row').onclick=()=>{ document.getElementById('builder-rows').insertAdjacentHTML('beforeend',builderRow()); bindRowX(); };
|
||||
document.getElementById('load-demo').onclick=()=>fillBuilder(DEMO_BOOK);
|
||||
document.getElementById('reset-default').onclick=()=>{
|
||||
customBook=null; localStorage.removeItem('fx_custom_book'); setBookPill(); closeBuilder(); refresh();
|
||||
};
|
||||
document.getElementById('book-reset').onclick=()=>{
|
||||
customBook=null; localStorage.removeItem('fx_custom_book'); setBookPill(); refresh();
|
||||
};
|
||||
document.getElementById('apply-book').onclick=async()=>{
|
||||
const book=readBuilder();
|
||||
if(book.length===0){ document.getElementById('builder-err').textContent='Add at least one valid position (ticker, shares>0, cost>0).'; return; }
|
||||
customBook=book; localStorage.setItem('fx_custom_book',JSON.stringify(book)); setBookPill(); closeBuilder();
|
||||
sliderEls={}; /* force slider rebuild for the new currency set */
|
||||
await refresh();
|
||||
};
|
||||
|
||||
let customBook=loadCustomBook();
|
||||
setBookPill();
|
||||
async function init(){ await refresh(); setInterval(refresh,REFRESH_MS); }
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Unit tests for the FX risk engine (multi-currency).
|
||||
|
||||
These run fully offline on synthetic correlated return series — no network,
|
||||
no live rates — so the maths is validated deterministically.
|
||||
|
||||
python3 -m unittest -v # or: python3 test_risk_engine.py
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import unittest
|
||||
|
||||
import risk_engine as risk
|
||||
|
||||
CCY = ["EUR", "SEK"]
|
||||
|
||||
|
||||
def make_returns(n=250, sigma_eur=0.004, sigma_sek=0.006, rho=0.8, seed=1):
|
||||
"""Generate n days of correlated EUR/SEK log returns via Cholesky."""
|
||||
rng = random.Random(seed)
|
||||
cov = [
|
||||
[sigma_eur ** 2, rho * sigma_eur * sigma_sek],
|
||||
[rho * sigma_eur * sigma_sek, sigma_sek ** 2],
|
||||
]
|
||||
L = risk.cholesky(cov)
|
||||
eur, sek = [], []
|
||||
for _ in range(n):
|
||||
r = risk._matvec(L, [rng.gauss(0, 1), rng.gauss(0, 1)])
|
||||
eur.append(r[0]); sek.append(r[1])
|
||||
return {"EUR": eur, "SEK": sek}
|
||||
|
||||
|
||||
class TestLinearAlgebra(unittest.TestCase):
|
||||
def test_cholesky_reconstructs_matrix(self):
|
||||
M = [[4.0, 2.0], [2.0, 3.0]]
|
||||
L = risk.cholesky(M)
|
||||
recon = [[sum(L[i][k] * L[j][k] for k in range(2)) for j in range(2)] for i in range(2)]
|
||||
for i in range(2):
|
||||
for j in range(2):
|
||||
self.assertAlmostEqual(recon[i][j], M[i][j], places=10)
|
||||
|
||||
def test_cholesky_lower_triangular(self):
|
||||
self.assertEqual(risk.cholesky([[4.0, 2.0], [2.0, 3.0]])[0][1], 0.0)
|
||||
|
||||
def test_covariance_symmetric(self):
|
||||
cov = risk.covariance_matrix(make_returns(), CCY)
|
||||
self.assertAlmostEqual(cov[0][1], cov[1][0], places=12)
|
||||
|
||||
def test_correlation_diagonal_and_bounds(self):
|
||||
corr = risk.correlation_matrix(risk.covariance_matrix(make_returns(), CCY))
|
||||
self.assertAlmostEqual(corr[0][0], 1.0, places=9)
|
||||
self.assertTrue(-1.0 <= corr[0][1] <= 1.0)
|
||||
|
||||
def test_correlation_recovers_input(self):
|
||||
corr = risk.correlation_matrix(risk.covariance_matrix(make_returns(n=2000, rho=0.8), CCY))
|
||||
self.assertAlmostEqual(corr[0][1], 0.8, delta=0.05)
|
||||
|
||||
def test_percentile_interpolates(self):
|
||||
xs = [0.0, 1.0, 2.0, 3.0, 4.0]
|
||||
self.assertAlmostEqual(risk._percentile(xs, 0.5), 2.0)
|
||||
self.assertAlmostEqual(risk._percentile(xs, 0.0), 0.0)
|
||||
self.assertAlmostEqual(risk._percentile(xs, 1.0), 4.0)
|
||||
|
||||
def test_cholesky_handles_three_assets(self):
|
||||
M = [[4, 2, 1], [2, 3, 0.5], [1, 0.5, 2]]
|
||||
L = risk.cholesky(M)
|
||||
recon = [[sum(L[i][k] * L[j][k] for k in range(3)) for j in range(3)] for i in range(3)]
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
self.assertAlmostEqual(recon[i][j], M[i][j], places=9)
|
||||
|
||||
|
||||
class TestVaR(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.returns = make_returns(n=2000, rho=0.8)
|
||||
self.cov = risk.covariance_matrix(self.returns, CCY)
|
||||
self.exposure = [60.0, 40.0]
|
||||
|
||||
def test_var99_exceeds_var95(self):
|
||||
p = risk.parametric_var(self.exposure, self.cov, CCY)
|
||||
self.assertGreater(p["levels"]["0.99"], p["levels"]["0.95"])
|
||||
|
||||
def test_component_var_sums_to_total(self):
|
||||
# Euler/additive property — compare within the 4-dp rounding tolerance.
|
||||
p = risk.parametric_var(self.exposure, self.cov, CCY)
|
||||
self.assertAlmostEqual(sum(p["components"].values()), p["levels"]["0.95"], delta=1e-3)
|
||||
|
||||
def test_parametric_and_montecarlo_agree(self):
|
||||
p = risk.parametric_var(self.exposure, self.cov, CCY)
|
||||
mc = risk.monte_carlo_var(self.exposure, self.cov, n_sims=40_000, seed=7)
|
||||
rel = abs(p["levels"]["0.95"] - mc["levels"]["0.95"]) / p["levels"]["0.95"]
|
||||
self.assertLess(rel, 0.07)
|
||||
|
||||
def test_expected_shortfall_exceeds_var(self):
|
||||
mc = risk.monte_carlo_var(self.exposure, self.cov, n_sims=40_000, seed=3)
|
||||
self.assertGreaterEqual(mc["es"]["0.95"], mc["levels"]["0.95"])
|
||||
|
||||
def test_horizon_scales_as_sqrt(self):
|
||||
p1 = risk.parametric_var(self.exposure, self.cov, CCY, horizon=1)
|
||||
p10 = risk.parametric_var(self.exposure, self.cov, CCY, horizon=10)
|
||||
self.assertAlmostEqual(p10["levels"]["0.95"] / p1["levels"]["0.95"], math.sqrt(10), delta=0.01)
|
||||
|
||||
def test_historical_var_positive(self):
|
||||
self.assertGreater(risk.historical_var(self.exposure, self.returns, CCY)["levels"]["0.95"], 0)
|
||||
|
||||
def test_montecarlo_histogram_shape(self):
|
||||
mc = risk.monte_carlo_var(self.exposure, self.cov, n_sims=20_000, bins=31)
|
||||
self.assertEqual(len(mc["histogram"]["centers"]), 31)
|
||||
self.assertEqual(sum(mc["histogram"]["counts"]), 20_000)
|
||||
|
||||
|
||||
class TestHedge(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cov = risk.covariance_matrix(make_returns(n=1500, rho=0.8), CCY)
|
||||
|
||||
def test_effectiveness_bounded(self):
|
||||
h = risk.best_hedge([60.0, 40.0], self.cov, CCY)["best_single"]
|
||||
self.assertTrue(0.0 <= h["effectiveness_pct"] <= 100.0)
|
||||
|
||||
def test_residual_below_unhedged(self):
|
||||
h = risk.best_hedge([60.0, 40.0], self.cov, CCY)
|
||||
self.assertLessEqual(h["best_single"]["residual_sigma"], h["unhedged_sigma"])
|
||||
|
||||
def test_single_currency_book_fully_hedged(self):
|
||||
# A pure-EUR book is perfectly hedged by the EUR/USD forward.
|
||||
h = risk.best_hedge([100.0, 0.0], self.cov, CCY)["best_single"]
|
||||
self.assertEqual(h["instrument"], "EUR")
|
||||
self.assertAlmostEqual(h["effectiveness_pct"], 100.0, delta=0.01)
|
||||
|
||||
|
||||
class TestStress(unittest.TestCase):
|
||||
def test_negative_shock_gives_loss(self):
|
||||
results = risk.stress_test([60.0, 40.0], CCY)
|
||||
gfc = next(r for r in results if "GFC" in r["name"])
|
||||
self.assertLess(gfc["impact_usd"], 0)
|
||||
|
||||
def test_riskon_gives_gain(self):
|
||||
results = risk.stress_test([60.0, 40.0], CCY)
|
||||
self.assertGreater(next(r for r in results if "Risk-on" in r["name"])["impact_usd"], 0)
|
||||
|
||||
def test_jpy_safe_haven_in_gfc(self):
|
||||
# JPY exposure should *gain* in the GFC scenario (safe-haven rally).
|
||||
results = risk.stress_test([100.0], ["JPY"])
|
||||
gfc = next(r for r in results if "GFC" in r["name"])
|
||||
self.assertGreater(gfc["impact_usd"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user