Spaces:
Running
Running
Commit ·
7bb0af0
0
Parent(s):
feat: initial implementation of VaR engine
Browse filesPortfolio Value-at-Risk computation engine with Historical and
Parametric (variance-covariance) methods, stressed VaR/ES, live
market data via yfinance, and audit-ready Excel export with
embedded formulas.
Includes:
- Gradio web UI with live-updating inputs
- Historical & Parametric VaR/ES calculations
- Stressed metrics over configurable stress windows
- Excel reports with formula-driven cells (no hardcoded values)
- Jupyter notebooks for exploration
- Project scaffolding (pyproject.toml, uv lockfile)
- .gitattributes +4 -0
- .gitignore +13 -0
- README.md +66 -0
- app.py +295 -0
- config.yaml +19 -0
- notebooks/historical.ipynb +229 -0
- notebooks/monte-carlo-simulation.ipynb +474 -0
- notebooks/parametric.ipynb +605 -0
- pyproject.toml +22 -0
- requirements.txt +10 -0
- src/__init__.py +57 -0
- src/config.py +21 -0
- src/excel_export.py +540 -0
- src/historical.py +168 -0
- src/logger.py +37 -0
- src/parametric.py +183 -0
- src/utils.py +240 -0
- uv.lock +0 -0
.gitattributes
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
.gradio
|
| 12 |
+
log/
|
| 13 |
+
output/
|
README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# VaR Engine
|
| 2 |
+
|
| 3 |
+
An interactive web app to compute **Value at Risk (VaR)** and **Expected Shortfall (ES)** for equities, featuring an interactive Gradio UI and **audit-ready Excel reports with embedded formulas**.
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
## Features
|
| 7 |
+
|
| 8 |
+
- **Interactive UI**: Sleek Gradio interface that dynamically updates on input changes.
|
| 9 |
+
- **Live Market Data**: Fetches historical prices via `yfinance`
|
| 10 |
+
- **Historical & Parametric VaR**: Supports both Historical and Parametric (variance-covariance) methods
|
| 11 |
+
- **Stressed VaR/ES**: Computes stressed metrics over a configurable stress window (e.g. GFC 2008)
|
| 12 |
+
- **Audit-Ready Excel**: Exports reports with **formula-driven calculations (no hardcoded outputs)**
|
| 13 |
+
|
| 14 |
+
## Getting Started
|
| 15 |
+
|
| 16 |
+
### Option 1: Using `uv` (Recommended)
|
| 17 |
+
|
| 18 |
+
1. **Sync the environment**:
|
| 19 |
+
```bash
|
| 20 |
+
uv sync
|
| 21 |
+
```
|
| 22 |
+
2. **Run the application**:
|
| 23 |
+
```bash
|
| 24 |
+
gradio app.py
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
### Option 2: Manual Setup (Pip)
|
| 28 |
+
|
| 29 |
+
1. **Create and activate a virtual environment**:
|
| 30 |
+
- **MacOS / Linux**:
|
| 31 |
+
```bash
|
| 32 |
+
python3 -m venv .venv
|
| 33 |
+
source .venv/bin/activate
|
| 34 |
+
```
|
| 35 |
+
- **Windows**:
|
| 36 |
+
```powershell
|
| 37 |
+
python -m venv .venv
|
| 38 |
+
.venv\Scripts\activate
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
2. **Install dependencies**:
|
| 42 |
+
```bash
|
| 43 |
+
pip install -r requirements.txt
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
3. **Run the application**:
|
| 47 |
+
```bash
|
| 48 |
+
gradio app.py
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
The server will launch locally (typically at `http://127.0.0.1:7860` or `http://localhost:7860`). Open this address in your browser to access the VaR Engine.
|
| 52 |
+
|
| 53 |
+
## Project Structure
|
| 54 |
+
|
| 55 |
+
- **`app.py`**: The thin Gradio presentation/UI layer.
|
| 56 |
+
- **`src/`**: Core VaR calculation engine, data processing, and plotting logic.
|
| 57 |
+
- **`config.yaml`**: Application settings (tickers, lookback window, stress period).
|
| 58 |
+
- **`notebooks/`**: Jupyter notebooks for exploratory analysis (historical, parametric, Monte Carlo).
|
| 59 |
+
- **`log/`**: Persistent application logs managed by `loguru`.
|
| 60 |
+
- **`output/`**: Directory for exported Excel report files.
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
## Future Work
|
| 64 |
+
|
| 65 |
+
- **Multi-Asset Portfolio Support (In Progress)**: Extending from single equities to portfolios with configurable weights and aggregation of VaR/ES
|
| 66 |
+
- **Asset Class Expansion (In Progress)**: Adding support for indices, ETFs, bonds, and other instruments
|
app.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py -- Gradio UI for Value at Risk Analysis.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
import gradio as gr
|
| 7 |
+
from src.logger import logger
|
| 8 |
+
from src.config import TICKERS, LOOKBACK_DAYS, STRESS_START_DATE, STRESS_END_DATE, STRESS_LABEL
|
| 9 |
+
from src.historical import historical_var_es_pipeline
|
| 10 |
+
from src.parametric import parametric_var_es_pipeline
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def calculate_var_analysis(
|
| 14 |
+
ticker: str,
|
| 15 |
+
end_date_str: str,
|
| 16 |
+
portfolio_value: float,
|
| 17 |
+
n_days: int,
|
| 18 |
+
var_confidence_label: str,
|
| 19 |
+
es_confidence_label: str,
|
| 20 |
+
method: str,
|
| 21 |
+
):
|
| 22 |
+
"""Calculate Value at Risk analysis based on Gradio inputs and delegate to the analysis pipeline."""
|
| 23 |
+
logger.info(
|
| 24 |
+
f"Analysis requested: {ticker} | VaR={var_confidence_label} ES={es_confidence_label} | {method} | N={n_days} | Date={end_date_str} | PV=${portfolio_value:,.0f}"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
var_confidence = float(var_confidence_label.strip().replace("%", "")) / 100.0
|
| 28 |
+
es_confidence = float(es_confidence_label.strip().replace("%", "")) / 100.0
|
| 29 |
+
var_conf_pct = var_confidence_label.strip()
|
| 30 |
+
es_conf_pct = es_confidence_label.strip()
|
| 31 |
+
|
| 32 |
+
today = pd.Timestamp.today().normalize()
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
end_date = pd.to_datetime(end_date_str, errors="raise").normalize()
|
| 36 |
+
except Exception:
|
| 37 |
+
gr.Warning("Invalid date selection. Please try again.")
|
| 38 |
+
return reset_analysis_results(n_days, var_confidence_label, es_confidence_label, method)
|
| 39 |
+
|
| 40 |
+
if end_date >= today:
|
| 41 |
+
gr.Warning(
|
| 42 |
+
"Invalid date selection. VaR estimation requires historical data, so please choose a date prior to today."
|
| 43 |
+
)
|
| 44 |
+
return reset_analysis_results(n_days, var_confidence_label, es_confidence_label, method)
|
| 45 |
+
|
| 46 |
+
fy_start = pd.Timestamp(year=today.year - 1, month=4, day=1)
|
| 47 |
+
|
| 48 |
+
if end_date < fy_start:
|
| 49 |
+
gr.Warning(f"VaR Date must be after {fy_start.strftime('%Y-%m-%d')}")
|
| 50 |
+
return reset_analysis_results(n_days, var_confidence_label, es_confidence_label, method)
|
| 51 |
+
|
| 52 |
+
if portfolio_value <= 0:
|
| 53 |
+
gr.Warning("Portfolio value must be positive.")
|
| 54 |
+
return reset_analysis_results(n_days, var_confidence_label, es_confidence_label, method)
|
| 55 |
+
|
| 56 |
+
pipeline = historical_var_es_pipeline if method == "Historical VaR" else parametric_var_es_pipeline
|
| 57 |
+
result = pipeline(
|
| 58 |
+
ticker,
|
| 59 |
+
var_confidence,
|
| 60 |
+
es_confidence,
|
| 61 |
+
LOOKBACK_DAYS,
|
| 62 |
+
int(n_days),
|
| 63 |
+
portfolio_value,
|
| 64 |
+
end_date,
|
| 65 |
+
stress_start=STRESS_START_DATE,
|
| 66 |
+
stress_end=STRESS_END_DATE,
|
| 67 |
+
stress_label=STRESS_LABEL,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
logger.success(
|
| 71 |
+
f"Analysis complete: VaR=${result['var_nd']:,.2f}, ES=${result['es_nd']:,.2f}, Excel={result['excel_path']}\n"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
gr.update(
|
| 76 |
+
label=f"{int(n_days)}-day {var_conf_pct} VaR",
|
| 77 |
+
value=f"${result['var_nd']:,.2f}",
|
| 78 |
+
),
|
| 79 |
+
gr.update(
|
| 80 |
+
label=f"{int(n_days)}-day {es_conf_pct} ES",
|
| 81 |
+
value=f"${result['es_nd']:,.2f}",
|
| 82 |
+
),
|
| 83 |
+
gr.update(
|
| 84 |
+
label=f"{int(n_days)}-day {var_conf_pct} Stressed VaR",
|
| 85 |
+
value=f"${result['stressed_var_nd']:,.2f}",
|
| 86 |
+
),
|
| 87 |
+
gr.update(
|
| 88 |
+
label=f"{int(n_days)}-day {es_conf_pct} Stressed ES",
|
| 89 |
+
value=f"${result['stressed_es_nd']:,.2f}",
|
| 90 |
+
),
|
| 91 |
+
gr.update(value=result["fig_dist"], visible=True),
|
| 92 |
+
gr.update(value=result["excel_path"], visible=True),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def reset_analysis_results(n_days: float, var_confidence_label: str, es_confidence_label: str, method: str):
|
| 97 |
+
"""Reset and hide analysis results when input parameters are modified."""
|
| 98 |
+
var_conf_pct = var_confidence_label.strip()
|
| 99 |
+
es_conf_pct = es_confidence_label.strip()
|
| 100 |
+
method_short = "Historical" if method == "Historical VaR" else "Parametric"
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
gr.update(value="", label=f"{int(n_days)}-day {var_conf_pct} VaR"),
|
| 104 |
+
gr.update(value="", label=f"{int(n_days)}-day {es_conf_pct} ES"),
|
| 105 |
+
gr.update(value="", label=f"{int(n_days)}-day {var_conf_pct} Stressed VaR"),
|
| 106 |
+
gr.update(value="", label=f"{int(n_days)}-day {es_conf_pct} Stressed ES"),
|
| 107 |
+
gr.update(value=None, visible=False),
|
| 108 |
+
gr.update(visible=False),
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def enable_run_button_for_method(method: str):
|
| 113 |
+
"""Enable the Run Analysis button only for fully implemented VaR methods."""
|
| 114 |
+
return gr.update(interactive=(method in ("Historical VaR", "Parametric VaR")))
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ------------------------------------------------------------------
|
| 118 |
+
# UI builder
|
| 119 |
+
# ------------------------------------------------------------------
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def build_app() -> gr.Blocks:
|
| 123 |
+
"""Construct and return the Gradio Blocks application."""
|
| 124 |
+
|
| 125 |
+
with gr.Blocks(
|
| 126 |
+
title="VaR Engine",
|
| 127 |
+
) as app:
|
| 128 |
+
with gr.Row():
|
| 129 |
+
with gr.Column(scale=3):
|
| 130 |
+
gr.Markdown("# VaR Engine")
|
| 131 |
+
with gr.Column(scale=1, min_width=260):
|
| 132 |
+
download_file = gr.DownloadButton(
|
| 133 |
+
"Download Excel Report",
|
| 134 |
+
variant="primary",
|
| 135 |
+
visible=False,
|
| 136 |
+
elem_id="excel-btn",
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
with gr.Row():
|
| 140 |
+
# Sidebar
|
| 141 |
+
with gr.Column(scale=1, min_width=260):
|
| 142 |
+
gr.Markdown("### Inputs")
|
| 143 |
+
with gr.Group():
|
| 144 |
+
ticker_dd = gr.Dropdown(
|
| 145 |
+
choices=TICKERS,
|
| 146 |
+
value=TICKERS[0],
|
| 147 |
+
label="Ticker",
|
| 148 |
+
)
|
| 149 |
+
end_date_input = gr.DateTime(
|
| 150 |
+
include_time=False,
|
| 151 |
+
type="datetime",
|
| 152 |
+
label="VaR Date",
|
| 153 |
+
)
|
| 154 |
+
portfolio_val_input = gr.Number(
|
| 155 |
+
value=1000000,
|
| 156 |
+
label="Portfolio Value ($)",
|
| 157 |
+
)
|
| 158 |
+
n_days_slider = gr.Slider(
|
| 159 |
+
minimum=1,
|
| 160 |
+
maximum=15,
|
| 161 |
+
step=1,
|
| 162 |
+
value=10,
|
| 163 |
+
label="N Days for VaR",
|
| 164 |
+
)
|
| 165 |
+
with gr.Row():
|
| 166 |
+
confidence_dd = gr.Dropdown(
|
| 167 |
+
choices=["99%", "97.5%", "95%"],
|
| 168 |
+
value="99%",
|
| 169 |
+
label="VaR Confidence",
|
| 170 |
+
min_width=100,
|
| 171 |
+
)
|
| 172 |
+
es_confidence_dd = gr.Dropdown(
|
| 173 |
+
choices=["99%", "97.5%", "95%"],
|
| 174 |
+
value="99%",
|
| 175 |
+
label="ES Confidence",
|
| 176 |
+
min_width=100,
|
| 177 |
+
)
|
| 178 |
+
method_radio = gr.Radio(
|
| 179 |
+
choices=[
|
| 180 |
+
"Historical VaR",
|
| 181 |
+
"Parametric VaR",
|
| 182 |
+
],
|
| 183 |
+
value="Historical VaR",
|
| 184 |
+
label="Method",
|
| 185 |
+
)
|
| 186 |
+
run_btn = gr.Button("Run Analysis", variant="primary")
|
| 187 |
+
|
| 188 |
+
# Enable/disable run button based on method availability
|
| 189 |
+
method_radio.change(
|
| 190 |
+
fn=enable_run_button_for_method,
|
| 191 |
+
inputs=method_radio,
|
| 192 |
+
outputs=run_btn,
|
| 193 |
+
)
|
| 194 |
+
with gr.Column(scale=3):
|
| 195 |
+
gr.Markdown("### Results")
|
| 196 |
+
with gr.Row():
|
| 197 |
+
with gr.Column(scale=1):
|
| 198 |
+
var_box = gr.Textbox(
|
| 199 |
+
label="10-day 99% VaR",
|
| 200 |
+
interactive=False,
|
| 201 |
+
)
|
| 202 |
+
with gr.Column(scale=1):
|
| 203 |
+
es_box = gr.Textbox(
|
| 204 |
+
label="10-day 99% ES",
|
| 205 |
+
interactive=False,
|
| 206 |
+
)
|
| 207 |
+
with gr.Row():
|
| 208 |
+
with gr.Column(scale=1):
|
| 209 |
+
stressed_var_box = gr.Textbox(
|
| 210 |
+
label="10-day 99% Stressed VaR",
|
| 211 |
+
interactive=False,
|
| 212 |
+
)
|
| 213 |
+
with gr.Column(scale=1):
|
| 214 |
+
stressed_es_box = gr.Textbox(
|
| 215 |
+
label="10-day 99% Stressed ES",
|
| 216 |
+
interactive=False,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
plot_dist = gr.Plot(show_label=False, visible=False)
|
| 220 |
+
|
| 221 |
+
# Wiring
|
| 222 |
+
all_outputs = [var_box, es_box, stressed_var_box, stressed_es_box, plot_dist, download_file]
|
| 223 |
+
|
| 224 |
+
run_btn.click(
|
| 225 |
+
fn=calculate_var_analysis,
|
| 226 |
+
inputs=[
|
| 227 |
+
ticker_dd,
|
| 228 |
+
end_date_input,
|
| 229 |
+
portfolio_val_input,
|
| 230 |
+
n_days_slider,
|
| 231 |
+
confidence_dd,
|
| 232 |
+
es_confidence_dd,
|
| 233 |
+
method_radio,
|
| 234 |
+
],
|
| 235 |
+
outputs=all_outputs,
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
all_inputs = [
|
| 239 |
+
ticker_dd,
|
| 240 |
+
end_date_input,
|
| 241 |
+
portfolio_val_input,
|
| 242 |
+
n_days_slider,
|
| 243 |
+
confidence_dd,
|
| 244 |
+
es_confidence_dd,
|
| 245 |
+
method_radio,
|
| 246 |
+
]
|
| 247 |
+
|
| 248 |
+
label_inputs = [n_days_slider, confidence_dd, es_confidence_dd, method_radio]
|
| 249 |
+
|
| 250 |
+
for comp in all_inputs:
|
| 251 |
+
if comp is n_days_slider:
|
| 252 |
+
comp.release(
|
| 253 |
+
fn=reset_analysis_results,
|
| 254 |
+
inputs=label_inputs,
|
| 255 |
+
outputs=all_outputs,
|
| 256 |
+
)
|
| 257 |
+
else:
|
| 258 |
+
comp.change(
|
| 259 |
+
fn=reset_analysis_results,
|
| 260 |
+
inputs=label_inputs,
|
| 261 |
+
outputs=all_outputs,
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
method_radio.change(
|
| 265 |
+
fn=enable_run_button_for_method, inputs=method_radio, outputs=run_btn
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
return app
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
# ------------------------------------------------------------------
|
| 272 |
+
# Entry point
|
| 273 |
+
# ------------------------------------------------------------------
|
| 274 |
+
|
| 275 |
+
if __name__ == "__main__":
|
| 276 |
+
custom_css = """
|
| 277 |
+
.form { border: none !important; box-shadow: none !important; gap: 0 !important; }
|
| 278 |
+
.form .block, .form .row, .form > * { border: none !important; box-shadow: none !important; }
|
| 279 |
+
#excel-btn, #excel-btn.primary {
|
| 280 |
+
background: #ea580c !important;
|
| 281 |
+
background-color: #ea580c !important;
|
| 282 |
+
color: white !important;
|
| 283 |
+
border-color: #7c2d12 !important;
|
| 284 |
+
border: 1px solid #7c2d12 !important;
|
| 285 |
+
}
|
| 286 |
+
#excel-btn:hover, #excel-btn.primary:hover {
|
| 287 |
+
background: #c2410c !important;
|
| 288 |
+
background-color: #c2410c !important;
|
| 289 |
+
border-color: #451a03 !important;
|
| 290 |
+
border: 1px solid #451a03 !important;
|
| 291 |
+
}
|
| 292 |
+
"""
|
| 293 |
+
|
| 294 |
+
application = build_app()
|
| 295 |
+
application.launch(share=True, theme=gr.themes.Base(), css=custom_css)
|
config.yaml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ------------------------------------------------------------------
|
| 2 |
+
# VaR Engine – Application Settings
|
| 3 |
+
# ------------------------------------------------------------------
|
| 4 |
+
|
| 5 |
+
tickers:
|
| 6 |
+
- AAPL
|
| 7 |
+
- MSFT
|
| 8 |
+
- GOOG
|
| 9 |
+
- AMZN
|
| 10 |
+
- NVDA
|
| 11 |
+
- JPM
|
| 12 |
+
- BCS
|
| 13 |
+
|
| 14 |
+
lookback_days: 251
|
| 15 |
+
|
| 16 |
+
# Stress window used for Stressed VaR/ES
|
| 17 |
+
stressed_period_label: "Global Financial Crisis (2008)"
|
| 18 |
+
stressed_period_start_date: "2008-01-01"
|
| 19 |
+
stressed_period_end_date: "2008-12-31"
|
notebooks/historical.ipynb
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "title",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# Historical VaR"
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"cell_type": "markdown",
|
| 13 |
+
"id": "e210b117",
|
| 14 |
+
"metadata": {},
|
| 15 |
+
"source": [
|
| 16 |
+
"## 1. Imports"
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"cell_type": "code",
|
| 21 |
+
"execution_count": null,
|
| 22 |
+
"id": "imports",
|
| 23 |
+
"metadata": {},
|
| 24 |
+
"outputs": [],
|
| 25 |
+
"source": "import numpy as np\nimport pandas as pd\nimport plotly.graph_objects as go\nimport yfinance as yf\nfrom datetime import datetime\nimport warnings\nwarnings.filterwarnings('ignore')"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"cell_type": "code",
|
| 29 |
+
"execution_count": 2,
|
| 30 |
+
"id": "c0717aab",
|
| 31 |
+
"metadata": {},
|
| 32 |
+
"outputs": [
|
| 33 |
+
{
|
| 34 |
+
"name": "stdout",
|
| 35 |
+
"output_type": "stream",
|
| 36 |
+
"text": [
|
| 37 |
+
"Analyzing GOOG with 99% confidence\n",
|
| 38 |
+
"Lookback period: 251 days, Horizon: 10 days\n",
|
| 39 |
+
"Portfolio value: $1,000,000\n"
|
| 40 |
+
]
|
| 41 |
+
}
|
| 42 |
+
],
|
| 43 |
+
"source": [
|
| 44 |
+
"# Set up parameters\n",
|
| 45 |
+
"TICKER = 'GOOG'\n",
|
| 46 |
+
"CONFIDENCE_LEVEL = 0.99\n",
|
| 47 |
+
"LOOKBACK_DAYS = 251 # ~1 year of trading days\n",
|
| 48 |
+
"HORIZON_DAYS = 10\n",
|
| 49 |
+
"PORTFOLIO_VALUE = 1_000_000\n",
|
| 50 |
+
"\n",
|
| 51 |
+
"print(f\"Analyzing {TICKER} with {CONFIDENCE_LEVEL:.0%} confidence\")\n",
|
| 52 |
+
"print(f\"Lookback period: {LOOKBACK_DAYS} days, Horizon: {HORIZON_DAYS} days\")\n",
|
| 53 |
+
"print(f\"Portfolio value: ${PORTFOLIO_VALUE:,}\")"
|
| 54 |
+
]
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"cell_type": "markdown",
|
| 58 |
+
"id": "bc738b1a",
|
| 59 |
+
"metadata": {},
|
| 60 |
+
"source": [
|
| 61 |
+
"## 1. Fetch Prices "
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"cell_type": "code",
|
| 66 |
+
"execution_count": 3,
|
| 67 |
+
"id": "fetch_data",
|
| 68 |
+
"metadata": {},
|
| 69 |
+
"outputs": [],
|
| 70 |
+
"source": [
|
| 71 |
+
"def fetch_prices(ticker, lookback, var_date=None):\n",
|
| 72 |
+
" \"\"\"Fetch daily close prices for a ticker.\n",
|
| 73 |
+
"\n",
|
| 74 |
+
" Gets the last `lookback` trading days of data up to the day before `var_date`.\n",
|
| 75 |
+
" If `var_date` is not given, it uses the last business day.\n",
|
| 76 |
+
" \"\"\"\n",
|
| 77 |
+
" # if the var date is none fetch the last business day\n",
|
| 78 |
+
" if var_date is None:\n",
|
| 79 |
+
" var_date = (pd.Timestamp.today() - pd.offsets.BDay()).date()\n",
|
| 80 |
+
" \n",
|
| 81 |
+
" calendar_days = int(lookback * 1.6)\n",
|
| 82 |
+
" start = var_date - pd.Timedelta(days=calendar_days)\n",
|
| 83 |
+
"\n",
|
| 84 |
+
" df = yf.download(\n",
|
| 85 |
+
" ticker,\n",
|
| 86 |
+
" start=start.strftime(\"%Y-%m-%d\"),\n",
|
| 87 |
+
" end=var_date.strftime(\"%Y-%m-%d\"),\n",
|
| 88 |
+
" progress=False,\n",
|
| 89 |
+
" interval=\"1d\",\n",
|
| 90 |
+
" auto_adjust=True\n",
|
| 91 |
+
" )\n",
|
| 92 |
+
"\n",
|
| 93 |
+
" if df.empty:\n",
|
| 94 |
+
" raise ValueError(f\"No data returned for ticker '{ticker}'.\")\n",
|
| 95 |
+
" \n",
|
| 96 |
+
" prices = df[\"Close\"].squeeze()\n",
|
| 97 |
+
" prices.name = ticker\n",
|
| 98 |
+
" result = prices.tail(lookback)\n",
|
| 99 |
+
" return result"
|
| 100 |
+
]
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"cell_type": "code",
|
| 104 |
+
"execution_count": 4,
|
| 105 |
+
"id": "ff696443",
|
| 106 |
+
"metadata": {},
|
| 107 |
+
"outputs": [
|
| 108 |
+
{
|
| 109 |
+
"name": "stdout",
|
| 110 |
+
"output_type": "stream",
|
| 111 |
+
"text": [
|
| 112 |
+
"Shape: (251,)\n",
|
| 113 |
+
"Start date: 2025-03-26\n",
|
| 114 |
+
"End date: 2026-03-25\n"
|
| 115 |
+
]
|
| 116 |
+
}
|
| 117 |
+
],
|
| 118 |
+
"source": [
|
| 119 |
+
"prices = fetch_prices(TICKER, LOOKBACK_DAYS)\n",
|
| 120 |
+
"print(f\"Shape: {prices.shape}\")\n",
|
| 121 |
+
"print(f\"Start date: {prices.index.min().date()}\")\n",
|
| 122 |
+
"print(f\"End date: {prices.index.max().date()}\")"
|
| 123 |
+
]
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"cell_type": "markdown",
|
| 127 |
+
"id": "614eb489",
|
| 128 |
+
"metadata": {},
|
| 129 |
+
"source": [
|
| 130 |
+
"## 2. Return Calculation\n",
|
| 131 |
+
"\n",
|
| 132 |
+
"Calculate daily returns from the price data."
|
| 133 |
+
]
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"cell_type": "code",
|
| 137 |
+
"execution_count": null,
|
| 138 |
+
"id": "a2d126c7",
|
| 139 |
+
"metadata": {},
|
| 140 |
+
"outputs": [],
|
| 141 |
+
"source": "def compute_returns(prices, kind=\"arithmetic\"):\n \"\"\"Compute daily returns from a price series.\n\n kind : \"arithmetic\" or \"log\"\n arithmetic -> (P_t - P_{t-1}) / P_{t-1}\n log -> log(P_t) - log(P_{t-1})\n \"\"\"\n if kind == \"log\":\n returns = np.log(prices) - np.log(prices.shift(1))\n returns.name = \"Daily Log Return\"\n else:\n returns = (prices - prices.shift(1)) / prices.shift(1)\n returns.name = \"Daily Return\"\n return returns.dropna()"
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"cell_type": "code",
|
| 145 |
+
"execution_count": null,
|
| 146 |
+
"id": "ba2e12a3",
|
| 147 |
+
"metadata": {},
|
| 148 |
+
"outputs": [],
|
| 149 |
+
"source": "# Calculate returns\ndaily_returns = compute_returns(prices, kind=\"arithmetic\")\n\nprint(\"Daily return series:\")\nprint(\"Shape: \", daily_returns.shape)\nprint(\"\\n \", daily_returns.head())"
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"cell_type": "markdown",
|
| 153 |
+
"id": "3aba51de",
|
| 154 |
+
"metadata": {},
|
| 155 |
+
"source": [
|
| 156 |
+
"## 3. Calculate VaR and ES"
|
| 157 |
+
]
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"cell_type": "code",
|
| 161 |
+
"execution_count": null,
|
| 162 |
+
"id": "calculate_var_es",
|
| 163 |
+
"metadata": {},
|
| 164 |
+
"outputs": [],
|
| 165 |
+
"source": "def calculate_historical_var(returns, confidence):\n \"\"\"Compute VaR from returns using the percentile method.\n\n VaR is the (1 - confidence) percentile of returns, negated to express as loss.\n Returns VaR as a positive loss fraction.\n \"\"\"\n vals = returns.values\n return -float(np.percentile(np.asarray(vals), (1.0 - confidence) * 100))\n\n\ndef calculate_historical_es(returns, confidence):\n \"\"\"Compute ES from returns using the percentile method.\n\n ES = E[loss | loss > VaR], the mean of losses exceeding VaR.\n Returns ES as a positive loss fraction.\n \"\"\"\n var = calculate_historical_var(returns, confidence)\n losses = -np.asarray(returns.values)\n tail = losses[losses > var]\n return float(np.mean(tail)) if len(tail) > 0 else var"
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"cell_type": "code",
|
| 169 |
+
"execution_count": null,
|
| 170 |
+
"id": "0368a7bb",
|
| 171 |
+
"metadata": {},
|
| 172 |
+
"outputs": [],
|
| 173 |
+
"source": "var_pct = calculate_historical_var(daily_returns, CONFIDENCE_LEVEL)\nes_pct = calculate_historical_es(daily_returns, CONFIDENCE_LEVEL)\nprint(f\"VaR: {var_pct:.4f} ({var_pct*100:.2f}%)\")\nprint(f\"ES: {es_pct:.4f} ({es_pct*100:.2f}%)\")"
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"cell_type": "markdown",
|
| 177 |
+
"id": "analysis_section",
|
| 178 |
+
"metadata": {},
|
| 179 |
+
"source": [
|
| 180 |
+
"## 5. Orchestration of the workflow"
|
| 181 |
+
]
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
"cell_type": "code",
|
| 185 |
+
"execution_count": null,
|
| 186 |
+
"id": "12a3fbb2",
|
| 187 |
+
"metadata": {},
|
| 188 |
+
"outputs": [],
|
| 189 |
+
"source": "def historical_var_es_pipeline(ticker, confidence, lookback, n_days, portfolio_value, end_date=None):\n \"\"\"Run the full historical VaR workflow.\n\n Fetches data, computes 1-day VaR/ES, and scales results to n-day horizon.\n Returns dollar VaR/ES along with the underlying data.\n \"\"\"\n # 1. Fetch data and compute returns\n prices = fetch_prices(ticker, lookback, end_date)\n daily_returns = compute_returns(prices, kind=\"arithmetic\")\n\n # 2. Calculate 1-day VaR and ES\n var_1d_pct = calculate_historical_var(daily_returns, confidence)\n es_1d_pct = calculate_historical_es(daily_returns, confidence)\n var_1d = var_1d_pct * portfolio_value\n es_1d = es_1d_pct * portfolio_value\n\n # 3. Scale to N-day horizon\n scaling_factor = np.sqrt(n_days)\n var_nd = var_1d * scaling_factor\n es_nd = es_1d * scaling_factor\n\n return {\n \"var_1d\": var_1d,\n \"var_nd\": var_nd,\n \"es_1d\": es_1d,\n \"es_nd\": es_nd,\n \"prices\": prices,\n \"daily_returns\": daily_returns,\n }"
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"cell_type": "code",
|
| 193 |
+
"execution_count": null,
|
| 194 |
+
"id": "2609e970",
|
| 195 |
+
"metadata": {},
|
| 196 |
+
"outputs": [],
|
| 197 |
+
"source": "results = historical_var_es_pipeline(\n ticker=TICKER,\n confidence=CONFIDENCE_LEVEL,\n lookback=LOOKBACK_DAYS,\n n_days=HORIZON_DAYS,\n portfolio_value=PORTFOLIO_VALUE)"
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"cell_type": "code",
|
| 201 |
+
"execution_count": null,
|
| 202 |
+
"id": "summary",
|
| 203 |
+
"metadata": {},
|
| 204 |
+
"outputs": [],
|
| 205 |
+
"source": "print(\"=\" * 60)\nprint(f\"HISTORICAL VaR ANALYSIS SUMMARY - {TICKER}\")\nprint(\"=\" * 60)\nprint(f\"VaR Date: {datetime.now().strftime('%Y-%m-%d')}\")\nprint(f\"Portfolio Value: ${PORTFOLIO_VALUE:,}\")\nprint(f\"Confidence Level: {CONFIDENCE_LEVEL:.0%}\")\nprint(f\"Time Horizon: {HORIZON_DAYS} days\")\nprint(f\"Historical Period: {LOOKBACK_DAYS} trading days\")\nprint()\nprint(\"VaR METRICS:\")\nprint('-'*60)\nprint(f\" {HORIZON_DAYS}-Day VaR: ${results['var_nd']:,.2f} ({results['var_nd']/PORTFOLIO_VALUE*100:.2f}%)\")\nprint(f\" {HORIZON_DAYS}-Day ES: ${results['es_nd']:,.2f} ({results['es_nd']/PORTFOLIO_VALUE*100:.2f}%)\")\nprint()"
|
| 206 |
+
}
|
| 207 |
+
],
|
| 208 |
+
"metadata": {
|
| 209 |
+
"kernelspec": {
|
| 210 |
+
"display_name": ".venv",
|
| 211 |
+
"language": "python",
|
| 212 |
+
"name": "python3"
|
| 213 |
+
},
|
| 214 |
+
"language_info": {
|
| 215 |
+
"codemirror_mode": {
|
| 216 |
+
"name": "ipython",
|
| 217 |
+
"version": 3
|
| 218 |
+
},
|
| 219 |
+
"file_extension": ".py",
|
| 220 |
+
"mimetype": "text/x-python",
|
| 221 |
+
"name": "python",
|
| 222 |
+
"nbconvert_exporter": "python",
|
| 223 |
+
"pygments_lexer": "ipython3",
|
| 224 |
+
"version": "3.13.5"
|
| 225 |
+
}
|
| 226 |
+
},
|
| 227 |
+
"nbformat": 4,
|
| 228 |
+
"nbformat_minor": 5
|
| 229 |
+
}
|
notebooks/monte-carlo-simulation.ipynb
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "title",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# Monte Carlo VaR — Step by Step"
|
| 9 |
+
]
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"cell_type": "code",
|
| 13 |
+
"execution_count": null,
|
| 14 |
+
"id": "c1",
|
| 15 |
+
"metadata": {},
|
| 16 |
+
"outputs": [],
|
| 17 |
+
"source": [
|
| 18 |
+
"import numpy as np\n",
|
| 19 |
+
"import matplotlib.pyplot as plt\n",
|
| 20 |
+
"import yfinance as yf\n",
|
| 21 |
+
"import warnings\n",
|
| 22 |
+
"warnings.filterwarnings('ignore')"
|
| 23 |
+
]
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"cell_type": "code",
|
| 27 |
+
"execution_count": null,
|
| 28 |
+
"id": "c2",
|
| 29 |
+
"metadata": {},
|
| 30 |
+
"outputs": [
|
| 31 |
+
{
|
| 32 |
+
"data": {
|
| 33 |
+
"text/plain": [
|
| 34 |
+
"array([0.4 , 0.35, 0.25])"
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
"execution_count": 26,
|
| 38 |
+
"metadata": {},
|
| 39 |
+
"output_type": "execute_result"
|
| 40 |
+
}
|
| 41 |
+
],
|
| 42 |
+
"source": [
|
| 43 |
+
"TICKERS = ['AAPL', 'GOOGL', 'MSFT']\n",
|
| 44 |
+
"WEIGHTS = np.array([0.40, 0.35, 0.25])\n",
|
| 45 |
+
"PORT_VALUE = 1_000_000\n",
|
| 46 |
+
"np.random.seed(42)\n",
|
| 47 |
+
"\n",
|
| 48 |
+
"WEIGHTS"
|
| 49 |
+
]
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"cell_type": "code",
|
| 53 |
+
"execution_count": 27,
|
| 54 |
+
"id": "c3",
|
| 55 |
+
"metadata": {},
|
| 56 |
+
"outputs": [
|
| 57 |
+
{
|
| 58 |
+
"name": "stderr",
|
| 59 |
+
"output_type": "stream",
|
| 60 |
+
"text": [
|
| 61 |
+
"[*********************100%***********************] 3 of 3 completed\n"
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"data": {
|
| 66 |
+
"text/html": [
|
| 67 |
+
"<div>\n",
|
| 68 |
+
"<style scoped>\n",
|
| 69 |
+
" .dataframe tbody tr th:only-of-type {\n",
|
| 70 |
+
" vertical-align: middle;\n",
|
| 71 |
+
" }\n",
|
| 72 |
+
"\n",
|
| 73 |
+
" .dataframe tbody tr th {\n",
|
| 74 |
+
" vertical-align: top;\n",
|
| 75 |
+
" }\n",
|
| 76 |
+
"\n",
|
| 77 |
+
" .dataframe thead th {\n",
|
| 78 |
+
" text-align: right;\n",
|
| 79 |
+
" }\n",
|
| 80 |
+
"</style>\n",
|
| 81 |
+
"<table border=\"1\" class=\"dataframe\">\n",
|
| 82 |
+
" <thead>\n",
|
| 83 |
+
" <tr style=\"text-align: right;\">\n",
|
| 84 |
+
" <th>Ticker</th>\n",
|
| 85 |
+
" <th>AAPL</th>\n",
|
| 86 |
+
" <th>GOOGL</th>\n",
|
| 87 |
+
" <th>MSFT</th>\n",
|
| 88 |
+
" </tr>\n",
|
| 89 |
+
" <tr>\n",
|
| 90 |
+
" <th>Date</th>\n",
|
| 91 |
+
" <th></th>\n",
|
| 92 |
+
" <th></th>\n",
|
| 93 |
+
" <th></th>\n",
|
| 94 |
+
" </tr>\n",
|
| 95 |
+
" </thead>\n",
|
| 96 |
+
" <tbody>\n",
|
| 97 |
+
" <tr>\n",
|
| 98 |
+
" <th>2025-12-24</th>\n",
|
| 99 |
+
" <td>273.554016</td>\n",
|
| 100 |
+
" <td>314.089996</td>\n",
|
| 101 |
+
" <td>486.908630</td>\n",
|
| 102 |
+
" </tr>\n",
|
| 103 |
+
" <tr>\n",
|
| 104 |
+
" <th>2025-12-26</th>\n",
|
| 105 |
+
" <td>273.144409</td>\n",
|
| 106 |
+
" <td>313.510010</td>\n",
|
| 107 |
+
" <td>486.599365</td>\n",
|
| 108 |
+
" </tr>\n",
|
| 109 |
+
" <tr>\n",
|
| 110 |
+
" <th>2025-12-29</th>\n",
|
| 111 |
+
" <td>273.504089</td>\n",
|
| 112 |
+
" <td>313.559998</td>\n",
|
| 113 |
+
" <td>485.990753</td>\n",
|
| 114 |
+
" </tr>\n",
|
| 115 |
+
" <tr>\n",
|
| 116 |
+
" <th>2025-12-30</th>\n",
|
| 117 |
+
" <td>272.824707</td>\n",
|
| 118 |
+
" <td>313.850006</td>\n",
|
| 119 |
+
" <td>486.369904</td>\n",
|
| 120 |
+
" </tr>\n",
|
| 121 |
+
" <tr>\n",
|
| 122 |
+
" <th>2025-12-31</th>\n",
|
| 123 |
+
" <td>271.605835</td>\n",
|
| 124 |
+
" <td>313.000000</td>\n",
|
| 125 |
+
" <td>482.518677</td>\n",
|
| 126 |
+
" </tr>\n",
|
| 127 |
+
" </tbody>\n",
|
| 128 |
+
"</table>\n",
|
| 129 |
+
"</div>"
|
| 130 |
+
],
|
| 131 |
+
"text/plain": [
|
| 132 |
+
"Ticker AAPL GOOGL MSFT\n",
|
| 133 |
+
"Date \n",
|
| 134 |
+
"2025-12-24 273.554016 314.089996 486.908630\n",
|
| 135 |
+
"2025-12-26 273.144409 313.510010 486.599365\n",
|
| 136 |
+
"2025-12-29 273.504089 313.559998 485.990753\n",
|
| 137 |
+
"2025-12-30 272.824707 313.850006 486.369904\n",
|
| 138 |
+
"2025-12-31 271.605835 313.000000 482.518677"
|
| 139 |
+
]
|
| 140 |
+
},
|
| 141 |
+
"execution_count": 27,
|
| 142 |
+
"metadata": {},
|
| 143 |
+
"output_type": "execute_result"
|
| 144 |
+
}
|
| 145 |
+
],
|
| 146 |
+
"source": [
|
| 147 |
+
"prices = yf.download(TICKERS, start='2023-01-01', end='2026-01-01', auto_adjust=True)['Close'][TICKERS].dropna()\n",
|
| 148 |
+
"prices.tail()"
|
| 149 |
+
]
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"cell_type": "code",
|
| 153 |
+
"execution_count": 28,
|
| 154 |
+
"id": "c4",
|
| 155 |
+
"metadata": {},
|
| 156 |
+
"outputs": [
|
| 157 |
+
{
|
| 158 |
+
"data": {
|
| 159 |
+
"text/html": [
|
| 160 |
+
"<div>\n",
|
| 161 |
+
"<style scoped>\n",
|
| 162 |
+
" .dataframe tbody tr th:only-of-type {\n",
|
| 163 |
+
" vertical-align: middle;\n",
|
| 164 |
+
" }\n",
|
| 165 |
+
"\n",
|
| 166 |
+
" .dataframe tbody tr th {\n",
|
| 167 |
+
" vertical-align: top;\n",
|
| 168 |
+
" }\n",
|
| 169 |
+
"\n",
|
| 170 |
+
" .dataframe thead th {\n",
|
| 171 |
+
" text-align: right;\n",
|
| 172 |
+
" }\n",
|
| 173 |
+
"</style>\n",
|
| 174 |
+
"<table border=\"1\" class=\"dataframe\">\n",
|
| 175 |
+
" <thead>\n",
|
| 176 |
+
" <tr style=\"text-align: right;\">\n",
|
| 177 |
+
" <th>Ticker</th>\n",
|
| 178 |
+
" <th>AAPL</th>\n",
|
| 179 |
+
" <th>GOOGL</th>\n",
|
| 180 |
+
" <th>MSFT</th>\n",
|
| 181 |
+
" </tr>\n",
|
| 182 |
+
" </thead>\n",
|
| 183 |
+
" <tbody>\n",
|
| 184 |
+
" <tr>\n",
|
| 185 |
+
" <th>count</th>\n",
|
| 186 |
+
" <td>751.0000</td>\n",
|
| 187 |
+
" <td>751.0000</td>\n",
|
| 188 |
+
" <td>751.0000</td>\n",
|
| 189 |
+
" </tr>\n",
|
| 190 |
+
" <tr>\n",
|
| 191 |
+
" <th>mean</th>\n",
|
| 192 |
+
" <td>0.0011</td>\n",
|
| 193 |
+
" <td>0.0017</td>\n",
|
| 194 |
+
" <td>0.0010</td>\n",
|
| 195 |
+
" </tr>\n",
|
| 196 |
+
" <tr>\n",
|
| 197 |
+
" <th>std</th>\n",
|
| 198 |
+
" <td>0.0160</td>\n",
|
| 199 |
+
" <td>0.0190</td>\n",
|
| 200 |
+
" <td>0.0146</td>\n",
|
| 201 |
+
" </tr>\n",
|
| 202 |
+
" <tr>\n",
|
| 203 |
+
" <th>min</th>\n",
|
| 204 |
+
" <td>-0.0970</td>\n",
|
| 205 |
+
" <td>-0.0999</td>\n",
|
| 206 |
+
" <td>-0.0638</td>\n",
|
| 207 |
+
" </tr>\n",
|
| 208 |
+
" <tr>\n",
|
| 209 |
+
" <th>25%</th>\n",
|
| 210 |
+
" <td>-0.0067</td>\n",
|
| 211 |
+
" <td>-0.0087</td>\n",
|
| 212 |
+
" <td>-0.0068</td>\n",
|
| 213 |
+
" </tr>\n",
|
| 214 |
+
" <tr>\n",
|
| 215 |
+
" <th>50%</th>\n",
|
| 216 |
+
" <td>0.0013</td>\n",
|
| 217 |
+
" <td>0.0023</td>\n",
|
| 218 |
+
" <td>0.0012</td>\n",
|
| 219 |
+
" </tr>\n",
|
| 220 |
+
" <tr>\n",
|
| 221 |
+
" <th>75%</th>\n",
|
| 222 |
+
" <td>0.0087</td>\n",
|
| 223 |
+
" <td>0.0116</td>\n",
|
| 224 |
+
" <td>0.0091</td>\n",
|
| 225 |
+
" </tr>\n",
|
| 226 |
+
" <tr>\n",
|
| 227 |
+
" <th>max</th>\n",
|
| 228 |
+
" <td>0.1426</td>\n",
|
| 229 |
+
" <td>0.0973</td>\n",
|
| 230 |
+
" <td>0.0965</td>\n",
|
| 231 |
+
" </tr>\n",
|
| 232 |
+
" </tbody>\n",
|
| 233 |
+
"</table>\n",
|
| 234 |
+
"</div>"
|
| 235 |
+
],
|
| 236 |
+
"text/plain": [
|
| 237 |
+
"Ticker AAPL GOOGL MSFT\n",
|
| 238 |
+
"count 751.0000 751.0000 751.0000\n",
|
| 239 |
+
"mean 0.0011 0.0017 0.0010\n",
|
| 240 |
+
"std 0.0160 0.0190 0.0146\n",
|
| 241 |
+
"min -0.0970 -0.0999 -0.0638\n",
|
| 242 |
+
"25% -0.0067 -0.0087 -0.0068\n",
|
| 243 |
+
"50% 0.0013 0.0023 0.0012\n",
|
| 244 |
+
"75% 0.0087 0.0116 0.0091\n",
|
| 245 |
+
"max 0.1426 0.0973 0.0965"
|
| 246 |
+
]
|
| 247 |
+
},
|
| 248 |
+
"execution_count": 28,
|
| 249 |
+
"metadata": {},
|
| 250 |
+
"output_type": "execute_result"
|
| 251 |
+
}
|
| 252 |
+
],
|
| 253 |
+
"source": [
|
| 254 |
+
"log_returns = np.log(prices / prices.shift(1)).dropna()\n",
|
| 255 |
+
"log_returns.describe().round(4)"
|
| 256 |
+
]
|
| 257 |
+
},
|
| 258 |
+
{
|
| 259 |
+
"cell_type": "code",
|
| 260 |
+
"execution_count": 29,
|
| 261 |
+
"id": "c5",
|
| 262 |
+
"metadata": {},
|
| 263 |
+
"outputs": [
|
| 264 |
+
{
|
| 265 |
+
"data": {
|
| 266 |
+
"text/plain": [
|
| 267 |
+
"array([0.00105378, 0.00168275, 0.00096676])"
|
| 268 |
+
]
|
| 269 |
+
},
|
| 270 |
+
"execution_count": 29,
|
| 271 |
+
"metadata": {},
|
| 272 |
+
"output_type": "execute_result"
|
| 273 |
+
}
|
| 274 |
+
],
|
| 275 |
+
"source": [
|
| 276 |
+
"mu = log_returns.mean().values\n",
|
| 277 |
+
"mu"
|
| 278 |
+
]
|
| 279 |
+
},
|
| 280 |
+
{
|
| 281 |
+
"cell_type": "code",
|
| 282 |
+
"execution_count": 30,
|
| 283 |
+
"id": "c6",
|
| 284 |
+
"metadata": {},
|
| 285 |
+
"outputs": [
|
| 286 |
+
{
|
| 287 |
+
"data": {
|
| 288 |
+
"text/plain": [
|
| 289 |
+
"array([[0.00025692, 0.00014008, 0.00011358],\n",
|
| 290 |
+
" [0.00014008, 0.00036247, 0.00013643],\n",
|
| 291 |
+
" [0.00011358, 0.00013643, 0.00021204]])"
|
| 292 |
+
]
|
| 293 |
+
},
|
| 294 |
+
"execution_count": 30,
|
| 295 |
+
"metadata": {},
|
| 296 |
+
"output_type": "execute_result"
|
| 297 |
+
}
|
| 298 |
+
],
|
| 299 |
+
"source": [
|
| 300 |
+
"cov = log_returns.cov().values\n",
|
| 301 |
+
"cov"
|
| 302 |
+
]
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
"cell_type": "code",
|
| 306 |
+
"execution_count": 35,
|
| 307 |
+
"id": "c7",
|
| 308 |
+
"metadata": {},
|
| 309 |
+
"outputs": [
|
| 310 |
+
{
|
| 311 |
+
"data": {
|
| 312 |
+
"text/plain": [
|
| 313 |
+
"array([1472.72241061, 1118.21086262, 518.11465968])"
|
| 314 |
+
]
|
| 315 |
+
},
|
| 316 |
+
"execution_count": 35,
|
| 317 |
+
"metadata": {},
|
| 318 |
+
"output_type": "execute_result"
|
| 319 |
+
}
|
| 320 |
+
],
|
| 321 |
+
"source": [
|
| 322 |
+
"S0 = prices.iloc[-1].values\n",
|
| 323 |
+
"shares = (WEIGHTS * PORT_VALUE) / S0\n",
|
| 324 |
+
"shares"
|
| 325 |
+
]
|
| 326 |
+
},
|
| 327 |
+
{
|
| 328 |
+
"cell_type": "code",
|
| 329 |
+
"execution_count": 36,
|
| 330 |
+
"id": "c8",
|
| 331 |
+
"metadata": {},
|
| 332 |
+
"outputs": [
|
| 333 |
+
{
|
| 334 |
+
"data": {
|
| 335 |
+
"text/plain": [
|
| 336 |
+
"(10000, 3)"
|
| 337 |
+
]
|
| 338 |
+
},
|
| 339 |
+
"execution_count": 36,
|
| 340 |
+
"metadata": {},
|
| 341 |
+
"output_type": "execute_result"
|
| 342 |
+
}
|
| 343 |
+
],
|
| 344 |
+
"source": [
|
| 345 |
+
"sim_returns = np.random.multivariate_normal(mu, cov, size=10_000)\n",
|
| 346 |
+
"sim_returns.shape"
|
| 347 |
+
]
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"cell_type": "code",
|
| 351 |
+
"execution_count": 37,
|
| 352 |
+
"id": "c9",
|
| 353 |
+
"metadata": {},
|
| 354 |
+
"outputs": [
|
| 355 |
+
{
|
| 356 |
+
"data": {
|
| 357 |
+
"text/plain": [
|
| 358 |
+
"array([[268.98623474, 310.87375941, 483.01010853],\n",
|
| 359 |
+
" [266.65990125, 306.33240488, 473.68121623],\n",
|
| 360 |
+
" [269.20598714, 303.56541957, 473.94807854]])"
|
| 361 |
+
]
|
| 362 |
+
},
|
| 363 |
+
"execution_count": 37,
|
| 364 |
+
"metadata": {},
|
| 365 |
+
"output_type": "execute_result"
|
| 366 |
+
}
|
| 367 |
+
],
|
| 368 |
+
"source": [
|
| 369 |
+
"sim_prices = S0 * np.exp(sim_returns)\n",
|
| 370 |
+
"sim_prices[:3]"
|
| 371 |
+
]
|
| 372 |
+
},
|
| 373 |
+
{
|
| 374 |
+
"cell_type": "code",
|
| 375 |
+
"execution_count": 38,
|
| 376 |
+
"id": "c10",
|
| 377 |
+
"metadata": {},
|
| 378 |
+
"outputs": [
|
| 379 |
+
{
|
| 380 |
+
"name": "stdout",
|
| 381 |
+
"output_type": "stream",
|
| 382 |
+
"text": [
|
| 383 |
+
"Mean ΔP: $+1,474 | Std: $13,661\n"
|
| 384 |
+
]
|
| 385 |
+
}
|
| 386 |
+
],
|
| 387 |
+
"source": [
|
| 388 |
+
"delta_P = sim_prices @ shares - PORT_VALUE\n",
|
| 389 |
+
"print(f'Mean ΔP: ${np.mean(delta_P):+,.0f} | Std: ${np.std(delta_P):,.0f}')"
|
| 390 |
+
]
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"cell_type": "code",
|
| 394 |
+
"execution_count": 39,
|
| 395 |
+
"id": "c11",
|
| 396 |
+
"metadata": {},
|
| 397 |
+
"outputs": [
|
| 398 |
+
{
|
| 399 |
+
"name": "stdout",
|
| 400 |
+
"output_type": "stream",
|
| 401 |
+
"text": [
|
| 402 |
+
"95% VaR = $21,007 (2.10% of portfolio)\n"
|
| 403 |
+
]
|
| 404 |
+
}
|
| 405 |
+
],
|
| 406 |
+
"source": [
|
| 407 |
+
"var95 = -np.percentile(delta_P, 5)\n",
|
| 408 |
+
"print(f'95% VaR = ${var95:,.0f} ({var95/PORT_VALUE:.2%} of portfolio)')"
|
| 409 |
+
]
|
| 410 |
+
},
|
| 411 |
+
{
|
| 412 |
+
"cell_type": "code",
|
| 413 |
+
"execution_count": 40,
|
| 414 |
+
"id": "c12",
|
| 415 |
+
"metadata": {},
|
| 416 |
+
"outputs": [
|
| 417 |
+
{
|
| 418 |
+
"name": "stdout",
|
| 419 |
+
"output_type": "stream",
|
| 420 |
+
"text": [
|
| 421 |
+
"99% VaR = $29,626 (2.96% of portfolio)\n"
|
| 422 |
+
]
|
| 423 |
+
}
|
| 424 |
+
],
|
| 425 |
+
"source": [
|
| 426 |
+
"var99 = -np.percentile(delta_P, 1)\n",
|
| 427 |
+
"print(f'99% VaR = ${var99:,.0f} ({var99/PORT_VALUE:.2%} of portfolio)')"
|
| 428 |
+
]
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
"cell_type": "code",
|
| 432 |
+
"execution_count": 43,
|
| 433 |
+
"id": "c14",
|
| 434 |
+
"metadata": {},
|
| 435 |
+
"outputs": [
|
| 436 |
+
{
|
| 437 |
+
"data": {
|
| 438 |
+
"image/png": "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",
|
| 439 |
+
"text/plain": [
|
| 440 |
+
"<Figure size 1000x400 with 1 Axes>"
|
| 441 |
+
]
|
| 442 |
+
},
|
| 443 |
+
"metadata": {},
|
| 444 |
+
"output_type": "display_data"
|
| 445 |
+
}
|
| 446 |
+
],
|
| 447 |
+
"source": [
|
| 448 |
+
"plt.figure(figsize=(10, 4))\n",
|
| 449 |
+
"plt.hist(delta_P, bins=80, density=True, alpha=0.7, color='steelblue', edgecolor='none')\n",
|
| 450 |
+
"plt.axvline(-var95, color='orange', lw=2, label=f'95% VaR ${var95:,.0f}')\n",
|
| 451 |
+
"plt.axvline(-var99, color='red', lw=2, label=f'99% VaR ${var99:,.0f}')\n",
|
| 452 |
+
"plt.xlabel('P&L ($)')\n",
|
| 453 |
+
"plt.ylabel('Density')\n",
|
| 454 |
+
"plt.title('1-Day P&L Distribution')\n",
|
| 455 |
+
"plt.legend()\n",
|
| 456 |
+
"plt.tight_layout()\n",
|
| 457 |
+
"plt.show()"
|
| 458 |
+
]
|
| 459 |
+
}
|
| 460 |
+
],
|
| 461 |
+
"metadata": {
|
| 462 |
+
"kernelspec": {
|
| 463 |
+
"display_name": "Python 3",
|
| 464 |
+
"language": "python",
|
| 465 |
+
"name": "python3"
|
| 466 |
+
},
|
| 467 |
+
"language_info": {
|
| 468 |
+
"name": "python",
|
| 469 |
+
"version": "3.9.6"
|
| 470 |
+
}
|
| 471 |
+
},
|
| 472 |
+
"nbformat": 4,
|
| 473 |
+
"nbformat_minor": 5
|
| 474 |
+
}
|
notebooks/parametric.ipynb
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "title",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# Parametric VaR\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"The Parametric (Variance-Covariance) approach assumes that portfolio returns are\n",
|
| 11 |
+
"**normally distributed**. Instead of sorting historical returns, we estimate the\n",
|
| 12 |
+
"mean and standard deviation of the return distribution and use the **z-score**\n",
|
| 13 |
+
"at the desired confidence level to compute VaR analytically."
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"cell_type": "markdown",
|
| 18 |
+
"id": "imports_header",
|
| 19 |
+
"metadata": {},
|
| 20 |
+
"source": [
|
| 21 |
+
"## 1. Imports"
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"cell_type": "code",
|
| 26 |
+
"execution_count": 1,
|
| 27 |
+
"id": "imports",
|
| 28 |
+
"metadata": {},
|
| 29 |
+
"outputs": [],
|
| 30 |
+
"source": [
|
| 31 |
+
"import numpy as np\n",
|
| 32 |
+
"import pandas as pd\n",
|
| 33 |
+
"import yfinance as yf\n",
|
| 34 |
+
"from scipy import stats\n",
|
| 35 |
+
"from datetime import datetime\n",
|
| 36 |
+
"import warnings\n",
|
| 37 |
+
"warnings.filterwarnings('ignore')"
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
"cell_type": "code",
|
| 42 |
+
"execution_count": 2,
|
| 43 |
+
"id": "parameters",
|
| 44 |
+
"metadata": {},
|
| 45 |
+
"outputs": [
|
| 46 |
+
{
|
| 47 |
+
"name": "stdout",
|
| 48 |
+
"output_type": "stream",
|
| 49 |
+
"text": [
|
| 50 |
+
"Analyzing GOOG with 99% confidence\n",
|
| 51 |
+
"Lookback period: 251 days, Horizon: 10 days\n",
|
| 52 |
+
"Portfolio value: $1,000,000\n"
|
| 53 |
+
]
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
"source": [
|
| 57 |
+
"# Set up parameters\n",
|
| 58 |
+
"TICKER = 'GOOG'\n",
|
| 59 |
+
"CONFIDENCE_LEVEL = 0.99\n",
|
| 60 |
+
"LOOKBACK_DAYS = 251 # ~1 year of trading days\n",
|
| 61 |
+
"HORIZON_DAYS = 10\n",
|
| 62 |
+
"PORTFOLIO_VALUE = 1_000_000\n",
|
| 63 |
+
"\n",
|
| 64 |
+
"print(f\"Analyzing {TICKER} with {CONFIDENCE_LEVEL:.0%} confidence\")\n",
|
| 65 |
+
"print(f\"Lookback period: {LOOKBACK_DAYS} days, Horizon: {HORIZON_DAYS} days\")\n",
|
| 66 |
+
"print(f\"Portfolio value: ${PORTFOLIO_VALUE:,}\")"
|
| 67 |
+
]
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"cell_type": "markdown",
|
| 71 |
+
"id": "fetch_header",
|
| 72 |
+
"metadata": {},
|
| 73 |
+
"source": [
|
| 74 |
+
"## 2. Fetch Prices"
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"cell_type": "code",
|
| 79 |
+
"execution_count": 3,
|
| 80 |
+
"id": "fetch_data",
|
| 81 |
+
"metadata": {},
|
| 82 |
+
"outputs": [],
|
| 83 |
+
"source": [
|
| 84 |
+
"def fetch_prices(ticker, lookback, var_date=None):\n",
|
| 85 |
+
" \"\"\"Fetch daily close prices for a ticker.\n",
|
| 86 |
+
"\n",
|
| 87 |
+
" Gets the last `lookback` trading days of data up to the day before `var_date`.\n",
|
| 88 |
+
" If `var_date` is not given, it uses the last business day.\n",
|
| 89 |
+
" \"\"\"\n",
|
| 90 |
+
" if var_date is None:\n",
|
| 91 |
+
" var_date = (pd.Timestamp.today() - pd.offsets.BDay()).date()\n",
|
| 92 |
+
"\n",
|
| 93 |
+
" calendar_days = int(lookback * 1.6)\n",
|
| 94 |
+
" start = var_date - pd.Timedelta(days=calendar_days)\n",
|
| 95 |
+
"\n",
|
| 96 |
+
" df = yf.download(\n",
|
| 97 |
+
" ticker,\n",
|
| 98 |
+
" start=start.strftime(\"%Y-%m-%d\"),\n",
|
| 99 |
+
" end=var_date.strftime(\"%Y-%m-%d\"),\n",
|
| 100 |
+
" progress=False,\n",
|
| 101 |
+
" interval=\"1d\",\n",
|
| 102 |
+
" auto_adjust=True\n",
|
| 103 |
+
" )\n",
|
| 104 |
+
"\n",
|
| 105 |
+
" if df.empty:\n",
|
| 106 |
+
" raise ValueError(f\"No data returned for ticker '{ticker}'.\")\n",
|
| 107 |
+
"\n",
|
| 108 |
+
" prices = df[\"Close\"].squeeze()\n",
|
| 109 |
+
" prices.name = ticker\n",
|
| 110 |
+
" result = prices.tail(lookback)\n",
|
| 111 |
+
" return result"
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"cell_type": "code",
|
| 116 |
+
"execution_count": 4,
|
| 117 |
+
"id": "run_fetch",
|
| 118 |
+
"metadata": {},
|
| 119 |
+
"outputs": [
|
| 120 |
+
{
|
| 121 |
+
"name": "stdout",
|
| 122 |
+
"output_type": "stream",
|
| 123 |
+
"text": [
|
| 124 |
+
"Shape: (251,)\n",
|
| 125 |
+
"Start date: 2025-03-27\n",
|
| 126 |
+
"End date: 2026-03-26\n"
|
| 127 |
+
]
|
| 128 |
+
}
|
| 129 |
+
],
|
| 130 |
+
"source": [
|
| 131 |
+
"prices = fetch_prices(TICKER, LOOKBACK_DAYS)\n",
|
| 132 |
+
"print(f\"Shape: {prices.shape}\")\n",
|
| 133 |
+
"print(f\"Start date: {prices.index.min().date()}\")\n",
|
| 134 |
+
"print(f\"End date: {prices.index.max().date()}\")"
|
| 135 |
+
]
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"cell_type": "markdown",
|
| 139 |
+
"id": "returns_header",
|
| 140 |
+
"metadata": {},
|
| 141 |
+
"source": [
|
| 142 |
+
"## 3. Return Calculation\n",
|
| 143 |
+
"\n",
|
| 144 |
+
"Calculate daily returns from the price data."
|
| 145 |
+
]
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
"cell_type": "code",
|
| 149 |
+
"execution_count": 5,
|
| 150 |
+
"id": "compute_returns",
|
| 151 |
+
"metadata": {},
|
| 152 |
+
"outputs": [],
|
| 153 |
+
"source": [
|
| 154 |
+
"def compute_returns(prices, kind=\"arithmetic\"):\n",
|
| 155 |
+
" \"\"\"Compute daily returns from a price series.\n",
|
| 156 |
+
"\n",
|
| 157 |
+
" kind : \"arithmetic\" or \"log\"\n",
|
| 158 |
+
" arithmetic -> (P_t - P_{t-1}) / P_{t-1}\n",
|
| 159 |
+
" log -> log(P_t) - log(P_{t-1})\n",
|
| 160 |
+
" \"\"\"\n",
|
| 161 |
+
" if kind == \"log\":\n",
|
| 162 |
+
" returns = np.log(prices) - np.log(prices.shift(1))\n",
|
| 163 |
+
" returns.name = \"Daily Log Return\"\n",
|
| 164 |
+
" else:\n",
|
| 165 |
+
" returns = (prices - prices.shift(1)) / prices.shift(1)\n",
|
| 166 |
+
" returns.name = \"Daily Return\"\n",
|
| 167 |
+
" return returns.dropna()"
|
| 168 |
+
]
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"cell_type": "code",
|
| 172 |
+
"execution_count": 6,
|
| 173 |
+
"id": "run_returns",
|
| 174 |
+
"metadata": {},
|
| 175 |
+
"outputs": [
|
| 176 |
+
{
|
| 177 |
+
"name": "stdout",
|
| 178 |
+
"output_type": "stream",
|
| 179 |
+
"text": [
|
| 180 |
+
"Daily log return series:\n",
|
| 181 |
+
"Shape: (250,)\n",
|
| 182 |
+
"\n",
|
| 183 |
+
" Date\n",
|
| 184 |
+
"2025-03-28 -0.050114\n",
|
| 185 |
+
"2025-03-31 0.001089\n",
|
| 186 |
+
"2025-04-01 0.016820\n",
|
| 187 |
+
"2025-04-02 -0.000126\n",
|
| 188 |
+
"2025-04-03 -0.040007\n",
|
| 189 |
+
"Name: Daily Log Return, dtype: float64\n"
|
| 190 |
+
]
|
| 191 |
+
}
|
| 192 |
+
],
|
| 193 |
+
"source": [
|
| 194 |
+
"daily_returns = compute_returns(prices, kind=\"log\")\n",
|
| 195 |
+
"\n",
|
| 196 |
+
"print(\"Daily log return series:\")\n",
|
| 197 |
+
"print(\"Shape: \", daily_returns.shape)\n",
|
| 198 |
+
"print(\"\\n \", daily_returns.head())"
|
| 199 |
+
]
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"cell_type": "markdown",
|
| 203 |
+
"id": "distribution_header",
|
| 204 |
+
"metadata": {},
|
| 205 |
+
"source": [
|
| 206 |
+
"## 4. Estimate Distribution Parameters\n",
|
| 207 |
+
"\n",
|
| 208 |
+
"The parametric method models daily returns as $R \\sim \\mathcal{N}(\\mu, \\sigma^2)$. \n",
|
| 209 |
+
"We estimate the **mean** ($\\mu$) and **standard deviation** ($\\sigma$) from the\n",
|
| 210 |
+
"historical sample, then derive VaR from the normal quantile."
|
| 211 |
+
]
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
"cell_type": "code",
|
| 215 |
+
"execution_count": 7,
|
| 216 |
+
"id": "estimate_params",
|
| 217 |
+
"metadata": {},
|
| 218 |
+
"outputs": [],
|
| 219 |
+
"source": [
|
| 220 |
+
"def estimate_distribution(returns):\n",
|
| 221 |
+
" \"\"\"Estimate mean and standard deviation of daily returns.\"\"\"\n",
|
| 222 |
+
" mu = returns.mean()\n",
|
| 223 |
+
" # ddof=1 ensures we divide by (N-1), giving an unbiased estimate of volatility from sample data\n",
|
| 224 |
+
" sigma = returns.std(ddof=1)\n",
|
| 225 |
+
" return mu, sigma"
|
| 226 |
+
]
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"cell_type": "code",
|
| 230 |
+
"execution_count": 8,
|
| 231 |
+
"id": "run_estimate",
|
| 232 |
+
"metadata": {},
|
| 233 |
+
"outputs": [
|
| 234 |
+
{
|
| 235 |
+
"name": "stdout",
|
| 236 |
+
"output_type": "stream",
|
| 237 |
+
"text": [
|
| 238 |
+
"Mean daily return (mu): 0.002162 (0.2162%)\n",
|
| 239 |
+
"Std dev daily return (sigma): 0.018841 (1.8841%)\n"
|
| 240 |
+
]
|
| 241 |
+
}
|
| 242 |
+
],
|
| 243 |
+
"source": [
|
| 244 |
+
"mu, sigma = estimate_distribution(daily_returns)\n",
|
| 245 |
+
"\n",
|
| 246 |
+
"print(f\"Mean daily return (mu): {mu:.6f} ({mu*100:.4f}%)\")\n",
|
| 247 |
+
"print(f\"Std dev daily return (sigma): {sigma:.6f} ({sigma*100:.4f}%)\")"
|
| 248 |
+
]
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
"cell_type": "markdown",
|
| 252 |
+
"id": "var_header",
|
| 253 |
+
"metadata": {},
|
| 254 |
+
"source": [
|
| 255 |
+
"## 5. Calculate VaR and ES\n",
|
| 256 |
+
"\n",
|
| 257 |
+
"**Parametric VaR** at confidence level $c$ is:\n",
|
| 258 |
+
"\n",
|
| 259 |
+
"$$\\text{VaR}_{1\\text{-day}} = -(\\mu + z_{\\alpha}\\,\\sigma)$$\n",
|
| 260 |
+
"\n",
|
| 261 |
+
"where $z_{\\alpha} = \\Phi^{-1}(1 - c)$ is the z-score for the lower tail.\n",
|
| 262 |
+
"\n",
|
| 263 |
+
"**Expected Shortfall (ES)** under the normal assumption is:\n",
|
| 264 |
+
"\n",
|
| 265 |
+
"$$\\text{ES}_{1\\text{-day}} = -(\\mu - \\sigma \\cdot \\frac{\\phi(z_{\\alpha})}{1-c})$$\n",
|
| 266 |
+
"\n",
|
| 267 |
+
"where $\\phi$ is the standard normal PDF."
|
| 268 |
+
]
|
| 269 |
+
},
|
| 270 |
+
{
|
| 271 |
+
"cell_type": "code",
|
| 272 |
+
"execution_count": 9,
|
| 273 |
+
"id": "calculate_var_es",
|
| 274 |
+
"metadata": {},
|
| 275 |
+
"outputs": [],
|
| 276 |
+
"source": [
|
| 277 |
+
"def calculate_parametric_var(returns, confidence):\n",
|
| 278 |
+
" \"\"\"Compute parametric VaR assuming normally distributed returns.\n",
|
| 279 |
+
"\n",
|
| 280 |
+
" VaR = -(mu - z * sigma) where z = norm.ppf(confidence).\n",
|
| 281 |
+
" Returns VaR as a positive loss fraction.\n",
|
| 282 |
+
" \"\"\"\n",
|
| 283 |
+
" mu, sigma = estimate_distribution(returns)\n",
|
| 284 |
+
" z = float(stats.norm.ppf(confidence))\n",
|
| 285 |
+
" return -(mu - z * sigma)\n",
|
| 286 |
+
"\n",
|
| 287 |
+
"\n",
|
| 288 |
+
"def calculate_parametric_es(returns, confidence):\n",
|
| 289 |
+
" \"\"\"Compute parametric ES assuming normally distributed returns.\n",
|
| 290 |
+
"\n",
|
| 291 |
+
" ES = -(mu - sigma * phi(z) / (1 - confidence)).\n",
|
| 292 |
+
" Returns ES as a positive loss fraction.\n",
|
| 293 |
+
" \"\"\"\n",
|
| 294 |
+
" mu, sigma = estimate_distribution(returns)\n",
|
| 295 |
+
" z = float(stats.norm.ppf(confidence))\n",
|
| 296 |
+
" alpha = 1.0 - confidence\n",
|
| 297 |
+
" return -(mu - sigma * float(stats.norm.pdf(z)) / alpha)"
|
| 298 |
+
]
|
| 299 |
+
},
|
| 300 |
+
{
|
| 301 |
+
"cell_type": "code",
|
| 302 |
+
"execution_count": 10,
|
| 303 |
+
"id": "run_var",
|
| 304 |
+
"metadata": {},
|
| 305 |
+
"outputs": [
|
| 306 |
+
{
|
| 307 |
+
"name": "stdout",
|
| 308 |
+
"output_type": "stream",
|
| 309 |
+
"text": [
|
| 310 |
+
"1-Day VaR: 0.0417 (4.17%)\n",
|
| 311 |
+
"1-Day ES: 0.0481 (4.81%)\n"
|
| 312 |
+
]
|
| 313 |
+
}
|
| 314 |
+
],
|
| 315 |
+
"source": [
|
| 316 |
+
"var_pct = calculate_parametric_var(daily_returns, CONFIDENCE_LEVEL)\n",
|
| 317 |
+
"es_pct = calculate_parametric_es(daily_returns, CONFIDENCE_LEVEL)\n",
|
| 318 |
+
"print(f\"1-Day VaR: {var_pct:.4f} ({var_pct*100:.2f}%)\")\n",
|
| 319 |
+
"print(f\"1-Day ES: {es_pct:.4f} ({es_pct*100:.2f}%)\")"
|
| 320 |
+
]
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
"cell_type": "markdown",
|
| 324 |
+
"id": "orchestration_header",
|
| 325 |
+
"metadata": {},
|
| 326 |
+
"source": [
|
| 327 |
+
"## 6. Orchestration of the Workflow"
|
| 328 |
+
]
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"cell_type": "code",
|
| 332 |
+
"execution_count": 11,
|
| 333 |
+
"id": "calculate_parametric_var",
|
| 334 |
+
"metadata": {},
|
| 335 |
+
"outputs": [],
|
| 336 |
+
"source": [
|
| 337 |
+
"def parametric_var_es_pipeline(ticker, confidence, lookback, n_days, portfolio_value, end_date=None):\n",
|
| 338 |
+
" \"\"\"Run the full parametric VaR workflow.\n",
|
| 339 |
+
"\n",
|
| 340 |
+
" Fetches data, estimates distribution parameters, computes 1-day VaR/ES\n",
|
| 341 |
+
" using the normal model, and scales results to the n-day horizon.\n",
|
| 342 |
+
" Returns dollar VaR/ES along with the underlying data.\n",
|
| 343 |
+
" \"\"\"\n",
|
| 344 |
+
" # 1. Fetch data and compute returns\n",
|
| 345 |
+
" prices = fetch_prices(ticker, lookback, end_date)\n",
|
| 346 |
+
" daily_returns = compute_returns(prices, kind=\"log\")\n",
|
| 347 |
+
" mu, sigma = estimate_distribution(daily_returns)\n",
|
| 348 |
+
"\n",
|
| 349 |
+
" # 2. Calculate 1-day VaR and ES\n",
|
| 350 |
+
" var_1d_pct = calculate_parametric_var(daily_returns, confidence)\n",
|
| 351 |
+
" es_1d_pct = calculate_parametric_es(daily_returns, confidence)\n",
|
| 352 |
+
" var_1d = var_1d_pct * portfolio_value\n",
|
| 353 |
+
" es_1d = es_1d_pct * portfolio_value\n",
|
| 354 |
+
"\n",
|
| 355 |
+
" # 3. Scale to N-day horizon\n",
|
| 356 |
+
" scaling_factor = np.sqrt(n_days)\n",
|
| 357 |
+
" var_nd = var_1d * scaling_factor\n",
|
| 358 |
+
" es_nd = es_1d * scaling_factor\n",
|
| 359 |
+
"\n",
|
| 360 |
+
" return {\n",
|
| 361 |
+
" \"var_1d\": var_1d,\n",
|
| 362 |
+
" \"var_nd\": var_nd,\n",
|
| 363 |
+
" \"es_1d\": es_1d,\n",
|
| 364 |
+
" \"es_nd\": es_nd,\n",
|
| 365 |
+
" \"prices\": prices,\n",
|
| 366 |
+
" \"daily_returns\": daily_returns,\n",
|
| 367 |
+
" \"mu\": mu,\n",
|
| 368 |
+
" \"sigma\": sigma,\n",
|
| 369 |
+
" }"
|
| 370 |
+
]
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
"cell_type": "code",
|
| 374 |
+
"execution_count": 12,
|
| 375 |
+
"id": "run_analysis",
|
| 376 |
+
"metadata": {},
|
| 377 |
+
"outputs": [],
|
| 378 |
+
"source": [
|
| 379 |
+
"results = parametric_var_es_pipeline(\n",
|
| 380 |
+
" ticker=TICKER,\n",
|
| 381 |
+
" confidence=CONFIDENCE_LEVEL,\n",
|
| 382 |
+
" lookback=LOOKBACK_DAYS,\n",
|
| 383 |
+
" n_days=HORIZON_DAYS,\n",
|
| 384 |
+
" portfolio_value=PORTFOLIO_VALUE)"
|
| 385 |
+
]
|
| 386 |
+
},
|
| 387 |
+
{
|
| 388 |
+
"cell_type": "code",
|
| 389 |
+
"execution_count": 13,
|
| 390 |
+
"id": "summary",
|
| 391 |
+
"metadata": {},
|
| 392 |
+
"outputs": [
|
| 393 |
+
{
|
| 394 |
+
"name": "stdout",
|
| 395 |
+
"output_type": "stream",
|
| 396 |
+
"text": [
|
| 397 |
+
"============================================================\n",
|
| 398 |
+
"PARAMETRIC VaR ANALYSIS SUMMARY - GOOG\n",
|
| 399 |
+
"============================================================\n",
|
| 400 |
+
"VaR Date: 2026-03-28\n",
|
| 401 |
+
"Portfolio Value: $1,000,000\n",
|
| 402 |
+
"Confidence Level: 99%\n",
|
| 403 |
+
"Time Horizon: 10 days\n",
|
| 404 |
+
"Historical Period: 251 trading days\n",
|
| 405 |
+
"\n",
|
| 406 |
+
"DISTRIBUTION PARAMETERS:\n",
|
| 407 |
+
"------------------------------------------------------------\n",
|
| 408 |
+
" Mean daily return (mu): 0.002162\n",
|
| 409 |
+
" Std dev (sigma): 0.018841\n",
|
| 410 |
+
"\n",
|
| 411 |
+
"VaR METRICS:\n",
|
| 412 |
+
"------------------------------------------------------------\n",
|
| 413 |
+
" 10-Day VaR: $131,766.21 (13.18%)\n",
|
| 414 |
+
" 10-Day ES: $151,955.80 (15.20%)\n",
|
| 415 |
+
"\n"
|
| 416 |
+
]
|
| 417 |
+
}
|
| 418 |
+
],
|
| 419 |
+
"source": [
|
| 420 |
+
"print(\"=\" * 60)\n",
|
| 421 |
+
"print(f\"PARAMETRIC VaR ANALYSIS SUMMARY - {TICKER}\")\n",
|
| 422 |
+
"print(\"=\" * 60)\n",
|
| 423 |
+
"print(f\"VaR Date: {datetime.now().strftime('%Y-%m-%d')}\")\n",
|
| 424 |
+
"print(f\"Portfolio Value: ${PORTFOLIO_VALUE:,}\")\n",
|
| 425 |
+
"print(f\"Confidence Level: {CONFIDENCE_LEVEL:.0%}\")\n",
|
| 426 |
+
"print(f\"Time Horizon: {HORIZON_DAYS} days\")\n",
|
| 427 |
+
"print(f\"Historical Period: {LOOKBACK_DAYS} trading days\")\n",
|
| 428 |
+
"print()\n",
|
| 429 |
+
"print(\"DISTRIBUTION PARAMETERS:\")\n",
|
| 430 |
+
"print('-'*60)\n",
|
| 431 |
+
"print(f\" Mean daily return (mu): {results['mu']:.6f}\")\n",
|
| 432 |
+
"print(f\" Std dev (sigma): {results['sigma']:.6f}\")\n",
|
| 433 |
+
"print()\n",
|
| 434 |
+
"print(\"VaR METRICS:\")\n",
|
| 435 |
+
"print('-'*60)\n",
|
| 436 |
+
"print(f\" {HORIZON_DAYS}-Day VaR: ${results['var_nd']:,.2f} ({results['var_nd']/PORTFOLIO_VALUE*100:.2f}%)\")\n",
|
| 437 |
+
"print(f\" {HORIZON_DAYS}-Day ES: ${results['es_nd']:,.2f} ({results['es_nd']/PORTFOLIO_VALUE*100:.2f}%)\")\n",
|
| 438 |
+
"print()"
|
| 439 |
+
]
|
| 440 |
+
},
|
| 441 |
+
{
|
| 442 |
+
"cell_type": "markdown",
|
| 443 |
+
"id": "tests_header",
|
| 444 |
+
"metadata": {},
|
| 445 |
+
"source": [
|
| 446 |
+
"## 7. Assumption Tests\n",
|
| 447 |
+
"\n",
|
| 448 |
+
"Parametric VaR rests on specific assumptions. The tests below verify that:\n",
|
| 449 |
+
"1. Returns are assumed to be **normally distributed**.\n",
|
| 450 |
+
"2. VaR is computed from **mean and standard deviation**.\n",
|
| 451 |
+
"3. **Tail risk is driven purely by volatility**."
|
| 452 |
+
]
|
| 453 |
+
},
|
| 454 |
+
{
|
| 455 |
+
"cell_type": "markdown",
|
| 456 |
+
"id": "test1_intro",
|
| 457 |
+
"metadata": {},
|
| 458 |
+
"source": [
|
| 459 |
+
"### Test 1 – Normality of Returns\n",
|
| 460 |
+
"\n",
|
| 461 |
+
"Parametric VaR assumes that daily returns follow a normal distribution. \n",
|
| 462 |
+
"We apply two statistical tests to evaluate this assumption:\n",
|
| 463 |
+
"\n",
|
| 464 |
+
"| Test | Purpose | Best for |\n",
|
| 465 |
+
"| --- | --- | --- |\n",
|
| 466 |
+
"| **Shapiro-Wilk** | General normality test (primary) | Small-to-medium samples (~250) |\n",
|
| 467 |
+
"| **Jarque-Bera** | Tests skewness & kurtosis specifically | Diagnosing *how* normality fails |\n",
|
| 468 |
+
"\n",
|
| 469 |
+
"A **p-value > 0.05** means we cannot reject the null hypothesis of normality\n",
|
| 470 |
+
"at the 5% significance level. The Shapiro-Wilk result drives the overall\n",
|
| 471 |
+
"conclusion; Jarque-Bera provides supporting detail."
|
| 472 |
+
]
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"cell_type": "code",
|
| 476 |
+
"execution_count": 14,
|
| 477 |
+
"id": "test_normality_fn",
|
| 478 |
+
"metadata": {},
|
| 479 |
+
"outputs": [],
|
| 480 |
+
"source": [
|
| 481 |
+
"def test_normality(returns):\n",
|
| 482 |
+
" \"\"\"Test whether returns follow a normal distribution.\n",
|
| 483 |
+
"\n",
|
| 484 |
+
" Runs both the Shapiro-Wilk test (primary, suitable for n ~ 250)\n",
|
| 485 |
+
" and the Jarque-Bera test (supporting, focuses on skewness and\n",
|
| 486 |
+
" kurtosis). Returns a dict with all test statistics.\n",
|
| 487 |
+
" \"\"\"\n",
|
| 488 |
+
" # Shapiro-Wilk (primary)\n",
|
| 489 |
+
" sw_stat, sw_p = stats.shapiro(returns)\n",
|
| 490 |
+
"\n",
|
| 491 |
+
" # Jarque-Bera (supporting diagnostic)\n",
|
| 492 |
+
" jb_stat, jb_p = stats.jarque_bera(returns)\n",
|
| 493 |
+
"\n",
|
| 494 |
+
" # Descriptive shape statistics\n",
|
| 495 |
+
" skewness = returns.skew()\n",
|
| 496 |
+
" excess_kurtosis = returns.kurtosis() # normal = 0\n",
|
| 497 |
+
"\n",
|
| 498 |
+
" return {\n",
|
| 499 |
+
" \"sw_stat\": sw_stat,\n",
|
| 500 |
+
" \"sw_pvalue\": sw_p,\n",
|
| 501 |
+
" \"jb_stat\": jb_stat,\n",
|
| 502 |
+
" \"jb_pvalue\": jb_p,\n",
|
| 503 |
+
" \"skewness\": skewness,\n",
|
| 504 |
+
" \"excess_kurtosis\": excess_kurtosis,\n",
|
| 505 |
+
" \"n_obs\": len(returns),\n",
|
| 506 |
+
" \"is_normal\": sw_p > 0.05\n",
|
| 507 |
+
" }"
|
| 508 |
+
]
|
| 509 |
+
},
|
| 510 |
+
{
|
| 511 |
+
"cell_type": "code",
|
| 512 |
+
"execution_count": 15,
|
| 513 |
+
"id": "run_test_normality",
|
| 514 |
+
"metadata": {},
|
| 515 |
+
"outputs": [
|
| 516 |
+
{
|
| 517 |
+
"name": "stdout",
|
| 518 |
+
"output_type": "stream",
|
| 519 |
+
"text": [
|
| 520 |
+
"============================================================\n",
|
| 521 |
+
"TEST 1: Normality of Returns\n",
|
| 522 |
+
"============================================================\n",
|
| 523 |
+
" Sample size: 250\n",
|
| 524 |
+
"\n",
|
| 525 |
+
"SHAPE STATISTICS:\n",
|
| 526 |
+
"------------------------------------------------------------\n",
|
| 527 |
+
" Skewness: 0.5195 (normal ~ 0)\n",
|
| 528 |
+
" Excess kurtosis: 4.2984 (normal ~ 0)\n",
|
| 529 |
+
"\n",
|
| 530 |
+
"PRIMARY TEST – Shapiro-Wilk:\n",
|
| 531 |
+
"------------------------------------------------------------\n",
|
| 532 |
+
" Statistic: 0.949339\n",
|
| 533 |
+
" p-value: 0.000000\n",
|
| 534 |
+
"\n",
|
| 535 |
+
"SUPPORTING TEST – Jarque-Bera:\n",
|
| 536 |
+
"------------------------------------------------------------\n",
|
| 537 |
+
" Statistic: 193.9030\n",
|
| 538 |
+
" p-value: 0.000000\n",
|
| 539 |
+
"\n",
|
| 540 |
+
"CONCLUSION (based on Shapiro-Wilk):\n",
|
| 541 |
+
"============================================================\n",
|
| 542 |
+
" WARNING - Normality rejected (Shapiro-Wilk p < 0.05).\n",
|
| 543 |
+
" Returns may exhibit fat tails or skew; Parametric VaR could underestimate true tail risk.\n",
|
| 544 |
+
" Data is NOT normal as per Shapiro-Wilk test.\n"
|
| 545 |
+
]
|
| 546 |
+
}
|
| 547 |
+
],
|
| 548 |
+
"source": [
|
| 549 |
+
"normality = test_normality(daily_returns)\n",
|
| 550 |
+
"\n",
|
| 551 |
+
"print(\"=\" * 60)\n",
|
| 552 |
+
"print(\"TEST 1: Normality of Returns\")\n",
|
| 553 |
+
"print(\"=\" * 60)\n",
|
| 554 |
+
"print(f\" Sample size: {normality['n_obs']}\")\n",
|
| 555 |
+
"print()\n",
|
| 556 |
+
"print(\"SHAPE STATISTICS:\")\n",
|
| 557 |
+
"print(\"-\" * 60)\n",
|
| 558 |
+
"print(f\" Skewness: {normality['skewness']:.4f} (normal ~ 0)\")\n",
|
| 559 |
+
"print(f\" Excess kurtosis: {normality['excess_kurtosis']:.4f} (normal ~ 0)\")\n",
|
| 560 |
+
"print()\n",
|
| 561 |
+
"print(\"PRIMARY TEST – Shapiro-Wilk:\")\n",
|
| 562 |
+
"print(\"-\" * 60)\n",
|
| 563 |
+
"print(f\" Statistic: {normality['sw_stat']:.6f}\")\n",
|
| 564 |
+
"print(f\" p-value: {normality['sw_pvalue']:.6f}\")\n",
|
| 565 |
+
"print()\n",
|
| 566 |
+
"print(\"SUPPORTING TEST – Jarque-Bera:\")\n",
|
| 567 |
+
"print(\"-\" * 60)\n",
|
| 568 |
+
"print(f\" Statistic: {normality['jb_stat']:.4f}\")\n",
|
| 569 |
+
"print(f\" p-value: {normality['jb_pvalue']:.6f}\")\n",
|
| 570 |
+
"print()\n",
|
| 571 |
+
"print(\"CONCLUSION (based on Shapiro-Wilk):\")\n",
|
| 572 |
+
"print(\"=\" * 60)\n",
|
| 573 |
+
"if normality[\"is_normal\"]:\n",
|
| 574 |
+
" print(\" PASS - Cannot reject normality at 5% significance.\")\n",
|
| 575 |
+
" print(\" The normal-distribution assumption is reasonable for this sample.\")\n",
|
| 576 |
+
" print(\" Data is considered normal as per Shapiro-Wilk test.\")\n",
|
| 577 |
+
"else:\n",
|
| 578 |
+
" print(\" WARNING - Normality rejected (Shapiro-Wilk p < 0.05).\")\n",
|
| 579 |
+
" print(\" Returns may exhibit fat tails or skew; Parametric VaR could underestimate true tail risk.\")\n",
|
| 580 |
+
" print(\" Data is NOT normal as per Shapiro-Wilk test.\")"
|
| 581 |
+
]
|
| 582 |
+
}
|
| 583 |
+
],
|
| 584 |
+
"metadata": {
|
| 585 |
+
"kernelspec": {
|
| 586 |
+
"display_name": "value-at-risk (3.13.5)",
|
| 587 |
+
"language": "python",
|
| 588 |
+
"name": "python3"
|
| 589 |
+
},
|
| 590 |
+
"language_info": {
|
| 591 |
+
"codemirror_mode": {
|
| 592 |
+
"name": "ipython",
|
| 593 |
+
"version": 3
|
| 594 |
+
},
|
| 595 |
+
"file_extension": ".py",
|
| 596 |
+
"mimetype": "text/x-python",
|
| 597 |
+
"name": "python",
|
| 598 |
+
"nbconvert_exporter": "python",
|
| 599 |
+
"pygments_lexer": "ipython3",
|
| 600 |
+
"version": "3.13.5"
|
| 601 |
+
}
|
| 602 |
+
},
|
| 603 |
+
"nbformat": 4,
|
| 604 |
+
"nbformat_minor": 5
|
| 605 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "value-at-risk"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Portfolio Value at Risk (VaR) Analysis Engine"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"gradio>=6.9.0",
|
| 9 |
+
"loguru>=0.7.3",
|
| 10 |
+
"matplotlib>=3.10.8",
|
| 11 |
+
"numpy>=2.4.3",
|
| 12 |
+
"openpyxl>=3.1.5",
|
| 13 |
+
"pandas>=3.0.1",
|
| 14 |
+
"plotly>=6.6.0",
|
| 15 |
+
"kaleido>=0.2.1",
|
| 16 |
+
"yfinance>=1.2.0",
|
| 17 |
+
"scipy>=1.17.1",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[tool.pyright]
|
| 21 |
+
include = ["."]
|
| 22 |
+
extraPaths = ["."]
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=6.9.0
|
| 2 |
+
loguru>=0.7.3
|
| 3 |
+
matplotlib>=3.10.8
|
| 4 |
+
numpy>=2.4.3
|
| 5 |
+
openpyxl>=3.1.5
|
| 6 |
+
pandas>=3.0.1
|
| 7 |
+
plotly>=6.6.0
|
| 8 |
+
kaleido>=0.2.1
|
| 9 |
+
yfinance>=1.2.0
|
| 10 |
+
scipy>=1.17.1
|
src/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VaR business logic."""
|
| 2 |
+
|
| 3 |
+
from src.logger import logger
|
| 4 |
+
|
| 5 |
+
from src.utils import (
|
| 6 |
+
fetch_prices,
|
| 7 |
+
compute_returns,
|
| 8 |
+
plot_distribution,
|
| 9 |
+
)
|
| 10 |
+
from src.historical import (
|
| 11 |
+
calculate_historical_var,
|
| 12 |
+
calculate_historical_es,
|
| 13 |
+
compute_historical_var_es,
|
| 14 |
+
compute_stressed_historical_var_es,
|
| 15 |
+
historical_var_es_pipeline,
|
| 16 |
+
)
|
| 17 |
+
from src.parametric import (
|
| 18 |
+
estimate_distribution,
|
| 19 |
+
calculate_parametric_var,
|
| 20 |
+
calculate_parametric_es,
|
| 21 |
+
compute_parametric_var_es,
|
| 22 |
+
compute_stressed_parametric_var_es,
|
| 23 |
+
parametric_var_es_pipeline,
|
| 24 |
+
)
|
| 25 |
+
from src.excel_export import export_historical_var_report, export_parametric_var_report
|
| 26 |
+
from src.config import (
|
| 27 |
+
TICKERS,
|
| 28 |
+
LOOKBACK_DAYS,
|
| 29 |
+
STRESS_LABEL,
|
| 30 |
+
STRESS_START_DATE,
|
| 31 |
+
STRESS_END_DATE,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
__all__ = [
|
| 35 |
+
"logger",
|
| 36 |
+
"fetch_prices",
|
| 37 |
+
"compute_returns",
|
| 38 |
+
"plot_distribution",
|
| 39 |
+
"calculate_historical_var",
|
| 40 |
+
"calculate_historical_es",
|
| 41 |
+
"compute_historical_var_es",
|
| 42 |
+
"compute_stressed_historical_var_es",
|
| 43 |
+
"historical_var_es_pipeline",
|
| 44 |
+
"estimate_distribution",
|
| 45 |
+
"calculate_parametric_var",
|
| 46 |
+
"calculate_parametric_es",
|
| 47 |
+
"compute_parametric_var_es",
|
| 48 |
+
"compute_stressed_parametric_var_es",
|
| 49 |
+
"parametric_var_es_pipeline",
|
| 50 |
+
"export_historical_var_report",
|
| 51 |
+
"export_parametric_var_report",
|
| 52 |
+
"TICKERS",
|
| 53 |
+
"LOOKBACK_DAYS",
|
| 54 |
+
"STRESS_LABEL",
|
| 55 |
+
"STRESS_START_DATE",
|
| 56 |
+
"STRESS_END_DATE",
|
| 57 |
+
]
|
src/config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
config.py -- Load application configuration from config.yaml.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
from src.config import TICKERS, LOOKBACK_DAYS, STRESS_LABEL, STRESS_START_DATE, STRESS_END_DATE
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import yaml
|
| 11 |
+
|
| 12 |
+
_CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.yaml"
|
| 13 |
+
|
| 14 |
+
with open(_CONFIG_PATH) as _f:
|
| 15 |
+
_cfg = yaml.safe_load(_f)
|
| 16 |
+
|
| 17 |
+
TICKERS: list[str] = _cfg["tickers"]
|
| 18 |
+
LOOKBACK_DAYS: int = _cfg["lookback_days"]
|
| 19 |
+
STRESS_LABEL: str = _cfg["stressed_period_label"]
|
| 20 |
+
STRESS_START_DATE: str = _cfg["stressed_period_start_date"]
|
| 21 |
+
STRESS_END_DATE: str = _cfg["stressed_period_end_date"]
|
src/excel_export.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
excel_export.py -- Excel workbook creation with natively calculated VaR/ES formulas.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import math
|
| 8 |
+
import os
|
| 9 |
+
from datetime import date
|
| 10 |
+
import openpyxl
|
| 11 |
+
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
| 12 |
+
from openpyxl.worksheet.worksheet import Worksheet
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from loguru import logger
|
| 15 |
+
|
| 16 |
+
# Cell values can be str, int, float, None, etc.
|
| 17 |
+
CellValue = str | int | float | None
|
| 18 |
+
SummaryRow = list[CellValue]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Shared styles
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
|
| 25 |
+
HEADER_FILL = PatternFill(start_color="1F497D", end_color="1F497D", fill_type="solid")
|
| 26 |
+
HEADER_FONT = Font(color="FFFFFF", bold=True)
|
| 27 |
+
VAR_95_FILL = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid") # orangish-yellow
|
| 28 |
+
VAR_99_FILL = PatternFill(start_color="F6C96C", end_color="F6C96C", fill_type="solid") # light amber
|
| 29 |
+
THIN_BORDER = Border(
|
| 30 |
+
left=Side(style="thin"),
|
| 31 |
+
right=Side(style="thin"),
|
| 32 |
+
top=Side(style="thin"),
|
| 33 |
+
bottom=Side(style="thin"),
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Public helpers
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def make_output_dir() -> str:
|
| 43 |
+
"""Create and return the directory ``output/{YYYY-MM-DD}/``."""
|
| 44 |
+
dir_path = os.path.join("output", date.today().strftime("%Y-%m-%d"))
|
| 45 |
+
os.makedirs(dir_path, exist_ok=True)
|
| 46 |
+
logger.debug(f"Output directory: {dir_path}")
|
| 47 |
+
return dir_path
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
# Data columns (A-D) -- shared between Historical and Parametric
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _write_data_columns(
|
| 56 |
+
worksheet: Worksheet,
|
| 57 |
+
prices: pd.Series,
|
| 58 |
+
max_data_row: int,
|
| 59 |
+
method: str,
|
| 60 |
+
) -> None:
|
| 61 |
+
"""Write date/price headers and rows into columns A-D.
|
| 62 |
+
|
| 63 |
+
Column C:
|
| 64 |
+
- Historical: -(P_t - P_{t-1}) / P_{t-1} (arithmetic loss, positive = loss)
|
| 65 |
+
- Parametric: LN(P_t / P_{t-1}) (log return, negative = loss)
|
| 66 |
+
Column D:
|
| 67 |
+
- Historical: LARGE() -- losses descending (worst first)
|
| 68 |
+
- Parametric: SMALL() -- returns ascending (worst first)
|
| 69 |
+
"""
|
| 70 |
+
if method == "Historical":
|
| 71 |
+
col_c_header = "Daily Arithmetic Return"
|
| 72 |
+
col_d_header = "Sorted Return"
|
| 73 |
+
else:
|
| 74 |
+
col_c_header = "Daily Log Return"
|
| 75 |
+
col_d_header = "Sorted Return"
|
| 76 |
+
center = Alignment(horizontal="right")
|
| 77 |
+
|
| 78 |
+
headers = ["Date", "Close Price", col_c_header, col_d_header]
|
| 79 |
+
for col_idx, header in enumerate(headers, start=1):
|
| 80 |
+
cell = worksheet.cell(row=1, column=col_idx, value=header)
|
| 81 |
+
cell.fill = HEADER_FILL
|
| 82 |
+
cell.font = HEADER_FONT
|
| 83 |
+
cell.border = THIN_BORDER
|
| 84 |
+
if col_idx in (2, 3, 4):
|
| 85 |
+
cell.alignment = center
|
| 86 |
+
|
| 87 |
+
dates = pd.DatetimeIndex(prices.index)
|
| 88 |
+
price_values = prices.values
|
| 89 |
+
|
| 90 |
+
for i in range(len(prices)):
|
| 91 |
+
row = i + 2
|
| 92 |
+
worksheet.cell(row=row, column=1, value=dates[i].strftime("%Y-%m-%d"))
|
| 93 |
+
price_cell = worksheet.cell(row=row, column=2, value=float(price_values[i]))
|
| 94 |
+
price_cell.alignment = center
|
| 95 |
+
|
| 96 |
+
if row > 2:
|
| 97 |
+
if method == "Historical":
|
| 98 |
+
col_c_formula = f"=(B{row}-B{row - 1})/B{row - 1}"
|
| 99 |
+
col_d_formula = f"=SMALL(C$3:C${max_data_row}, ROW()-2)"
|
| 100 |
+
else:
|
| 101 |
+
col_c_formula = f"=LN(B{row}/B{row - 1})"
|
| 102 |
+
col_d_formula = f"=SMALL(C$3:C${max_data_row}, ROW()-2)"
|
| 103 |
+
return_cell = worksheet.cell(row=row, column=3, value=col_c_formula)
|
| 104 |
+
return_cell.number_format = "0.0000%"
|
| 105 |
+
return_cell.alignment = center
|
| 106 |
+
|
| 107 |
+
sorted_cell = worksheet.cell(row=row, column=4, value=col_d_formula)
|
| 108 |
+
sorted_cell.number_format = "0.0000%"
|
| 109 |
+
sorted_cell.alignment = center
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ---------------------------------------------------------------------------
|
| 113 |
+
# VaR / ES formulas -- method-specific
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _var_dollar_formula(method: str, max_data_row: int, alpha: float, pv_ref: str) -> str:
|
| 118 |
+
"""Return the Excel formula for 1-Day VaR ($) — positive = loss.
|
| 119 |
+
|
| 120 |
+
Historical: PERCENTILE(losses, confidence) * V
|
| 121 |
+
Parametric: -V * (mu - z_alpha * sigma) where column C = LN returns
|
| 122 |
+
"""
|
| 123 |
+
confidence = 1.0 - alpha
|
| 124 |
+
rng = f"C$3:C${max_data_row}"
|
| 125 |
+
if method == "Historical":
|
| 126 |
+
return f"=-PERCENTILE({rng},{alpha})*{pv_ref}"
|
| 127 |
+
else:
|
| 128 |
+
return (
|
| 129 |
+
f"=-{pv_ref}*(AVERAGE({rng})"
|
| 130 |
+
f"-_xlfn.NORM.S.INV({confidence})*_xlfn.STDEV.S({rng}))"
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _es_dollar_formula(method: str, max_data_row: int, alpha: float, pv_ref: str) -> str:
|
| 135 |
+
"""Return the Excel formula for 1-Day ES ($) — positive = loss.
|
| 136 |
+
|
| 137 |
+
Historical: ES = E[loss | loss > VaR] where loss = -return
|
| 138 |
+
-AVERAGEIF(returns < VaR_threshold) * V
|
| 139 |
+
Parametric: -V * (mu - sigma * phi(z) / alpha)
|
| 140 |
+
"""
|
| 141 |
+
confidence = 1.0 - alpha
|
| 142 |
+
rng = f"C$3:C${max_data_row}"
|
| 143 |
+
if method == "Historical":
|
| 144 |
+
var_threshold = f"PERCENTILE({rng},{alpha})"
|
| 145 |
+
return f'=-AVERAGEIF({rng},"<"&{var_threshold})*{pv_ref}'
|
| 146 |
+
else:
|
| 147 |
+
return (
|
| 148 |
+
f"=-{pv_ref}*(AVERAGE({rng})"
|
| 149 |
+
f"-_xlfn.STDEV.S({rng})"
|
| 150 |
+
f"*_xlfn.NORM.DIST(_xlfn.NORM.S.INV({confidence}),0,1,FALSE)/{alpha})"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ---------------------------------------------------------------------------
|
| 155 |
+
# Core export (shared between Historical and Parametric)
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _export_sheet(
|
| 160 |
+
method: str,
|
| 161 |
+
path: str,
|
| 162 |
+
prices: pd.Series,
|
| 163 |
+
ticker: str,
|
| 164 |
+
n_days: int,
|
| 165 |
+
portfolio_value: float,
|
| 166 |
+
var_date: pd.Timestamp | None,
|
| 167 |
+
stressed: bool,
|
| 168 |
+
lookback: int | None,
|
| 169 |
+
stress_start: str,
|
| 170 |
+
stress_end: str,
|
| 171 |
+
stress_label: str,
|
| 172 |
+
var_confidence: float = 0.99,
|
| 173 |
+
es_confidence: float = 0.975,
|
| 174 |
+
) -> str:
|
| 175 |
+
"""Create or append a VaR/ES sheet to an Excel workbook.
|
| 176 |
+
|
| 177 |
+
``method`` must be ``"Historical"`` or ``"Parametric"``.
|
| 178 |
+
"""
|
| 179 |
+
|
| 180 |
+
# ---- 1. Workbook / sheet setup -----------------------------------------
|
| 181 |
+
|
| 182 |
+
sheet_title = "VaR and ES"
|
| 183 |
+
stressed_sheet_title = "Stressed VaR and ES"
|
| 184 |
+
|
| 185 |
+
if stressed:
|
| 186 |
+
workbook = openpyxl.load_workbook(path)
|
| 187 |
+
worksheet = workbook.create_sheet(title=stressed_sheet_title)
|
| 188 |
+
else:
|
| 189 |
+
workbook = openpyxl.Workbook()
|
| 190 |
+
worksheet = workbook.active
|
| 191 |
+
assert worksheet is not None
|
| 192 |
+
worksheet.title = sheet_title
|
| 193 |
+
|
| 194 |
+
max_data_row = len(prices) + 1
|
| 195 |
+
|
| 196 |
+
# ---- 2. Write data columns A-D -----------------------------------------
|
| 197 |
+
|
| 198 |
+
_write_data_columns(worksheet, prices, max_data_row, method)
|
| 199 |
+
|
| 200 |
+
# ---- 2b. Highlight sorted returns at 95% and 99% VaR positions ---------
|
| 201 |
+
|
| 202 |
+
n_returns = max_data_row - 2 # returns start at row 3
|
| 203 |
+
for alpha, fill in [(0.05, VAR_95_FILL), (0.01, VAR_99_FILL)]:
|
| 204 |
+
pos = alpha * n_returns
|
| 205 |
+
lo = math.floor(pos)
|
| 206 |
+
hi = math.ceil(pos)
|
| 207 |
+
for k in {lo, hi}:
|
| 208 |
+
if 1 <= k <= n_returns:
|
| 209 |
+
worksheet.cell(row=k + 2, column=4).fill = fill # column D
|
| 210 |
+
|
| 211 |
+
# ---- 3. Build parameter entries ----------------------------------------
|
| 212 |
+
|
| 213 |
+
date_str = var_date.strftime("%Y-%m-%d") if var_date is not None else ""
|
| 214 |
+
|
| 215 |
+
if stressed:
|
| 216 |
+
param_entries: list[SummaryRow] = [
|
| 217 |
+
["Method", method, ""],
|
| 218 |
+
["Ticker", ticker, ""],
|
| 219 |
+
["VaR Date", date_str, ""],
|
| 220 |
+
["Portfolio Value ($)", portfolio_value, ""],
|
| 221 |
+
["N-Day Horizon", n_days, ""],
|
| 222 |
+
["Stress Period Start Date", stress_start, ""],
|
| 223 |
+
["Stress Period End Date", stress_end, ""],
|
| 224 |
+
["Stress Period", stress_label, ""],
|
| 225 |
+
]
|
| 226 |
+
else:
|
| 227 |
+
param_entries = [
|
| 228 |
+
["Method", method, ""],
|
| 229 |
+
["Ticker", ticker, ""],
|
| 230 |
+
["VaR Date", date_str, ""],
|
| 231 |
+
["Portfolio Value ($)", portfolio_value, ""],
|
| 232 |
+
["N-Day Horizon", n_days, ""],
|
| 233 |
+
["Return Observations", lookback - 1 if lookback else "", ""],
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
# ---- 4. Compute row layout ---------------------------------------------
|
| 237 |
+
#
|
| 238 |
+
# Layout (0-based indices into summary_data):
|
| 239 |
+
# [0] Parameter header
|
| 240 |
+
# [1..N] param_entries (N = len(param_entries))
|
| 241 |
+
# [N+1] separator
|
| 242 |
+
# [N+2] Standard header (Risk Metric | 99% VaR ($) | 97.5% ES ($))
|
| 243 |
+
# [N+3] Standard 1-Day
|
| 244 |
+
# [N+4] Standard 10-Day Scaled
|
| 245 |
+
# [N+5] separator
|
| 246 |
+
# [N+6] Custom header (Risk Metric | VaR X% | ES Y%)
|
| 247 |
+
# [N+7] Custom 1-Day
|
| 248 |
+
# [N+8] Custom 10-Day Scaled
|
| 249 |
+
# [N+9] Custom n-Day Scaled (only when n_days != 10)
|
| 250 |
+
|
| 251 |
+
summary_start_row = 2
|
| 252 |
+
summary_start_col = 7 # Column G
|
| 253 |
+
|
| 254 |
+
N_params = len(param_entries)
|
| 255 |
+
# Portfolio Value is always param_entries[3]; entries start at absolute index 1
|
| 256 |
+
portfolio_value_abs_idx = 1 + 3 # = 4
|
| 257 |
+
pv_ref = f"$H${summary_start_row + portfolio_value_abs_idx}"
|
| 258 |
+
|
| 259 |
+
param_header_idx = 0
|
| 260 |
+
param_separator_idx = N_params + 1
|
| 261 |
+
|
| 262 |
+
std_header_idx = param_separator_idx + 1
|
| 263 |
+
std_1day_idx = std_header_idx + 1
|
| 264 |
+
std_10day_idx = std_1day_idx + 1
|
| 265 |
+
std_separator_idx = std_10day_idx + 1
|
| 266 |
+
|
| 267 |
+
custom_header_idx = std_separator_idx + 1
|
| 268 |
+
custom_1day_idx = custom_header_idx + 1
|
| 269 |
+
custom_10day_idx = custom_1day_idx + 1
|
| 270 |
+
custom_nday_idx: int | None = None
|
| 271 |
+
if n_days != 10:
|
| 272 |
+
custom_nday_idx = custom_10day_idx + 1
|
| 273 |
+
|
| 274 |
+
# ---- 5. Build standard table (fixed 99% VaR / 97.5% ES) ----------------
|
| 275 |
+
|
| 276 |
+
# Only show custom table when the selected levels differ from the standard (99%/97.5%)
|
| 277 |
+
show_custom = not (var_confidence == 0.99 and es_confidence == 0.975)
|
| 278 |
+
|
| 279 |
+
std_1day_h = f"H{summary_start_row + std_1day_idx}"
|
| 280 |
+
std_1day_i = f"I{summary_start_row + std_1day_idx}"
|
| 281 |
+
|
| 282 |
+
std_header_label = "Standard Stressed Risk Summary" if stressed else "Standard Risk Summary"
|
| 283 |
+
|
| 284 |
+
std_rows: list[SummaryRow] = [
|
| 285 |
+
[std_header_label, "99% VaR ($)", "97.5% ES ($)"],
|
| 286 |
+
["1-Day",
|
| 287 |
+
_var_dollar_formula(method, max_data_row, 0.01, pv_ref),
|
| 288 |
+
_es_dollar_formula(method, max_data_row, 0.025, pv_ref)],
|
| 289 |
+
["10-Day Scaled",
|
| 290 |
+
f"={std_1day_h} * SQRT(10)",
|
| 291 |
+
f"={std_1day_i} * SQRT(10)"],
|
| 292 |
+
]
|
| 293 |
+
std_nday_idx: int | None = None
|
| 294 |
+
if n_days != 10 and not show_custom:
|
| 295 |
+
std_nday_idx = std_10day_idx + 1
|
| 296 |
+
std_rows.append([
|
| 297 |
+
f"{n_days}-Day Scaled",
|
| 298 |
+
f"={std_1day_h} * SQRT({n_days})",
|
| 299 |
+
f"={std_1day_i} * SQRT({n_days})",
|
| 300 |
+
])
|
| 301 |
+
|
| 302 |
+
# ---- 6. Build custom table (user-selected confidence levels) ------------
|
| 303 |
+
|
| 304 |
+
var_alpha = 1.0 - var_confidence
|
| 305 |
+
es_alpha = 1.0 - es_confidence
|
| 306 |
+
var_conf_label = f"{var_confidence * 100:g}%"
|
| 307 |
+
es_conf_label = f"{es_confidence * 100:g}%"
|
| 308 |
+
|
| 309 |
+
custom_1day_h = f"H{summary_start_row + custom_1day_idx}"
|
| 310 |
+
custom_1day_i = f"I{summary_start_row + custom_1day_idx}"
|
| 311 |
+
|
| 312 |
+
custom_header_label = "Stressed Risk Summary" if stressed else "Risk Summary"
|
| 313 |
+
|
| 314 |
+
custom_rows: list[SummaryRow] = [
|
| 315 |
+
[custom_header_label, f"{var_conf_label} VaR ($)", f"{es_conf_label} ES ($)"],
|
| 316 |
+
["1-Day",
|
| 317 |
+
_var_dollar_formula(method, max_data_row, var_alpha, pv_ref),
|
| 318 |
+
_es_dollar_formula(method, max_data_row, es_alpha, pv_ref)],
|
| 319 |
+
["10-Day Scaled",
|
| 320 |
+
f"={custom_1day_h} * SQRT(10)",
|
| 321 |
+
f"={custom_1day_i} * SQRT(10)"],
|
| 322 |
+
]
|
| 323 |
+
if n_days != 10:
|
| 324 |
+
custom_rows.append([
|
| 325 |
+
f"{n_days}-Day Scaled",
|
| 326 |
+
f"={custom_1day_h} * SQRT({n_days})",
|
| 327 |
+
f"={custom_1day_i} * SQRT({n_days})",
|
| 328 |
+
])
|
| 329 |
+
|
| 330 |
+
# ---- 7. Assemble full summary ------------------------------------------
|
| 331 |
+
|
| 332 |
+
empty_row: SummaryRow = ["", "", ""]
|
| 333 |
+
summary_data: list[SummaryRow] = []
|
| 334 |
+
summary_data.append(["Parameter", "Value", ""]) # index 0
|
| 335 |
+
summary_data.extend(param_entries) # indices 1..N
|
| 336 |
+
summary_data.append(empty_row) # index N+1
|
| 337 |
+
summary_data.extend(std_rows) # indices N+2..N+4
|
| 338 |
+
if show_custom:
|
| 339 |
+
summary_data.append(empty_row) # index N+5
|
| 340 |
+
summary_data.extend(custom_rows) # indices N+6..
|
| 341 |
+
|
| 342 |
+
# ---- 9. Write and style the summary ------------------------------------
|
| 343 |
+
|
| 344 |
+
# Indices of param-section rows (skip col I for these)
|
| 345 |
+
param_all_indices = {param_header_idx} | set(range(1, 1 + N_params))
|
| 346 |
+
|
| 347 |
+
# Which rows to right-align value (col H) — param entries except Portfolio Value
|
| 348 |
+
right_align_indices = set(range(1, 1 + N_params)) - {portfolio_value_abs_idx}
|
| 349 |
+
|
| 350 |
+
# Which rows get money ($) formatting
|
| 351 |
+
money_format_indices: set[int] = {
|
| 352 |
+
portfolio_value_abs_idx,
|
| 353 |
+
std_1day_idx, std_10day_idx,
|
| 354 |
+
}
|
| 355 |
+
if std_nday_idx is not None:
|
| 356 |
+
money_format_indices.add(std_nday_idx)
|
| 357 |
+
if show_custom:
|
| 358 |
+
money_format_indices |= {custom_1day_idx, custom_10day_idx}
|
| 359 |
+
if custom_nday_idx is not None:
|
| 360 |
+
money_format_indices.add(custom_nday_idx)
|
| 361 |
+
|
| 362 |
+
# Which rows get dark-blue header styling
|
| 363 |
+
section_header_indices = {param_header_idx, std_header_idx}
|
| 364 |
+
if show_custom:
|
| 365 |
+
section_header_indices.add(custom_header_idx)
|
| 366 |
+
|
| 367 |
+
# Which rows skip all styling (separators)
|
| 368 |
+
unstyled_indices = {param_separator_idx}
|
| 369 |
+
if show_custom:
|
| 370 |
+
unstyled_indices.add(std_separator_idx)
|
| 371 |
+
|
| 372 |
+
for data_index, row_data in enumerate(summary_data):
|
| 373 |
+
sheet_row = summary_start_row + data_index
|
| 374 |
+
for col_offset, value in enumerate(row_data):
|
| 375 |
+
# Third column (col I) is unused for parameter rows
|
| 376 |
+
if data_index in param_all_indices and col_offset == 2:
|
| 377 |
+
continue
|
| 378 |
+
|
| 379 |
+
col = summary_start_col + col_offset
|
| 380 |
+
cell = worksheet.cell(row=sheet_row, column=col, value=value) # type: ignore[arg-type]
|
| 381 |
+
|
| 382 |
+
# Skip styling for empty separators
|
| 383 |
+
if data_index in unstyled_indices:
|
| 384 |
+
continue
|
| 385 |
+
|
| 386 |
+
cell.border = THIN_BORDER
|
| 387 |
+
|
| 388 |
+
if data_index in section_header_indices:
|
| 389 |
+
cell.fill = HEADER_FILL
|
| 390 |
+
cell.font = HEADER_FONT
|
| 391 |
+
if col == 8:
|
| 392 |
+
cell.alignment = Alignment(horizontal="right")
|
| 393 |
+
elif col == 9 and data_index != param_header_idx:
|
| 394 |
+
cell.alignment = Alignment(horizontal="right")
|
| 395 |
+
|
| 396 |
+
if data_index in right_align_indices and col_offset == 1:
|
| 397 |
+
cell.alignment = Alignment(horizontal="right")
|
| 398 |
+
|
| 399 |
+
if col in (8, 9): # Columns H and I
|
| 400 |
+
if data_index in money_format_indices:
|
| 401 |
+
cell.number_format = '"$"#,##0.00'
|
| 402 |
+
|
| 403 |
+
# ---- 10. Column widths -------------------------------------------------
|
| 404 |
+
|
| 405 |
+
column_widths = {
|
| 406 |
+
"A": 12, "B": 15, "C": 20, "D": 15,
|
| 407 |
+
"E": 5, "F": 5,
|
| 408 |
+
"G": 27, "H": 24, "I": 24,
|
| 409 |
+
}
|
| 410 |
+
for column_letter, width in column_widths.items():
|
| 411 |
+
worksheet.column_dimensions[column_letter].width = width
|
| 412 |
+
|
| 413 |
+
# ---- 11. Save ----------------------------------------------------------
|
| 414 |
+
|
| 415 |
+
workbook.save(path)
|
| 416 |
+
action = "sheet added" if stressed else "report saved"
|
| 417 |
+
logger.info(f"{method} VaR ES {action}: {path}")
|
| 418 |
+
return path
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# ---------------------------------------------------------------------------
|
| 422 |
+
# API -- thin wrappers around _export_sheet
|
| 423 |
+
# ---------------------------------------------------------------------------
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def export_historical_var_sheet(
|
| 427 |
+
path: str,
|
| 428 |
+
prices: pd.Series,
|
| 429 |
+
ticker: str,
|
| 430 |
+
n_days: int,
|
| 431 |
+
portfolio_value: float,
|
| 432 |
+
var_date: pd.Timestamp | None = None,
|
| 433 |
+
stressed: bool = False,
|
| 434 |
+
lookback: int | None = None,
|
| 435 |
+
stress_start: str = "",
|
| 436 |
+
stress_end: str = "",
|
| 437 |
+
stress_label: str = "",
|
| 438 |
+
var_confidence: float = 0.99,
|
| 439 |
+
es_confidence: float = 0.975,
|
| 440 |
+
) -> str:
|
| 441 |
+
"""Create or append a Historical VaR/ES sheet to an Excel workbook."""
|
| 442 |
+
return _export_sheet(
|
| 443 |
+
"Historical", path, prices, ticker, n_days, portfolio_value,
|
| 444 |
+
var_date, stressed, lookback, stress_start, stress_end, stress_label,
|
| 445 |
+
var_confidence, es_confidence,
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def export_parametric_var_sheet(
|
| 450 |
+
path: str,
|
| 451 |
+
prices: pd.Series,
|
| 452 |
+
ticker: str,
|
| 453 |
+
n_days: int,
|
| 454 |
+
portfolio_value: float,
|
| 455 |
+
var_date: pd.Timestamp | None = None,
|
| 456 |
+
stressed: bool = False,
|
| 457 |
+
lookback: int | None = None,
|
| 458 |
+
stress_start: str = "",
|
| 459 |
+
stress_end: str = "",
|
| 460 |
+
stress_label: str = "",
|
| 461 |
+
var_confidence: float = 0.99,
|
| 462 |
+
es_confidence: float = 0.975,
|
| 463 |
+
) -> str:
|
| 464 |
+
"""Create or append a Parametric VaR/ES sheet to an Excel workbook."""
|
| 465 |
+
return _export_sheet(
|
| 466 |
+
"Parametric", path, prices, ticker, n_days, portfolio_value,
|
| 467 |
+
var_date, stressed, lookback, stress_start, stress_end, stress_label,
|
| 468 |
+
var_confidence, es_confidence,
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
# ---------------------------------------------------------------------------
|
| 473 |
+
# Report-level exports (output dir + both normal & stressed sheets)
|
| 474 |
+
# ---------------------------------------------------------------------------
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def export_historical_var_report(
|
| 478 |
+
prices: pd.Series,
|
| 479 |
+
ticker: str,
|
| 480 |
+
n_days: int,
|
| 481 |
+
portfolio_value: float,
|
| 482 |
+
var_date: pd.Timestamp | None,
|
| 483 |
+
lookback: int,
|
| 484 |
+
stressed_prices: pd.Series,
|
| 485 |
+
stress_start: str,
|
| 486 |
+
stress_end: str,
|
| 487 |
+
stress_label: str,
|
| 488 |
+
var_confidence: float = 0.99,
|
| 489 |
+
es_confidence: float = 0.975,
|
| 490 |
+
) -> str:
|
| 491 |
+
"""Generate a full Historical VaR Excel report (normal + stressed sheets)."""
|
| 492 |
+
output_dir = make_output_dir()
|
| 493 |
+
date_str = var_date.strftime("%Y-%m-%d") if var_date else ""
|
| 494 |
+
excel_path = os.path.join(output_dir, f"{ticker}_{date_str}_Historical_VaR.xlsx")
|
| 495 |
+
|
| 496 |
+
export_historical_var_sheet(
|
| 497 |
+
path=excel_path, prices=prices, ticker=ticker, n_days=n_days,
|
| 498 |
+
portfolio_value=portfolio_value, var_date=var_date, stressed=False,
|
| 499 |
+
lookback=lookback, var_confidence=var_confidence, es_confidence=es_confidence,
|
| 500 |
+
)
|
| 501 |
+
export_historical_var_sheet(
|
| 502 |
+
path=excel_path, prices=stressed_prices, ticker=ticker, n_days=n_days,
|
| 503 |
+
portfolio_value=portfolio_value, var_date=var_date, stressed=True,
|
| 504 |
+
stress_start=stress_start, stress_end=stress_end, stress_label=stress_label,
|
| 505 |
+
var_confidence=var_confidence, es_confidence=es_confidence,
|
| 506 |
+
)
|
| 507 |
+
return excel_path
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def export_parametric_var_report(
|
| 511 |
+
prices: pd.Series,
|
| 512 |
+
ticker: str,
|
| 513 |
+
n_days: int,
|
| 514 |
+
portfolio_value: float,
|
| 515 |
+
var_date: pd.Timestamp | None,
|
| 516 |
+
lookback: int,
|
| 517 |
+
stressed_prices: pd.Series,
|
| 518 |
+
stress_start: str,
|
| 519 |
+
stress_end: str,
|
| 520 |
+
stress_label: str,
|
| 521 |
+
var_confidence: float = 0.99,
|
| 522 |
+
es_confidence: float = 0.975,
|
| 523 |
+
) -> str:
|
| 524 |
+
"""Generate a full Parametric VaR Excel report (normal + stressed sheets)."""
|
| 525 |
+
output_dir = make_output_dir()
|
| 526 |
+
date_str = var_date.strftime("%Y-%m-%d") if var_date else ""
|
| 527 |
+
excel_path = os.path.join(output_dir, f"{ticker}_{date_str}_Parametric_VaR.xlsx")
|
| 528 |
+
|
| 529 |
+
export_parametric_var_sheet(
|
| 530 |
+
path=excel_path, prices=prices, ticker=ticker, n_days=n_days,
|
| 531 |
+
portfolio_value=portfolio_value, var_date=var_date, stressed=False,
|
| 532 |
+
lookback=lookback, var_confidence=var_confidence, es_confidence=es_confidence,
|
| 533 |
+
)
|
| 534 |
+
export_parametric_var_sheet(
|
| 535 |
+
path=excel_path, prices=stressed_prices, ticker=ticker, n_days=n_days,
|
| 536 |
+
portfolio_value=portfolio_value, var_date=var_date, stressed=True,
|
| 537 |
+
stress_start=stress_start, stress_end=stress_end, stress_label=stress_label,
|
| 538 |
+
var_confidence=var_confidence, es_confidence=es_confidence,
|
| 539 |
+
)
|
| 540 |
+
return excel_path
|
src/historical.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
historical_var.py -- Historical Value at Risk calculation and analysis pipeline.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from loguru import logger
|
| 8 |
+
from src.utils import fetch_prices, compute_returns, plot_distribution
|
| 9 |
+
from src.excel_export import export_historical_var_report
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def calculate_historical_var(returns: pd.Series, confidence: float) -> float:
|
| 13 |
+
"""Return VaR as a positive loss value.
|
| 14 |
+
|
| 15 |
+
VaR = (1 - confidence) percentile of returns, negated to express as loss.
|
| 16 |
+
"""
|
| 17 |
+
vals = returns.values
|
| 18 |
+
return -float(np.percentile(np.asarray(vals), (1.0 - confidence) * 100))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def calculate_historical_es(returns: pd.Series, confidence: float) -> float:
|
| 22 |
+
"""Return ES as a positive loss value.
|
| 23 |
+
|
| 24 |
+
ES = E[loss | loss > VaR], the mean of losses exceeding VaR.
|
| 25 |
+
"""
|
| 26 |
+
var = calculate_historical_var(returns, confidence)
|
| 27 |
+
losses = -np.asarray(returns.values)
|
| 28 |
+
tail = losses[losses > var]
|
| 29 |
+
return float(np.mean(tail)) if len(tail) > 0 else var
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def compute_historical_var_es(
|
| 34 |
+
returns: pd.Series,
|
| 35 |
+
var_confidence: float,
|
| 36 |
+
es_confidence: float,
|
| 37 |
+
n_days: int,
|
| 38 |
+
portfolio_value: float,
|
| 39 |
+
) -> dict:
|
| 40 |
+
"""Compute VaR and ES from returns and scale to n-day horizon.
|
| 41 |
+
|
| 42 |
+
Returns a dict with 1-day and n-day dollar VaR and ES.
|
| 43 |
+
"""
|
| 44 |
+
var_1d_pct = calculate_historical_var(returns, var_confidence)
|
| 45 |
+
es_1d_pct = calculate_historical_es(returns, es_confidence)
|
| 46 |
+
var_1d = var_1d_pct * portfolio_value
|
| 47 |
+
es_1d = es_1d_pct * portfolio_value
|
| 48 |
+
|
| 49 |
+
scaling_factor = np.sqrt(n_days)
|
| 50 |
+
var_nd = var_1d * scaling_factor
|
| 51 |
+
es_nd = es_1d * scaling_factor
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"var_1d": var_1d,
|
| 55 |
+
"var_nd": var_nd,
|
| 56 |
+
"es_1d": es_1d,
|
| 57 |
+
"es_nd": es_nd,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def compute_stressed_historical_var_es(
|
| 62 |
+
ticker: str,
|
| 63 |
+
var_confidence: float,
|
| 64 |
+
es_confidence: float,
|
| 65 |
+
n_days: int,
|
| 66 |
+
portfolio_value: float,
|
| 67 |
+
stress_start: str,
|
| 68 |
+
stress_end: str,
|
| 69 |
+
stress_label: str,
|
| 70 |
+
) -> dict:
|
| 71 |
+
"""Compute Stressed Historical VaR and ES over a defined stress window."""
|
| 72 |
+
prices = fetch_prices(ticker, start_date=stress_start, end_date=stress_end)
|
| 73 |
+
daily_returns = compute_returns(prices, kind="arithmetic")
|
| 74 |
+
|
| 75 |
+
result = compute_historical_var_es(daily_returns, var_confidence, es_confidence, n_days, portfolio_value)
|
| 76 |
+
|
| 77 |
+
logger.info(
|
| 78 |
+
f"Stressed VaR: 1d=${result['var_1d']:,.2f}, {n_days}d=${result['var_nd']:,.2f} | "
|
| 79 |
+
f"Stressed ES: 1d=${result['es_1d']:,.2f}, {n_days}d=${result['es_nd']:,.2f}"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
**result,
|
| 84 |
+
"stress_start": stress_start,
|
| 85 |
+
"stress_end": stress_end,
|
| 86 |
+
"stress_label": stress_label,
|
| 87 |
+
"prices": prices,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def historical_var_es_pipeline(
|
| 92 |
+
ticker: str,
|
| 93 |
+
var_confidence: float,
|
| 94 |
+
es_confidence: float,
|
| 95 |
+
lookback: int,
|
| 96 |
+
n_days: int,
|
| 97 |
+
portfolio_value: float,
|
| 98 |
+
end_date: pd.Timestamp | None = None,
|
| 99 |
+
stress_start: str = "2008-01-01",
|
| 100 |
+
stress_end: str = "2008-12-31",
|
| 101 |
+
stress_label: str = "Global Financial Crisis (2008)",
|
| 102 |
+
):
|
| 103 |
+
"""Execute the full Historical VaR pipeline.
|
| 104 |
+
|
| 105 |
+
Returns a dict with all computed results.
|
| 106 |
+
PnL and VaR values are expressed in dollars based on *portfolio_value*.
|
| 107 |
+
If end_date is None, defaults to current date.
|
| 108 |
+
"""
|
| 109 |
+
# 1. Fetch data and compute returns
|
| 110 |
+
prices = fetch_prices(ticker, lookback, end_date)
|
| 111 |
+
daily_returns = compute_returns(prices, kind="arithmetic")
|
| 112 |
+
|
| 113 |
+
# 2. Compute normal VaR and ES
|
| 114 |
+
normal = compute_historical_var_es(
|
| 115 |
+
daily_returns, var_confidence, es_confidence, n_days, portfolio_value,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# 3. Compute Stressed VaR/ES
|
| 119 |
+
stressed = compute_stressed_historical_var_es(
|
| 120 |
+
ticker, var_confidence, es_confidence, n_days, portfolio_value,
|
| 121 |
+
stress_start, stress_end, stress_label,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# 4. Generate Excel report (normal + stressed sheets)
|
| 125 |
+
excel_path = export_historical_var_report(
|
| 126 |
+
prices=prices,
|
| 127 |
+
ticker=ticker,
|
| 128 |
+
n_days=n_days,
|
| 129 |
+
portfolio_value=portfolio_value,
|
| 130 |
+
var_date=end_date,
|
| 131 |
+
lookback=lookback,
|
| 132 |
+
stressed_prices=stressed["prices"],
|
| 133 |
+
stress_start=stressed["stress_start"],
|
| 134 |
+
stress_end=stressed["stress_end"],
|
| 135 |
+
stress_label=stressed["stress_label"],
|
| 136 |
+
var_confidence=var_confidence,
|
| 137 |
+
es_confidence=es_confidence,
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# 5. Generate distribution plot
|
| 141 |
+
var_date_str = end_date.strftime("%Y-%m-%d") if end_date else ""
|
| 142 |
+
var_conf_pct = f"{var_confidence * 100:g}"
|
| 143 |
+
es_conf_pct = f"{es_confidence * 100:g}"
|
| 144 |
+
fig_dist = plot_distribution(
|
| 145 |
+
returns=daily_returns * portfolio_value,
|
| 146 |
+
var_cutoff=-normal["var_nd"],
|
| 147 |
+
var_label=f"VaR ({var_conf_pct}%, {n_days}d)",
|
| 148 |
+
es_cutoff=-normal["es_nd"],
|
| 149 |
+
es_label=f"ES ({es_conf_pct}%, {n_days}d)",
|
| 150 |
+
var_date=var_date_str,
|
| 151 |
+
method="Historical",
|
| 152 |
+
ticker=ticker,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
logger.info(
|
| 156 |
+
f"VaR: 1d=${normal['var_1d']:,.2f}, {n_days}d=${normal['var_nd']:,.2f} | "
|
| 157 |
+
f"ES: 1d=${normal['es_1d']:,.2f}, {n_days}d=${normal['es_nd']:,.2f}"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
return {
|
| 161 |
+
**normal,
|
| 162 |
+
"stressed_var_nd": stressed["var_nd"],
|
| 163 |
+
"stressed_es_nd": stressed["es_nd"],
|
| 164 |
+
"prices": prices,
|
| 165 |
+
"daily_returns": daily_returns,
|
| 166 |
+
"excel_path": excel_path,
|
| 167 |
+
"fig_dist": fig_dist,
|
| 168 |
+
}
|
src/logger.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
logger.py -- Centralized loguru configuration.
|
| 3 |
+
|
| 4 |
+
Import this module once (in app.py) to activate console + file sinks.
|
| 5 |
+
All other modules just do `from loguru import logger` directly.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from loguru import logger
|
| 12 |
+
|
| 13 |
+
# Project root = parent of src/
|
| 14 |
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
| 15 |
+
LOG_DIR = PROJECT_ROOT / "log"
|
| 16 |
+
LOG_FILE = LOG_DIR / "var_engine.log"
|
| 17 |
+
|
| 18 |
+
# Remove default stderr handler
|
| 19 |
+
logger.remove()
|
| 20 |
+
|
| 21 |
+
# Colored console output (INFO+)
|
| 22 |
+
logger.add(
|
| 23 |
+
sys.stderr,
|
| 24 |
+
level="INFO",
|
| 25 |
+
colorize=True,
|
| 26 |
+
format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>",
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# Single rotating file (DEBUG+)
|
| 30 |
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
logger.add(
|
| 32 |
+
str(LOG_FILE),
|
| 33 |
+
level="INFO",
|
| 34 |
+
rotation="10 MB",
|
| 35 |
+
retention="30 days",
|
| 36 |
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
| 37 |
+
)
|
src/parametric.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
parametric_var.py -- Parametric (Variance-Covariance) Value at Risk calculation and analysis pipeline.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from scipy import stats
|
| 8 |
+
from loguru import logger
|
| 9 |
+
from src.utils import fetch_prices, compute_returns, plot_distribution
|
| 10 |
+
from src.excel_export import export_parametric_var_report
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def estimate_distribution(returns: pd.Series) -> tuple[float, float]:
|
| 14 |
+
"""Estimate mean and standard deviation of daily returns.
|
| 15 |
+
|
| 16 |
+
Uses an unbiased sample standard deviation (ddof=1).
|
| 17 |
+
Returns (mu, sigma).
|
| 18 |
+
"""
|
| 19 |
+
mu = float(returns.mean())
|
| 20 |
+
sigma = float(returns.std(ddof=1))
|
| 21 |
+
return mu, sigma
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def calculate_parametric_var(returns: pd.Series, confidence: float) -> float:
|
| 25 |
+
"""Return VaR as a positive loss value using the normal model.
|
| 26 |
+
|
| 27 |
+
VaR = -(mu - z * sigma) where z = norm.ppf(confidence).
|
| 28 |
+
"""
|
| 29 |
+
mu, sigma = estimate_distribution(returns)
|
| 30 |
+
z = float(stats.norm.ppf(confidence))
|
| 31 |
+
return -(mu - z * sigma)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def calculate_parametric_es(returns: pd.Series, confidence: float) -> float:
|
| 35 |
+
"""Return ES as a positive loss value using the normal model.
|
| 36 |
+
|
| 37 |
+
ES = -(mu - sigma * phi(z) / (1 - confidence)).
|
| 38 |
+
"""
|
| 39 |
+
mu, sigma = estimate_distribution(returns)
|
| 40 |
+
z = float(stats.norm.ppf(confidence))
|
| 41 |
+
alpha = 1.0 - confidence
|
| 42 |
+
return -(mu - sigma * float(stats.norm.pdf(z)) / alpha)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def compute_parametric_var_es(
|
| 47 |
+
returns: pd.Series,
|
| 48 |
+
var_confidence: float,
|
| 49 |
+
es_confidence: float,
|
| 50 |
+
n_days: int,
|
| 51 |
+
portfolio_value: float,
|
| 52 |
+
) -> dict:
|
| 53 |
+
"""Compute VaR and ES from returns and scale to n-day horizon.
|
| 54 |
+
|
| 55 |
+
Returns a dict with 1-day and n-day dollar VaR and ES.
|
| 56 |
+
"""
|
| 57 |
+
var_1d_pct = calculate_parametric_var(returns, var_confidence)
|
| 58 |
+
es_1d_pct = calculate_parametric_es(returns, es_confidence)
|
| 59 |
+
var_1d = var_1d_pct * portfolio_value
|
| 60 |
+
es_1d = es_1d_pct * portfolio_value
|
| 61 |
+
|
| 62 |
+
scaling_factor = np.sqrt(n_days)
|
| 63 |
+
var_nd = var_1d * scaling_factor
|
| 64 |
+
es_nd = es_1d * scaling_factor
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
"var_1d": var_1d,
|
| 68 |
+
"var_nd": var_nd,
|
| 69 |
+
"es_1d": es_1d,
|
| 70 |
+
"es_nd": es_nd,
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def compute_stressed_parametric_var_es(
|
| 75 |
+
ticker: str,
|
| 76 |
+
var_confidence: float,
|
| 77 |
+
es_confidence: float,
|
| 78 |
+
n_days: int,
|
| 79 |
+
portfolio_value: float,
|
| 80 |
+
stress_start: str,
|
| 81 |
+
stress_end: str,
|
| 82 |
+
stress_label: str,
|
| 83 |
+
) -> dict:
|
| 84 |
+
"""Compute Stressed Parametric VaR and ES over a defined stress window."""
|
| 85 |
+
prices = fetch_prices(ticker, start_date=stress_start, end_date=stress_end)
|
| 86 |
+
daily_returns = compute_returns(prices, kind="log")
|
| 87 |
+
result = compute_parametric_var_es(daily_returns, var_confidence, es_confidence, n_days, portfolio_value)
|
| 88 |
+
|
| 89 |
+
logger.info(
|
| 90 |
+
f"Stressed Parametric VaR: 1d=${result['var_1d']:,.2f}, {n_days}d=${result['var_nd']:,.2f} | "
|
| 91 |
+
f"Stressed ES: 1d=${result['es_1d']:,.2f}, {n_days}d=${result['es_nd']:,.2f}"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
return {
|
| 95 |
+
**result,
|
| 96 |
+
"stress_start": stress_start,
|
| 97 |
+
"stress_end": stress_end,
|
| 98 |
+
"stress_label": stress_label,
|
| 99 |
+
"prices": prices,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def parametric_var_es_pipeline(
|
| 104 |
+
ticker: str,
|
| 105 |
+
var_confidence: float,
|
| 106 |
+
es_confidence: float,
|
| 107 |
+
lookback: int,
|
| 108 |
+
n_days: int,
|
| 109 |
+
portfolio_value: float,
|
| 110 |
+
end_date: pd.Timestamp | None = None,
|
| 111 |
+
stress_start: str = "2008-01-01",
|
| 112 |
+
stress_end: str = "2008-12-31",
|
| 113 |
+
stress_label: str = "Global Financial Crisis (2008)",
|
| 114 |
+
):
|
| 115 |
+
"""Execute the full Parametric VaR pipeline.
|
| 116 |
+
|
| 117 |
+
Returns a dict with all computed results.
|
| 118 |
+
VaR and ES values are expressed as positive dollar losses based on *portfolio_value*.
|
| 119 |
+
If end_date is None, defaults to the last business day.
|
| 120 |
+
"""
|
| 121 |
+
# 1. Fetch data and compute returns
|
| 122 |
+
prices = fetch_prices(ticker, lookback, end_date)
|
| 123 |
+
daily_returns = compute_returns(prices, kind="log")
|
| 124 |
+
mu, sigma = estimate_distribution(daily_returns)
|
| 125 |
+
|
| 126 |
+
# 2. Compute normal VaR and ES
|
| 127 |
+
normal = compute_parametric_var_es(
|
| 128 |
+
daily_returns, var_confidence, es_confidence, n_days, portfolio_value,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# 3. Compute Stressed VaR/ES
|
| 132 |
+
stressed = compute_stressed_parametric_var_es(
|
| 133 |
+
ticker, var_confidence, es_confidence, n_days, portfolio_value,
|
| 134 |
+
stress_start, stress_end, stress_label,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# 4. Generate Excel report (normal + stressed sheets)
|
| 138 |
+
excel_path = export_parametric_var_report(
|
| 139 |
+
prices=prices,
|
| 140 |
+
ticker=ticker,
|
| 141 |
+
n_days=n_days,
|
| 142 |
+
portfolio_value=portfolio_value,
|
| 143 |
+
var_date=end_date,
|
| 144 |
+
lookback=lookback,
|
| 145 |
+
stressed_prices=stressed["prices"],
|
| 146 |
+
stress_start=stressed["stress_start"],
|
| 147 |
+
stress_end=stressed["stress_end"],
|
| 148 |
+
stress_label=stressed["stress_label"],
|
| 149 |
+
var_confidence=var_confidence,
|
| 150 |
+
es_confidence=es_confidence,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# 5. Generate distribution plot
|
| 154 |
+
var_date_str = end_date.strftime("%Y-%m-%d") if end_date else ""
|
| 155 |
+
var_conf_pct = f"{var_confidence * 100:g}"
|
| 156 |
+
es_conf_pct = f"{es_confidence * 100:g}"
|
| 157 |
+
fig_dist = plot_distribution(
|
| 158 |
+
returns=daily_returns * portfolio_value,
|
| 159 |
+
var_cutoff=-normal["var_nd"],
|
| 160 |
+
var_label=f"VaR ({var_conf_pct}%, {n_days}d)",
|
| 161 |
+
es_cutoff=-normal["es_nd"],
|
| 162 |
+
es_label=f"ES ({es_conf_pct}%, {n_days}d)",
|
| 163 |
+
var_date=var_date_str,
|
| 164 |
+
method="Parametric",
|
| 165 |
+
ticker=ticker,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
logger.info(
|
| 169 |
+
f"Parametric VaR: 1d=${normal['var_1d']:,.2f}, {n_days}d=${normal['var_nd']:,.2f} | "
|
| 170 |
+
f"ES: 1d=${normal['es_1d']:,.2f}, {n_days}d=${normal['es_nd']:,.2f}"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
return {
|
| 174 |
+
**normal,
|
| 175 |
+
"stressed_var_nd": stressed["var_nd"],
|
| 176 |
+
"stressed_es_nd": stressed["es_nd"],
|
| 177 |
+
"prices": prices,
|
| 178 |
+
"daily_returns": daily_returns,
|
| 179 |
+
"mu": mu,
|
| 180 |
+
"sigma": sigma,
|
| 181 |
+
"excel_path": excel_path,
|
| 182 |
+
"fig_dist": fig_dist,
|
| 183 |
+
}
|
src/utils.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
utils.py -- Shared utilities: data fetching, return computation, and plotting.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
import yfinance as yf
|
| 9 |
+
from loguru import logger
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def fetch_prices(
|
| 13 |
+
ticker: str,
|
| 14 |
+
lookback: int | None = None,
|
| 15 |
+
var_date: pd.Timestamp | None = None,
|
| 16 |
+
start_date: str | None = None,
|
| 17 |
+
end_date: str | None = None,
|
| 18 |
+
) -> pd.Series:
|
| 19 |
+
"""Download close prices for *ticker*.
|
| 20 |
+
|
| 21 |
+
Two modes of operation:
|
| 22 |
+
|
| 23 |
+
**Lookback mode** (default): Supply *lookback* and optionally *var_date*.
|
| 24 |
+
Fetches the last *lookback* trading days ending before *var_date*.
|
| 25 |
+
|
| 26 |
+
**Date-range mode**: Supply *start_date* and *end_date* (YYYY-MM-DD strings).
|
| 27 |
+
Fetches all trading days in that window, plus one prior day so the
|
| 28 |
+
first daily return falls on or near *start_date*.
|
| 29 |
+
"""
|
| 30 |
+
if start_date and end_date:
|
| 31 |
+
# Date-range mode (stress periods)
|
| 32 |
+
start = pd.to_datetime(start_date) - pd.Timedelta(days=10)
|
| 33 |
+
end = pd.to_datetime(end_date) + pd.Timedelta(days=1) # yfinance 'end' is exclusive
|
| 34 |
+
|
| 35 |
+
logger.debug(
|
| 36 |
+
f"Fetching {ticker}: {start.strftime('%Y-%m-%d')} to {end_date}"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
df = yf.download(
|
| 41 |
+
ticker,
|
| 42 |
+
start=start.strftime("%Y-%m-%d"),
|
| 43 |
+
end=end.strftime("%Y-%m-%d"),
|
| 44 |
+
progress=False,
|
| 45 |
+
interval="1d",
|
| 46 |
+
auto_adjust=True,
|
| 47 |
+
)
|
| 48 |
+
except Exception:
|
| 49 |
+
raise ValueError(
|
| 50 |
+
f"No data returned for ticker '{ticker}' ({start_date} to {end_date})."
|
| 51 |
+
)
|
| 52 |
+
if not isinstance(df, pd.DataFrame) or df.empty:
|
| 53 |
+
raise ValueError(
|
| 54 |
+
f"No data returned for ticker '{ticker}' ({start_date} to {end_date})."
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
prices = pd.Series(df["Close"].squeeze())
|
| 58 |
+
prices.name = ticker
|
| 59 |
+
|
| 60 |
+
# Trim to one trading day before start_date through end_date
|
| 61 |
+
start_ts = pd.to_datetime(start_date)
|
| 62 |
+
start_idx = prices.index.searchsorted(start_ts)
|
| 63 |
+
start_idx = max(0, start_idx - 1)
|
| 64 |
+
prices = prices.iloc[start_idx:]
|
| 65 |
+
prices = prices.loc[:end_date]
|
| 66 |
+
|
| 67 |
+
logger.info(
|
| 68 |
+
f"Fetched {len(prices)} trading days for {ticker} "
|
| 69 |
+
f"({prices.index[0].strftime('%Y-%m-%d')} to {prices.index[-1].strftime('%Y-%m-%d')})"
|
| 70 |
+
)
|
| 71 |
+
return prices
|
| 72 |
+
|
| 73 |
+
# Lookback mode (historical VaR)
|
| 74 |
+
if var_date is None:
|
| 75 |
+
var_date = pd.Timestamp((pd.Timestamp.today() - pd.offsets.BDay()).date())
|
| 76 |
+
|
| 77 |
+
if lookback is None:
|
| 78 |
+
raise ValueError("lookback is required when start_date/end_date are not provided.")
|
| 79 |
+
calendar_days = int(lookback * 1.6)
|
| 80 |
+
# yfinance 'end' is exclusive, so passing var_date fetches up to the day before
|
| 81 |
+
start = var_date - pd.Timedelta(days=calendar_days)
|
| 82 |
+
logger.debug(
|
| 83 |
+
f"Fetching {ticker}: {start.strftime('%Y-%m-%d')} to {var_date.strftime('%Y-%m-%d')} (lookback={lookback})"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
df = yf.download(
|
| 88 |
+
ticker,
|
| 89 |
+
start=start.strftime("%Y-%m-%d"),
|
| 90 |
+
end=var_date.strftime("%Y-%m-%d"),
|
| 91 |
+
progress=False,
|
| 92 |
+
interval="1d",
|
| 93 |
+
auto_adjust=True
|
| 94 |
+
)
|
| 95 |
+
except Exception:
|
| 96 |
+
raise ValueError(f"No data returned for ticker '{ticker}'.")
|
| 97 |
+
if not isinstance(df, pd.DataFrame) or df.empty:
|
| 98 |
+
raise ValueError(f"No data returned for ticker '{ticker}'.")
|
| 99 |
+
|
| 100 |
+
prices = pd.Series(df["Close"].squeeze())
|
| 101 |
+
prices.name = ticker
|
| 102 |
+
result = prices.tail(lookback)
|
| 103 |
+
logger.info(
|
| 104 |
+
f"Fetched {len(result)} trading days for {ticker} (last date: {result.index[-1].strftime('%Y-%m-%d')})"
|
| 105 |
+
)
|
| 106 |
+
return result
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ------------------------------------------------------------------
|
| 110 |
+
# Return computation
|
| 111 |
+
# ------------------------------------------------------------------
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def compute_returns(prices: pd.Series, kind: str = "arithmetic") -> pd.Series:
|
| 115 |
+
"""Compute daily returns from a price series.
|
| 116 |
+
|
| 117 |
+
Parameters
|
| 118 |
+
----------
|
| 119 |
+
kind : "arithmetic" or "log"
|
| 120 |
+
arithmetic -> (P_t - P_{t-1}) / P_{t-1}
|
| 121 |
+
log -> log(P_t) - log(P_{t-1})
|
| 122 |
+
"""
|
| 123 |
+
if kind == "log":
|
| 124 |
+
log_prices = pd.Series(np.log(prices))
|
| 125 |
+
returns = log_prices - log_prices.shift(1)
|
| 126 |
+
name = "Daily Log Return"
|
| 127 |
+
else:
|
| 128 |
+
returns = (prices - prices.shift(1)) / prices.shift(1)
|
| 129 |
+
name = "Daily Return"
|
| 130 |
+
returns = pd.Series(returns, name=name)
|
| 131 |
+
return returns.dropna()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ------------------------------------------------------------------
|
| 136 |
+
# Plotting (Plotly)
|
| 137 |
+
# ------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def plot_distribution(
|
| 141 |
+
returns: pd.Series,
|
| 142 |
+
var_cutoff: float,
|
| 143 |
+
var_label: str = "VaR",
|
| 144 |
+
es_cutoff: float | None = None,
|
| 145 |
+
es_label: str = "ES",
|
| 146 |
+
var_date: str = "",
|
| 147 |
+
method: str = "",
|
| 148 |
+
ticker: str = "",
|
| 149 |
+
) -> go.Figure:
|
| 150 |
+
"""Return a histogram of the daily P&L distribution highlighting VaR and ES tail risk."""
|
| 151 |
+
fig = go.Figure()
|
| 152 |
+
|
| 153 |
+
# Split the distribution at the VaR cutoff (P&L below VaR are in the left tail)
|
| 154 |
+
normal_returns = returns[returns >= var_cutoff]
|
| 155 |
+
tail_returns = returns[returns < var_cutoff]
|
| 156 |
+
|
| 157 |
+
fig.add_trace(
|
| 158 |
+
go.Histogram(
|
| 159 |
+
x=normal_returns.values,
|
| 160 |
+
marker_color="steelblue",
|
| 161 |
+
opacity=0.8,
|
| 162 |
+
)
|
| 163 |
+
)
|
| 164 |
+
fig.add_trace(
|
| 165 |
+
go.Histogram(
|
| 166 |
+
x=tail_returns.values,
|
| 167 |
+
marker_color="darkorange",
|
| 168 |
+
opacity=0.8,
|
| 169 |
+
)
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
if var_cutoff is not None:
|
| 173 |
+
fig.add_vline(x=var_cutoff, line_width=1.5, line_dash="dot", line_color="black")
|
| 174 |
+
fig.add_annotation(
|
| 175 |
+
x=var_cutoff, xref="x",
|
| 176 |
+
y=0.5, yref="paper",
|
| 177 |
+
text=f"{var_label}<br>= ${abs(var_cutoff):,.2f}",
|
| 178 |
+
xanchor="left", yanchor="middle",
|
| 179 |
+
xshift=6,
|
| 180 |
+
showarrow=False,
|
| 181 |
+
font=dict(size=9, color="#444444"),
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
if es_cutoff is not None:
|
| 185 |
+
fig.add_vline(x=es_cutoff, line_width=1.5, line_dash="dash", line_color="darkred")
|
| 186 |
+
fig.add_annotation(
|
| 187 |
+
x=es_cutoff, xref="x",
|
| 188 |
+
y=0.5, yref="paper",
|
| 189 |
+
text=f"{es_label}<br>= ${abs(es_cutoff):,.2f}",
|
| 190 |
+
xanchor="right", yanchor="middle",
|
| 191 |
+
xshift=-6,
|
| 192 |
+
showarrow=False,
|
| 193 |
+
font=dict(size=9, color="darkred"),
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
title = "Daily Portfolio P&L Distribution with VaR & ES Thresholds"
|
| 197 |
+
|
| 198 |
+
fig.update_layout(
|
| 199 |
+
title=dict(text=title, font=dict(size=14)),
|
| 200 |
+
xaxis_title=dict(text="P&L ($)", font=dict(size=12)),
|
| 201 |
+
yaxis_title=dict(text="Frequency", font=dict(size=12)),
|
| 202 |
+
barmode="stack",
|
| 203 |
+
template="plotly_white",
|
| 204 |
+
yaxis=dict(showgrid=False),
|
| 205 |
+
margin=dict(t=80, b=40),
|
| 206 |
+
height=392.5,
|
| 207 |
+
showlegend=False,
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
if var_date:
|
| 211 |
+
fig.add_annotation(
|
| 212 |
+
text=f"VaR Date: {var_date}",
|
| 213 |
+
xref="paper", yref="paper",
|
| 214 |
+
x=1.08, y=1.22,
|
| 215 |
+
xanchor="right", yanchor="top",
|
| 216 |
+
showarrow=False,
|
| 217 |
+
font=dict(size=9, color="#444444"),
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
if method:
|
| 221 |
+
fig.add_annotation(
|
| 222 |
+
text=f"Method: {method}",
|
| 223 |
+
xref="paper", yref="paper",
|
| 224 |
+
x=1.08, y=1.16,
|
| 225 |
+
xanchor="right", yanchor="top",
|
| 226 |
+
showarrow=False,
|
| 227 |
+
font=dict(size=9, color="#444444"),
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
if ticker:
|
| 231 |
+
fig.add_annotation(
|
| 232 |
+
text=f"Ticker: {ticker}",
|
| 233 |
+
xref="paper", yref="paper",
|
| 234 |
+
x=1.08, y=1.10,
|
| 235 |
+
xanchor="right", yanchor="top",
|
| 236 |
+
showarrow=False,
|
| 237 |
+
font=dict(size=9, color="#444444"),
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
return fig
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|