From 8f6238fd6d4835016602d994b3c0df434ec04bd4 Mon Sep 17 00:00:00 2001 From: Amanda Achiangia Date: Sat, 8 Aug 2026 11:25:22 -0400 Subject: [PATCH] Refactor: consolidate to risklib, fix 14 bugs, expand tests 5->129 --- .github/workflows/ci.yml | 41 +- .github/workflows/python-package.yml | 40 - .gitignore | 5 + MIGRATION_NOTES.md | 178 ++- README.md | 528 +++---- app/app.py | 1396 +++++++++-------- docs/model_report.md | 440 ++++-- docs/model_risk_report.md | 53 - docs/validation_results.md | 150 ++ .../notebooks/exceptions_99pct_window250.csv | 1258 --------------- .../{notebooks => }/test_var_with_yf.ipynb | 8 +- pyproject.toml | 33 +- requirements-dev.txt | 5 + requirements.txt | 14 +- risk_engine/__init__.py | 0 risk_engine/data.py | 78 - risk_engine/market.py | 467 ------ risk_engine/scenarios.py | 72 - risk_engine/stress.py | 43 - risklib/__init__.py | 19 + risklib/credit/__init__.py | 19 + .../credit/credit_risk_model.py | 57 +- risklib/data.py | 162 ++ risklib/market/__init__.py | 81 + risklib/market/backtest.py | 396 +++-- risklib/market/extras.py | 286 ++++ risklib/market/garch.py | 303 ++++ risklib/market/garch_mle.py | 271 ---- risklib/market/market_risk_model.py | 605 ++++--- risklib/market/scenarios.py | 356 +++++ scripts/generate_validation_results.py | 400 +++++ tests/conftest.py | 37 +- tests/test_app_contract.py | 256 +++ tests/test_backtest.py | 239 +++ tests/test_credit.py | 143 +- tests/test_data_and_scenarios.py | 197 +++ tests/test_extras.py | 114 ++ tests/test_garch.py | 151 ++ tests/test_market_risk.py | 218 +++ tests/test_market_risk_properties.py | 28 - tests/test_var.py | 21 - 41 files changed, 5260 insertions(+), 3908 deletions(-) delete mode 100644 .github/workflows/python-package.yml delete mode 100644 docs/model_risk_report.md create mode 100644 docs/validation_results.md delete mode 100644 notebooks/notebooks/notebooks/exceptions_99pct_window250.csv rename notebooks/{notebooks => }/test_var_with_yf.ipynb (98%) create mode 100644 requirements-dev.txt delete mode 100644 risk_engine/__init__.py delete mode 100644 risk_engine/data.py delete mode 100644 risk_engine/market.py delete mode 100644 risk_engine/scenarios.py delete mode 100644 risk_engine/stress.py create mode 100644 risklib/credit/__init__.py rename risk_engine/credit.py => risklib/credit/credit_risk_model.py (71%) create mode 100644 risklib/data.py create mode 100644 risklib/market/extras.py create mode 100644 risklib/market/garch.py delete mode 100644 risklib/market/garch_mle.py create mode 100644 risklib/market/scenarios.py create mode 100644 scripts/generate_validation_results.py create mode 100644 tests/test_app_contract.py create mode 100644 tests/test_data_and_scenarios.py create mode 100644 tests/test_extras.py create mode 100644 tests/test_garch.py create mode 100644 tests/test_market_risk.py delete mode 100644 tests/test_market_risk_properties.py delete mode 100644 tests/test_var.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43d7e5d..a25792b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,29 +2,50 @@ name: CI on: push: + branches: ["main"] pull_request: + branches: ["main"] jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Matches `requires-python = ">=3.11"` in pyproject.toml. The previous + # workflow tested 3.9 and 3.10, which the project does not support. + python-version: ["3.11", "3.12"] + steps: - - name: Checkout - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Python + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: "3.11" # use 3.11 for better SciPy/NumPy wheel support + python-version: ${{ matrix.python-version }} + cache: pip - - name: Install deps + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - # optional: install pytest explicitly if not in requirements - pip install pytest + pip install -r requirements-dev.txt - - name: Expose repo root on PYTHONPATH - run: echo "PYTHONPATH=$PWD" >> $GITHUB_ENV + - name: Lint (syntax and undefined names are hard failures) + run: | + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=12 --max-line-length=110 --statistics + + - name: Verify the package installs cleanly + run: | + pip install . + python -c "import risklib; print('risklib', risklib.__version__)" - name: Run tests + env: + PYTHONPATH: ${{ github.workspace }} run: pytest -q + + - name: Reproduce the published validation results + env: + PYTHONPATH: ${{ github.workspace }} + run: python scripts/generate_validation_results.py --check diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index e56abb6..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,40 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python package - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.9", "3.10", "3.11"] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest diff --git a/.gitignore b/.gitignore index 95a756e..fc10c13 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ data/* !data/market_data.csv !data/credit_example.csv *.egg-info/ + +# Build artifacts +build/ +dist/ +.pytest_cache/ diff --git a/MIGRATION_NOTES.md b/MIGRATION_NOTES.md index 83c3981..1b3a0b3 100644 --- a/MIGRATION_NOTES.md +++ b/MIGRATION_NOTES.md @@ -1,94 +1,134 @@ -# Migration Plan (Recruiter-Efficient): From "App With Features" → "Model Risk Validation Sandbox" +# Migration Record: "App With Features" → Model Risk Validation Sandbox -## Objective (North Star) -Turn the repo into a **validation-ready risk engine** where: -- the **engine** is the source of truth (VaR/ES/EL), -- the **backtesting module** validates model behavior, -- the **Streamlit app** only displays outputs (no math in the UI). +**Status: complete (v0.2).** This began as a plan; it is retained as a record of +what was decided, what was executed, and where the plan was deliberately +overruled. -This migration is intentionally minimal: move only what increases credibility for **pensions / model validation / risk boutiques**. +--- + +## Objective + +Turn the repository into a validation-ready risk engine where: + +- the **engine** is the source of truth for VaR / ES / EL, +- the **validation layer** evaluates whether the model works, +- the **Streamlit app** only displays outputs and computes nothing. --- -## Step B — Decide What Stays vs What Moves (One-Time) +## What moved -### ✅ MOVES into `risklib/market/market_risk_model.py` (Risk Measurement Layer) -Move all functions that answer: **"What is the risk number?"** +### Into `risklib/market/market_risk_model.py` — measurement -- Historical VaR / ES logic -- Parametric (Normal) VaR / ES logic -- Monte Carlo VaR / ES logic -- Filtered Historical (GARCH-lite / FHS) VaR / ES logic -- Supporting utilities required by those methods (only if used by the above) +Everything answering *"what is the risk number?"*: historical, parametric, +Monte Carlo and FHS VaR/ES, plus `MarketRiskConfig` and `MarketRiskModel`. -**Rule:** VaR and ES must be reported as **positive loss numbers** (single loss convention). +**Rule applied:** VaR and ES are reported as positive loss amounts under a single +convention, enforced at `fit()` time. ---- +### Into `risklib/market/backtest.py` — validation + +Everything answering *"did the model work?"*: Kupiec POF, Christoffersen +independence, joint conditional coverage, the rolling historical backtest and +the rolling FHS backtest. + +**Rule applied:** the app calls these and renders the output; it does not +implement backtesting. The app previously carried its own third copy of the +Kupiec p-value inline — that has been removed. + +### Into `risklib/market/garch.py` — volatility + +The GARCH(1,1) recursion existed in **three** places with subtly different +initialisation. There is now exactly one `variance_path`; the fixed-parameter +and MLE paths differ only in how they obtain (ω, α, β) before calling it. + +### Into `risklib/market/extras.py` — attribution + +Euler decomposition, incremental VaR and ERC budgeting. -### ✅ MOVES into `risklib/market/backtest.py` (Validation Layer) -Move all functions that answer: **"Did the model work?"** +### Into `risklib/market/scenarios.py` — stress -- Kupiec POF test -- Rolling backtest loop (build VaR series over time) -- Exception counting (losses > VaR) -- Backtest summary outputs (exceptions, expected, p-values) +`stress.py` and `scenarios.py` were merged. They had drifted into two +**incompatible conventions** for the same question: one held unshocked +instruments flat, the other carried their last observed return. Flat is now the +single default; the alternative is opt-in and reported in the output. -**Rule:** The app should call backtest functions and display tables/plots—**not implement backtesting itself**. +### Into `risklib/credit/credit_risk_model.py` and `risklib/data.py` + +The credit EL pipeline and data ingestion, unchanged in intent. --- -### ✅ STAYS (for now) — but must not recompute risk -These can remain in place while we stabilize the engine: +## What was removed + +**`risk_engine/` — deleted entirely.** It was documented as "thin wrappers" but +was in fact a complete second implementation: twelve core functions duplicated, +with divergent signatures. Only the `risklib` copy accepted `fit_garch`, which +is how that parameter came to be silently inert. -- Streamlit UI layout and charting (Plotly / Streamlit elements) -- Data loading (e.g., yfinance, CSV demos) +A consequence worth recording: `app.py` imported `backtest_var_historical` from +*both* modules, and got the three-test version only because of import line +ordering. Reordering those two lines would have silently reduced the app to +Kupiec alone and then crashed on a missing key. That class of accident is now +structurally impossible. -**Rule:** UI/plots may visualize VaR/ES, but must **never recompute** VaR/ES internally. +Also removed: the duplicate CI workflow (whose 3.9/3.10 matrix contradicted +`requires-python = ">=3.11"`), the stale second copy of the model report, the +triple-nested `notebooks/notebooks/notebooks/` path, and a committed CSV output +artifact. --- -### ❌ REMOVE or DEPRECATE (Out of scope for the North Star) -These reduce clarity and create "doing too much" signals. +## Where the plan was overruled + +The original plan listed ERC weights and marginal VaR decomposition as **out of +scope for V1**, to be deprecated into `extras.py`. + +**Decision: kept, and promoted.** Portfolio-level attribution is the output a +risk committee actually acts on — it is what converts "portfolio VaR is $19,929" +into "the two equities are 93% of your risk on a 67% weight". The Euler identity +also gives the test suite one of its sharpest assertions, since component VaR +must sum to portfolio VaR to machine precision. They live in `extras.py` as the +plan specified, but as a supported part of the engine rather than a deprecation +holding pen. + +Incremental VaR was additionally **corrected** during the move: the previous +implementation returned component VaR under that name. True incremental VaR is a +discrete recomputation (remove the position, renormalise the rest), and the two +differ materially on any position of size. Both are now reported side by side. -- Duplicate VaR implementations with inconsistent conventions -- Any `abs()` band-aids used to fix sign issues -- Any risk calculations performed inside `app/app.py` -- Extra features not required for validation sandbox V1 (e.g., ERC weights, marginal VaR decomposition) +--- + +## Definition of done -**Rule:** If it’s not part of "measure → validate → document", it moves to `extras.py` or is marked deprecated. +| Criterion | Status | +|---|---| +| One loss convention, losses positive | Enforced as a runtime invariant | +| ES ≥ VaR | Tested for all four methods | +| VaR₉₉ ≥ VaR₉₅ | Tested for all four methods | +| Backtest returns exceptions, expected exceptions, Kupiec p | Plus Christoffersen and joint CC | +| `app/app.py` calls the engine and computes nothing | Inline Kupiec removed; contract-tested | +| No duplicate implementations | `risk_engine/` deleted; single GARCH recursion | +| Published results reproducible | Generated by script, verified in CI | +| Test coverage | 5 tests → 129 tests | --- -## Definition of Done (What a model-risk reviewer expects) -- One loss convention (losses positive) -- ES ≥ VaR (tested) -- 99% VaR ≥ 95% VaR (tested) -- Backtest returns exceptions, expected exceptions, Kupiec p-value -- `app/app.py` calls the engine; it does not compute risk - -# Migration Map: risk_engine/market.py → risklib/ - -## Move to `risklib/market/market_risk_model.py` (Measurement) -- portfolio_returns -- var_parametric -- es_parametric -- var_historical -- es_historical -- _cov_shrink -- var_es_monte_carlo -- garch11_filter -- garch11_forecast_sigma_next -- fhs_var_es_next - -## Move to `risklib/market/backtest.py` (Validation) -- kupiec_pof -- backtest_var_historical -- backtest_fhs_var -- (optional) backtest_var utility wrapper - -## Move to `risklib/market/extras.py` (Out of Scope for V1) -- var_parametric_normal_parts -- incremental_var_normal -- erc_weights_from_cov -- erc_weights -- normalize_weights / _normalize_weights (keep one) +## Bugs surfaced by the migration + +Consolidation exposed defects that the duplication had been hiding: + +1. `fit_garch=True` was inert — never forwarded from config to estimator +2. Monte Carlo `n_sims` / `seed` / shrinkage were collected but never reached the computation +3. `assumptions()` reported "fixed-parameter GARCH" even under MLE +4. `load_prices` mutated its frame while iterating its own column index +5. Rate shocks applied a 7-year default duration to equities +6. Portfolio EL/EAD was an equal-weighted mean of ratios, inconsistent with its own subtotals +7. The FHS backtest derived ω from the full sample, contaminating every threshold +8. Correlation bumping could push the matrix outside the PSD cone +9. ERC clipped weights and then renormalised, violating the stated cap +10. GARCH log-likelihood, AIC and BIC omitted the 2π constant +11. Currency parsing silently stopped working under pandas 3.0's dedicated string dtype + +Items 9 and 11 were found by tests written during the migration, not by reading +the code — which is the argument for writing them. diff --git a/README.md b/README.md index 46c0ce5..c0b64a1 100644 --- a/README.md +++ b/README.md @@ -1,321 +1,257 @@ -# Integrated Risk App (Python) - -**Validation-Grade Market & Credit Risk Engine** - -![Python](https://img.shields.io/badge/Python-3.10%2B-blue) +# Integrated Risk App + +**Validation-grade market and credit risk engine** + +![Python](https://img.shields.io/badge/Python-3.11%2B-blue) ![License](https://img.shields.io/badge/License-MIT-green) -![Status](https://img.shields.io/badge/Status-Active-brightgreen) -[![Live App](https://img.shields.io/badge/Live%20App-Streamlit-red)](https://integrated-risk-app.onrender.com/) - +![Tests](https://img.shields.io/badge/tests-129-brightgreen) + --- - -## 🎯 Project Objective - -This project is a **model-risk–oriented risk analytics engine** designed to **measure, validate, and document** market and credit risk models in a manner consistent with institutional risk management and model validation practices. - -The objective is **not** to build a trading system or dashboard-centric application, but to demonstrate: - -- Sound quantitative risk methodology -- Explicit assumptions and documented loss conventions -- Clear separation between **model logic**, **configuration**, and **presentation** -- Standard validation and backtesting diagnostics consistent with SR 11-7 and OSFI E-23 guidance -This repository functions as a **model risk validation sandbox** and work sample for roles in market risk, model validation, and risk analytics. - + +## What this is + +A model-risk–oriented risk analytics engine that **measures, validates and +documents** market and credit risk models the way an institutional risk function +would. + +It is not a trading system and not a dashboard. The organising question is not +"what is the number?" but "**is the number any good, and how would you know?**" + +Three things follow from that: + +- **Every published figure is reproducible.** The results in + [`docs/validation_results.md`](docs/validation_results.md) are generated from + the data committed in `data/` by `scripts/generate_validation_results.py`, and + CI fails if the committed results drift from what the code produces. +- **Conventions are enforced, not documented.** VaR ≥ 0 and ES ≥ VaR are runtime + invariants that raise, not notes in a README. +- **Assumptions travel with results.** `summary()` returns the risk numbers, the + full configuration, and the assumptions actually in force for that + configuration. + --- - -## 📌 Scope - -### Market Risk -- Value-at-Risk (VaR) and Expected Shortfall (ES) -- Four methodologies: Historical Simulation, Parametric (Normal), Monte Carlo, Filtered Historical Simulation (GARCH-lite, fixed or MLE-estimated params) -- Multi-confidence-level analysis (95%, 97.5%, 99%) -- Rolling-window estimation -- Out-of-sample backtesting: **Kupiec POF**, **Christoffersen independence**, and **joint conditional coverage** (LR_cc ~ χ²(2)) -- VaR decomposition: marginal, component, and incremental VaR (Euler allocation) -- Equal Risk Contribution (ERC) risk budgeting -### Credit Risk -- Expected Loss (EL) framework: PD × LGD × EAD -- Portfolio-level aggregation and segment-level decomposition -- Scenario shock capability (PD multiplier, additive basis points, LGD stress) -### Stress & Scenario Analysis -- Single-name equity shocks -- Interest rate shocks via duration approximation -- Covariance scaling (volatility + correlation stress) -- Historical window replay + +## Scope + +**Market risk** — VaR and Expected Shortfall by four methods (historical +simulation, parametric Normal, Monte Carlo, Filtered Historical Simulation with +a GARCH(1,1) filter); multi-α analysis; rolling out-of-sample backtesting with +Kupiec POF, Christoffersen independence, and joint conditional coverage; Euler +VaR decomposition (marginal and component); true incremental VaR; Equal Risk +Contribution budgeting. + +**Credit risk** — Expected Loss (PD × LGD × EAD) with portfolio and segment +aggregation, multiplicative and additive scenario shocks, and data-quality +reporting on out-of-range inputs. + +**Stress and scenarios** — single-name shocks, parallel rate shocks via duration +approximation, covariance scaling, correlation-breakdown stress with PSD +projection, and historical window replay. + --- - -## 📊 Validation Results - -> Results generated using a 4-asset portfolio: **SPY 25% | QQQ 25% | TLT 25% | GLD 25%** -> **Exposure: $1,000,000 | Data: Jan 2020 – Dec 2024 | 1,258 daily observations** -> Parameters calibrated to observed market behaviour (SPY ~19% vol, QQQ ~24%, TLT ~15%, GLD ~13%). - -### Point Risk Measures — 1-Day Horizon - -| Method | VaR @ 95% | ES @ 95% | VaR @ 99% | ES @ 99% | -|---|---:|---:|---:|---:| -| Historical Simulation | $10,305 | $13,223 | $15,516 | $16,926 | -| Parametric (Normal) | $10,621 | $13,375 | $15,113 | $17,347 | -| Monte Carlo (100k sims) | $10,575 | $13,312 | $15,061 | $17,231 | -| Filtered Hist. (GARCH-lite) | $12,481 | $16,072 | $18,077 | $20,628 | - -> **Interpretation:** The FHS/GARCH model produces materially higher estimates than static methods, reflecting its sensitivity to recent volatility clustering. The parametric and historical methods converge closely at 95%, diverging at 99% where distributional tail assumptions matter more. ES consistently exceeds VaR as required under the loss convention invariant enforced by the model object. - ---- - -### Backtesting — Rolling Historical VaR (250-day window) - -Three tests are reported. **Kupiec POF** (unconditional coverage) tests whether exception frequency equals (1−α). **Christoffersen independence** tests whether exceptions cluster in time. **Joint conditional coverage** (LR_cc = LR_uc + LR_ind ~ χ²(2)) combines both. - -| α | OOS (T) | Exceed. | Hit % | Exp % | Kupiec LR | p | Christ. LR | p | Joint LR | p | Result | -|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---| -| 95.0% | 1,008 | 59 | 5.85% | 5.00% | 1.468 | 0.2257 | 0.092 | 0.7614 | 1.560 | 0.4584 | ✅ PASS | -| 97.5% | 1,008 | 27 | 2.68% | 2.50% | 0.129 | 0.7196 | 0.100 | 0.7520 | 0.229 | 0.8919 | ✅ PASS | -| 99.0% | 1,008 | 15 | 1.49% | 1.00% | 2.109 | 0.1464 | 0.454 | 0.5006 | 2.563 | 0.2776 | ✅ PASS | - -> **Interpretation:** All three confidence levels pass all three tests at the 5% significance level. The Christoffersen independence test is particularly important: it detects whether exceptions cluster in time — a failure mode invisible to Kupiec alone. High p-values on the independence test (0.50–0.76) confirm exceptions are well-distributed across the sample period, not concentrated during stress events. The joint conditional coverage test combines both criteria; all p-values are well above 0.05. - ---- - -### VaR Decomposition — Component VaR (Normal, α=95%) - -| Asset | Weight | Component VaR | % of Portfolio VaR | -|---|---:|---:|---:| -| SPY | 25.0% | $3,913 | 36.8% | -| QQQ | 25.0% | $5,005 | 47.1% | -| TLT | 25.0% | $409 | 3.9% | -| GLD | 25.0% | $1,294 | 12.2% | -| **Total** | **100%** | **$10,621** | **100.0%** | - -> **Interpretation:** Despite equal weighting, QQQ contributes ~47% of total VaR due to its higher volatility (~23% annualised) and strong co-movement with SPY (ρ = 0.86). TLT's negative correlation with equities (ρ = −0.30 with SPY) reduces its risk contribution to under 4% of the portfolio total — a meaningful diversification effect. Component VaR sums to portfolio VaR under the Euler allocation. - ---- - -### Stress Scenarios - -| Scenario | Description | Portfolio Impact | -|---|---|---:| -| Equity shock | SPY + QQQ each −20%, TLT/GLD flat | **−$100,000** | -| Rate shock | +200bp parallel shift, TLT duration 18y | **−$90,000** | -| Vol/correlation stress | Covariance matrix ×2 | VaR: $10,575 → $15,246 (**+$4,671**) | - ---- - -### Portfolio Summary Statistics - -| Metric | Value | -|---|---| -| Annualised Return | 5.57% | -| Annualised Volatility | 10.46% | -| Sharpe Ratio (RF = 0) | 0.53 | -| SPY annualised vol | 18.55% | -| QQQ annualised vol | 23.02% | -| TLT annualised vol | 15.18% | -| GLD annualised vol | 12.94% | - -### Asset Correlation Matrix - -| | SPY | QQQ | TLT | GLD | -|---|---:|---:|---:|---:| -| SPY | 1.000 | 0.859 | −0.302 | 0.072 | -| QQQ | 0.859 | 1.000 | −0.251 | 0.038 | -| TLT | −0.302 | −0.251 | 1.000 | 0.089 | -| GLD | 0.072 | 0.038 | 0.089 | 1.000 | - ---- - -## ⚠️ Known Model Limitations - -This section documents known weaknesses, as required under institutional model risk standards (SR 11-7 / OSFI E-23). Resolved items are retained for transparency and audit trail. - -**1. ~~Unconditional coverage only~~ — ✅ Resolved** -~~The backtesting framework implements Kupiec POF only.~~ -`risklib/market/backtest.py` now implements the full **Christoffersen (1998)** test suite: `christoffersen_independence()` tests H₀ that exceptions are serially independent (no clustering), and `joint_coverage_test()` combines Kupiec and Christoffersen into the joint conditional coverage statistic LR_cc ~ χ²(2). All three tests are returned by `backtest_var_historical()` and displayed in the validation results above. - -**2. IID and stationarity assumptions** -All methods assume i.i.d. returns within the rolling window and stationarity of the return distribution. These assumptions are violated during volatility regime changes. The GARCH-lite filter partially addresses this for the FHS method only. - -**3. ~~Fixed GARCH parameter estimation~~ — ✅ Resolved** -~~The GARCH(1,1) filter uses fixed parameters (α = 0.05, β = 0.94) rather than MLE-estimated parameters.~~ -`risklib/market/garch_mle.py` now provides `fit_garch11_mle()` and `garch11_filter_mle()`, implementing MLE estimation via `scipy.optimize` (L-BFGS-B, multiple restarts). Parameters are estimated in unconstrained space with transformations enforcing stationarity (α + β < 1). Enabled via `fit_garch=True` in `MarketRiskConfig` — default `False` preserves existing behaviour. Validation on simulated data with known parameters (α=0.08, β=0.91) showed MLE error on α of 8.3% vs 37.5% for fixed defaults, and a sigma path 1.36pp more correlated with the true conditional volatility path. - -**4. Multivariate normality (Monte Carlo)** -The Monte Carlo method assumes a multivariate normal distribution for joint asset returns. Empirical return distributions exhibit excess kurtosis and negative skewness, meaning tail losses are likely underestimated at high confidence levels (99%+). - -**5. 1-day horizon scaling** -Multi-day VaR is approximated via square-root-of-time scaling (√h). This assumption holds only if returns are i.i.d. normal — it underestimates risk when volatility is autocorrelated. - -**6. Credit model scope** -The credit EL framework computes point-in-time Expected Loss using user-supplied PD/LGD/EAD inputs. It does not estimate PD from historical default data (e.g. via logistic regression or scorecard), does not model loss distributions (only expected values), and does not compute Unexpected Loss or Economic Capital. - ---- - -## 🧱 Repository Structure - + +## Quickstart + +```bash +git clone https://github.com/sensor-aae/Integrated-Risk-App.git +cd Integrated-Risk-App + +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt + +streamlit run app/app.py ``` -risklib/ - market/ - market_risk_model.py # MarketRiskModel class + MarketRiskConfig (fit_garch flag) - market.py # Risk primitives: VaR, ES, backtest, GARCH, ERC - backtest.py # Kupiec + Christoffersen + joint conditional coverage tests - garch_mle.py # MLE GARCH(1,1) estimation (scipy.optimize, L-BFGS-B) - credit/ - credit_risk_model.py # EL pipeline: validate → shock → compute → summarize -risk_engine/ # Thin wrappers used by Streamlit app -app/ - app.py # Streamlit UI — presentation only, no risk logic -docs/ - model_report.md # Full model methodology and validation notes -tests/ # Unit tests for model invariants -notebooks/ # Exploratory analysis + +Upload `data/market_data.csv` for market risk and `data/credit_example.csv` for +credit. To reproduce every published number: + +```bash +pip install -r requirements-dev.txt +pytest -q +python scripts/generate_validation_results.py ``` - -### Design Principles - -- **`risklib/` is the source of truth** — All modelling, estimation, and validation logic lives here -- **`app/` is presentation-only** — The UI calls `risklib` and visualizes outputs; it computes nothing directly -- **Loss-based convention enforced** — All outputs are positive loss amounts; the `MarketRiskModel` raises if VaR < 0 or ES < VaR + --- - -## 🔍 Market Risk Model - + +## Using the engine directly + ```python -from risklib.market.market_risk_model import MarketRiskModel, MarketRiskConfig - -# Standard FHS with fixed GARCH params (default) +import numpy as np +from risklib.data import load_prices, to_returns +from risklib.market import MarketRiskConfig, MarketRiskModel, backtest_var_historical + +returns = to_returns(load_prices("data/market_data.csv"), method="log") +weights = np.ones(returns.shape[1]) / returns.shape[1] + cfg = MarketRiskConfig( alpha=0.99, - method="fhs", - horizon_days=1, - exposure=1_000_000, -) - -# FHS with MLE-estimated GARCH params (data-driven) -cfg_mle = MarketRiskConfig( - alpha=0.99, - method="fhs", + method="fhs", # historical | parametric | monte_carlo | fhs horizon_days=1, exposure=1_000_000, - fit_garch=True, # estimates omega, alpha, beta via MLE + fit_garch=True, # estimate omega, alpha, beta by MLE ) - -model = MarketRiskModel(returns, weights, cfg) -model.fit() - -var = model.compute_var() # e.g. 18,077 -es = model.compute_es() # e.g. 20,628 -summary = model.summary() # includes assumptions, config metadata + +model = MarketRiskModel(returns, weights, cfg).fit() + +model.compute_var() # positive loss amount +model.compute_es() +model.summary() # VaR, ES, full config, assumptions in force +model.fit_info_["source"] # "MLE" or "fixed" — estimated vs assumed + +bt = backtest_var_historical(returns, weights, alpha=0.99, window=250) +bt["kupiec_pvalue"], bt["christoffersen_pvalue"], bt["joint_pvalue"] +``` + +`MarketRiskConfig` carries every parameter that changes a number — confidence +level, horizon, exposure, backtest window, Monte Carlo paths and seed, +covariance shrinkage, and the GARCH settings. It is one serialisable object you +can log, diff and attach to a result, which is the difference between "we ran a +VaR" and "we ran *this* VaR". + +--- + +## Repository structure + +``` +risklib/ # single source of truth for all modelling + data.py # ingestion: prices -> returns + market/ + market_risk_model.py # estimators, MarketRiskConfig, MarketRiskModel + backtest.py # Kupiec + Christoffersen + joint CC; both backtests + garch.py # one GARCH(1,1) recursion: fixed and MLE + extras.py # Euler decomposition, incremental VaR, ERC + scenarios.py # stress testing and scenario library + credit/ + credit_risk_model.py # EL pipeline: validate -> shock -> compute -> summarise + +app/app.py # Streamlit interface — presentation only +scripts/ + generate_validation_results.py # regenerates docs/validation_results.md +docs/ + model_report.md # methodology, assumptions, limitations + validation_results.md # GENERATED — do not edit by hand +tests/ # 129 tests +data/ # demo market and credit data ``` - -The `MarketRiskModel` enforces two invariants at `fit()` time: -- `VaR >= 0` (loss convention) -- `ES >= VaR` (coherence requirement) -A `ValueError` is raised if either condition is violated, surfacing methodology errors early. - + +**Design rules** + +- `risklib/` holds all modelling, estimation and validation logic +- `app/` computes nothing — it collects inputs, calls `risklib`, and visualises +- Losses are positive throughout, enforced at `fit()` time + --- - -## 🧪 Validation & Testing - -Unit tests verify model invariants independently of data: - -- VaR monotonicity across confidence levels (VaR₉₉ > VaR₉₅) -- ES ≥ VaR under consistent loss convention -- Correct exception counting in rolling backtests -- Credit EL aggregation consistency (sum of facility EL = portfolio EL) -- Component VaR sums to portfolio VaR under Euler allocation -Backtesting is conducted **out-of-sample** using a trailing window to prevent look-ahead bias. The VaR threshold at time *t* is estimated from returns up to *t−1* only. - + +## Validation results + +Full tables — point measures, GARCH parameter comparison, backtests across three +confidence levels, Euler decomposition, ERC, stress scenarios, and credit EL — +are in **[`docs/validation_results.md`](docs/validation_results.md)**, generated +from the shipped demo portfolio (AAPL / TLT / MSFT, equal-weighted, $1,000,000 +exposure, 1,257 daily observations from January 2020 to December 2024). + +A few results worth calling out: + +**All three tests pass at every confidence level**, for both the historical and +FHS backtests. The independence p-values are the interesting ones — they confirm +exceptions are distributed across the sample rather than concentrated in stress +periods, which Kupiec alone cannot detect. + +**Estimated GARCH parameters differ materially from the convention.** The fixed +defaults (α = 0.05, β = 0.94) imply persistence of 0.9900; MLE estimates 0.9665 +on this sample. The assumed parameters overstate volatility persistence, which +is exactly the misspecification the MLE path exists to address. + +**Component VaR sums to portfolio VaR to 3.6 × 10⁻¹²** — the Euler identity is +exact, not approximate, because VaR is homogeneous of degree 1 in the weights. + +**ERC substantially rebalances the book.** Under equal weighting the two equities +contribute 93% of portfolio VaR between them; ERC moves the bond from 33% to 54% +of the portfolio and cuts VaR by roughly 20%. + --- - -## 🧠 Methodology - + +## Validation and testing + +129 tests. The suite asserts **model invariants** rather than frozen numbers, so +the assertions hold for any input and do not break when a default changes. + +The test worth reading is `test_christoffersen_rejects_clustering`. It builds an +exception series with the **correct total count** but bunched into one contiguous +block, then asserts that Kupiec passes it with p > 0.9 and Christoffersen rejects +it at p < 0.001. That contrast is the entire argument for implementing the +independence test. + +Also covered: no-look-ahead verification (the threshold at *t* is recomputed by +hand from *t−1* and compared), GARCH MLE parameter recovery on simulated data, +the Euler summation identity, credit EL aggregation consistency, and a +contract-test file pinning every dictionary key the Streamlit app reads — so a +rename in the engine fails in CI rather than in front of a user. + +CI runs the suite on Python 3.11 and 3.12, verifies the package installs cleanly, +and re-runs the validation script to confirm the published results still +reproduce. + +--- + +## Methodology summary + | Model | Formula | Notes | |---|---|---| -| VaR (Historical) | −Q₁₋ₐ(r_p) × exposure | Empirical quantile of portfolio returns | -| VaR (Parametric) | (−μ_p + z_α × σ_p) × exposure | Assumes normality | -| ES (Parametric) | (−μ_p + σ_p × φ(z_α)/(1−α)) × exposure | Closed-form under normality | -| VaR (Monte Carlo) | Empirical quantile of 100k simulated paths | Multivariate normal with covariance shrinkage | -| VaR (FHS) | −q_z × σ_{t+1} × exposure | GARCH-standardised residuals, one-step-ahead forecast; σ estimated by MLE or fixed params | -| Expected Loss | PD × LGD × EAD | Per-facility; aggregated to portfolio/segment level | - +| VaR (historical) | −Q₁₋α(r_p) × E | Empirical quantile; no distributional assumption | +| VaR (parametric) | (−μ_p + z_α σ_p) × E | Closed form under normality | +| ES (parametric) | (−μ_p + σ_p φ(z_α)/(1−α)) × E | Closed-form tail expectation | +| VaR (Monte Carlo) | Empirical quantile of simulated paths | Multivariate normal, covariance shrinkage | +| VaR (FHS) | −q_z × σ_{t+1} × E | GARCH-standardised residuals, one-step-ahead | +| Component VaR | w_i · ∂VaR/∂w_i | Euler allocation; sums exactly to portfolio VaR | +| Expected Loss | PD × LGD × EAD | Per facility, aggregated to segment and portfolio | + +Full derivations, assumptions and limitations: **[`docs/model_report.md`](docs/model_report.md)**. + --- - -## 🚫 Out of Scope (By Design) - -This project does **not** attempt to be: -- A trading or portfolio optimisation system -- A real-time production risk engine -- A regulatory-approved model -Deferred extensions: factor models, ALM, CVA, portfolio optimisation, multi-step GARCH forecasting. - + +## Known limitations + +Documented in full in [`docs/model_report.md`](docs/model_report.md#6-assumptions-and-limitations), +with resolved items retained as an audit trail. The open ones: + +- i.i.d. and stationarity assumptions, violated across volatility regime changes +- Multivariate normality in Monte Carlo understates tails at high confidence +- √h horizon scaling understates risk when volatility is autocorrelated +- The rolling FHS backtest retains fixed GARCH parameters +- Zero conditional mean in the GARCH filter +- Duration approximation ignores convexity +- Credit model computes Expected Loss only — no loss distribution, no economic capital +- Results derive from a single demo portfolio and demonstrate reproducibility, + not general model performance + --- - -## 🖥 Application Interface - -A live **Streamlit app** is deployed at **[integrated-risk-app.onrender.com](https://integrated-risk-app.onrender.com/)**. - -The UI allows a user to: -- Upload a prices CSV or exposures CSV -- Select method, confidence level, horizon, and exposure -- View VaR/ES point estimates, backtest chart with exception markers, Kupiec results -- Run stress scenarios and what-if weight analysis -- Export a Markdown risk report and CSV decompositions -All modelling logic remains in `risklib/`. The app is a viewer only. - + +## Out of scope by design + +Trading and portfolio optimisation, real-time production risk, regulatory capital +calculation. Deferred: factor models, ALM, CVA, copula simulation, PD estimation +from default history, multi-step GARCH forecasting. + --- - -## ⚙️ Tech Stack - + +## Tech stack + | Layer | Libraries | |---|---| -| Risk engine | NumPy, Pandas, SciPy, Statsmodels | -| Visualisation | Plotly | -| UI | Streamlit | -| Data (demo) | yfinance | +| Engine | NumPy, pandas, SciPy | +| Interface | Streamlit, Plotly | | Tests | pytest | - ---- - -## ⚡ Quickstart - -```bash -git clone https://github.com/sensor-aae/Integrated-Risk-App.git -cd Integrated-Risk-App - -python -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate - -pip install -r requirements.txt -streamlit run app/app.py -``` - ---- - -## 📄 Documentation - -A full **Model Risk Report** (`docs/model_report.md`) covers: -- Methodology and theoretical basis -- Assumptions and their implications -- Validation results and test statistics -- Known limitations and areas for improvement -This mirrors institutional model documentation standards. - ---- - -## ⭐ Why This Project Exists - -This repository is designed as a **work sample** for roles in: - -- Market Risk -- Model Risk / Model Validation -- Credit Risk Analytics -- Pension & Institutional Investment Risk -- Risk Consulting -It reflects how quantitative risk models are **built, tested, challenged, and reviewed** — not just how they are computed. - + +The core VaR and backtesting math depends on neither SciPy nor statsmodels — +Normal quantiles come from `statistics.NormalDist` and χ² p-values from exact +closed forms (`erfc(√(x/2))` for one degree of freedom, `exp(−x/2)` for two). +SciPy is required only for GARCH maximum likelihood. + --- - -## ⚠️ Disclaimer - -This project is for educational and demonstrative purposes only. It is **not** intended for production use or investment decision-making. All results shown are generated from simulated data calibrated to approximate market conditions; they do not constitute forecasts. - + +## Disclaimer + +Educational and demonstrative. Not intended for production use or investment +decision-making, and not a regulatory-approved model. diff --git a/app/app.py b/app/app.py index 265d8c9..4e58e88 100644 --- a/app/app.py +++ b/app/app.py @@ -1,834 +1,854 @@ # app/app.py """ -UI RULE: -- This file must not compute VaR/ES/EL directly. -- It may only call risklib models/backtests and visualize the outputs. -- If risk math is needed, implement it in risklib/ and import it here. +Integrated Risk App — Streamlit interface. + +UI RULE +------- +This file computes NO risk numbers. It collects inputs, calls `risklib`, and +visualises what comes back. Every statistic on screen is produced by the engine +and can be reproduced outside the app by constructing the same MarketRiskConfig. + +If a calculation is needed and does not exist, it goes in `risklib/` — not here. +(An earlier version of this file carried its own inline Kupiec implementation, +which is exactly the drift this rule exists to prevent.) """ + from pathlib import Path import sys + +# The repo root must be on the path before risklib can be imported, so the +# imports below deliberately sit after this line (flake8 E402). sys.path.append(str(Path(__file__).resolve().parents[1])) -import math -import streamlit as st -import numpy as np -import pandas as pd -import plotly.graph_objects as go -import plotly.express as px - -from risk_engine.data import load_prices, to_returns -from risk_engine.market import ( - var_parametric, es_parametric, - var_historical, es_historical, +import io # noqa: E402 + +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import plotly.express as px # noqa: E402 +import plotly.graph_objects as go # noqa: E402 +import streamlit as st # noqa: E402 + +from risklib.credit import compute_el_table, summarize_el # noqa: E402 +from risklib.data import load_prices, to_returns # noqa: E402 +from risklib.market import ( # noqa: E402 + DEFAULT_DURATIONS, + MarketRiskConfig, + MarketRiskModel, + backtest_var_fhs, backtest_var_historical, - var_es_monte_carlo, - mc_portfolio_loss_from_mu_cov, - fhs_var_es_next, backtest_fhs_var, - incremental_var_normal, var_parametric_normal_parts, - erc_weights -) -from risk_engine.stress import ( - apply_single_name_shocks, scale_covariance, historical_window_mu_cov + erc_weights, + incremental_var, + scenario_corr_bump_mc, + scenario_covariance_scale, + scenario_equities_shock, + scenario_historical_replay, + scenario_rates_bp, + scenario_single_name, + var_parametric_normal_parts, ) -from risk_engine.credit import compute_el_table, summarize_el -from risk_engine.scenarios import scenario_equities_shock, scenario_rates_bp, scenario_corr_bump_mc -from risklib.market.market_risk_model import MarketRiskModel, MarketRiskConfig -from risklib.market.backtest import backtest_var_historical -# ================================================================ +METHOD_LABELS = { + "Historical Simulation": "historical", + "Parametric (Normal)": "parametric", + "Monte Carlo": "monte_carlo", + "Filtered Historical (GARCH)": "fhs", +} + +RATE_SENSITIVE_HINT = ("TLT", "IEF", "AGG", "BND", "EDV", "ZROZ", "SHY", "TIP", "LQD", "HYG") + + +# =========================================================================== # CACHE LAYER -# All expensive computations live here. Each function is keyed on -# exactly the inputs that change its output — Streamlit hashes -# DataFrames by value automatically; weights are passed as a tuple. -# ================================================================ +# +# Streamlit re-executes this script top to bottom on every widget interaction. +# Without caching, nudging the alpha slider would re-parse the CSV, refit the +# model, re-run the rolling backtest and re-simulate 100,000 Monte Carlo paths. +# +# Two constraints shape the signatures below: +# - The uploaded-file object is not stably hashable across reruns, so the raw +# BYTES are passed instead. +# - NumPy arrays are unhashable, so weights arrive as a tuple and are +# converted inside. +# =========================================================================== @st.cache_data(show_spinner=False) -def _cached_load(file_bytes: bytes, ret_method: str, date_col: str): - """Parse the uploaded CSV once; re-runs only when the file or - method/date-col settings change.""" - import io - prices = load_prices(io.BytesIO(file_bytes), data_col=(date_col or None)) +def cached_load(file_bytes: bytes, ret_method: str, date_col: str): + prices = load_prices(io.BytesIO(file_bytes), date_col=(date_col or None)) returns = to_returns(prices, method=ret_method) return prices, returns @st.cache_data(show_spinner=False) -def _cached_var_es( - returns: pd.DataFrame, - weights_t: tuple, - alpha: float, - horizon: int, - exposure: float, - method_str: str, -) -> tuple: - """Fit MarketRiskModel and return (var_val, es_val).""" - weights = np.array(weights_t) - cfg = MarketRiskConfig( - alpha=alpha, - method=method_str, - horizon_days=horizon, - exposure=exposure, - ) - model = MarketRiskModel(returns, weights, cfg) +def cached_risk(returns: pd.DataFrame, weights_t: tuple, cfg_kwargs: dict) -> dict: + """ + Fit MarketRiskModel and return its full summary. + + The whole config is the cache key, so every parameter that changes a number + also busts the cache. Previously only alpha/horizon/exposure/method were + keyed, and the Monte Carlo and GARCH settings were not passed at all. + """ + model = MarketRiskModel(returns, np.array(weights_t), MarketRiskConfig(**cfg_kwargs)) model.fit() - return model.compute_var(), model.compute_es() + return model.summary() @st.cache_data(show_spinner=False) -def _cached_backtest( - returns: pd.DataFrame, - weights_t: tuple, - alpha: float, - window: int, -) -> dict: - """Rolling historical VaR backtest.""" - weights = np.array(weights_t) - return backtest_var_historical( - returns=returns, weights=weights, alpha=alpha, window=window - ) +def cached_backtest_hist(returns: pd.DataFrame, weights_t: tuple, alpha: float, window: int) -> dict: + return backtest_var_historical(returns, np.array(weights_t), alpha=alpha, window=window) @st.cache_data(show_spinner=False) -def _cached_var_decomp( - returns: pd.DataFrame, - weights_t: tuple, - alpha: float, - horizon: int, - exposure: float, -) -> dict: - """var_parametric_normal_parts — shared by Analytics, What-if, Report.""" - weights = np.array(weights_t) - return var_parametric_normal_parts( - returns, weights, alpha=alpha, horizon_days=horizon, exposure=exposure - ) +def cached_backtest_fhs(returns: pd.DataFrame, weights_t: tuple, alpha: float, + window: int, alpha_g: float, beta_g: float) -> dict: + return backtest_var_fhs(returns, np.array(weights_t), alpha=alpha, window=window, + alpha_g=alpha_g, beta_g=beta_g) @st.cache_data(show_spinner=False) -def _cached_incremental_var( - returns: pd.DataFrame, - weights_t: tuple, - alpha: float, - horizon: int, - exposure: float, -) -> dict: - weights = np.array(weights_t) - return incremental_var_normal( - returns, weights, alpha=alpha, horizon_days=horizon, exposure=exposure - ) +def cached_decomp(returns: pd.DataFrame, weights_t: tuple, alpha: float, + horizon: int, exposure: float) -> dict: + return var_parametric_normal_parts(returns, np.array(weights_t), alpha=alpha, + horizon_days=horizon, exposure=exposure) + + +@st.cache_data(show_spinner=False) +def cached_incremental(returns: pd.DataFrame, weights_t: tuple, alpha: float, + horizon: int, exposure: float) -> dict: + return incremental_var(returns, np.array(weights_t), alpha=alpha, + horizon_days=horizon, exposure=exposure) @st.cache_data(show_spinner=False) -def _cached_corr(returns: pd.DataFrame, lookback: int) -> pd.DataFrame: - """Correlation matrix for Analytics tab.""" +def cached_corr(returns: pd.DataFrame, lookback: int) -> pd.DataFrame: r_slice = returns.tail(lookback) if len(returns) >= lookback else returns return r_slice.corr() -@st.cache_data(show_spinner=False) -def _cached_cov_mu(returns: pd.DataFrame, horizon: int): - """Annualised (horizon-scaled) mu and cov used by stress tests.""" - mu = returns.mean().values * horizon - cov = returns.cov().values * horizon - return mu, cov +def pass_fail(p: float, level: float = 0.05) -> str: + return "PASS" if p > level else "FAIL" + +# =========================================================================== +# SIDEBAR — the single source of every parameter +# +# Each control appears exactly ONCE. Previously the Monte Carlo simulation +# count, seed and backtest window were declared both here and again inside +# individual tabs, so the same conceptual setting had two independent values +# and the sidebar copy silently governed nothing. +# =========================================================================== -# ================================================================ -# UI CONFIG -# ================================================================ -st.set_page_config(page_title="Risk App", layout="wide") -st.title("Integrated Risk App (Python)") +st.set_page_config(page_title="Integrated Risk App", layout="wide") +st.title("Integrated Risk App") +st.caption("Market and credit risk measurement, validation and stress testing. " + "All calculations are performed by `risklib`; this interface only displays them.") -# ---------------- SIDEBAR ---------------- -st.sidebar.header("1) Portfolio") +st.sidebar.header("1 · Portfolio") ret_method = st.sidebar.selectbox("Return type", ["log", "simple"], index=0) weights_mode = st.sidebar.selectbox("Weights", ["Equal", "Manual by column name"]) -alpha = st.sidebar.slider("Confidence (α)", 0.80, 0.999, 0.95, 0.001) -horizon = st.sidebar.number_input("Horizon (days)", 1, 30, 1) -exposure = st.sidebar.number_input("Exposure", 0.0, 1e12, 1_000_000.0, step=1000.0) - -st.sidebar.header("2) Method") -method_choice = st.sidebar.radio( - "Method", - ["Historical", "Parametric (Normal)", "Monte Carlo", "Filtered Historical (GARCH-lite)"], - index=0, - key="method_choice" +alpha = st.sidebar.slider("Confidence (α)", 0.80, 0.999, 0.99, 0.001) +horizon = int(st.sidebar.number_input("Horizon (days)", 1, 30, 1)) +exposure = float(st.sidebar.number_input("Exposure", 0.0, 1e12, 1_000_000.0, step=1000.0)) + +st.sidebar.header("2 · Method") +method_label = st.sidebar.radio("Method", list(METHOD_LABELS), index=0) +method = METHOD_LABELS[method_label] + +st.sidebar.header("3 · Simulation") +st.sidebar.caption("Used by Monte Carlo VaR and every scenario that re-simulates.") +n_sims = int(st.sidebar.number_input("Simulations", 5_000, 1_000_000, 100_000, step=5_000)) +seed = int(st.sidebar.number_input("Random seed", 0, 10_000, 42, step=1)) +shrink_lambda = st.sidebar.slider("Covariance shrinkage (λ)", 0.0, 0.2, 0.01, 0.005) + +st.sidebar.header("4 · GARCH") +fit_garch = st.sidebar.checkbox( + "Estimate parameters by MLE", + value=False, + help="Off: variance targeting on the fixed α/β below. " + "On: ω, α and β are estimated from the data by maximum likelihood.", ) +if fit_garch: + alpha_g, beta_g = 0.05, 0.94 # retained for the FHS backtest, which uses fixed params + st.sidebar.caption("Point estimates use MLE. The rolling FHS backtest keeps fixed " + "parameters — re-estimating at every step is a separate exercise.") +else: + alpha_g = float(st.sidebar.number_input("GARCH α (ARCH)", 0.0, 0.5, 0.05, 0.01)) + beta_g = float(st.sidebar.number_input("GARCH β (GARCH)", 0.0, 0.999, 0.94, 0.01)) + if alpha_g + beta_g >= 1.0: + st.sidebar.error(f"α + β = {alpha_g + beta_g:.3f} ≥ 1 — the process is " + "non-stationary. Reduce one of them.") -n_sims = seed = shrink = None -alpha_g = beta_g = None -if method_choice == "Monte Carlo": - n_sims = st.sidebar.number_input("MC simulations", min_value=5_000, value=50_000, step=5_000) - seed = st.sidebar.number_input("Random seed", min_value=0, value=42, step=1) - shrink = st.sidebar.slider("Covariance shrinkage (λ)", 0.0, 0.2, 0.01, 0.005) -if method_choice == "Filtered Historical (GARCH-lite)": - alpha_g = st.sidebar.number_input("GARCH α (ARCH)", min_value=0.0, max_value=0.5, value=0.05, step=0.01) - beta_g = st.sidebar.number_input("GARCH β (GARCH)", min_value=0.0, max_value=0.999, value=0.94, step=0.01) +st.sidebar.header("5 · Backtest") +bt_window = int(st.sidebar.number_input("Rolling window (days)", 50, 1000, 250, step=10)) -st.sidebar.header("3) Backtest") -bt_window = st.sidebar.number_input("Rolling window (days)", min_value=50, value=250, step=10) -# ---------------- MARKET DATA INPUT ---------------- +# =========================================================================== +# MARKET DATA +# =========================================================================== + st.markdown("### Market data") -st.caption("Upload a prices CSV (Date column + one column per ticker). This powers the market risk, backtests, analytics, scenarios, ERC, and report features.") -file = st.file_uploader("Upload prices CSV", type=["csv"], key="prices_csv_main") +st.caption("Upload a prices CSV: a date column plus one column per instrument.") +file = st.file_uploader("Prices CSV", type=["csv"], key="prices_csv") date_col = st.text_input("Date column name (optional)", value="") has_market = False -prices = None -returns = None -weights = None +prices = returns = weights = None +summary = None if file is not None: try: - # Read bytes once so the cache key is stable across reruns - file_bytes = file.read() - prices, returns = _cached_load( - file_bytes, - "log" if ret_method == "log" else "simple", - date_col, - ) + prices, returns = cached_load(file.read(), ret_method, date_col) has_market = True - except Exception as e: - st.error(f"Error loading market data: {e}") + except Exception as exc: + st.error(f"Could not load market data: {exc}") else: - st.info("No market CSV uploaded yet. You can still use the **Credit — Expected Loss (Batch)** section below.") + st.info("No market CSV uploaded. The **Credit — Expected Loss** section below " + "works independently and needs no price data.") -# ---------------- DATA PREVIEW + WEIGHTS ---------------- if has_market: - st.subheader("Market data preview") - st.dataframe(prices.tail()) + st.subheader("Preview") + c1, c2 = st.columns([3, 1]) + c1.dataframe(prices.tail(), use_container_width=True) + c2.metric("Instruments", returns.shape[1]) + c2.metric("Return observations", len(returns)) + c2.caption(f"{returns.index.min():%Y-%m-%d} → {returns.index.max():%Y-%m-%d}") if weights_mode == "Equal": weights = np.ones(len(returns.columns)) / len(returns.columns) else: - st.write("Enter weights like: TICKER=WEIGHT, TICKER=WEIGHT") - manual = st.text_input("Example: AAPL=1.0, MSFT=0.0, TLT=0.0", value="") - wdict = {} - if manual.strip(): - for p in manual.split(","): - p = p.strip() - if "=" in p: - k, v = p.split("=") - try: - wdict[k.strip()] = float(v) - except Exception: - pass + st.write("Enter weights as `TICKER=WEIGHT, TICKER=WEIGHT`. They are normalised to sum to 1.") + manual = st.text_input("Weights", value="", placeholder="SPY=0.6, TLT=0.4") + wdict, bad = {}, [] + for part in (p.strip() for p in manual.split(",") if p.strip()): + if "=" in part: + k, v = part.split("=", 1) + try: + wdict[k.strip()] = float(v) + except ValueError: + bad.append(part) + else: + bad.append(part) + if bad: + st.warning(f"Ignored unparseable entries: {', '.join(bad)}") + unknown = set(wdict) - set(returns.columns) + if unknown: + st.warning(f"Not in the uploaded data, ignored: {', '.join(sorted(unknown))}") + weights = np.array([wdict.get(c, 0.0) for c in returns.columns], dtype=float) - s = weights.sum() - if s != 0: - weights = weights / s - -# ---------------- POINT MEASURES ---------------- -if has_market and returns is not None and not returns.empty: - st.subheader("Point Risk Measures") - - method_map = { - "Historical": "historical", - "Parametric (Normal)": "parametric", - "Monte Carlo": "monte_carlo", - "Filtered Historical (GARCH-lite)": "fhs", - } - method_str = method_map.get(method_choice, "historical") - weights_t = tuple(weights.tolist()) + if weights.sum() <= 0: + st.warning("No valid weights supplied — falling back to equal weighting.") + weights = np.ones(len(returns.columns)) / len(returns.columns) + else: + weights = weights / weights.sum() - var_val, es_val = _cached_var_es( - returns, weights_t, alpha, int(horizon), exposure, method_str +if has_market: + weights_t = tuple(weights.tolist()) + cfg_kwargs = dict( + alpha=alpha, method=method, horizon_days=horizon, exposure=exposure, + window=bt_window, n_sims=n_sims, seed=seed, shrink_lambda=shrink_lambda, + fit_garch=fit_garch, alpha_g=alpha_g, beta_g=beta_g, ) - c1, c2 = st.columns(2) - c1.metric(f"VaR @ {int(alpha*100)}%, {horizon}d", f"{var_val:,.0f}") - c2.metric("Expected Shortfall (ES)", f"{es_val:,.0f}") - st.caption("VaR/ES are reported as loss amounts (positive numbers).") -else: - st.info("Load market data to compute risk measures.") +# =========================================================================== +# POINT RISK MEASURES +# =========================================================================== + +if has_market: + st.markdown("---") + st.header("Point risk measures") + try: + summary = cached_risk(returns, weights_t, cfg_kwargs) -# ---------------- BACKTEST ---------------- -if has_market and returns is not None and not returns.empty: - st.subheader("Backtest — Rolling Historical VaR (1d)") + c1, c2, c3 = st.columns(3) + c1.metric(f"VaR @ {alpha:.1%}, {horizon}d", f"{summary['VaR']:,.0f}") + c2.metric("Expected Shortfall", f"{summary['ES']:,.0f}") + c3.metric("ES / VaR", f"{summary['ES'] / summary['VaR']:.2f}" + if summary["VaR"] else "—") + st.caption("Reported as positive loss amounts. The engine enforces VaR ≥ 0 and ES ≥ VaR.") + + with st.expander("Assumptions in force"): + for a in summary["assumptions"]: + st.write(f"- {a}") + if "fit_info" in summary: + fi = summary["fit_info"] + st.markdown("**GARCH parameters**") + src = fi.get("source", "—") + st.write(f"Source: **{src}**") + if "fallback_reason" in fi: + st.warning(f"MLE requested but fell back to fixed parameters: {fi['fallback_reason']}") + if all(k in fi for k in ("omega", "alpha_g", "beta_g")): + st.write(f"ω = {fi['omega']:.3e} | α = {fi['alpha_g']:.4f} | " + f"β = {fi['beta_g']:.4f} | persistence = {fi['persistence']:.4f}") + if "log_likelihood" in fi: + st.write(f"log-likelihood = {fi['log_likelihood']:,.1f} | " + f"AIC = {fi['aic']:,.1f} | BIC = {fi['bic']:,.1f}") + except Exception as exc: + st.error(f"Could not compute risk measures: {exc}") + + +# =========================================================================== +# BACKTESTING +# =========================================================================== - available = len(returns) - if available <= bt_window: - st.warning( - f"Not enough data for backtest: have {available} return rows, window is {bt_window}." - ) +if has_market: + st.markdown("---") + st.header("Backtesting") + + if len(returns) <= bt_window: + st.warning(f"Not enough history: {len(returns)} return observations against a " + f"{bt_window}-day window. Reduce the window or supply a longer series.") else: - weights_t = tuple(weights.tolist()) - bt = _cached_backtest(returns, weights_t, alpha, int(bt_window)) + bt_kind = st.radio("Backtested model", ["Historical Simulation", "Filtered Historical (GARCH)"], + horizontal=True) + if bt_kind == "Historical Simulation": + bt = cached_backtest_hist(returns, weights_t, alpha, bt_window) + else: + bt = cached_backtest_fhs(returns, weights_t, alpha, bt_window, alpha_g, beta_g) left, right = st.columns([2, 1]) with left: fig = go.Figure() - fig.add_trace(go.Scatter( - x=bt["r_p"].index, y=bt["r_p"].values, - mode="lines", name="Portfolio returns" - )) - fig.add_trace(go.Scatter( - x=bt["VaR_threshold"].index, y=bt["VaR_threshold"].values, - mode="lines", name=f"VaR threshold ({int(alpha*100)}%)" - )) - - exc_mask = (bt["exceptions"] == 1) & bt["VaR_threshold"].notna() - fig.add_trace(go.Scatter( - x=bt["r_p"].index[exc_mask], - y=bt["r_p"].values[exc_mask], - mode="markers", name="Exceptions" - )) - - fig.update_layout(height=420, xaxis_title="Date", yaxis_title="Return") + fig.add_trace(go.Scatter(x=bt["r_p"].index, y=bt["r_p"].values, + mode="lines", name="Portfolio return", + line=dict(width=1))) + fig.add_trace(go.Scatter(x=bt["VaR_threshold"].index, y=bt["VaR_threshold"].values, + mode="lines", name=f"VaR threshold ({alpha:.1%})")) + exc = (bt["exceptions"] == 1) & bt["VaR_threshold"].notna() + fig.add_trace(go.Scatter(x=bt["r_p"].index[exc], y=bt["r_p"].values[exc], + mode="markers", name="Exception", + marker=dict(size=7, symbol="x"))) + fig.update_layout(height=430, xaxis_title="Date", yaxis_title="Return", + legend=dict(orientation="h", y=1.08)) st.plotly_chart(fig, use_container_width=True) + st.caption("Exceptions bunched into one region of the horizontal axis are exactly " + "what the independence test quantifies. The threshold at each date uses " + "only information available the day before.") with right: st.markdown("**Sample**") - st.write(f"Window: **{bt['window']}**") - st.write(f"OOS points (T): **{bt['T']}**") - st.write(f"Exceedances (x): **{bt['exceedances']}**") - st.write(f"Hit rate (x/T): **{bt['hit_rate']:.4f}**") - st.write(f"Expected rate: **{1 - alpha:.4f}**") + st.write(f"Window: **{bt['window']}** days") + st.write(f"Out-of-sample points (T): **{bt['T']}**") + st.write(f"Exceptions (x): **{bt['exceedances']}**") + st.write(f"Hit rate: **{bt['hit_rate']:.4f}** (expected {1 - alpha:.4f})") st.markdown("---") - - kupiec_pass = bt["kupiec_pvalue"] > 0.05 st.markdown("**① Kupiec POF** — unconditional coverage") - st.caption("H₀: exception rate = (1 − α)") - st.write(f"LR statistic: **{bt['kupiec_LR']:.3f}**") - st.write(f"p-value: **{bt['kupiec_pvalue']:.4f}**") - st.write("Result: " + ("✅ Pass" if kupiec_pass else "❌ Fail")) + st.caption("H₀: exception rate equals (1 − α)") + st.write(f"LR = **{bt['kupiec_LR']:.3f}**, p = **{bt['kupiec_pvalue']:.4f}** " + f"→ **{pass_fail(bt['kupiec_pvalue'])}**") - st.markdown("---") - - christ_pass = bt["christoffersen_pvalue"] > 0.05 st.markdown("**② Christoffersen** — independence") - st.caption("H₀: exceptions are serially independent (no clustering)") - st.write(f"LR statistic: **{bt['christoffersen_LR']:.3f}**") - st.write(f"p-value: **{bt['christoffersen_pvalue']:.4f}**") - st.write("Result: " + ("✅ Pass" if christ_pass else "❌ Fail")) + st.caption("H₀: exceptions are serially independent") + st.write(f"LR = **{bt['christoffersen_LR']:.3f}**, p = **{bt['christoffersen_pvalue']:.4f}** " + f"→ **{pass_fail(bt['christoffersen_pvalue'])}**") with st.expander("Transition matrix"): tr = bt["transitions"] - st.write(f"n₀₀ (no exc → no exc): **{tr['n00']}**") - st.write(f"n₀₁ (no exc → exc): **{tr['n01']}**") - st.write(f"n₁₀ (exc → no exc): **{tr['n10']}**") - st.write(f"n₁₁ (exc → exc): **{tr['n11']}**") - st.write(f"π₀₁ (P(exc|no exc)): **{tr['pi_01']:.4f}**") - st.write(f"π₁₁ (P(exc|exc)): **{tr['pi_11']:.4f}**") - st.caption( - "π₁₁ > π₀₁ indicates exception clustering. " - "Under H₀ (independence) these should be approximately equal." - ) + st.write(f"n₀₀ = {tr['n00']} n₀₁ = {tr['n01']}") + st.write(f"n₁₀ = {tr['n10']} n₁₁ = {tr['n11']}") + st.write(f"π₀₁ = P(exc | no exc) = **{tr['pi_01']:.4f}**") + st.write(f"π₁₁ = P(exc | exc) = **{tr['pi_11']:.4f}**") + st.caption("Under independence these are approximately equal. " + "π₁₁ > π₀₁ indicates clustering.") + + st.markdown("**③ Joint conditional coverage**") + st.caption("H₀: correct frequency AND independence (LR_cc ~ χ²(2))") + st.write(f"LR = **{bt['joint_LR']:.3f}**, p = **{bt['joint_pvalue']:.4f}** " + f"→ **{pass_fail(bt['joint_pvalue'])}**") st.markdown("---") - - joint_pass = bt["joint_pvalue"] > 0.05 - st.markdown("**③ Joint CC** — coverage + independence") - st.caption("H₀: correct frequency AND no clustering (LR_cc ~ χ²(2))") - st.write(f"LR statistic: **{bt['joint_LR']:.3f}**") - st.write(f"p-value: **{bt['joint_pvalue']:.4f}**") - st.write("Result: " + ("✅ Pass" if joint_pass else "❌ Fail")) - - st.markdown("---") - all_pass = kupiec_pass and christ_pass and joint_pass - if all_pass: - st.success("All three tests pass at 5% significance.") + failed = [name for name, key in + (("Kupiec", "kupiec_pvalue"), ("Christoffersen", "christoffersen_pvalue"), + ("Joint CC", "joint_pvalue")) + if bt[key] <= 0.05] + if failed: + st.error(f"Rejected at 5%: {', '.join(failed)}") else: - failed = [] - if not kupiec_pass: failed.append("Kupiec") - if not christ_pass: failed.append("Christoffersen") - if not joint_pass: failed.append("Joint CC") - st.error(f"Failed: {', '.join(failed)}") - - ex_df = pd.DataFrame({ - "date": bt["r_p"].index, - "return": bt["r_p"].values, + st.success("All three tests pass at 5% significance.") + + bt_df = pd.DataFrame({ + "date": bt["r_p"].index, + "return": bt["r_p"].values, "VaR_threshold": bt["VaR_threshold"].values, - "exception": bt["exceptions"].values + "exception": bt["exceptions"].values, }) - st.download_button( - "Download backtest series (CSV)", - data=ex_df.to_csv(index=False).encode("utf-8"), - file_name=f"backtest_{int(alpha*100)}pct_window{bt_window}.csv", - mime="text/csv" - ) - -# ---------------- STRESS TESTING ---------------- -if has_market: - st.markdown("---") - st.header("Stress Testing") - - tabs = st.tabs(["Single-name shock", "Covariance scaling", "Historical window replay"]) + st.download_button("Download backtest series (CSV)", + bt_df.to_csv(index=False).encode(), + file_name=f"backtest_{bt['method']}_{int(alpha * 100)}pct_w{bt_window}.csv", + mime="text/csv") - with tabs[0]: - st.write("Inject one-day shocks to chosen tickers (in return space).") - tickers = list(returns.columns) - picked = st.multiselect("Choose tickers to shock", options=tickers, default=tickers[:1]) - shock_pairs = {} - cols = st.columns(min(3, max(1, len(picked)))) - for i, t in enumerate(picked): - with cols[i % len(cols)]: - shock = st.number_input(f"{t} shock (e.g., -0.10 = -10%)", value=0.0, step=0.01, format="%.4f") - shock_pairs[t] = shock - if st.button("Run single-name shock"): - shock_row = apply_single_name_shocks(returns, shock_pairs) - port_ret = float(shock_row.values.squeeze() @ weights) - loss = -port_ret * exposure - st.metric("Shocked one-day loss", f"{loss:,.0f}") - - with tabs[1]: - st.write("Scale market covariance (volatility) and re-compute MC VaR/ES.") - scale = st.slider("Covariance scale (×)", 0.5, 3.0, 1.5, 0.1) - sims = st.number_input("MC simulations", min_value=10_000, value=50_000, step=10_000) - seed2 = st.number_input("Random seed", min_value=0, value=7, step=1) - if st.button("Run covariance scaling stress"): - mu, cov = _cached_cov_mu(returns, int(horizon)) - cov_s = scale_covariance(cov, scale) - var_s, es_s = mc_portfolio_loss_from_mu_cov( - mu, cov_s, weights, alpha=alpha, - exposure=exposure, n_sims=int(sims), seed=int(seed2) - ) - c1, c2 = st.columns(2) - c1.metric(f"VaR stressed (×{scale:.1f})", f"{var_s:,.0f}") - c2.metric("ES stressed", f"{es_s:,.0f}") - - with tabs[2]: - st.write("Replay a historical period's mean/covariance for your current portfolio (MC VaR/ES).") - st.caption("Choose dates within your uploaded data.") - min_d, max_d = returns.index.min().date(), returns.index.max().date() - c1, c2 = st.columns(2) - start = c1.date_input("Window start", min_d, min_value=min_d, max_value=max_d) - end = c2.date_input("Window end", max_d, min_value=min_d, max_value=max_d) - sims2 = st.number_input("MC simulations", min_value=10_000, value=50_000, step=10_000, key="sims_hist") - seed_hist = st.number_input("Random seed", min_value=0, value=11, step=1, key="seed_hist") - if st.button("Run historical replay"): - try: - mu_win, cov_win = historical_window_mu_cov(returns, str(start), str(end)) - mu_h, cov_h = mu_win * horizon, cov_win * horizon - var_h, es_h = mc_portfolio_loss_from_mu_cov( - mu_h, cov_h, weights, alpha=alpha, - exposure=exposure, n_sims=int(sims2), seed=int(seed_hist) - ) - c1, c2 = st.columns(2) - c1.metric("VaR (historical window)", f"{var_h:,.0f}") - c2.metric("ES (historical window)", f"{es_h:,.0f}") - except Exception as e: - st.error(f"Error: {e}") -# ---------------- CREDIT EL ---------------- -st.markdown("---") -st.header("Credit — Expected Loss (Batch)") -st.write("Upload a CSV with columns for **PD**, **LGD**, **EAD** (case-insensitive). Optional grouping columns (Segment/Rating/etc.) are auto-detected.") -credit_file = st.file_uploader("Upload exposures CSV", type=["csv"], key="credit_csv") +# =========================================================================== +# CALIBRATION ACROSS CONFIDENCE LEVELS +# =========================================================================== -with st.expander("Scenario shocks"): - c1, c2, c3 = st.columns(3) - with c1: - pd_mult = st.number_input("PD multiplier (×)", min_value=0.0, value=1.0, step=0.05) - pd_add_bps = st.number_input("PD additive (basis points)", min_value=-5000, max_value=5000, value=0, step=25) - with c2: - lgd_mult = st.number_input("LGD multiplier (×)", min_value=0.0, value=1.0, step=0.05) - lgd_add_pct = st.number_input("LGD additive (percentage points)", min_value=-100, max_value=100, value=0, step=1) - with c3: - ead_mult = st.number_input("EAD multiplier (×)", min_value=0.0, value=1.0, step=0.05) - -if credit_file is not None: - try: - cdf = pd.read_csv(credit_file) - df_el, seg_col = compute_el_table( - cdf, pd_mult=float(pd_mult), pd_add_bps=float(pd_add_bps), - lgd_mult=float(lgd_mult), lgd_add_pct=float(lgd_add_pct), - ead_mult=float(ead_mult), - ) - st.subheader("Per-facility results") - st.dataframe(df_el) +if has_market and len(returns) > bt_window: + st.markdown("---") + st.header("Calibration across confidence levels") + st.caption("A model can be well calibrated at 95% and badly calibrated at 99% — the two " + "sit in different parts of the tail. Testing across α is standard outcomes analysis.") - grp, totals = summarize_el(df_el, seg_col) - c1, c2, c3 = st.columns(3) - c1.metric("Total EAD", f"{totals['total_EAD']:,.0f}") - c2.metric("Total EL", f"{totals['total_EL']:,.0f}") - c3.metric("EL / EAD (avg)", f"{totals['EL_pct_of_EAD']*100:,.2f}%") + with st.expander("Run multi-α calibration"): + alphas = st.multiselect("Confidence levels", [0.90, 0.95, 0.975, 0.99, 0.995], + default=[0.95, 0.975, 0.99]) + include_fhs = st.checkbox("Include the FHS backtest for comparison", value=True) - if not grp.empty: - st.subheader(f"Grouped summary by **{seg_col}**") - st.dataframe(grp) - - st.download_button( - "Download detailed EL (CSV)", - data=df_el.to_csv(index=False).encode("utf-8"), - file_name="credit_el_detailed.csv", - mime="text/csv", - ) - if not grp.empty: - st.download_button( - "Download grouped summary (CSV)", - data=grp.to_csv(index=False).encode("utf-8"), - file_name="credit_el_grouped.csv", - mime="text/csv", - ) - except Exception as e: - st.error(f"Error processing credit file: {e}") -else: - st.info("For credit EL, upload an exposures CSV (PD, LGD, EAD). This does not require market data.") - -# ---------------- CALIBRATION ---------------- -if has_market: - st.markdown("---") - st.header("Calibration") - - def _kupiec_pval(x: int, T: int, alpha_: float) -> float: - if T <= 0: - return float("nan") - p = 1 - alpha_ - eps = 1e-12 - p = min(max(p, eps), 1 - eps) - pi_hat = min(max(x / T, eps), 1 - eps) - ll0 = (T - x) * np.log(1 - p) + x * np.log(p) - ll1 = (T - x) * np.log(1 - pi_hat) + x * np.log(pi_hat) - LR = -2 * (ll0 - ll1) - return float(math.erfc(math.sqrt(LR / 2.0))) - - with st.expander("Run multi-alpha calibration"): - alphas_to_test = st.multiselect( - "Confidence levels", - options=[0.95, 0.975, 0.99, 0.995], - default=[0.95, 0.975, 0.99, 0.995] - ) - window_cal = st.number_input("Backtest window (days)", min_value=50, value=int(bt_window), step=10, key="calib_bt_window") - include_fhs = st.checkbox( - "Include GARCH-lite (FHS) backtest comparison", - value=(method_choice == "Filtered Historical (GARCH-lite)") - ) - - if st.button("Run calibration"): - weights_t = tuple(weights.tolist()) + if st.button("Run calibration") and alphas: rows = [] - for a in alphas_to_test: - if len(returns) > window_cal: - bt_hist = _cached_backtest(returns, weights_t, alpha=a, window=int(window_cal)) - T_h = int(bt_hist["T"]); x_h = int(bt_hist["exceedances"]) - hit_h = float(bt_hist["hit_rate"]) if T_h > 0 else float("nan") - p_h = _kupiec_pval(x_h, T_h, a) if T_h > 0 else float("nan") - else: - T_h = x_h = float("nan"); hit_h = p_h = float("nan") - + for a in sorted(alphas): + h = cached_backtest_hist(returns, weights_t, a, bt_window) row = { "alpha": a, - "expected_exceed_%": (1 - a) * 100.0, - "Hist_T": T_h, "Hist_exceed": x_h, - "Hist_hit_%": hit_h * 100.0 if not np.isnan(hit_h) else np.nan, - "Hist_Kupiec_p": p_h, + "expected_%": (1 - a) * 100, + "HS_T": h["T"], "HS_exceptions": h["exceedances"], + "HS_hit_%": h["hit_rate"] * 100, + "HS_kupiec_p": h["kupiec_pvalue"], + "HS_christoffersen_p": h["christoffersen_pvalue"], + "HS_joint_p": h["joint_pvalue"], } - if include_fhs: - bt_fhs = backtest_fhs_var( - returns, weights, alpha=a, window_min=int(window_cal), - alpha_g=float(alpha_g if alpha_g is not None else 0.05), - beta_g=float(beta_g if beta_g is not None else 0.94) - ) - T_f = int(bt_fhs["T"]); x_f = int(bt_fhs["exceedances"]) - hit_f = float(bt_fhs["hit_rate"]) if T_f > 0 else float("nan") - p_f = _kupiec_pval(x_f, T_f, a) if T_f > 0 else float("nan") + f = cached_backtest_fhs(returns, weights_t, a, bt_window, alpha_g, beta_g) row.update({ - "FHS_T": T_f, "FHS_exceed": x_f, - "FHS_hit_%": hit_f * 100.0 if not np.isnan(hit_f) else np.nan, - "FHS_Kupiec_p": p_f, + "FHS_exceptions": f["exceedances"], + "FHS_hit_%": f["hit_rate"] * 100, + "FHS_kupiec_p": f["kupiec_pvalue"], + "FHS_christoffersen_p": f["christoffersen_pvalue"], + "FHS_joint_p": f["joint_pvalue"], }) - rows.append(row) - calib_df = pd.DataFrame(rows) - st.subheader("Calibration table") - st.dataframe(calib_df) + calib = pd.DataFrame(rows) + st.dataframe(calib.style.format({ + c: "{:.4f}" for c in calib.columns if c.endswith("_p") + }), use_container_width=True) + + figc = go.Figure() + labels = [f"{a:.1%}" for a in calib["alpha"]] + figc.add_trace(go.Bar(x=labels, y=calib["expected_%"], name="Expected %")) + figc.add_trace(go.Bar(x=labels, y=calib["HS_hit_%"], name="Historical hit %")) + if "FHS_hit_%" in calib: + figc.add_trace(go.Bar(x=labels, y=calib["FHS_hit_%"], name="FHS hit %")) + figc.update_layout(barmode="group", height=360, + xaxis_title="Confidence level", yaxis_title="Percent") + st.plotly_chart(figc, use_container_width=True) + + st.download_button("Download calibration (CSV)", + calib.to_csv(index=False).encode(), + file_name=f"calibration_w{bt_window}.csv", mime="text/csv") + + +# =========================================================================== +# ANALYTICS +# =========================================================================== - try: - figc = go.Figure() - figc.add_trace(go.Bar( - x=[f"{int(a*100)}%" for a in calib_df["alpha"]], - y=calib_df["expected_exceed_%"], name="Expected exceed %" - )) - figc.add_trace(go.Bar( - x=[f"{int(a*100)}%" for a in calib_df["alpha"]], - y=calib_df["Hist_hit_%"], name="Historical hit %" - )) - if "FHS_hit_%" in calib_df.columns: - figc.add_trace(go.Bar( - x=[f"{int(a*100)}%" for a in calib_df["alpha"]], - y=calib_df["FHS_hit_%"], name="FHS hit %" - )) - figc.update_layout(barmode="group", yaxis_title="Percent", xaxis_title="Alpha", height=360) - st.plotly_chart(figc, use_container_width=True) - except Exception: - pass - - st.download_button( - "Download calibration CSV", - data=calib_df.to_csv(index=False).encode("utf-8"), - file_name=f"calibration_window{int(window_cal)}.csv", - mime="text/csv", - ) - -# ---------------- ANALYTICS ---------------- if has_market: st.markdown("---") st.header("Analytics") - - tabs_a = st.tabs(["Correlation heatmap", "VaR decomposition (Normal)"]) - - with tabs_a[0]: - st.write("Correlation matrix of asset returns (last 250 days by default).") - lookback = st.number_input("Lookback (days)", min_value=50, value=250, step=10, key="corr_lookback") - corr = _cached_corr(returns, int(lookback)) - fig_corr = px.imshow( - corr, text_auto=True, color_continuous_scale="RdBu_r", zmin=-1, zmax=1, - title="Correlation heatmap" - ) - st.plotly_chart(fig_corr, use_container_width=True) - - with tabs_a[1]: - st.write("Component & marginal VaR under a Normal approximation (Euler allocation).") - weights_t = tuple(weights.tolist()) - parts = _cached_var_decomp(returns, weights_t, alpha, int(horizon), exposure) + tab_corr, tab_decomp = st.tabs(["Correlation", "VaR decomposition"]) + + with tab_corr: + lookback = int(st.number_input("Lookback (days)", 50, 5000, 250, step=10, key="corr_lb")) + corr = cached_corr(returns, lookback) + st.plotly_chart( + px.imshow(corr, text_auto=".2f", color_continuous_scale="RdBu_r", + zmin=-1, zmax=1, title=f"Correlation — last {lookback} days"), + use_container_width=True) + st.caption("The scale is fixed to [−1, 1] so colour is comparable across datasets.") + + with tab_decomp: + parts = cached_decomp(returns, weights_t, alpha, horizon, exposure) tickers = list(returns.columns) + df_parts = pd.DataFrame({ - "Ticker": tickers, - "Weight": (weights / weights.sum()) if weights.sum() != 0 else weights, - "mVaR": parts["mVaR"], - "cVaR": parts["cVaR"], - "Percent of VaR": parts["pContrib"] + "Instrument": tickers, + "Weight": parts["w"], + "Marginal VaR": parts["mVaR"], + "Component VaR": parts["cVaR"], + "% of VaR": parts["pContrib"] * 100, }) - df_parts["Percent of VaR"] = (df_parts["Percent of VaR"] * 100).round(2) - st.dataframe(df_parts) + st.dataframe(df_parts.style.format({ + "Weight": "{:.2%}", "Marginal VaR": "{:,.0f}", + "Component VaR": "{:,.0f}", "% of VaR": "{:.2f}%", + }), use_container_width=True) - try: - fig = px.bar(df_parts, x="Ticker", y="cVaR", title="Component VaR (money)") - st.plotly_chart(fig, use_container_width=True) - except Exception: - pass + st.plotly_chart(px.bar(df_parts, x="Instrument", y="Component VaR", + title="Component VaR"), use_container_width=True) c1, c2, c3 = st.columns(3) c1.metric("Portfolio VaR (Normal)", f"{parts['VaR']:,.0f}") c2.metric("μ (portfolio, horizon)", f"{parts['mu_p']:.6f}") c3.metric("σ (portfolio, horizon)", f"{parts['sigma_p']:.6f}") - st.caption("Component VaR sums (≈) to portfolio VaR. Marginal VaR is the sensitivity to a small weight increase.") + st.caption("Component VaR sums to portfolio VaR **exactly** — VaR is homogeneous of " + "degree 1 in the weights, so Euler's theorem applies with equality. " + "Marginal VaR is the sensitivity to a small increase in a weight.") + + inc = cached_incremental(returns, weights_t, alpha, horizon, exposure) + st.markdown("**Incremental VaR**") + st.dataframe(pd.DataFrame({ + "Instrument": tickers, + "Incremental VaR": inc["iVaR"], + "Component VaR": inc["cVaR"], + }).style.format({"Incremental VaR": "{:,.0f}", "Component VaR": "{:,.0f}"}), + use_container_width=True) + st.caption("Incremental VaR is a true recomputation — portfolio VaR minus the VaR of " + "the portfolio with that position removed and the rest renormalised. It " + "converges to component VaR only for small positions.") + + +# =========================================================================== +# WHAT-IF WEIGHTS +# =========================================================================== -# ---------------- WHAT-IF WEIGHTS ---------------- if has_market: st.markdown("---") - st.header("What-if: tweak weights") + st.header("What-if: adjust weights") tickers = list(returns.columns) cols = st.columns(min(4, max(2, len(tickers)))) new_w = [] for i, t in enumerate(tickers): with cols[i % len(cols)]: - v = st.slider( - f"{t} weight", min_value=0.0, max_value=1.0, - value=float(weights[i]) if weights.sum() > 0 else 0.0, - step=0.01, key=f"w_{t}" - ) - new_w.append(v) + new_w.append(st.slider(t, 0.0, 1.0, float(weights[i]), 0.01, key=f"w_{t}")) new_w = np.array(new_w, dtype=float) - if new_w.sum() > 0: - new_w = new_w / new_w.sum() - else: - st.warning("All weights are zero; cannot compute.") - new_w = weights - weights_t = tuple(weights.tolist()) - new_w_t = tuple(new_w.tolist()) + if new_w.sum() <= 0: + st.warning("All weights are zero — showing the current portfolio instead.") + new_w = weights.copy() + new_w = new_w / new_w.sum() - curr = _cached_var_decomp(returns, weights_t, alpha, int(horizon), exposure) - what = _cached_var_decomp(returns, new_w_t, alpha, int(horizon), exposure) + curr = cached_decomp(returns, weights_t, alpha, horizon, exposure) + what = cached_decomp(returns, tuple(new_w.tolist()), alpha, horizon, exposure) c1, c2, c3 = st.columns(3) c1.metric("Current VaR (Normal)", f"{curr['VaR']:,.0f}") c2.metric("What-if VaR (Normal)", f"{what['VaR']:,.0f}") - c3.metric("Δ VaR", f"{(what['VaR'] - curr['VaR']):,.0f}") - - df_curr = pd.DataFrame({ - "Ticker": tickers, - "Weight": (weights / weights.sum()) if weights.sum() != 0 else weights, - "cVaR (curr)": curr["cVaR"], - "%VaR (curr)": (curr["pContrib"] * 100.0), - }) - df_what = pd.DataFrame({ - "Ticker": tickers, - "Weight (what-if)": new_w, - "cVaR (what-if)": what["cVaR"], - "%VaR (what-if)": (what["pContrib"] * 100.0), - }) - df_join = df_curr.merge(df_what, on="Ticker") - st.dataframe(df_join) + c3.metric("Change", f"{what['VaR'] - curr['VaR']:,.0f}", + delta=f"{(what['VaR'] / curr['VaR'] - 1) * 100:.2f}%" if curr["VaR"] else None, + delta_color="inverse") - inc = _cached_incremental_var(returns, weights_t, alpha, int(horizon), exposure) - df_inc = pd.DataFrame({"Ticker": tickers, "iVaR ≈ cVaR (curr)": inc["cVaR"]}) - st.caption("Incremental VaR ≈ component VaR under the Normal Euler allocation.") - st.dataframe(df_inc) + st.dataframe(pd.DataFrame({ + "Instrument": tickers, + "Weight (current)": curr["w"], + "Weight (what-if)": new_w, + "cVaR (current)": curr["cVaR"], + "cVaR (what-if)": what["cVaR"], + "% of VaR (current)": curr["pContrib"] * 100, + "% of VaR (what-if)": what["pContrib"] * 100, + }).style.format({ + "Weight (current)": "{:.2%}", "Weight (what-if)": "{:.2%}", + "cVaR (current)": "{:,.0f}", "cVaR (what-if)": "{:,.0f}", + "% of VaR (current)": "{:.2f}%", "% of VaR (what-if)": "{:.2f}%", + }), use_container_width=True) + + fig = go.Figure() + fig.add_trace(go.Bar(name="Current", x=tickers, y=curr["cVaR"])) + fig.add_trace(go.Bar(name="What-if", x=tickers, y=what["cVaR"])) + fig.update_layout(barmode="group", title="Component VaR", height=380) + st.plotly_chart(fig, use_container_width=True) + + +# =========================================================================== +# STRESS TESTING +# =========================================================================== - try: - fig = go.Figure() - fig.add_trace(go.Bar(name="cVaR (curr)", x=tickers, y=df_join["cVaR (curr)"])) - fig.add_trace(go.Bar(name="cVaR (what-if)", x=tickers, y=df_join["cVaR (what-if)"])) - fig.update_layout(barmode="group", title="Component VaR (money)") - st.plotly_chart(fig, use_container_width=True) - except Exception: - pass - -# ---------------- RISK BUDGETING (ERC) ---------------- if has_market: st.markdown("---") - st.header("Risk budgeting — Equal Risk Contribution (ERC)") + st.header("Stress testing") + st.caption(f"Scenarios re-simulate with {n_sims:,} paths and seed {seed} " + "(set in the sidebar), so base and stressed figures differ by the " + "scenario alone, not by Monte Carlo noise.") - with st.expander("Compute ERC weights"): - erc_min = st.number_input("Min weight", 0.0, 1.0, 0.0, 0.01) - erc_max = st.number_input("Max weight", 0.0, 1.0, 1.0, 0.01) - erc_step = st.slider("Update damping (step)", 0.1, 1.0, 0.5, 0.1) - erc_tol = st.number_input("Tolerance", 1e-12, 1e-2, 1e-8, format="%.1e") - init_from_current = st.checkbox("Initialize from current weights", value=True) - - if st.button("Run ERC"): - init = weights if init_from_current and weights.sum() > 0 else None - w_erc, info = erc_weights( - returns, horizon_days=horizon, init=init, - min_w=float(erc_min), max_w=float(erc_max), - step=float(erc_step), tol=float(erc_tol) - ) - weights_t = tuple(weights.tolist()) - w_erc_t = tuple(w_erc.tolist()) - parts_curr = _cached_var_decomp(returns, weights_t, alpha, int(horizon), exposure) - parts_erc = _cached_var_decomp(returns, w_erc_t, alpha, int(horizon), exposure) - - tickers = list(returns.columns) - df_erc = pd.DataFrame({ - "Ticker": tickers, - "Weight (current)": (weights / weights.sum()) if weights.sum() != 0 else weights, - "Weight (ERC)": w_erc, - "%VaR (current)": (parts_curr["pContrib"] * 100.0), - "%VaR (ERC)": (parts_erc["pContrib"] * 100.0), - }) - c1, c2 = st.columns(2) - c1.metric("Portfolio VaR (current, Normal)", f"{parts_curr['VaR']:,.0f}") - c2.metric("Portfolio VaR (ERC, Normal)", f"{parts_erc['VaR']:,.0f}") - st.dataframe(df_erc) - - st.download_button( - "Download ERC weights (CSV)", - data=df_erc[["Ticker", "Weight (ERC)"]].to_csv(index=False).encode("utf-8"), - file_name="erc_weights.csv", mime="text/csv" - ) - st.caption(f"Converged in {info['iter']} iters; RC dispersion={info['rc_dispersion']:.2e}") - -# ---------------- SCENARIO LIBRARY ---------------- -if has_market: - st.markdown("---") - st.header("Scenario library") - - tabs_s = st.tabs(["Equities −X%", "Rates +bp (duration)", "Correlations +X% (MC)"]) - - with tabs_s[0]: - eqs = st.multiselect( - "Equity tickers", options=list(returns.columns), - default=[c for c in returns.columns if c not in ["TLT", "IEF", "AGG", "BND", "EDV", "ZROZ"]] - ) - eq_shock = st.number_input( - "Equity shock (return, e.g., -0.05 = -5%)", - min_value=-1.0, max_value=1.0, value=-0.05, step=0.01 - ) - if st.button("Run equities shock"): - loss = scenario_equities_shock(returns, weights, eqs, shock=float(eq_shock), exposure=exposure) - st.metric("Scenario P&L (loss +ve)", f"{loss:,.0f}") - - with tabs_s[1]: - st.write("Duration approximation: ΔP/P ≈ −D × Δy.") - bp = st.number_input("Rate move (bp)", min_value=-1000, max_value=1000, value=200, step=25) - st.caption("Override durations (defaults provided for common ETFs):") + t_single, t_eq, t_rates, t_cov, t_corr, t_hist = st.tabs( + ["Single name", "Equity shock", "Rate shock", "Volatility", "Correlation", "Historical replay"]) + + tickers = list(returns.columns) + + with t_single: + st.write("Apply one-day shocks in return space. Instruments not shocked are held flat.") + picked = st.multiselect("Instruments to shock", tickers, default=tickers[:1]) + shocks = {} + if picked: + cols = st.columns(min(3, len(picked))) + for i, t in enumerate(picked): + with cols[i % len(cols)]: + shocks[t] = st.number_input(f"{t} shock", value=-0.10, step=0.01, + format="%.4f", key=f"sn_{t}") + if st.button("Run single-name shock") and shocks: + res = scenario_single_name(returns, weights, shocks, exposure=exposure) + st.metric("Scenario loss", f"{res['loss']:,.0f}") + st.dataframe(pd.DataFrame({"Instrument": tickers, + "Shock": res["shock_vector"].values, + "Weight": weights}), + use_container_width=True) + + with t_eq: + eqs = st.multiselect("Equity instruments", tickers, + default=[c for c in tickers if c not in RATE_SENSITIVE_HINT]) + eq_shock = st.number_input("Shock (return)", -1.0, 1.0, -0.20, 0.01) + if st.button("Run equity shock"): + loss = scenario_equities_shock(returns, weights, eqs, + shock=float(eq_shock), exposure=exposure) + st.metric("Scenario loss", f"{loss:,.0f}") + + with t_rates: + st.write("Parallel rate shock via the duration approximation, ΔP/P ≈ −D × Δy.") + bp = st.number_input("Rate move (bp)", -1000, 1000, 200, 25) + st.caption("Durations default to 0 for anything not recognised as rate-sensitive. " + "A parallel rate shock should not move an equity position.") dur_inputs = {} - cols = st.columns(min(4, max(2, len(returns.columns)))) - for i, c in enumerate(returns.columns): + cols = st.columns(min(4, max(2, len(tickers)))) + for i, c in enumerate(tickers): with cols[i % len(cols)]: - dur_inputs[c] = st.number_input( - f"{c} duration", min_value=0.0, - value=18.0 if c == "TLT" else 7.0, step=0.5 - ) - if st.button("Run rates shock"): - loss = scenario_rates_bp(returns, weights, durations=dur_inputs, bp=float(bp), exposure=exposure) - st.metric("Scenario P&L (loss +ve)", f"{loss:,.0f}") - - with tabs_s[2]: - a_corr = st.number_input("Alpha for VaR/ES", min_value=0.8, max_value=0.999, - value=float(alpha), step=0.001, format="%.3f") - corr_bump = st.slider("Correlation bump (%)", min_value=0, max_value=200, value=50, step=5) - sims3 = st.number_input("MC simulations", min_value=10_000, value=50_000, step=10_000, key="sims_corr") - seed3 = st.number_input("Random seed", min_value=0, value=13, step=1, key="seed_corr") - if st.button("Run correlation bump"): - base, stressed = scenario_corr_bump_mc( - returns, weights, alpha=float(a_corr), horizon_days=horizon, - exposure=exposure, corr_bump_pct=float(corr_bump), - n_sims=int(sims3), seed=int(seed3) - ) - (var_b, es_b), (var_s, es_s) = base, stressed - c1, c2 = st.columns(2) - c1.metric("VaR (base)", f"{var_b:,.0f}") - c2.metric("VaR (corr bumped)", f"{var_s:,.0f}") - st.caption(f"ΔVaR = {var_s - var_b:,.0f}; ES base {es_b:,.0f} → stressed {es_s:,.0f}") - -# ---------------- REPORT EXPORT ---------------- + dur_inputs[c] = st.number_input(f"{c} duration", 0.0, 40.0, + float(DEFAULT_DURATIONS.get(c, 0.0)), + step=0.5, key=f"dur_{c}") + if st.button("Run rate shock"): + res = scenario_rates_bp(returns, weights, durations=dur_inputs, + bp=float(bp), exposure=exposure) + st.metric("Scenario loss", f"{res['loss']:,.0f}") + st.caption("Linear approximation — convexity is ignored, which overstates the " + "loss for large moves on long-duration instruments.") + + with t_cov: + st.write("Scale the covariance matrix and re-simulate.") + scale = st.slider("Covariance scale (×)", 0.5, 5.0, 2.0, 0.1) + st.caption(f"Covariance ×{scale:.1f} means volatility ×{np.sqrt(scale):.2f}; " + "correlations are unchanged.") + if st.button("Run volatility stress"): + res = scenario_covariance_scale(returns, weights, scale=float(scale), alpha=alpha, + horizon_days=horizon, exposure=exposure, + n_sims=n_sims, seed=seed) + c1, c2, c3 = st.columns(3) + c1.metric("VaR (base)", f"{res['base']['VaR']:,.0f}") + c2.metric("VaR (stressed)", f"{res['stressed']['VaR']:,.0f}") + c3.metric("Change", f"{res['delta_VaR']:,.0f}") + st.caption(f"ES {res['base']['ES']:,.0f} → {res['stressed']['ES']:,.0f}") + + with t_corr: + st.write("Bump off-diagonal correlations, hold volatilities constant, re-simulate.") + st.caption("Isolates the diversification-breakdown channel: in a crisis correlations " + "converge toward 1 and a diversified book turns out to be one bet.") + corr_bump = st.slider("Correlation bump (%)", 0, 300, 50, 5) + if st.button("Run correlation stress"): + res = scenario_corr_bump_mc(returns, weights, alpha=alpha, horizon_days=horizon, + exposure=exposure, corr_bump_pct=float(corr_bump), + n_sims=n_sims, seed=seed) + c1, c2, c3 = st.columns(3) + c1.metric("VaR (base)", f"{res['base']['VaR']:,.0f}") + c2.metric("VaR (stressed)", f"{res['stressed']['VaR']:,.0f}") + c3.metric("Change", f"{res['delta_VaR']:,.0f}") + if res["psd_adjusted"]: + st.warning("The bumped correlation matrix was not positive semi-definite and " + "was projected back onto the PSD cone. At this magnitude the " + "scenario is straining the correlation structure.") + + with t_hist: + st.write("Re-run the current portfolio through a past statistical regime.") + min_d, max_d = returns.index.min().date(), returns.index.max().date() + c1, c2 = st.columns(2) + start = c1.date_input("Window start", min_d, min_value=min_d, max_value=max_d) + end = c2.date_input("Window end", max_d, min_value=min_d, max_value=max_d) + if st.button("Run historical replay"): + try: + res = scenario_historical_replay(returns, weights, str(start), str(end), + alpha=alpha, horizon_days=horizon, + exposure=exposure, n_sims=n_sims, seed=seed) + c1, c2, c3 = st.columns(3) + c1.metric("VaR (replayed regime)", f"{res['VaR']:,.0f}") + c2.metric("ES (replayed regime)", f"{res['ES']:,.0f}") + c3.metric("Observations used", res["n_obs"]) + except Exception as exc: + st.error(str(exc)) + + +# =========================================================================== +# RISK BUDGETING +# =========================================================================== + if has_market: st.markdown("---") - st.header("Report export") + st.header("Risk budgeting — Equal Risk Contribution") - tickers = list(returns.columns) - w_norm = (weights / weights.sum()) if weights.sum() != 0 else weights - weights_t = tuple(weights.tolist()) + with st.expander("Compute ERC weights"): + c1, c2 = st.columns(2) + erc_min = c1.number_input("Minimum weight", 0.0, 1.0, 0.0, 0.01) + erc_max = c2.number_input("Maximum weight", 0.0, 1.0, 1.0, 0.01) + erc_step = st.slider("Update damping", 0.1, 1.0, 0.5, 0.1) + init_current = st.checkbox("Initialise from current weights", value=True) + + n_assets = returns.shape[1] + if n_assets * erc_max < 1.0 or n_assets * erc_min > 1.0: + st.error(f"Bounds are infeasible for {n_assets} instruments: weights cannot " + f"sum to 1 with min={erc_min} and max={erc_max}.") + elif st.button("Run ERC"): + try: + w_erc, info = erc_weights( + returns, horizon_days=horizon, + init=weights if init_current else None, + min_w=float(erc_min), max_w=float(erc_max), step=float(erc_step), + ) + p_curr = cached_decomp(returns, weights_t, alpha, horizon, exposure) + p_erc = cached_decomp(returns, tuple(w_erc.tolist()), alpha, horizon, exposure) - # Re-use cached values — no recomputation - parts_now = _cached_var_decomp(returns, weights_t, alpha, int(horizon), exposure) - - summary = [] - summary.append("# Risk Report\n") - summary.append(f"**Method:** {method_choice}\n") - summary.append(f"**Alpha:** {alpha:.3f} **Horizon (days):** {horizon} **Exposure:** {exposure:,.0f}\n") - summary.append("## Current portfolio\n") - summary.append("| Ticker | Weight |") - summary.append("|---|---:|") - for t, wv in zip(tickers, w_norm): - summary.append(f"| {t} | {wv:.4f} |") - summary.append("\n## Point risk measures") - summary.append(f"- VaR: **{var_val:,.0f}**") - summary.append(f"- ES : **{es_val:,.0f}**") + c1, c2 = st.columns(2) + c1.metric("VaR (current)", f"{p_curr['VaR']:,.0f}") + c2.metric("VaR (ERC)", f"{p_erc['VaR']:,.0f}") + + df_erc = pd.DataFrame({ + "Instrument": list(returns.columns), + "Weight (current)": p_curr["w"], + "Weight (ERC)": w_erc, + "% of VaR (current)": p_curr["pContrib"] * 100, + "% of VaR (ERC)": p_erc["pContrib"] * 100, + }) + st.dataframe(df_erc.style.format({ + "Weight (current)": "{:.2%}", "Weight (ERC)": "{:.2%}", + "% of VaR (current)": "{:.2f}%", "% of VaR (ERC)": "{:.2f}%", + }), use_container_width=True) - try: - summary.append("\n## Backtest (Historical VaR)") - summary.append( - f"- Window: {bt['window']} | OOS T: {bt['T']} | " - f"Exceedances: {bt['exceedances']} | Hit rate: {bt['hit_rate']:.4f} | " - f"Kupiec p: {bt['kupiec_pvalue']:.4f}" - ) - except Exception: - pass - - report_md = "\n".join(summary) - - st.download_button( - "Download report (Markdown)", - data=report_md.encode("utf-8"), - file_name="risk_report.md", - mime="text/markdown" - ) + st.caption( + f"{'Converged' if info['converged'] else 'Stopped'} after {info['iter']} " + f"iterations; risk-contribution dispersion {info['rc_dispersion']:.2e}. " + "Contributions are equalised in variance space; percentage-of-VaR figures " + "additionally carry the drift term, so they are close but not identical." + ) + st.download_button("Download ERC weights (CSV)", + df_erc[["Instrument", "Weight (ERC)"]].to_csv(index=False).encode(), + file_name="erc_weights.csv", mime="text/csv") + except ValueError as exc: + st.error(str(exc)) + + +# =========================================================================== +# CREDIT — EXPECTED LOSS +# =========================================================================== + +st.markdown("---") +st.header("Credit — Expected Loss") +st.write("Upload an exposures CSV with **PD**, **LGD** and **EAD** columns " + "(case-insensitive, common aliases accepted). A segment, rating or grade " + "column is detected automatically and used for grouping.") + +credit_file = st.file_uploader("Exposures CSV", type=["csv"], key="credit_csv") + +with st.expander("Scenario shocks"): + c1, c2, c3 = st.columns(3) + with c1: + pd_mult = st.number_input("PD multiplier (×)", 0.0, 100.0, 1.0, 0.05) + pd_add_bps = st.number_input("PD additive (bp)", -5000, 5000, 0, 25) + with c2: + lgd_mult = st.number_input("LGD multiplier (×)", 0.0, 100.0, 1.0, 0.05) + lgd_add_pct = st.number_input("LGD additive (pp)", -100, 100, 0, 1) + with c3: + ead_mult = st.number_input("EAD multiplier (×)", 0.0, 100.0, 1.0, 0.05) + st.caption("Multiplicative shocks represent proportional deterioration; additive shocks " + "represent a uniform level shift. Regulatory narratives use both.") +if credit_file is not None: try: - df_contrib = pd.DataFrame({ - "Ticker": tickers, - "Weight": w_norm, - "Component VaR": parts_now["cVaR"], - "Percent of VaR": parts_now["pContrib"] - }) - st.download_button( - "Download VaR decomposition (CSV)", - data=df_contrib.to_csv(index=False).encode("utf-8"), - file_name="var_decomposition.csv", - mime="text/csv" - ) - except Exception: - pass - -st.caption("Tip: PNG exports of charts require `kaleido` (optional).") + cdf = pd.read_csv(credit_file) + df_el, seg_col = compute_el_table( + cdf, pd_mult=float(pd_mult), pd_add_bps=float(pd_add_bps), + lgd_mult=float(lgd_mult), lgd_add_pct=float(lgd_add_pct), + ead_mult=float(ead_mult)) + grp, totals = summarize_el(df_el, seg_col) + + quality = df_el.attrs.get("data_quality", {}) + issues = {k: v for k, v in quality.items() if v} + if issues: + st.warning("Data quality: " + "; ".join( + f"{v} row(s) {k.replace('_', ' ')}" for k, v in issues.items())) + + c1, c2, c3, c4 = st.columns(4) + c1.metric("Facilities", f"{int(totals['facilities']):,}") + c2.metric("Total EAD", f"{totals['total_EAD']:,.0f}") + c3.metric("Total EL", f"{totals['total_EL']:,.0f}") + c4.metric("EL / EAD", f"{totals['EL_pct_of_EAD'] * 100:.2f}%") + st.caption("Portfolio EL/EAD is exposure-weighted (total EL ÷ total EAD), consistent " + "with the grouped subtotals below.") + + if not grp.empty: + st.subheader(f"By {seg_col}") + st.dataframe(grp.style.format({ + "total_EAD": "{:,.0f}", "total_EL": "{:,.0f}", + "avg_PD": "{:.4f}", "avg_LGD": "{:.4f}", + "EL_pct_of_EAD": "{:.4%}", + }), use_container_width=True) + st.plotly_chart(px.bar(grp, x=seg_col, y="total_EL", title="Expected Loss by segment"), + use_container_width=True) + + st.subheader("Per-facility detail") + st.dataframe(df_el, use_container_width=True) + + c1, c2 = st.columns(2) + c1.download_button("Download detailed EL (CSV)", + df_el.to_csv(index=False).encode(), + file_name="credit_el_detailed.csv", mime="text/csv") + if not grp.empty: + c2.download_button("Download grouped summary (CSV)", + grp.to_csv(index=False).encode(), + file_name="credit_el_grouped.csv", mime="text/csv") + except Exception as exc: + st.error(f"Could not process the exposures file: {exc}") +else: + st.info("Upload an exposures CSV to compute Expected Loss. No market data required.") + + +# =========================================================================== +# REPORT EXPORT +# =========================================================================== + +if has_market and summary is not None: + st.markdown("---") + st.header("Report export") + + parts_now = cached_decomp(returns, weights_t, alpha, horizon, exposure) + tickers = list(returns.columns) + + lines = [ + "# Risk Report", "", + f"**Method:** {method_label}", + f"**Confidence (α):** {alpha:.3f} | **Horizon:** {horizon}d | " + f"**Exposure:** {exposure:,.0f}", + f"**Sample:** {len(returns):,} observations, " + f"{returns.index.min():%Y-%m-%d} to {returns.index.max():%Y-%m-%d}", + "", "## Portfolio", "", "| Instrument | Weight |", "|---|---:|", + ] + lines += [f"| {t} | {w:.4f} |" for t, w in zip(tickers, parts_now["w"])] + + lines += ["", "## Point risk measures", "", + f"- VaR: **{summary['VaR']:,.0f}**", + f"- ES: **{summary['ES']:,.0f}**", + "", "### Assumptions", ""] + lines += [f"- {a}" for a in summary["assumptions"]] + + lines += ["", "## VaR decomposition (Normal, Euler allocation)", "", + "| Instrument | Weight | Component VaR | % of VaR |", "|---|---:|---:|---:|"] + lines += [f"| {t} | {w:.4f} | {c:,.0f} | {p * 100:.2f}% |" + for t, w, c, p in zip(tickers, parts_now["w"], + parts_now["cVaR"], parts_now["pContrib"])] + + if len(returns) > bt_window: + b = cached_backtest_hist(returns, weights_t, alpha, bt_window) + lines += ["", "## Backtest — rolling historical VaR", "", + f"- Window: {b['window']}d | OOS observations: {b['T']} | " + f"Exceptions: {b['exceedances']} | Hit rate: {b['hit_rate']:.4f} " + f"(expected {1 - alpha:.4f})", + f"- Kupiec: LR = {b['kupiec_LR']:.3f}, p = {b['kupiec_pvalue']:.4f} " + f"({pass_fail(b['kupiec_pvalue'])})", + f"- Christoffersen: LR = {b['christoffersen_LR']:.3f}, " + f"p = {b['christoffersen_pvalue']:.4f} ({pass_fail(b['christoffersen_pvalue'])})", + f"- Joint CC: LR = {b['joint_LR']:.3f}, p = {b['joint_pvalue']:.4f} " + f"({pass_fail(b['joint_pvalue'])})"] + + c1, c2 = st.columns(2) + c1.download_button("Download report (Markdown)", "\n".join(lines).encode(), + file_name="risk_report.md", mime="text/markdown") + c2.download_button("Download VaR decomposition (CSV)", + pd.DataFrame({ + "Instrument": tickers, "Weight": parts_now["w"], + "Component VaR": parts_now["cVaR"], + "Percent of VaR": parts_now["pContrib"], + }).to_csv(index=False).encode(), + file_name="var_decomposition.csv", mime="text/csv") diff --git a/docs/model_report.md b/docs/model_report.md index 7278397..8e86df2 100644 --- a/docs/model_report.md +++ b/docs/model_report.md @@ -1,211 +1,391 @@ -# Model Risk Report +# Model Risk Report ## Integrated Risk App — Market & Credit Risk +**Version 0.2 · Model owner: Amanda Achiangia** + +This document consolidates what were previously two divergent reports +(`model_report.md` and `model_risk_report.md`), which had drifted apart and +described limitations that no longer held. + +Empirical results are **not** reproduced here. They live in +[`validation_results.md`](validation_results.md), which is generated directly +from the code by `scripts/generate_validation_results.py` and verified by CI. +Numbers in a document that is maintained by hand go stale; numbers generated +from the engine cannot. + --- -## 1. Model Purpose & Intended Use +## 1. Purpose and intended use ### Purpose -The purpose of this model is to **measure and validate portfolio-level market and credit risk** using standard quantitative risk methodologies commonly employed by institutional investors, pensions, and risk management teams. +Measure and validate portfolio-level market and credit risk using standard +quantitative methodologies, with the emphasis on **validation** rather than +production risk reporting: transparent assumptions, reproducible results, a +clean separation between model logic and presentation, and standard backtesting +diagnostics. -The model is designed as a **validation sandbox**, emphasizing: -- Transparent assumptions -- Reproducible results -- Clear separation between model logic and presentation -- Standard backtesting diagnostics +### Intended use -### Intended Use - -This model is intended for: -- Risk measurement and monitoring -- Model validation exercises +- Risk measurement and monitoring on a defined portfolio +- Model validation exercises and methodology comparison - Educational and demonstrative analysis -### Non-Intended Use +### Non-intended use -The model is **not** intended for: -- Trading or portfolio optimization -- Real-time risk management +- Trading, hedging, or portfolio optimisation decisions +- Real-time or production risk management - Regulatory capital calculation -- Production deployment +- Any use where the outputs inform an actual financial commitment --- -## 2. Data Description +## 2. Governing conventions -### Market Data +### 2.1 Loss convention -- Asset returns are computed from historical price data -- Data frequency: daily -- Data source: publicly available market data (via `yfinance`, demo use only) +Every quantity leaving the engine is a **positive loss amount**: -Returns are computed as simple or log returns and aggregated into a portfolio using fixed user-defined weights. +- returns below zero represent losses +- VaR ≥ 0 +- ES ≥ VaR -### Credit Data +`MarketRiskModel.fit()` enforces both inequalities as runtime invariants and +raises `ValueError` if either fails. -Credit risk inputs are provided as tabular datasets containing: -- Probability of Default (PD) -- Loss Given Default (LGD) -- Exposure at Default (EAD) +This is deliberate. Sign conventions are the classic silent-failure mode in risk +code: one estimator returns a signed return, another a positive loss, the +mismatch gets patched with `abs()`, and one confidence level is quietly wrong +forever. Encoding the convention as an assertion converts a methodology error +into an exception at fit time rather than a plausible-looking number in a report. -These inputs are assumed to be **exogenous** and are not estimated dynamically by the model. +### 2.2 Configuration as specification ---- +Every parameter that changes a number lives on `MarketRiskConfig` — confidence +level, horizon, exposure, backtest window, Monte Carlo paths and seed, +covariance shrinkage, and the GARCH settings. The config is a single +serialisable object that can be logged, diffed and attached to a result. -## 3. Methodology Overview +`summary()` returns the risk numbers together with the full config and the +assumptions actually in force for that configuration. A risk number without its +assumptions is not a deliverable. -### 3.1 Market Risk Measures +### 2.3 Estimated versus assumed -All market risk measures follow a **loss-based convention**, where reported values represent **positive losses**. +Wherever a parameter can be either assumed or estimated, the result carries a +`source` tag (`"fixed"` or `"MLE"`). Any downstream consumer — including the +report exporter — can therefore tell which it received. If MLE is requested but +fails, the engine falls back to fixed parameters and records +`fallback_reason` rather than silently substituting. -#### Value at Risk (VaR) +--- -VaR at confidence level α is defined as the loss threshold exceeded with probability: +## 3. Data -\[ -P(L > \text{VaR}_\alpha) = 1 - \alpha -\] +### 3.1 Market data -#### Expected Shortfall (ES) +Daily prices, one column per instrument, converted to log returns by default. +Log returns are time-additive, which is the property that makes square-root-of- +time scaling and the i.i.d. assumption internally coherent. Simple returns are +available because portfolio aggregation (**w · r**) is strictly correct for them +and only approximate for log returns; the trade-off is exposed to the user +rather than decided silently. -Expected Shortfall is defined as the **average loss conditional on exceeding VaR**: +Ingestion guards, each protecting a specific downstream failure: -\[ -\text{ES}_\alpha = \mathbb{E}[L \mid L > \text{VaR}_\alpha] -\] +| Guard | Failure prevented | +|---|---| +| Sort by date | A newest-first export runs every rolling window backwards through time and inverts the backtest | +| Drop duplicate dates | Breaks date slicing and double-counts observations in the window | +| Null non-positive prices | `log(P_t/P_{t-1})` is undefined; a single zero emits `-inf` into the covariance matrix | +| Strip currency formatting | Excel exports carry `"$1,234.56"` as text, which silently poisons `.cov()` and `.mean()` | +| Require ≥ 2 observations per column | Cannot produce even one return | + +### 3.2 Credit data + +PD, LGD and EAD supplied per facility, with an optional segment/rating column +detected automatically. Inputs are **exogenous** — the model does not estimate +PD from default history. + +PD and LGD are accepted as either decimals or percentages: values above 1 and at +or below 100 are interpreted as percentages and divided by 100. A PD of exactly +1.0 is treated as certain default, not as 1%. Out-of-range values are clamped +to valid ranges, and the count of clamped rows is reported as a data-quality +finding rather than absorbed silently. --- -### 3.2 Market Risk Methodologies +## 4. Methodology -The following methodologies are implemented: +### 4.1 Market risk measures -#### Historical Simulation -- Empirical quantiles of historical portfolio returns -- No distributional assumptions -- Assumes stationarity of historical returns +VaR at confidence α is the loss threshold exceeded with probability (1 − α): -#### Parametric (Normal) -- Portfolio returns assumed normally distributed -- Mean and covariance estimated from historical data -- Closed-form VaR and ES expressions +$$P(L > \text{VaR}_\alpha) = 1 - \alpha$$ -#### Monte Carlo Simulation -- Multivariate normal simulation of asset returns -- Mean and covariance estimated from data -- Light covariance shrinkage applied for numerical stability -- Portfolio losses simulated to estimate VaR and ES +Expected Shortfall is the mean loss conditional on exceeding it: -#### Filtered Historical Simulation (GARCH-lite) -- Portfolio returns filtered using fixed-parameter GARCH(1,1) -- Standardized residuals used for tail estimation -- One-step-ahead volatility forecast applied -- Captures time-varying volatility while retaining empirical tails +$$\text{ES}_\alpha = \mathbb{E}[L \mid L > \text{VaR}_\alpha]$$ ---- +ES is reported alongside VaR throughout because VaR gives the threshold but says +nothing about severity beyond it, and is not subadditive. ES is coherent and is +the measure FRTB moved to. -### 3.3 Credit Risk Methodology +| Method | Formula | Notes | +|---|---|---| +| Historical | −Q₁₋α(r_p) × E | Empirical quantile; no distributional assumption | +| Parametric | (−μ_p + z_α σ_p) × E | Closed form under normality | +| ES parametric | (−μ_p + σ_p φ(z_α)/(1−α)) × E | Closed-form tail expectation | +| Monte Carlo | Empirical quantile of simulated paths | Multivariate normal, covariance shrinkage | +| FHS | −q_z × σ_{t+1} × E | GARCH-standardised residuals, one-step-ahead | +| Expected Loss | PD × LGD × EAD | Per facility, aggregated | -Credit risk is measured using the **Expected Loss (EL)** framework: +### 4.2 Covariance shrinkage -\[ -\text{EL} = \text{PD} \times \text{LGD} \times \text{EAD} -\] +$$\Sigma_s = (1-\lambda)\Sigma + \lambda \,\text{diag}(\Sigma)$$ -Expected Loss is: -- Computed at the facility level -- Aggregated to portfolio level -- Decomposed by segment where applicable +On the diagonal this leaves variances **unchanged**; off the diagonal it +multiplies every correlation by (1 − λ). The purpose is numerical: the sample +covariance is near-singular when the instrument count is large relative to the +sample or when two series are nearly collinear, and a near-singular Σ makes the +Cholesky factorisation fail or return garbage. Shrinkage pulls the matrix away +from the boundary of the PSD cone at negligible cost to the risk estimate. -No default correlation or portfolio credit model (e.g. Vasicek) is assumed. +### 4.3 Filtered Historical Simulation ---- +1. Project to portfolio returns, r_p = **R w** +2. Filter to obtain the conditional volatility path σ_t +3. Standardise: z_t = r_t / σ_t +4. Take the empirical (1 − α) quantile of z, and its tail mean for ES +5. Forecast σ_{t+1} and rescale: VaR = −q_z · σ_{t+1} · E -## 4. Backtesting & Validation +FHS improves on both parents. Historical simulation preserves the empirical tail +shape but treats a calm day and a panicked day as equally informative. Parametric +responds to current volatility but forces Normal tails. FHS keeps the empirical — +fat, skewed — tail shape of the *standardised residuals* while letting the scale +track today's volatility. -### 4.1 Market Risk Backtesting +Below 50 observations the filter is noise, and the method degrades explicitly to +plain historical simulation. -Market risk models are evaluated using **rolling out-of-sample backtests**. +### 4.4 GARCH(1,1) -- 1-day VaR horizon -- Rolling estimation window -- VaR forecast computed using information available up to time *t−1* -- Exceptions recorded when realized return breaches the VaR threshold +$$\sigma^2_t = \omega + \alpha r^2_{t-1} + \beta \sigma^2_{t-1}$$ ---- +**Fixed mode (variance targeting).** α and β are supplied (defaults 0.05 / 0.94), +and ω is pinned so the model's unconditional variance ω/(1−α−β) equals the sample +variance. Cheap and reproducible, but an assumption. + +**MLE mode.** ω, α and β are estimated by maximising the Gaussian conditional +log-likelihood via L-BFGS-B with multiple restarts. Restarts matter: the +likelihood is nearly flat in the (α + β) direction near persistence 1, so a +single start from a poor point can converge to a local optimum *and report +success*. + +Constraints are enforced **structurally** rather than handed to the optimiser: -### 4.2 Kupiec Proportion-of-Failures (POF) Test +| Constraint | Transform | +|---|---| +| ω > 0 | ω = exp(p₀) | +| α ∈ (0,1) | α = sigmoid(p₁) | +| α + β < 1 | β = (1 − α − ε) · sigmoid(p₂) | -The Kupiec POF test evaluates **unconditional coverage** of the VaR model. +The optimiser roams freely over ℝ³ while stationarity is impossible to violate. +This avoids the boundary-stalling that constrained solvers exhibit on the coupled +stationarity inequality. -#### Null Hypothesis +The reported log-likelihood **includes** the 2π normalising constant, so the +log-likelihood, AIC and BIC are directly comparable to values from `arch`, +`statsmodels` or R. -\[ -H_0: \pi = 1 - \alpha -\] +### 4.5 Attribution -Where: -- π is the observed exception rate -- α is the VaR confidence level +**Euler allocation.** VaR is homogeneous of degree 1 in the weights, so Euler's +theorem applies *with equality*: -#### Test Statistic +$$\text{mVaR}_i = \frac{\partial \text{VaR}}{\partial w_i} = \left(-\mu_i + z_\alpha \frac{(\Sigma w)_i}{\sigma_p}\right) E, \qquad \text{cVaR}_i = w_i \cdot \text{mVaR}_i, \qquad \sum_i \text{cVaR}_i = \text{VaR}$$ -The likelihood ratio statistic follows an asymptotic χ²(1) distribution. +Component VaR sums to portfolio VaR to machine precision, not approximately. +This is what converts a portfolio-level number into "instrument X is 47% of your +risk on a 33% weight" — the figure a risk committee acts on. -- High LR statistic / low p-value → reject model coverage -- Low LR statistic → model consistent with expected exception rate +**Incremental VaR** is a true recomputation: portfolio VaR minus the VaR of the +portfolio with that position removed and the remainder renormalised. It converges +to component VaR only for small positions; on a material weight the two differ, +so both are reported side by side. + +**Equal Risk Contribution** equalises RC_i = w_i (Σw)_i by damped multiplicative +updates, with convergence measured on risk-contribution dispersion — the +objective itself rather than a proxy. Weights are projected onto the +box-constrained simplex exactly (bisection on a single shift), because clipping +and then renormalising scales clipped weights back above the cap and satisfies +neither constraint. + +Note that ERC equalises contributions to **variance**. Percentage-of-VaR +contributions additionally carry the drift term (−μ_i), so they will be close to +but not exactly equal. --- -## 5. Model Assumptions & Limitations +## 5. Validation approach -### Key Assumptions +### 5.1 Out-of-sample discipline -- Historical returns are representative of future risk -- Portfolio weights are static over the risk horizon -- Normality assumptions apply where specified -- Credit risk inputs (PD, LGD, EAD) are externally provided +Every backtest threshold at time *t* uses information available strictly through +*t − 1*. -### Limitations +This is not incidental. `rolling(window).quantile()` at row *t* includes row *t* +itself — the very day being predicted — so every threshold is shifted by one +period. Without that shift, every backtest result would be meaningless, and it +is the first property a reviewer checks. -- No dynamic correlation modeling -- No intraday or high-frequency data -- No regulatory capital framework (e.g. Basel) implemented -- GARCH parameters are fixed rather than estimated +The FHS backtest additionally derives ω from an **expanding, lagged** variance +estimate, so the volatility level is also free of look-ahead. A regression test +truncates the sample and asserts the σ path is unchanged. -These limitations are **intentional** to preserve clarity and interpretability. +### 5.2 The three tests ---- +**Kupiec POF** (unconditional coverage). H₀: the exception rate equals (1 − α). +Exceptions are modelled as i.i.d. Bernoulli; the likelihood ratio is +asymptotically χ²(1). *Limitation: it sees only the count.* A model producing +exactly 5% exceptions, all in one week, passes. -## 6. Model Governance Notes +**Christoffersen independence.** H₀: π₀₁ = π₁₁ — exceptions are serially +independent. The indicator series is treated as a first-order Markov chain. +This is the test that matters: a model clustering its exceptions is +systematically understating risk in stressed regimes and overstating it in calm +ones, which is exactly the failure mode of a static historical-simulation model +in March 2020. Kupiec is blind to it. -- Model logic is isolated in `risklib/` -- Configuration objects explicitly capture modeling assumptions -- UI layer does not modify or implement risk calculations -- Backtesting is performed out-of-sample -- Results are reproducible and exportable +**Joint conditional coverage.** LR_cc = LR_uc + LR_ind ~ χ²(2). The additivity +is the standard Christoffersen decomposition; the two statistics are +asymptotically independent. -This structure mirrors common **model risk governance principles**, including: -- Transparency -- Auditability -- Separation of concerns -- Clear documentation of assumptions +χ² p-values use exact closed forms — erfc(√(x/2)) for one degree of freedom and +exp(−x/2) for two — so the validation layer carries no SciPy dependency. + +### 5.3 Test coverage + +The test suite asserts model invariants independently of any dataset: + +- VaR ≥ 0 and ES ≥ VaR, for all four methods +- VaR monotone in α, for all four methods +- VaR homogeneous of degree 1 in exposure +- Component VaR sums to portfolio VaR under Euler allocation +- Facility EL sums to portfolio EL; grouped subtotals reconcile to the total +- Backtest exception counts match the flags actually raised +- The threshold at *t* equals the trailing quantity computed by hand from *t−1* +- Christoffersen rejects a correctly-sized but clustered exception series that + Kupiec passes +- GARCH MLE recovers known parameters from simulated data, and beats the fixed + defaults on the same series +- FHS σ is unchanged when future observations are removed + +A separate contract-test file pins every dictionary key the Streamlit app reads, +so a rename in the engine fails in CI rather than in front of a user. --- -## 7. Conclusion +## 6. Assumptions and limitations + +Limitations are stated as standing properties of the model. Items resolved since +the previous version are retained with their resolution, as an audit trail. -This model provides a **validation-focused implementation** of standard market and credit risk methodologies. +### 6.1 Open limitations -The emphasis is on: -- Correct methodology -- Proper validation -- Interpretability -- Governance-aligned design +**L1 — i.i.d. and stationarity.** All methods assume returns are i.i.d. within +the estimation window and that the return distribution is stationary. Both are +violated during volatility regime changes. The GARCH filter partially addresses +this, for the FHS method only. -The model is suitable as a **demonstration artifact** for risk analytics, model validation, and institutional risk roles. +**L2 — Multivariate normality (Monte Carlo).** Joint returns are assumed +multivariate normal. Empirical returns exhibit excess kurtosis and negative +skewness, so tail losses are likely understated at high confidence levels. A +copula or multivariate-*t* specification is the natural extension. + +**L3 — Square-root-of-time scaling.** Multi-day VaR is approximated by √h +scaling, valid only under i.i.d. returns. It understates risk when volatility is +autocorrelated. Applies to the historical, parametric and Monte Carlo methods; +FHS is a one-step-ahead forecast and does not scale. + +**L4 — Fixed GARCH parameters in the rolling FHS backtest.** Point estimates can +use MLE, but the rolling backtest retains fixed α and β. Re-estimating at every +step requires a full optimisation per day and is deliberately out of scope. + +**L5 — Zero conditional mean in the GARCH filter.** Defensible for daily equity +returns, but it is an assumption, and it is now reported as one. + +**L6 — Duration approximation ignores convexity.** ΔP/P ≈ −D·Δy is first-order. +At 200bp on a long-duration instrument the omitted second-order term is material, +and the linear estimate **overstates** the loss. + +**L7 — Credit model scope.** Point-in-time Expected Loss from user-supplied +inputs. It does not estimate PD from default history, does not model the loss +distribution, and computes neither Unexpected Loss nor economic capital. + +**L8 — Correlation stress and PSD.** Element-wise correlation bumping does not +preserve positive semi-definiteness. The bumped matrix is projected back onto +the PSD cone by eigenvalue clipping, and the projection is flagged in the output. +A large bump requiring adjustment signals that the scenario is straining the +correlation structure. + +**L9 — Single demo dataset.** Published results derive from one three-instrument +portfolio over one sample period. They demonstrate that the machinery works and +is reproducible; they are not evidence of general model performance. + +### 6.2 Resolved + +**R1 — Unconditional coverage only.** *Resolved.* `backtest.py` implements the +full Christoffersen suite: independence and joint conditional coverage alongside +Kupiec. All three are returned by both backtests and displayed in the app. + +**R2 — Fixed GARCH parameters.** *Resolved for point estimates.* `garch.py` +provides MLE estimation with stationarity enforced by parameter transformation. +Enabled via `fit_garch=True`; the default remains `False` so prior results stay +reproducible. See L4 for the remaining scope limit. + +**R3 — `fit_garch` had no effect.** *Resolved.* The flag was accepted by the +config but never forwarded to the estimator, so the MLE path was unreachable +while the documentation described it as working. It is now threaded through +`fit()` and covered by a regression test. + +**R4 — Monte Carlo settings were not honoured.** *Resolved.* Simulation count, +seed and shrinkage were collected in the interface but never reached the +computation. They are now config fields, threaded through and part of the cache +key. + +**R5 — Look-ahead in the FHS backtest.** *Resolved.* The long-run variance +driving ω was computed from the full sample; it is now an expanding, lagged +estimate. Covered by a truncation test. + +**R6 — Portfolio EL/EAD was equal-weighted.** *Resolved.* It was the mean of +per-facility ratios, which let a small facility move the portfolio figure as much +as a large one and disagreed with every grouped subtotal beside it. It is now +exposure-weighted. + +**R7 — Rate shocks moved equities.** *Resolved.* The default duration was 7 +years applied to every instrument, so a +200bp scenario knocked roughly 14% off +an equity position. The default is now 0; only instruments with an explicit or +recognised duration respond. + +**R8 — Duplicate implementations.** *Resolved.* Twelve core functions existed in +two directories with divergent signatures. `risklib` is now the single source of +truth and `risk_engine` has been removed. --- -**End of Report** +## 7. Change log + +| Version | Change | +|---|---| +| 0.2 | Consolidated to a single `risklib` package; removed duplicate implementations; fixed R3–R7; unified GARCH into one recursion; added true incremental VaR; exact box-constrained ERC projection; generated, CI-verified validation results; test suite from 5 to 129 tests | +| 0.1 | Initial engine, Kupiec backtest, Streamlit interface | + +--- + +## 8. Deferred extensions + +Factor models, ALM, CVA, portfolio optimisation, multi-step GARCH forecasting, +copula and multivariate-*t* simulation, PD estimation from default history, loss +distributions and economic capital, bootstrap confidence intervals for VaR, and +window-sensitivity analysis. diff --git a/docs/model_risk_report.md b/docs/model_risk_report.md deleted file mode 100644 index 2307231..0000000 --- a/docs/model_risk_report.md +++ /dev/null @@ -1,53 +0,0 @@ -# Model Risk Report (V1) — Market Risk VaR/ES - -## 1. Purpose -Demonstrate a validation-ready risk engine that: -1) computes VaR/ES under clear assumptions and conventions, and -2) evaluates reliability using standard model risk diagnostics. - -## 2. Loss Convention (Non-negotiable) -All risk measures are reported as **positive loss amounts**: -- returns < 0 represent losses -- VaR, ES ≥ 0 -- ES ≥ VaR - -## 3. Models Implemented -### 3.1 Historical Simulation (HS) -- VaR computed as the empirical *(1 − α)* quantile of portfolio returns -- ES computed as the conditional mean of returns beyond the VaR threshold - -### 3.2 Parametric Normal -- assumes portfolio returns are approximately normal -- VaR/ES derived from μ and σ using Φ^{-1}(α) and φ(z) - -### 3.3 Monte Carlo (MVN) -- simulates multivariate normal returns using sample μ, Σ -- includes light covariance shrinkage toward diagonal for stability -- VaR/ES computed from simulated portfolio return distribution - -### 3.4 Filtered Historical Simulation (GARCH-lite) -- fixed-parameter GARCH(1,1) volatility filter produces σ_t -- standardized residuals z_t = r_t / σ_t -- VaR/ES computed from z distribution and scaled by σ_{t+1} - -## 4. Validation Approach -### 4.1 Rolling Backtest (1-day VaR) -- estimate VaR_t from trailing window using data up to t−1 -- exception occurs if realized r_t falls below VaR threshold -- output: exception series and backtest summary - -### 4.2 Kupiec POF (Unconditional Coverage) -- H₀: exception probability equals (1 − α) -- returns LR statistic and p-value -- small p-values indicate rejection of correct coverage - -## 5. Known Limitations (Explicit) -- HS assumes returns are iid/stationary within window -- Normal and MVN assume elliptical tails and may understate tail risk -- GARCH-lite uses fixed parameters (not MLE-estimated) -- Backtest currently targets unconditional coverage only (no independence test in V1) - -## 6. Next Validation Extensions (Planned) -- Christoffersen independence / conditional coverage -- stress testing of window sensitivity and α calibration -- bootstrap confidence intervals for VaR estimates diff --git a/docs/validation_results.md b/docs/validation_results.md new file mode 100644 index 0000000..3810f1a --- /dev/null +++ b/docs/validation_results.md @@ -0,0 +1,150 @@ +# Validation Results + +> **Generated by `scripts/generate_validation_results.py`. Do not edit by hand.** +> Every figure below is reproducible from the data committed in `data/`: +> `python scripts/generate_validation_results.py` + +## Test portfolio + +- **Instruments:** AAPL, TLT, MSFT (equal-weighted, 33.3% each) +- **Exposure:** $1,000,000 +- **Sample:** 2020-01-03 to 2024-12-31 (1,257 daily log-return observations) +- **Monte Carlo:** 100,000 paths, seed 42 +- **Backtest window:** 250 trading days + +### Portfolio statistics + +| Metric | Value | +|---|---:| +| Annualised return | 12.84% | +| Annualised volatility | 19.73% | +| Sharpe ratio (RF = 0) | 0.65 | +| AAPL annualised volatility | 31.65% | +| TLT annualised volatility | 17.95% | +| MSFT annualised volatility | 30.52% | + +### Correlation matrix + +| | AAPL | TLT | MSFT | +|---|---:|---:|---:| +| **AAPL** | 1.000 | -0.089 | 0.751 | +| **TLT** | -0.089 | 1.000 | -0.095 | +| **MSFT** | 0.751 | -0.095 | 1.000 | + +## 1. Point risk measures (1-day horizon) + +| Method | VaR 95% | ES 95% | VaR 99% | ES 99% | +|---|---:|---:|---:|---:| +| Historical Simulation | $19,903 | $28,428 | $31,645 | $44,913 | +| Parametric (Normal) | $19,929 | $25,121 | $28,397 | $32,608 | +| Monte Carlo | $20,005 | $25,211 | $28,505 | $32,483 | +| Filtered Historical (GARCH, fixed) | $15,295 | $20,326 | $23,298 | $27,499 | +| Filtered Historical (GARCH, MLE) | $16,313 | $21,328 | $24,263 | $28,829 | + +ES exceeds VaR at every confidence level for every method, as the loss-convention invariant in `MarketRiskModel.fit()` requires. + +## 2. GARCH(1,1) parameters — assumed vs estimated + +| Parameter | Fixed (variance targeting) | MLE | +|---|---:|---:| +| omega | 1.544e-06 | 4.826e-06 | +| alpha (ARCH) | 0.0500 | 0.0907 | +| beta (GARCH) | 0.9400 | 0.8758 | +| persistence | 0.9900 | 0.9665 | +| long-run volatility (ann.) | 19.73% | 19.05% | +| log-likelihood | — | 3,856.0 | +| AIC / BIC | — | -7,705.9 / -7,690.5 | + +The assumed parameters imply persistence of 0.9900; the estimated value is 0.9665. The fixed convention therefore overstates volatility persistence on this sample, which is precisely the misspecification the MLE path was added to address. + +**Assumptions reported by the model object under MLE:** + +- iid returns +- stationarity within estimation window +- GARCH(1,1) volatility filter, parameters estimated by MLE +- zero conditional mean + +## 3. Backtesting + +Thresholds use information available strictly through *t−1*. Three tests are reported: **Kupiec POF** (unconditional coverage), **Christoffersen** (independence / no clustering), and **joint conditional coverage** (LR_cc = LR_uc + LR_ind ~ chi-squared with 2 df). + +### Rolling Historical Simulation (250-day window) + +| α | T | Exc. | Hit % | Exp. % | Kupiec LR | p | Christ. LR | p | Joint LR | p | Result | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---| +| 95.0% | 1,007 | 51 | 5.06% | 5.00% | 0.009 | 0.9253 | 0.070 | 0.7907 | 0.079 | 0.9611 | PASS | +| 97.5% | 1,007 | 23 | 2.28% | 2.50% | 0.198 | 0.6560 | 0.358 | 0.5499 | 0.556 | 0.7573 | PASS | +| 99.0% | 1,007 | 15 | 1.49% | 1.00% | 2.119 | 0.1455 | 1.526 | 0.2167 | 3.645 | 0.1616 | PASS | + +### Rolling Filtered Historical Simulation (250-day window) + +| α | T | Exc. | Hit % | Exp. % | Kupiec LR | p | Christ. LR | p | Joint LR | p | Result | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---| +| 95.0% | 1,007 | 54 | 5.36% | 5.00% | 0.272 | 0.6017 | 1.812 | 0.1783 | 2.084 | 0.3527 | PASS | +| 97.5% | 1,007 | 29 | 2.88% | 2.50% | 0.569 | 0.4508 | 0.032 | 0.8576 | 0.601 | 0.7405 | PASS | +| 99.0% | 1,007 | 15 | 1.49% | 1.00% | 2.119 | 0.1455 | 0.454 | 0.5004 | 2.573 | 0.2762 | PASS | + +**Exception transition counts (Historical, α = 95%)** + +| | → no exception | → exception | +|---|---:|---:| +| **no exception →** | 907 | 48 | +| **exception →** | 48 | 3 | + +P(exception | no exception) = 0.0503; P(exception | exception) = 0.0588. Under independence these are approximately equal; a materially larger second figure indicates clustering. + +## 4. VaR decomposition — Euler allocation (Normal, α = 95%) + +| Instrument | Weight | Marginal VaR | Component VaR | % of VaR | +|---|---:|---:|---:|---:| +| AAPL | 33.3% | $28,377 | $9,459 | 47.5% | +| TLT | 33.3% | $4,103 | $1,368 | 6.9% | +| MSFT | 33.3% | $27,307 | $9,102 | 45.7% | +| **Total** | **100.0%** | | **$19,929** | **100.0%** | + +Component VaR sums to $19,928.81 against a portfolio VaR of $19,928.81 — agreement to within 1e-09 relative, verified when this report is generated. The identity is exact, not approximate: VaR is homogeneous of degree 1 in the weights, so Euler's theorem holds with equality. + +## 5. Risk budgeting — Equal Risk Contribution + +| Instrument | Weight (equal) | Weight (ERC) | % of VaR (equal) | % of VaR (ERC) | +|---|---:|---:|---:|---:| +| AAPL | 33.3% | 22.4% | 47.5% | 32.5% | +| TLT | 33.3% | 54.3% | 6.9% | 34.8% | +| MSFT | 33.3% | 23.3% | 45.7% | 32.7% | + +Converged in 22 iterations; risk-contribution dispersion 3.08e-08. Portfolio VaR moves from $19,929 to $15,994. Contributions are equalised in variance space, so the percentage-of-VaR figures — which additionally carry the drift term — are close to but not exactly equal. + +## 6. Stress scenarios + +| Scenario | Definition | Impact | +|---|---|---:| +| Equity shock | AAPL, MSFT each −20%, rate-sensitive instruments flat | −$133,333 | +| Rate shock | +200bp parallel; durations TLT 18y; equities 0y | −$120,000 | +| Volatility stress | covariance ×2 (volatility ×1.41) | VaR $28,552 → $40,590 (+$12,038) | +| Correlation stress | off-diagonal correlations ×1.5, volatilities held | VaR $28,552 → $29,953 (+$1,400) | + +Equity instruments carry zero duration, so the parallel rate shock does not move them. The rate impact is attributable entirely to TLT. + +## 7. Credit — Expected Loss + +| Segment | Facilities | EAD | Expected Loss | EL / EAD | +|---|---:|---:|---:|---:| +| Corporate | 2 | $450,000 | $4,075 | 0.91% | +| Retail | 2 | $250,000 | $4,550 | 1.82% | +| **Portfolio** | **4** | **$700,000** | **$8,625** | **1.23%** | + +Segment EL sums to portfolio EL, and portfolio EL/EAD is exposure-weighted (total EL ÷ total EAD) so it is consistent with the segment rows above it. + +### Scenario sensitivity + +| Scenario | Expected Loss | Change | +|---|---:|---:| +| Base | $8,625 | — | +| PD ×1.5 | $12,938 | +$4,312 | +| PD +100bp | $11,600 | +$2,975 | +| LGD +10pp | $10,525 | +$1,900 | +| PD ×2 and LGD ×1.25 | $21,562 | +$12,938 | + +--- + +*Regenerate with `python scripts/generate_validation_results.py`. Last generated 2026-08-08.* diff --git a/notebooks/notebooks/notebooks/exceptions_99pct_window250.csv b/notebooks/notebooks/notebooks/exceptions_99pct_window250.csv deleted file mode 100644 index 9302b92..0000000 --- a/notebooks/notebooks/notebooks/exceptions_99pct_window250.csv +++ /dev/null @@ -1,1258 +0,0 @@ -date,return,VaR_threshold,exception -2020-01-03,-0.0023387563088326318,,0 -2020-01-06,0.0016077127252058653,,0 -2020-01-07,-0.006267098542734821,,0 -2020-01-08,0.008375852421693608,,0 -2020-01-09,0.012312911226099805,,0 -2020-01-10,0.0021572053542718087,,0 -2020-01-13,0.010017883474843173,,0 -2020-01-14,-0.005177545572126249,,0 -2020-01-15,0.002923583871182725,,0 -2020-01-16,0.009150043524431096,,0 -2020-01-17,0.0026688561469991306,,0 -2020-01-21,1.7969063836101488e-05,,0 -2020-01-22,0.0007513495197669431,,0 -2020-01-23,0.005972815379968026,,0 -2020-01-24,-0.0016526613970011363,,0 -2020-01-27,-0.010425410823296658,,0 -2020-01-28,0.01314696310243304,,0 -2020-01-29,0.015306882255943201,,0 -2020-01-30,0.00883510985085846,,0 -2020-01-31,-0.0170441430528672,,0 -2020-02-03,0.006842063034728869,,0 -2020-02-04,0.017102761523379313,,0 -2020-02-05,-0.0013651031520893545,,0 -2020-02-06,0.012215707605085401,,0 -2020-02-07,4.302238862326606e-05,,0 -2020-02-10,0.011108258473306303,,0 -2020-02-11,-0.011312771999649513,,0 -2020-02-12,0.006617884241547978,,0 -2020-02-13,-0.002915405780975125,,0 -2020-02-14,0.004500652500042147,,0 -2020-02-18,-0.0005210445732531907,,0 -2020-02-19,0.005768251884995674,,0 -2020-02-20,-0.005965957638368689,,0 -2020-02-21,-0.01524032682929712,,0 -2020-02-24,-0.025973698008539766,,0 -2020-02-25,-0.01526268290282415,,0 -2020-02-26,0.007615494153861827,,0 -2020-02-27,-0.04329216904314712,,0 -2020-02-28,0.015224041917688882,,0 -2020-03-02,0.04868411073899499,,0 -2020-03-03,-0.021991184411364585,,0 -2020-03-04,0.02357418565442269,,0 -2020-03-05,-0.011267553584223041,,0 -2020-03-06,0.002896957557537861,,0 -2020-03-09,-0.04194366071866374,,0 -2020-03-10,0.02769133245150288,,0 -2020-03-11,-0.03973324771040264,,0 -2020-03-12,-0.0658139286372888,,0 -2020-03-13,0.07440810533060982,,0 -2020-03-16,-0.07813582866850434,,0 -2020-03-17,0.017714444277899817,,0 -2020-03-18,-0.041952910937667905,,0 -2020-03-19,0.011829788340958988,,0 -2020-03-20,-0.010456696875935586,,0 -2020-03-23,0.00296171488036656,,0 -2020-03-24,0.05460482914066003,,0 -2020-03-25,-0.005805098144155852,,0 -2020-03-26,0.0389556349511728,,0 -2020-03-27,-0.01928963493556548,,0 -2020-03-30,0.029284469297880398,,0 -2020-03-31,-0.008682146098125077,,0 -2020-04-01,-0.025430598285120263,,0 -2020-04-02,0.014457237905709575,,0 -2020-04-03,-0.007117384945132116,,0 -2020-04-06,0.05091940920906697,,0 -2020-04-07,-0.011001940288874317,,0 -2020-04-08,0.009316806405881284,,0 -2020-04-09,0.0030221062244464793,,0 -2020-04-13,0.004167742844718162,,0 -2020-04-14,0.03244123850311732,,0 -2020-04-15,0.0021472829711772375,,0 -2020-04-16,0.01626220246220582,,0 -2020-04-17,-0.006128074830680335,,0 -2020-04-20,-0.01099393883713753,,0 -2020-04-21,-0.02037393176955423,,0 -2020-04-22,0.017175962271405634,,0 -2020-04-23,-0.0035881037888741236,,0 -2020-04-24,0.016299562015239525,,0 -2020-04-27,-0.006983685374539408,,0 -2020-04-28,-0.00985307315593269,,0 -2020-04-29,0.023670340037764814,,0 -2020-04-30,0.006371056785235295,,0 -2020-05-01,-0.01131195631387799,,0 -2020-05-04,0.011166736462017386,,0 -2020-05-05,0.006384802758743899,,0 -2020-05-06,0.0012653148774160556,,0 -2020-05-07,0.01088362263369663,,0 -2020-05-08,0.005433683280745383,,0 -2020-05-11,0.006289924054666411,,0 -2020-05-12,-0.00806515115799623,,0 -2020-05-13,-0.006804321422989398,,0 -2020-05-14,0.0067312978239876045,,0 -2020-05-15,0.001986109790905322,,0 -2020-05-18,0.0035326354646350315,,0 -2020-05-19,-0.003002525425135496,,0 -2020-05-20,0.012008825005466644,,0 -2020-05-21,-0.005690866848385524,,0 -2020-05-22,0.004283796059603959,,0 -2020-05-26,-0.010308119067393587,,0 -2020-05-27,0.001276642583167489,,0 -2020-05-28,-0.0018350697607397758,,0 -2020-05-29,0.005429533125858512,,0 -2020-06-01,0.0010576128377620165,,0 -2020-06-02,0.004096281669459836,,0 -2020-06-03,-0.0018219012484102307,,0 -2020-06-04,-0.01216178999904374,,0 -2020-06-05,0.014687662707003123,,0 -2020-06-08,0.005302898535283129,,0 -2020-06-09,0.016666653868744996,,0 -2020-06-10,0.025513641283304727,,0 -2020-06-11,-0.028556922446839973,,0 -2020-06-12,0.0022565835452505413,,0 -2020-06-15,0.006405447414121332,,0 -2020-06-16,0.011638324615198637,,0 -2020-06-17,0.0020605362405502837,,0 -2020-06-18,0.007194135634025321,,0 -2020-06-19,-0.0036360783586967017,,0 -2020-06-22,0.017782000447634047,,0 -2020-06-23,0.006973645603659197,,0 -2020-06-24,-0.00916827529793561,,0 -2020-06-25,0.009541259540303682,,0 -2020-06-26,-0.013593390471420401,,0 -2020-06-29,0.01010766309557129,,0 -2020-06-30,0.009557590074596901,,0 -2020-07-01,0.0006644863340890505,,0 -2020-07-02,0.0028772752908456795,,0 -2020-07-06,0.014530777289775346,,0 -2020-07-07,-0.0002818810369805844,,0 -2020-07-08,0.013591569007147721,,0 -2020-07-09,0.009021461078031812,,0 -2020-07-10,-0.00210909759288694,,0 -2020-07-13,-0.010899285293509468,,0 -2020-07-14,0.008003992366026053,,0 -2020-07-15,0.00022885989470683435,,0 -2020-07-16,-0.009215385368891924,,0 -2020-07-17,-0.003077153133133568,,0 -2020-07-20,0.02179791187994256,,0 -2020-07-21,-0.00895357059022977,,0 -2020-07-22,0.00668642325418327,,0 -2020-07-23,-0.026480358759647105,,0 -2020-07-24,-0.0028933527127777176,,0 -2020-07-27,0.010764591619050325,,0 -2020-07-28,-0.006229087207051012,,0 -2020-07-29,0.009109977257764816,,0 -2020-07-30,0.005936719402929844,,0 -2020-07-31,0.03478319858739773,,0 -2020-08-03,0.02485142429647458,,0 -2020-08-04,0.00034018307487011817,,0 -2020-08-05,-0.002268386635908337,,0 -2020-08-06,0.01858387071618338,,0 -2020-08-07,-0.015912034975813148,,0 -2020-08-10,-0.003387337771182136,,0 -2020-08-11,-0.021936868446537427,,0 -2020-08-12,0.017241110557821463,,0 -2020-08-13,0.0014835358335089496,,0 -2020-08-14,-0.001282616913373596,,0 -2020-08-17,0.002159943216550274,,0 -2020-08-18,0.007054181915081556,,0 -2020-08-19,-0.003700829579512747,,0 -2020-08-20,0.017983546953577075,,0 -2020-08-21,0.01630862598724171,,0 -2020-08-24,0.004467883137721902,,0 -2020-08-25,-0.0009982978374448428,,0 -2020-08-26,0.010354419725766055,,0 -2020-08-27,-0.0016416769695545726,,0 -2020-08-28,0.00262156847424499,,0 -2020-08-31,0.008364625320661484,,0 -2020-09-01,0.01937263752896945,,0 -2020-09-02,0.0025426604468311207,,0 -2020-09-03,-0.04818652144660451,,0 -2020-09-04,-0.010881414830008896,,0 -2020-09-08,-0.03971816090698572,,0 -2020-09-09,0.02604068864344069,,0 -2020-09-10,-0.01884622593205299,,0 -2020-09-11,-0.00587728870203256,,0 -2020-09-14,0.012079792199662497,,0 -2020-09-15,0.005132326825215631,,0 -2020-09-16,-0.016992054178115362,,0 -2020-09-17,-0.007843936497827102,,0 -2020-09-18,-0.01594783949848969,,0 -2020-09-21,0.015202568009714347,,0 -2020-09-22,0.0128702929485569,,0 -2020-09-23,-0.02499862430070402,,0 -2020-09-24,0.008972600011999728,,0 -2020-09-25,0.019786788040466772,,0 -2020-09-28,0.00950107280469518,,0 -2020-09-29,-0.005635703066733712,,0 -2020-09-30,0.006678737925606564,,0 -2020-10-01,0.006716900095364435,,0 -2020-10-02,-0.022169871944128578,,0 -2020-10-05,0.010237088314045004,,0 -2020-10-06,-0.015063184537212946,,0 -2020-10-07,0.009454491736927691,,0 -2020-10-08,0.0026656994072024997,,0 -2020-10-09,0.01386406510971014,,0 -2020-10-12,0.030112256633921627,,0 -2020-10-13,-0.004392401463851791,,0 -2020-10-14,-0.001995647383670067,,0 -2020-10-15,-0.0037150067220742297,,0 -2020-10-16,-0.005628038181591119,,0 -2020-10-19,-0.018246014248181053,,0 -2020-10-20,0.0018497799989286858,,0 -2020-10-21,-0.0027407665566140083,,0 -2020-10-22,-0.006554044669909861,,0 -2020-10-23,0.0020527100811074265,,0 -2020-10-26,-0.006502599256559485,,0 -2020-10-27,0.011682218395990695,,0 -2020-10-28,-0.03244065354726682,,0 -2020-10-29,0.012213526358641192,,0 -2020-10-30,-0.026204743805234194,,0 -2020-11-02,0.0019811308361141044,,0 -2020-11-03,0.009826710154902142,,0 -2020-11-04,0.03620192707442494,,0 -2020-11-05,0.02266802798403461,,0 -2020-11-06,-0.003790421536009784,,0 -2020-11-09,-0.021840583249064474,,0 -2020-11-10,-0.014391302262272206,,0 -2020-11-11,0.01980671144441461,,0 -2020-11-12,0.00312966392434893,,0 -2020-11-13,0.001348986228378127,,0 -2020-11-16,0.0031988971434016395,,0 -2020-11-17,-0.004639814894314183,,0 -2020-11-18,-0.007278546478838682,,0 -2020-11-19,0.005539408145422605,,0 -2020-11-20,-0.0039299793641686745,,0 -2020-11-23,-0.01203917913162098,,0 -2020-11-24,0.0064052753926556695,,0 -2020-11-25,0.0014678153664234765,,0 -2020-11-27,0.006937302081108212,,0 -2020-11-30,0.0047421705850164185,,0 -2020-12-01,0.008484796981065428,,0 -2020-12-02,-0.002977191368307562,,0 -2020-12-03,0.0005445616025770583,,0 -2020-12-04,-0.006614489035617947,,0 -2020-12-07,0.006949552987245319,,0 -2020-12-08,0.005929179553598304,,0 -2020-12-09,-0.014706093601392337,,0 -2020-12-10,0.0048934554685989414,,0 -2020-12-11,0.0032356893616464033,,0 -2020-12-14,-0.001179030354337514,,0 -2020-12-15,0.015065400035427218,,0 -2020-12-16,0.00685119847076014,,0 -2020-12-17,0.0016427338044300204,,0 -2020-12-18,-0.007589754045253967,,0 -2020-12-21,0.011470262653206676,,0 -2020-12-22,0.013087151317836276,,0 -2020-12-23,-0.009019770987410587,,0 -2020-12-24,0.006476434193634503,,0 -2020-12-28,0.015152832517806374,,0 -2020-12-29,-0.006094470801490889,,0 -2020-12-30,-0.005827724423673687,-0.04578828876891038,0 -2020-12-31,-0.0009801331488943413,-0.04578828876891038,0 -2021-01-04,-0.015909945733030492,-0.04578828876891038,0 -2021-01-05,0.0019325547499995383,-0.04578828876891038,0 -2021-01-06,-0.027084780197871635,-0.04578828876891038,0 -2021-01-07,0.017586543797737216,-0.04578828876891038,0 -2021-01-08,0.0038119392529914506,-0.04578828876891038,0 -2021-01-11,-0.011640891297748614,-0.04578828876891038,0 -2021-01-12,-0.004677281917828835,-0.04578828876891038,0 -2021-01-13,0.011278180836839053,-0.04578828876891038,0 -2021-01-14,-0.013373341190721791,-0.04578828876891038,0 -2021-01-15,-0.0038681145479472602,-0.04578828876891038,0 -2021-01-19,0.008766739252948965,-0.04578828876891038,0 -2021-01-20,0.023053932807171752,-0.04578828876891038,0 -2021-01-21,0.010588028329804523,-0.04578828876891038,0 -2021-01-22,0.007841331641714626,-0.04578828876891038,0 -2021-01-25,0.018248146381796077,-0.04578828876891038,0 -2021-01-26,0.003971221616014407,-0.04578828876891038,0 -2021-01-27,-0.0009078312905101169,-0.04578828876891038,0 -2021-01-28,-0.005197939513728416,-0.04578828876891038,0 -2021-01-29,-0.024592824075158917,-0.04578828876891038,0 -2021-02-01,0.016719417434243013,-0.04578828876891038,0 -2021-02-02,-0.0002452363305023627,-0.04578828876891038,0 -2021-02-03,-0.0007517852803093838,-0.04578828876891038,0 -2021-02-04,0.0062244027251057,-0.04578828876891038,0 -2021-02-05,-0.0035756548537715753,-0.04578828876891038,0 -2021-02-08,0.0021970909575603844,-0.04578828876891038,0 -2021-02-09,-0.00016924338744900131,-0.04578828876891038,0 -2021-02-10,-0.0006139654508624478,-0.04578828876891038,0 -2021-02-11,-0.00018613142748046373,-0.04578828876891038,0 -2021-02-12,-0.0028930634505220224,-0.04578828876891038,0 -2021-02-16,-0.012286280422782753,-0.04578828876891038,0 -2021-02-17,-0.0023736046540120855,-0.04578828876891038,0 -2021-02-18,-0.004803311619690013,-0.04578828876891038,0 -2021-02-19,-0.007927684332991994,-0.04578828876891038,0 -2021-02-22,-0.021687764700742043,-0.04578828876891038,0 -2021-02-23,-0.003100221133610489,-0.04578828876891038,0 -2021-02-24,-0.0017232340781072478,-0.04578828876891038,0 -2021-02-25,-0.025286162620929885,-0.04513205229722556,0 -2021-02-26,0.016483144382275897,-0.04513205229722556,0 -2021-03-01,0.01952961517147864,-0.04513205229722556,0 -2021-03-02,-0.011361906647734017,-0.04513205229722556,0 -2021-03-03,-0.021005595450757388,-0.04513205229722556,0 -2021-03-04,-0.008687767612791277,-0.04513205229722556,0 -2021-03-05,0.011292904650240215,-0.04513205229722556,0 -2021-03-08,-0.022905629543786873,-0.04513205229722556,0 -2021-03-09,0.027107117036290446,-0.04513205229722556,0 -2021-03-10,-0.004347144393312461,-0.04513205229722556,0 -2021-03-11,0.00973027158038964,-0.04085788342263363,0 -2021-03-12,-0.011647009584175387,-0.04085788342263363,0 -2021-03-15,0.008676509319518508,-0.036152182300723444,0 -2021-03-16,0.006970291471643093,-0.036152182300723444,0 -2021-03-17,-0.005601852508576769,-0.030537625308057658,0 -2021-03-18,-0.023898395045722554,-0.030537625308057658,0 -2021-03-19,2.7762694311159564e-05,-0.030537625308057658,0 -2021-03-22,0.021091678100472976,-0.030537625308057658,0 -2021-03-23,0.002929657838628148,-0.030537625308057658,0 -2021-03-24,-0.007930594140169443,-0.030537625308057658,0 -2021-03-25,-0.005700392510662541,-0.030537625308057658,0 -2021-03-26,0.006428039530138451,-0.030537625308057658,0 -2021-03-29,-0.004099352250650469,-0.030537625308057658,0 -2021-03-30,-0.007213337871656071,-0.030537625308057658,0 -2021-03-31,0.009920945081342626,-0.030537625308057658,0 -2021-04-01,0.01697388509642579,-0.030537625308057658,0 -2021-04-05,0.015427262029863883,-0.030537625308057658,0 -2021-04-06,0.001453044888936485,-0.030537625308057658,0 -2021-04-07,0.004836330462277512,-0.030537625308057658,0 -2021-04-08,0.013529546634839382,-0.030537625308057658,0 -2021-04-09,0.008878195816596901,-0.030537625308057658,0 -2021-04-12,-0.004507789202058174,-0.030537625308057658,0 -2021-04-13,0.013837439146481301,-0.030537625308057658,0 -2021-04-14,-0.010826350373885487,-0.030537625308057658,0 -2021-04-15,0.016770937733665673,-0.030537625308057658,0 -2021-04-16,-0.0018533637085121582,-0.030537625308057658,0 -2021-04-19,-0.0018403183856753975,-0.030537625308057658,0 -2021-04-20,-0.0033905220124956243,-0.030537625308057658,0 -2021-04-21,0.004624676263377447,-0.030537625308057658,0 -2021-04-22,-0.00685748620870052,-0.030537625308057658,0 -2021-04-23,0.01029411542795712,-0.030537625308057658,0 -2021-04-26,0.0010249141280396909,-0.030537625308057658,0 -2021-04-27,-0.0032030786663420356,-0.030537625308057658,0 -2021-04-28,-0.011363255288057103,-0.030537625308057658,0 -2021-04-29,-0.003931506184577954,-0.030537625308057658,0 -2021-04-30,-0.004748678990330833,-0.030537625308057658,0 -2021-05-03,0.0025037621493575277,-0.030537625308057658,0 -2021-05-04,-0.015185162090864611,-0.030537625308057658,0 -2021-05-05,-0.0005798930299259013,-0.030537625308057658,0 -2021-05-06,0.009168703459802759,-0.030537625308057658,0 -2021-05-07,0.003747366355559386,-0.030537625308057658,0 -2021-05-10,-0.01915261328931536,-0.030537625308057658,0 -2021-05-11,-0.005654901605753151,-0.030537625308057658,0 -2021-05-12,-0.02187336671757582,-0.030537625308057658,0 -2021-05-13,0.01200987473168033,-0.030537625308057658,0 -2021-05-14,0.016602381522245583,-0.030537625308057658,0 -2021-05-17,-0.007820209639539187,-0.030537625308057658,0 -2021-05-18,-0.007491011654221601,-0.030537625308057658,0 -2021-05-19,-0.0004110157185831119,-0.030537625308057658,0 -2021-05-20,0.014238266666293008,-0.030537625308057658,0 -2021-05-21,-0.005668307833121485,-0.030537625308057658,0 -2021-05-24,0.013182751284602542,-0.030537625308057658,0 -2021-05-25,0.0037955292344944267,-0.030537625308057658,0 -2021-05-26,-0.0010100203151601494,-0.030537625308057658,0 -2021-05-27,-0.008396975728850814,-0.030537625308057658,0 -2021-05-28,-0.001822348086553938,-0.030537625308057658,0 -2021-06-01,-0.004016533220817289,-0.030537625308057658,0 -2021-06-02,0.0027215752440426177,-0.030537625308057658,0 -2021-06-03,-0.007479940704651877,-0.030537625308057658,0 -2021-06-04,0.01763622228964692,-0.030537625308057658,0 -2021-06-07,0.002990768602525612,-0.030537625308057658,0 -2021-06-08,0.0029180075120888867,-0.030537625308057658,0 -2021-06-09,0.0052976769026554,-0.030537625308057658,0 -2021-06-10,0.004071993419096296,-0.029816275606063168,0 -2021-06-11,0.0035643596073235665,-0.029816275606063168,0 -2021-06-14,0.00810574616672668,-0.029816275606063168,0 -2021-06-15,-0.004522436930155211,-0.029816275606063168,0 -2021-06-16,-0.00024179763678784934,-0.029816275606063168,0 -2021-06-17,0.013655543264055867,-0.029816275606063168,0 -2021-06-18,0.0009461521302893012,-0.029816275606063168,0 -2021-06-21,0.003126401062756979,-0.029816275606063168,0 -2021-06-22,0.008654816144725781,-0.029816275606063168,0 -2021-06-23,-0.0018584910246654057,-0.029816275606063168,0 -2021-06-24,0.001590598997808692,-0.029816275606063168,0 -2021-06-25,-0.006323741382753525,-0.029816275606063168,0 -2021-06-28,0.012186945666720473,-0.029816275606063168,0 -2021-06-29,0.007676851522416913,-0.029816275606063168,0 -2021-06-30,0.0024032204406028265,-0.029816275606063168,0 -2021-07-01,0.0016324173262601857,-0.029816275606063168,0 -2021-07-02,0.015818027319925123,-0.029816275606063168,0 -2021-07-06,0.00876668933683783,-0.029816275606063168,0 -2021-07-07,0.011586087754480641,-0.029816275606063168,0 -2021-07-08,-0.0047796373335746,-0.029816275606063168,0 -2021-07-09,0.00022686354518881873,-0.029816275606063168,0 -2021-07-12,-0.0025810906720688974,-0.029816275606063168,0 -2021-07-13,0.00442879388052251,-0.029816275606063168,0 -2021-07-14,0.013514372221012221,-0.029816275606063168,0 -2021-07-15,0.00040521329541113433,-0.029816275606063168,0 -2021-07-16,-0.0056867240465625106,-0.029816275606063168,0 -2021-07-19,-0.006334193891685059,-0.029816275606063168,0 -2021-07-20,0.008086606650705801,-0.029816275606063168,0 -2021-07-21,-0.0034910489232507836,-0.029816275606063168,0 -2021-07-22,0.011898521062105412,-0.029816275606063168,0 -2021-07-23,0.005822394174541161,-0.029816275606063168,0 -2021-07-26,-0.0007398821393040414,-0.029816275606063168,0 -2021-07-27,-0.0043729868557240035,-0.029816275606063168,0 -2021-07-28,-0.004440513704385956,-0.029816275606063168,0 -2021-07-29,-1.3921819306677809e-05,-0.029816275606063168,0 -2021-07-30,0.00021222427526227037,-0.029816275606063168,0 -2021-08-02,0.0020766090664386385,-0.029816275606063168,0 -2021-08-03,0.007046372038581825,-0.029816275606063168,0 -2021-08-04,-0.000953001259777232,-0.029816275606063168,0 -2021-08-05,0.002029604336362431,-0.029816275606063168,0 -2021-08-06,-0.007275743883463287,-0.029816275606063168,0 -2021-08-09,-0.002615739375950181,-0.029816275606063168,0 -2021-08-10,-0.004854860948377695,-0.029816275606063168,0 -2021-08-11,0.0009830921861904245,-0.029816275606063168,0 -2021-08-12,0.009612592503743524,-0.029816275606063168,0 -2021-08-13,0.009172189349647275,-0.029816275606063168,0 -2021-08-16,0.007278745908386719,-0.029816275606063168,0 -2021-08-17,-0.0039164455257224,-0.029816275606063168,0 -2021-08-18,-0.009538646022261392,-0.029816275606063168,0 -2021-08-19,0.01007352452825701,-0.029816275606063168,0 -2021-08-20,0.01200790880905268,-0.029816275606063168,0 -2021-08-23,0.0034977274101331878,-0.029816275606063168,0 -2021-08-24,-0.005031419285757254,-0.029816275606063168,0 -2021-08-25,-0.006272165348174148,-0.029816275606063168,0 -2021-08-26,-0.004164085625912573,-0.029816275606063168,0 -2021-08-27,0.005347823537409992,-0.029816275606063168,0 -2021-08-30,0.015133139917834796,-0.029816275606063168,0 -2021-08-31,-0.006979744103047265,-0.029816275606063168,0 -2021-09-01,0.001992478706286807,-0.029816275606063168,0 -2021-09-02,0.003182546853121121,-0.026653562365479288,0 -2021-09-03,-0.0016493130727725182,-0.026653562365479288,0 -2021-09-07,0.0012353387121626434,-0.025754639024925082,0 -2021-09-08,-0.001084075157996981,-0.025754639024925082,0 -2021-09-09,-0.0015361045775557835,-0.025754639024925082,0 -2021-09-10,-0.015903806574494292,-0.025754639024925082,0 -2021-09-13,0.004750405157091278,-0.025754639024925082,0 -2021-09-14,0.003941939579997989,-0.025754639024925082,0 -2021-09-15,0.006416845869964227,-0.025754639024925082,0 -2021-09-16,-0.0016309448875339687,-0.025754639024925082,0 -2021-09-17,-0.013672571155627698,-0.025754639024925082,0 -2021-09-20,-0.009338736664556016,-0.025754639024925082,0 -2021-09-21,0.001419556916919549,-0.025754639024925082,0 -2021-09-22,0.011806412859074833,-0.025754639024925082,0 -2021-09-23,-0.004294178575103567,-0.025754639024925082,0 -2021-09-24,-0.003303569387121358,-0.025754639024925082,0 -2021-09-27,-0.01058133115772385,-0.025754639024925082,0 -2021-09-28,-0.025554651505714874,-0.025754639024925082,0 -2021-09-29,0.0032957444801529163,-0.025886198578469726,0 -2021-09-30,-0.005615009262998637,-0.025886198578469726,0 -2021-10-01,0.013874953924331655,-0.025886198578469726,0 -2021-10-04,-0.016133352083182593,-0.025886198578469726,0 -2021-10-05,0.008038871488307594,-0.025886198578469726,0 -2021-10-06,0.00895477006409464,-0.025886198578469726,0 -2021-10-07,0.0014831960300970204,-0.025886198578469726,0 -2021-10-08,-0.0032496677514796406,-0.025886198578469726,0 -2021-10-11,-0.0017583344700215745,-0.025886198578469726,0 -2021-10-12,0.0010705452631121265,-0.025886198578469726,0 -2021-10-13,0.005691029469966374,-0.025886198578469726,0 -2021-10-14,0.015078093000749169,-0.025886198578469726,0 -2021-10-15,0.002150589711745434,-0.025886198578469726,0 -2021-10-18,0.008829307193540543,-0.025886198578469726,0 -2021-10-19,0.001377214861087368,-0.025886198578469726,0 -2021-10-20,-0.00202724091037679,-0.025886198578469726,0 -2021-10-21,0.0037065167803293215,-0.025886198578469726,0 -2021-10-22,0.000163729999041272,-0.025886198578469726,0 -2021-10-25,-0.0017332866558884715,-0.025886198578469726,0 -2021-10-26,0.006401474897041874,-0.025886198578469726,0 -2021-10-27,0.018709929853612427,-0.02542309195217023,0 -2021-10-28,0.008312978754656126,-0.02542309195217023,0 -2021-10-29,0.002298539127611568,-0.02494642673350211,0 -2021-11-01,-0.0065522727630543,-0.02494642673350211,0 -2021-11-02,0.007623392897985987,-0.02494642673350211,0 -2021-11-03,0.0006572475412054654,-0.02494642673350211,0 -2021-11-04,0.004743071335210274,-0.02494642673350211,0 -2021-11-05,0.005763608981061407,-0.02494642673350211,0 -2021-11-08,-0.0015603166419502353,-0.02494642673350211,0 -2021-11-09,0.004099865813346687,-0.02494642673350211,0 -2021-11-10,-0.017749930039464522,-0.02494642673350211,0 -2021-11-11,0.001030516965627924,-0.02494642673350211,0 -2021-11-12,0.00755172397694717,-0.02494642673350211,0 -2021-11-15,-0.004833937571088947,-0.02494642673350211,0 -2021-11-16,0.004760752477681311,-0.02494642673350211,0 -2021-11-17,0.008309152834610837,-0.02494642673350211,0 -2021-11-18,0.012736613015809532,-0.02494642673350211,0 -2021-11-19,0.010904370163830739,-0.02494642673350211,0 -2021-11-22,-0.006159942332761086,-0.02494642673350211,0 -2021-11-23,-0.006163992213155409,-0.02494642673350211,0 -2021-11-24,0.006628804846039027,-0.02494642673350211,0 -2021-11-26,-0.01063099792317918,-0.02494642673350211,0 -2021-11-29,0.011476431619405815,-0.02494642673350211,0 -2021-11-30,0.009357175324534893,-0.02494642673350211,0 -2021-12-01,0.0004647649232585488,-0.02494642673350211,0 -2021-12-02,-0.002230402346825685,-0.02494642673350211,0 -2021-12-03,-0.006598373171181341,-0.02494642673350211,0 -2021-12-06,0.005747192272904447,-0.02494642673350211,0 -2021-12-07,0.017710582770685827,-0.02494642673350211,0 -2021-12-08,0.001746965978938843,-0.02494642673350211,0 -2021-12-09,-0.0009982897523451307,-0.02494642673350211,0 -2021-12-10,0.017719414427831762,-0.02494642673350211,0 -2021-12-13,-0.005143223321844432,-0.02494642673350211,0 -2021-12-14,-0.014523759869825395,-0.02494642673350211,0 -2021-12-15,0.01249264604693871,-0.02494642673350211,0 -2021-12-16,-0.023078108966072357,-0.02494642673350211,0 -2021-12-17,0.00045130532599318166,-0.02494642673350211,0 -2021-12-20,-0.009253987441739213,-0.02494642673350211,0 -2021-12-21,0.012633008640101393,-0.02494642673350211,0 -2021-12-22,0.012571710397045468,-0.02494642673350211,0 -2021-12-23,-0.00020534194137933277,-0.02494642673350211,0 -2021-12-27,0.016018845142446562,-0.02494642673350211,0 -2021-12-28,-0.004421587186768225,-0.02494642673350211,0 -2021-12-29,-0.0028112787347456354,-0.02494642673350211,0 -2021-12-30,-0.001989937569582577,-0.02494642673350211,0 -2021-12-31,-0.003487690432481291,-0.02494642673350211,0 -2022-01-03,-0.0021943702840956014,-0.02494642673350211,0 -2022-01-04,-0.011411705793178373,-0.024252553850735097,0 -2022-01-05,-0.023849080620184267,-0.024252553850735097,0 -2022-01-06,-0.0073938689154748344,-0.024252553850735097,0 -2022-01-07,-0.0019055762088118473,-0.024252553850735097,0 -2022-01-10,0.001101897995634974,-0.024252553850735097,0 -2022-01-11,0.00851342567049331,-0.024252553850735097,0 -2022-01-12,0.0030398498072377925,-0.024252553850735097,0 -2022-01-13,-0.017871569988927104,-0.024252553850735097,0 -2022-01-14,0.0024779965528603956,-0.024252553850735097,0 -2022-01-18,-0.01929640626434623,-0.024252553850735097,0 -2022-01-19,-0.004035057629589251,-0.024252553850735097,0 -2022-01-20,-0.0033240561428239885,-0.024252553850735097,0 -2022-01-21,-0.006550788725493967,-0.024252553850735097,0 -2022-01-24,-0.003992522714761392,-0.024252553850735097,0 -2022-01-25,-0.013338151738733539,-0.024252553850735097,0 -2022-01-26,0.005121424780580468,-0.024252553850735097,0 -2022-01-27,0.008580814026245595,-0.023874230977208793,0 -2022-01-28,0.03183168152047671,-0.023874230977208793,0 -2022-01-31,0.009797191445202216,-0.023874230977208793,0 -2022-02-01,-0.0038208648283007103,-0.023874230977208793,0 -2022-02-02,0.008502777641506407,-0.023874230977208793,0 -2022-02-03,-0.02147639096454271,-0.023874230977208793,0 -2022-02-04,-0.0003613800974668604,-0.023874230977208793,0 -2022-02-07,-0.006680327499817761,-0.023874230977208793,0 -2022-02-08,0.00783797970059075,-0.023874230977208793,0 -2022-02-09,0.010579728862515164,-0.023874230977208793,0 -2022-02-10,-0.02289513749432206,-0.023874230977208793,0 -2022-02-11,-0.01009328689679325,-0.023874230977208793,0 -2022-02-14,-0.003744227129362205,-0.023874230977208793,0 -2022-02-15,0.009947662509219348,-0.023874230977208793,0 -2022-02-16,0.0011171031454668856,-0.023874230977208793,0 -2022-02-17,-0.014602217916171967,-0.023874230977208793,0 -2022-02-18,-0.00286847875572828,-0.023874230977208793,0 -2022-02-22,-0.005367202873235577,-0.023874230977208793,0 -2022-02-23,-0.022105474101640255,-0.02347130450966943,0 -2022-02-24,0.02234409839326608,-0.02347130450966943,0 -2022-02-25,0.007601094531742556,-0.02347130450966943,0 -2022-02-28,0.009428208509477518,-0.02347130450966943,0 -2022-03-01,-0.004378615332181743,-0.02347130450966943,0 -2022-03-02,0.001069462546987674,-0.02347130450966943,0 -2022-03-03,-0.0020587607738548055,-0.02347130450966943,0 -2022-03-04,-0.007384695915338826,-0.02347130450966943,0 -2022-03-07,-0.023390192893015856,-0.02347130450966943,0 -2022-03-08,-0.010962449337895482,-0.023624225633871745,0 -2022-03-09,0.023129657978750162,-0.023624225633871745,0 -2022-03-10,-0.017390832652901415,-0.023624225633871745,0 -2022-03-11,-0.01345834078220426,-0.023624225633871745,0 -2022-03-14,-0.021197845952323095,-0.023624225633871745,0 -2022-03-15,0.02183645180951943,-0.023624225633871745,0 -2022-03-16,0.021087845926380258,-0.02323727176881354,0 -2022-03-17,0.0005888355450473009,-0.02323727176881354,0 -2022-03-18,0.016778741273267197,-0.02323727176881354,0 -2022-03-21,-0.00638800818397719,-0.02323727176881354,0 -2022-03-22,0.007979740231562222,-0.02323727176881354,0 -2022-03-23,0.004911443578235736,-0.02323727176881354,0 -2022-03-24,0.009945503350634953,-0.02323727176881354,0 -2022-03-25,-0.0038749203965433546,-0.02323727176881354,0 -2022-03-28,0.012130160111775543,-0.02323727176881354,0 -2022-03-29,0.013841122286354165,-0.02323727176881354,0 -2022-03-30,-0.0012756692856240486,-0.02323727176881354,0 -2022-03-31,-0.011116929149923592,-0.02323727176881354,0 -2022-04-01,0.0018912151277084774,-0.02323727176881354,0 -2022-04-04,0.011406984283616781,-0.02323727176881354,0 -2022-04-05,-0.018348635556597704,-0.02323727176881354,0 -2022-04-06,-0.021347517843462577,-0.02323727176881354,0 -2022-04-07,0.00015521164732869527,-0.02323727176881354,0 -2022-04-08,-0.012525964525629927,-0.02323727176881354,0 -2022-04-11,-0.02734281106093039,-0.02323727176881354,1 -2022-04-12,-0.00040168711700099246,-0.023624225633871745,0 -2022-04-13,0.012587209867729567,-0.023624225633871745,0 -2022-04-14,-0.026051453573922207,-0.023624225633871745,1 -2022-04-18,-0.0012834504359183975,-0.02471892177180487,0 -2022-04-19,0.007797951350757505,-0.02471892177180487,0 -2022-04-20,0.007539437458625867,-0.02471892177180487,0 -2022-04-21,-0.010617468178112764,-0.02471892177180487,0 -2022-04-22,-0.019656671375726374,-0.02471892177180487,0 -2022-04-25,0.013595281487186068,-0.02471892177180487,0 -2022-04-26,-0.022049084075548415,-0.02471892177180487,0 -2022-04-27,0.010870159756192344,-0.02471892177180487,0 -2022-04-28,0.02267816120983653,-0.02471892177180487,0 -2022-04-29,-0.031020216846397206,-0.02471892177180487,1 -2022-05-02,0.003071188496031103,-0.025808020560500612,0 -2022-05-03,0.0022649420071266285,-0.025808020560500612,0 -2022-05-04,0.02478980945722729,-0.025808020560500612,0 -2022-05-05,-0.043213541525703746,-0.025808020560500612,1 -2022-05-06,-0.006539977597128478,-0.026710045892296377,0 -2022-05-09,-0.020879719215996625,-0.026710045892296377,0 -2022-05-10,0.014478964676456013,-0.026710045892296377,0 -2022-05-11,-0.022639219965693756,-0.026710045892296377,0 -2022-05-12,-0.01642976238096688,-0.026710045892296377,0 -2022-05-13,0.012957101187136959,-0.026710045892296377,0 -2022-05-16,-0.003436917212077168,-0.026710045892296377,0 -2022-05-17,0.011029290211553081,-0.026710045892296377,0 -2022-05-18,-0.02785952955319643,-0.026710045892296377,1 -2022-05-19,-0.008754634644744881,-0.02760633749198607,0 -2022-05-20,0.003579337359403374,-0.02760633749198607,0 -2022-05-23,0.018091147900794823,-0.02760633749198607,0 -2022-05-24,-0.0012740212437019854,-0.02760633749198607,0 -2022-05-25,0.0053980198960979844,-0.02760633749198607,0 -2022-05-26,0.010397383126221328,-0.02760633749198607,0 -2022-05-27,0.023205464923600802,-0.02760633749198607,0 -2022-05-31,-0.010592230235941984,-0.02760633749198607,0 -2022-06-01,1.2579475657602035e-06,-0.02760633749198607,0 -2022-06-02,0.008361669665056885,-0.02760633749198607,0 -2022-06-03,-0.019405893833889683,-0.02760633749198607,0 -2022-06-06,-0.0060386552642086614,-0.02760633749198607,0 -2022-06-07,0.014010735527946247,-0.02760633749198607,0 -2022-06-08,-0.0072183412513080894,-0.02760633749198607,0 -2022-06-09,-0.018098434087664712,-0.02760633749198607,0 -2022-06-10,-0.030401051264774673,-0.02760633749198607,1 -2022-06-13,-0.03808578859710257,-0.029155705626101328,1 -2022-06-14,0.0010399599456550881,-0.030716825711402163,0 -2022-06-15,0.022565948290932676,-0.030716825711402163,0 -2022-06-16,-0.019963739941513754,-0.030716825711402163,0 -2022-06-17,0.008521617784390104,-0.030716825711402163,0 -2022-06-21,0.013143913794072773,-0.030716825711402163,0 -2022-06-22,0.006962518638032752,-0.030716825711402163,0 -2022-06-23,0.01730323629790376,-0.030716825711402163,0 -2022-06-24,0.014620568105606182,-0.030716825711402163,0 -2022-06-27,-0.006372432973100175,-0.030716825711402163,0 -2022-06-28,-0.01928599234719806,-0.030716825711402163,0 -2022-06-29,0.014352687581072669,-0.030716825711402163,0 -2022-06-30,-0.007571822377927295,-0.030716825711402163,0 -2022-07-01,0.01230002855408776,-0.030716825711402163,0 -2022-07-05,0.01303280269416132,-0.030716825711402163,0 -2022-07-06,0.0017766857200594772,-0.030716825711402163,0 -2022-07-07,0.007689870392484794,-0.030716825711402163,0 -2022-07-08,-0.0030649400904903235,-0.030716825711402163,0 -2022-07-11,-0.003203012481055065,-0.030716825711402163,0 -2022-07-12,-0.00970330673947429,-0.030716825711402163,0 -2022-07-13,0.0017030114100797637,-0.030716825711402163,0 -2022-07-14,0.0058168725142877555,-0.030716825711402163,0 -2022-07-15,0.009111399130191947,-0.030716825711402163,0 -2022-07-18,-0.013666883390490063,-0.030716825711402163,0 -2022-07-19,0.013838777707184708,-0.030716825711402163,0 -2022-07-20,0.008847539052532718,-0.030716825711402163,0 -2022-07-21,0.013953700483555353,-0.030716825711402163,0 -2022-07-22,-0.0028443557912086353,-0.030716825711402163,0 -2022-07-25,-0.007717476111274637,-0.030716825711402163,0 -2022-07-26,-0.011746194145435564,-0.030716825711402163,0 -2022-07-27,0.030913338202285284,-0.030716825711402163,0 -2022-07-28,0.01321193654653586,-0.030716825711402163,0 -2022-07-29,0.01502980573500917,-0.030716825711402163,0 -2022-08-01,0.002617848035493816,-0.030716825711402163,0 -2022-08-02,-0.01408592583106246,-0.030716825711402163,0 -2022-08-03,0.026870669612651416,-0.030716825711402163,0 -2022-08-04,0.0006351696270352913,-0.030716825711402163,0 -2022-08-05,-0.009364704977126886,-0.030716825711402163,0 -2022-08-08,0.0013029313246115213,-0.030716825711402163,0 -2022-08-09,0.0011491489385260204,-0.030716825711402163,0 -2022-08-10,0.01443808882114177,-0.030716825711402163,0 -2022-08-11,-0.01181836222644556,-0.030716825711402163,0 -2022-08-12,0.016061010257772002,-0.030716825711402163,0 -2022-08-15,0.003592340598734171,-0.030716825711402163,0 -2022-08-16,0.00021065588777823617,-0.030716825711402163,0 -2022-08-17,-0.0014618835497405116,-0.030716825711402163,0 -2022-08-18,-0.001589682399277085,-0.030716825711402163,0 -2022-08-19,-0.015133745079105837,-0.030716825711402163,0 -2022-08-22,-0.018820132268493564,-0.030716825711402163,0 -2022-08-23,-0.004092630821240048,-0.030716825711402163,0 -2022-08-24,-0.0026356618867475925,-0.030716825711402163,0 -2022-08-25,0.01322899231431464,-0.030716825711402163,0 -2022-08-26,-0.02342363050240757,-0.030716825711402163,0 -2022-08-29,-0.010939097125002982,-0.030716825711402163,0 -2022-08-30,-0.007166469681198549,-0.030716825711402163,0 -2022-08-31,-0.008673218272774301,-0.030716825711402163,0 -2022-09-01,-0.005960890100208354,-0.030716825711402163,0 -2022-09-02,-0.00829004404859211,-0.030716825711402163,0 -2022-09-06,-0.014788143042608896,-0.030716825711402163,0 -2022-09-07,0.014611463186327459,-0.030716825711402163,0 -2022-09-08,-0.006103357737389659,-0.030716825711402163,0 -2022-09-09,0.014533149338847596,-0.030716825711402163,0 -2022-09-12,0.01259366110082058,-0.030716825711402163,0 -2022-09-13,-0.038231473370587084,-0.030716825711402163,1 -2022-09-14,0.0046176725291847435,-0.03462365843925693,0 -2022-09-15,-0.015751174668005307,-0.03462365843925693,0 -2022-09-16,-0.0073342280729672945,-0.03462365843925693,0 -2022-09-19,0.00873545689235034,-0.03462365843925693,0 -2022-09-20,-0.0009926969182709306,-0.03462365843925693,0 -2022-09-21,-0.0061342552993786156,-0.03462365843925693,0 -2022-09-22,-0.007938705713790701,-0.03462365843925693,0 -2022-09-23,-0.007980891898971717,-0.03462365843925693,0 -2022-09-26,-0.006338437206746167,-0.03462365843925693,0 -2022-09-27,-0.008176185641534666,-0.03462365843925693,0 -2022-09-28,0.01324036449492047,-0.03462365843925693,0 -2022-09-29,-0.023491770167383928,-0.03462365843925693,0 -2022-09-30,-0.0210177271707995,-0.03462365843925693,0 -2022-10-03,0.026387284738879253,-0.03462365843925693,0 -2022-10-04,0.018585625729667245,-0.03462365843925693,0 -2022-10-05,-0.0020904558665547327,-0.03462365843925693,0 -2022-10-06,-0.007313190047511801,-0.03462365843925693,0 -2022-10-07,-0.033118766853100265,-0.03462365843925693,0 -2022-10-10,-0.011616247508645592,-0.03565194794254143,0 -2022-10-11,-0.007594831275737395,-0.03565194794254143,0 -2022-10-12,0.0005956844105673529,-0.03565194794254143,0 -2022-10-13,0.020121900627328788,-0.03565194794254143,0 -2022-10-14,-0.02186774767292736,-0.03565194794254143,0 -2022-10-17,0.02077341050726576,-0.03565194794254143,0 -2022-10-18,0.00526099571014776,-0.03565194794254143,0 -2022-10-19,-0.008704783846997572,-0.03565194794254143,0 -2022-10-20,-0.007303350992438843,-0.03565194794254143,0 -2022-10-21,0.011159637402152244,-0.03565194794254143,0 -2022-10-24,0.009120655644324679,-0.03565194794254143,0 -2022-10-25,0.020515733661395436,-0.03565194794254143,0 -2022-10-26,-0.028604405308937316,-0.03565194794254143,0 -2022-10-27,-0.01349210441418983,-0.03565194794254143,0 -2022-10-28,0.03512313478821869,-0.03565194794254143,0 -2022-10-31,-0.012889018443532935,-0.03565194794254143,0 -2022-11-01,-0.008534406402769375,-0.03565194794254143,0 -2022-11-02,-0.026125866062869763,-0.03565194794254143,0 -2022-11-03,-0.02522666201456532,-0.03565194794254143,0 -2022-11-04,0.0046300659839464235,-0.03565194794254143,0 -2022-11-07,0.007572348292677559,-0.03565194794254143,0 -2022-11-08,0.006473504922965106,-0.03565194794254143,0 -2022-11-09,-0.016568383436318498,-0.03565194794254143,0 -2022-11-10,0.06734917423177599,-0.03565194794254143,0 -2022-11-11,0.010756487418985468,-0.03565194794254143,0 -2022-11-14,-0.011581099961690414,-0.03565194794254143,0 -2022-11-15,0.009862559057012677,-0.03565194794254143,0 -2022-11-16,0.005029411339991492,-0.03565194794254143,0 -2022-11-17,0.0006909526109830119,-0.03565194794254143,0 -2022-11-18,-0.0016772565889976142,-0.03565194794254143,0 -2022-11-21,-0.004759069023173676,-0.03565194794254143,0 -2022-11-22,0.013627695331231572,-0.03565194794254143,0 -2022-11-23,0.011184453810198298,-0.03565194794254143,0 -2022-11-25,-0.00784908376550987,-0.03565194794254143,0 -2022-11-28,-0.015741929295507946,-0.03565194794254143,0 -2022-11-29,-0.013034261957637369,-0.03565194794254143,0 -2022-11-30,0.03819230986098129,-0.03565194794254143,0 -2022-12-01,0.010523593293561017,-0.03565194794254143,0 -2022-12-02,0.003471609994275373,-0.03565194794254143,0 -2022-12-05,-0.01373414768320486,-0.03565194794254143,0 -2022-12-06,-0.011137459495317147,-0.03565194794254143,0 -2022-12-07,0.0021146786476818535,-0.03565194794254143,0 -2022-12-08,0.007212722826735053,-0.03565194794254143,0 -2022-12-09,-0.01261159216547296,-0.03565194794254143,0 -2022-12-12,0.015976408743745493,-0.03565194794254143,0 -2022-12-13,0.011227734275928235,-0.03565194794254143,0 -2022-12-14,-0.0034094042101005875,-0.03565194794254143,0 -2022-12-15,-0.025510810830540607,-0.03565194794254143,0 -2022-12-16,-0.014473463575260755,-0.03565194794254143,0 -2022-12-19,-0.016821910025008022,-0.03565194794254143,0 -2022-12-20,-0.004282284456913763,-0.03565194794254143,0 -2022-12-21,0.012286309094635208,-0.03565194794254143,0 -2022-12-22,-0.016704307487052542,-0.03565194794254143,0 -2022-12-23,-0.005101992946030055,-0.03565194794254143,0 -2022-12-27,-0.013796251401086396,-0.03565194794254143,0 -2022-12-28,-0.015794314799819027,-0.03565194794254143,0 -2022-12-29,0.022157576864834726,-0.03565194794254143,0 -2022-12-30,-0.00455693790934895,-0.03565194794254143,0 -2023-01-03,-0.006739803084113107,-0.03565194794254143,0 -2023-01-04,-0.006953401287369272,-0.03565194794254143,0 -2023-01-05,-0.012191622690440461,-0.03565194794254143,0 -2023-01-06,0.022026371855224304,-0.03565194794254143,0 -2023-01-09,0.006359779780875993,-0.03565194794254143,0 -2023-01-10,-0.001550945897552672,-0.03565194794254143,0 -2023-01-11,0.022267638678150636,-0.03565194794254143,0 -2023-01-12,0.010148676369021193,-0.03565194794254143,0 -2023-01-13,0.0012218516260679344,-0.03565194794254143,0 -2023-01-17,0.002301542701100383,-0.03565194794254143,0 -2023-01-18,-0.00017066927169567974,-0.03565194794254143,0 -2023-01-19,-0.007475453882357992,-0.03565194794254143,0 -2023-01-20,0.012604481237195769,-0.03565194794254143,0 -2023-01-23,0.0094286334868181,-0.03565194794254143,0 -2023-01-24,0.007354069373979551,-0.03565194794254143,0 -2023-01-25,-0.0027383562343777164,-0.03565194794254143,0 -2023-01-26,0.013427634437998636,-0.03565194794254143,0 -2023-01-27,0.0039031946411151575,-0.03565194794254143,0 -2023-01-30,-0.015383358391948727,-0.03565194794254143,0 -2023-01-31,0.012579319142376243,-0.03565194794254143,0 -2023-02-01,0.013186028913497372,-0.03565194794254143,0 -2023-02-02,0.027834662499903053,-0.03565194794254143,0 -2023-02-03,-0.0049551993221576334,-0.03565194794254143,0 -2023-02-06,-0.01055253522098991,-0.03565194794254143,0 -2023-02-07,0.01738891161955404,-0.03565194794254143,0 -2023-02-08,-0.005389623092214567,-0.03565194794254143,0 -2023-02-09,-0.009394276050724666,-0.03565194794254143,0 -2023-02-10,-0.0035913699939722193,-0.03565194794254143,0 -2023-02-13,0.019258836043901557,-0.03565194794254143,0 -2023-02-14,-0.0011368218304706184,-0.03565194794254143,0 -2023-02-15,-0.0011953809785552273,-0.03565194794254143,0 -2023-02-16,-0.017245666791175375,-0.03565194794254143,0 -2023-02-17,-0.005184665307827099,-0.03565194794254143,0 -2023-02-21,-0.022592812491538484,-0.03565194794254143,0 -2023-02-22,0.0024708963726060426,-0.03565194794254143,0 -2023-02-23,0.008629370608661202,-0.03565194794254143,0 -2023-02-24,-0.017760335926784616,-0.03565194794254143,0 -2023-02-27,0.004915868820811107,-0.03565194794254143,0 -2023-02-28,-0.0006276590243003236,-0.03565194794254143,0 -2023-03-01,-0.01261976990062229,-0.03565194794254143,0 -2023-03-02,0.004892106101355645,-0.03565194794254143,0 -2023-03-03,0.0249782347278692,-0.03565194794254143,0 -2023-03-06,0.005585399442716126,-0.03565194794254143,0 -2023-03-07,-0.006378215834364893,-0.03565194794254143,0 -2023-03-08,0.002517751686835462,-0.03565194794254143,0 -2023-03-09,-0.006009810720257437,-0.03565194794254143,0 -2023-03-10,0.0016786087701365538,-0.03565194794254143,0 -2023-03-13,0.01222110464721976,-0.03565194794254143,0 -2023-03-14,0.008036291046543374,-0.03565194794254143,0 -2023-03-15,0.013139259711607038,-0.03565194794254143,0 -2023-03-16,0.016801409644472902,-0.03565194794254143,0 -2023-03-17,0.007018432746923592,-0.03565194794254143,0 -2023-03-20,-0.006525180562714918,-0.03565194794254143,0 -2023-03-21,0.0029738045194693035,-0.03565194794254143,0 -2023-03-22,-0.00045242710831719173,-0.03565194794254143,0 -2023-03-23,0.008825013609612107,-0.03565194794254143,0 -2023-03-24,0.007639254421901196,-0.03565194794254143,0 -2023-03-27,-0.01706247353010729,-0.03565194794254143,0 -2023-03-28,-0.0021129183971381597,-0.03565194794254143,0 -2023-03-29,0.012196382220843464,-0.03565194794254143,0 -2023-03-30,0.008990906121721175,-0.03565194794254143,0 -2023-03-31,0.015081370325944872,-0.03565194794254143,0 -2023-04-03,0.002882015735716636,-0.03565194794254143,0 -2023-04-04,0.0005101442463825125,-0.03565194794254143,0 -2023-04-05,-0.0036308573159878972,-0.03565194794254143,0 -2023-04-06,0.011092139924460957,-0.03565194794254143,0 -2023-04-10,-0.013321606100798598,-0.03565194794254143,0 -2023-04-11,-0.009497216185163885,-0.03565194794254143,0 -2023-04-12,-0.001020096411839939,-0.03565194794254143,0 -2023-04-13,0.01593255850008538,-0.03565194794254143,0 -2023-04-14,-0.0080509022146198,-0.03565194794254143,0 -2023-04-17,-0.0008642276120523027,-0.03565194794254143,0 -2023-04-18,0.003181264733687315,-0.03565194794254143,0 -2023-04-19,0.0020870443583788983,-0.03565194794254143,0 -2023-04-20,-0.0018319427506850521,-0.03565194794254143,0 -2023-04-21,-0.005562775610012607,-0.03565194794254143,0 -2023-04-24,-0.0008521725418947058,-0.03565194794254143,0 -2023-04-25,-0.005892371929210464,-0.03565194794254143,0 -2023-04-26,0.019718329658849093,-0.03565194794254143,0 -2023-04-27,0.016517688874134878,-0.03565194794254143,0 -2023-04-28,0.010485031641027131,-0.03565194794254143,0 -2023-05-01,-0.011779165525894485,-0.03565194794254143,0 -2023-05-02,0.005970943918394616,-0.03565194794254143,0 -2023-05-03,-0.0014114380165281652,-0.03565194794254143,0 -2023-05-04,-0.00552607924172805,-0.03565194794254143,0 -2023-05-05,0.019846588783405658,-0.03178708621482072,0 -2023-05-08,-0.006992096770418205,-0.03178708621482072,0 -2023-05-09,-0.006321832528974888,-0.03178708621482072,0 -2023-05-10,0.012390865591861417,-0.03178708621482072,0 -2023-05-11,0.0015137402461996787,-0.03178708621482072,0 -2023-05-12,-0.005839678252877777,-0.03178708621482072,0 -2023-05-15,-0.003909597176830116,-0.03178708621482072,0 -2023-05-16,0.0014439446971756364,-0.03178708621482072,0 -2023-05-17,0.00336113839279857,-0.03178708621482072,0 -2023-05-18,0.006809896201026285,-0.03178708621482072,0 -2023-05-19,-0.0023445370961518107,-0.03178708621482072,0 -2023-05-22,-6.0518728265258434e-05,-0.03178708621482072,0 -2023-05-23,-0.01033332481273345,-0.03178708621482072,0 -2023-05-24,-0.0026043840924327303,-0.03178708621482072,0 -2023-05-25,0.013972287825462115,-0.03178708621482072,0 -2023-05-26,0.014403698427313988,-0.03178708621482072,0 -2023-05-30,0.005161825612372211,-0.03178708621482072,0 -2023-05-31,-5.115104874326523e-05,-0.03178708621482072,0 -2023-06-01,0.010829920402826612,-0.03178708621482072,0 -2023-06-02,0.0007295150954454637,-0.03178708621482072,0 -2023-06-05,-0.002618570723393961,-0.03178708621482072,0 -2023-06-06,-0.0009786722838006578,-0.03178708621482072,0 -2023-06-07,-0.018032020703898054,-0.03178708621482072,0 -2023-06-08,0.010924236476918174,-0.03178708621482072,0 -2023-06-09,0.001826004058412803,-0.03178708621482072,0 -2023-06-12,0.011274035797866637,-0.03090672969646041,0 -2023-06-13,-0.00173971668878104,-0.02738992107836421,0 -2023-06-14,0.0068463590137120365,-0.02738992107836421,0 -2023-06-15,0.01736510053973073,-0.02738992107836421,0 -2023-06-16,-0.008827736469824746,-0.02738992107836421,0 -2023-06-20,-0.001732777276895583,-0.02738992107836421,0 -2023-06-21,-0.005548575729487631,-0.02738992107836421,0 -2023-06-22,0.0075053775420542744,-0.02738992107836421,0 -2023-06-23,-0.0018981573938772552,-0.02738992107836421,0 -2023-06-26,-0.008622229746160127,-0.02738992107836421,0 -2023-06-27,0.01011271479476977,-0.02738992107836421,0 -2023-06-28,0.004793990661303573,-0.02738992107836421,0 -2023-06-29,-0.006267791250263265,-0.02738992107836421,0 -2023-06-30,0.016939485999172773,-0.02738992107836421,0 -2023-07-03,-0.007005565587890733,-0.02738992107836421,0 -2023-07-05,-0.004987728244142411,-0.02738992107836421,0 -2023-07-06,-0.0008512905833672568,-0.02738992107836421,0 -2023-07-07,-0.007961518711416513,-0.02738992107836421,0 -2023-07-10,-0.008572209094184583,-0.02738992107836421,0 -2023-07-11,0.0014133741528759983,-0.02738992107836421,0 -2023-07-12,0.01138056948846154,-0.02738992107836421,0 -2023-07-13,0.010189843009737938,-0.02738992107836421,0 -2023-07-14,0.0007939555984391285,-0.02738992107836421,0 -2023-07-17,0.006356580237957338,-0.02738992107836421,0 -2023-07-18,0.014071913707431409,-0.02738992107836421,0 -2023-07-19,0.001978879503715671,-0.02738992107836421,0 -2023-07-20,-0.015252524538542383,-0.02738992107836421,0 -2023-07-21,-0.004954369177638972,-0.02738992107836421,0 -2023-07-24,0.0014859999496816575,-0.02738992107836421,0 -2023-07-25,0.006497762685283396,-0.02738992107836421,0 -2023-07-26,-0.010947314563665125,-0.02738992107836421,0 -2023-07-27,-0.01578002877869703,-0.02738992107836421,0 -2023-07-28,0.013802765281573013,-0.02738992107836421,0 -2023-07-31,-0.0005680681427037051,-0.02738992107836421,0 -2023-08-01,-0.006519485969311293,-0.02738992107836421,0 -2023-08-02,-0.017667381737645538,-0.02738992107836421,0 -2023-08-03,-0.011086225967274512,-0.02738992107836421,0 -2023-08-04,-0.00941049765815954,-0.02738992107836421,0 -2023-08-07,-0.0067370799793299,-0.02738992107836421,0 -2023-08-08,0.0014897522203222568,-0.02738992107836421,0 -2023-08-09,-0.005207439067958997,-0.02738992107836421,0 -2023-08-10,-0.0052216965637361485,-0.02738992107836421,0 -2023-08-11,-0.002643235384835055,-0.02738992107836421,0 -2023-08-14,0.005513174196310743,-0.02738992107836421,0 -2023-08-15,-0.008042414035410431,-0.02738992107836421,0 -2023-08-16,-0.005086010211952032,-0.02738992107836421,0 -2023-08-17,-0.009993489585684101,-0.02738992107836421,0 -2023-08-18,0.0016914791448010853,-0.02738992107836421,0 -2023-08-21,0.003735127669533288,-0.02738992107836421,0 -2023-08-22,0.005772926929592511,-0.02738992107836421,0 -2023-08-23,0.02005609040793769,-0.02738992107836421,0 -2023-08-24,-0.018289217200898183,-0.02738992107836421,0 -2023-08-25,0.008395858557184336,-0.02738992107836421,0 -2023-08-28,0.004027917144870791,-0.02738992107836421,0 -2023-08-29,0.01545134544981759,-0.02738992107836421,0 -2023-08-30,0.006369520165926442,-0.02738992107836421,0 -2023-08-31,0.0008310253821400201,-0.02738992107836421,0 -2023-09-01,-0.0015104000079494814,-0.02738992107836421,0 -2023-09-05,0.0006378105771639739,-0.02738992107836421,0 -2023-09-06,-0.012001240774284456,-0.02738992107836421,0 -2023-09-07,-0.011959437975670614,-0.02738992107836421,0 -2023-09-08,0.00681225403065261,-0.02738992107836421,0 -2023-09-11,0.0034294797796868563,-0.02738992107836421,0 -2023-09-12,-0.009962473226397586,-0.02738992107836421,0 -2023-09-13,0.00023664014425165582,-0.025824488999028474,0 -2023-09-14,0.0031084345042339764,-0.025824488999028474,0 -2023-09-15,-0.011876981371778372,-0.025824488999028474,0 -2023-09-18,0.006312645489448985,-0.025824488999028474,0 -2023-09-19,-0.0008309117455623018,-0.025824488999028474,0 -2023-09-20,-0.013781220260471074,-0.025824488999028474,0 -2023-09-21,-0.012937305542040582,-0.025824488999028474,0 -2023-09-22,0.0016769046884643273,-0.025824488999028474,0 -2023-09-25,-0.005297601528354437,-0.025824488999028474,0 -2023-09-26,-0.014770287780506675,-0.025824488999028474,0 -2023-09-27,-0.004015519956888141,-0.025824488999028474,0 -2023-09-28,0.002429258921297513,-0.025824488999028474,0 -2023-09-29,0.003286473431803209,-0.025824488999028474,0 -2023-10-02,0.005608022338375426,-0.025824488999028474,0 -2023-10-03,-0.0186761807174923,-0.025824488999028474,0 -2023-10-04,0.012969392278189981,-0.025824488999028474,0 -2023-10-05,0.0011427878147559007,-0.025824488999028474,0 -2023-10-06,0.008962693156660432,-0.025824488999028474,0 -2023-10-09,0.01313531842880074,-0.025371577910712716,0 -2023-10-10,-0.0030287958881094045,-0.025371577910712716,0 -2023-10-11,0.013580364839464986,-0.025371577910712716,0 -2023-10-12,-0.008750592118986189,-0.025371577910712716,0 -2023-10-13,-0.0010078092037082323,-0.025371577910712716,0 -2023-10-16,-0.000693740456643194,-0.025371577910712716,0 -2023-10-17,-0.007216913824785974,-0.025371577910712716,0 -2023-10-18,-0.0073829269176742054,-0.025371577910712716,0 -2023-10-19,-0.006396908359011295,-0.025371577910712716,0 -2023-10-20,-0.0077616429278238855,-0.025371577910712716,0 -2023-10-23,0.006904987863853639,-0.025371577910712716,0 -2023-10-24,0.006432787730886912,-0.025371577910712716,0 -2023-10-25,-0.001959774692161358,-0.025371577910712716,0 -2023-10-26,-0.01597578481579728,-0.023936075748282166,0 -2023-10-27,0.0031728202152098367,-0.023936075748282166,0 -2023-10-30,0.010067198723041272,-0.023936075748282166,0 -2023-10-31,9.673016198069596e-05,-0.023936075748282166,0 -2023-11-01,0.021095205000302934,-0.023936075748282166,0 -2023-11-02,0.0165011052919519,-0.020673662922255847,0 -2023-11-03,0.0047802208663898765,-0.01848656859436118,0 -2023-11-06,0.005089762802777557,-0.01848656859436118,0 -2023-11-07,0.013382114715734392,-0.01848656859436118,0 -2023-11-08,0.010045583785595454,-0.01848656859436118,0 -2023-11-09,-0.010944337908283766,-0.01848656859436118,0 -2023-11-10,0.017710401475282158,-0.01848656859436118,0 -2023-11-13,-0.006339215859066165,-0.01848656859436118,0 -2023-11-14,0.015447373763243644,-0.01848656859436118,0 -2023-11-15,-0.0035637517061964745,-0.01848656859436118,0 -2023-11-16,0.012927271649090554,-0.01848656859436118,0 -2023-11-17,-0.004124575375384503,-0.01848656859436118,0 -2023-11-20,0.01187973413193592,-0.01848656859436118,0 -2023-11-21,-0.005442273357626259,-0.01848656859436118,0 -2023-11-22,0.006589161956688845,-0.01848656859436118,0 -2023-11-24,-0.006662076503165369,-0.01848656859436118,0 -2023-11-27,0.006246452418331989,-0.01848656859436118,0 -2023-11-28,0.005307754799881699,-0.01848656859436118,0 -2023-11-29,-0.0010143526066056542,-0.01848656859436118,0 -2023-11-30,-0.0028006691063406385,-0.01848656859436118,0 -2023-12-01,0.004582453448181676,-0.01848656859436118,0 -2023-12-04,-0.0093130271178822,-0.01848656859436118,0 -2023-12-05,0.01707237578824069,-0.01848656859436118,0 -2023-12-06,-0.0008364948596018187,-0.01848656859436118,0 -2023-12-07,0.0033824751082977833,-0.01848656859436118,0 -2023-12-08,0.0026572471961834386,-0.01848656859436118,0 -2023-12-11,-0.00766326212001997,-0.01848656859436118,0 -2023-12-12,0.006371255120992038,-0.01848656859436118,0 -2023-12-13,0.013239371336705788,-0.01848656859436118,0 -2023-12-14,0.0012117036946179382,-0.01848656859436118,0 -2023-12-15,0.0038042985736588377,-0.018163190917368118,0 -2023-12-18,-0.0037912228941775973,-0.018163190917368118,0 -2023-12-19,0.004118455679164572,-0.018163190917368118,0 -2023-12-20,-0.0037059498491618303,-0.018163190917368118,0 -2023-12-21,0.00024332406373756417,-0.018163190917368118,0 -2023-12-22,-0.0024472493315041656,-0.018163190917368118,0 -2023-12-26,6.915460569206985e-05,-0.018163190917368118,0 -2023-12-27,0.005502280765207089,-0.018163190917368118,0 -2023-12-28,-0.0006120796401645489,-0.018163190917368118,0 -2023-12-29,-0.004158900779266512,-0.018163190917368118,0 -2024-01-02,-0.018689180063998216,-0.018163190917368118,1 -2024-01-03,-0.0013607453820402536,-0.01848656859436118,0 -2024-01-04,-0.011765428659613256,-0.01848656859436118,0 -2024-01-05,-0.004716454274051025,-0.01848656859436118,0 -2024-01-08,0.01746680491167326,-0.01848656859436118,0 -2024-01-09,-0.0019103129706843829,-0.01848656859436118,0 -2024-01-10,0.00646364975709594,-0.01848656859436118,0 -2024-01-11,0.0024064427799305277,-0.01848656859436118,0 -2024-01-12,0.0032479522496165767,-0.01848656859436118,0 -2024-01-16,-0.008513524176654871,-0.01848656859436118,0 -2024-01-17,-0.0029407954658155493,-0.01848656859436118,0 -2024-01-18,0.011315635255614345,-0.01848656859436118,0 -2024-01-19,0.010240156851282534,-0.01848656859436118,0 -2024-01-22,0.0041971255310148895,-0.01848656859436118,0 -2024-01-23,0.0015616015407215005,-0.01848656859436118,0 -2024-01-24,-7.703258733774657e-05,-0.01848656859436118,0 -2024-01-25,0.0035124327292086173,-0.01848656859436118,0 -2024-01-26,-0.004431872882487765,-0.01848656859436118,0 -2024-01-29,0.007363582945559634,-0.01848656859436118,0 -2024-01-30,-0.004390137575094725,-0.01848656859436118,0 -2024-01-31,-0.012363682288106585,-0.01848656859436118,0 -2024-02-01,0.016050884120369414,-0.01848656859436118,0 -2024-02-02,-0.0031660118714550963,-0.01848656859436118,0 -2024-02-05,-0.008079842597935653,-0.01848656859436118,0 -2024-02-06,0.005975477282349919,-0.01848656859436118,0 -2024-02-07,0.005540124029473658,-0.01848656859436118,0 -2024-02-08,-0.0038194178232539974,-0.01848656859436118,0 -2024-02-09,0.005831657299291401,-0.01848656859436118,0 -2024-02-12,-0.0068432526415882185,-0.01848656859436118,0 -2024-02-13,-0.016795237886228515,-0.01848656859436118,0 -2024-02-14,0.0032913506321711103,-0.01848656859436118,0 -2024-02-15,-0.0011997300161155882,-0.01848656859436118,0 -2024-02-16,-0.006812839804471037,-0.01848656859436118,0 -2024-02-20,-0.0021361933315750406,-0.01848656859436118,0 -2024-02-21,-0.0014908973109967237,-0.018163190917368118,0 -2024-02-22,0.013108297455897585,-0.018163190917368118,0 -2024-02-23,8.478376850534459e-06,-0.018163190917368118,0 -2024-02-26,-0.005771132075063924,-0.018163190917368118,0 -2024-02-27,0.000285875826291101,-0.018163190917368118,0 -2024-02-28,8.998909412351871e-05,-0.018163190917368118,0 -2024-02-29,0.005915984211203282,-0.018163190917368118,0 -2024-03-01,0.001549840887129657,-0.018163190917368118,0 -2024-03-04,-0.010378752444034479,-0.018163190917368118,0 -2024-03-05,-0.014910048008418388,-0.018163190917368118,0 -2024-03-06,-0.0004787813909991168,-0.018163190917368118,0 -2024-03-07,0.005244571812284608,-0.018163190917368118,0 -2024-03-08,0.0004159298763596757,-0.018163190917368118,0 -2024-03-11,0.0023487184028270036,-0.018163190917368118,0 -2024-03-12,0.006876693653221032,-0.018163190917368118,0 -2024-03-13,-0.0058300165265184395,-0.018163190917368118,0 -2024-03-14,0.006493073586153027,-0.018163190917368118,0 -2024-03-15,-0.007811387820232338,-0.018163190917368118,0 -2024-03-18,0.0018313555973044448,-0.018163190917368118,0 -2024-03-19,0.008682901540633744,-0.018163190917368118,0 -2024-03-20,0.007767693770933097,-0.018163190917368118,0 -2024-03-21,-0.009958694364296156,-0.018163190917368118,0 -2024-03-22,0.004447629123901782,-0.018163190917368118,0 -2024-03-25,-0.009052716073713279,-0.018163190917368118,0 -2024-03-26,-0.00226129521474488,-0.018163190917368118,0 -2024-03-27,0.010112588566510353,-0.018163190917368118,0 -2024-03-28,-0.004382145821057616,-0.018163190917368118,0 -2024-04-01,-0.0060664369141549285,-0.018163190917368118,0 -2024-04-02,-0.006649435405068431,-0.018163190917368118,0 -2024-04-03,0.0007387973307559505,-0.018163190917368118,0 -2024-04-04,-0.001296316083316692,-0.018163190917368118,0 -2024-04-05,0.0028642082822964705,-0.018163190917368118,0 -2024-04-08,-0.00299433078959061,-0.018163190917368118,0 -2024-04-09,0.006815877462931302,-0.018163190917368118,0 -2024-04-10,-0.01344867357851826,-0.018163190917368118,0 -2024-04-11,0.016259751336593183,-0.018163190917368118,0 -2024-04-12,-9.038630393984383e-05,-0.018163190917368118,0 -2024-04-15,-0.019168490682185988,-0.018163190917368118,1 -2024-04-16,-0.007914317169887004,-0.01848656859436118,0 -2024-04-17,-0.0012582557990146515,-0.01848656859436118,0 -2024-04-18,-0.009778503058741549,-0.01848656859436118,0 -2024-04-19,-0.007170903351921306,-0.01848656859436118,0 -2024-04-22,0.0026645174393343666,-0.01848656859436118,0 -2024-04-23,0.007686449417837565,-0.01848656859436118,0 -2024-04-24,0.003056749855112078,-0.01848656859436118,0 -2024-04-25,-0.008901435615331507,-0.01848656859436118,0 -2024-04-26,0.006608938449310471,-0.01848656859436118,0 -2024-04-29,0.007596461898411908,-0.01848656859436118,0 -2024-04-30,-0.019888085238438564,-0.01848656859436118,1 -2024-05-01,0.005195002349620576,-0.018682810384210317,0 -2024-05-02,0.011130260145163232,-0.018682810384210317,0 -2024-05-03,0.030030313844939246,-0.018682810384210317,0 -2024-05-06,0.003838937405780461,-0.018682810384210317,0 -2024-05-07,-0.00011279428219272283,-0.018682810384210317,0 -2024-05-08,-0.00042999757652070286,-0.018682810384210317,0 -2024-05-09,0.006385795774598657,-0.018682810384210317,0 -2024-05-10,-0.0022350821492469375,-0.018682810384210317,0 -2024-05-13,0.005859337500062033,-0.018682810384210317,0 -2024-05-14,0.0062082360403472765,-0.018682810384210317,0 -2024-05-15,0.014343932415951327,-0.018682810384210317,0 -2024-05-16,-0.0017658933439832377,-0.018682810384210317,0 -2024-05-17,-0.002819277398945311,-0.018682810384210317,0 -2024-05-20,0.005106338974011134,-0.018682810384210317,0 -2024-05-21,0.006879920981463177,-0.018682810384210317,0 -2024-05-22,-0.0009743077378379765,-0.018682810384210317,0 -2024-05-23,-0.011982554260769284,-0.018682810384210317,0 -2024-05-24,0.008928063925476667,-0.018682810384210317,0 -2024-05-28,-0.004671523070122197,-0.018682810384210317,0 -2024-05-29,-0.004424592006072194,-0.018682810384210317,0 -2024-05-30,-0.006503417930403384,-0.018682810384210317,0 -2024-05-31,0.004293879269608836,-0.018682810384210317,0 -2024-06-03,0.007125166399766577,-0.018682810384210317,0 -2024-06-04,0.006469673381568645,-0.018682810384210317,0 -2024-06-05,0.011335027064202067,-0.018682810384210317,0 -2024-06-06,-0.0024735390385550115,-0.018682810384210317,0 -2024-06-07,-0.0025931927072420096,-0.018682810384210317,0 -2024-06-10,-0.005527580764879343,-0.018682810384210317,0 -2024-06-11,0.03053310686432138,-0.018682810384210317,0 -2024-06-12,0.018282010744375462,-0.018682810384210317,0 -2024-06-13,0.007082299204986042,-0.018682810384210317,0 -2024-06-14,0.0008057887263379673,-0.018682810384210317,0 -2024-06-17,0.007507309238220158,-0.018682810384210317,0 -2024-06-18,-0.0021499019312455547,-0.018682810384210317,0 -2024-06-20,-0.009955084270629553,-0.018682810384210317,0 -2024-06-21,-0.00046224999994211156,-0.018682810384210317,0 -2024-06-24,0.00082045183347674,-0.018682810384210317,0 -2024-06-25,0.004484352301281824,-0.018682810384210317,0 -2024-06-26,0.0026956945189275864,-0.018682810384210317,0 -2024-06-27,0.003155656813041352,-0.018682810384210317,0 -2024-06-28,-0.0160943279751273,-0.018682810384210317,0 -2024-07-01,0.010975157914298588,-0.018682810384210317,0 -2024-07-02,0.00981084322856741,-0.018682810384210317,0 -2024-07-03,0.007360205120127986,-0.018682810384210317,0 -2024-07-05,0.01475444135690984,-0.018682810384210317,0 -2024-07-08,0.0019496865223363785,-0.018682810384210317,0 -2024-07-09,-0.005045537835989317,-0.018682810384210317,0 -2024-07-10,0.012086810237272868,-0.018682810384210317,0 -2024-07-11,-0.012970232029081503,-0.018682810384210317,0 -2024-07-12,0.004900354684106668,-0.018682810384210317,0 -2024-07-15,0.0019817112352207853,-0.018682810384210317,0 -2024-07-16,0.0019900480727727213,-0.018682810384210317,0 -2024-07-17,-0.012948885010304953,-0.018682810384210317,0 -2024-07-18,-0.011849922621569814,-0.018682810384210317,0 -2024-07-19,-0.004250711982876874,-0.018682810384210317,0 -2024-07-22,0.0029260686213687867,-0.018682810384210317,0 -2024-07-23,0.0025253101104304202,-0.018682810384210317,0 -2024-07-24,-0.025518827642156648,-0.018682810384210317,1 -2024-07-25,-0.007146880107881933,-0.01893362847927398,0 -2024-07-26,0.008739297791233612,-0.01893362847927398,0 -2024-07-29,0.0033579262923568794,-0.01893362847927398,0 -2024-07-30,-0.0008541679099966427,-0.01893362847927398,0 -2024-07-31,0.00473069257172636,-0.01893362847927398,0 -2024-08-01,-0.003757791075329985,-0.01893362847927398,0 -2024-08-02,0.005549705595160959,-0.01893362847927398,0 -2024-08-05,-0.02576340082299568,-0.01893362847927398,1 -2024-08-06,-0.007064985362304904,-0.0195354839058748,0 -2024-08-07,0.000799522084699372,-0.0195354839058748,0 -2024-08-08,0.006986962704821241,-0.0195354839058748,0 -2024-08-09,0.010563680744207233,-0.0195354839058748,0 -2024-08-12,0.004225992149772292,-0.0195354839058748,0 -2024-08-13,0.013834003233437775,-0.0195354839058748,0 -2024-08-14,0.005047645752784984,-0.0195354839058748,0 -2024-08-15,0.005697039986608812,-0.0195354839058748,0 -2024-08-16,0.0010992382119915036,-0.0195354839058748,0 -2024-08-19,0.0037283783758620035,-0.0195354839058748,0 -2024-08-20,0.0061350007542692615,-0.0195354839058748,0 -2024-08-21,-0.0004775120224375997,-0.0195354839058748,0 -2024-08-22,-0.012910121774214658,-0.0195354839058748,0 -2024-08-23,0.006580311280086444,-0.0195354839058748,0 -2024-08-26,-0.002998500194899179,-0.0195354839058748,0 -2024-08-27,0.0009489754803397486,-0.0195354839058748,0 -2024-08-28,-0.005287293839856284,-0.0195354839058748,0 -2024-08-29,0.005769344564189365,-0.0195354839058748,0 -2024-08-30,-0.0014935592697075716,-0.0195354839058748,0 -2024-09-03,-0.00999688466805193,-0.0195354839058748,0 -2024-09-04,0.000943873014818242,-0.0195354839058748,0 -2024-09-05,0.003765308847113884,-0.0195354839058748,0 -2024-09-06,-0.007885712243908,-0.0195354839058748,0 -2024-09-09,0.004891635667418558,-0.0195354839058748,0 -2024-09-10,0.008011342140474425,-0.0195354839058748,0 -2024-09-11,0.010613797523108561,-0.0195354839058748,0 -2024-09-12,0.00170962985243749,-0.0195354839058748,0 -2024-09-13,0.0032840634965574846,-0.0195354839058748,0 -2024-09-16,-0.005769107468815701,-0.0195354839058748,0 -2024-09-17,0.002038979064846049,-0.0195354839058748,0 -2024-09-18,-0.0015557068903212716,-0.0195354839058748,0 -2024-09-19,0.01706733431828195,-0.0195354839058748,0 -2024-09-20,-0.004864686885089309,-0.0195354839058748,0 -2024-09-23,-0.0045958614754871415,-0.0195354839058748,0 -2024-09-24,-0.0020994096892752415,-0.0195354839058748,0 -2024-09-25,-0.001975897042618947,-0.0195354839058748,0 -2024-09-26,0.0018541065514884939,-0.0195354839058748,0 -2024-09-27,-0.00042786155909310366,-0.0195354839058748,0 -2024-09-30,0.007715744153787868,-0.0195354839058748,0 -2024-10-01,-0.01498890157911294,-0.0195354839058748,0 -2024-10-02,-0.004814923897291801,-0.0195354839058748,0 -2024-10-03,-0.005262374800843355,-0.0195354839058748,0 -2024-10-04,-0.0028451848634844035,-0.0195354839058748,0 -2024-10-07,-0.015382414100872098,-0.0195354839058748,0 -2024-10-08,0.010822475754428679,-0.0195354839058748,0 -2024-10-09,0.005822937716088669,-0.0195354839058748,0 -2024-10-10,-0.0033313592992484584,-0.0195354839058748,0 -2024-10-11,-0.003140109106139675,-0.0195354839058748,0 -2024-10-14,0.007947744230939132,-0.0195354839058748,0 -2024-10-15,0.007364560465490369,-0.0195354839058748,0 -2024-10-16,-0.0036540973406705072,-0.0195354839058748,0 -2024-10-17,-0.004311277092971053,-0.0195354839058748,0 -2024-10-18,0.005465800427847788,-0.0195354839058748,0 -2024-10-21,-0.0032885825979001226,-0.0195354839058748,0 -2024-10-22,0.006327356545741835,-0.0195354839058748,0 -2024-10-23,-0.010467320300874099,-0.0195354839058748,0 -2024-10-24,0.001956767417856474,-0.0195354839058748,0 -2024-10-25,0.002009605919428227,-0.0195354839058748,0 -2024-10-28,0.0007316800011044177,-0.0195354839058748,0 -2024-10-29,0.005055117612388793,-0.0195354839058748,0 -2024-10-30,-0.003708177309666861,-0.0195354839058748,0 -2024-10-31,-0.026396805190479336,-0.0195354839058748,1 -2024-11-01,-0.005907542119728536,-0.022759763864334776,0 -2024-11-04,0.002230499022294657,-0.022759763864334776,0 -2024-11-05,0.0063602267347873664,-0.022759763864334776,0 -2024-11-06,-0.0033571182957098725,-0.022759763864334776,0 -2024-11-07,0.015338021919011735,-0.022759763864334776,0 -2024-11-08,0.001538642286214682,-0.022759763864334776,0 -2024-11-11,-0.009252417143077282,-0.022759763864334776,0 -2024-11-12,-0.001056489409057131,-0.022759763864334776,0 -2024-11-13,-0.00015109027745365943,-0.022759763864334776,0 -2024-11-14,0.007805683211310703,-0.022759763864334776,0 -2024-11-15,-0.015039420579183452,-0.022759763864334776,0 -2024-11-18,0.0056458096774692984,-0.022759763864334776,0 -2024-11-19,0.0036982020024744757,-0.022759763864334776,0 -2024-11-20,-0.0018579279627419334,-0.022759763864334776,0 -2024-11-21,-0.0023996804001519814,-0.022759763864334776,0 -2024-11-22,0.005465671740736797,-0.022759763864334776,0 -2024-11-25,0.014269417020887364,-0.022759763864334776,0 -2024-11-26,0.009066956715753301,-0.022759763864334776,0 -2024-11-27,-0.0017998895870628175,-0.022759763864334776,0 -2024-11-29,0.007181006267512311,-0.022759763864334776,0 -2024-12-02,0.009826700019108061,-0.022759763864334776,0 -2024-12-03,0.0015115464304531335,-0.022759763864334776,0 -2024-12-04,0.008830994041527597,-0.022759763864334776,0 -2024-12-05,0.004653026916442565,-0.022759763864334776,0 -2024-12-06,0.0009350098894055831,-0.022759763864334776,0 -2024-12-09,0.004073753161047028,-0.022759763864334776,0 -2024-12-10,-0.002213313151395942,-0.022759763864334776,0 -2024-12-11,-0.0006641834043939893,-0.022759763864334776,0 -2024-12-12,-0.0016690596784101406,-0.022759763864334776,0 -2024-12-13,-0.004894913381041673,-0.022759763864334776,0 -2024-12-16,0.0080873322080687,-0.022759763864334776,0 -2024-12-17,0.006145981835952242,-0.022759763864334776,0 -2024-12-18,-0.024174004842850524,-0.022759763864334776,1 -2024-12-19,-0.003030219842133543,-0.024859864470496644,0 -2024-12-20,0.00777822027112946,-0.024859864470496644,0 -2024-12-23,-0.0030837169395265514,-0.024859864470496644,0 -2024-12-24,0.008320871252678027,-0.024859864470496644,0 -2024-12-26,-5.979863203058543e-05,-0.024859864470496644,0 -2024-12-27,-0.01300530209463514,-0.024859864470496644,0 -2024-12-30,-0.006225143163376766,-0.024859864470496644,0 -2024-12-31,-0.006773204467746917,-0.024859864470496644,0 diff --git a/notebooks/notebooks/test_var_with_yf.ipynb b/notebooks/test_var_with_yf.ipynb similarity index 98% rename from notebooks/notebooks/test_var_with_yf.ipynb rename to notebooks/test_var_with_yf.ipynb index ecc5e9e..2fa506f 100644 --- a/notebooks/notebooks/test_var_with_yf.ipynb +++ b/notebooks/test_var_with_yf.ipynb @@ -25,8 +25,8 @@ "import pandas as pd\n", "import numpy as np\n", "\n", - "from risk_engine.data import to_returns\n", - "from risk_engine.market import (\n", + "from risklib.data import to_returns\n", + "from risklib.market import (\n", " var_parametric, var_historical, var_es_monte_carlo, backtest_var_historical\n", ")\n", "\n", @@ -187,7 +187,7 @@ "import pandas as pd\n", "import numpy as np\n", "from math import sqrt, erfc\n", - "from risk_engine.market import backtest_var_historical, var_parametric, var_historical, var_es_monte_carlo\n", + "from risklib.market import backtest_var_historical, var_parametric, var_historical, var_es_monte_carlo\n", "\n", "def kupiec_pval(x, T, alpha):\n", " # LR_uc from our implementation; p = erfc(sqrt(LR/2))\n", @@ -259,7 +259,7 @@ "from pathlib import Path\n", "\n", "# assumes you already have: `returns` (DataFrame of daily returns) and `w` (weights array)\n", - "from risk_engine.market import backtest_var_historical\n", + "from risklib.market import backtest_var_historical\n", "\n", "alpha = 0.99\n", "window = 250\n", diff --git a/pyproject.toml b/pyproject.toml index 374d568..45c8f8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,20 +4,31 @@ build-backend = "setuptools.build_meta" [project] name = "integrated-risk-app" -version = "0.1.0" +version = "0.2.0" +description = "Validation-grade market and credit risk engine" +readme = "README.md" requires-python = ">=3.11" +license = { text = "MIT" } + dependencies = [ - "streamlit", - "pandas", - "numpy", - "plotly", - "yfinance", - "scipy", - "statsmodels", - "matplotlib", - "pytest" + "numpy>=1.24", + "pandas>=2.0", + "scipy>=1.10", + "plotly>=5.18", + "streamlit>=1.30", ] +[project.optional-dependencies] +dev = ["pytest>=7.4", "pytest-cov>=4.1", "flake8>=6.0"] + [tool.setuptools.packages.find] where = ["."] -include = ["risk_engine*"] +# `risklib` was previously EXCLUDED here (the include list named only +# `risk_engine*`), so `pip install .` produced an installation in which every +# `from risklib...` import failed. +include = ["risklib*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q --strict-markers" +filterwarnings = ["error::DeprecationWarning:risklib.*"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ee92882 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Development and CI dependencies. +-r requirements.txt +pytest>=7.4 +pytest-cov>=4.1 +flake8>=6.0 diff --git a/requirements.txt b/requirements.txt index 9e96ecf..7206d97 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,10 @@ -pandas -numpy -plotly -streamlit \ No newline at end of file +# Runtime dependencies for the Streamlit app and risklib engine. +# +# scipy is REQUIRED: risklib/market/garch.py uses scipy.optimize for GARCH MLE. +# It was previously missing here, so the MLE path would ImportError on any fresh +# deploy (Streamlit Community Cloud and CI both install from this file). +numpy>=1.24 +pandas>=2.0 +scipy>=1.10 +plotly>=5.18 +streamlit>=1.30 diff --git a/risk_engine/__init__.py b/risk_engine/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/risk_engine/data.py b/risk_engine/data.py deleted file mode 100644 index 4279696..0000000 --- a/risk_engine/data.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations -import pandas as pd -import numpy as np - -# Loading the data -def load_prices(csv_path:str,data_col:str | None = None) -> pd.DataFrame: - """ load wide price table (columns are asset tickers).""" - - df = pd.read_csv(csv_path) - - # Detect or use provided column - - if data_col and data_col in df.columns: - df[data_col] = pd.to_datetime(df[data_col]) - dcol = data_col - else: - dcol = df.columns[0] - - # parse date - df[dcol] = pd.to_datetime(df[dcol] , errors="coerce") - df = df.set_index(dcol).sort_index() - - # Keep only non-date columns - df = df[[c for c in df.columns if c != dcol]] - # strip spaces in headers - df.columns = [str(c).strip() for c in df.columns] - # Coerce to numeric - for c in df.columns: - if df[c].dtype == "object": - df[c] = ( df[c].astype(str).str.replace(",", "",regex=False).str.replace("$", "", regex= False).str.strip()) - df[c] = pd.to_numeric(df[c], errors="coerce") - # Drop columns that are entirely NaN - df = df.dropna(axis=1 , how ="all") - return df - - -def clean_prices(prices: pd.DataFrame) -> pd.DataFrame: - """ - Ensure prices are usable: - - Remove duplicate dates - - Drop columns with <2 valid observations - - Drop non-positive prices (cannot compute log returns) - """ - # Remove duplicate index (keep first) - prices = prices[~prices.index.duplicated(keep="first")] - # Drop non-positive entries - prices = prices.where(prices > 0) - # Drop columns with fewer than 2 valid points - valid_counts = prices.count() - prices = prices.loc[:, valid_counts >= 2] - return prices - -def to_returns(prices: pd.DataFrame, method:str = "log") -> pd.DataFrame: - """ Compute the returns from prices.""" - if not isinstance(prices, pd.DataFrame) or prices.empty: - raise ValueError("Input 'prices' is empty or not a DataFrame.") - prices = clean_prices(prices) - if prices.empty or (prices.count()<2).all(): - raise ValueError( "Not enough valid price data per column (need ≥2 rows). " - "Check your CSV: dates, numeric values, no blanks.") - - if method == "log": - rets = np.log(prices / prices.shift(1)) - elif method == "simple": - rets = prices.pct_change() - else: - raise ValueError(f"Unknown method: {method}. Use 'log' or 'simple'.") - - # Drop rows where all assets are Nan - rets = rets.dropna(how="all") - # Drop columns that never produced returns - rets = rets.dropna(axis=1, how="all") - - if rets.empty: - raise ValueError("All returns were dropped. Likely only one row of prices or all non-numeric.") - - return rets - diff --git a/risk_engine/market.py b/risk_engine/market.py deleted file mode 100644 index 8883484..0000000 --- a/risk_engine/market.py +++ /dev/null @@ -1,467 +0,0 @@ -from __future__ import annotations -import numpy as np -import pandas as pd -from statistics import NormalDist -from math import sqrt, erfc -from typing import Tuple - - -# -------- Core helpers -------- -def _normalize_weights(w: np.ndarray) -> np.ndarray: - """Normalize weights to sum to 1 (safe if sum is 0).""" - s = float(np.sum(w)) - return (w / s) if s != 0 else w - -def portfolio_returns(returns: pd.DataFrame, weights: np.ndarray) -> pd.Series: - """Project multivariate return matrix onto portfolio weights.""" - return pd.Series(returns.values @ weights, index=returns.index, name="r_p") - -# Convenience for Normal -_N = NormalDist() - -def _z(alpha: float) -> float: - """Standard Normal quantile Φ^{-1}(alpha).""" - return _N.inv_cdf(alpha) - -def _phi(x: float) -> float: - """Standard Normal pdf φ(x).""" - return _N.pdf(x) - -# -------- Parametric (Normal) VaR/ES -------- -def var_parametric(returns: pd.DataFrame, weights: np.ndarray, alpha: float = 0.95, - horizon_days: int = 1, exposure: float = 1.0) -> float: - mu = returns.mean().values - cov = returns.cov().values - mu_p = float(weights @ mu) * horizon_days - sigma_p = float(np.sqrt(weights @ cov @ weights)) * sqrt(horizon_days) - z = _z(alpha) - loss_var = (-mu_p + z * sigma_p) * exposure # report as +ve loss - return float(loss_var) - -def es_parametric(returns: pd.DataFrame, weights: np.ndarray, alpha: float = 0.95, - horizon_days: int = 1, exposure: float = 1.0) -> float: - mu = returns.mean().values - cov = returns.cov().values - mu_p = float(weights @ mu) * horizon_days - sigma_p = float(np.sqrt(weights @ cov @ weights)) * sqrt(horizon_days) - z = _z(alpha) - es = (-mu_p + sigma_p * (_phi(z) / (1 - alpha))) * exposure - return float(es) - -# -------- Historical VaR/ES -------- -def var_historical(returns: pd.DataFrame, weights: np.ndarray, - alpha: float = 0.95, horizon_days: int = 1, - exposure: float = 1.0, sqrt_time: bool = True) -> float: - r_p = portfolio_returns(returns, weights) - if sqrt_time and horizon_days > 1: - r_p = r_p * sqrt(horizon_days) - q = r_p.quantile(1 - alpha) # lower tail (likely negative) - return float(-q * exposure) # report loss as +ve - -def es_historical(returns: pd.DataFrame, weights: np.ndarray, - alpha: float = 0.95, horizon_days: int = 1, - exposure: float = 1.0, sqrt_time: bool = True) -> float: - r_p = portfolio_returns(returns, weights) - if sqrt_time and horizon_days > 1: - r_p = r_p * sqrt(horizon_days) - var_loss = -r_p.quantile(1 - alpha) - tail = r_p[r_p <= -var_loss] - es = -tail.mean() if len(tail) else var_loss - return float(es * exposure) - -# -------- Kupiec POF + rolling backtest -------- -def kupiec_pof(exceedances: int, T: int, alpha: float = 0.95) -> Tuple[float, float]: - """ - Kupiec Proportion-of-Failures test. H0: hit rate == (1 - alpha). - Returns (LR statistic ~ Chi^2(1), p-value). - For χ² with 1 dof: CDF(x) = erf( sqrt(x/2) ), so p = 1 - CDF = erfc( sqrt(x/2) ). - """ - p = 1 - alpha - x = exceedances - if T <= 0: - raise ValueError("T must be > 0 for Kupiec test.") - pi_hat = x / T - - # log-likelihoods - # handle edge cases for stability - eps = 1e-12 - pi_hat = min(max(pi_hat, eps), 1 - eps) - p = min(max(p, eps), 1 - eps) - - ll0 = (T - x) * np.log(1 - p) + x * np.log(p) - ll1 = (T - x) * np.log(1 - pi_hat) + x * np.log(pi_hat) - LR = float(-2 * (ll0 - ll1)) - - # p-value using χ²(1): p = 1 - F(LR) = erfc(sqrt(LR/2)) - pval = float(erfc(sqrt(LR / 2.0))) - return LR, pval - -def backtest_var_historical(returns: pd.DataFrame, weights: np.ndarray, - alpha: float = 0.95, window: int = 250) -> dict: - """ - Rolling historical VaR backtest (1-day horizon). - - Computes rolling (t-1)-based VaR_t from the last `window` obs. - - Flags exceptions when r_p,t < VaR_t threshold (returns are negative). - Returns dict with series and Kupiec stats. - """ - r_p = portfolio_returns(returns, weights) - q = r_p.rolling(window).quantile(1 - alpha).shift(1) - exceptions = (r_p < q).astype(int) - - mask = q.notna() - T = int(mask.sum()) - x = int(exceptions[mask].sum()) - LR, pval = kupiec_pof(x, T, alpha=alpha) - - return { - "r_p": r_p, - "VaR_threshold": q, - "exceptions": exceptions, - "window": window, - "alpha": alpha, - "T": T, - "exceedances": x, - "hit_rate": (x / T) if T > 0 else np.nan, - "kupiec_LR": LR, - "kupiec_pvalue": pval, - } - - -def _cov_shrink(cov: np.ndarray, lam: float = 0.01) -> np.ndarray: - """ - Tiny shrinkage toward diagonal to avoid near-singulaer coveriences. - cov_s = (1-lam) * cov + lam * diag(diag(cov)) - - """ - d = np.diag(np.diag(cov)) - return (1-lam) * cov + lam * d - -def var_es_monte_carlo(returns: pd.DataFrame, weights: np.ndarray, alpha: float = 0.95, horizon_days: int = 1, exposure: float = 1.0 , n_sims: int = 100_000, seed: int | None = 42 , shrink_lambda: float = 0.01) -> tuple[float, float]: - """ - Monte-Carlo VaR & ES via multivariate normal using sample μ, Σ. - Returns (VaR, ES) as +ve loss amounts. - - Notes: - - Adds light covariance shrinkage for stability. - - Scales μ by horizon_days, Σ by horizon_days. - """ - rng = np.random.default_rng(seed) - mu = returns.mean().values * horizon_days - cov = returns.cov().values * horizon_days - cov = _cov_shrink(cov, lam = shrink_lambda) - - sims = rng.multivariate_normal(mu, cov, size=int(n_sims), method="cholesky") - port = sims @ weights #simulates portfolio returns for the horizon - - # VaR threshold is the (1 - alpha) lower-tail quantile of returns - q = np.quantile(port, 1 - alpha) - var_loss = float(-q * exposure) - - # ES = mean of returns <= quantile; report as +ve loss - tail = port[port <= q] - es_loss = float(-tail.mean() * exposure) if tail.size else var_loss - return var_loss, es_loss -def mc_portfolio_loss_from_mu_cov( - mu: np.ndarray, - cov: np.ndarray, - weights: np.ndarray, - alpha: float = 0.95, - exposure: float = 1.0, - n_sims: int = 50_000, - seed: int | None = 42, -) -> tuple[float, float]: - """ - Monte Carlo VaR & ES (losses) for a given μ, Σ (already at the chosen horizon). - """ - rng = np.random.default_rng(seed) - sims = rng.multivariate_normal(mu, cov, size=int(n_sims), method="cholesky") - port = sims @ weights - q = np.quantile(port, 1 - alpha) - var_loss = float(-q * exposure) - tail = port[port <= q] - es_loss = float(-tail.mean() * exposure) if tail.size else var_loss - return var_loss, es_loss - -# ---------- GARCH(1,1) "lite" (fixed params) ---------- -def garch11_filter( - r: pd.Series, - alpha_g: float = 0.05, - beta_g: float = 0.94, - long_run_var: float | None = None, - init_var: float | None = None, -) -> pd.Series: - """ - Compute conditional variance series sigma_t^2 via fixed-parameter GARCH(1,1). - r: returns series (pd.Series) - alpha_g, beta_g: GARCH parameters (alpha+beta < 1 recommended) - long_run_var: if None, uses sample variance of r - init_var: initial variance; if None, uses long_run_var - Returns sigma (standard deviation) series aligned with r index. - """ - r = r.dropna() - if long_run_var is None: - long_run_var = float(r.var(ddof=1)) if len(r) > 1 else float((r**2).mean()) - if init_var is None: - init_var = long_run_var - omega = max(1e-18, (1.0 - alpha_g - beta_g) * long_run_var) - - sig2 = np.empty(len(r), dtype=float) - sig2[0] = max(1e-18, init_var) - r2 = r.values**2 - for t in range(1, len(r)): - sig2[t] = omega + alpha_g * r2[t-1] + beta_g * sig2[t-1] - sigma = np.sqrt(sig2) - return pd.Series(sigma, index=r.index, name="sigma_garch") - -def garch11_forecast_sigma_next( - r_last: float, - sigma_last: float, - alpha_g: float, - beta_g: float, - long_run_var: float -) -> float: - """One-step-ahead sigma forecast under fixed-parameter GARCH(1,1).""" - omega = max(1e-18, (1.0 - alpha_g - beta_g) * long_run_var) - sig2_next = omega + alpha_g * (r_last**2) + beta_g * (sigma_last**2) - return float(np.sqrt(max(sig2_next, 1e-18))) - -def fhs_var_es_next( - returns: pd.DataFrame, - weights: np.ndarray, - alpha: float = 0.99, - exposure: float = 1.0, - alpha_g: float = 0.05, - beta_g: float = 0.94, -) -> Tuple[float, float]: - """ - Filtered Historical Simulation VaR/ES for t+1 (1-day horizon). - 1) Build portfolio return series r_t - 2) Fit GARCH-lite sigma_t - 3) Standardize z_t = r_t / sigma_t - 4) qz = quantile(z, 1-alpha); z-tail mean for ES - 5) Forecast sigma_{t+1}; VaR = -qz * sigma_{t+1} * exposure; ES similar - """ - r_p = pd.Series(returns.values @ weights, index=returns.index, name="r_p").dropna() - if len(r_p) < 50: - # not enough history for a stable filter - q = r_p.quantile(1 - alpha) - var_loss = float(-q * exposure) - # ES fallback - tail = r_p[r_p <= q] - es_loss = float(-tail.mean() * exposure) if len(tail) else var_loss - return var_loss, es_loss - - long_var = float(r_p.var(ddof=1)) - sigma = garch11_filter(r_p, alpha_g=alpha_g, beta_g=beta_g, long_run_var=long_var) - # Avoid div-by-zero - sigma_safe = sigma.replace(0.0, np.nan).bfill().ffill() - z = (r_p / sigma_safe).dropna() - - qz = z.quantile(1 - alpha) - tail_z = z[z <= qz] - z_es = tail_z.mean() if len(tail_z) else qz - - # Forecast next sigma - sigma_next = garch11_forecast_sigma_next(float(r_p.iloc[-1]), float(sigma.iloc[-1]), - alpha_g, beta_g, long_run_var=long_var) - - var_loss = float(-qz * sigma_next * exposure) - es_loss = float(-z_es * sigma_next * exposure) - return var_loss, es_loss - -def backtest_fhs_var( - returns: pd.DataFrame, - weights: np.ndarray, - alpha: float = 0.99, - window_min: int = 50, - alpha_g: float = 0.05, - beta_g: float = 0.94, -) -> dict: - """ - Produce a rolling series of FHS VaR thresholds (1-day ahead) and exceptions. - For each t, fit sigma up to t-1, get z-quantile from past z, forecast sigma_t, compare r_t. - """ - r_p = pd.Series(returns.values @ weights, index=returns.index, name="r_p").dropna() - if len(r_p) <= window_min: - return { - "r_p": r_p, - "VaR_FHS": pd.Series(index=r_p.index, dtype=float), - "exceptions": pd.Series(index=r_p.index, dtype=int), - "T": 0, "exceedances": 0, "hit_rate": np.nan, - } - - long_var = float(r_p.var(ddof=1)) - sigma = garch11_filter(r_p, alpha_g=alpha_g, beta_g=beta_g, long_run_var=long_var) - sigma_safe = sigma.replace(0.0, np.nan).bfill().ffill() - - # Build z up to t-1; rolling quantile of z with at least window_min obs - z = (r_p / sigma_safe).dropna() - qz_series = z.rolling(window_min).quantile(1 - alpha).shift(1) - - # One-step-ahead sigma using GARCH recursion - # sigma_next at t is computed from (t-1) info: - r_shift = r_p.shift(1) - sig_shift = sigma_safe.shift(1) - omega = max(1e-18, (1.0 - alpha_g - beta_g) * long_var) - sig2_next = omega + alpha_g * (r_shift**2) + beta_g * (sig_shift**2) - sigma_next = (sig2_next**0.5).replace([np.inf, -np.inf], np.nan) - - # FHS VaR threshold at t (as negative return level) - var_thresh = -qz_series * sigma_next - - # Exceptions when realized r_p < var_thresh - exceptions = ((r_p < var_thresh) & var_thresh.notna()).astype(int) - - mask = var_thresh.notna() - T = int(mask.sum()) - x = int(exceptions[mask].sum()) - hit_rate = (x / T) if T > 0 else np.nan - - return { - "r_p": r_p, - "VaR_FHS": var_thresh, - "exceptions": exceptions, - "alpha": alpha, - "window_min": window_min, - "T": T, - "exceedances": x, - "hit_rate": hit_rate, - } - - - -def normalize_weights(w: np.ndarray) -> np.ndarray: - s = float(np.sum(w)) - return (w / s) if s != 0 else w - -def var_parametric_normal_parts( - returns: pd.DataFrame, - weights: np.ndarray, - alpha: float = 0.95, - horizon_days: int = 1, - exposure: float = 1.0, -) -> dict: - """Parametric VaR (Normal) + marginal & component contributions (Euler).""" - w = normalize_weights(np.asarray(weights, dtype=float)) - mu = returns.mean().values * horizon_days - cov = returns.cov().values * horizon_days - sigma_p = float(np.sqrt(w @ cov @ w)) - mu_p = float(w @ mu) - z = _N.inv_cdf(alpha) - var_loss = (-mu_p + z * sigma_p) * exposure - Sigma_w = cov @ w - mvar = (-mu + z * (Sigma_w / (sigma_p if sigma_p > 0 else 1e-18))) * exposure - cvar = w * mvar - pcontrib = (cvar / var_loss) if var_loss != 0 else np.zeros_like(cvar) - return {"VaR": float(var_loss), "mu_p": mu_p, "sigma_p": sigma_p, - "mVaR": mvar, "cVaR": cvar, "pContrib": pcontrib, "cov": cov, "w": w} - -def incremental_var_normal( - returns: pd.DataFrame, - weights: np.ndarray, - alpha: float = 0.95, - horizon_days: int = 1, - exposure: float = 1.0, -) -> dict: - """ - Incremental VaR (iVaR) and Component VaR under Normal approx. - iVaR_i ≈ VaR(w) - VaR(w with a tiny reduction on asset i) ≈ w_i * mVaR_i - Returns dict with portfolio VaR, mVaR (per asset), cVaR (per asset). - """ - w = np.asarray(weights, dtype=float) - s = float(w.sum()) - if s == 0: - return {"VaR": 0.0, "mVaR": np.zeros_like(w), "cVaR": np.zeros_like(w)} - - w = w / s - mu = returns.mean().values * horizon_days - cov = returns.cov().values * horizon_days - - sigma_p = float(np.sqrt(w @ cov @ w)) - mu_p = float(w @ mu) - z = _N.inv_cdf(alpha) - - var_loss = (-mu_p + z * sigma_p) * exposure - - Sigma_w = cov @ w - # ∂VaR/∂w_i = (-μ_i + z * (Σw)_i / σ_p) * exposure - mvar = (-mu + z * (Sigma_w / (sigma_p if sigma_p > 0 else 1e-18))) * exposure - cvar = w * mvar # iVaR approximation via Euler allocation - - return {"VaR": float(var_loss), "mVaR": mvar, "cVaR": cvar} - -# ---------- Risk Parity (Equal Risk Contribution) ---------- - - -def erc_weights_from_cov( - cov: np.ndarray, - init: np.ndarray | None = None, - min_w: float = 0.0, - max_w: float = 1.0, - tol: float = 1e-8, - max_iter: int = 5000, - step: float = 0.5, - eps: float = 1e-12, -) -> tuple[np.ndarray, dict]: - """ - SciPy-free multiplicative updates to find Equal-Risk-Contribution (ERC) weights. - Minimizes dispersion of risk contributions RC_i = w_i * (Σ w)_i. - - 'step' in (0,1] damps updates for stability. - - Box constraints via clipping [min_w, max_w], then renormalize to sum=1. - Returns: (weights, info_dict) - """ - n = cov.shape[0] - if init is None: - w = np.ones(n) / n - else: - w = _normalize_weights(np.asarray(init, dtype=float)) - if w.shape[0] != n: - raise ValueError("init length must match covariance dimension") - - for it in range(1, max_iter + 1): - Sigma_w = cov @ w - RC = w * Sigma_w # risk contributions to variance - sigma2 = float(w @ Sigma_w) - target = sigma2 / n # target RC per asset - - # multiplicative update toward equal RC - update = (target / (RC + eps)) ** step - w_new = w * update - - # project to bounds + renormalize - w_new = np.clip(w_new, min_w, max_w) - if w_new.sum() == 0: - w_new = np.ones(n) / n - w_new = _normalize_weights(w_new) - - # convergence check (RC dispersion) - Sigma_w_new = cov @ w_new - RC_new = w_new * Sigma_w_new - disp = float(np.linalg.norm(RC_new / (RC_new.mean() + eps) - 1.0, ord=np.inf)) - w_move = float(np.linalg.norm(w_new - w, 1)) - - w = w_new - if disp < tol or w_move < 1e-10: - break - - info = { - "iter": it, - "rc_dispersion": disp, - "sigma": float(np.sqrt(sigma2)), - "RC": RC_new, - } - return w, info - -def erc_weights( - returns: pd.DataFrame, - horizon_days: int = 1, - init: np.ndarray | None = None, - min_w: float = 0.0, - max_w: float = 1.0, - **kw, -) -> tuple[np.ndarray, dict]: - """ - Convenience wrapper: compute Σ from returns, scale to horizon, then ERC. - """ - cov = returns.cov().values * horizon_days - return erc_weights_from_cov(cov, init=init, min_w=min_w, max_w=max_w, **kw) diff --git a/risk_engine/scenarios.py b/risk_engine/scenarios.py deleted file mode 100644 index 2065921..0000000 --- a/risk_engine/scenarios.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations -import numpy as np -import pandas as pd -from typing import Dict, Iterable, Tuple -from .market import mc_portfolio_loss_from_mu_cov - -# Basic helpers -def _portfolio_loss_from_vector(returns: pd.DataFrame, weights: np.ndarray, shock_vector: Dict[str, float], exposure: float = 1.0) -> float: - cols = returns.columns.tolist() - v = np.zeros(len(cols), dtype=float) - for i, c in enumerate(cols): - if c in shock_vector: - v[i] = float(shock_vector[c]) - port_ret = float(v @ weights) - return -port_ret * float(exposure) - -# 1) Equities -X% one-day -def scenario_equities_shock( - returns: pd.DataFrame, weights: np.ndarray, - equities: Iterable[str], shock: float = -0.05, exposure: float = 1.0 -) -> float: - shock_map = {e: shock for e in equities if e in returns.columns} - return _portfolio_loss_from_vector(returns, weights, shock_map, exposure=exposure) - -# 2) Rates +bp using duration approximation: ΔP/P ≈ -D * Δy -_DEFAULT_DUR = { - "TLT": 18.0, "IEF": 7.5, "AGG": 6.5, "BND": 6.5, "EDV": 24.0, "ZROZ": 27.0 -} -def scenario_rates_bp( - returns: pd.DataFrame, weights: np.ndarray, - durations: Dict[str, float] | None = None, bp: float = 200.0, exposure: float = 1.0, - default_duration: float = 7.0 -) -> float: - dur = dict(_DEFAULT_DUR) - if durations: - dur.update({k: float(v) for k, v in durations.items()}) - dy = float(bp) / 10_000.0 - shock_map = {} - for c in returns.columns: - D = dur.get(c, default_duration) - shock_map[c] = -D * dy - return _portfolio_loss_from_vector(returns, weights, shock_map, exposure=exposure) - -# 3) Correlations +X% (MC VaR/ES recompute with bumped correlations, vols constant) -def scenario_corr_bump_mc( - returns: pd.DataFrame, weights: np.ndarray, - alpha: float = 0.99, horizon_days: int = 1, exposure: float = 1.0, - corr_bump_pct: float = 50.0, n_sims: int = 50_000, seed: int = 7 -) -> Tuple[Tuple[float,float], Tuple[float,float]]: - """ - Multiply off-diagonal correlations by (1 + corr_bump_pct/100), clip to [-0.99, 0.99], - keep vols same; recompute MC VaR/ES. Returns ((VaR_base, ES_base), (VaR_stress, ES_stress)). - """ - mu = returns.mean().values * horizon_days - cov = returns.cov().values * horizon_days - sig = np.sqrt(np.diag(cov)) - sig[sig == 0] = 1e-12 - R = cov / np.outer(sig, sig) - bump = 1.0 + corr_bump_pct / 100.0 - Rb = R.copy() - n = Rb.shape[0] - for i in range(n): - for j in range(n): - if i != j: - Rb[i, j] = np.clip(R[i, j] * bump, -0.99, 0.99) - cov_bumped = np.outer(sig, sig) * Rb - - # base - base = mc_portfolio_loss_from_mu_cov(mu, cov, weights, alpha=alpha, exposure=exposure, n_sims=int(n_sims), seed=int(seed)) - # stressed - stressed = mc_portfolio_loss_from_mu_cov(mu, cov_bumped, weights, alpha=alpha, exposure=exposure, n_sims=int(n_sims), seed=int(seed)) - return base, stressed diff --git a/risk_engine/stress.py b/risk_engine/stress.py deleted file mode 100644 index 7bc6e98..0000000 --- a/risk_engine/stress.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations -import numpy as np -import pandas as pd -from typing import Dict, Tuple - -def apply_single_name_shocks( - returns: pd.DataFrame, - shocks: Dict[str, float] -) -> pd.DataFrame: - """ - Apply one-day shocks (in return space) to specified tickers. - shocks: dict like {"AAPL": -0.10, "MSFT": -0.05} - Non-specified tickers unchanged. Returns a *one-row* DataFrame you can dot with weights. - """ - cols = returns.columns.tolist() - last = returns.iloc[[-1]].copy() - for k, v in shocks.items(): - if k in cols: - last[k] = float(v) - return last - -def scale_covariance(cov: np.ndarray, scale: float) -> np.ndarray: - """ - Uniformly scale covariance matrix by 'scale' factor. - scale > 1 increases volatility, < 1 reduces it. - """ - return cov * float(scale) - -def historical_window_mu_cov( - returns: pd.DataFrame, - start: str, - end: str -) -> Tuple[np.ndarray, np.ndarray]: - """ - Compute mean vector and covariance from a historical sub-window. - Dates are inclusive; expects returns to have a DatetimeIndex. - """ - sub = returns.loc[start:end] - if len(sub) < 5: - raise ValueError("Selected window too short for μ/Σ.") - mu = sub.mean().values - cov = sub.cov().values - return mu, cov diff --git a/risklib/__init__.py b/risklib/__init__.py index e69de29..2c5d447 100644 --- a/risklib/__init__.py +++ b/risklib/__init__.py @@ -0,0 +1,19 @@ +""" +risklib +======= +Validation-grade market and credit risk engine. + +`risklib` is the single source of truth for all modelling, estimation and +validation logic. The Streamlit application in `app/` imports from here and +computes nothing itself. + + risklib.data : price ingestion and return construction + risklib.market : VaR/ES, backtesting, GARCH, attribution, scenarios + risklib.credit : Expected Loss +""" + +__version__ = "0.2.0" + +from . import credit, data, market + +__all__ = ["data", "market", "credit", "__version__"] diff --git a/risklib/credit/__init__.py b/risklib/credit/__init__.py new file mode 100644 index 0000000..1e749cf --- /dev/null +++ b/risklib/credit/__init__.py @@ -0,0 +1,19 @@ +""" +risklib.credit +============== +Credit Expected Loss: validate -> shock -> compute -> summarise. +""" + +from .credit_risk_model import ( + apply_credit_shocks, + compute_el_table, + summarize_el, + validate_and_standardize, +) + +__all__ = [ + "validate_and_standardize", + "apply_credit_shocks", + "compute_el_table", + "summarize_el", +] diff --git a/risk_engine/credit.py b/risklib/credit/credit_risk_model.py similarity index 71% rename from risk_engine/credit.py rename to risklib/credit/credit_risk_model.py index a465131..6fa1cb1 100644 --- a/risk_engine/credit.py +++ b/risklib/credit/credit_risk_model.py @@ -1,14 +1,16 @@ from __future__ import annotations -import pandas as pd +import pandas as pd import numpy as np -from typing import Dict, List, Tuple, Optional +from typing import List, Optional, Tuple + # --------- Column helpers --------- -_PD_ALIASES = ["pd", "probability_of_default", "probability of default", "p_default"] +_PD_ALIASES = ["pd", "probability_of_default", "probability of default", "p_default"] _LGD_ALIASES = ["lgd", "loss_given_default", "loss given default"] _EAD_ALIASES = ["ead", "exposure_at_default", "exposure at default"] _SEG_ALIASES = ["segment", "bucket", "group", "portfolio", "business_unit", "bu", "rating", "grade", "class"] + def _find_col(df: pd.DataFrame, aliases: List[str]) -> Optional[str]: lower_map = {c.lower(): c for c in df.columns} for a in aliases: @@ -16,6 +18,7 @@ def _find_col(df: pd.DataFrame, aliases: List[str]) -> Optional[str]: return lower_map[a] return None + def _to_decimal_if_percent(s: pd.Series) -> pd.Series: """ Convert only entries that look like percentages (>1 and <=100) to decimals. @@ -28,13 +31,16 @@ def _to_decimal_if_percent(s: pd.Series) -> pd.Series: out.loc[mask] = out.loc[mask] / 100.0 return out + def _safe_div(n: pd.Series, d: pd.Series) -> pd.Series: out = pd.Series(np.zeros(len(n)), index=n.index, dtype=float) mask = d != 0 out.loc[mask] = n.loc[mask] / d.loc[mask] return out + # --------- Public API --------- + def validate_and_standardize( df: pd.DataFrame, pd_col: str | None = None, @@ -46,7 +52,7 @@ def validate_and_standardize( Returns (df_std, PD_COL, LGD_COL, EAD_COL, SEG_COL_or_None). """ # Find columns (case-insensitive, with aliases) - PDc = pd_col or _find_col(df, _PD_ALIASES) + PDc = pd_col or _find_col(df, _PD_ALIASES) LGDc = lgd_col or _find_col(df, _LGD_ALIASES) EADc = ead_col or _find_col(df, _EAD_ALIASES) if PDc is None or LGDc is None or EADc is None: @@ -60,13 +66,29 @@ def validate_and_standardize( out[c] = pd.to_numeric(out[c], errors="coerce") # Convert PD/LGD from % if needed; clamp to [0,1] - out[PDc] = _to_decimal_if_percent(out[PDc]).clip(0.0, 1.0) - out[LGDc] = _to_decimal_if_percent(out[LGDc]).clip(0.0, 1.0) - # EAD non-negative + pd_dec = _to_decimal_if_percent(out[PDc]) + lgd_dec = _to_decimal_if_percent(out[LGDc]) + + # Count what we are about to clamp. Silently clipping out-of-range inputs + # hides a data-quality finding: "3 facilities had PD > 1 and were capped" is + # something a validator needs to see, not something to swallow. + quality = { + "pd_out_of_range": int(((pd_dec < 0) | (pd_dec > 1)).sum()), + "lgd_out_of_range": int(((lgd_dec < 0) | (lgd_dec > 1)).sum()), + "ead_negative": int((out[EADc] < 0).sum()), + "pd_missing": int(pd_dec.isna().sum()), + "lgd_missing": int(lgd_dec.isna().sum()), + "ead_missing": int(out[EADc].isna().sum()), + } + + out[PDc] = pd_dec.clip(0.0, 1.0) + out[LGDc] = lgd_dec.clip(0.0, 1.0) out[EADc] = out[EADc].clip(lower=0.0) + out.attrs["data_quality"] = quality return out, PDc, LGDc, EADc, SEGc + def apply_credit_shocks( df: pd.DataFrame, PDc: str, LGDc: str, EADc: str, @@ -84,11 +106,12 @@ def apply_credit_shocks( pd_add = pd_add_bps / 10_000.0 lgd_add = lgd_add_pct / 100.0 - out["PD_final"] = (out[PDc] * float(pd_mult) + pd_add).clip(0.0, 1.0) + out["PD_final"] = (out[PDc] * float(pd_mult) + pd_add).clip(0.0, 1.0) out["LGD_final"] = (out[LGDc] * float(lgd_mult) + lgd_add).clip(0.0, 1.0) out["EAD_final"] = (out[EADc] * float(ead_mult)).clip(lower=0.0) return out + def compute_el_table( df: pd.DataFrame, pd_col: str | None = None, @@ -108,8 +131,10 @@ def compute_el_table( shocked = apply_credit_shocks(std, PDc, LGDc, EADc, pd_mult, pd_add_bps, lgd_mult, lgd_add_pct, ead_mult) shocked["EL"] = shocked["PD_final"] * shocked["LGD_final"] * shocked["EAD_final"] shocked["EL_pct_of_EAD"] = _safe_div(shocked["EL"], shocked["EAD_final"]) + shocked.attrs["data_quality"] = std.attrs.get("data_quality", {}) return shocked, SEGc + def summarize_el( df_el: pd.DataFrame, seg_col: Optional[str] = None @@ -129,9 +154,19 @@ def summarize_el( else: grp = pd.DataFrame() + total_ead = float(df_el["EAD_final"].sum()) + total_el = float(df_el["EL"].sum()) + + # Exposure-weighted, matching the grouped rows above. + # + # This previously computed mean(per-facility EL/EAD) — an EQUAL-weighted + # average of ratios, which let a $1,000 facility move the portfolio figure + # as much as a $10m one, and disagreed with every grouped subtotal shown + # beside it. Portfolio EL% is total EL over total EAD. totals = pd.Series({ - "total_EAD": float(df_el["EAD_final"].sum()), - "total_EL": float(df_el["EL"].sum()), - "EL_pct_of_EAD": float(_safe_div(df_el["EL"], df_el["EAD_final"]).replace([np.inf, -np.inf], 0).mean()) + "total_EAD": total_ead, + "total_EL": total_el, + "EL_pct_of_EAD": (total_el / total_ead) if total_ead != 0 else 0.0, + "facilities": int(len(df_el)), }) return grp, totals diff --git a/risklib/data.py b/risklib/data.py new file mode 100644 index 0000000..7580a59 --- /dev/null +++ b/risklib/data.py @@ -0,0 +1,162 @@ +""" +risklib/data.py +=============== +Price ingestion and return construction. + +Pipeline: load_prices -> clean_prices -> to_returns + +Each stage guards a specific downstream failure rather than validating in the +abstract. The notes on each function say which one. +""" + +from __future__ import annotations + +import warnings + +from typing import Any + +import numpy as np +import pandas as pd + +__all__ = ["load_prices", "clean_prices", "to_returns"] + + +def load_prices(csv_path: Any, date_col: str | None = None) -> pd.DataFrame: + """ + Load a wide price table (one column per instrument) into a date-indexed frame. + + Parameters + ---------- + csv_path : path, file-like object, or buffer + date_col : name of the date column; if omitted, the first column is used + + Notes + ----- + A sorted DatetimeIndex is a hard requirement for everything downstream — + .rolling(window), .shift(1), .loc[start:end] and the expanding-window + variance in the FHS backtest all depend on it. A CSV exported newest-first + would otherwise run every rolling window backwards through time and silently + invert the backtest, so the sort is not defensive decoration. + + `errors="coerce"` turns unparseable dates into NaT instead of raising, so a + single malformed row does not kill an upload. + + Real exports carry values like "$1,234.56". Excel-origin CSVs read those as + object dtype, and an object column silently poisons .cov() and .mean(). + Stripping the separators and then coercing forces every column to float64 + or NaN. + """ + df = pd.read_csv(csv_path) + if df.empty: + raise ValueError("Price file is empty.") + + dcol = date_col if (date_col and date_col in df.columns) else df.columns[0] + + # Mixed/unknown date formats are expected from arbitrary user uploads, so the + # "could not infer format" notice is not actionable for the caller. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + df[dcol] = pd.to_datetime(df[dcol], errors="coerce") + df = df[df[dcol].notna()] + if df.empty: + raise ValueError( + f"No parseable dates in column {dcol!r}. Check the date format, or pass " + "the correct column name explicitly." + ) + + df = df.set_index(dcol).sort_index() + + df.columns = [str(c).strip() for c in df.columns] + + # Coerce every remaining column to numeric. + # + # The test is "not already numeric" rather than `dtype == "object"`. pandas 3.0 + # introduced a dedicated `str` dtype, so text columns no longer report as + # object and an equality check silently stopped stripping currency symbols — + # the values then failed to parse and the whole column was dropped as + # non-numeric. is_numeric_dtype is stable across pandas 1.x, 2.x and 3.x. + for c in df.columns: + if not pd.api.types.is_numeric_dtype(df[c]): + df[c] = ( + df[c].astype(str) + .str.replace(",", "", regex=False) + .str.replace("$", "", regex=False) + .str.replace("%", "", regex=False) + .str.strip() + ) + df[c] = pd.to_numeric(df[c], errors="coerce") + + # Dropped AFTER the loop. Previously this sat inside it, mutating the frame + # while iterating over its own (stale) column index — a KeyError waiting for + # the first all-NaN column, and an O(n^2) rescan in the meantime. + df = df.dropna(axis=1, how="all") + + if df.empty or df.shape[1] == 0: + raise ValueError( + "No numeric price columns survived parsing. Check that the file has a " + "date column plus at least one column of numeric prices." + ) + return df + + +def clean_prices(prices: pd.DataFrame) -> pd.DataFrame: + """ + Make a price frame safe for return computation. + + - Duplicate dates break .loc[start:end] slicing and double-count + observations inside the rolling window. + - Non-positive prices are set to NaN: log(P_t / P_{t-1}) is undefined for + them, and a single zero would emit -inf into the return series and then + into .cov(), turning the entire covariance matrix into NaN. + - Columns with fewer than two valid observations cannot produce even one + return. + """ + prices = prices[~prices.index.duplicated(keep="first")] + prices = prices.where(prices > 0) + return prices.loc[:, prices.count() >= 2] + + +def to_returns(prices: pd.DataFrame, method: str = "log") -> pd.DataFrame: + """ + Convert prices to returns. + + method="log" : r = ln(P_t / P_{t-1}) [default] + method="simple" : r = P_t / P_{t-1} - 1 + + Log returns are the default because they are time-additive — an h-day return + is the sum of h daily returns — which is exactly the property that makes + sqrt(h) scaling and the i.i.d. assumption coherent. Simple returns are + offered because portfolio aggregation (w . r) and P&L attribution are + strictly correct for simple returns and only approximate for log returns. + Both are exposed; the trade-off is the user's to make. + + dropna(how="all") removes only the first row, which is all-NaN by + construction from .shift(1). Dropping rows containing ANY NaN would silently + shorten the sample whenever one instrument has a holiday the others do not. + """ + if not isinstance(prices, pd.DataFrame) or prices.empty: + raise ValueError("`prices` is empty or not a DataFrame.") + + prices = clean_prices(prices) + if prices.empty or (prices.count() < 2).all(): + raise ValueError( + "Not enough valid price data per column (need at least 2 rows). " + "Check the file for dates, numeric values, and blank cells." + ) + + if method == "log": + rets = np.log(prices / prices.shift(1)) + elif method == "simple": + rets = prices.pct_change() + else: + raise ValueError(f"Unknown method {method!r}. Use 'log' or 'simple'.") + + rets = rets.replace([np.inf, -np.inf], np.nan) + rets = rets.dropna(how="all").dropna(axis=1, how="all") + + if rets.empty: + raise ValueError( + "All returns were dropped. The file likely has only one row of prices, " + "or no numeric columns." + ) + return rets diff --git a/risklib/market/__init__.py b/risklib/market/__init__.py index e69de29..872cabe 100644 --- a/risklib/market/__init__.py +++ b/risklib/market/__init__.py @@ -0,0 +1,81 @@ +""" +risklib.market +============== +Market risk measurement, validation, attribution and scenario analysis. + +Layout +------ + market_risk_model : VaR/ES estimators, MarketRiskConfig, MarketRiskModel + backtest : Kupiec / Christoffersen / joint CC tests, rolling backtests + garch : the single GARCH(1,1) variance recursion (fixed + MLE) + extras : Euler decomposition, incremental VaR, ERC budgeting + scenarios : stress testing and the deterministic scenario library +""" + +from .market_risk_model import ( + METHODS, + MarketRiskConfig, + MarketRiskModel, + cov_shrink, + es_historical, + es_parametric, + fhs_var_es_next, + mc_portfolio_loss_from_mu_cov, + normalize_weights, + portfolio_returns, + var_es_monte_carlo, + var_historical, + var_parametric, +) +from .backtest import ( + backtest_var_fhs, + backtest_var_historical, + christoffersen_independence, + joint_coverage_test, + kupiec_pof, +) +from .garch import ( + fit_garch11_mle, + garch11_filter, + garch11_forecast_next, + variance_path, +) +from .extras import ( + erc_weights, + erc_weights_from_cov, + incremental_var, + var_parametric_normal_parts, +) +from .scenarios import ( + DEFAULT_DURATIONS, + historical_window_mu_cov, + scale_covariance, + scenario_corr_bump_mc, + scenario_covariance_scale, + scenario_equities_shock, + scenario_historical_replay, + scenario_rates_bp, + scenario_single_name, + shock_vector, +) + +__all__ = [ + # measurement + "MarketRiskConfig", "MarketRiskModel", "METHODS", + "var_parametric", "es_parametric", "var_historical", "es_historical", + "var_es_monte_carlo", "mc_portfolio_loss_from_mu_cov", "fhs_var_es_next", + "portfolio_returns", "normalize_weights", "cov_shrink", + # validation + "kupiec_pof", "christoffersen_independence", "joint_coverage_test", + "backtest_var_historical", "backtest_var_fhs", + # volatility + "garch11_filter", "garch11_forecast_next", "fit_garch11_mle", "variance_path", + # attribution + "var_parametric_normal_parts", "incremental_var", + "erc_weights", "erc_weights_from_cov", + # scenarios + "shock_vector", "scenario_single_name", "scenario_equities_shock", + "scenario_rates_bp", "scale_covariance", "scenario_covariance_scale", + "scenario_corr_bump_mc", "historical_window_mu_cov", + "scenario_historical_replay", "DEFAULT_DURATIONS", +] diff --git a/risklib/market/backtest.py b/risklib/market/backtest.py index 7b99138..ff33b02 100644 --- a/risklib/market/backtest.py +++ b/risklib/market/backtest.py @@ -1,30 +1,40 @@ """ risklib/market/backtest.py ========================== -Backtesting framework for VaR models. - -Implements: - - Kupiec (1995) Proportion-of-Failures (POF) test — unconditional coverage - - Christoffersen (1998) Independence test — serial independence of exceptions - - Christoffersen (1998) Joint Conditional Coverage test — LR_cc = LR_uc + LR_ind - -References: - Kupiec, P. (1995). Techniques for Verifying the Accuracy of Risk Measurement Models. - Journal of Derivatives, 3(2), 73–84. +VaR backtesting: out-of-sample exception generation and coverage tests. + +Tests implemented +----------------- + Kupiec (1995) Proportion-of-Failures — unconditional coverage + Christoffersen (1998) Independence — serial independence of exceptions + Christoffersen (1998) Conditional Coverage — LR_cc = LR_uc + LR_ind ~ Chi^2(2) + +Backtests implemented +--------------------- + backtest_var_historical : rolling historical-simulation VaR + backtest_var_fhs : rolling Filtered Historical Simulation VaR + +Out-of-sample discipline +------------------------ +Both backtests estimate the threshold at time t using information available +strictly through t-1. This is the single most important property of the module: +`rolling(window).quantile()` at row t includes row t itself — the very day being +predicted — so every threshold is `.shift(1)`-ed. Without that, every result in +the repository would be meaningless, and it is the first thing a reviewer checks. + +Regulatory context +------------------ +Basel III/IV (FRTB) backtesting tests unconditional coverage at 99%. The joint +conditional coverage test additionally detects exception clustering, a known +failure mode of static VaR models during volatility regime shifts (March 2020, +the 2022 rate shock). Kupiec alone cannot see it. + +References +---------- + Kupiec, P. (1995). Techniques for Verifying the Accuracy of Risk Measurement + Models. Journal of Derivatives, 3(2), 73-84. Christoffersen, P. (1998). Evaluating Interval Forecasts. - International Economic Review, 39(4), 841–862. - -Regulatory context: - Basel III/IV backtesting requirements (FRTB) test unconditional coverage at 99%. - The joint conditional coverage test additionally detects exception clustering — - a known failure mode of static VaR models during volatility regime shifts - (e.g. March 2020, 2022 rate shock). Kupiec alone cannot detect this. - -Design: - - All functions are pure (no side effects, no global state) - - backtest_var_historical() is the primary entry point; returns a flat dict - containing raw series, exception counts, and all three test statistics - - joint_coverage_test() can be called independently on any exception series + International Economic Review, 39(4), 841-862. """ from __future__ import annotations @@ -36,17 +46,20 @@ import numpy as np import pandas as pd +from .garch import VAR_FLOOR, omega_from_variance_target +from .market_risk_model import portfolio_returns -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - -def _portfolio_returns(returns: pd.DataFrame, weights: np.ndarray) -> pd.Series: - """Project multivariate return matrix onto portfolio weights.""" - return pd.Series(returns.values @ weights, index=returns.index, name="r_p") +__all__ = [ + "kupiec_pof", + "christoffersen_independence", + "joint_coverage_test", + "backtest_var_historical", + "backtest_var_fhs", +] def _clamp(x: float, eps: float = 1e-12) -> float: + """Keep probabilities strictly inside (0, 1) so log() is finite.""" return min(max(float(x), eps), 1.0 - eps) @@ -56,170 +69,137 @@ def _clamp(x: float, eps: float = 1e-12) -> float: def kupiec_pof(exceedances: int, T: int, alpha: float = 0.95) -> Tuple[float, float]: """ - Kupiec (1995) Proportion-of-Failures (POF) test. + Kupiec Proportion-of-Failures test — unconditional coverage. - H0: observed exception rate == (1 - alpha) [unconditional coverage] - LR_uc ~ Chi^2(1) under H0. + Exceptions are modelled as i.i.d. Bernoulli. `ll0` is the log-likelihood + under the null rate (1-alpha), `ll1` under the MLE rate x/T. The likelihood + ratio is asymptotically Chi^2(1). - Parameters - ---------- - exceedances : number of VaR exceptions observed - T : total out-of-sample observations - alpha : VaR confidence level (e.g. 0.95) + H0: observed exception rate == (1 - alpha) + + The p-value uses the exact Chi^2(1) survival function, + P(X > x) = 2*(1 - Phi(sqrt(x))) = erfc(sqrt(x/2)) — no SciPy, no table. + + Limitation: this test sees only the COUNT. A model producing exactly 5% + exceptions, all in one week, passes. That is what the independence test + below is for. Returns ------- - (LR_uc, p_value) - LR_uc : likelihood-ratio statistic - p_value: p-value; reject H0 (model mis-specified) if p < 0.05 + (LR_uc, p_value) — reject H0 (mis-specified coverage) if p < 0.05 """ if T <= 0: raise ValueError("T must be > 0.") - p = _clamp(1.0 - alpha) + p = _clamp(1.0 - alpha) pi_hat = _clamp(exceedances / T) - x = exceedances + x = exceedances - ll0 = (T - x) * log(1.0 - p) + x * log(p) + ll0 = (T - x) * log(1.0 - p) + x * log(p) ll1 = (T - x) * log(1.0 - pi_hat) + x * log(pi_hat) - LR_uc = float(-2.0 * (ll0 - ll1)) - pval = float(erfc(sqrt(LR_uc / 2.0))) # Chi^2(1) survival - return LR_uc, pval + LR_uc = max(float(-2.0 * (ll0 - ll1)), 0.0) + return LR_uc, float(erfc(sqrt(LR_uc / 2.0))) # --------------------------------------------------------------------------- # Test 2 — Christoffersen (1998) Independence # --------------------------------------------------------------------------- -def christoffersen_independence( - exceptions: pd.Series, -) -> Tuple[float, float, Dict]: +def christoffersen_independence(exceptions: pd.Series) -> Tuple[float, float, Dict]: """ - Christoffersen (1998) independence test. - - Tests H0: VaR exceptions are serially independent — i.e., knowing that - an exception occurred yesterday conveys no information about today. - Equivalently: the transition probability from exception to exception - (pi_11) equals the transition probability from no-exception to exception - (pi_01). + Christoffersen independence test — do exceptions cluster in time? - H0: pi_01 == pi_11 [independence / no clustering] - LR_ind ~ Chi^2(1) under H0. + The exception indicator series is treated as a first-order Markov chain + with transition counts n_ij = #{t : I_{t-1}=i, I_t=j}. Under H1 today's + exception probability may depend on yesterday's state; under H0 there is a + single unconditional probability. - Transition counts from the binary indicator series I_t ∈ {0, 1}: - n_ij = #{t : I_{t-1}=i, I_t=j} + H0: pi_01 == pi_11 (no clustering) + LR_ind ~ Chi^2(1) - Parameters - ---------- - exceptions : pd.Series of 0/1 exception indicators (aligned to OOS window) + Why this is the test that matters: a VaR model that clusters its exceptions + is systematically understating risk in stressed regimes and overstating it + in calm ones — precisely the failure mode of a static historical-simulation + model in March 2020. Kupiec is blind to it. Returns ------- - (LR_ind, p_value, transitions_dict) - LR_ind : independence LR statistic - p_value : p-value; reject H0 (exceptions cluster) if p < 0.05 - transitions_dict: n00, n01, n10, n11, pi_01, pi_11, pi_hat + (LR_ind, p_value, transitions) """ - I = exceptions.dropna().values.astype(int) + ind = pd.Series(exceptions).dropna().values.astype(int) - # Transition counts - n00 = int(np.sum((I[:-1] == 0) & (I[1:] == 0))) - n01 = int(np.sum((I[:-1] == 0) & (I[1:] == 1))) - n10 = int(np.sum((I[:-1] == 1) & (I[1:] == 0))) - n11 = int(np.sum((I[:-1] == 1) & (I[1:] == 1))) + if len(ind) < 2: + return 0.0, 1.0, {"n00": 0, "n01": 0, "n10": 0, "n11": 0, + "pi_01": 0.0, "pi_11": 0.0, "pi_hat": 0.0} + prev, curr = ind[:-1], ind[1:] + n00 = int(np.sum((prev == 0) & (curr == 0))) + n01 = int(np.sum((prev == 0) & (curr == 1))) + n10 = int(np.sum((prev == 1) & (curr == 0))) + n11 = int(np.sum((prev == 1) & (curr == 1))) total = n00 + n01 + n10 + n11 + if total == 0: return 0.0, 1.0, {"n00": 0, "n01": 0, "n10": 0, "n11": 0, - "pi_01": 0.0, "pi_11": 0.0, "pi_hat": 0.0} + "pi_01": 0.0, "pi_11": 0.0, "pi_hat": 0.0} - # Conditional transition probabilities - pi_01 = _clamp(n01 / (n00 + n01)) if (n00 + n01) > 0 else 1e-12 - pi_11 = _clamp(n11 / (n10 + n11)) if (n10 + n11) > 0 else 1e-12 + pi_01 = _clamp(n01 / (n00 + n01)) if (n00 + n01) > 0 else _clamp(0.0) + pi_11 = _clamp(n11 / (n10 + n11)) if (n10 + n11) > 0 else _clamp(0.0) pi_hat = _clamp((n01 + n11) / total) - # Log-likelihoods - # H1: Markov(1) — transition probs depend on previous state + # H1: Markov(1) — transition probabilities depend on the previous state ll_H1 = (n00 * log(1.0 - pi_01) + n01 * log(pi_01) + n10 * log(1.0 - pi_11) + n11 * log(pi_11)) - # H0: iid — single unconditional exception probability + # H0: i.i.d. — one unconditional exception probability ll_H0 = ((n00 + n10) * log(1.0 - pi_hat) + (n01 + n11) * log(pi_hat)) - LR_ind = max(float(-2.0 * (ll_H0 - ll_H1)), 0.0) # numerical guard - pval = float(erfc(sqrt(LR_ind / 2.0))) # Chi^2(1) survival + # Non-negative in theory; floating point in degenerate cases (n11 = 0) can + # drift slightly below zero. + LR_ind = max(float(-2.0 * (ll_H0 - ll_H1)), 0.0) transitions = { "n00": n00, "n01": n01, "n10": n10, "n11": n11, - "pi_01": float(pi_01), - "pi_11": float(pi_11), - "pi_hat": float(pi_hat), + "pi_01": float(pi_01), "pi_11": float(pi_11), "pi_hat": float(pi_hat), } - return LR_ind, pval, transitions + return LR_ind, float(erfc(sqrt(LR_ind / 2.0))), transitions # --------------------------------------------------------------------------- -# Test 3 — Joint Conditional Coverage (Christoffersen 1998) +# Test 3 — Joint Conditional Coverage # --------------------------------------------------------------------------- -def joint_coverage_test( - exceedances: int, - T: int, - alpha: float, - exceptions_series: pd.Series, -) -> Dict: +def joint_coverage_test(exceedances: int, T: int, alpha: float, + exceptions_series: pd.Series) -> Dict: """ - Christoffersen (1998) joint conditional coverage test. - - Combines unconditional coverage (Kupiec) and serial independence - (Christoffersen) into a single joint test: - - LR_cc = LR_uc + LR_ind ~ Chi^2(2) under H0 - - H0: exception frequency is correct AND exceptions are independent. - - For Chi^2(2): p-value = exp(-LR_cc / 2) [exact closed form] + Christoffersen joint conditional coverage: LR_cc = LR_uc + LR_ind ~ Chi^2(2). - A model that passes Kupiec but fails the independence test produces - correctly-sized exceptions on average but clusters them in time — - understating risk during stressed periods and overstating it in calm ones. - This is the key failure mode the independence test is designed to detect. + The additivity is the standard Christoffersen decomposition — correct + conditional coverage means correct frequency AND independence, and the two + LR statistics are asymptotically independent. The Chi^2(2) survival + function is exactly exp(-x/2). - Parameters - ---------- - exceedances : number of VaR exceptions (int) - T : total OOS observations (int) - alpha : VaR confidence level - exceptions_series : pd.Series of 0/1 exception indicators - - Returns - ------- - dict with keys: - kupiec_LR, kupiec_pvalue - christoffersen_LR, christoffersen_pvalue - joint_LR, joint_pvalue - transitions (sub-dict with n00/n01/n10/n11/pi_01/pi_11/pi_hat) + A model that passes Kupiec but fails independence produces correctly-sized + exceptions on average while clustering them in time. """ - LR_uc, p_uc = kupiec_pof(exceedances, T, alpha) + LR_uc, p_uc = kupiec_pof(exceedances, T, alpha) LR_ind, p_ind, transitions = christoffersen_independence(exceptions_series) - LR_cc = LR_uc + LR_ind - p_cc = float(math.exp(-LR_cc / 2.0)) # Chi^2(2) exact return { - "kupiec_LR": LR_uc, - "kupiec_pvalue": p_uc, - "christoffersen_LR": LR_ind, - "christoffersen_pvalue": p_ind, - "joint_LR": LR_cc, - "joint_pvalue": p_cc, - "transitions": transitions, + "kupiec_LR": LR_uc, + "kupiec_pvalue": p_uc, + "christoffersen_LR": LR_ind, + "christoffersen_pvalue": p_ind, + "joint_LR": LR_cc, + "joint_pvalue": float(math.exp(-LR_cc / 2.0)), + "transitions": transitions, } # --------------------------------------------------------------------------- -# Primary entry point — rolling historical VaR backtest +# Backtest 1 — rolling historical simulation # --------------------------------------------------------------------------- def backtest_var_historical( @@ -229,34 +209,20 @@ def backtest_var_historical( window: int = 250, ) -> Dict: """ - Rolling 1-day historical VaR backtest with full statistical diagnostics. - - Procedure: - 1. Compute portfolio returns r_p = returns @ weights - 2. At each t, estimate VaR from the trailing `window` observations - ending at t-1 (strict out-of-sample: no look-ahead) - 3. Flag exception if r_p,t < VaR_threshold_t - 4. Run Kupiec POF, Christoffersen independence, and joint CC tests - - Parameters - ---------- - returns : pd.DataFrame of asset log-returns (rows = dates, cols = assets) - weights : np.ndarray of portfolio weights (must sum to 1) - alpha : VaR confidence level (default 0.95) - window : rolling estimation window in trading days (default 250) + Rolling one-day historical VaR backtest with full diagnostics. - Returns - ------- - dict containing: - r_p, VaR_threshold, exceptions — time series (pd.Series) - window, alpha, T, exceedances, hit_rate - kupiec_LR, kupiec_pvalue - christoffersen_LR, christoffersen_pvalue - joint_LR, joint_pvalue - transitions — Markov transition counts and probs + 1. r_p = R @ w + 2. threshold at t = (1-alpha) quantile of the trailing `window` + observations ending at t-1 [.shift(1) — no look-ahead] + 3. exception if r_p,t < threshold_t + 4. Kupiec, Christoffersen and joint conditional coverage + + `mask = q.notna()` drops the burn-in period so T counts only genuine + out-of-sample days, and the exception series passed to the independence + test is a contiguous block — transitions must not be computed across a gap. """ - r_p = _portfolio_returns(returns, weights) - q = r_p.rolling(window).quantile(1.0 - alpha).shift(1) + r_p = portfolio_returns(returns, weights) + q = r_p.rolling(window).quantile(1.0 - alpha).shift(1) exceptions = (r_p < q).astype(int) mask = q.notna() @@ -266,13 +232,111 @@ def backtest_var_historical( tests = joint_coverage_test(x, T, alpha, exceptions[mask]) return { - "r_p": r_p, + "method": "historical", + "r_p": r_p, "VaR_threshold": q, - "exceptions": exceptions, - "window": window, - "alpha": alpha, - "T": T, - "exceedances": x, - "hit_rate": float(x / T) if T > 0 else float("nan"), + "exceptions": exceptions, + "window": window, + "alpha": alpha, + "T": T, + "exceedances": x, + "hit_rate": float(x / T) if T > 0 else float("nan"), + **tests, + } + + +# --------------------------------------------------------------------------- +# Backtest 2 — rolling Filtered Historical Simulation +# --------------------------------------------------------------------------- + +def backtest_var_fhs( + returns: pd.DataFrame, + weights: np.ndarray, + alpha: float = 0.99, + window: int = 250, + alpha_g: float = 0.05, + beta_g: float = 0.94, +) -> Dict: + """ + Rolling one-day FHS VaR backtest. + + Out-of-sample construction + -------------------------- + The GARCH recursion sigma^2_t = omega_t + a*r^2_{t-1} + b*sigma^2_{t-1} is + already causal in r and sigma. The remaining leakage risk is `omega`, which + under variance targeting depends on the long-run variance. An earlier + implementation computed that from the FULL sample, so every threshold + embedded information about the future. + + Here the long-run variance is an EXPANDING-window estimate lagged by one + day, so omega_t uses only returns through t-1: + + lrv_t = Var(r_1 .. r_{t-1}) + omega_t = (1 - a - b) * lrv_t + sig2_t = omega_t + a*r^2_{t-1} + b*sig2_{t-1} + + sigma_t is therefore exactly the one-step-ahead forecast formed at t-1. The + standardised-residual quantile is likewise a trailing rolling quantile, + shifted by one. + + Note: alpha_g and beta_g remain fixed here. Re-estimating GARCH by MLE at + every step is the correct extension but costs a full optimisation per day; + it is deliberately out of scope for this function. + """ + r_p = portfolio_returns(returns, weights).dropna() + n = len(r_p) + + empty = pd.Series(index=r_p.index, dtype=float) + if n <= window: + return { + "method": "fhs", "r_p": r_p, "VaR_threshold": empty, + "exceptions": pd.Series(index=r_p.index, dtype=int), + "window": window, "alpha": alpha, "T": 0, "exceedances": 0, + "hit_rate": float("nan"), + **joint_coverage_test(0, 1, alpha, pd.Series(dtype=int)), + } + + r_vals = r_p.values.astype(float) + + # Expanding, lagged long-run variance -> causal omega_t + lrv = r_p.expanding(min_periods=2).var(ddof=1).shift(1) + lrv = lrv.bfill().fillna(float(np.var(r_vals[:window], ddof=1))) + lrv_vals = np.maximum(lrv.values.astype(float), VAR_FLOOR) + + sig2 = np.empty(n, dtype=float) + sig2[0] = max(lrv_vals[0], VAR_FLOOR) + for t in range(1, n): + omega_t = omega_from_variance_target(lrv_vals[t], alpha_g, beta_g) + sig2[t] = max(VAR_FLOOR, omega_t + alpha_g * r_vals[t - 1] ** 2 + beta_g * sig2[t - 1]) + + # sigma_t is the forecast made at t-1, so no further shift is required. + sigma = pd.Series(np.sqrt(sig2), index=r_p.index, name="sigma_garch") + + # Standardised residuals; their trailing quantile is lagged one day. + z = r_p / sigma.replace(0.0, np.nan).bfill().ffill() + qz = z.rolling(window).quantile(1.0 - alpha).shift(1) + + var_thresh = qz * sigma # negative return level + exceptions = ((r_p < var_thresh) & var_thresh.notna()).astype(int) + + mask = var_thresh.notna() + T = int(mask.sum()) + x = int(exceptions[mask].sum()) + tests = joint_coverage_test(x, T, alpha, exceptions[mask]) if T > 0 else \ + joint_coverage_test(0, 1, alpha, pd.Series(dtype=int)) + + return { + "method": "fhs", + "r_p": r_p, + "VaR_threshold": var_thresh, + "sigma": sigma, + "exceptions": exceptions, + "window": window, + "alpha": alpha, + "alpha_g": alpha_g, + "beta_g": beta_g, + "T": T, + "exceedances": x, + "hit_rate": float(x / T) if T > 0 else float("nan"), **tests, } diff --git a/risklib/market/extras.py b/risklib/market/extras.py new file mode 100644 index 0000000..046f08a --- /dev/null +++ b/risklib/market/extras.py @@ -0,0 +1,286 @@ +""" +risklib/market/extras.py +======================== +Risk attribution and allocation, under the Normal (parametric) approximation. + + var_parametric_normal_parts : marginal + component VaR (Euler allocation) + incremental_var : true incremental VaR (position-removal) + erc_weights : Equal Risk Contribution budgeting + +These answer "where is the risk coming from?" rather than "how much is there?", +which is why they live beside the measurement layer rather than inside it. The +migration plan scoped them out of V1; they are retained because portfolio-level +attribution is the output a risk committee actually acts on. +""" + +from __future__ import annotations + +from statistics import NormalDist +from typing import Dict, Tuple + +import numpy as np +import pandas as pd + +from .market_risk_model import normalize_weights + +__all__ = [ + "var_parametric_normal_parts", + "incremental_var", + "erc_weights_from_cov", + "erc_weights", +] + +_N = NormalDist() + + +# --------------------------------------------------------------------------- +# Euler decomposition +# --------------------------------------------------------------------------- + +def var_parametric_normal_parts( + returns: pd.DataFrame, + weights: np.ndarray, + alpha: float = 0.95, + horizon_days: int = 1, + exposure: float = 1.0, +) -> Dict: + """ + Parametric VaR with marginal and component contributions (Euler allocation). + + VaR(w) = (-w'mu + z * sqrt(w'Sigma w)) * E + mVaR_i = dVaR/dw_i = (-mu_i + z * (Sigma w)_i / sigma_p) * E + cVaR_i = w_i * mVaR_i + + The decomposition is EXACT, not approximate. VaR(w) is homogeneous of + degree 1 in w, so Euler's theorem gives sum_i w_i * dVaR/dw_i = VaR(w). + Directly: sum_i w_i * z*(Sigma w)_i / sigma_p = z * (w'Sigma w) / sigma_p + = z * sigma_p. Component VaRs therefore sum to portfolio VaR to machine + precision. + + Why it matters: this is what turns "portfolio VaR is $10,621" into "QQQ is + 47% of your risk on a 25% weight" — the number a risk committee acts on. + + Returns + ------- + dict with VaR, mu_p, sigma_p, mVaR, cVaR, pContrib, cov, w + """ + w = normalize_weights(np.asarray(weights, dtype=float)) + mu = returns.mean().values * horizon_days + cov = returns.cov().values * horizon_days + + sigma_p = float(np.sqrt(w @ cov @ w)) + mu_p = float(w @ mu) + z = _N.inv_cdf(alpha) + + var_loss = (-mu_p + z * sigma_p) * exposure + sigma_w = cov @ w + mvar = (-mu + z * (sigma_w / (sigma_p if sigma_p > 0 else 1e-18))) * exposure + cvar = w * mvar + pcontrib = (cvar / var_loss) if var_loss != 0 else np.zeros_like(cvar) + + return { + "VaR": float(var_loss), + "mu_p": mu_p, + "sigma_p": sigma_p, + "mVaR": mvar, + "cVaR": cvar, + "pContrib": pcontrib, + "cov": cov, + "w": w, + } + + +# --------------------------------------------------------------------------- +# Incremental VaR +# --------------------------------------------------------------------------- + +def incremental_var( + returns: pd.DataFrame, + weights: np.ndarray, + alpha: float = 0.95, + horizon_days: int = 1, + exposure: float = 1.0, +) -> Dict: + """ + TRUE incremental VaR: the change in portfolio VaR from removing a position + entirely and renormalising the remainder. + + iVaR_i = VaR(w) - VaR(w with position i removed, rest renormalised) + + This is a discrete recomputation, not a derivative. A previous version of + this codebase returned component VaR under the name "incremental VaR"; the + two converge only for small positions. On a 25% weight they differ + materially, so both are now returned side by side and can be compared. + + Returns + ------- + dict with VaR, iVaR (true), cVaR (Euler component), mVaR (marginal) + """ + w = normalize_weights(np.asarray(weights, dtype=float)) + n = len(w) + + base = var_parametric_normal_parts(returns, w, alpha, horizon_days, exposure) + var_full = base["VaR"] + + ivar = np.zeros(n, dtype=float) + for i in range(n): + w_ex = w.copy() + w_ex[i] = 0.0 + if w_ex.sum() <= 0: + # Removing the only funded position leaves nothing to measure. + ivar[i] = var_full + continue + w_ex = normalize_weights(w_ex) + var_ex = var_parametric_normal_parts(returns, w_ex, alpha, horizon_days, exposure)["VaR"] + ivar[i] = var_full - var_ex + + return { + "VaR": float(var_full), + "iVaR": ivar, + "cVaR": base["cVaR"], + "mVaR": base["mVaR"], + } + + +# --------------------------------------------------------------------------- +# Equal Risk Contribution +# --------------------------------------------------------------------------- + +def _project_capped_simplex( + v: np.ndarray, + min_w: float, + max_w: float, + tol: float = 1e-14, + max_bisect: int = 200, +) -> np.ndarray: + """ + Project v onto {w : sum(w) = 1, min_w <= w_i <= max_w}. + + Clipping and then renormalising does NOT respect the box: renormalising + scales every weight up, which can push a clipped weight back above max_w. + (This is exactly what the previous implementation did, and it produced + weights of 0.508 under a stated 0.50 cap.) + + The correct projection solves for a single shift theta such that + w_i = clip(v_i - theta, min_w, max_w) + sums to 1. sum(w(theta)) is continuous and non-increasing in theta, so a + bisection converges reliably. + """ + n = len(v) + if n * min_w > 1.0 + 1e-12 or n * max_w < 1.0 - 1e-12: + raise ValueError( + f"Infeasible bounds: {n} assets with min_w={min_w}, max_w={max_w} " + "cannot produce weights summing to 1." + ) + + lo, hi = float(v.min() - max_w - 1.0), float(v.max() - min_w + 1.0) + for _ in range(max_bisect): + theta = 0.5 * (lo + hi) + s = float(np.clip(v - theta, min_w, max_w).sum()) + if abs(s - 1.0) < tol: + break + if s > 1.0: + lo = theta + else: + hi = theta + return np.clip(v - theta, min_w, max_w) + + +def erc_weights_from_cov( + cov: np.ndarray, + init: np.ndarray | None = None, + min_w: float = 0.0, + max_w: float = 1.0, + tol: float = 1e-8, + max_iter: int = 5000, + step: float = 0.5, + eps: float = 1e-12, +) -> Tuple[np.ndarray, Dict]: + """ + Equal Risk Contribution weights by damped multiplicative updates. + + RC_i = w_i * (Sigma w)_i (contribution to VARIANCE) + target = w'Sigma w / n + w_i <- w_i * (target / RC_i)^step + + If asset i contributes more than its 1/n share then target/RC_i < 1 and its + weight shrinks; and conversely. `step` in (0, 1] damps the update — at + step=1 the iteration can overshoot and oscillate on ill-conditioned + covariance matrices. + + Implemented directly rather than via scipy.optimize: this is a constrained + problem where a generic solver needs careful bounds and starting points, + whereas the multiplicative update is three lines and converges in tens of + iterations. Convergence is measured on RISK-CONTRIBUTION DISPERSION, which + is the objective itself rather than a proxy. + + Note: contributions are equalised in VARIANCE space, computed from Sigma + alone. Percentage-of-VaR contributions additionally include the drift term + (-mu_i), so they will be close to but not exactly equal. + + Returns + ------- + (weights, info) with info = iter, rc_dispersion, sigma, RC, converged + """ + n = cov.shape[0] + if init is None: + w = np.ones(n) / n + else: + w = normalize_weights(np.asarray(init, dtype=float)) + if w.shape[0] != n: + raise ValueError("init length must match the covariance dimension.") + if w.sum() <= 0: + w = np.ones(n) / n + + # Initialised before the loop so `info` is always well-defined, even if + # max_iter is 0 or the first iteration breaks immediately. + sigma_w = cov @ w + rc = w * sigma_w + sigma2 = float(w @ sigma_w) + disp = float("inf") + it = 0 + converged = False + + for it in range(1, max_iter + 1): + sigma_w = cov @ w + rc = w * sigma_w + sigma2 = float(w @ sigma_w) + target = sigma2 / n + + w_new = w * (target / (rc + eps)) ** step + if not np.isfinite(w_new).all() or w_new.sum() <= 0: + w_new = np.ones(n) / n + # Exact projection onto the box-constrained simplex: respects min_w and + # max_w AND sums to 1. Clipping then renormalising satisfies neither. + w_new = _project_capped_simplex(normalize_weights(w_new), min_w, max_w) + + rc_new = w_new * (cov @ w_new) + disp = float(np.linalg.norm(rc_new / (rc_new.mean() + eps) - 1.0, ord=np.inf)) + w_move = float(np.linalg.norm(w_new - w, 1)) + + w = w_new + rc = rc_new + if disp < tol or w_move < 1e-10: + converged = True + break + + return w, { + "iter": it, + "rc_dispersion": disp, + "sigma": float(np.sqrt(max(sigma2, 0.0))), + "RC": rc, + "converged": converged, + } + + +def erc_weights( + returns: pd.DataFrame, + horizon_days: int = 1, + init: np.ndarray | None = None, + min_w: float = 0.0, + max_w: float = 1.0, + **kw, +) -> Tuple[np.ndarray, Dict]: + """Convenience wrapper: build Sigma from returns, scale to horizon, run ERC.""" + cov = returns.cov().values * horizon_days + return erc_weights_from_cov(cov, init=init, min_w=min_w, max_w=max_w, **kw) diff --git a/risklib/market/garch.py b/risklib/market/garch.py new file mode 100644 index 0000000..15d4246 --- /dev/null +++ b/risklib/market/garch.py @@ -0,0 +1,303 @@ +""" +risklib/market/garch.py +======================= +GARCH(1,1) conditional volatility: filtering, forecasting, and MLE estimation. + +Model +----- + sigma^2_t = omega + alpha * r^2_{t-1} + beta * sigma^2_{t-1} + + Stationarity: alpha + beta < 1 + Unconditional (long-run) variance: omega / (1 - alpha - beta) + +Two parameterisation modes +-------------------------- +1. **Fixed parameters (variance targeting).** alpha and beta are supplied + (defaults 0.05 / 0.94, the RiskMetrics-adjacent convention), and omega is + pinned so the model's unconditional variance equals the sample variance: + omega = (1 - alpha - beta) * var(r) + Cheap, stable, and reproducible — but it is an assumption, not an estimate. + +2. **Maximum likelihood.** omega, alpha, beta are estimated from the data by + maximising the Gaussian conditional log-likelihood. + +Design note +----------- +There is exactly ONE implementation of the variance recursion in this codebase +(`variance_path`). The fixed-parameter and MLE paths differ only in how they +obtain (omega, alpha, beta) before calling it. Prior to this module the same +recursion existed in three places with subtly different initialisation. + +References +---------- + Bollerslev, T. (1986). Generalized Autoregressive Conditional + Heteroskedasticity. Journal of Econometrics, 31(3), 307-327. + Engle, R.F. (1982). Autoregressive Conditional Heteroscedasticity with + Estimates of the Variance of United Kingdom Inflation. + Econometrica, 50(4), 987-1007. +""" + +from __future__ import annotations + +from math import log, pi, sqrt +from typing import Dict, Tuple + +import numpy as np +import pandas as pd +from scipy.optimize import minimize +from scipy.special import expit + +__all__ = [ + "variance_path", + "garch11_filter", + "garch11_forecast_next", + "fit_garch11_mle", + "TRADING_DAYS", + "VAR_FLOOR", +] + +TRADING_DAYS = 252 +VAR_FLOOR = 1e-18 # keeps sigma^2 strictly positive through the recursion + + +# --------------------------------------------------------------------------- +# Core recursion — the single implementation +# --------------------------------------------------------------------------- + +def variance_path( + r: np.ndarray, + omega: float, + alpha_g: float, + beta_g: float, + init_var: float | None = None, +) -> np.ndarray: + """ + Conditional variance series sigma^2_t for GARCH(1,1). + + Parameters + ---------- + r : 1-D array of returns + omega : constant term (> 0) + alpha_g : ARCH coefficient + beta_g : GARCH coefficient + init_var : sigma^2_0; defaults to the sample variance of r + + Notes + ----- + Each step is floored at VAR_FLOOR so that a poor parameter proposal during + optimisation produces a large likelihood penalty rather than log(0) or a + division by zero. + """ + n = len(r) + if n == 0: + return np.empty(0, dtype=float) + + if init_var is None: + init_var = float(np.var(r, ddof=1)) if n > 1 else float(np.mean(r ** 2)) + + sig2 = np.empty(n, dtype=float) + sig2[0] = max(VAR_FLOOR, float(init_var)) + r2 = np.asarray(r, dtype=float) ** 2 + for t in range(1, n): + sig2[t] = max(VAR_FLOOR, omega + alpha_g * r2[t - 1] + beta_g * sig2[t - 1]) + return sig2 + + +def omega_from_variance_target(long_run_var: float, alpha_g: float, beta_g: float) -> float: + """omega implied by variance targeting: omega = (1 - alpha - beta) * sigma^2_LR.""" + return max(VAR_FLOOR, (1.0 - alpha_g - beta_g) * float(long_run_var)) + + +# --------------------------------------------------------------------------- +# MLE +# --------------------------------------------------------------------------- + +def _unpack(params: np.ndarray) -> Tuple[float, float, float]: + """ + Map unconstrained R^3 to the admissible GARCH parameter space. + + omega = exp(p0) > 0 + alpha = sigmoid(p1) in (0, 1) + beta = (1 - alpha - eps) * sigmoid(p2) => alpha + beta < 1 + + Constraints are enforced structurally rather than handed to the optimiser. + L-BFGS-B can then roam freely over R^3, which avoids the boundary-stalling + that constrained solvers exhibit on the coupled stationarity inequality. + """ + p0, p1, p2 = params + omega = float(np.exp(np.clip(p0, -700.0, 100.0))) + alpha_g = float(expit(p1)) + beta_g = float((1.0 - alpha_g - 1e-6) * expit(p2)) + return omega, alpha_g, beta_g + + +def _neg_loglik(params: np.ndarray, r: np.ndarray) -> float: + """ + Negative Gaussian conditional log-likelihood, including the 2*pi constant. + + The constant is retained so that the reported log-likelihood, AIC and BIC + are directly comparable to values from `arch`, `statsmodels` or R. (An + earlier version dropped it, which left every reported IC offset by + +0.5 * n * log(2*pi).) + """ + omega, alpha_g, beta_g = _unpack(params) + sig2 = variance_path(r, omega, alpha_g, beta_g) + nll = 0.5 * float(np.sum(np.log(2.0 * pi) + np.log(sig2) + r ** 2 / sig2)) + return nll if np.isfinite(nll) else 1e18 + + +def fit_garch11_mle( + r: pd.Series | np.ndarray, + n_restarts: int = 5, + max_iter: int = 2000, + min_obs: int = 50, +) -> Dict: + """ + Fit GARCH(1,1) by maximum likelihood (L-BFGS-B, multiple restarts). + + Restarts matter: the GARCH likelihood is close to flat in the + (alpha + beta) direction near persistence = 1, so a single start from a + poor point can converge to a local optimum *and report success*. The best + NLL across restarts is kept. + + Returns + ------- + dict with omega, alpha_g, beta_g, persistence, long_run_vol (annualised), + log_likelihood, aic, bic, converged, n_obs, source="MLE". + """ + r_arr = np.asarray(pd.Series(r).dropna().values, dtype=float) + n = len(r_arr) + if n < min_obs: + raise ValueError( + f"Need >= {min_obs} observations for GARCH MLE; got {n}. " + "Use fixed parameters (fit=False) for short series." + ) + + var_est = max(float(np.var(r_arr, ddof=1)), VAR_FLOOR) + rng = np.random.default_rng(42) + + # Deterministic starts spanning low / mid / high persistence, then randoms. + starts = [ + [log(var_est * 0.05), 0.0, 0.0], + [log(var_est * 0.10), 0.5, 1.5], + [log(var_est * 0.02), -1.0, 2.5], + ] + while len(starts) < n_restarts: + starts.append([log(var_est) + rng.normal(0, 1), rng.normal(0, 1), rng.normal(0, 1)]) + starts = starts[:n_restarts] + + best_nll, best_res = np.inf, None + for x0 in starts: + try: + res = minimize( + _neg_loglik, + x0=np.asarray(x0, dtype=float), + args=(r_arr,), + method="L-BFGS-B", + options={"maxiter": max_iter, "ftol": 1e-12, "gtol": 1e-8}, + ) + if np.isfinite(res.fun) and res.fun < best_nll: + best_nll, best_res = float(res.fun), res + except Exception: + continue + + if best_res is None: + raise RuntimeError("GARCH MLE: optimisation failed on all restarts.") + + omega, alpha_g, beta_g = _unpack(best_res.x) + persist = alpha_g + beta_g + log_lik = float(-best_nll) + k = 3 + + return { + "omega": omega, + "alpha_g": alpha_g, + "beta_g": beta_g, + "persistence": persist, + "long_run_vol": float(sqrt(omega / max(1.0 - persist, 1e-12)) * sqrt(TRADING_DAYS)), + "log_likelihood": log_lik, + "aic": float(2 * k - 2 * log_lik), + "bic": float(k * log(n) - 2 * log_lik), + "converged": bool(best_res.success), + "n_obs": n, + "source": "MLE", + } + + +# --------------------------------------------------------------------------- +# Public filter — the single entry point used by the risk models +# --------------------------------------------------------------------------- + +def garch11_filter( + r: pd.Series, + fit: bool = False, + alpha_g: float = 0.05, + beta_g: float = 0.94, + n_restarts: int = 5, +) -> Tuple[pd.Series, Dict]: + """ + Conditional volatility filter, with optional MLE estimation. + + Parameters + ---------- + r : returns series + fit : True -> estimate (omega, alpha, beta) by MLE + False -> variance targeting on the supplied alpha_g / beta_g + alpha_g : ARCH parameter, used only when fit=False + beta_g : GARCH parameter, used only when fit=False + n_restarts : MLE restarts, used only when fit=True + + Returns + ------- + (sigma, info) + sigma : conditional standard deviations, indexed like r + info : parameter dict; `info["source"]` is "MLE" or "fixed", so any + downstream consumer can tell whether the numbers were estimated + or assumed. This tag is what makes the choice auditable. + + If fit=True fails (non-convergence, too few observations), the function + falls back to the fixed-parameter path and records the reason in + info["fallback_reason"] rather than raising — a stress panel should not + disappear because an optimiser had a bad day. + """ + r_clean = pd.Series(r).dropna() + long_run_var = max(float(r_clean.var(ddof=1)), VAR_FLOOR) if len(r_clean) > 1 else VAR_FLOOR + fallback_reason = None + + if fit: + try: + info = fit_garch11_mle(r_clean, n_restarts=n_restarts) + omega_, alpha_, beta_ = info["omega"], info["alpha_g"], info["beta_g"] + except (ValueError, RuntimeError) as exc: + fallback_reason = str(exc) + fit = False + + if not fit: + omega_ = omega_from_variance_target(long_run_var, alpha_g, beta_g) + alpha_, beta_ = alpha_g, beta_g + info = { + "omega": omega_, + "alpha_g": alpha_, + "beta_g": beta_, + "persistence": alpha_ + beta_, + "long_run_vol": float(sqrt(long_run_var) * sqrt(TRADING_DAYS)), + "source": "fixed", + } + if fallback_reason: + info["fallback_reason"] = fallback_reason + + sig2 = variance_path(r_clean.values, omega_, alpha_, beta_, init_var=long_run_var) + sigma = pd.Series(np.sqrt(sig2), index=r_clean.index, name="sigma_garch") + return sigma, info + + +def garch11_forecast_next( + r_last: float, + sigma_last: float, + omega: float, + alpha_g: float, + beta_g: float, +) -> float: + """One-step-ahead conditional standard deviation: sigma^2_{t+1} = w + a*r_t^2 + b*sigma_t^2.""" + sig2_next = omega + alpha_g * (r_last ** 2) + beta_g * (sigma_last ** 2) + return float(sqrt(max(sig2_next, VAR_FLOOR))) diff --git a/risklib/market/garch_mle.py b/risklib/market/garch_mle.py deleted file mode 100644 index d6c990b..0000000 --- a/risklib/market/garch_mle.py +++ /dev/null @@ -1,271 +0,0 @@ -""" -risklib/market/garch_mle.py -============================ -GARCH(1,1) Maximum Likelihood Estimation. - -Provides MLE-based parameter estimation as an upgrade over the fixed-parameter -GARCH filter in market.py. This module closes the documented model limitation: - - "The GARCH(1,1) filter uses fixed parameters (α=0.05, β=0.94) rather than - MLE-estimated parameters. This simplification may misspecify the volatility - process for individual assets, particularly during regime shifts." - -Model ------ - sigma^2_t = omega + alpha * r^2_{t-1} + beta * sigma^2_{t-1} - - Stationarity condition: alpha + beta < 1 (enforced via parameter transform) - Gaussian log-likelihood maximised via scipy.optimize (L-BFGS-B) - -Design ------- - - garch11_filter_mle() is a drop-in replacement for garch11_filter() in market.py - with an additional `fit=True` flag. When fit=True, parameters are estimated - by MLE from the data; when fit=False, behaviour is identical to the original. - - No new runtime dependencies (scipy is already in requirements) - - MarketRiskConfig gains an optional `fit_garch` flag; default=False preserves - existing behaviour so no existing code breaks - -References ----------- - Bollerslev, T. (1986). Generalized Autoregressive Conditional Heteroskedasticity. - Journal of Econometrics, 31(3), 307–327. - Engle, R.F. (1982). Autoregressive Conditional Heteroscedasticity with Estimates - of the Variance of United Kingdom Inflation. Econometrica, 50(4), 987–1007. -""" - -from __future__ import annotations - -from math import log, sqrt -from typing import Dict, Optional, Tuple - -import numpy as np -import pandas as pd -from scipy.optimize import minimize - - -# --------------------------------------------------------------------------- -# Internal: variance path and negative log-likelihood -# --------------------------------------------------------------------------- - -def _variance_path( - r: np.ndarray, - omega: float, - alpha_g: float, - beta_g: float, -) -> np.ndarray: - """ - Compute conditional variance series sigma^2_t for GARCH(1,1). - Initialised at the sample variance of r. - """ - n = len(r) - sig2 = np.empty(n, dtype=float) - sig2[0] = max(1e-18, float(np.var(r, ddof=1))) - r2 = r ** 2 - for t in range(1, n): - sig2[t] = max(1e-18, omega + alpha_g * r2[t - 1] + beta_g * sig2[t - 1]) - return sig2 - - -def _neg_loglik(params: np.ndarray, r: np.ndarray) -> float: - """ - Negative Gaussian log-likelihood for GARCH(1,1). - Parameters are in unconstrained space; transformations enforce constraints: - omega = exp(p0) > 0 - alpha_g = sigmoid(p1) ∈ (0,1) - beta_g = (1 - alpha_g - eps) * sigmoid(p2) ensures alpha + beta < 1 - """ - p0, p1, p2 = params - omega = float(np.exp(p0)) - alpha_g = float(1.0 / (1.0 + np.exp(-p1))) - beta_g = float((1.0 - alpha_g - 1e-6) / (1.0 + np.exp(-p2))) - - sig2 = _variance_path(r, omega, alpha_g, beta_g) - - # Gaussian NLL: 0.5 * sum[log(sig2_t) + r_t^2 / sig2_t] - nll = 0.5 * float(np.sum(np.log(sig2) + r ** 2 / sig2)) - return nll if np.isfinite(nll) else 1e18 - - -# --------------------------------------------------------------------------- -# Public: MLE estimation -# --------------------------------------------------------------------------- - -def fit_garch11_mle( - r: pd.Series, - n_restarts: int = 3, - max_iter: int = 2000, -) -> Dict: - """ - Fit GARCH(1,1) by Maximum Likelihood Estimation. - - Uses L-BFGS-B with multiple random restarts to avoid local minima. - Parameters are estimated in unconstrained space and transformed to - enforce stationarity (alpha + beta < 1). - - Parameters - ---------- - r : pd.Series of asset or portfolio returns - n_restarts : number of random restarts (best result kept) - max_iter : maximum optimizer iterations per restart - - Returns - ------- - dict with keys: - omega, alpha_g, beta_g — MLE parameter estimates - persistence — alpha + beta (< 1 for stationarity) - long_run_vol — annualised long-run volatility = sqrt(omega / (1-alpha-beta)) * sqrt(252) - log_likelihood — maximised log-likelihood - aic — Akaike information criterion (3 free parameters) - bic — Bayesian information criterion - converged — bool, True if best restart converged - n_obs — number of observations used - source — "MLE" - """ - r_arr = r.dropna().values.astype(float) - n = len(r_arr) - if n < 50: - raise ValueError( - f"Need ≥50 observations for GARCH MLE; got {n}. " - "Use fixed parameters (fit=False) for short series." - ) - - best_nll = np.inf - best_res = None - rng = np.random.default_rng(42) - var_est = float(np.var(r_arr, ddof=1)) - - # Deterministic starting points + random restarts - start_configs = [ - [log(var_est * 0.05), 0.0, 0.0], - [log(var_est * 0.10), 0.5, 1.5], - [log(var_est * 0.02), -1.0, 2.5], - ] - for _ in range(max(0, n_restarts - 3)): - start_configs.append(rng.normal(0.0, 1.0, 3).tolist()) - - for x0 in start_configs[:n_restarts]: - try: - res = minimize( - _neg_loglik, - x0=x0, - args=(r_arr,), - method="L-BFGS-B", - options={"maxiter": max_iter, "ftol": 1e-12, "gtol": 1e-8}, - ) - if np.isfinite(res.fun) and res.fun < best_nll: - best_nll = res.fun - best_res = res - except Exception: - continue - - if best_res is None: - raise RuntimeError("GARCH MLE: optimisation failed on all restarts.") - - # Recover constrained estimates - p0, p1, p2 = best_res.x - omega = float(np.exp(p0)) - alpha_g = float(1.0 / (1.0 + np.exp(-p1))) - beta_g = float((1.0 - alpha_g - 1e-6) / (1.0 + np.exp(-p2))) - persist = alpha_g + beta_g - - log_lik = float(-best_nll) - k = 3 - aic = float(2 * k - 2 * log_lik) - bic = float(k * log(n) - 2 * log_lik) - lrv_denom = max(1.0 - persist, 1e-12) - long_run_vol = float(sqrt(omega / lrv_denom) * sqrt(252)) - - return { - "omega": omega, - "alpha_g": alpha_g, - "beta_g": beta_g, - "persistence": persist, - "long_run_vol": long_run_vol, - "log_likelihood": log_lik, - "aic": aic, - "bic": bic, - "converged": bool(best_res.success), - "n_obs": n, - "source": "MLE", - } - - -# --------------------------------------------------------------------------- -# Public: volatility filter (drop-in replacement for market.garch11_filter) -# --------------------------------------------------------------------------- - -def garch11_filter_mle( - r: pd.Series, - fit: bool = True, - alpha_g: float = 0.05, - beta_g: float = 0.94, - n_restarts: int = 3, -) -> Tuple[pd.Series, Dict]: - """ - GARCH(1,1) conditional volatility filter with optional MLE estimation. - - Drop-in replacement for garch11_filter() in market.py, adding the `fit` - flag. When fit=True, omega/alpha/beta are estimated from the data. - When fit=False, the function is behaviourally identical to the original. - - Parameters - ---------- - r : pd.Series of returns - fit : if True, estimate parameters via MLE (recommended) - if False, use the provided alpha_g / beta_g (legacy behaviour) - alpha_g : ARCH parameter — used only when fit=False (default 0.05) - beta_g : GARCH parameter — used only when fit=False (default 0.94) - n_restarts : MLE random restarts (used only when fit=True) - - Returns - ------- - (sigma, fit_info) - sigma : pd.Series of conditional standard deviations (same index as r) - fit_info : dict with parameter estimates and model diagnostics - Always contains: omega, alpha_g, beta_g, persistence, source - MLE additionally: log_likelihood, aic, bic, converged, n_obs - """ - r_clean = r.dropna() - - if fit: - fit_info = fit_garch11_mle(r_clean, n_restarts=n_restarts) - omega_ = fit_info["omega"] - alpha_g_ = fit_info["alpha_g"] - beta_g_ = fit_info["beta_g"] - else: - long_run_var = max(float(r_clean.var(ddof=1)), 1e-18) - omega_ = max(1e-18, (1.0 - alpha_g - beta_g) * long_run_var) - alpha_g_ = alpha_g - beta_g_ = beta_g - fit_info = { - "omega": omega_, - "alpha_g": alpha_g_, - "beta_g": beta_g_, - "persistence": alpha_g + beta_g, - "long_run_vol": float(sqrt(long_run_var) * sqrt(252)), - "source": "fixed", - } - - sig2 = _variance_path(r_clean.values, omega_, alpha_g_, beta_g_) - sigma = pd.Series(np.sqrt(sig2), index=r_clean.index, name="sigma_garch") - return sigma, fit_info - - -# --------------------------------------------------------------------------- -# Convenience: one-step-ahead sigma forecast -# --------------------------------------------------------------------------- - -def garch11_forecast_next( - r_last: float, - sigma_last: float, - omega: float, - alpha_g: float, - beta_g: float, -) -> float: - """ - One-step-ahead conditional standard deviation forecast. - sigma^2_{t+1} = omega + alpha * r_t^2 + beta * sigma_t^2 - """ - sig2_next = omega + alpha_g * (r_last ** 2) + beta_g * (sigma_last ** 2) - return float(sqrt(max(sig2_next, 1e-18))) diff --git a/risklib/market/market_risk_model.py b/risklib/market/market_risk_model.py index 815e4a5..654da67 100644 --- a/risklib/market/market_risk_model.py +++ b/risklib/market/market_risk_model.py @@ -1,167 +1,333 @@ +""" +risklib/market/market_risk_model.py +================================== +Market risk measurement: VaR and Expected Shortfall under four methodologies. + +Loss convention (non-negotiable) +-------------------------------- +Every quantity leaving this module is a POSITIVE LOSS amount: + VaR >= 0 + ES >= VaR +`MarketRiskModel.fit()` enforces both as runtime invariants and raises if +either is violated. Sign conventions are the classic silent-failure mode in +risk code — one estimator returns a signed return, another returns a loss, +someone patches it with abs(), and a confidence level is quietly wrong in one +branch forever. Encoding the convention as an assertion turns a methodology +error into an exception at fit time instead of a plausible-looking number in a +report. + +Methods +------- + historical : empirical (1-alpha) quantile of realised portfolio returns + parametric : closed-form Normal + monte_carlo : multivariate Normal simulation with covariance shrinkage + fhs : Filtered Historical Simulation (GARCH-standardised residuals) +""" + from __future__ import annotations -from dataclasses import dataclass + +from dataclasses import dataclass, asdict from math import sqrt from statistics import NormalDist -from typing import Dict, Any, Tuple +from typing import Any, Dict, Tuple + import numpy as np import pandas as pd +from .garch import garch11_filter, garch11_forecast_next + +__all__ = [ + "MarketRiskConfig", + "MarketRiskModel", + "portfolio_returns", + "normalize_weights", + "var_parametric", + "es_parametric", + "var_historical", + "es_historical", + "var_es_monte_carlo", + "mc_portfolio_loss_from_mu_cov", + "fhs_var_es_next", + "cov_shrink", + "METHODS", +] + +METHODS = ("historical", "parametric", "monte_carlo", "fhs") + +# stdlib Normal: keeps the core VaR math free of any SciPy dependency +_N = NormalDist() + + +def _z(alpha: float) -> float: + """Standard Normal quantile, Phi^{-1}(alpha).""" + return _N.inv_cdf(alpha) + + +def _phi(x: float) -> float: + """Standard Normal pdf, phi(x).""" + return _N.pdf(x) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- @dataclass class MarketRiskConfig: + """ + Complete specification of a market risk calculation. + + Every knob that changes a number lives here. That is deliberate: the config + is one serialisable object you can log, diff and attach to a result, which + is the difference between "we ran a VaR" and "we ran *this* VaR". + + Core + ---- + alpha : confidence level (e.g. 0.99) + window : rolling window for backtesting, in trading days + method : one of METHODS + horizon_days : holding period; mu scales by h, sigma by sqrt(h) + exposure : portfolio notional; results scale linearly + + Monte Carlo + ----------- + n_sims : simulation count + seed : RNG seed (fixed by default so results are reproducible) + shrink_lambda : covariance shrinkage toward the diagonal + + FHS / GARCH + ----------- + fit_garch : True -> estimate (omega, alpha, beta) by MLE + False -> variance targeting on alpha_g / beta_g below + alpha_g : ARCH parameter used when fit_garch=False + beta_g : GARCH parameter used when fit_garch=False + """ + alpha: float = 0.99 window: int = 250 - method: str = "historical" # historical | parametric | monte_carlo | fhs + method: str = "historical" horizon_days: int = 1 exposure: float = 1.0 - fit_garch: bool = False + n_sims: int = 100_000 + seed: int | None = 42 + shrink_lambda: float = 0.01 + + fit_garch: bool = False + alpha_g: float = 0.05 + beta_g: float = 0.94 + + def __post_init__(self) -> None: + if self.method not in METHODS: + raise ValueError(f"Unknown method {self.method!r}. Expected one of {METHODS}.") + if not 0.0 < self.alpha < 1.0: + raise ValueError(f"alpha must be in (0, 1); got {self.alpha}.") + if self.horizon_days < 1: + raise ValueError(f"horizon_days must be >= 1; got {self.horizon_days}.") + if self.exposure < 0: + raise ValueError(f"exposure must be non-negative; got {self.exposure}.") + if self.window < 2: + raise ValueError(f"window must be >= 2; got {self.window}.") + if not 0.0 <= self.shrink_lambda <= 1.0: + raise ValueError(f"shrink_lambda must be in [0, 1]; got {self.shrink_lambda}.") + if self.alpha_g + self.beta_g >= 1.0: + raise ValueError( + f"GARCH parameters must satisfy alpha + beta < 1 for stationarity; " + f"got {self.alpha_g} + {self.beta_g} = {self.alpha_g + self.beta_g}." + ) + + +# --------------------------------------------------------------------------- +# Shared primitives +# --------------------------------------------------------------------------- + +def normalize_weights(w: np.ndarray) -> np.ndarray: + """Scale weights to sum to 1. Returns unchanged if the sum is zero.""" + w = np.asarray(w, dtype=float) + s = float(np.sum(w)) + return (w / s) if s != 0 else w -# Convenience for Normal -_N = NormalDist() +def portfolio_returns(returns: pd.DataFrame, weights: np.ndarray) -> pd.Series: + """Project the multivariate return matrix onto portfolio weights: r_p = R @ w.""" + return pd.Series(returns.values @ np.asarray(weights, dtype=float), + index=returns.index, name="r_p") -def _z(alpha: float) -> float: - return _N.inv_cdf(alpha) +def _mu_cov(returns: pd.DataFrame, horizon_days: int) -> Tuple[np.ndarray, np.ndarray]: + """ + Horizon-scaled mean vector and covariance matrix. -def _phi(x: float) -> float: - return _N.pdf(x) + Under i.i.d. returns the h-day return is a sum of h daily returns, so means + add and variances add: mu * h and Sigma * h (hence sigma * sqrt(h)). This is + the square-root-of-time assumption, and it fails whenever volatility is + autocorrelated — which it always is. Documented as a known limitation. + """ + mu = returns.mean().values * horizon_days + cov = returns.cov().values * horizon_days + return mu, cov -def portfolio_returns(returns: pd.DataFrame, weights: np.ndarray) -> pd.Series: - return pd.Series(returns.values @ weights, index=returns.index, name="r_p") +def cov_shrink(cov: np.ndarray, lam: float = 0.01) -> np.ndarray: + """ + Shrink a covariance matrix toward its diagonal. + cov_s = (1 - lam) * cov + lam * diag(diag(cov)) -def var_parametric( - returns: pd.DataFrame, - weights: np.ndarray, - alpha: float = 0.95, - horizon_days: int = 1, - exposure: float = 1.0, -) -> float: - mu = returns.mean().values - cov = returns.cov().values - mu_p = float(weights @ mu) * horizon_days - sigma_p = float(np.sqrt(weights @ cov @ weights)) * sqrt(horizon_days) - z = _z(alpha) - loss_var = (-mu_p + z * sigma_p) * exposure - return float(loss_var) + On the diagonal this is (1-lam)*s_ii + lam*s_ii = s_ii, so VARIANCES ARE + UNCHANGED. Off the diagonal it is (1-lam)*s_ij, so every correlation is + multiplied by (1 - lam). + Why: the sample covariance is near-singular when the asset count is large + relative to the sample, or when two series are nearly collinear (SPY/QQQ at + rho = 0.86 is already close). A near-singular Sigma makes the Cholesky + factorisation fail or return garbage. Shrinking correlations pulls the + matrix away from the boundary of the PSD cone at negligible cost to the + risk estimate. + """ + d = np.diag(np.diag(cov)) + return (1.0 - lam) * cov + lam * d -def es_parametric( - returns: pd.DataFrame, - weights: np.ndarray, - alpha: float = 0.95, - horizon_days: int = 1, - exposure: float = 1.0, -) -> float: - mu = returns.mean().values - cov = returns.cov().values - mu_p = float(weights @ mu) * horizon_days - sigma_p = float(np.sqrt(weights @ cov @ weights)) * sqrt(horizon_days) + +# --------------------------------------------------------------------------- +# Parametric (Normal) +# --------------------------------------------------------------------------- + +def var_parametric(returns, weights, alpha=0.95, horizon_days=1, exposure=1.0) -> float: + """ + Closed-form Normal VaR. + + With R ~ N(mu_p, sigma_p^2) and loss L = -R, the alpha-quantile of L is + VaR = -mu_p + z_alpha * sigma_p + """ + w = np.asarray(weights, dtype=float) + mu, cov = _mu_cov(returns, horizon_days) + mu_p = float(w @ mu) + sigma_p = float(np.sqrt(w @ cov @ w)) + return float((-mu_p + _z(alpha) * sigma_p) * exposure) + + +def es_parametric(returns, weights, alpha=0.95, horizon_days=1, exposure=1.0) -> float: + """ + Closed-form Normal Expected Shortfall. + + ES = E[L | L > VaR] = -mu_p + sigma_p * phi(z_alpha) / (1 - alpha) + + ES is reported alongside VaR because VaR gives the threshold but says + nothing about severity beyond it, and is not subadditive. ES is coherent + and is the measure FRTB moved to. + """ + w = np.asarray(weights, dtype=float) + mu, cov = _mu_cov(returns, horizon_days) + mu_p = float(w @ mu) + sigma_p = float(np.sqrt(w @ cov @ w)) z = _z(alpha) - es = (-mu_p + sigma_p * (_phi(z) / (1 - alpha))) * exposure - return float(es) + return float((-mu_p + sigma_p * (_phi(z) / (1.0 - alpha))) * exposure) -def var_historical( - returns: pd.DataFrame, - weights: np.ndarray, - alpha: float = 0.95, - horizon_days: int = 1, - exposure: float = 1.0, - sqrt_time: bool = True, -) -> float: +# --------------------------------------------------------------------------- +# Historical simulation +# --------------------------------------------------------------------------- + +def _scaled_portfolio_returns(returns, weights, horizon_days, sqrt_time=True) -> pd.Series: r_p = portfolio_returns(returns, weights) if sqrt_time and horizon_days > 1: r_p = r_p * sqrt(horizon_days) - q = r_p.quantile(1 - alpha) - return float(-q * exposure) + return r_p -def es_historical( - returns: pd.DataFrame, - weights: np.ndarray, - alpha: float = 0.95, - horizon_days: int = 1, - exposure: float = 1.0, - sqrt_time: bool = True, -) -> float: - r_p = portfolio_returns(returns, weights) - if sqrt_time and horizon_days > 1: - r_p = r_p * sqrt(horizon_days) - var_loss = -r_p.quantile(1 - alpha) - tail = r_p[r_p <= -var_loss] - es = -tail.mean() if len(tail) else var_loss - return float(es * exposure) +def var_historical(returns, weights, alpha=0.95, horizon_days=1, + exposure=1.0, sqrt_time=True) -> float: + """ + Empirical (1-alpha) quantile of realised portfolio returns, sign-flipped. + Makes no distributional assumption: fat tails, skew and jumps are captured + to the extent they appear in the sample. That is also the weakness — it + cannot produce a loss it has never observed. + """ + r_p = _scaled_portfolio_returns(returns, weights, horizon_days, sqrt_time) + return float(-r_p.quantile(1.0 - alpha) * exposure) -def _cov_shrink(cov: np.ndarray, lam: float = 0.01) -> np.ndarray: - d = np.diag(np.diag(cov)) - return (1 - lam) * cov + lam * d +def es_historical(returns, weights, alpha=0.95, horizon_days=1, + exposure=1.0, sqrt_time=True) -> float: + """ + Mean of observations at or beyond the VaR threshold. -def var_es_monte_carlo( - returns: pd.DataFrame, + Falls back to VaR itself when the tail is empty (very high alpha on a short + sample), which keeps the ES >= VaR invariant intact instead of returning NaN. + """ + r_p = _scaled_portfolio_returns(returns, weights, horizon_days, sqrt_time) + q = r_p.quantile(1.0 - alpha) + tail = r_p[r_p <= q] + es = -tail.mean() if len(tail) else -q + return float(es * exposure) + + +# --------------------------------------------------------------------------- +# Monte Carlo +# --------------------------------------------------------------------------- + +def mc_portfolio_loss_from_mu_cov( + mu: np.ndarray, + cov: np.ndarray, weights: np.ndarray, alpha: float = 0.95, - horizon_days: int = 1, exposure: float = 1.0, n_sims: int = 100_000, seed: int | None = 42, - shrink_lambda: float = 0.01, ) -> Tuple[float, float]: + """ + THE Monte Carlo core. Simulates multivariate Normal returns for a given + (mu, Sigma) already scaled to the target horizon, and returns (VaR, ES) as + positive losses. + + Factored out from `var_es_monte_carlo` because every stress scenario works + by modifying mu or Sigma and re-simulating — covariance scaling, historical + window replay, correlation bumping. One simulator, many scenarios. + + An explicit seed makes results reproducible, which is non-negotiable if a + validator has to reproduce your numbers. `method="cholesky"` is materially + faster than the default SVD path and is exact when Sigma is positive + definite; if it is not, we fall back to the eigenvalue-based path rather + than raising. + """ rng = np.random.default_rng(seed) - mu = returns.mean().values * horizon_days - cov = returns.cov().values * horizon_days - cov = _cov_shrink(cov, lam=shrink_lambda) - - sims = rng.multivariate_normal(mu, cov, size=int(n_sims), method="cholesky") - port = sims @ weights - - q = np.quantile(port, 1 - alpha) + w = np.asarray(weights, dtype=float) + try: + sims = rng.multivariate_normal(mu, cov, size=int(n_sims), method="cholesky") + except np.linalg.LinAlgError: + sims = rng.multivariate_normal(mu, cov, size=int(n_sims), method="eigh") + + port = sims @ w + q = float(np.quantile(port, 1.0 - alpha)) var_loss = float(-q * exposure) - tail = port[port <= q] es_loss = float(-tail.mean() * exposure) if tail.size else var_loss return var_loss, es_loss -def garch11_filter( - r: pd.Series, - alpha_g: float = 0.05, - beta_g: float = 0.94, - long_run_var: float | None = None, - init_var: float | None = None, -) -> pd.Series: - r = r.dropna() - if long_run_var is None: - long_run_var = float(r.var(ddof=1)) if len(r) > 1 else float((r**2).mean()) - if init_var is None: - init_var = long_run_var - omega = max(1e-18, (1.0 - alpha_g - beta_g) * long_run_var) - - sig2 = np.empty(len(r), dtype=float) - sig2[0] = max(1e-18, init_var) - r2 = r.values**2 - for t in range(1, len(r)): - sig2[t] = omega + alpha_g * r2[t - 1] + beta_g * sig2[t - 1] - sigma = np.sqrt(sig2) - return pd.Series(sigma, index=r.index, name="sigma_garch") - - -def garch11_forecast_sigma_next( - r_last: float, - sigma_last: float, - alpha_g: float, - beta_g: float, - long_run_var: float, -) -> float: - omega = max(1e-18, (1.0 - alpha_g - beta_g) * long_run_var) - sig2_next = omega + alpha_g * (r_last**2) + beta_g * (sigma_last**2) - return float(np.sqrt(max(sig2_next, 1e-18))) +def var_es_monte_carlo( + returns: pd.DataFrame, + weights: np.ndarray, + alpha: float = 0.95, + horizon_days: int = 1, + exposure: float = 1.0, + n_sims: int = 100_000, + seed: int | None = 42, + shrink_lambda: float = 0.01, +) -> Tuple[float, float]: + """Monte Carlo VaR/ES estimated from the sample mu and Sigma.""" + mu, cov = _mu_cov(returns, horizon_days) + cov = cov_shrink(cov, lam=shrink_lambda) + return mc_portfolio_loss_from_mu_cov( + mu, cov, weights, alpha=alpha, exposure=exposure, n_sims=n_sims, seed=seed + ) + +# --------------------------------------------------------------------------- +# Filtered Historical Simulation +# --------------------------------------------------------------------------- def fhs_var_es_next( returns: pd.DataFrame, @@ -170,89 +336,139 @@ def fhs_var_es_next( exposure: float = 1.0, alpha_g: float = 0.05, beta_g: float = 0.94, - fit_garch: bool = False, # ← new: True = MLE params, False = use alpha_g/beta_g -) -> Tuple[float, float]: - r_p = pd.Series(returns.values @ weights, index=returns.index, name="r_p").dropna() - if len(r_p) < 50: - q = r_p.quantile(1 - alpha) + fit_garch: bool = False, + min_obs: int = 50, + return_info: bool = False, +): + """ + Filtered Historical Simulation VaR/ES for t+1 (one-day horizon). + + Procedure + --------- + 1. r_p = R @ w + 2. filter to obtain sigma_t + 3. standardise: z_t = r_t / sigma_t + 4. q_z = empirical (1-alpha) quantile of z; tail mean of z for ES + 5. forecast sigma_{t+1} and rescale: + VaR = -q_z * sigma_{t+1} * exposure + + Why this beats both parents. Historical simulation keeps the empirical tail + shape but treats a calm 2017 day and a panicked March-2020 day as equally + informative. Parametric responds to current volatility but forces Normal + tails. FHS keeps the empirical (fat, skewed) tail shape *of the + standardised residuals* while letting the scale track today's volatility. + + Below `min_obs` observations the GARCH filter is noise, so the function + degrades to plain historical simulation. + """ + r_p = portfolio_returns(returns, weights).dropna() + + if len(r_p) < min_obs: + q = r_p.quantile(1.0 - alpha) var_loss = float(-q * exposure) tail = r_p[r_p <= q] es_loss = float(-tail.mean() * exposure) if len(tail) else var_loss - return var_loss, es_loss - - if fit_garch: - # MLE-estimated parameters — imports from new garch_mle module - from risklib.market.garch_mle import garch11_filter_mle, garch11_forecast_next - sigma, fit_info = garch11_filter_mle(r_p, fit=True) - omega = fit_info["omega"] - alpha_g = fit_info["alpha_g"] - beta_g = fit_info["beta_g"] - sigma_safe = sigma.replace(0.0, np.nan).bfill().ffill() - z = (r_p / sigma_safe).dropna() - qz = z.quantile(1 - alpha) - tail_z = z[z <= qz] - z_es = tail_z.mean() if len(tail_z) else qz - sigma_next = garch11_forecast_next( - float(r_p.iloc[-1]), float(sigma.iloc[-1]), omega, alpha_g, beta_g - ) - else: - # Original fixed-parameter path — unchanged behaviour - long_var = float(r_p.var(ddof=1)) - sigma = garch11_filter(r_p, alpha_g=alpha_g, beta_g=beta_g, long_run_var=long_var) - sigma_safe = sigma.replace(0.0, np.nan).bfill().ffill() - z = (r_p / sigma_safe).dropna() - qz = z.quantile(1 - alpha) - tail_z = z[z <= qz] - z_es = tail_z.mean() if len(tail_z) else qz - sigma_next = garch11_forecast_sigma_next( - float(r_p.iloc[-1]), float(sigma.iloc[-1]), alpha_g, beta_g, long_run_var=long_var - ) - + info = {"source": "insufficient_history", "n_obs": len(r_p)} + return (var_loss, es_loss, info) if return_info else (var_loss, es_loss) + + sigma, info = garch11_filter(r_p, fit=fit_garch, alpha_g=alpha_g, beta_g=beta_g) + + # Guard against a degenerate sigma before dividing. + sigma_safe = sigma.replace(0.0, np.nan).bfill().ffill() + z = (r_p / sigma_safe).dropna() + + qz = float(z.quantile(1.0 - alpha)) + tail_z = z[z <= qz] + z_es = float(tail_z.mean()) if len(tail_z) else qz + + sigma_next = garch11_forecast_next( + float(r_p.iloc[-1]), float(sigma.iloc[-1]), + info["omega"], info["alpha_g"], info["beta_g"], + ) + var_loss = float(-qz * sigma_next * exposure) - es_loss = float(-z_es * sigma_next * exposure) - return var_loss, es_loss - + es_loss = float(-z_es * sigma_next * exposure) + info = {**info, "sigma_next": sigma_next, "q_z": qz, "z_es": z_es} + return (var_loss, es_loss, info) if return_info else (var_loss, es_loss) + +# --------------------------------------------------------------------------- +# Model object +# --------------------------------------------------------------------------- class MarketRiskModel: """ - Validation-ready Market Risk engine. - Outputs are positive loss numbers: VaR >= 0, ES >= VaR. + Validation-ready market risk engine. + + Usage + ----- + cfg = MarketRiskConfig(alpha=0.99, method="fhs", exposure=1_000_000) + model = MarketRiskModel(returns, weights, cfg) + model.fit() + model.compute_var(), model.compute_es(), model.summary() """ def __init__(self, returns: pd.DataFrame, weights: np.ndarray, config: MarketRiskConfig): self.returns = returns.dropna() self.weights = np.asarray(weights, dtype=float) self.config = config - self.var_ = None - self.es_ = None - - def fit(self) -> None: - a = self.config.alpha - h = self.config.horizon_days - e = self.config.exposure - m = self.config.method + self.var_: float | None = None + self.es_: float | None = None + self.fit_info_: Dict[str, Any] = {} + + if self.returns.empty: + raise ValueError("`returns` is empty after dropping NaN rows.") + if self.weights.shape[0] != self.returns.shape[1]: + raise ValueError( + f"weights length ({self.weights.shape[0]}) does not match the number " + f"of assets in returns ({self.returns.shape[1]})." + ) + + def fit(self) -> "MarketRiskModel": + cfg = self.config + a, h, e, m = cfg.alpha, cfg.horizon_days, cfg.exposure, cfg.method if m == "historical": self.var_ = var_historical(self.returns, self.weights, alpha=a, horizon_days=h, exposure=e) self.es_ = es_historical(self.returns, self.weights, alpha=a, horizon_days=h, exposure=e) + elif m == "parametric": self.var_ = var_parametric(self.returns, self.weights, alpha=a, horizon_days=h, exposure=e) self.es_ = es_parametric(self.returns, self.weights, alpha=a, horizon_days=h, exposure=e) + elif m == "monte_carlo": - self.var_, self.es_ = var_es_monte_carlo(self.returns, self.weights, alpha=a, horizon_days=h, exposure=e) + # Config values are threaded through — previously these were the + # function defaults regardless of what the caller configured. + self.var_, self.es_ = var_es_monte_carlo( + self.returns, self.weights, alpha=a, horizon_days=h, exposure=e, + n_sims=cfg.n_sims, seed=cfg.seed, shrink_lambda=cfg.shrink_lambda, + ) + elif m == "fhs": - self.var_, self.es_ = fhs_var_es_next(self.returns, self.weights, alpha=a, exposure=e) + # FHS is a one-step-ahead forecast: horizon_days does not apply. + self.var_, self.es_, self.fit_info_ = fhs_var_es_next( + self.returns, self.weights, alpha=a, exposure=e, + alpha_g=cfg.alpha_g, beta_g=cfg.beta_g, + fit_garch=cfg.fit_garch, return_info=True, + ) else: raise ValueError(f"Unknown method: {m}") - # Enforce loss convention invariants (model risk sanity) self.var_ = float(self.var_) self.es_ = float(self.es_) + + # Loss-convention invariants if self.var_ < 0: - raise ValueError("VaR must be non-negative under loss convention.") + raise ValueError( + f"VaR must be non-negative under the loss convention; got {self.var_:.6g} " + f"(method={m}, alpha={a})." + ) if self.es_ < self.var_: - raise ValueError("ES must be >= VaR under loss convention.") + raise ValueError( + f"ES must be >= VaR under the loss convention; got ES={self.es_:.6g} " + f"< VaR={self.var_:.6g} (method={m}, alpha={a})." + ) + return self def compute_var(self) -> float: if self.var_ is None: @@ -265,23 +481,54 @@ def compute_es(self) -> float: return self.es_ def assumptions(self) -> list: - base = ["iid returns", "stationarity within rolling window"] - if self.config.method == "parametric": - base.append("normality") - if self.config.method in ("monte_carlo",): - base.append("multivariate normal joint distribution") - if self.config.method in ("fhs",): - base.append("fixed-parameter GARCH(1,1) volatility filter") + """ + Assumptions actually in force for this configuration. + + Branches on fit_garch so an MLE-estimated filter is not reported as a + fixed-parameter one. + """ + base = ["iid returns", "stationarity within estimation window"] + m = self.config.method + + if self.config.horizon_days > 1 and m in ("historical", "parametric", "monte_carlo"): + base.append("square-root-of-time horizon scaling") + if m == "parametric": + base.append("normality of portfolio returns") + if m == "monte_carlo": + base += ["multivariate normal joint distribution", + f"covariance shrinkage lambda={self.config.shrink_lambda}"] + if m == "fhs": + if self.config.fit_garch: + base.append("GARCH(1,1) volatility filter, parameters estimated by MLE") + if self.fit_info_.get("source") == "fixed": + base.append("MLE requested but fell back to fixed parameters") + else: + base.append( + f"GARCH(1,1) volatility filter, fixed parameters " + f"(alpha={self.config.alpha_g}, beta={self.config.beta_g})" + ) + base.append("zero conditional mean") return base def summary(self) -> Dict[str, Any]: - return { + """ + VaR, ES, the full configuration, and the assumptions in force. + + The assumptions travel with the result rather than living only in a + README someone forgot to update. A risk number without its assumptions + is not a deliverable. + """ + out: Dict[str, Any] = { "VaR": self.var_, "ES": self.es_, - "alpha": self.config.alpha, - "window": self.config.window, - "method": self.config.method, - "horizon_days": self.config.horizon_days, - "exposure": self.config.exposure, + "config": asdict(self.config), "assumptions": self.assumptions(), + "n_obs": int(len(self.returns)), + "n_assets": int(self.returns.shape[1]), } + # Flattened for backwards compatibility with existing callers. + out.update({k: getattr(self.config, k) + for k in ("alpha", "window", "method", "horizon_days", "exposure")}) + if self.fit_info_: + out["fit_info"] = self.fit_info_ + return out diff --git a/risklib/market/scenarios.py b/risklib/market/scenarios.py new file mode 100644 index 0000000..820e185 --- /dev/null +++ b/risklib/market/scenarios.py @@ -0,0 +1,356 @@ +""" +risklib/market/scenarios.py +=========================== +Stress testing and deterministic scenario analysis. + +Merged from the former `risk_engine/stress.py` and `risk_engine/scenarios.py`, +which had drifted into two incompatible conventions for the same question. + +Convention for unshocked instruments +------------------------------------ +Instruments not named in a shock are held FLAT (zero return). This is the +standard "everything else unchanged" reading of a stress scenario, and it is +now applied uniformly. + +The previous `apply_single_name_shocks` instead carried each unshocked +instrument's LAST OBSERVED RETURN into the scenario, so a single-name stress +was contaminated by whatever the rest of the book happened to do on the final +day of the sample. That behaviour is still reachable via `base="last"`, but it +is no longer the default and the choice is now explicit and reported. + +Scenario types +-------------- + single-name shocks — direct moves in return space + rate shocks — duration approximation, dP/P ~ -D * dy + covariance scaling — uniform volatility stress + correlation bumps — diversification-breakdown stress + historical window replay — re-run today's book through a past regime +""" + +from __future__ import annotations + +from typing import Dict, Iterable, Tuple + +import numpy as np +import pandas as pd + +from .market_risk_model import mc_portfolio_loss_from_mu_cov + +__all__ = [ + "shock_vector", + "portfolio_loss_from_shocks", + "scenario_single_name", + "scenario_equities_shock", + "scenario_rates_bp", + "scale_covariance", + "scenario_covariance_scale", + "scenario_corr_bump_mc", + "historical_window_mu_cov", + "scenario_historical_replay", + "DEFAULT_DURATIONS", +] + +# Modified duration, years. Used only for instruments explicitly identified as +# rate-sensitive; everything else defaults to zero duration (see scenario_rates_bp). +DEFAULT_DURATIONS: Dict[str, float] = { + "TLT": 18.0, "EDV": 24.0, "ZROZ": 27.0, "IEF": 7.5, + "AGG": 6.5, "BND": 6.5, "SHY": 1.9, "TIP": 6.8, "LQD": 8.4, "HYG": 3.5, +} + + +# --------------------------------------------------------------------------- +# Core shock application +# --------------------------------------------------------------------------- + +def shock_vector( + returns: pd.DataFrame, + shocks: Dict[str, float], + base: str = "flat", +) -> pd.Series: + """ + Build a one-period shock vector aligned to `returns.columns`. + + Parameters + ---------- + base : "flat" -> unshocked instruments return 0.0 (default, standard) + "last" -> unshocked instruments keep their last observed return + + Returns a Series indexed by ticker, so downstream code cannot silently + misalign it against the weight vector. + """ + if base not in ("flat", "last"): + raise ValueError(f"base must be 'flat' or 'last'; got {base!r}.") + + if base == "last": + v = returns.iloc[-1].astype(float).copy() + else: + v = pd.Series(0.0, index=returns.columns, dtype=float) + + unknown = set(shocks) - set(returns.columns) + if unknown: + raise KeyError(f"Shocked tickers not present in returns: {sorted(unknown)}") + + for k, val in shocks.items(): + v[k] = float(val) + return v + + +def portfolio_loss_from_shocks( + returns: pd.DataFrame, + weights: np.ndarray, + shocks: Dict[str, float], + exposure: float = 1.0, + base: str = "flat", +) -> float: + """Portfolio loss (positive) implied by a shock vector.""" + v = shock_vector(returns, shocks, base=base) + port_ret = float(v.values @ np.asarray(weights, dtype=float)) + return float(-port_ret * exposure) + + +def scenario_single_name( + returns: pd.DataFrame, + weights: np.ndarray, + shocks: Dict[str, float], + exposure: float = 1.0, + base: str = "flat", +) -> Dict: + """ + Single-name (or multi-name) shock in return space. + + Returns the loss plus the shock vector actually applied and the base + convention used, so the scenario is self-documenting in an exported report. + """ + v = shock_vector(returns, shocks, base=base) + port_ret = float(v.values @ np.asarray(weights, dtype=float)) + return { + "loss": float(-port_ret * exposure), + "portfolio_return": port_ret, + "shock_vector": v, + "base": base, + } + + +def scenario_equities_shock( + returns: pd.DataFrame, + weights: np.ndarray, + equities: Iterable[str], + shock: float = -0.05, + exposure: float = 1.0, +) -> float: + """Uniform shock applied to a named set of equity instruments; others flat.""" + present = [e for e in equities if e in returns.columns] + return portfolio_loss_from_shocks( + returns, weights, {e: shock for e in present}, exposure=exposure, base="flat" + ) + + +# --------------------------------------------------------------------------- +# Rates +# --------------------------------------------------------------------------- + +def scenario_rates_bp( + returns: pd.DataFrame, + weights: np.ndarray, + durations: Dict[str, float] | None = None, + bp: float = 200.0, + exposure: float = 1.0, + default_duration: float = 0.0, + use_default_map: bool = True, +) -> Dict: + """ + Parallel interest rate shock via the first-order duration approximation: + + dP/P ~ -D * dy + + `default_duration` is 0.0. This is a deliberate correction: the previous + implementation defaulted every unmapped instrument to 7.0 years, so a +200bp + scenario knocked roughly 14% off SPY. A parallel rate shock does not do that + to equities. Instruments now contribute only if a duration is supplied for + them, explicitly or through DEFAULT_DURATIONS. + + Limitation: the approximation is linear and ignores convexity. At 200bp on + an 18-year-duration bond the omitted second-order term is material and the + linear estimate OVERSTATES the loss. Stated rather than silently absorbed. + + Returns the loss plus the duration map actually used. + """ + dur: Dict[str, float] = dict(DEFAULT_DURATIONS) if use_default_map else {} + if durations: + dur.update({k: float(v) for k, v in durations.items()}) + + dy = float(bp) / 10_000.0 + applied = {c: dur.get(c, default_duration) for c in returns.columns} + shocks = {c: -D * dy for c, D in applied.items() if D != 0.0} + + return { + "loss": portfolio_loss_from_shocks(returns, weights, shocks, + exposure=exposure, base="flat"), + "durations_used": applied, + "bp": bp, + "dy": dy, + } + + +# --------------------------------------------------------------------------- +# Covariance stresses +# --------------------------------------------------------------------------- + +def scale_covariance(cov: np.ndarray, scale: float) -> np.ndarray: + """ + Uniformly scale a covariance matrix. + + Multiplying Sigma by k multiplies every VOLATILITY by sqrt(k) and leaves + all CORRELATIONS unchanged. So "covariance x2" means "volatility x1.41, + same correlation structure" — worth stating explicitly, because a reader + will otherwise assume x2 doubles the vol. + """ + return np.asarray(cov, dtype=float) * float(scale) + + +def scenario_covariance_scale( + returns: pd.DataFrame, + weights: np.ndarray, + scale: float = 2.0, + alpha: float = 0.99, + horizon_days: int = 1, + exposure: float = 1.0, + n_sims: int = 50_000, + seed: int = 7, +) -> Dict: + """Re-run Monte Carlo VaR/ES with a uniformly scaled covariance matrix.""" + mu = returns.mean().values * horizon_days + cov = returns.cov().values * horizon_days + + base = mc_portfolio_loss_from_mu_cov(mu, cov, weights, alpha=alpha, + exposure=exposure, n_sims=n_sims, seed=seed) + stressed = mc_portfolio_loss_from_mu_cov(mu, scale_covariance(cov, scale), weights, + alpha=alpha, exposure=exposure, + n_sims=n_sims, seed=seed) + return { + "base": {"VaR": base[0], "ES": base[1]}, + "stressed": {"VaR": stressed[0], "ES": stressed[1]}, + "delta_VaR": stressed[0] - base[0], + "vol_multiplier": float(np.sqrt(scale)), + "cov_scale": scale, + } + + +def _nearest_psd(matrix: np.ndarray, floor: float = 1e-12) -> Tuple[np.ndarray, bool]: + """ + Project a symmetric matrix onto the PSD cone by clipping eigenvalues. + + Returns (projected, was_adjusted). + """ + sym = (matrix + matrix.T) / 2.0 + vals, vecs = np.linalg.eigh(sym) + if vals.min() >= floor: + return sym, False + vals_clipped = np.clip(vals, floor, None) + return vecs @ np.diag(vals_clipped) @ vecs.T, True + + +def scenario_corr_bump_mc( + returns: pd.DataFrame, + weights: np.ndarray, + alpha: float = 0.99, + horizon_days: int = 1, + exposure: float = 1.0, + corr_bump_pct: float = 50.0, + n_sims: int = 50_000, + seed: int = 7, +) -> Dict: + """ + Correlation-breakdown stress: multiply off-diagonal correlations by + (1 + corr_bump_pct/100), clip to [-0.99, 0.99], hold volatilities constant, + and re-simulate. + + Holding vols constant isolates the diversification channel. In a crisis + correlations converge toward 1 and a "diversified" portfolio turns out to + be one bet; this scenario measures exactly that, uncontaminated by a + simultaneous volatility move. + + The same seed is used for base and stressed runs, so the difference is a + pure covariance effect rather than Monte Carlo noise. + + Element-wise correlation bumping does NOT preserve positive semi-definiteness. + The bumped matrix is therefore projected back onto the PSD cone via + eigenvalue clipping, and `psd_adjusted` reports whether that was necessary — + a large bump that requires adjustment is a signal the scenario is straining + the correlation structure. + """ + mu = returns.mean().values * horizon_days + cov = returns.cov().values * horizon_days + + sig = np.sqrt(np.diag(cov)) + sig[sig == 0] = 1e-12 + corr = cov / np.outer(sig, sig) + + bump = 1.0 + corr_bump_pct / 100.0 + corr_b = np.clip(corr * bump, -0.99, 0.99) + np.fill_diagonal(corr_b, 1.0) + + corr_b, psd_adjusted = _nearest_psd(corr_b) + np.fill_diagonal(corr_b, 1.0) + cov_bumped = np.outer(sig, sig) * corr_b + + base = mc_portfolio_loss_from_mu_cov(mu, cov, weights, alpha=alpha, + exposure=exposure, n_sims=n_sims, seed=seed) + stressed = mc_portfolio_loss_from_mu_cov(mu, cov_bumped, weights, alpha=alpha, + exposure=exposure, n_sims=n_sims, seed=seed) + return { + "base": {"VaR": base[0], "ES": base[1]}, + "stressed": {"VaR": stressed[0], "ES": stressed[1]}, + "delta_VaR": stressed[0] - base[0], + "corr_bump_pct": corr_bump_pct, + "psd_adjusted": psd_adjusted, + } + + +# --------------------------------------------------------------------------- +# Historical replay +# --------------------------------------------------------------------------- + +def historical_window_mu_cov( + returns: pd.DataFrame, + start: str, + end: str, + min_obs: int = 5, +) -> Tuple[np.ndarray, np.ndarray]: + """Mean vector and covariance from a historical sub-window (inclusive dates).""" + sub = returns.loc[start:end] + if len(sub) < min_obs: + raise ValueError( + f"Selected window has {len(sub)} observations; need at least {min_obs} " + "to estimate mu and Sigma." + ) + return sub.mean().values, sub.cov().values + + +def scenario_historical_replay( + returns: pd.DataFrame, + weights: np.ndarray, + start: str, + end: str, + alpha: float = 0.99, + horizon_days: int = 1, + exposure: float = 1.0, + n_sims: int = 50_000, + seed: int = 11, +) -> Dict: + """ + Re-run today's portfolio through a past statistical regime: keep the current + weights, adopt the selected window's mu and Sigma. + """ + mu_w, cov_w = historical_window_mu_cov(returns, start, end) + var_h, es_h = mc_portfolio_loss_from_mu_cov( + mu_w * horizon_days, cov_w * horizon_days, weights, + alpha=alpha, exposure=exposure, n_sims=n_sims, seed=seed, + ) + return { + "VaR": var_h, + "ES": es_h, + "start": str(start), + "end": str(end), + "n_obs": int(len(returns.loc[start:end])), + } diff --git a/scripts/generate_validation_results.py b/scripts/generate_validation_results.py new file mode 100644 index 0000000..5a0d4a9 --- /dev/null +++ b/scripts/generate_validation_results.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +""" +scripts/generate_validation_results.py +====================================== +Regenerate every published validation figure from the demo data in `data/`. + +Why this exists +--------------- +The results previously quoted in the README were computed on a four-asset +SPY/QQQ/TLT/GLD portfolio that was never committed to the repository — the +shipped demo file holds different instruments entirely. Anyone cloning the +project therefore could not reproduce a single published number, which is the +one thing a model validator will actually try to do. + +Every figure in `docs/validation_results.md` is now produced by this script from +`data/market_data.csv` and `data/credit_example.csv`. All random draws are +seeded, so the output is deterministic. + +Usage +----- + python scripts/generate_validation_results.py # regenerate + python scripts/generate_validation_results.py --check # verify, exit 1 on drift + +CI runs `--check`, so published results cannot silently drift away from the code +that produced them. +""" + +from __future__ import annotations + +import argparse +import sys +from datetime import date +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from risklib.credit import compute_el_table, summarize_el # noqa: E402 +from risklib.data import load_prices, to_returns # noqa: E402 +from risklib.market import ( # noqa: E402 + DEFAULT_DURATIONS, + MarketRiskConfig, + MarketRiskModel, + backtest_var_fhs, + backtest_var_historical, + erc_weights, + scenario_corr_bump_mc, + scenario_covariance_scale, + scenario_equities_shock, + scenario_rates_bp, + var_parametric_normal_parts, +) + +DATA = ROOT / "data" +OUT = ROOT / "docs" / "validation_results.md" + +EXPOSURE = 1_000_000.0 +SEED = 42 +N_SIMS = 100_000 +WINDOW = 250 +ALPHAS = (0.95, 0.975, 0.99) +TRADING_DAYS = 252 + +# Relative tolerance for the Euler summation identity. Component VaR must sum to +# portfolio VaR to within this bound on any platform; the residual itself is +# machine noise and is deliberately not published. +EULER_TOL = 1e-9 + +METHOD_LABELS = { + "historical": "Historical Simulation", + "parametric": "Parametric (Normal)", + "monte_carlo": "Monte Carlo", + "fhs": "Filtered Historical (GARCH, fixed)", +} + + +def verdict(p: float) -> str: + return "PASS" if p > 0.05 else "FAIL" + + +def build_report() -> str: + prices = load_prices(DATA / "market_data.csv") + returns = to_returns(prices, method="log") + tickers = list(returns.columns) + weights = np.ones(len(tickers)) / len(tickers) + + L: list[str] = [] + add = L.append + + add("# Validation Results") + add("") + add("> **Generated by `scripts/generate_validation_results.py`. Do not edit by hand.**") + add("> Every figure below is reproducible from the data committed in `data/`:") + add("> `python scripts/generate_validation_results.py`") + add("") + + # ------------------------------------------------------------------ + add("## Test portfolio") + add("") + add(f"- **Instruments:** {', '.join(tickers)} (equal-weighted, " + f"{1 / len(tickers):.1%} each)") + add(f"- **Exposure:** ${EXPOSURE:,.0f}") + add(f"- **Sample:** {returns.index.min():%Y-%m-%d} to {returns.index.max():%Y-%m-%d} " + f"({len(returns):,} daily log-return observations)") + add(f"- **Monte Carlo:** {N_SIMS:,} paths, seed {SEED}") + add(f"- **Backtest window:** {WINDOW} trading days") + add("") + + r_p = pd.Series(returns.values @ weights, index=returns.index) + ann_ret = float(r_p.mean() * TRADING_DAYS) + ann_vol = float(r_p.std(ddof=1) * np.sqrt(TRADING_DAYS)) + + add("### Portfolio statistics") + add("") + add("| Metric | Value |") + add("|---|---:|") + add(f"| Annualised return | {ann_ret:.2%} |") + add(f"| Annualised volatility | {ann_vol:.2%} |") + add(f"| Sharpe ratio (RF = 0) | {ann_ret / ann_vol:.2f} |") + for t in tickers: + v = float(returns[t].std(ddof=1) * np.sqrt(TRADING_DAYS)) + add(f"| {t} annualised volatility | {v:.2%} |") + add("") + + add("### Correlation matrix") + add("") + corr = returns.corr() + add("| | " + " | ".join(tickers) + " |") + add("|---|" + "---:|" * len(tickers)) + for t in tickers: + add(f"| **{t}** | " + " | ".join(f"{corr.loc[t, c]:.3f}" for c in tickers) + " |") + add("") + + # ------------------------------------------------------------------ + add("## 1. Point risk measures (1-day horizon)") + add("") + add("| Method | VaR 95% | ES 95% | VaR 99% | ES 99% |") + add("|---|---:|---:|---:|---:|") + for method, label in METHOD_LABELS.items(): + cells = [] + for a in (0.95, 0.99): + cfg = MarketRiskConfig(alpha=a, method=method, horizon_days=1, + exposure=EXPOSURE, n_sims=N_SIMS, seed=SEED) + m = MarketRiskModel(returns, weights, cfg).fit() + cells += [f"${m.compute_var():,.0f}", f"${m.compute_es():,.0f}"] + add(f"| {label} | {cells[0]} | {cells[1]} | {cells[2]} | {cells[3]} |") + + # MLE row + mle_cells = [] + for a in (0.95, 0.99): + cfg = MarketRiskConfig(alpha=a, method="fhs", horizon_days=1, + exposure=EXPOSURE, fit_garch=True) + m = MarketRiskModel(returns, weights, cfg).fit() + mle_cells += [f"${m.compute_var():,.0f}", f"${m.compute_es():,.0f}"] + add(f"| Filtered Historical (GARCH, MLE) | {mle_cells[0]} | {mle_cells[1]} | " + f"{mle_cells[2]} | {mle_cells[3]} |") + add("") + add("ES exceeds VaR at every confidence level for every method, as the loss-convention " + "invariant in `MarketRiskModel.fit()` requires.") + add("") + + # ------------------------------------------------------------------ + add("## 2. GARCH(1,1) parameters — assumed vs estimated") + add("") + cfg_fixed = MarketRiskConfig(method="fhs", alpha=0.99, exposure=EXPOSURE, fit_garch=False) + cfg_mle = MarketRiskConfig(method="fhs", alpha=0.99, exposure=EXPOSURE, fit_garch=True) + fi_fixed = MarketRiskModel(returns, weights, cfg_fixed).fit().fit_info_ + m_mle = MarketRiskModel(returns, weights, cfg_mle).fit() + fi_mle = m_mle.fit_info_ + + add("| Parameter | Fixed (variance targeting) | MLE |") + add("|---|---:|---:|") + add(f"| omega | {fi_fixed['omega']:.3e} | {fi_mle['omega']:.3e} |") + add(f"| alpha (ARCH) | {fi_fixed['alpha_g']:.4f} | {fi_mle['alpha_g']:.4f} |") + add(f"| beta (GARCH) | {fi_fixed['beta_g']:.4f} | {fi_mle['beta_g']:.4f} |") + add(f"| persistence | {fi_fixed['persistence']:.4f} | {fi_mle['persistence']:.4f} |") + add(f"| long-run volatility (ann.) | {fi_fixed['long_run_vol']:.2%} | " + f"{fi_mle['long_run_vol']:.2%} |") + add(f"| log-likelihood | — | {fi_mle['log_likelihood']:,.1f} |") + add(f"| AIC / BIC | — | {fi_mle['aic']:,.1f} / {fi_mle['bic']:,.1f} |") + add("") + add(f"The assumed parameters imply persistence of {fi_fixed['persistence']:.4f}; the " + f"estimated value is {fi_mle['persistence']:.4f}. The fixed convention therefore " + "overstates volatility persistence on this sample, which is precisely the " + "misspecification the MLE path was added to address.") + add("") + add("**Assumptions reported by the model object under MLE:**") + add("") + for a in m_mle.assumptions(): + add(f"- {a}") + add("") + + # ------------------------------------------------------------------ + add("## 3. Backtesting") + add("") + add("Thresholds use information available strictly through *t−1*. Three tests are " + "reported: **Kupiec POF** (unconditional coverage), **Christoffersen** " + "(independence / no clustering), and **joint conditional coverage** " + "(LR_cc = LR_uc + LR_ind ~ chi-squared with 2 df).") + add("") + + for name, fn in (("Rolling Historical Simulation", backtest_var_historical), + ("Rolling Filtered Historical Simulation", backtest_var_fhs)): + add(f"### {name} ({WINDOW}-day window)") + add("") + add("| α | T | Exc. | Hit % | Exp. % | Kupiec LR | p | Christ. LR | p | " + "Joint LR | p | Result |") + add("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|") + for a in ALPHAS: + b = fn(returns, weights, alpha=a, window=WINDOW) + allp = min(b["kupiec_pvalue"], b["christoffersen_pvalue"], b["joint_pvalue"]) + add(f"| {a:.1%} | {b['T']:,} | {b['exceedances']} | {b['hit_rate'] * 100:.2f}% | " + f"{(1 - a) * 100:.2f}% | {b['kupiec_LR']:.3f} | {b['kupiec_pvalue']:.4f} | " + f"{b['christoffersen_LR']:.3f} | {b['christoffersen_pvalue']:.4f} | " + f"{b['joint_LR']:.3f} | {b['joint_pvalue']:.4f} | {verdict(allp)} |") + add("") + + b95 = backtest_var_historical(returns, weights, alpha=0.95, window=WINDOW) + tr = b95["transitions"] + add("**Exception transition counts (Historical, α = 95%)**") + add("") + add("| | → no exception | → exception |") + add("|---|---:|---:|") + add(f"| **no exception →** | {tr['n00']} | {tr['n01']} |") + add(f"| **exception →** | {tr['n10']} | {tr['n11']} |") + add("") + add(f"P(exception | no exception) = {tr['pi_01']:.4f}; " + f"P(exception | exception) = {tr['pi_11']:.4f}. Under independence these are " + "approximately equal; a materially larger second figure indicates clustering.") + add("") + + # ------------------------------------------------------------------ + add("## 4. VaR decomposition — Euler allocation (Normal, α = 95%)") + add("") + parts = var_parametric_normal_parts(returns, weights, alpha=0.95, + horizon_days=1, exposure=EXPOSURE) + add("| Instrument | Weight | Marginal VaR | Component VaR | % of VaR |") + add("|---|---:|---:|---:|---:|") + for t, w, mv, cv, pc in zip(tickers, parts["w"], parts["mVaR"], + parts["cVaR"], parts["pContrib"]): + add(f"| {t} | {w:.1%} | ${mv:,.0f} | ${cv:,.0f} | {pc * 100:.1f}% |") + add(f"| **Total** | **100.0%** | | **${parts['cVaR'].sum():,.0f}** | " + f"**{parts['pContrib'].sum() * 100:.1f}%** |") + add("") + # The Euler residual is exactly the kind of number that must NOT be published: + # its value is pure floating-point noise and varies with CPU architecture and + # BLAS backend (observed: 3.6e-12 on Linux/OpenBLAS, 0.0 on Apple Accelerate). + # Printing it made the reproducibility check fail across platforms for no real + # reason. Assert the bound instead, and publish the bound. + euler_residual = abs(float(parts["cVaR"].sum()) - float(parts["VaR"])) + euler_rel = euler_residual / abs(parts["VaR"]) if parts["VaR"] else 0.0 + if euler_rel >= EULER_TOL: + raise AssertionError( + f"Euler identity violated: component VaR sums to {parts['cVaR'].sum():.10f} " + f"against portfolio VaR {parts['VaR']:.10f} (relative error {euler_rel:.2e}, " + f"tolerance {EULER_TOL:.0e}). This is a real decomposition error, not noise." + ) + add(f"Component VaR sums to ${parts['cVaR'].sum():,.2f} against a portfolio VaR of " + f"${parts['VaR']:,.2f} — agreement to within {EULER_TOL:.0e} relative, verified " + "when this report is generated. The identity is exact, not approximate: VaR is " + "homogeneous of degree 1 in the weights, so Euler's theorem holds with equality.") + add("") + + # ------------------------------------------------------------------ + add("## 5. Risk budgeting — Equal Risk Contribution") + add("") + w_erc, info = erc_weights(returns, horizon_days=1, tol=1e-10) + p_erc = var_parametric_normal_parts(returns, w_erc, alpha=0.95, + horizon_days=1, exposure=EXPOSURE) + add("| Instrument | Weight (equal) | Weight (ERC) | % of VaR (equal) | % of VaR (ERC) |") + add("|---|---:|---:|---:|---:|") + for t, we, wr, pe, pr in zip(tickers, weights, w_erc, + parts["pContrib"], p_erc["pContrib"]): + add(f"| {t} | {we:.1%} | {wr:.1%} | {pe * 100:.1f}% | {pr * 100:.1f}% |") + add("") + add(f"Converged in {info['iter']} iterations; risk-contribution dispersion " + f"{info['rc_dispersion']:.2e}. Portfolio VaR moves from ${parts['VaR']:,.0f} " + f"to ${p_erc['VaR']:,.0f}. Contributions are equalised in variance space, so the " + "percentage-of-VaR figures — which additionally carry the drift term — are close " + "to but not exactly equal.") + add("") + + # ------------------------------------------------------------------ + add("## 6. Stress scenarios") + add("") + eq = [c for c in tickers if c not in DEFAULT_DURATIONS] + rate_assets = [c for c in tickers if c in DEFAULT_DURATIONS] + + eq_loss = scenario_equities_shock(returns, weights, eq, shock=-0.20, exposure=EXPOSURE) + durations = {c: DEFAULT_DURATIONS.get(c, 0.0) for c in tickers} + rate_res = scenario_rates_bp(returns, weights, durations=durations, + bp=200.0, exposure=EXPOSURE) + cov_res = scenario_covariance_scale(returns, weights, scale=2.0, alpha=0.99, + exposure=EXPOSURE, n_sims=N_SIMS, seed=SEED) + corr_res = scenario_corr_bump_mc(returns, weights, alpha=0.99, exposure=EXPOSURE, + corr_bump_pct=50.0, n_sims=N_SIMS, seed=SEED) + + add("| Scenario | Definition | Impact |") + add("|---|---|---:|") + add(f"| Equity shock | {', '.join(eq)} each −20%, rate-sensitive instruments flat | " + f"−${eq_loss:,.0f} |") + add(f"| Rate shock | +200bp parallel; durations " + f"{', '.join(f'{c} {durations[c]:.0f}y' for c in rate_assets) or 'n/a'}; " + f"equities 0y | −${rate_res['loss']:,.0f} |") + add(f"| Volatility stress | covariance ×2 (volatility ×{cov_res['vol_multiplier']:.2f}) | " + f"VaR ${cov_res['base']['VaR']:,.0f} → ${cov_res['stressed']['VaR']:,.0f} " + f"(+${cov_res['delta_VaR']:,.0f}) |") + add(f"| Correlation stress | off-diagonal correlations ×1.5, volatilities held | " + f"VaR ${corr_res['base']['VaR']:,.0f} → ${corr_res['stressed']['VaR']:,.0f} " + f"(+${corr_res['delta_VaR']:,.0f}) |") + add("") + add("Equity instruments carry zero duration, so the parallel rate shock does not move " + "them. The rate impact is attributable entirely to " + f"{', '.join(rate_assets) if rate_assets else 'no instrument in this portfolio'}.") + if corr_res["psd_adjusted"]: + add("") + add("The bumped correlation matrix required projection back onto the positive " + "semi-definite cone.") + add("") + + # ------------------------------------------------------------------ + add("## 7. Credit — Expected Loss") + add("") + cdf = pd.read_csv(DATA / "credit_example.csv") + el, seg = compute_el_table(cdf) + grp, totals = summarize_el(el, seg) + + add(f"| {seg} | Facilities | EAD | Expected Loss | EL / EAD |") + add("|---|---:|---:|---:|---:|") + for _, row in grp.iterrows(): + add(f"| {row[seg]} | {int(row['count'])} | ${row['total_EAD']:,.0f} | " + f"${row['total_EL']:,.0f} | {row['EL_pct_of_EAD']:.2%} |") + add(f"| **Portfolio** | **{int(totals['facilities'])}** | " + f"**${totals['total_EAD']:,.0f}** | **${totals['total_EL']:,.0f}** | " + f"**{totals['EL_pct_of_EAD']:.2%}** |") + add("") + add("Segment EL sums to portfolio EL, and portfolio EL/EAD is exposure-weighted " + "(total EL ÷ total EAD) so it is consistent with the segment rows above it.") + add("") + + add("### Scenario sensitivity") + add("") + add("| Scenario | Expected Loss | Change |") + add("|---|---:|---:|") + base_el = totals["total_EL"] + for label, kw in (("Base", {}), + ("PD ×1.5", {"pd_mult": 1.5}), + ("PD +100bp", {"pd_add_bps": 100.0}), + ("LGD +10pp", {"lgd_add_pct": 10.0}), + ("PD ×2 and LGD ×1.25", {"pd_mult": 2.0, "lgd_mult": 1.25})): + e, s = compute_el_table(cdf, **kw) + _, t = summarize_el(e, s) + delta = t["total_EL"] - base_el + add(f"| {label} | ${t['total_EL']:,.0f} | " + f"{'—' if label == 'Base' else f'+${delta:,.0f}'} |") + add("") + + add("---") + add("") + add(f"*Regenerate with `python scripts/generate_validation_results.py`. " + f"Last generated {date.today():%Y-%m-%d}.*") + add("") + return "\n".join(L) + + +def strip_timestamp(text: str) -> str: + """Ignore the trailing generation date when comparing.""" + return "\n".join(ln for ln in text.splitlines() if "Last generated" not in ln) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--check", action="store_true", + help="verify the committed results match the code; exit 1 on drift") + args = ap.parse_args() + + report = build_report() + + if args.check: + if not OUT.exists(): + print(f"FAIL: {OUT.relative_to(ROOT)} does not exist. Run without --check.") + return 1 + if strip_timestamp(OUT.read_text()) != strip_timestamp(report): + print(f"FAIL: {OUT.relative_to(ROOT)} is out of date with the engine.\n" + "Run `python scripts/generate_validation_results.py` and commit the result.") + return 1 + print(f"OK: {OUT.relative_to(ROOT)} reproduces exactly.") + return 0 + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(report) + print(f"Wrote {OUT.relative_to(ROOT)} ({len(report.splitlines())} lines)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index 54dc0a6..e304f8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,37 @@ +"""Shared fixtures. Puts the repo root on sys.path so `import risklib` works under bare pytest.""" import sys from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + ROOT = Path(__file__).resolve().parents[1] -p = str(ROOT) -if p not in sys.path: - sys.path.insert(0, p) +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +@pytest.fixture +def returns_3(): + """1,000 days of well-behaved iid normal returns across 3 assets.""" + rng = np.random.default_rng(0) + idx = pd.date_range("2020-01-01", periods=1000, freq="B") + return pd.DataFrame(rng.normal(0, 0.01, size=(1000, 3)), + index=idx, columns=["A", "B", "C"]) + + +@pytest.fixture +def weights_3(): + return np.array([0.4, 0.3, 0.3]) + + +@pytest.fixture +def correlated_returns(): + """3 assets with a known correlation structure, for decomposition tests.""" + rng = np.random.default_rng(7) + idx = pd.date_range("2020-01-01", periods=1500, freq="B") + cov = np.array([[0.0004, 0.00030, -0.00005], + [0.00030, 0.0009, -0.00008], + [-0.00005, -0.00008, 0.0002]]) + data = rng.multivariate_normal([0.0003, 0.0004, 0.0001], cov, size=1500) + return pd.DataFrame(data, index=idx, columns=["EQ", "TECH", "BOND"]) diff --git a/tests/test_app_contract.py b/tests/test_app_contract.py new file mode 100644 index 0000000..af65379 --- /dev/null +++ b/tests/test_app_contract.py @@ -0,0 +1,256 @@ +""" +Contract tests between app/app.py and risklib. + +The app indexes into the dicts that risklib returns. If a key is renamed or +dropped, the app raises a KeyError at runtime — in front of the user, on a +deployed instance, with no test having caught it. These tests pin the shape of +every payload the UI reads, and walk the same call sequence the app performs +against the shipped demo data. + +They are also the regression net for the import-shadowing accident in the +previous version, where the app imported `backtest_var_historical` from two +different modules and only got the three-test version because of line ordering. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from risklib.credit import compute_el_table, summarize_el +from risklib.data import load_prices, to_returns +from risklib.market import ( + DEFAULT_DURATIONS, + MarketRiskConfig, + MarketRiskModel, + backtest_var_fhs, + backtest_var_historical, + erc_weights, + incremental_var, + scenario_corr_bump_mc, + scenario_covariance_scale, + scenario_equities_shock, + scenario_historical_replay, + scenario_rates_bp, + scenario_single_name, + var_parametric_normal_parts, +) + +DATA = Path(__file__).resolve().parents[1] / "data" + + +@pytest.fixture(scope="module") +def market(): + returns = to_returns(load_prices(DATA / "market_data.csv"), method="log") + weights = np.ones(returns.shape[1]) / returns.shape[1] + return returns, weights + + +# --------------------------------------------------------------------------- +# Demo data must actually load +# --------------------------------------------------------------------------- + +def test_demo_market_data_loads(market): + returns, _ = market + assert isinstance(returns.index, pd.DatetimeIndex) + assert returns.index.is_monotonic_increasing + assert len(returns) > 250 + assert returns.notna().all().all() + + +def test_demo_credit_data_loads(): + df = pd.read_csv(DATA / "credit_example.csv") + out, seg = compute_el_table(df) + assert seg is not None + assert out["EL"].sum() > 0 + + +# --------------------------------------------------------------------------- +# Keys the app reads from summary() +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("method", ["historical", "parametric", "monte_carlo", "fhs"]) +def test_summary_exposes_keys_the_app_reads(market, method): + returns, weights = market + cfg = MarketRiskConfig(alpha=0.99, method=method, horizon_days=1, exposure=1e6, + window=250, n_sims=20_000, seed=42, shrink_lambda=0.01) + s = MarketRiskModel(returns, weights, cfg).fit().summary() + + for key in ("VaR", "ES", "assumptions", "config"): + assert key in s + assert isinstance(s["assumptions"], list) and s["assumptions"] + assert s["VaR"] > 0 and s["ES"] >= s["VaR"] + + +def test_fhs_summary_exposes_garch_fit_info(market): + """The app renders omega / alpha / beta / persistence from fit_info.""" + returns, weights = market + for flag in (False, True): + cfg = MarketRiskConfig(method="fhs", alpha=0.99, exposure=1e6, fit_garch=flag) + s = MarketRiskModel(returns, weights, cfg).fit().summary() + fi = s["fit_info"] + for key in ("source", "omega", "alpha_g", "beta_g", "persistence"): + assert key in fi + assert fi["source"] == ("MLE" if flag else "fixed") + + +# --------------------------------------------------------------------------- +# Keys the app reads from the backtests +# --------------------------------------------------------------------------- + +APP_BACKTEST_KEYS = ( + "r_p", "VaR_threshold", "exceptions", "window", "alpha", "T", "exceedances", + "hit_rate", "kupiec_LR", "kupiec_pvalue", "christoffersen_LR", + "christoffersen_pvalue", "joint_LR", "joint_pvalue", "transitions", "method", +) + + +def test_historical_backtest_payload(market): + returns, weights = market + bt = backtest_var_historical(returns, weights, alpha=0.99, window=250) + for key in APP_BACKTEST_KEYS: + assert key in bt, f"app reads bt[{key!r}] but it is missing" + for key in ("n00", "n01", "n10", "n11", "pi_01", "pi_11"): + assert key in bt["transitions"] + + +def test_fhs_backtest_payload_matches_historical(market): + """ + Both backtests must expose the SAME keys — the app renders them through one + code path and switches only the source. + """ + returns, weights = market + bt = backtest_var_fhs(returns, weights, alpha=0.99, window=250) + for key in APP_BACKTEST_KEYS: + assert key in bt, f"app reads bt[{key!r}] but it is missing" + + +def test_backtest_series_align_for_plotting(market): + """The chart zips these three series together; they must share an index.""" + returns, weights = market + for bt in (backtest_var_historical(returns, weights, alpha=0.99, window=250), + backtest_var_fhs(returns, weights, alpha=0.99, window=250)): + assert bt["r_p"].index.equals(bt["VaR_threshold"].index) + assert bt["r_p"].index.equals(bt["exceptions"].index) + + +# --------------------------------------------------------------------------- +# Attribution payloads +# --------------------------------------------------------------------------- + +def test_decomposition_payload(market): + returns, weights = market + parts = var_parametric_normal_parts(returns, weights, alpha=0.99, + horizon_days=1, exposure=1e6) + for key in ("VaR", "mu_p", "sigma_p", "mVaR", "cVaR", "pContrib", "w"): + assert key in parts + n = returns.shape[1] + for key in ("mVaR", "cVaR", "pContrib", "w"): + assert len(parts[key]) == n + + +def test_incremental_payload(market): + returns, weights = market + inc = incremental_var(returns, weights, alpha=0.99, horizon_days=1, exposure=1e6) + for key in ("VaR", "iVaR", "cVaR"): + assert key in inc + assert len(inc["iVaR"]) == returns.shape[1] + + +def test_erc_payload(market): + returns, weights = market + w_erc, info = erc_weights(returns, horizon_days=1, init=weights, step=0.5) + for key in ("iter", "rc_dispersion", "converged"): + assert key in info + assert w_erc.sum() == pytest.approx(1.0, rel=1e-9) + + +# --------------------------------------------------------------------------- +# Scenario payloads +# --------------------------------------------------------------------------- + +def test_single_name_scenario_payload(market): + returns, weights = market + res = scenario_single_name(returns, weights, {returns.columns[0]: -0.15}, exposure=1e6) + for key in ("loss", "shock_vector", "base"): + assert key in res + assert len(res["shock_vector"]) == returns.shape[1] + + +def test_rate_scenario_payload(market): + returns, weights = market + durations = {c: DEFAULT_DURATIONS.get(c, 0.0) for c in returns.columns} + res = scenario_rates_bp(returns, weights, durations=durations, bp=200.0, exposure=1e6) + for key in ("loss", "durations_used", "bp"): + assert key in res + assert set(res["durations_used"]) == set(returns.columns) + + +def test_covariance_and_correlation_scenario_payloads(market): + returns, weights = market + cov = scenario_covariance_scale(returns, weights, scale=2.0, alpha=0.99, + exposure=1e6, n_sims=20_000, seed=42) + corr = scenario_corr_bump_mc(returns, weights, alpha=0.99, exposure=1e6, + corr_bump_pct=50.0, n_sims=20_000, seed=42) + for res in (cov, corr): + assert res["base"]["VaR"] > 0 and res["stressed"]["VaR"] > 0 + assert "delta_VaR" in res + assert "vol_multiplier" in cov + assert "psd_adjusted" in corr + + +def test_historical_replay_payload(market): + returns, weights = market + res = scenario_historical_replay( + returns, weights, + str(returns.index.min().date()), str(returns.index.max().date()), + alpha=0.99, exposure=1e6, n_sims=20_000, seed=42) + for key in ("VaR", "ES", "n_obs"): + assert key in res + + +def test_equity_scenario_returns_scalar(market): + returns, weights = market + loss = scenario_equities_shock(returns, weights, list(returns.columns), + shock=-0.20, exposure=1e6) + assert isinstance(loss, float) + assert loss == pytest.approx(0.20 * 1e6, rel=1e-9) + + +# --------------------------------------------------------------------------- +# Credit payloads +# --------------------------------------------------------------------------- + +def test_credit_summary_payload(): + df = pd.read_csv(DATA / "credit_example.csv") + out, seg = compute_el_table(df) + grp, totals = summarize_el(out, seg) + + for key in ("total_EAD", "total_EL", "EL_pct_of_EAD", "facilities"): + assert key in totals.index + for col in ("total_EAD", "total_EL", "avg_PD", "avg_LGD", "EL_pct_of_EAD"): + assert col in grp.columns + assert "data_quality" in out.attrs + + +# --------------------------------------------------------------------------- +# Full app call sequence +# --------------------------------------------------------------------------- + +def test_full_app_sequence_runs_without_error(market): + """Walk the app's happy path end to end for every method.""" + returns, weights = market + + for method in ("historical", "parametric", "monte_carlo", "fhs"): + cfg = MarketRiskConfig(alpha=0.99, method=method, horizon_days=1, exposure=1e6, + window=250, n_sims=20_000, seed=42) + summary = MarketRiskModel(returns, weights, cfg).fit().summary() + + bt = backtest_var_historical(returns, weights, alpha=0.99, window=250) + parts = var_parametric_normal_parts(returns, weights, alpha=0.99, exposure=1e6) + + # The report builder reads exactly these. + assert summary["VaR"] > 0 + assert bt["T"] > 0 + assert parts["cVaR"].sum() == pytest.approx(parts["VaR"], rel=1e-9) diff --git a/tests/test_backtest.py b/tests/test_backtest.py index e69de29..84f90e8 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -0,0 +1,239 @@ +""" +Tests for the validation layer. + +This file was previously empty — the backtesting module, which is the +centrepiece of the project, had no coverage at all. + +The most important test here is `test_christoffersen_rejects_clustering`: it +constructs an exception series with the correct TOTAL count but deliberately +clustered in time, confirms Kupiec cannot see the problem, and confirms +Christoffersen does. That contrast is the entire argument for implementing the +independence test. +""" + +import numpy as np +import pandas as pd +import pytest + +from risklib.market.backtest import ( + backtest_var_fhs, + backtest_var_historical, + christoffersen_independence, + joint_coverage_test, + kupiec_pof, +) + + +# --------------------------------------------------------------------------- +# Kupiec +# --------------------------------------------------------------------------- + +def test_kupiec_perfect_coverage_gives_zero_statistic(): + """Observed rate exactly equal to (1-alpha) => LR = 0, p = 1.""" + LR, p = kupiec_pof(exceedances=50, T=1000, alpha=0.95) + assert LR == pytest.approx(0.0, abs=1e-9) + assert p == pytest.approx(1.0, abs=1e-9) + + +def test_kupiec_rejects_gross_overshoot(): + """15% exceptions against a 5% target must be rejected decisively.""" + LR, p = kupiec_pof(exceedances=150, T=1000, alpha=0.95) + assert LR > 10.0 + assert p < 0.01 + + +def test_kupiec_rejects_too_few_exceptions(): + """An over-conservative model (0.5% vs 5% target) is also mis-specified.""" + _, p = kupiec_pof(exceedances=5, T=1000, alpha=0.95) + assert p < 0.01 + + +def test_kupiec_handles_zero_exceptions(): + """Zero exceptions must not produce log(0); the clamp handles it.""" + LR, p = kupiec_pof(exceedances=0, T=500, alpha=0.95) + assert np.isfinite(LR) and np.isfinite(p) + assert LR > 0 + + +def test_kupiec_requires_positive_sample(): + with pytest.raises(ValueError): + kupiec_pof(exceedances=0, T=0, alpha=0.95) + + +# --------------------------------------------------------------------------- +# Christoffersen independence — the core test +# --------------------------------------------------------------------------- + +def test_christoffersen_accepts_independent_exceptions(): + """Randomly scattered exceptions should not be flagged as clustered.""" + rng = np.random.default_rng(42) + exc = pd.Series(rng.binomial(1, 0.05, size=2000)) + LR, p, tr = christoffersen_independence(exc) + assert p > 0.05 + assert tr["n00"] + tr["n01"] + tr["n10"] + tr["n11"] == len(exc) - 1 + + +def test_christoffersen_rejects_clustering(): + """ + The headline test. Build a series with the CORRECT total exception count + but all exceptions bunched together. Kupiec sees nothing wrong; the + independence test must catch it. + """ + T, n_exc = 1000, 50 + clustered = np.zeros(T, dtype=int) + clustered[300:300 + n_exc] = 1 # one contiguous block + exc = pd.Series(clustered) + + # Kupiec is blind: the count is exactly on target. + _, p_kupiec = kupiec_pof(n_exc, T, alpha=0.95) + assert p_kupiec > 0.9, "count is on target, so Kupiec must pass" + + # Christoffersen sees it. + LR_ind, p_ind, tr = christoffersen_independence(exc) + assert p_ind < 0.001, "clustered exceptions must be rejected" + assert tr["pi_11"] > tr["pi_01"], "clustering means P(exc|exc) > P(exc|no exc)" + + +def test_christoffersen_statistic_is_non_negative(): + """LR is non-negative in theory; the guard must hold in degenerate cases.""" + for series in (np.zeros(100, dtype=int), + np.ones(100, dtype=int), + np.array([1] + [0] * 99)): + LR, p, _ = christoffersen_independence(pd.Series(series)) + assert LR >= 0.0 + assert 0.0 <= p <= 1.0 + + +def test_christoffersen_handles_degenerate_input(): + """Empty and single-element series must return neutral results, not raise.""" + for s in (pd.Series(dtype=int), pd.Series([1])): + LR, p, tr = christoffersen_independence(s) + assert LR == 0.0 and p == 1.0 + assert tr["n11"] == 0 + + +# --------------------------------------------------------------------------- +# Joint conditional coverage +# --------------------------------------------------------------------------- + +def test_joint_statistic_is_sum_of_components(): + """LR_cc = LR_uc + LR_ind by construction.""" + rng = np.random.default_rng(3) + exc = pd.Series(rng.binomial(1, 0.05, size=1000)) + res = joint_coverage_test(int(exc.sum()), len(exc), 0.95, exc) + assert res["joint_LR"] == pytest.approx( + res["kupiec_LR"] + res["christoffersen_LR"], rel=1e-12 + ) + + +def test_joint_pvalue_uses_chi2_two_dof(): + """For Chi^2(2) the survival function is exactly exp(-x/2).""" + exc = pd.Series(np.zeros(100, dtype=int)) + res = joint_coverage_test(0, 100, 0.95, exc) + assert res["joint_pvalue"] == pytest.approx(np.exp(-res["joint_LR"] / 2.0), rel=1e-12) + + +def test_joint_test_catches_clustering_that_kupiec_misses(): + """End-to-end: correct count, clustered timing => joint test rejects.""" + T, n_exc = 1000, 50 + clustered = np.zeros(T, dtype=int) + clustered[100:100 + n_exc] = 1 + exc = pd.Series(clustered) + + res = joint_coverage_test(n_exc, T, 0.95, exc) + assert res["kupiec_pvalue"] > 0.9 + assert res["joint_pvalue"] < 0.01 + + +# --------------------------------------------------------------------------- +# Rolling backtests — out-of-sample discipline +# --------------------------------------------------------------------------- + +def test_historical_backtest_has_no_lookahead(returns_3, weights_3): + """ + The threshold at t must depend only on data through t-1. Verified directly: + recompute the quantile from the trailing window ending at t-1 and compare. + """ + window = 100 + bt = backtest_var_historical(returns_3, weights_3, alpha=0.95, window=window) + + r_p = bt["r_p"] + t = window + 25 + expected = r_p.iloc[t - window:t].quantile(0.05) + assert bt["VaR_threshold"].iloc[t] == pytest.approx(expected, rel=1e-12) + + +def test_historical_backtest_burn_in_excluded(returns_3, weights_3): + """T must count only days with a defined threshold.""" + window = 250 + bt = backtest_var_historical(returns_3, weights_3, alpha=0.95, window=window) + assert bt["T"] == len(returns_3) - window + assert bt["VaR_threshold"].isna().sum() == window + + +def test_backtest_exception_count_matches_series(returns_3, weights_3): + """The reported count must equal the flags actually raised in-sample.""" + bt = backtest_var_historical(returns_3, weights_3, alpha=0.95, window=100) + mask = bt["VaR_threshold"].notna() + manual = int((bt["r_p"][mask] < bt["VaR_threshold"][mask]).sum()) + assert bt["exceedances"] == manual + + +def test_backtest_hit_rate_near_target_on_iid_data(returns_3, weights_3): + """On clean iid normal data a 95% VaR should exceed roughly 5% of the time.""" + bt = backtest_var_historical(returns_3, weights_3, alpha=0.95, window=250) + assert 0.02 < bt["hit_rate"] < 0.09 + assert bt["kupiec_pvalue"] > 0.01 + + +def test_backtest_higher_alpha_yields_fewer_exceptions(returns_3, weights_3): + """Monotonicity: a stricter confidence level cannot be breached more often.""" + bt95 = backtest_var_historical(returns_3, weights_3, alpha=0.95, window=250) + bt99 = backtest_var_historical(returns_3, weights_3, alpha=0.99, window=250) + assert bt99["exceedances"] <= bt95["exceedances"] + + +def test_backtest_returns_all_three_tests(returns_3, weights_3): + """The dict contract the app relies on.""" + bt = backtest_var_historical(returns_3, weights_3, alpha=0.95, window=250) + for key in ("kupiec_LR", "kupiec_pvalue", + "christoffersen_LR", "christoffersen_pvalue", + "joint_LR", "joint_pvalue", "transitions", + "r_p", "VaR_threshold", "exceptions", "T", "exceedances", "hit_rate"): + assert key in bt, f"missing key: {key}" + + +# --------------------------------------------------------------------------- +# FHS backtest +# --------------------------------------------------------------------------- + +def test_fhs_backtest_runs_and_is_calibrated(returns_3, weights_3): + bt = backtest_var_fhs(returns_3, weights_3, alpha=0.95, window=250) + assert bt["T"] > 0 + assert 0.01 < bt["hit_rate"] < 0.12 + + +def test_fhs_backtest_sigma_is_causal(returns_3, weights_3): + """ + sigma_t is the forecast formed at t-1, so truncating the sample after t must + not change it. This is the regression test for the in-sample long-run + variance that the previous implementation used. + """ + window = 250 + full = backtest_var_fhs(returns_3, weights_3, alpha=0.95, window=window) + cut = 600 + truncated = backtest_var_fhs(returns_3.iloc[:cut], weights_3, alpha=0.95, window=window) + + common = truncated["sigma"].index + np.testing.assert_allclose( + full["sigma"].loc[common].values, + truncated["sigma"].values, + rtol=1e-10, + err_msg="sigma changed when future data was removed => look-ahead leakage", + ) + + +def test_fhs_backtest_short_sample_returns_empty_result(returns_3, weights_3): + bt = backtest_var_fhs(returns_3.iloc[:100], weights_3, alpha=0.99, window=250) + assert bt["T"] == 0 + assert bt["exceedances"] == 0 diff --git a/tests/test_credit.py b/tests/test_credit.py index 23e1827..552db69 100644 --- a/tests/test_credit.py +++ b/tests/test_credit.py @@ -1,9 +1,140 @@ +"""Tests for the credit Expected Loss pipeline.""" + +import numpy as np import pandas as pd -from risk_engine.credit import compute_el_table +import pytest + +from risklib.credit import compute_el_table, summarize_el, validate_and_standardize + + +@pytest.fixture +def book(): + return pd.DataFrame({ + "Segment": ["Retail", "Retail", "Corporate", "Corporate"], + "PD": [0.02, 0.05, 0.01, 0.03], + "LGD": [0.40, 0.50, 0.35, 0.45], + "EAD": [100_000, 150_000, 200_000, 250_000], + }) + + +def test_el_is_pd_times_lgd_times_ead(book): + out, _ = compute_el_table(book) + expected = book["PD"] * book["LGD"] * book["EAD"] + np.testing.assert_allclose(out["EL"].values, expected.values, rtol=1e-12) + + +def test_percent_inputs_are_detected_and_converted(): + """Accepts PD/LGD as either decimals or percentages.""" + df = pd.DataFrame({"PD": [0.02, 2.0], "LGD": [0.4, 40.0], "EAD": [1000, 2000]}) + out, _ = compute_el_table(df) + assert out["EL"].iloc[0] == pytest.approx(8.0) + assert out["EL"].iloc[1] == pytest.approx(16.0) -def test_credit_el_basic(): - df = pd.DataFrame({"PD":[0.02, 2.0], "LGD":[0.4, 40.0], "EAD":[1000, 2000]}) + +def test_pd_of_one_is_not_treated_as_percent(): + """PD = 1.0 means certain default, not 1%.""" + df = pd.DataFrame({"PD": [1.0], "LGD": [0.5], "EAD": [1000]}) out, _ = compute_el_table(df) - # Accept decimals or percents in input; EL should be around 8 and 16 - assert abs(out["EL"].iloc[0] - 8) < 1e-6 - assert abs(out["EL"].iloc[1] - 16) < 1e-6 + assert out["PD_final"].iloc[0] == pytest.approx(1.0) + + +def test_column_aliases_are_resolved(): + df = pd.DataFrame({ + "probability_of_default": [0.02], + "loss_given_default": [0.4], + "exposure_at_default": [1000], + "rating": ["BBB"], + }) + out, seg = compute_el_table(df) + assert seg == "rating" + assert out["EL"].iloc[0] == pytest.approx(8.0) + + +def test_missing_columns_raise(): + with pytest.raises(ValueError, match="Missing PD/LGD/EAD"): + compute_el_table(pd.DataFrame({"PD": [0.02], "LGD": [0.4]})) + + +def test_facility_el_sums_to_portfolio_el(book): + """Aggregation consistency: the parts must sum to the whole.""" + out, seg = compute_el_table(book) + grp, totals = summarize_el(out, seg) + assert totals["total_EL"] == pytest.approx(out["EL"].sum(), rel=1e-12) + assert grp["total_EL"].sum() == pytest.approx(totals["total_EL"], rel=1e-12) + assert grp["total_EAD"].sum() == pytest.approx(totals["total_EAD"], rel=1e-12) + + +def test_portfolio_el_pct_is_exposure_weighted(book): + """ + REGRESSION. Portfolio EL% was mean(per-facility EL/EAD) — an equal-weighted + average of ratios that disagreed with every grouped subtotal beside it and + let a tiny facility move the portfolio figure as much as a huge one. + """ + out, seg = compute_el_table(book) + _, totals = summarize_el(out, seg) + assert totals["EL_pct_of_EAD"] == pytest.approx( + totals["total_EL"] / totals["total_EAD"], rel=1e-12 + ) + # And it must differ from the old equal-weighted calculation on this book. + equal_weighted = (out["EL"] / out["EAD_final"]).mean() + assert totals["EL_pct_of_EAD"] != pytest.approx(equal_weighted, rel=1e-6) + + +def test_grouped_and_portfolio_el_pct_use_the_same_definition(book): + out, seg = compute_el_table(book) + grp, totals = summarize_el(out, seg) + for _, row in grp.iterrows(): + assert row["EL_pct_of_EAD"] == pytest.approx(row["total_EL"] / row["total_EAD"], rel=1e-12) + assert totals["EL_pct_of_EAD"] == pytest.approx(totals["total_EL"] / totals["total_EAD"], rel=1e-12) + + +def test_pd_multiplier_scales_expected_loss(book): + base, _ = compute_el_table(book) + shocked, _ = compute_el_table(book, pd_mult=2.0) + np.testing.assert_allclose(shocked["EL"].values, 2.0 * base["EL"].values, rtol=1e-12) + + +def test_additive_pd_shock_uses_basis_points(book): + shocked, _ = compute_el_table(book, pd_add_bps=100.0) # +1.00% + np.testing.assert_allclose(shocked["PD_final"].values, + book["PD"].values + 0.01, rtol=1e-12) + + +def test_additive_lgd_shock_uses_percentage_points(book): + shocked, _ = compute_el_table(book, lgd_add_pct=10.0) # +0.10 + np.testing.assert_allclose(shocked["LGD_final"].values, + book["LGD"].values + 0.10, rtol=1e-12) + + +def test_shocks_are_clamped_to_valid_ranges(book): + shocked, _ = compute_el_table(book, pd_mult=100.0, lgd_mult=100.0) + assert (shocked["PD_final"] <= 1.0).all() + assert (shocked["LGD_final"] <= 1.0).all() + assert (shocked["EAD_final"] >= 0).all() + + +def test_zero_ead_does_not_produce_infinity(): + df = pd.DataFrame({"PD": [0.02, 0.03], "LGD": [0.4, 0.5], "EAD": [0, 1000]}) + out, _ = compute_el_table(df) + assert np.isfinite(out["EL_pct_of_EAD"]).all() + assert out["EL_pct_of_EAD"].iloc[0] == 0.0 + + +def test_out_of_range_inputs_are_reported_not_just_clamped(): + """Silently clipping bad inputs hides a data-quality finding.""" + df = pd.DataFrame({"PD": [150.0], "LGD": [0.4], "EAD": [-500]}) + std, *_ = validate_and_standardize(df) + q = std.attrs["data_quality"] + assert q["pd_out_of_range"] == 1 + assert q["ead_negative"] == 1 + + +def test_unlabelled_segments_form_their_own_bucket(): + df = pd.DataFrame({ + "Segment": ["Retail", None], + "PD": [0.02, 0.03], "LGD": [0.4, 0.5], "EAD": [1000, 2000], + }) + out, seg = compute_el_table(df) + grp, totals = summarize_el(out, seg) + assert len(grp) == 2 + assert grp["total_EL"].sum() == pytest.approx(totals["total_EL"], rel=1e-12) diff --git a/tests/test_data_and_scenarios.py b/tests/test_data_and_scenarios.py new file mode 100644 index 0000000..20a1722 --- /dev/null +++ b/tests/test_data_and_scenarios.py @@ -0,0 +1,197 @@ +"""Tests for data ingestion and the scenario library.""" + +import io + +import numpy as np +import pandas as pd +import pytest + +from risklib.data import clean_prices, load_prices, to_returns +from risklib.market.scenarios import ( + scenario_corr_bump_mc, + scenario_covariance_scale, + scenario_equities_shock, + scenario_rates_bp, + scenario_single_name, + shock_vector, +) + +CSV = """Date,SPY,TLT +2024-01-02,470.10,95.20 +2024-01-03,468.50,95.80 +2024-01-04,471.20,95.10 +2024-01-05,473.00,94.70 +""" + + +# --------------------------------------------------------------------------- +# Data ingestion +# --------------------------------------------------------------------------- + +def test_load_prices_parses_dates_and_numerics(): + df = load_prices(io.StringIO(CSV)) + assert isinstance(df.index, pd.DatetimeIndex) + assert list(df.columns) == ["SPY", "TLT"] + assert df.dtypes.apply(lambda d: np.issubdtype(d, np.floating)).all() + + +def test_load_prices_sorts_by_date(): + """A newest-first export would otherwise invert every rolling window.""" + reversed_csv = "Date,SPY\n2024-01-05,473.0\n2024-01-02,470.1\n" + df = load_prices(io.StringIO(reversed_csv)) + assert df.index.is_monotonic_increasing + + +def test_load_prices_strips_currency_formatting(): + """Excel exports carry '$1,234.56' as object dtype, which poisons .cov().""" + df = load_prices(io.StringIO('Date,SPY\n2024-01-02,"$1,234.56"\n2024-01-03,"$1,240.00"\n')) + assert df["SPY"].iloc[0] == pytest.approx(1234.56) + + +def test_load_prices_drops_all_nan_columns_without_error(): + """ + REGRESSION. The all-NaN drop previously sat INSIDE the column loop, mutating + the frame while iterating its own stale column index. + """ + csv = "Date,SPY,JUNK,TLT\n2024-01-02,470.1,,95.2\n2024-01-03,468.5,,95.8\n" + df = load_prices(io.StringIO(csv)) + assert list(df.columns) == ["SPY", "TLT"] + + +def test_load_prices_rejects_unparseable_dates(): + with pytest.raises(ValueError, match="No parseable dates"): + load_prices(io.StringIO("Date,SPY\nnot-a-date,470\nalso-bad,471\n")) + + +def test_clean_prices_removes_duplicate_dates(): + idx = pd.to_datetime(["2024-01-02", "2024-01-02", "2024-01-03"]) + prices = pd.DataFrame({"A": [100.0, 101.0, 102.0]}, index=idx) + cleaned = clean_prices(prices) + assert len(cleaned) == 2 + assert cleaned["A"].iloc[0] == 100.0 # first occurrence kept + + +def test_clean_prices_nulls_non_positive_prices(): + """A zero price would emit -inf into log returns and NaN the covariance matrix.""" + idx = pd.to_datetime(["2024-01-02", "2024-01-03", "2024-01-04"]) + prices = pd.DataFrame({"A": [100.0, 0.0, 102.0]}, index=idx) + cleaned = clean_prices(prices) + assert cleaned["A"].isna().iloc[1] + assert cleaned["A"].notna().sum() == 2 + + +def test_clean_prices_drops_columns_with_too_few_observations(): + idx = pd.to_datetime(["2024-01-02", "2024-01-03", "2024-01-04"]) + prices = pd.DataFrame({"GOOD": [100.0, 101.0, 102.0], + "SPARSE": [100.0, -5.0, np.nan]}, index=idx) + assert list(clean_prices(prices).columns) == ["GOOD"] + + +def test_log_returns_are_time_additive(): + df = load_prices(io.StringIO(CSV)) + r = to_returns(df, "log") + total = np.log(df["SPY"].iloc[-1] / df["SPY"].iloc[0]) + assert r["SPY"].sum() == pytest.approx(total, rel=1e-12) + + +def test_returns_drop_only_the_first_row(): + df = load_prices(io.StringIO(CSV)) + assert len(to_returns(df, "log")) == len(df) - 1 + + +def test_returns_reject_unknown_method(): + with pytest.raises(ValueError, match="Unknown method"): + to_returns(load_prices(io.StringIO(CSV)), method="geometric") + + +# --------------------------------------------------------------------------- +# Scenarios +# --------------------------------------------------------------------------- + +@pytest.fixture +def rets(): + rng = np.random.default_rng(0) + idx = pd.date_range("2022-01-01", periods=600, freq="B") + return pd.DataFrame(rng.normal(0, 0.01, (600, 3)), index=idx, + columns=["SPY", "QQQ", "TLT"]) + + +def test_shock_vector_holds_unshocked_instruments_flat(rets): + """ + REGRESSION. The two former scenario modules disagreed: one held unshocked + names flat, the other carried their last observed return. Flat is now the + single convention. + """ + v = shock_vector(rets, {"SPY": -0.10}) + assert v["SPY"] == -0.10 + assert v["QQQ"] == 0.0 and v["TLT"] == 0.0 + + +def test_shock_vector_last_mode_is_opt_in(rets): + v = shock_vector(rets, {"SPY": -0.10}, base="last") + assert v["QQQ"] == pytest.approx(rets["QQQ"].iloc[-1]) + + +def test_shock_vector_rejects_unknown_ticker(rets): + with pytest.raises(KeyError): + shock_vector(rets, {"NVDA": -0.10}) + + +def test_single_name_shock_loss_is_weight_times_shock(rets): + w = np.array([0.5, 0.3, 0.2]) + res = scenario_single_name(rets, w, {"SPY": -0.20}, exposure=1_000_000) + assert res["loss"] == pytest.approx(0.20 * 0.5 * 1_000_000, rel=1e-12) + + +def test_equities_shock_leaves_bonds_untouched(rets): + w = np.array([0.4, 0.4, 0.2]) + loss = scenario_equities_shock(rets, w, ["SPY", "QQQ"], shock=-0.20, exposure=1e6) + assert loss == pytest.approx(0.20 * 0.8 * 1e6, rel=1e-12) + + +def test_rate_shock_does_not_hit_equities(rets): + """ + REGRESSION. default_duration was 7.0 applied to EVERY column, so a +200bp + scenario knocked ~14% off SPY. Only instruments with a duration should move. + """ + w = np.array([1 / 3, 1 / 3, 1 / 3]) + res = scenario_rates_bp(rets, w, bp=200.0, exposure=1e6) + assert res["durations_used"]["SPY"] == 0.0 + assert res["durations_used"]["QQQ"] == 0.0 + assert res["durations_used"]["TLT"] == 18.0 + + # Loss comes from TLT alone: 18 * 0.02 * (1/3) * 1e6 + assert res["loss"] == pytest.approx(18.0 * 0.02 * (1 / 3) * 1e6, rel=1e-9) + + +def test_rate_shock_honours_explicit_duration_override(rets): + w = np.array([0.0, 0.0, 1.0]) + res = scenario_rates_bp(rets, w, durations={"TLT": 10.0}, bp=100.0, exposure=1e6) + assert res["loss"] == pytest.approx(10.0 * 0.01 * 1e6, rel=1e-9) + + +def test_covariance_scaling_increases_var(rets): + w = np.array([1 / 3, 1 / 3, 1 / 3]) + res = scenario_covariance_scale(rets, w, scale=4.0, alpha=0.99, + exposure=1e6, n_sims=40_000, seed=1) + assert res["stressed"]["VaR"] > res["base"]["VaR"] + assert res["vol_multiplier"] == pytest.approx(2.0) + + +def test_correlation_bump_preserves_psd(rets): + """ + REGRESSION. Element-wise correlation bumping can push the matrix out of the + PSD cone and break the Cholesky factorisation. The projection must handle it. + """ + w = np.array([1 / 3, 1 / 3, 1 / 3]) + res = scenario_corr_bump_mc(rets, w, alpha=0.99, exposure=1e6, + corr_bump_pct=500.0, n_sims=20_000, seed=1) + assert np.isfinite(res["stressed"]["VaR"]) + assert res["psd_adjusted"] in (True, False) + + +def test_correlation_bump_uses_same_seed_for_base_and_stress(rets): + """The difference must be a covariance effect, not Monte Carlo noise.""" + w = np.array([1 / 3, 1 / 3, 1 / 3]) + a = scenario_corr_bump_mc(rets, w, corr_bump_pct=0.0, n_sims=20_000, seed=5) + assert a["base"]["VaR"] == pytest.approx(a["stressed"]["VaR"], rel=1e-9) diff --git a/tests/test_extras.py b/tests/test_extras.py new file mode 100644 index 0000000..4e116da --- /dev/null +++ b/tests/test_extras.py @@ -0,0 +1,114 @@ +""" +Tests for attribution and risk budgeting. + +The Euler summation identity (component VaR summing exactly to portfolio VaR) +is claimed in the README; this file is where that claim is actually enforced. +""" + +import numpy as np +import pytest + +from risklib.market.extras import ( + erc_weights, + erc_weights_from_cov, + incremental_var, + var_parametric_normal_parts, +) +from risklib.market.market_risk_model import var_parametric + + +def test_component_var_sums_to_portfolio_var(correlated_returns): + """ + Euler's theorem: VaR is homogeneous of degree 1 in w, so + sum_i w_i * dVaR/dw_i = VaR exactly — not approximately. + """ + w = np.array([0.25, 0.25, 0.50]) + parts = var_parametric_normal_parts(correlated_returns, w, alpha=0.95, exposure=1e6) + assert parts["cVaR"].sum() == pytest.approx(parts["VaR"], rel=1e-10) + + +def test_percentage_contributions_sum_to_one(correlated_returns): + w = np.array([0.4, 0.4, 0.2]) + parts = var_parametric_normal_parts(correlated_returns, w, alpha=0.99, exposure=1e6) + assert parts["pContrib"].sum() == pytest.approx(1.0, rel=1e-10) + + +def test_decomposition_var_matches_standalone_estimator(correlated_returns): + """The decomposition must not disagree with the plain estimator.""" + w = np.array([1 / 3, 1 / 3, 1 / 3]) + parts = var_parametric_normal_parts(correlated_returns, w, alpha=0.99, + horizon_days=1, exposure=1e6) + direct = var_parametric(correlated_returns, w, alpha=0.99, + horizon_days=1, exposure=1e6) + assert parts["VaR"] == pytest.approx(direct, rel=1e-10) + + +def test_riskier_asset_contributes_more_than_its_weight(correlated_returns): + """TECH has the highest variance, so equal weighting must over-contribute.""" + w = np.array([1 / 3, 1 / 3, 1 / 3]) + parts = var_parametric_normal_parts(correlated_returns, w, alpha=0.95, exposure=1e6) + tech = list(correlated_returns.columns).index("TECH") + bond = list(correlated_returns.columns).index("BOND") + assert parts["pContrib"][tech] > 1 / 3 + assert parts["pContrib"][bond] < 1 / 3 + + +def test_incremental_var_is_a_true_recomputation(correlated_returns): + """ + REGRESSION. The old `incremental_var_normal` returned component VaR under + the name incremental VaR. True iVaR removes the position and renormalises, + so on a material weight the two must differ. + """ + w = np.array([0.25, 0.25, 0.50]) + res = incremental_var(correlated_returns, w, alpha=0.95, exposure=1e6) + assert not np.allclose(res["iVaR"], res["cVaR"], rtol=1e-3) + + +def test_incremental_var_matches_manual_removal(correlated_returns): + """iVaR_i = VaR(full) - VaR(without i, renormalised).""" + w = np.array([0.25, 0.25, 0.50]) + res = incremental_var(correlated_returns, w, alpha=0.95, exposure=1e6) + + w_ex = np.array([0.0, 0.25, 0.50]) + w_ex = w_ex / w_ex.sum() + manual = res["VaR"] - var_parametric(correlated_returns, w_ex, alpha=0.95, exposure=1e6) + assert res["iVaR"][0] == pytest.approx(manual, rel=1e-10) + + +def test_erc_equalises_risk_contributions(correlated_returns): + """The objective itself: risk contributions to variance become equal.""" + w_erc, info = erc_weights(correlated_returns, tol=1e-10, max_iter=20_000) + cov = correlated_returns.cov().values + rc = w_erc * (cov @ w_erc) + assert info["converged"] + assert rc.std() / rc.mean() < 1e-4 + assert w_erc.sum() == pytest.approx(1.0, rel=1e-12) + + +def test_erc_underweights_the_riskiest_asset(correlated_returns): + """Equalising risk means the high-vol asset gets a smaller weight.""" + w_erc, _ = erc_weights(correlated_returns, tol=1e-10) + cols = list(correlated_returns.columns) + assert w_erc[cols.index("TECH")] < w_erc[cols.index("BOND")] + + +def test_erc_respects_weight_bounds(correlated_returns): + w_erc, _ = erc_weights(correlated_returns, min_w=0.2, max_w=0.5, tol=1e-10) + assert w_erc.min() >= 0.2 - 1e-9 + assert w_erc.max() <= 0.5 + 1e-9 + + +def test_erc_info_well_defined_with_zero_iterations(): + """REGRESSION: info referenced loop variables that could be unbound.""" + cov = np.diag([0.01, 0.02, 0.03]) + w, info = erc_weights_from_cov(cov, max_iter=0) + assert info["iter"] == 0 + assert info["converged"] is False + assert np.isfinite(info["sigma"]) + assert len(info["RC"]) == 3 + + +def test_erc_on_identity_covariance_is_equal_weight(): + """Uncorrelated, equal-variance assets => equal weights.""" + w, _ = erc_weights_from_cov(np.eye(4) * 0.01, tol=1e-12) + np.testing.assert_allclose(w, np.ones(4) / 4, atol=1e-6) diff --git a/tests/test_garch.py b/tests/test_garch.py new file mode 100644 index 0000000..0c16a3c --- /dev/null +++ b/tests/test_garch.py @@ -0,0 +1,151 @@ +""" +Tests for the GARCH module. + +The README claims the MLE estimator recovers known parameters better than the +fixed defaults. That claim is now enforced here rather than only asserted in +prose. +""" + +import numpy as np +import pandas as pd +import pytest + +from risklib.market.garch import ( + fit_garch11_mle, + garch11_filter, + garch11_forecast_next, + variance_path, +) + + +def simulate_garch(n=3000, omega=2e-6, alpha=0.08, beta=0.91, seed=0): + """Simulate a GARCH(1,1) series with known parameters.""" + rng = np.random.default_rng(seed) + r = np.zeros(n) + sig2 = omega / (1 - alpha - beta) + for t in range(n): + r[t] = np.sqrt(sig2) * rng.normal() + sig2 = omega + alpha * r[t] ** 2 + beta * sig2 + return pd.Series(r, index=pd.date_range("2015-01-01", periods=n, freq="B")) + + +def test_variance_path_matches_manual_recursion(): + r = np.array([0.01, -0.02, 0.015, -0.005]) + omega, a, b = 1e-6, 0.05, 0.94 + sig2 = variance_path(r, omega, a, b, init_var=1e-4) + + expected = 1e-4 + assert sig2[0] == pytest.approx(expected) + for t in range(1, len(r)): + expected = omega + a * r[t - 1] ** 2 + b * expected + assert sig2[t] == pytest.approx(expected, rel=1e-12) + + +def test_variance_path_stays_positive(): + """The floor must hold even for absurd parameter proposals.""" + r = np.zeros(50) + assert (variance_path(r, 0.0, 0.0, 0.0, init_var=0.0) > 0).all() + + +def test_mle_recovers_known_parameters(): + """Estimates should land near the true values on simulated data.""" + r = simulate_garch(n=3000, omega=2e-6, alpha=0.08, beta=0.91, seed=1) + fit = fit_garch11_mle(r, n_restarts=5) + + assert fit["converged"] + assert fit["alpha_g"] == pytest.approx(0.08, abs=0.04) + assert fit["beta_g"] == pytest.approx(0.91, abs=0.06) + assert fit["persistence"] < 1.0 + + +def test_mle_beats_fixed_defaults_on_alpha(): + """ + The claim in the model documentation: MLE estimates alpha more accurately + than the fixed 0.05 default when the true value is 0.08. + """ + true_alpha = 0.08 + r = simulate_garch(n=3000, alpha=true_alpha, beta=0.91, seed=2) + fit = fit_garch11_mle(r, n_restarts=5) + + err_mle = abs(fit["alpha_g"] - true_alpha) / true_alpha + err_fixed = abs(0.05 - true_alpha) / true_alpha + assert err_mle < err_fixed + + +def test_mle_enforces_stationarity(): + """The parameter transform makes alpha + beta < 1 structurally impossible to break.""" + for seed in range(4): + fit = fit_garch11_mle(simulate_garch(n=1200, seed=seed), n_restarts=3) + assert 0 < fit["alpha_g"] < 1 + assert 0 <= fit["beta_g"] < 1 + assert fit["alpha_g"] + fit["beta_g"] < 1.0 + assert fit["omega"] > 0 + + +def test_mle_rejects_short_series(): + with pytest.raises(ValueError, match="observations"): + fit_garch11_mle(pd.Series(np.random.default_rng(0).normal(0, 0.01, 30))) + + +def test_loglikelihood_includes_normalising_constant(): + """ + REGRESSION. The 2*pi constant was previously dropped, leaving the reported + log-likelihood, AIC and BIC offset by 0.5*n*log(2*pi) versus any external + package. Verified against a direct Gaussian evaluation. + """ + r = simulate_garch(n=800, seed=5) + fit = fit_garch11_mle(r, n_restarts=3) + + sig2 = variance_path(r.values, fit["omega"], fit["alpha_g"], fit["beta_g"]) + manual = -0.5 * np.sum(np.log(2 * np.pi) + np.log(sig2) + r.values ** 2 / sig2) + assert fit["log_likelihood"] == pytest.approx(manual, rel=1e-8) + + k = 3 + assert fit["aic"] == pytest.approx(2 * k - 2 * manual, rel=1e-8) + + +def test_filter_fixed_mode_uses_variance_targeting(): + """omega = (1 - a - b) * sample variance, and the source is tagged 'fixed'.""" + r = simulate_garch(n=600, seed=3) + sigma, info = garch11_filter(r, fit=False, alpha_g=0.05, beta_g=0.94) + + assert info["source"] == "fixed" + assert info["omega"] == pytest.approx((1 - 0.05 - 0.94) * r.var(ddof=1), rel=1e-9) + assert len(sigma) == len(r) + assert (sigma > 0).all() + + +def test_filter_mle_mode_is_tagged_and_differs(): + """The source tag is what makes 'estimated vs assumed' auditable downstream.""" + r = simulate_garch(n=1200, seed=4) + s_fixed, i_fixed = garch11_filter(r, fit=False) + s_mle, i_mle = garch11_filter(r, fit=True) + + assert i_fixed["source"] == "fixed" + assert i_mle["source"] == "MLE" + assert not np.allclose(s_fixed.values, s_mle.values) + + +def test_filter_falls_back_rather_than_raising_on_short_series(): + """A stress panel should not vanish because an optimiser had a bad day.""" + r = pd.Series(np.random.default_rng(0).normal(0, 0.01, 20)) + sigma, info = garch11_filter(r, fit=True) + assert info["source"] == "fixed" + assert "fallback_reason" in info + assert len(sigma) == len(r) + + +def test_forecast_matches_recursion(): + assert garch11_forecast_next(0.02, 0.01, 1e-6, 0.05, 0.94) == pytest.approx( + np.sqrt(1e-6 + 0.05 * 0.02 ** 2 + 0.94 * 0.01 ** 2), rel=1e-12 + ) + + +def test_filter_responds_to_volatility_clustering(): + """A calm period followed by a shock must raise the conditional volatility.""" + r = pd.Series(np.concatenate([ + np.random.default_rng(0).normal(0, 0.005, 300), + np.random.default_rng(1).normal(0, 0.04, 100), + ])) + sigma, _ = garch11_filter(r, fit=False) + assert sigma.iloc[-1] > 3 * sigma.iloc[290] diff --git a/tests/test_market_risk.py b/tests/test_market_risk.py new file mode 100644 index 0000000..848b6fd --- /dev/null +++ b/tests/test_market_risk.py @@ -0,0 +1,218 @@ +""" +Tests for the market risk measurement layer. + +Property-based rather than golden-value: these assertions hold for ANY input, +so they test the contract rather than a frozen number, and they do not break +when a default changes. + +Includes regression tests for two bugs found in the pre-refactor code: + - MarketRiskConfig.fit_garch was accepted but never forwarded, so the MLE + path was unreachable and the flag silently did nothing. + - MarketRiskConfig had no Monte Carlo fields, so n_sims / seed / shrinkage + were always the function defaults regardless of configuration. +""" + +import numpy as np +import pytest + +from risklib.market.market_risk_model import ( + METHODS, + MarketRiskConfig, + MarketRiskModel, + cov_shrink, + es_historical, + es_parametric, + var_historical, + var_parametric, +) + + +# --------------------------------------------------------------------------- +# Loss-convention invariants +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("method", METHODS) +def test_loss_convention_holds_for_every_method(returns_3, weights_3, method): + """VaR >= 0 and ES >= VaR, for all four methodologies.""" + cfg = MarketRiskConfig(alpha=0.99, method=method, exposure=1_000_000) + m = MarketRiskModel(returns_3, weights_3, cfg).fit() + assert m.compute_var() >= 0 + assert m.compute_es() >= m.compute_var() + + +@pytest.mark.parametrize("method", METHODS) +def test_var_monotonic_in_alpha(returns_3, weights_3, method): + """A stricter confidence level cannot produce a smaller loss estimate.""" + v = [] + for a in (0.95, 0.975, 0.99): + cfg = MarketRiskConfig(alpha=a, method=method, exposure=1_000_000) + v.append(MarketRiskModel(returns_3, weights_3, cfg).fit().compute_var()) + assert v[0] <= v[1] <= v[2] + + +def test_var_scales_linearly_with_exposure(returns_3, weights_3): + """VaR is homogeneous of degree 1 in exposure — catches scaling/sign errors.""" + base = var_parametric(returns_3, weights_3, alpha=0.99, exposure=1_000_000) + doubled = var_parametric(returns_3, weights_3, alpha=0.99, exposure=2_000_000) + assert doubled == pytest.approx(2.0 * base, rel=1e-12) + + +def test_es_strictly_exceeds_var_on_continuous_data(returns_3, weights_3): + v = var_parametric(returns_3, weights_3, alpha=0.95, exposure=1e6) + e = es_parametric(returns_3, weights_3, alpha=0.95, exposure=1e6) + assert e > v + + vh = var_historical(returns_3, weights_3, alpha=0.95, exposure=1e6) + eh = es_historical(returns_3, weights_3, alpha=0.95, exposure=1e6) + assert eh > vh + + +def test_parametric_matches_closed_form(returns_3, weights_3): + """Cross-check the estimator against the formula computed independently.""" + from statistics import NormalDist + mu = returns_3.mean().values + cov = returns_3.cov().values + mu_p = float(weights_3 @ mu) + sigma_p = float(np.sqrt(weights_3 @ cov @ weights_3)) + expected = (-mu_p + NormalDist().inv_cdf(0.99) * sigma_p) * 1e6 + assert var_parametric(returns_3, weights_3, alpha=0.99, exposure=1e6) == \ + pytest.approx(expected, rel=1e-12) + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + +def test_config_rejects_unknown_method(): + with pytest.raises(ValueError, match="Unknown method"): + MarketRiskConfig(method="magic") + + +@pytest.mark.parametrize("kwargs", [ + {"alpha": 1.5}, {"alpha": 0.0}, {"horizon_days": 0}, + {"exposure": -1}, {"window": 1}, {"shrink_lambda": 2.0}, +]) +def test_config_rejects_invalid_parameters(kwargs): + with pytest.raises(ValueError): + MarketRiskConfig(**kwargs) + + +def test_config_enforces_garch_stationarity(): + """alpha + beta >= 1 is a non-stationary GARCH and must be refused.""" + with pytest.raises(ValueError, match="stationarity"): + MarketRiskConfig(alpha_g=0.10, beta_g=0.95) + + +def test_model_rejects_weight_length_mismatch(returns_3): + with pytest.raises(ValueError, match="does not match"): + MarketRiskModel(returns_3, np.array([0.5, 0.5]), MarketRiskConfig()) + + +def test_compute_before_fit_raises(returns_3, weights_3): + m = MarketRiskModel(returns_3, weights_3, MarketRiskConfig()) + with pytest.raises(RuntimeError, match="fit\\(\\)"): + m.compute_var() + + +# --------------------------------------------------------------------------- +# Regression: config values must actually reach the computation +# --------------------------------------------------------------------------- + +def test_fit_garch_flag_changes_the_result(returns_3, weights_3): + """ + REGRESSION. `fit_garch=True` was accepted by the config but never forwarded + to fhs_var_es_next, so the MLE path was dead code and the flag was inert + while the documentation described it as a working feature. + """ + fixed = MarketRiskModel( + returns_3, weights_3, + MarketRiskConfig(alpha=0.99, method="fhs", exposure=1e6, fit_garch=False), + ).fit() + mle = MarketRiskModel( + returns_3, weights_3, + MarketRiskConfig(alpha=0.99, method="fhs", exposure=1e6, fit_garch=True), + ).fit() + + assert fixed.fit_info_["source"] == "fixed" + assert mle.fit_info_["source"] == "MLE" + assert mle.compute_var() != fixed.compute_var() + + +def test_assumptions_reflect_the_garch_mode(returns_3, weights_3): + """An MLE-estimated filter must not be reported as fixed-parameter.""" + fixed = MarketRiskModel( + returns_3, weights_3, + MarketRiskConfig(method="fhs", fit_garch=False), + ).fit() + mle = MarketRiskModel( + returns_3, weights_3, + MarketRiskConfig(method="fhs", fit_garch=True), + ).fit() + + assert any("fixed parameters" in a for a in fixed.assumptions()) + assert any("MLE" in a for a in mle.assumptions()) + + +def test_monte_carlo_seed_is_honoured(returns_3, weights_3): + """REGRESSION. Config seed was ignored; MC always ran with the default 42.""" + def run(seed): + cfg = MarketRiskConfig(alpha=0.99, method="monte_carlo", + exposure=1e6, n_sims=20_000, seed=seed) + return MarketRiskModel(returns_3, weights_3, cfg).fit().compute_var() + + assert run(1) != run(2) + assert run(1) == run(1) # and it is reproducible + + +def test_monte_carlo_n_sims_is_honoured(returns_3, weights_3): + """REGRESSION. Config n_sims was ignored; MC always ran 100,000 paths.""" + def run(n): + cfg = MarketRiskConfig(alpha=0.99, method="monte_carlo", + exposure=1e6, n_sims=n, seed=42) + return MarketRiskModel(returns_3, weights_3, cfg).fit().compute_var() + + assert run(5_000) != run(80_000) + + +def test_monte_carlo_converges_to_parametric(returns_3, weights_3): + """On normal data, MC with many paths should approach the closed form.""" + cfg = MarketRiskConfig(alpha=0.99, method="monte_carlo", exposure=1e6, + n_sims=400_000, seed=42, shrink_lambda=0.0) + mc = MarketRiskModel(returns_3, weights_3, cfg).fit().compute_var() + par = var_parametric(returns_3, weights_3, alpha=0.99, exposure=1e6) + assert mc == pytest.approx(par, rel=0.03) + + +# --------------------------------------------------------------------------- +# Covariance shrinkage +# --------------------------------------------------------------------------- + +def test_shrinkage_preserves_variances(correlated_returns): + """Diagonal is untouched; only correlations are pulled in.""" + cov = correlated_returns.cov().values + shrunk = cov_shrink(cov, lam=0.25) + np.testing.assert_allclose(np.diag(cov), np.diag(shrunk), rtol=1e-12) + off = ~np.eye(len(cov), dtype=bool) + np.testing.assert_allclose(shrunk[off], 0.75 * cov[off], rtol=1e-12) + + +def test_shrinkage_keeps_matrix_positive_semidefinite(correlated_returns): + cov = correlated_returns.cov().values + assert np.linalg.eigvalsh(cov_shrink(cov, lam=0.10)).min() > 0 + + +# --------------------------------------------------------------------------- +# Summary contract +# --------------------------------------------------------------------------- + +def test_summary_carries_config_and_assumptions(returns_3, weights_3): + """A risk number without its assumptions is not a deliverable.""" + cfg = MarketRiskConfig(alpha=0.975, method="parametric", + horizon_days=10, exposure=5e6) + s = MarketRiskModel(returns_3, weights_3, cfg).fit().summary() + + assert s["VaR"] > 0 and s["ES"] >= s["VaR"] + assert s["alpha"] == 0.975 and s["method"] == "parametric" + assert s["config"]["horizon_days"] == 10 + assert "normality of portfolio returns" in s["assumptions"] + assert any("square-root-of-time" in a for a in s["assumptions"]) diff --git a/tests/test_market_risk_properties.py b/tests/test_market_risk_properties.py deleted file mode 100644 index 1ae8ccd..0000000 --- a/tests/test_market_risk_properties.py +++ /dev/null @@ -1,28 +0,0 @@ -import numpy as np -import pandas as pd -from risklib.market.market_risk_model import MarketRiskModel, MarketRiskConfig - - -def test_es_ge_var(): - rng = np.random.default_rng(0) - returns = pd.DataFrame(rng.normal(0, 0.01, size=(1000, 3)), columns=["A", "B", "C"]) - w = np.array([0.4, 0.3, 0.3]) - - m = MarketRiskModel(returns, w, MarketRiskConfig(alpha=0.99, method="historical")) - m.fit() - - assert m.compute_var() >= 0 - assert m.compute_es() >= m.compute_var() - - -def test_var_monotonicity(): - rng = np.random.default_rng(1) - returns = pd.DataFrame(rng.normal(0, 0.01, size=(1000, 3)), columns=["A", "B", "C"]) - w = np.array([0.5, 0.2, 0.3]) - - m95 = MarketRiskModel(returns, w, MarketRiskConfig(alpha=0.95, method="historical")) - m99 = MarketRiskModel(returns, w, MarketRiskConfig(alpha=0.99, method="historical")) - m95.fit() - m99.fit() - - assert m99.compute_var() >= m95.compute_var() diff --git a/tests/test_var.py b/tests/test_var.py deleted file mode 100644 index 5f06a7e..0000000 --- a/tests/test_var.py +++ /dev/null @@ -1,21 +0,0 @@ -import numpy as np, pandas as pd -from risk_engine.market import var_parametric, backtest_var_historical - -def fake_returns(n=300, m=3, seed=0): - rng = np.random.default_rng(seed) - dates = pd.date_range("2022-01-01", periods=n, freq="B") - R = pd.DataFrame(rng.normal(0, 0.01, size=(n, m)), index=dates, columns=[f"A{i}" for i in range(m)]) - return R - -def test_var_scales_with_exposure(): - R = fake_returns() - w = np.ones(R.shape[1]) / R.shape[1] - v1 = var_parametric(R, w, alpha=0.99, exposure=1_000_000) - v2 = var_parametric(R, w, alpha=0.99, exposure=2_000_000) - assert 1.9 < (v2 / v1) < 2.1 - -def test_backtest_shapes(): - R = fake_returns() - w = np.ones(R.shape[1]) / R.shape[1] - bt = backtest_var_historical(R, w, alpha=0.95, window=100) - assert "T" in bt and bt["T"] > 0