From 183bcf14830d83128cc85fd5f679b81682e6ecf6 Mon Sep 17 00:00:00 2001 From: Jiaji_Qin Date: Thu, 9 Oct 2025 19:18:07 -0400 Subject: [PATCH 1/3] Add Quant Risk Analytics Suite project structure --- risk_analytics_suite/README.md | 66 ++++++++++ risk_analytics_suite/__init__.py | 0 risk_analytics_suite/app/dashboard.py | 53 ++++++++ risk_analytics_suite/configs/base.yaml | 10 ++ risk_analytics_suite/configs/factors.yaml | 29 +++++ risk_analytics_suite/configs/risk.yaml | 13 ++ .../experiments/exp_factor_exposure.ipynb | 25 ++++ .../experiments/exp_stress_scenarios.ipynb | 25 ++++ .../experiments/exp_var_compare.ipynb | 25 ++++ risk_analytics_suite/requirements.txt | 15 +++ risk_analytics_suite/risklab/__init__.py | 14 ++ risk_analytics_suite/risklab/attribution.py | 43 ++++++ risk_analytics_suite/risklab/backtest.py | 34 +++++ risk_analytics_suite/risklab/factors.py | 123 ++++++++++++++++++ risk_analytics_suite/risklab/io.py | 110 ++++++++++++++++ risk_analytics_suite/risklab/pca.py | 26 ++++ risk_analytics_suite/risklab/preprocess.py | 40 ++++++ risk_analytics_suite/risklab/risk.py | 96 ++++++++++++++ risk_analytics_suite/risklab/stress.py | 34 +++++ risk_analytics_suite/risklab/viz.py | 24 ++++ 20 files changed, 805 insertions(+) create mode 100644 risk_analytics_suite/README.md create mode 100644 risk_analytics_suite/__init__.py create mode 100644 risk_analytics_suite/app/dashboard.py create mode 100644 risk_analytics_suite/configs/base.yaml create mode 100644 risk_analytics_suite/configs/factors.yaml create mode 100644 risk_analytics_suite/configs/risk.yaml create mode 100644 risk_analytics_suite/experiments/exp_factor_exposure.ipynb create mode 100644 risk_analytics_suite/experiments/exp_stress_scenarios.ipynb create mode 100644 risk_analytics_suite/experiments/exp_var_compare.ipynb create mode 100644 risk_analytics_suite/requirements.txt create mode 100644 risk_analytics_suite/risklab/__init__.py create mode 100644 risk_analytics_suite/risklab/attribution.py create mode 100644 risk_analytics_suite/risklab/backtest.py create mode 100644 risk_analytics_suite/risklab/factors.py create mode 100644 risk_analytics_suite/risklab/io.py create mode 100644 risk_analytics_suite/risklab/pca.py create mode 100644 risk_analytics_suite/risklab/preprocess.py create mode 100644 risk_analytics_suite/risklab/risk.py create mode 100644 risk_analytics_suite/risklab/stress.py create mode 100644 risk_analytics_suite/risklab/viz.py diff --git a/risk_analytics_suite/README.md b/risk_analytics_suite/README.md new file mode 100644 index 0000000..be72bca --- /dev/null +++ b/risk_analytics_suite/README.md @@ -0,0 +1,66 @@ +# Quant Risk Analytics Suite + +Quantitative Risk Framework for Multi-Asset Portfolios. This project provides a modular research environment for estimating multi-factor models, decomposing risk, and visualising portfolio diagnostics through an interactive dashboard. + +## Key Features +- **Data Engineering** – pull, cache, and preprocess multi-asset price series together with macro factors. +- **Factor Modelling** – construct Fama-French style factors with momentum, liquidity, and custom macro extensions; run rolling regressions to obtain exposures. +- **Dimensionality Reduction** – perform Ledoit-Wolf shrinkage covariance estimation and principal component analysis on the return matrix. +- **Risk Analytics** – compute historical, Monte Carlo, and Extreme Value Theory (EVT) based VaR / CVaR metrics with Kupiec backtesting utilities. +- **Stress Testing** – evaluate bespoke macro shock scenarios and produce interactive loss waterfalls. +- **Attribution & Performance** – breakdown portfolio returns into factor contributions and residuals; run simple backtests with turnover-aware costs. +- **Visualisation & Reporting** – produce Plotly charts and a Streamlit dashboard for quick iteration. + +## Project Layout +``` +risk_analytics_suite/ +├── README.md +├── requirements.txt +├── configs/ +│ ├── base.yaml +│ ├── factors.yaml +│ └── risk.yaml +├── data/ +│ ├── raw/ +│ └── processed/ +├── risklab/ +│ ├── __init__.py +│ ├── io.py +│ ├── preprocess.py +│ ├── factors.py +│ ├── pca.py +│ ├── risk.py +│ ├── stress.py +│ ├── attribution.py +│ ├── backtest.py +│ └── viz.py +├── experiments/ +│ ├── exp_factor_exposure.ipynb +│ ├── exp_var_compare.ipynb +│ └── exp_stress_scenarios.ipynb +└── app/ + └── dashboard.py +``` + +## Quickstart +1. Install dependencies + ```bash + python -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` +2. Fetch data & run experiments + ```bash + python -m risk_analytics_suite.risklab.io --config base + jupyter lab + ``` +3. Launch the dashboard + ```bash + streamlit run app/dashboard.py + ``` + +## Reproducibility +Configuration is managed via [Hydra](https://hydra.cc) YAML files. Adjust tickers, windows, and confidence levels without modifying code. A ``make reproduce`` workflow can be added to fetch fresh data and execute notebooks end-to-end. + +## License +MIT diff --git a/risk_analytics_suite/__init__.py b/risk_analytics_suite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/risk_analytics_suite/app/dashboard.py b/risk_analytics_suite/app/dashboard.py new file mode 100644 index 0000000..5e3c3c8 --- /dev/null +++ b/risk_analytics_suite/app/dashboard.py @@ -0,0 +1,53 @@ +"""Streamlit dashboard for the Quant Risk Analytics Suite.""" +from __future__ import annotations + +import streamlit as st + +from risk_analytics_suite.risklab import io, preprocess, factors, risk, viz + + +st.set_page_config(page_title="Quant Risk Analytics Suite", layout="wide") +st.title("Quant Risk Analytics Suite") + +config = io.load_hydra_config() +dataset = config.dataset + + +@st.cache_data(show_spinner=False) +def load_data(): + prices = io.fetch_prices( + dataset.tickers, + dataset.start, + dataset.end, + dataset.get("source", "yfinance"), + dataset.get("frequency", "1d"), + ) + returns = preprocess.to_log_returns(prices) + return prices, returns + + +prices, returns = load_data() +st.sidebar.header("Portfolio Setup") +selected = st.sidebar.multiselect("Assets", list(returns.columns), default=list(returns.columns)[:4]) +weights = st.sidebar.slider("Equal Weight Portfolio", 0.0, 1.0, 1.0) +portfolio_returns = returns[selected].mean(axis=1) * weights + +st.subheader("Portfolio Performance") +fig_equity = viz.line_chart((1 + portfolio_returns).cumprod(), title="Equity Curve") +st.plotly_chart(fig_equity, use_container_width=True) + +st.subheader("Risk Metrics") +alpha = st.sidebar.select_slider("VaR Confidence", options=[0.90, 0.95, 0.975, 0.99], value=0.95) +var_hist = risk.var_historical(portfolio_returns, alpha=alpha) +cvar_hist = risk.cvar_historical(portfolio_returns, alpha=alpha) +st.metric("Historical VaR", f"{var_hist:.2%}") +st.metric("Historical CVaR", f"{cvar_hist:.2%}") + +st.subheader("Factor Exposures") +specs = [ + factors.FactorSpec(name="market", method="fama_french", params={"level": "mkt"}), + factors.FactorSpec(name="momentum", method="momentum", params={"window": 126}), +] +factor_df = factors.build_factors(returns[selected], specs) +exposures = factors.regress_exposure(returns[selected], factor_df) +st.plotly_chart(viz.heatmap(exposures.drop(columns="r2"), title="Factor Betas"), use_container_width=True) diff --git a/risk_analytics_suite/configs/base.yaml b/risk_analytics_suite/configs/base.yaml new file mode 100644 index 0000000..38bf7f9 --- /dev/null +++ b/risk_analytics_suite/configs/base.yaml @@ -0,0 +1,10 @@ +dataset: + tickers: ["SPY", "QQQ", "EFA", "TLT", "GLD", "USO", "XLF", "XLE"] + benchmark: "SPY" + start: "2015-01-01" + end: "2024-01-01" + source: "yfinance" + frequency: "1d" +preprocess: + winsor_limits: [0.01, 0.99] + standardize: true diff --git a/risk_analytics_suite/configs/factors.yaml b/risk_analytics_suite/configs/factors.yaml new file mode 100644 index 0000000..132842a --- /dev/null +++ b/risk_analytics_suite/configs/factors.yaml @@ -0,0 +1,29 @@ +factors: + - name: "market" + method: "fama_french" + params: + level: "mkt" + - name: "size" + method: "fama_french" + params: + level: "smb" + - name: "value" + method: "fama_french" + params: + level: "hml" + - name: "momentum" + method: "momentum" + params: + window: 252 + - name: "liquidity" + method: "liquidity" + params: + volume_window: 63 + - name: "vix" + method: "macro" + params: + symbol: "^VIX" + - name: "term_spread" + method: "macro" + params: + fred_series: "T10Y2Y" diff --git a/risk_analytics_suite/configs/risk.yaml b/risk_analytics_suite/configs/risk.yaml new file mode 100644 index 0000000..5860770 --- /dev/null +++ b/risk_analytics_suite/configs/risk.yaml @@ -0,0 +1,13 @@ +risk: + alpha: 0.95 + backtest_window: 252 + methods: + - historical + - monte_carlo + - evt +mc: + n_paths: 10000 + seed: 42 +evt: + threshold_quantile: 0.9 + tail: "lower" diff --git a/risk_analytics_suite/experiments/exp_factor_exposure.ipynb b/risk_analytics_suite/experiments/exp_factor_exposure.ipynb new file mode 100644 index 0000000..346c324 --- /dev/null +++ b/risk_analytics_suite/experiments/exp_factor_exposure.ipynb @@ -0,0 +1,25 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Placeholder\n", + "This notebook documents the ${nb/exp_/} experiment. Populate with data pulls and analysis steps." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/risk_analytics_suite/experiments/exp_stress_scenarios.ipynb b/risk_analytics_suite/experiments/exp_stress_scenarios.ipynb new file mode 100644 index 0000000..346c324 --- /dev/null +++ b/risk_analytics_suite/experiments/exp_stress_scenarios.ipynb @@ -0,0 +1,25 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Placeholder\n", + "This notebook documents the ${nb/exp_/} experiment. Populate with data pulls and analysis steps." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/risk_analytics_suite/experiments/exp_var_compare.ipynb b/risk_analytics_suite/experiments/exp_var_compare.ipynb new file mode 100644 index 0000000..346c324 --- /dev/null +++ b/risk_analytics_suite/experiments/exp_var_compare.ipynb @@ -0,0 +1,25 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Placeholder\n", + "This notebook documents the ${nb/exp_/} experiment. Populate with data pulls and analysis steps." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/risk_analytics_suite/requirements.txt b/risk_analytics_suite/requirements.txt new file mode 100644 index 0000000..8f8cdc6 --- /dev/null +++ b/risk_analytics_suite/requirements.txt @@ -0,0 +1,15 @@ +numpy +pandas +scipy +scikit-learn +statsmodels +numba +plotly +streamlit +pydantic +hydra-core +yfinance +fredapi +matplotlib +seaborn +pyyaml diff --git a/risk_analytics_suite/risklab/__init__.py b/risk_analytics_suite/risklab/__init__.py new file mode 100644 index 0000000..ccc91fe --- /dev/null +++ b/risk_analytics_suite/risklab/__init__.py @@ -0,0 +1,14 @@ +"""RiskLab package exposing analytics utilities.""" +from . import io, preprocess, factors, pca, risk, stress, attribution, backtest, viz + +__all__ = [ + "io", + "preprocess", + "factors", + "pca", + "risk", + "stress", + "attribution", + "backtest", + "viz", +] diff --git a/risk_analytics_suite/risklab/attribution.py b/risk_analytics_suite/risklab/attribution.py new file mode 100644 index 0000000..2679120 --- /dev/null +++ b/risk_analytics_suite/risklab/attribution.py @@ -0,0 +1,43 @@ +"""Factor attribution utilities.""" +from __future__ import annotations + +from typing import Mapping + +import numpy as np +import pandas as pd +import statsmodels.api as sm + + +def factor_attribution( + weights: Mapping[str, float], + factor_returns: pd.DataFrame, + benchmark_weights: Mapping[str, float] | None = None, +) -> pd.DataFrame: + """Decompose returns into factor contributions.""" + + weights = pd.Series(weights, dtype=float) + factor_portfolio = factor_returns.mul(weights, axis=1) + contributions = factor_portfolio.sum(axis=1) + df = pd.DataFrame({"factor_contribution": contributions}) + if benchmark_weights is not None: + benchmark = pd.Series(benchmark_weights, dtype=float) + active = weights - benchmark.reindex(weights.index).fillna(0.0) + df["active_weight"] = active.reindex(weights.index).sum() + return df + + +def performance_breakdown(returns: pd.DataFrame, factors: pd.DataFrame) -> pd.DataFrame: + """Calculate rolling R-squared and residual volatility.""" + + aligned_returns, aligned_factors = returns.align(factors, join="inner", axis=0) + resid_vol = {} + for asset in aligned_returns: + y = aligned_returns[asset] + X = sm.add_constant(aligned_factors) + model = sm.OLS(y, X, missing="drop") + results = model.fit() + resid_vol[asset] = { + "r2": results.rsquared, + "resid_vol": results.resid.std(ddof=0) * np.sqrt(252), + } + return pd.DataFrame(resid_vol).T diff --git a/risk_analytics_suite/risklab/backtest.py b/risk_analytics_suite/risklab/backtest.py new file mode 100644 index 0000000..e223456 --- /dev/null +++ b/risk_analytics_suite/risklab/backtest.py @@ -0,0 +1,34 @@ +"""Simple backtesting engine.""" +from __future__ import annotations + +from typing import Mapping + +import pandas as pd + + +def run_backtest( + signals: pd.DataFrame, + prices: pd.DataFrame | None = None, + costs: float = 0.0005, + rebalance: str = "W-FRI", +) -> dict[str, pd.Series]: + """Turn trading signals into portfolio performance.""" + + weights = signals.resample(rebalance).last().fillna(0.0) + weights = weights.div(weights.abs().sum(axis=1), axis=0).fillna(0.0) + if prices is None: + returns = signals.pct_change().fillna(0.0) + else: + returns = prices.pct_change().reindex(weights.index, method="ffill").fillna(0.0) + aligned_returns = returns.reindex(weights.index).fillna(0.0) + turnover = weights.diff().abs().sum(axis=1).fillna(0.0) + gross = (weights.shift().fillna(0.0) * aligned_returns).sum(axis=1) + net = gross - costs * turnover + cum = (1 + net).cumprod() + stats = { + "returns": net, + "gross_returns": gross, + "turnover": turnover, + "equity_curve": cum, + } + return stats diff --git a/risk_analytics_suite/risklab/factors.py b/risk_analytics_suite/risklab/factors.py new file mode 100644 index 0000000..272692e --- /dev/null +++ b/risk_analytics_suite/risklab/factors.py @@ -0,0 +1,123 @@ +"""Factor modelling utilities.""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from functools import lru_cache +from typing import Iterable, Literal + +import numpy as np +import pandas as pd +import statsmodels.api as sm + +try: + import yfinance as yf +except ImportError as exc: # pragma: no cover + raise ImportError("yfinance is required for factor construction") from exc + +try: + from fredapi import Fred +except ImportError: # pragma: no cover - optional dependency + Fred = None + + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class FactorSpec: + name: str + method: Literal["fama_french", "momentum", "liquidity", "macro", "sentiment"] + params: dict + + +@lru_cache(None) +def _download_series(ticker: str, start: str = "2010-01-01", end: str | None = None) -> pd.Series: + data = yf.download(ticker, start=start, end=end, progress=False)["Adj Close"].rename(ticker) + return data.dropna() + + +def _macro_series(params: dict) -> pd.Series: + if "symbol" in params: + return _download_series(params["symbol"]) + if "fred_series" in params and Fred is not None: + fred = Fred() + series = fred.get_series(params["fred_series"]) + return series.rename(params["fred_series"]) + raise ValueError("Macro factor requires `symbol` or `fred_series` parameter") + + +def _fama_french_proxy(level: str) -> pd.Series: + level = level.lower() + if level == "mkt": + return _download_series("^GSPC") + if level == "smb": + return _download_series("IWM") - _download_series("SPY") + if level == "hml": + return _download_series("VLUE") - _download_series("SPY") + raise ValueError(f"Unsupported Fama-French level: {level}") + + +def _momentum_factor(returns: pd.DataFrame, window: int = 252) -> pd.Series: + cumret = (1 + returns).rolling(window=window).apply(np.prod, raw=True) - 1 + cross_section = cumret.rank(axis=1, pct=True) + factor = (cross_section - 0.5).mean(axis=1) + return factor.rename("momentum") + + +def _liquidity_factor(returns: pd.DataFrame, window: int = 63) -> pd.Series: + volatility = returns.abs().rolling(window).mean() + liquidity_score = -volatility.mean(axis=1) + return liquidity_score.rename("liquidity") + + +def build_factors(returns: pd.DataFrame, specs: Iterable[FactorSpec]) -> pd.DataFrame: + """Construct a factor return DataFrame from specifications.""" + + factors = {} + index = returns.index + for spec in specs: + if spec.method == "fama_french": + series = _fama_french_proxy(spec.params.get("level", "mkt")) + factors[spec.name] = series.pct_change().reindex(index).fillna(0.0) + elif spec.method == "momentum": + factors[spec.name] = _momentum_factor(returns, window=spec.params.get("window", 252)) + elif spec.method == "liquidity": + factors[spec.name] = _liquidity_factor(returns, window=spec.params.get("volume_window", 63)) + elif spec.method == "macro": + macro_series = _macro_series(spec.params) + factors[spec.name] = macro_series.pct_change().reindex(index).fillna(0.0) + else: + raise ValueError(f"Unsupported factor method: {spec.method}") + factor_df = pd.DataFrame(factors).dropna(how="all") + factor_df.index = pd.to_datetime(factor_df.index) + factor_df = factor_df.sort_index() + return factor_df.loc[index.min() : index.max()] + + +def regress_exposure( + returns: pd.DataFrame, + factors: pd.DataFrame, + robust: bool = True, +) -> pd.DataFrame: + """Run time-series regressions to estimate factor exposures.""" + + aligned_returns, aligned_factors = returns.dropna(how="all").align( + factors.dropna(how="all"), join="inner", axis=0 + ) + + exposures = [] + for asset in aligned_returns: + y = aligned_returns[asset].dropna() + X = aligned_factors.reindex(y.index) + X = sm.add_constant(X) + model = sm.OLS(y, X, missing="drop") + if robust: + results = model.fit(cov_type="HAC", cov_kwds={"maxlags": 5}) + else: + results = model.fit() + params = results.params.rename(asset) + params.loc["r2"] = results.rsquared + exposures.append(params) + exposure_df = pd.DataFrame(exposures) + return exposure_df diff --git a/risk_analytics_suite/risklab/io.py b/risk_analytics_suite/risklab/io.py new file mode 100644 index 0000000..169cc29 --- /dev/null +++ b/risk_analytics_suite/risklab/io.py @@ -0,0 +1,110 @@ +"""Data acquisition utilities for the Quant Risk Analytics Suite.""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +import pandas as pd +from omegaconf import DictConfig, OmegaConf + +try: + import yfinance as yf +except ImportError as exc: # pragma: no cover - optional dependency + raise ImportError("yfinance is required for data fetching") from exc + + +DATA_ROOT = Path(__file__).resolve().parents[1] / "data" +RAW_DIR = DATA_ROOT / "raw" +PROCESSED_DIR = DATA_ROOT / "processed" + + +@dataclass +class PriceFetchConfig: + tickers: Iterable[str] + start: str + end: str + source: str = "yfinance" + interval: str = "1d" + + +def fetch_prices( + tickers: Iterable[str], + start: str, + end: str, + source: str = "yfinance", + interval: str = "1d", + auto_adjust: bool = True, +) -> pd.DataFrame: + """Fetch adjusted close prices for a collection of tickers.""" + + if source != "yfinance": # pragma: no cover + raise NotImplementedError(f"Data source {source} is not supported") + + data = ( + yf.download( + tickers=list(tickers), + start=start, + end=end, + interval=interval, + auto_adjust=auto_adjust, + progress=False, + )["Close"].rename_axis("date") + ) + data = data.dropna(how="all") + data.index = pd.to_datetime(data.index) + return data.sort_index() + + +def cache_prices(prices: pd.DataFrame, name: str) -> Path: + RAW_DIR.mkdir(parents=True, exist_ok=True) + path = RAW_DIR / f"{name}.csv" + prices.to_csv(path, index=True) + return path + + +def load_cached(path: str | Path) -> pd.DataFrame: + return pd.read_csv(path, index_col=0, parse_dates=True) + + +def fetch_with_config(cfg: PriceFetchConfig, cache: bool = True) -> pd.DataFrame: + prices = fetch_prices( + tickers=cfg.tickers, + start=cfg.start, + end=cfg.end, + source=cfg.source, + interval=cfg.interval, + ) + if cache: + cache_prices(prices, name="_".join(cfg.tickers)) + return prices + + +def load_hydra_config(config_name: str = "base", config_dir: str = "configs") -> DictConfig: + config_path = Path(__file__).resolve().parents[1] / config_dir / f"{config_name}.yaml" + if not config_path.exists(): + raise FileNotFoundError(f"Config file {config_path} does not exist") + return OmegaConf.load(config_path) + + +def cli_entry(config_name: str = "base") -> None: + cfg = load_hydra_config(config_name=config_name) + dataset = cfg.get("dataset") + prices = fetch_prices( + dataset["tickers"], + dataset["start"], + dataset["end"], + dataset.get("source", "yfinance"), + dataset.get("frequency", "1d"), + ) + cache_prices(prices, name=f"{config_name}_prices") + print(f"Cached {len(prices)} rows of price data to {RAW_DIR}") + + +if __name__ == "__main__": # pragma: no cover + import argparse + + parser = argparse.ArgumentParser(description="Fetch and cache price data") + parser.add_argument("--config", default="base", help="Name of the config yaml in configs/") + args = parser.parse_args() + cli_entry(config_name=args.config) diff --git a/risk_analytics_suite/risklab/pca.py b/risk_analytics_suite/risklab/pca.py new file mode 100644 index 0000000..0e8cb37 --- /dev/null +++ b/risk_analytics_suite/risklab/pca.py @@ -0,0 +1,26 @@ +"""Principal component analysis helpers.""" +from __future__ import annotations + +import numpy as np +import pandas as pd +from sklearn.covariance import LedoitWolf + + +def shrinkage_cov(returns: pd.DataFrame, method: str = "ledoit_wolf") -> np.ndarray: + """Estimate a covariance matrix with shrinkage.""" + + if method != "ledoit_wolf": # pragma: no cover + raise NotImplementedError(f"Covariance shrinkage method {method} is not supported") + lw = LedoitWolf().fit(returns.dropna()) + return lw.covariance_ + + +def pca_factors(cov: np.ndarray, n_components: int) -> tuple[np.ndarray, np.ndarray]: + """Return leading eigenvectors and explained variance ratios.""" + + eigenvalues, eigenvectors = np.linalg.eigh(cov) + idx = np.argsort(eigenvalues)[::-1] + eigenvalues = eigenvalues[idx][:n_components] + eigenvectors = eigenvectors[:, idx][:, :n_components] + explained = eigenvalues / eigenvalues.sum() + return eigenvectors, explained diff --git a/risk_analytics_suite/risklab/preprocess.py b/risk_analytics_suite/risklab/preprocess.py new file mode 100644 index 0000000..be1b530 --- /dev/null +++ b/risk_analytics_suite/risklab/preprocess.py @@ -0,0 +1,40 @@ +"""Preprocessing routines for price and return data.""" +from __future__ import annotations + +import numpy as np +import pandas as pd + + +def to_log_returns(prices: pd.DataFrame) -> pd.DataFrame: + """Convert price levels to log returns.""" + + prices = prices.sort_index() + log_prices = np.log(prices) + return log_prices.diff().dropna(how="all") + + +def winsorize(df: pd.DataFrame, limits: tuple[float, float] = (0.01, 0.99)) -> pd.DataFrame: + """Apply winsorisation to each column of the DataFrame.""" + + lower, upper = limits + clipped = df.copy() + for col in clipped.columns: + series = clipped[col].dropna() + lo, hi = series.quantile([lower, upper]) + clipped[col] = series.clip(lo, hi) + return clipped + + +def standardize(df: pd.DataFrame) -> pd.DataFrame: + """Standardise columns to zero mean and unit variance.""" + + return (df - df.mean()) / df.std(ddof=0) + + +def align_returns_and_factors(returns: pd.DataFrame, factors: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]: + """Align asset returns with factor returns (lag factors by one period).""" + + aligned_returns = returns.loc[factors.index.intersection(returns.index)] + aligned_factors = factors.reindex(aligned_returns.index).shift(1).dropna() + aligned_returns = aligned_returns.loc[aligned_factors.index] + return aligned_returns, aligned_factors diff --git a/risk_analytics_suite/risklab/risk.py b/risk_analytics_suite/risklab/risk.py new file mode 100644 index 0000000..48f2819 --- /dev/null +++ b/risk_analytics_suite/risklab/risk.py @@ -0,0 +1,96 @@ +"""Risk measurement toolkit.""" +from __future__ import annotations + +import numpy as np +import pandas as pd +from scipy.stats import genpareto + + +def var_historical(returns: pd.Series, alpha: float = 0.95, window: int = 252) -> float: + """Historical Value-at-Risk.""" + + windowed = returns.dropna().iloc[-window:] + quantile = np.quantile(windowed, 1 - alpha) + return float(-quantile) + + +def cvar_historical(returns: pd.Series, alpha: float = 0.95, window: int = 252) -> float: + """Conditional VaR under the historical distribution.""" + + windowed = returns.dropna().iloc[-window:] + threshold = np.quantile(windowed, 1 - alpha) + tail_losses = windowed[windowed <= threshold] + return float(-tail_losses.mean()) + + +def var_mc( + weights: np.ndarray, + mu: np.ndarray, + cov: np.ndarray, + alpha: float = 0.95, + n: int = 10_000, + seed: int | None = None, +) -> float: + """Parametric Monte Carlo VaR using multivariate normal simulation.""" + + rng = np.random.default_rng(seed) + simulated = rng.multivariate_normal(mu, cov, size=n) + pnl = simulated @ weights + quantile = np.quantile(pnl, 1 - alpha) + return float(-quantile) + + +def var_evt(returns: pd.Series, alpha: float = 0.99, threshold_quantile: float = 0.9, tail: str = "lower") -> float: + """Extreme Value Theory based VaR using the Generalized Pareto distribution.""" + + returns = returns.dropna() + threshold = np.quantile(returns, threshold_quantile) + if tail == "lower": + tail_losses = threshold - returns[returns < threshold] + else: + tail_losses = returns[returns > threshold] - threshold + if len(tail_losses) < 5: + raise ValueError("Insufficient tail observations for EVT estimation") + params = genpareto.fit(tail_losses) + shape, loc, scale = params + prob = 1 - alpha + q = genpareto.ppf(prob / (1 - threshold_quantile), shape, loc=loc, scale=scale) + if tail == "lower": + return float(-(threshold - q)) + return float(-(threshold + q)) + + +def kupiec_test(violations: pd.Series, alpha: float) -> float: + """Kupiec unconditional coverage test statistic.""" + + N = len(violations) + N1 = violations.sum() + if N1 == 0 or N1 == N: + return np.inf + p_hat = N1 / N + likelihood_ratio = -2 * ( + (N - N1) * np.log((1 - alpha) / (1 - p_hat)) + N1 * np.log(alpha / p_hat) + ) + return float(likelihood_ratio) + + +def rolling_var(returns: pd.Series, method: str = "historical", **kwargs) -> pd.Series: + """Convenience wrapper to compute rolling VaR estimates.""" + + window = kwargs.pop("window", 252) + alpha = kwargs.pop("alpha", 0.95) + out = [] + for i in range(window, len(returns) + 1): + windowed = returns.iloc[i - window : i] + if method == "historical": + out.append(var_historical(windowed, alpha=alpha, window=window)) + elif method == "cvar": + out.append(cvar_historical(windowed, alpha=alpha, window=window)) + elif method == "evt": + out.append( + var_evt(windowed, alpha=alpha, threshold_quantile=kwargs.get("threshold_quantile", 0.9)) + ) + else: + raise ValueError(f"Unsupported method {method}") + index = returns.index[window - 1 :] + return pd.Series(out, index=index, name=f"VaR_{method}") diff --git a/risk_analytics_suite/risklab/stress.py b/risk_analytics_suite/risklab/stress.py new file mode 100644 index 0000000..9729950 --- /dev/null +++ b/risk_analytics_suite/risklab/stress.py @@ -0,0 +1,34 @@ +"""Stress testing helpers.""" +from __future__ import annotations + +from typing import Mapping + +import pandas as pd + + +def shock_scenarios(returns: pd.DataFrame, shocks: Mapping[str, float]) -> pd.Series: + """Apply deterministic shocks to asset returns.""" + + shocked = returns.copy() + for asset, shock in shocks.items(): + if asset not in shocked: + continue + shocked[asset] += shock + portfolio = shocked.mean(axis=1) + return portfolio.rename("scenario_return") + + +def rate_spread_shock(portfolio: Mapping[str, float], bp: int) -> dict[str, float]: + """Translate a basis point move into asset shocks.""" + + scale = bp / 10_000 + return {asset: -scale * weight for asset, weight in portfolio.items()} + + +def loss_impact(weights: Mapping[str, float], returns: pd.DataFrame) -> pd.Series: + """Compute portfolio loss impact under stressed returns.""" + + weights_vec = pd.Series(weights) + aligned = returns[weights_vec.index].fillna(0.0) + pnl = aligned.mul(weights_vec, axis=1).sum(axis=1) + return pnl.rename("stressed_pnl") diff --git a/risk_analytics_suite/risklab/viz.py b/risk_analytics_suite/risklab/viz.py new file mode 100644 index 0000000..abab270 --- /dev/null +++ b/risk_analytics_suite/risklab/viz.py @@ -0,0 +1,24 @@ +"""Plotly visualisations for the analytics suite.""" +from __future__ import annotations + +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go + + +def line_chart(series: pd.Series, title: str = "Line Chart") -> go.Figure: + fig = px.line(series, title=title) + fig.update_layout(template="plotly_white") + return fig + + +def heatmap(df: pd.DataFrame, title: str = "Heatmap") -> go.Figure: + fig = px.imshow(df.T, aspect="auto", title=title, color_continuous_scale="RdBu") + fig.update_layout(template="plotly_white") + return fig + + +def distribution(data: pd.Series, title: str = "Distribution") -> go.Figure: + fig = px.histogram(data, nbins=50, marginal="box", title=title) + fig.update_layout(template="plotly_white") + return fig From 237381d2a329cde0110d5f5407df1ed31138c874 Mon Sep 17 00:00:00 2001 From: Jiaji_Qin Date: Thu, 9 Oct 2025 19:52:40 -0400 Subject: [PATCH 2/3] Refactor optional imports to use importlib guards --- risk_analytics_suite/risklab/factors.py | 31 ++++++++++++++++--------- risk_analytics_suite/risklab/io.py | 15 ++++++++---- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/risk_analytics_suite/risklab/factors.py b/risk_analytics_suite/risklab/factors.py index 272692e..6b2084f 100644 --- a/risk_analytics_suite/risklab/factors.py +++ b/risk_analytics_suite/risklab/factors.py @@ -4,24 +4,29 @@ import logging from dataclasses import dataclass from functools import lru_cache +from importlib import import_module +from importlib.util import find_spec from typing import Iterable, Literal import numpy as np import pandas as pd import statsmodels.api as sm -try: - import yfinance as yf -except ImportError as exc: # pragma: no cover - raise ImportError("yfinance is required for factor construction") from exc -try: - from fredapi import Fred -except ImportError: # pragma: no cover - optional dependency - Fred = None +LOGGER = logging.getLogger(__name__) -LOGGER = logging.getLogger(__name__) +def _load_yfinance() -> "yfinance": + if find_spec("yfinance") is None: # pragma: no cover - import-time guard + raise ImportError("yfinance is required for factor construction") + return import_module("yfinance") + + +def _load_fred() -> "fredapi.Fred" | None: + if find_spec("fredapi") is None: # pragma: no cover - optional dependency + return None + module = import_module("fredapi") + return getattr(module, "Fred") @dataclass @@ -33,6 +38,7 @@ class FactorSpec: @lru_cache(None) def _download_series(ticker: str, start: str = "2010-01-01", end: str | None = None) -> pd.Series: + yf = _load_yfinance() data = yf.download(ticker, start=start, end=end, progress=False)["Adj Close"].rename(ticker) return data.dropna() @@ -40,8 +46,11 @@ def _download_series(ticker: str, start: str = "2010-01-01", end: str | None = N def _macro_series(params: dict) -> pd.Series: if "symbol" in params: return _download_series(params["symbol"]) - if "fred_series" in params and Fred is not None: - fred = Fred() + if "fred_series" in params: + fred_cls = _load_fred() + if fred_cls is None: + raise ImportError("fredapi is required for macro FRED factors") + fred = fred_cls() series = fred.get_series(params["fred_series"]) return series.rename(params["fred_series"]) raise ValueError("Macro factor requires `symbol` or `fred_series` parameter") diff --git a/risk_analytics_suite/risklab/io.py b/risk_analytics_suite/risklab/io.py index 169cc29..54a97b1 100644 --- a/risk_analytics_suite/risklab/io.py +++ b/risk_analytics_suite/risklab/io.py @@ -2,16 +2,21 @@ from __future__ import annotations from dataclasses import dataclass +from importlib import import_module +from importlib.util import find_spec from pathlib import Path from typing import Iterable import pandas as pd from omegaconf import DictConfig, OmegaConf -try: - import yfinance as yf -except ImportError as exc: # pragma: no cover - optional dependency - raise ImportError("yfinance is required for data fetching") from exc + +def _load_yfinance() -> "yfinance": + """Import and return the yfinance module.""" + + if find_spec("yfinance") is None: # pragma: no cover - import-time guard + raise ImportError("yfinance is required for data fetching") + return import_module("yfinance") DATA_ROOT = Path(__file__).resolve().parents[1] / "data" @@ -41,6 +46,8 @@ def fetch_prices( if source != "yfinance": # pragma: no cover raise NotImplementedError(f"Data source {source} is not supported") + yf = _load_yfinance() + data = ( yf.download( tickers=list(tickers), From 30b3cf2520b49f65a14cee5c056c12448fc765cd Mon Sep 17 00:00:00 2001 From: Jiaji_Qin Date: Thu, 9 Oct 2025 19:52:49 -0400 Subject: [PATCH 3/3] Document local execution workflow --- risk_analytics_suite/README.md | 40 +++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/risk_analytics_suite/README.md b/risk_analytics_suite/README.md index be72bca..d59010a 100644 --- a/risk_analytics_suite/README.md +++ b/risk_analytics_suite/README.md @@ -43,24 +43,54 @@ risk_analytics_suite/ ``` ## Quickstart -1. Install dependencies +1. **Clone & create an isolated environment** ```bash + git clone https://github.com//quant-risk-analytics-suite.git + cd quant-risk-analytics-suite/risk_analytics_suite python -m venv .venv - source .venv/bin/activate + source .venv/bin/activate # Windows: .venv\Scripts\activate + pip install --upgrade pip pip install -r requirements.txt ``` -2. Fetch data & run experiments + +2. **Fetch a sample dataset** ```bash python -m risk_analytics_suite.risklab.io --config base + ``` + The command reads `configs/base.yaml`, downloads the configured tickers from Yahoo Finance, and caches the CSV in `data/raw/`. + Adjust tickers or the time range by editing `configs/base.yaml` (or by creating a new config file and passing `--config my_config`). + +3. **Run exploratory notebooks (optional)** + ```bash jupyter lab ``` -3. Launch the dashboard + Open the notebooks in `experiments/` to reproduce the factor exposure, VaR comparison, or stress scenario studies. Each notebook assumes that the cached prices from step 2 are available. + +4. **Execute the automated checks** + ```bash + pytest + ``` + This repository currently contains placeholder tests; add your own regression or integration tests as you expand the toolkit. + +5. **Launch the Streamlit dashboard** ```bash streamlit run app/dashboard.py ``` + The app loads prices/factors based on the active configuration and surfaces VaR, CVaR, factor exposures, and stress-test views. Use `--server.port ` when running on shared infrastructure (e.g., cloud notebooks). + +6. **(Optional) Use the modules programmatically** + ```python + from risk_analytics_suite.risklab import io, preprocess, risk + + prices = io.fetch_prices(["SPY", "QQQ"], start="2020-01-01", end="2024-01-01") + returns = preprocess.to_log_returns(prices) + portfolio_var = risk.var_historical(returns["SPY"], alpha=0.95) + print(portfolio_var) + ``` + Each submodule exposes well-documented functions so you can compose your own research scripts or notebooks. ## Reproducibility -Configuration is managed via [Hydra](https://hydra.cc) YAML files. Adjust tickers, windows, and confidence levels without modifying code. A ``make reproduce`` workflow can be added to fetch fresh data and execute notebooks end-to-end. +Configuration is managed via [Hydra](https://hydra.cc) YAML files. Adjust tickers, windows, and confidence levels without modifying code. Compose alternative configurations under `configs/` and call the relevant module with `--config `. A ``make reproduce`` workflow can be added to fetch fresh data and execute notebooks end-to-end. ## License MIT