From 4b525e7867d4fc584f027564fc13cff2a6b287b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:20:42 +0000 Subject: [PATCH 1/2] Organise the README as install, example, API sketch and how it works At 434 lines the README carried a "Why" section, a feature list, every CLI transcript, five figure write-ups, a component table, dense limitation paragraphs and 26 references. The first screen opened with a disclaimer box. README (434 to 195 lines): - First screen: what the library does, how to install it, and the first doctest block. - An API sketch table of the five modules and the CLI. The other two doctest blocks stay, with shorter notes after them. - A short "How it works" with the backtester's timing contract. - Five one-sentence limits, the AI note, and links to the new docs. Relocated, not removed: - docs/CLI.md: the command-line transcripts, unchanged. - docs/EXAMPLES.md: the figure and study write-ups, unchanged numbers, with bold lead-ins turned into headings. - docs/DESIGN.md: the per-component checks table, the full limitations and roadmap, and the references. "Questions worth being able to answer" is now "Common questions". Verification: - uv run pytest --doctest-glob=README.md --cov=quantkit: 435 passed, coverage 99.62%; ruff check, ruff format --check and mypy pass. - Built the wheel, installed it in a fresh venv and ran python -m doctest README.md from outside the repo: 34 examples passed, the same count as before; quantkit var-backtest --help works. - Re-ran every CLI command now in docs/CLI.md and the five example scripts (in an exported copy): all output matches the moved text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016h2RvhZFj77hPxFn8TQUqq --- CHANGELOG.md | 4 + README.md | 401 ++++++++++------------------------------------- docs/CLI.md | 123 +++++++++++++++ docs/DESIGN.md | 85 +++++++++- docs/EXAMPLES.md | 98 ++++++++++++ 5 files changed, 386 insertions(+), 325 deletions(-) create mode 100644 docs/CLI.md create mode 100644 docs/EXAMPLES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e616c..1fd793f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ All notable changes to this project are documented here. The format follows - The tail size `ceil(n * (1 - level))` of historical VaR moved to a shared helper, so single-sample and rolling historical VaR always use the same rule. Results are unchanged. +- The README is organised as install, one example, an API sketch and how it + works, and its doctests are unchanged. The command-line examples moved to + `docs/CLI.md`, the figures and studies to `docs/EXAMPLES.md`, and the + per-component checks, full limitations and references to `docs/DESIGN.md`. ### Fixed diff --git a/README.md b/README.md index a49a2ed..3839013 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,33 @@ # quantkit -Options pricing, portfolio optimization, backtesting and VaR model validation in Python. Every model is checked against closed-form results, textbook values or an independent method. +A Python library for option pricing, portfolio construction, backtesting and VaR model +validation. Each model is tested against something it did not produce itself: a +textbook value, a closed form, an independent implementation or an identity that must +hold. [![CI](https://github.com/ult666666/quantkit/actions/workflows/ci.yml/badge.svg)](https://github.com/ult666666/quantkit/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Python 3.11 | 3.12 | 3.13](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue.svg)](pyproject.toml) -> **Note:** quantkit is educational and research software. It is not investment advice, and nothing in this repository is a recommendation to buy or sell anything. - -## Why - -Quantitative finance code fails quietly: a sign slip in a Greek, an off-by-one day in a backtest or an optimizer that stops early all still return plausible-looking numbers. quantkit is a small, readable library built around verification. Each model is tested against something it did not produce itself: a textbook value, a closed-form solution, an independent implementation, or an identity that must hold. The backtester has an explicit, tested no-look-ahead contract. - -## Features - -- **Option pricing** (`quantkit.pricing`) - - Black–Scholes–Merton prices and analytic Greeks (delta, gamma, vega, theta, rho) with a continuous dividend yield, on a validated, frozen `OptionSpec` dataclass. - - Implied volatility with Brent's method, raising a typed `ArbitrageBoundError` when a price violates the no-arbitrage bounds. - - Cox–Ross–Rubinstein binomial tree for European and American (early-exercise) options. - - Monte Carlo under geometric Brownian motion with antithetic variates, standard errors and 95% confidence intervals, reproducible from a seed. - - Arithmetic-average Asian options by Monte Carlo, with the geometric-average closed form as a control variate. -- **Portfolio construction** (`quantkit.portfolio`) - - Simple and log returns; annualized sample covariance and Ledoit–Wolf shrinkage. - - Minimum-variance, maximum-Sharpe and target-return portfolios with weight bounds (SciPy SLSQP), closed-form solutions for the unbounded case, and efficient frontiers. - - Equal-risk-contribution (risk parity) and custom risk-budget portfolios. - - Volatility, historical and Gaussian VaR and CVaR (expected shortfall). -- **Backtesting** (`quantkit.backtest`) - - Daily vectorized engine: weights decided at the close of day *t* are held from day *t*+1, with drift-aware turnover and proportional costs in basis points. - - Strategies: buy-and-hold, periodically rebalanced equal weight, moving-average crossover, time-series momentum and inverse volatility. - - Metrics: CAGR, volatility, Sharpe, Sortino, maximum drawdown and its duration, Calmar, hit rate and turnover, plus a look-ahead checker for custom strategies. -- **VaR model validation** (`quantkit.var`) - - Rolling one-day Value at Risk (VaR) and expected shortfall (ES, the mean loss beyond VaR) forecasts by historical simulation, a Gaussian model, EWMA (RiskMetrics, decay 0.94) and filtered historical simulation, each from a trailing window that never contains the return it is scored against. - - Exception backtests: Kupiec's proportion-of-failures test and Christoffersen's independence and conditional coverage tests, with p-values, plus the exact probability that each test rejects a correct model at a given sample length. - - The Basel traffic light: green, yellow and red zones and the capital plus factors for 250 days of 99% VaR. - - Acerbi and Székely's Z2 test of expected shortfall, with p-values from a null distribution simulated from the forecasts themselves. -- **Data** (`quantkit.data`): a seeded correlated-GBM price simulator and a validating CSV loader (long or wide format). -- **CLI**: `quantkit price | iv | simulate | backtest | frontier | var-backtest`. -- Fully typed (`mypy --strict`, ships `py.typed`) and linted with ruff; the CI workflow targets Python 3.11–3.13. - -## Quick start - -quantkit is not on PyPI. Install the library and the `quantkit` CLI from GitHub (Python 3.11+): +## Install + +quantkit is not on PyPI. Install the library and the `quantkit` command from GitHub +(Python 3.11 or newer): ```bash pip install "git+https://github.com/ult666666/quantkit.git" ``` -The [v0.1.0 release](https://github.com/ult666666/quantkit/releases/tag/v0.1.0) attaches a wheel, the source distribution and SHA-256 checksums, but it predates VaR model validation: it has no `quantkit.var` and no `var-backtest` command. To work on the code, install it from source with [uv](https://docs.astral.sh/uv/): - -```bash -git clone https://github.com/ult666666/quantkit.git -cd quantkit -uv sync # creates .venv with quantkit and the development tools -uv run quantkit --help -``` +The wheel attached to the [v0.1.0 release](https://github.com/ult666666/quantkit/releases/tag/v0.1.0) +predates VaR model validation, so it has no `quantkit.var` and no `var-backtest`. To +work on the code, clone the repository and run `uv sync` with [uv](https://docs.astral.sh/uv/), +or `pip install ".[plot]"` in a virtual environment for the charts. -Or, with pip inside a virtual environment: `pip install .` (or `pip install ".[plot]"` for charts). +## Example -Price Hull's textbook option four ways. The outputs shown here are checked as doctests by the test suite: +Hull's textbook call: its price, Greeks and implied volatility, a Monte Carlo check, +then an American put on a binomial tree. CI runs this block, and the two below, as +doctests: ```python >>> from quantkit.pricing import ( @@ -80,7 +50,21 @@ delta=0.7791 gamma=0.0500 vega=8.8134 theta=-4.5591 ``` -Build portfolios from (here, synthetic) prices and backtest a strategy: +## API sketch + +| Module | Main entry points | +| --- | --- | +| `quantkit.pricing` | `OptionSpec`; `bsm_price`, `bsm_greeks`, `implied_vol`, `binomial_price` (European or American), `monte_carlo_price`, `asian_monte_carlo_price` | +| `quantkit.portfolio` | `compute_returns`, `ledoit_wolf_covariance`; `min_variance`, `max_sharpe`, `efficient_return`, `efficient_frontier`, `risk_parity`; historical and parametric VaR and CVaR | +| `quantkit.backtest` | `run_backtest` with `BuyAndHold`, `EqualWeight`, `MovingAverageCrossover`, `TimeSeriesMomentum` or `InverseVolatility`; performance metrics; `check_no_lookahead` for your own strategies | +| `quantkit.var` | `rolling_var_es` (historical, Gaussian, EWMA, filtered historical simulation); `backtest_var` (Kupiec and Christoffersen tests); `basel_traffic_light`; `es_backtest` (Acerbi and Székely's Z2) | +| `quantkit.data` | `simulate_gbm_prices` (seeded, correlated) and `load_prices_csv` (long or wide format) | +| `quantkit` command | `price`, `iv`, `simulate`, `backtest`, `frontier` and `var-backtest`; see [docs/CLI.md](docs/CLI.md) | + +The package is typed (`mypy --strict`, with `py.typed` shipped). Public functions +validate their inputs and raise typed errors whose messages name the problem. + +Portfolios and a backtest, on five years of synthetic prices: ```python >>> from quantkit.data import simulate_gbm_prices @@ -109,9 +93,13 @@ CAGR 0.36%, Sharpe 0.08, max drawdown 14.9% ``` -The maximum-Sharpe portfolio leaves out `CCC` because its *estimated* mean return over this sample is negative (−10.9% a year), even though it was simulated with a +8% drift: five years of data pin down expected returns very poorly. That estimation error, not the optimizer, is the main weakness of mean–variance optimization in practice. +The maximum-Sharpe portfolio leaves out `CCC`. It was simulated with a +8% drift, yet +its estimated mean return over these five years is −10.9% a year. Five years of data +pin down expected returns poorly, and that estimation error, more than the optimizer, +is the weak point of mean–variance optimization. -Forecast the equal-weight portfolio's one-day 99% VaR and ES each day from the previous 250 days, then backtest the forecasts: +Rolling VaR and expected shortfall (ES, the mean loss beyond VaR) forecasts for the +equal-weight portfolio, each made from a trailing window only, then backtested: ```python >>> from quantkit.var import ( @@ -137,245 +125,38 @@ Z2 -0.13, simulated p-value 0.164 ``` -FHS (filtered historical simulation) needs two 250-day windows of history, so the 1,259 returns give 759 forecasts. The last line is a warning about the tests themselves: with 2.5 exceptions expected in 250 days, the nominal 5% Kupiec test rejects a correct model 9.5% of the time, and the two Christoffersen tests about 1%. - -## Usage - -### Command line - -Price an option with all three methods and print the Greeks: - -```console -$ uv run quantkit price --spot 42 --strike 40 --rate 0.10 --vol 0.20 --maturity 0.5 -European call: S=42 K=40 T=0.5 r=0.1 q=0 sigma=0.2 - -method price std. error -Black-Scholes-Merton 4.7594 -CRR binomial (500 steps) 4.7593 -Monte Carlo (100,000 paths) 4.7659 0.0078 - -Greeks (Black-Scholes-Merton) -delta 0.7791 -gamma 0.0500 -vega 8.8134 per 1.00 change in volatility -theta -4.5591 per year -rho 13.9820 per 1.00 change in the rate -``` - -American options use the binomial tree: - -```console -$ uv run quantkit price --spot 50 --strike 50 --rate 0.10 --vol 0.40 --maturity 0.4167 --kind put --american -American put: S=50 K=50 T=0.4167 r=0.1 q=0 sigma=0.4 - -method price -CRR binomial, American (500 steps) 4.2832 -CRR binomial, European (500 steps) 4.0736 -Black-Scholes-Merton, European 4.0761 -early-exercise premium 0.2096 -``` - -The premium compares two trees with the same number of steps, so the tree's own discretization error cancels out. - -Implied volatility, and the error for a price that no volatility can produce: - -```console -$ uv run quantkit iv --price 4.76 --spot 42 --strike 40 --rate 0.10 --maturity 0.5 -implied volatility: 0.200066 (20.01%) -$ uv run quantkit iv --price 1.50 --spot 42 --strike 40 --rate 0.10 --maturity 0.5 -quantkit: error: price 1.5 is outside the no-arbitrage bounds (3.95082, 42); no volatility reproduces it -``` - -Backtests and frontiers read a CSV of daily closes, either long (`date,ticker,close`) or wide (`date` plus one column per ticker). To try them without real data, generate a **synthetic** file (five correlated GBM assets `AAA`–`EEE`, seed 42): - -```console -$ uv run quantkit simulate --output prices.csv -wrote 1260 days x 5 tickers of synthetic GBM prices to prices.csv -$ uv run quantkit backtest prices.csv --strategy ma-crossover --cost-bps 10 -5 assets, 1260 days (2020-01-01 to 2024-10-29), costs 10 bps of traded notional - - ma-crossover (50/200) buy-and-hold (equal weight) -total return 16.7% 6.1% -CAGR 3.14% 1.18% -volatility 8.22% 11.92% -Sharpe 0.42 0.16 -Sortino 0.61 0.22 -max drawdown 14.2% 27.3% -max drawdown length (days) 446 1101 -Calmar 0.22 0.04 -hit rate 50.9% 51.8% -annual turnover 1.31x 0.20x -``` - -The crossover "wins" on this one synthetic history, but GBM has no trends to follow; the backtest example below shows why a single backtest proves little. Other strategies: `--strategy buy-and-hold | equal-weight | momentum | inverse-vol` (see `quantkit backtest --help`). - -```console -$ uv run quantkit frontier prices.csv --shrinkage --risk-free 0.02 -1259 daily returns of 5 assets; expected returns = annualized historical means, Ledoit-Wolf covariance; weights in [0, 1]; risk-free rate 2.00% - - return volatility Sharpe AAA BBB CCC DDD EEE -1 0.5% 7.6% -0.20 75.7% 19.1% 5.2% 0.0% 0.0% -2 1.5% 7.9% -0.06 60.7% 24.1% 14.5% 0.6% 0.0% -3 2.5% 8.6% 0.06 45.9% 28.0% 22.6% 3.4% 0.0% -4 3.5% 9.6% 0.15 31.1% 32.0% 30.7% 6.2% 0.0% -5 4.5% 10.9% 0.23 16.3% 35.9% 38.8% 9.0% 0.0% -6 5.5% 12.3% 0.28 1.5% 39.8% 46.8% 11.8% 0.0% -7 6.4% 14.2% 0.31 0.0% 18.3% 65.8% 16.0% 0.0% -8 7.4% 17.6% 0.31 0.0% 0.0% 100.0% 0.0% 0.0% -min variance 0.5% 7.6% -0.20 75.7% 19.1% 5.2% 0.0% 0.0% -max Sharpe 7.2% 16.2% 0.32 0.0% 0.0% 81.7% 18.3% 0.0% -``` - -Add `--plot frontier.png` to also save a chart (needs the `plot` extra), and, for example, `--min-weight -0.2 --max-weight 0.6` to change the weight bounds. - -Backtest rolling VaR and ES forecasts of the equal-weight portfolio (or one column, with `--ticker`): - -```console -$ uv run quantkit var-backtest prices.csv --method ewma --level 0.99 -equal-weight portfolio of 5 assets, rebalanced daily: 1259 daily returns, 2020-01-02 to 2024-10-29 -99% one-day VaR and ES, 250-day window, EWMA decay 0.94 -backtest: 1009 days, 2020-12-17 to 2024-10-29 - - EWMA -exceptions 14 -expected exceptions 10.1 -exception rate 1.39% -Kupiec LR_uc p-value 0.243 -Christoffersen LR_ind p-value 0.185 -conditional coverage LR_cc p-value 0.209 -Basel zone, last 250 days green (3) -Acerbi-Szekely Z2 -0.42 -Z2 simulated p-value 0.102 - -LR p-values use chi-squared limits. Over 1009 days at 99%, a correct model fails the 5% tests -with probability 5.5% (LR_uc), 1.7% (LR_ind) and 2.7% (LR_cc). -Z2 p-values compare with 9,999 paths simulated from each method's own forecasts (seed 0). -``` - -Without `--method` it compares all four methods on the days they all cover. An exception is a day whose loss exceeded that day's VaR forecast; at 99% a correct model expects one in a hundred days, independent of each other. The Basel zone counts the exceptions of the last 250 days, as bank supervisors do. Z2 is negative when losses beyond VaR are larger than the ES forecasts promised. - -### Examples and figures - -The scripts in [`examples/`](examples) regenerate the figures below (`uv run python examples/.py`). The walk-forward example writes CSVs instead of a figure. - -**Binomial convergence** ([`binomial_convergence.py`](examples/binomial_convergence.py)). CRR tree prices for Hull's call approach the Black–Scholes–Merton value with an error that shrinks like 1/steps, oscillating as tree nodes move relative to the strike. - -![CRR binomial price converging to Black-Scholes-Merton](docs/images/binomial_convergence.png) - -**Efficient frontier** ([`efficient_frontier.py`](examples/efficient_frontier.py)), on **synthetic** data: the long-only frontier from SLSQP, the unconstrained frontier from its closed form (always to the left, since removing constraints can only lower risk), the minimum-variance and maximum-Sharpe portfolios and the capital market line. The means are estimated from five years of simulated prices, so `EEE`, simulated with an 11% drift, shows a negative estimated return. - -![Efficient frontier on synthetic data](docs/images/efficient_frontier.png) - -**Backtest** ([`backtest_trend.py`](examples/backtest_trend.py)), on **synthetic** data: a 50/200-day moving-average crossover against equal-weight buy-and-hold over ten years, with 10 bps costs. On this history (seed 42) the crossover ends ahead. The script then repeats the comparison on 100 independent synthetic histories: - -```text -over 100 independent synthetic histories, CAGR(MA) - CAGR(buy-and-hold): mean -3.41%, standard error 0.29%; MA crossover came out ahead in 14 of 100 -``` - -Seed 42 happens to be one of the 14. With no trends in the data, the crossover mostly sits in cash while the assets drift upward. - -![Equity curves and drawdowns on synthetic data](docs/images/backtest_equity.png) - -**VaR backtest** ([`var_backtest.py`](examples/var_backtest.py)), on **synthetic** data: GARCH(1,1) returns, whose volatility jumps after large moves, with fat-tailed Student-t shocks. Historical simulation weights a year of history equally, so its VaR (red) reacts late to the volatility burst and its exceptions cluster; filtered historical simulation (blue) rescales history by the current EWMA volatility. Over 1,250 days at 99%: - -```text - historical Gaussian EWMA FHS -exceptions 20 24 24 16 -expected 12.5 12.5 12.5 12.5 -Kupiec p 0.0499 0.0037 0.0037 0.3403 -independence p 0.0389 0.0833 0.4815 0.5193 -cond. coverage p 0.0172 0.0033 0.0115 0.5142 -Basel zone (last 250 days) yellow (5) yellow (7) green (2) green (2) -Z2 -0.79 -1.25 -1.14 -0.33 -Z2 p 0.0014 0.0002 0.0005 0.0484 -``` - -All three exception tests reject historical simulation, and the independence test shows why: its exceptions bunch up in the volatile period. EWMA follows the volatility (independence p = 0.48) but keeps normal tails, so it has as many exceptions as the Gaussian model, 24 against 12.5 expected. FHS passes the three exception tests; Z2 still rejects it, narrowly, at 5%. - -![VaR forecasts, exceptions and cumulative exception counts on synthetic data](docs/images/var_backtest.png) - -**Size and power of the backtests** ([`var_size_power.py`](examples/var_size_power.py)), on **synthetic** data. Each row simulates 1,000 independent paths, forecasts with a 250-day window and runs the 5% tests on the last 250 or 1,000 days (`uv run python examples/var_size_power.py`, about 40 seconds): - -```text -Share of 1,000 SYNTHETIC paths rejected by each 5% test (Monte Carlo standard error at most 1.6%) - - LR_uc LR_ind LR_cc Z2 exceptions -size: EWMA forecasts on returns from the EWMA model, 99% - 250 days, measured 10.5% 1.8% 1.0% 5.1% 1.00% - 250 days, exact 9.5% 1.4% 1.1% 5.0% - 1,000 days, measured 4.2% 2.2% 2.2% 4.4% 1.00% - 1,000 days, exact 5.5% 1.8% 2.7% 5.0% -power: Gaussian forecasts, 99% - normal returns, 250 days 8.1% 1.3% 0.8% 5.3% 1.02% - Student-t(4) returns, 250 days 13.0% 1.4% 6.8% 42.2% 1.65% - normal returns, 1,000 days 2.2% 1.2% 0.9% 3.9% 1.05% - Student-t(4) returns, 1,000 days 46.9% 1.9% 31.3% 87.3% 1.63% -power: Gaussian forecasts, 97.5% - normal returns, 1,000 days 1.2% 2.3% 2.1% 4.0% 2.57% - Student-t(4) returns, 1,000 days 3.4% 2.0% 3.2% 43.3% 2.70% -``` - -- **Size.** The paths are drawn from the EWMA model itself, so the forecasts are exactly right and every rejection is a false alarm. The measured rates agree with the exact ones from `null_rejection_rates` within the Monte Carlo error, and they are not 5%: with 2.5 expected exceptions in 250 days, zero exceptions (8.1% likely) already fails Kupiec's test, and the independence test rarely has a pair of exceptions to work with. -- **Power.** Student-t returns with 4 degrees of freedom have the same variance as the normal ones but fatter tails, so Gaussian VaR is too low. Exception counts catch this in only 13% of 250-day samples. Z2, which also weighs how large the tail losses are, catches it in 42%, and in 87% of 1,000-day samples. At 97.5% (the level of the Basel FRTB expected shortfall) the Gaussian VaR happens to have about the right number of exceptions, so the count-based tests see nothing (3.4%) while Z2 rejects 43% of samples. - -**Walk-forward portfolio construction** ([`walk_forward_risk_parity.py`](examples/walk_forward_risk_parity.py)), on **synthetic** data. This example connects trailing-return estimation, risk-parity allocation and backtesting. Each monthly allocation uses only closes before the rebalance date. A monthly equal-weight benchmark shares the same cash warmup, dates and costs. - -```bash -uv run python examples/walk_forward_risk_parity.py -``` - -The command exports prices, both target tables, estimation windows, net wealth and a common-period summary to `walk-forward-output/`. The [walkthrough](docs/WALK_FORWARD.md) explains the timing, CSV fields, reproducible inputs and limitations. +FHS (filtered historical simulation) needs two 250-day windows of history before its +first forecast. That is why the 1,259 returns give 759 forecasts. The last line is +about the tests themselves: with so few exceptions expected, a correct model fails the +nominal 5% Kupiec test 9.5% of the time. ## How it works -```mermaid -flowchart LR - CSV["CSV file or synthetic GBM
quantkit.data"] --> PX["wide price table
(dates x tickers)"] - PX --> PF["quantkit.portfolio
returns, covariance, optimizers, VaR"] - PF --> VR["quantkit.var
rolling VaR/ES forecasts, exception tests,
Basel traffic light, ES backtest"] - PX --> ST["strategy
target weights per close"] - ST --> EN["backtest engine
weights held from t+1, costs"] - EN --> MT["metrics
CAGR, Sharpe, drawdown, ..."] - OS["OptionSpec"] --> PR["quantkit.pricing
BSM, IV, CRR tree, Monte Carlo, Asian"] -``` - -- **Pricing.** Every pricer takes the same frozen, validated `OptionSpec`, so one contract can be priced by independent methods and the results compared. The normal CDF is computed with `erfc`, which stays accurate deep in the tails. Implied volatility checks the model-free bounds first and then runs Brent's method, which cannot diverge the way Newton's method can when vega is tiny. The CRR tree rolls values back one vectorized level at a time; Monte Carlo samples the terminal price exactly, so its only error is sampling error. -- **Portfolio.** Optimizers use SLSQP so that weight bounds are easy to add, and the unbounded problems also have closed forms that serve as a reference in the tests. The maximum-Sharpe problem is solved through a change of variables that turns it into a convex quadratic program. Risk parity minimizes a convex log-barrier objective (Maillard, Roncalli & Teïletche 2010) by cyclical coordinate descent. -- **Backtest.** The timing contract is explicit: +Every pricer takes the same frozen, validated `OptionSpec`, so one contract can be +priced by independent methods and the results compared. Implied volatility checks the +no-arbitrage bounds first, then runs Brent's method, which cannot diverge the way +Newton's method can when vega is tiny. The optimizers use SciPy's SLSQP so that weight +bounds are easy to add, and the unbounded problems also have closed forms that the +tests use as a reference. - | At the close of day *t* | During day *t*+1 | - | --- | --- | - | The strategy sees prices for days 0..*t* and returns target weights (or NaN for "no trade"). The portfolio trades to those weights at that close and pays `cost_bps` on the traded notional. | The new weights earn day *t*+1's return. Between trades, weights drift with prices. | +The backtester's timing contract is explicit: - The engine is vectorized: between trades the share counts are constant, so each day's weights follow from the weights at the last trade and the price growth since then. - -- **VaR model validation.** Every forecaster is a location-scale model `r[t] = mu[t] + sigma[t] e[t]` fitted to the window before day `t`, with normal or resampled innovations `e`; VaR, ES and the simulated null of the ES backtest all come from that one description. The likelihood-ratio tests use `0 log 0 = 0`, so they stay finite with no exceptions or no consecutive pairs, and a dynamic program over the exception sequence gives their exact size. +| At the close of day *t* | During day *t*+1 | +| --- | --- | +| The strategy sees prices for days 0..*t* and returns target weights (or NaN for "no trade"). The portfolio trades to those weights at that close and pays `cost_bps` on the traded notional. | The new weights earn day *t*+1's return. Between trades, weights drift with prices. | -[docs/DESIGN.md](docs/DESIGN.md) explains the architecture, the main design decisions and their trade-offs in more detail. +A perturbation test changes every price after day *t* and requires the weights in +effect through day *t*+1 to stay the same; a strategy that peeks fails it. The VaR +forecasts pass the same kind of test. [docs/DESIGN.md](docs/DESIGN.md) covers the +architecture, the trade-offs and the full list of checks per component. -## Correctness +More: -| Component | Checked against | -| --- | --- | -| BSM prices | Hull's textbook values (call ≈ 4.76, put ≈ 0.81); put–call parity and no-arbitrage bounds over random inputs (Hypothesis) | -| BSM Greeks | Hull's Greek-letters example; central finite differences; call–put Greek identities | -| Implied volatility | Round trips through BSM over random inputs; typed errors for prices outside the bounds | -| Binomial tree | Convergence to BSM at rate 1/steps; Hull's American put values for 5, 30, 50, 100 and 500 steps; a one-step tree by hand; American put ≥ European put; American call = European call when q = 0 | -| Monte Carlo | Within 3 standard errors of BSM; 95% intervals contain the BSM price in 92–98% of 400 seeded runs; antithetic variates lower the standard error | -| Asian options | Geometric formula equals BSM with one fixing and matches an independent simulation; the control-variate estimate agrees with plain Monte Carlo at over 10x lower standard error | -| Ledoit–Wolf | scikit-learn's `ledoit_wolf`, including more assets than observations | -| Mean–variance | Closed-form minimum-variance, tangency and target-return portfolios (unbounded case); first-order conditions; no random feasible portfolio beats the optimizer; budget and bounds respected | -| Risk parity | Equal risk contributions; inverse-volatility closed forms (uncorrelated or two assets); volatility between minimum-variance and 1/N | -| VaR / CVaR | Hand-computed tails; Gaussian closed forms; historical converges to parametric on normal data | -| Rolling VaR / ES forecasts | Every window agrees with the single-sample `historical_var` and `parametric_var`; the RiskMetrics recursion; a Hull–White volatility-rescaling loop for FHS; **no look-ahead**: changing the return a forecast is scored against (and all later ones) leaves the forecast unchanged, and a peeking forecaster fails the same check; exception probabilities on i.i.d. data match closed forms (`m / (window + 1)` for historical simulation, a Student-t prediction interval for the Gaussian model) | -| Exception tests | Hand-computed Kupiec and Christoffersen statistics; SciPy's binomial log-likelihoods and `chi2` p-values; `LR_cc = LR_uc + LR_ind`; exact sizes against brute-force enumeration of all 2^T exception sequences; simulated sizes on returns drawn from the forecast model itself | -| Basel traffic light | The zones, plus factors and cumulative probabilities of the 1996 Basel table, and the 95% / 99.99% binomial rule behind them | -| ES backtest (Z2) | A hand calculation; mean zero when returns follow the forecasts; a null distribution that does not depend on the volatility path; power against fat tails | -| Backtester | An independent day-by-day reference loop; buy-and-hold equals the asset return; costs reduce wealth by exactly the computed amount; a hand-computed two-day example; **no look-ahead**: perturbing prices after day *t* leaves every weight in effect through day *t*+1 unchanged, and a deliberately cheating strategy fails the same check | -| Metrics | Hand-computed series (drawdown and its duration, CAGR, Sharpe, Sortino, Calmar, hit rate) | -| Data | Simulated means, volatilities and correlations match their targets; CSV round trips and specific error messages | - -Monte Carlo and other statistical tests use fixed seeds, so they cannot fail by chance; the Hypothesis property tests draw fresh random inputs on every run (200 per test, or 5,000 with `pytest --hypothesis-profile=thorough`). The CI workflow runs ruff, `mypy --strict`, the tests with a 90% coverage floor, the README doctests (against the source tree and against an installed wheel), CLI smoke tests and the example scripts on Python 3.11, 3.12 and 3.13. +- [docs/CLI.md](docs/CLI.md): the command line, with sample output. +- [docs/EXAMPLES.md](docs/EXAMPLES.md): figures and studies, including the size and power + of the VaR backtests on 1,000 simulated paths. +- [docs/WALK_FORWARD.md](docs/WALK_FORWARD.md): a walk-forward risk-parity example that + exports its inputs and results as CSV files. ## Development @@ -388,46 +169,26 @@ uv run pytest --cov=quantkit # unit tests, Hypothesis properties an uv run python examples/backtest_trend.py # regenerate a figure in docs/images ``` -## Limitations and roadmap - -**Models.** Black–Scholes–Merton and GBM assume constant volatility and rates, lognormal prices and continuous dividends: no volatility smile, jumps or discrete dividends. Monte Carlo covers European and Asian payoffs only (no Longstaff–Schwartz for American options). The CRR tree converges slowly and with oscillation; smoother trees such as Leisen–Reimer are not implemented. Implied volatilities outside 0.0001%–1000% are reported as errors. The pricing API is scalar; large option chains would call for a vectorized interface. - -**Portfolio.** Expected returns are plain historical means, which are very noisy. The same weight bounds apply to every asset, with no per-asset, sector or turnover constraints. SLSQP is a general-purpose solver that works well for tens of assets; a dedicated QP solver would scale better. Risk parity is long-only. VaR is one-period and in return units. - -**VaR models and backtests.** Forecasts cover one day and one return series, in return units: there is no multi-day scaling, no P&L from positions and no revaluation of options. The EWMA decay is fixed rather than estimated (no GARCH fit), and FHS filters with that EWMA volatility. The likelihood-ratio p-values are asymptotic; `null_rejection_rates` reports how far the true size is from the nominal one, but the tests do not yet offer exact or Monte Carlo p-values. The independence test only looks at consecutive days. The traffic light implements the 1996 table for 250 days of 99% VaR, not the 97.5% zones or the P&L attribution test of the Basel FRTB standards. Z2's null distribution is simulated from each method's own forecasts, so it tests the forecast distribution's tail as a whole and needs positive ES forecasts. - -**Backtesting.** Daily close-to-close only: trades fill at the same close that produced the signal, and the only friction is a proportional cost (no slippage, market impact, borrowing or financing costs). Idle cash earns nothing. Prices must be complete (no missing values and no assets entering mid-sample) and should already be adjusted for splits and dividends. The no-look-ahead guarantee covers the engine and the built-in strategies; custom strategies can be checked with `check_no_lookahead`. - -**Data and verification.** There is deliberately no data downloader: bring your own CSV with ISO dates. CI runs lint, strict type checks and the full suite with README doctests on Python 3.11, 3.12 and 3.13. - -**Roadmap ideas:** per-asset and linear constraints, a vectorized BSM interface, Longstaff–Schwartz for American options, Black–Litterman expected returns, an engine option to fill trades after the signal close, Monte Carlo p-values and duration-based tests (Christoffersen & Pelletier 2004) for VaR exceptions, and GARCH-filtered historical simulation. The walk-forward example lags its estimation window by one close, but its trades still fill at the signal close. - -## References - -- Hull, J. C. *Options, Futures, and Other Derivatives*. Pearson (chapters on the Black–Scholes–Merton model, the Greek letters, binomial trees and numerical procedures). -- Black, F. & Scholes, M. (1973). The pricing of options and corporate liabilities. *Journal of Political Economy*, 81(3), 637–654. -- Merton, R. C. (1973). Theory of rational option pricing. *Bell Journal of Economics and Management Science*, 4(1), 141–183. -- Cox, J. C., Ross, S. A. & Rubinstein, M. (1979). Option pricing: A simplified approach. *Journal of Financial Economics*, 7(3), 229–263. -- Glasserman, P. (2003). *Monte Carlo Methods in Financial Engineering*. Springer. -- Kemna, A. G. Z. & Vorst, A. C. F. (1990). A pricing method for options based on average asset values. *Journal of Banking & Finance*, 14(1), 113–129. -- Markowitz, H. (1952). Portfolio selection. *Journal of Finance*, 7(1), 77–91. -- Merton, R. C. (1972). An analytic derivation of the efficient portfolio frontier. *Journal of Financial and Quantitative Analysis*, 7(4), 1851–1872. -- Cornuéjols, G. & Tütüncü, R. (2007). *Optimization Methods in Finance*. Cambridge University Press. -- Ledoit, O. & Wolf, M. (2004). A well-conditioned estimator for large-dimensional covariance matrices. *Journal of Multivariate Analysis*, 88(2), 365–411. -- Maillard, S., Roncalli, T. & Teïletche, J. (2010). The properties of equally weighted risk contribution portfolios. *Journal of Portfolio Management*, 36(4), 60–70. -- Griveau-Billion, T., Richard, J.-C. & Roncalli, T. (2013). A fast algorithm for computing high-dimensional risk parity portfolios. arXiv:1311.4057. -- Moskowitz, T. J., Ooi, Y. H. & Pedersen, L. H. (2012). Time series momentum. *Journal of Financial Economics*, 104(2), 228–250. -- J.P. Morgan/Reuters (1996). *RiskMetrics Technical Document*, 4th edition. -- Hull, J. & White, A. (1998). Incorporating volatility updating into the historical simulation method for value-at-risk. *Journal of Risk*, 1(1), 5–19. -- Barone-Adesi, G., Giannopoulos, K. & Vosper, L. (1999). VaR without correlations for portfolios of derivative securities. *Journal of Futures Markets*, 19(5), 583–602. -- Kupiec, P. H. (1995). Techniques for verifying the accuracy of risk measurement models. *Journal of Derivatives*, 3(2), 73–84. -- Christoffersen, P. F. (1998). Evaluating interval forecasts. *International Economic Review*, 39(4), 841–862. -- Christoffersen, P. & Pelletier, D. (2004). Backtesting value-at-risk: A duration-based approach. *Journal of Financial Econometrics*, 2(1), 84–108. -- Basel Committee on Banking Supervision (1996). *Supervisory framework for the use of "backtesting" in conjunction with the internal models approach to market risk capital requirements*. [bis.org/publ/bcbs22.htm](https://www.bis.org/publ/bcbs22.htm) -- Acerbi, C. & Székely, B. (2014). Backtesting expected shortfall. *Risk*, December 2014. -- Gneiting, T. (2011). Making and evaluating point forecasts. *Journal of the American Statistical Association*, 106(494), 746–762. -- Jorion, P. (2007). *Value at Risk: The New Benchmark for Managing Financial Risk*, 3rd edition. McGraw-Hill (chapter on backtesting VaR). -- Davison, A. C. & Hinkley, D. V. (1997). *Bootstrap Methods and their Application*. Cambridge University Press (Monte Carlo tests). +CI runs these on Python 3.11, 3.12 and 3.13, plus the README doctests against an +installed wheel, the CLI and the example scripts. + +## Limits + +- This is educational software, not investment advice, and there is no data + downloader: bring a CSV of daily closes. +- Black–Scholes–Merton and GBM assume constant volatility and rates, so there is no + smile, no jumps and no discrete dividends. +- Expected returns are plain historical means, and one set of weight bounds applies to + every asset. +- Backtests are daily close to close, fill at the signal's close and charge only a + proportional cost. +- VaR forecasts cover one day of one return series, and the likelihood-ratio p-values + are asymptotic. + +The full list, with the roadmap, is in [docs/DESIGN.md](docs/DESIGN.md#10-limitations-and-roadmap). + +Built with AI coding assistants. The tests and CI checks described above verify its +behaviour. ## License diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..024f05c --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,123 @@ +# Command line + +The `quantkit` command prices options, writes synthetic prices, and runs backtests, +efficient frontiers and VaR backtests on a CSV of daily closes. The outputs below come +from these commands run in a source checkout, where `uv run` puts the command on the +path; after `pip install`, drop the `uv run` prefix. Every command has `--help`. + +## Options + +Price an option with all three methods and print the Greeks: + +```console +$ uv run quantkit price --spot 42 --strike 40 --rate 0.10 --vol 0.20 --maturity 0.5 +European call: S=42 K=40 T=0.5 r=0.1 q=0 sigma=0.2 + +method price std. error +Black-Scholes-Merton 4.7594 +CRR binomial (500 steps) 4.7593 +Monte Carlo (100,000 paths) 4.7659 0.0078 + +Greeks (Black-Scholes-Merton) +delta 0.7791 +gamma 0.0500 +vega 8.8134 per 1.00 change in volatility +theta -4.5591 per year +rho 13.9820 per 1.00 change in the rate +``` + +American options use the binomial tree: + +```console +$ uv run quantkit price --spot 50 --strike 50 --rate 0.10 --vol 0.40 --maturity 0.4167 --kind put --american +American put: S=50 K=50 T=0.4167 r=0.1 q=0 sigma=0.4 + +method price +CRR binomial, American (500 steps) 4.2832 +CRR binomial, European (500 steps) 4.0736 +Black-Scholes-Merton, European 4.0761 +early-exercise premium 0.2096 +``` + +The premium compares two trees with the same number of steps, so the tree's own discretization error cancels out. + +Implied volatility, and the error for a price that no volatility can produce: + +```console +$ uv run quantkit iv --price 4.76 --spot 42 --strike 40 --rate 0.10 --maturity 0.5 +implied volatility: 0.200066 (20.01%) +$ uv run quantkit iv --price 1.50 --spot 42 --strike 40 --rate 0.10 --maturity 0.5 +quantkit: error: price 1.5 is outside the no-arbitrage bounds (3.95082, 42); no volatility reproduces it +``` + +## Backtests and frontiers + +Backtests and frontiers read a CSV of daily closes, either long (`date,ticker,close`) or wide (`date` plus one column per ticker). To try them without real data, generate a synthetic file (five correlated GBM assets `AAA`–`EEE`, seed 42): + +```console +$ uv run quantkit simulate --output prices.csv +wrote 1260 days x 5 tickers of synthetic GBM prices to prices.csv +$ uv run quantkit backtest prices.csv --strategy ma-crossover --cost-bps 10 +5 assets, 1260 days (2020-01-01 to 2024-10-29), costs 10 bps of traded notional + + ma-crossover (50/200) buy-and-hold (equal weight) +total return 16.7% 6.1% +CAGR 3.14% 1.18% +volatility 8.22% 11.92% +Sharpe 0.42 0.16 +Sortino 0.61 0.22 +max drawdown 14.2% 27.3% +max drawdown length (days) 446 1101 +Calmar 0.22 0.04 +hit rate 50.9% 51.8% +annual turnover 1.31x 0.20x +``` + +The crossover "wins" on this one synthetic history, but GBM has no trends to follow; the backtest example below shows why a single backtest proves little. Other strategies: `--strategy buy-and-hold | equal-weight | momentum | inverse-vol` (see `quantkit backtest --help`). + +```console +$ uv run quantkit frontier prices.csv --shrinkage --risk-free 0.02 +1259 daily returns of 5 assets; expected returns = annualized historical means, Ledoit-Wolf covariance; weights in [0, 1]; risk-free rate 2.00% + + return volatility Sharpe AAA BBB CCC DDD EEE +1 0.5% 7.6% -0.20 75.7% 19.1% 5.2% 0.0% 0.0% +2 1.5% 7.9% -0.06 60.7% 24.1% 14.5% 0.6% 0.0% +3 2.5% 8.6% 0.06 45.9% 28.0% 22.6% 3.4% 0.0% +4 3.5% 9.6% 0.15 31.1% 32.0% 30.7% 6.2% 0.0% +5 4.5% 10.9% 0.23 16.3% 35.9% 38.8% 9.0% 0.0% +6 5.5% 12.3% 0.28 1.5% 39.8% 46.8% 11.8% 0.0% +7 6.4% 14.2% 0.31 0.0% 18.3% 65.8% 16.0% 0.0% +8 7.4% 17.6% 0.31 0.0% 0.0% 100.0% 0.0% 0.0% +min variance 0.5% 7.6% -0.20 75.7% 19.1% 5.2% 0.0% 0.0% +max Sharpe 7.2% 16.2% 0.32 0.0% 0.0% 81.7% 18.3% 0.0% +``` + +Add `--plot frontier.png` to also save a chart (needs the `plot` extra), and, for example, `--min-weight -0.2 --max-weight 0.6` to change the weight bounds. + +## VaR backtests + +Backtest rolling VaR and ES forecasts of the equal-weight portfolio (or one column, with `--ticker`): + +```console +$ uv run quantkit var-backtest prices.csv --method ewma --level 0.99 +equal-weight portfolio of 5 assets, rebalanced daily: 1259 daily returns, 2020-01-02 to 2024-10-29 +99% one-day VaR and ES, 250-day window, EWMA decay 0.94 +backtest: 1009 days, 2020-12-17 to 2024-10-29 + + EWMA +exceptions 14 +expected exceptions 10.1 +exception rate 1.39% +Kupiec LR_uc p-value 0.243 +Christoffersen LR_ind p-value 0.185 +conditional coverage LR_cc p-value 0.209 +Basel zone, last 250 days green (3) +Acerbi-Szekely Z2 -0.42 +Z2 simulated p-value 0.102 + +LR p-values use chi-squared limits. Over 1009 days at 99%, a correct model fails the 5% tests +with probability 5.5% (LR_uc), 1.7% (LR_ind) and 2.7% (LR_cc). +Z2 p-values compare with 9,999 paths simulated from each method's own forecasts (seed 0). +``` + +Without `--method` it compares all four methods on the days they all cover. An exception is a day whose loss exceeded that day's VaR forecast; at 99% a correct model expects one in a hundred days, independent of each other. The Basel zone counts the exceptions of the last 250 days, as bank supervisors do. Z2 is negative when losses beyond VaR are larger than the ES forecasts promised. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f7e53f0..af368bd 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -12,7 +12,7 @@ This document explains how quantkit is put together, why it is built that way, a **Non-goals** -- Speed at scale. The pricing API is scalar, the optimizers use a general-purpose solver, and the backtester is daily. These are deliberate trade-offs for readability. +- Speed at scale. The pricing API is scalar, the optimizers use a general-purpose solver, and the backtester is daily. These trade-offs keep the code readable. - Market data. There is no downloader; users bring a CSV, and examples use clearly labelled synthetic data. - Production trading features: order management, execution simulation, intraday data, corporate actions. @@ -224,7 +224,7 @@ Under a correct model the exception indicators are i.i.d. Bernoulli(`p = 1 - c`) ### Basel traffic light (`traffic_light.py`) -Table 2 of the Basel Committee's 1996 backtesting framework maps the exceptions of 250 daily 99% VaR forecasts to zones: green for 0 to 4, yellow for 5 to 9 with plus factors 0.40, 0.50, 0.65, 0.75 and 0.85 added to the capital multiplier of 3, and red (plus factor 1) from 10. The boundaries come from the binomial(250, 1%) distribution: green while the probability of that many exceptions or fewer is below 95%, red from 99.99%. The tests reproduce the published cumulative probabilities from `scipy.stats.binom` and re-derive the zones from that rule. The function deliberately takes only the count for 250 days at 99%, the case the table defines. +Table 2 of the Basel Committee's 1996 backtesting framework maps the exceptions of 250 daily 99% VaR forecasts to zones: green for 0 to 4, yellow for 5 to 9 with plus factors 0.40, 0.50, 0.65, 0.75 and 0.85 added to the capital multiplier of 3, and red (plus factor 1) from 10. The boundaries come from the binomial(250, 1%) distribution: green while the probability of that many exceptions or fewer is below 95%, red from 99.99%. The tests reproduce the published cumulative probabilities from `scipy.stats.binom` and re-derive the zones from that rule. The function takes only the count for 250 days at 99%, the case the table defines. ### Expected shortfall backtest (`es_backtest.py`) @@ -255,15 +255,37 @@ If each day's forecast distribution is right and continuous, `E[r I] = -α ES` e Monte Carlo and simulation tests use fixed seeds, so each either always passes or always fails; they cannot fail by chance in CI. Hypothesis property tests draw new random inputs on every run (200 per test, or 5,000 with `pytest --hypothesis-profile=thorough`). Tolerances are tied to the method's error: for example, the binomial test allows an error below `0.05 · S / steps` because CRR converges at rate 1/steps, and the gamma finite difference allows a relative error of 1e-4 because a second difference loses roughly half the digits to round-off. +### By component + +| Component | Checked against | +| --- | --- | +| BSM prices | Hull's textbook values (call ≈ 4.76, put ≈ 0.81); put–call parity and no-arbitrage bounds over random inputs (Hypothesis) | +| BSM Greeks | Hull's Greek-letters example; central finite differences; call–put Greek identities | +| Implied volatility | Round trips through BSM over random inputs; typed errors for prices outside the bounds | +| Binomial tree | Convergence to BSM at rate 1/steps; Hull's American put values for 5, 30, 50, 100 and 500 steps; a one-step tree by hand; American put ≥ European put; American call = European call when q = 0 | +| Monte Carlo | Within 3 standard errors of BSM; 95% intervals contain the BSM price in 92–98% of 400 seeded runs; antithetic variates lower the standard error | +| Asian options | Geometric formula equals BSM with one fixing and matches an independent simulation; the control-variate estimate agrees with plain Monte Carlo at over 10x lower standard error | +| Ledoit–Wolf | scikit-learn's `ledoit_wolf`, including more assets than observations | +| Mean–variance | Closed-form minimum-variance, tangency and target-return portfolios (unbounded case); first-order conditions; no random feasible portfolio beats the optimizer; budget and bounds respected | +| Risk parity | Equal risk contributions; inverse-volatility closed forms (uncorrelated or two assets); volatility between minimum-variance and 1/N | +| VaR / CVaR | Hand-computed tails; Gaussian closed forms; historical converges to parametric on normal data | +| Rolling VaR / ES forecasts | Every window agrees with the single-sample `historical_var` and `parametric_var`; the RiskMetrics recursion; a Hull–White volatility-rescaling loop for FHS; **no look-ahead**: changing the return a forecast is scored against (and all later ones) leaves the forecast unchanged, and a peeking forecaster fails the same check; exception probabilities on i.i.d. data match closed forms (`m / (window + 1)` for historical simulation, a Student-t prediction interval for the Gaussian model) | +| Exception tests | Hand-computed Kupiec and Christoffersen statistics; SciPy's binomial log-likelihoods and `chi2` p-values; `LR_cc = LR_uc + LR_ind`; exact sizes against brute-force enumeration of all 2^T exception sequences; simulated sizes on returns drawn from the forecast model itself | +| Basel traffic light | The zones, plus factors and cumulative probabilities of the 1996 Basel table, and the 95% / 99.99% binomial rule behind them | +| ES backtest (Z2) | A hand calculation; mean zero when returns follow the forecasts; a null distribution that does not depend on the volatility path; power against fat tails | +| Backtester | An independent day-by-day reference loop; buy-and-hold equals the asset return; costs reduce wealth by exactly the computed amount; a hand-computed two-day example; **no look-ahead**: perturbing prices after day *t* leaves every weight in effect through day *t*+1 unchanged, and a deliberately cheating strategy fails the same check | +| Metrics | Hand-computed series (drawdown and its duration, CAGR, Sharpe, Sortino, Calmar, hit rate) | +| Data | Simulated means, volatilities and correlations match their targets; CSV round trips and specific error messages | + ## 8. Tooling - **uv** manages the environment; `uv.lock` pins every dependency for every supported Python. The minimum versions in `pyproject.toml` are the oldest versions the lockfile actually exercises (the Python 3.11 job resolves NumPy 2.4 and SciPy 1.17). - **mypy --strict** covers `src`, `tests` and `examples`. SciPy and scikit-learn ship no type information, so a single override ignores their missing stubs, and their results are wrapped in `float()` or `np.asarray()`. Tests that deliberately pass wrong types use `typing.cast` instead of `type: ignore`. - **ruff** handles linting (including pydocstyle with Google-style docstrings) and formatting. - **pytest** runs unit tests, Hypothesis properties and the README doctests, with warnings turned into errors so deprecations surface immediately. Coverage must stay at or above 90% in CI. -- **CI** (GitHub Actions) runs lint, format check, type check, tests with coverage, CLI smoke tests and the example scripts on Python 3.11, 3.12 and 3.13, with read-only permissions and cancellation of superseded runs. +- **CI** (GitHub Actions) runs lint, format check, type check, tests with coverage, the README doctests against both the source tree and an installed wheel, CLI smoke tests and the example scripts on Python 3.11, 3.12 and 3.13, with read-only permissions and cancellation of superseded runs. -## 9. Questions worth being able to answer +## 9. Common questions - *Why does the binomial error oscillate instead of falling smoothly?* The strike falls at different positions between terminal nodes as the step count changes; the payoff kink is not resolved consistently. - *Why Brent rather than Newton for implied volatility?* Guaranteed convergence on a bracket; Newton divides by vega, which can be nearly zero. @@ -278,4 +300,57 @@ Monte Carlo and simulation tests use fixed seeds, so each either always passes o - *Why simulate Z2's critical values from the forecasts themselves?* Under the null the realized returns are one draw from the forecast distributions, so ranking the observed statistic among simulated ones gives a test of exact size without asymptotics. - *What would you change for production use?* Execution lag and slippage, per-asset constraints, a QP solver, vectorized pricing, robust estimates of expected returns, and VaR from position-level P&L with full revaluation. -See the README's *Limitations and roadmap* section for what is out of scope. +Section 10 lists what is out of scope. + +## 10. Limitations and roadmap + +### Models + +Black–Scholes–Merton and GBM assume constant volatility and rates, lognormal prices and continuous dividends: no volatility smile, jumps or discrete dividends. Monte Carlo covers European and Asian payoffs only (no Longstaff–Schwartz for American options). The CRR tree converges slowly and with oscillation; smoother trees such as Leisen–Reimer are not implemented. Implied volatilities outside 0.0001%–1000% are reported as errors. The pricing API is scalar; large option chains would call for a vectorized interface. + +### Portfolio + +Expected returns are plain historical means, which are very noisy. The same weight bounds apply to every asset, with no per-asset, sector or turnover constraints. SLSQP is a general-purpose solver that works well for tens of assets; a dedicated QP solver would scale better. Risk parity is long-only. VaR is one-period and in return units. + +### VaR models and backtests + +Forecasts cover one day and one return series, in return units: there is no multi-day scaling, no P&L from positions and no revaluation of options. The EWMA decay is fixed rather than estimated (no GARCH fit), and FHS filters with that EWMA volatility. The likelihood-ratio p-values are asymptotic; `null_rejection_rates` reports how far the true size is from the nominal one, but the tests do not yet offer exact or Monte Carlo p-values. The independence test only looks at consecutive days. The traffic light implements the 1996 table for 250 days of 99% VaR, not the 97.5% zones or the P&L attribution test of the Basel FRTB standards. Z2's null distribution is simulated from each method's own forecasts, so it tests the forecast distribution's tail as a whole and needs positive ES forecasts. + +### Backtesting + +Daily close-to-close only: trades fill at the same close that produced the signal, and the only friction is a proportional cost (no slippage, market impact, borrowing or financing costs). Idle cash earns nothing. Prices must be complete (no missing values and no assets entering mid-sample) and should already be adjusted for splits and dividends. The no-look-ahead guarantee covers the engine and the built-in strategies; custom strategies can be checked with `check_no_lookahead`. + +### Data and verification + +There is no data downloader: bring your own CSV with ISO dates. CI runs lint, strict type checks and the full suite with README doctests on Python 3.11, 3.12 and 3.13. + +### Roadmap ideas + +per-asset and linear constraints, a vectorized BSM interface, Longstaff–Schwartz for American options, Black–Litterman expected returns, an engine option to fill trades after the signal close, Monte Carlo p-values and duration-based tests (Christoffersen & Pelletier 2004) for VaR exceptions, and GARCH-filtered historical simulation. The walk-forward example lags its estimation window by one close, but its trades still fill at the signal close. + +## 11. References + +- Hull, J. C. *Options, Futures, and Other Derivatives*. Pearson (chapters on the Black–Scholes–Merton model, the Greek letters, binomial trees and numerical procedures). +- Black, F. & Scholes, M. (1973). The pricing of options and corporate liabilities. *Journal of Political Economy*, 81(3), 637–654. +- Merton, R. C. (1973). Theory of rational option pricing. *Bell Journal of Economics and Management Science*, 4(1), 141–183. +- Cox, J. C., Ross, S. A. & Rubinstein, M. (1979). Option pricing: A simplified approach. *Journal of Financial Economics*, 7(3), 229–263. +- Glasserman, P. (2003). *Monte Carlo Methods in Financial Engineering*. Springer. +- Kemna, A. G. Z. & Vorst, A. C. F. (1990). A pricing method for options based on average asset values. *Journal of Banking & Finance*, 14(1), 113–129. +- Markowitz, H. (1952). Portfolio selection. *Journal of Finance*, 7(1), 77–91. +- Merton, R. C. (1972). An analytic derivation of the efficient portfolio frontier. *Journal of Financial and Quantitative Analysis*, 7(4), 1851–1872. +- Cornuéjols, G. & Tütüncü, R. (2007). *Optimization Methods in Finance*. Cambridge University Press. +- Ledoit, O. & Wolf, M. (2004). A well-conditioned estimator for large-dimensional covariance matrices. *Journal of Multivariate Analysis*, 88(2), 365–411. +- Maillard, S., Roncalli, T. & Teïletche, J. (2010). The properties of equally weighted risk contribution portfolios. *Journal of Portfolio Management*, 36(4), 60–70. +- Griveau-Billion, T., Richard, J.-C. & Roncalli, T. (2013). A fast algorithm for computing high-dimensional risk parity portfolios. arXiv:1311.4057. +- Moskowitz, T. J., Ooi, Y. H. & Pedersen, L. H. (2012). Time series momentum. *Journal of Financial Economics*, 104(2), 228–250. +- J.P. Morgan/Reuters (1996). *RiskMetrics Technical Document*, 4th edition. +- Hull, J. & White, A. (1998). Incorporating volatility updating into the historical simulation method for value-at-risk. *Journal of Risk*, 1(1), 5–19. +- Barone-Adesi, G., Giannopoulos, K. & Vosper, L. (1999). VaR without correlations for portfolios of derivative securities. *Journal of Futures Markets*, 19(5), 583–602. +- Kupiec, P. H. (1995). Techniques for verifying the accuracy of risk measurement models. *Journal of Derivatives*, 3(2), 73–84. +- Christoffersen, P. F. (1998). Evaluating interval forecasts. *International Economic Review*, 39(4), 841–862. +- Christoffersen, P. & Pelletier, D. (2004). Backtesting value-at-risk: A duration-based approach. *Journal of Financial Econometrics*, 2(1), 84–108. +- Basel Committee on Banking Supervision (1996). *Supervisory framework for the use of "backtesting" in conjunction with the internal models approach to market risk capital requirements*. [bis.org/publ/bcbs22.htm](https://www.bis.org/publ/bcbs22.htm) +- Acerbi, C. & Székely, B. (2014). Backtesting expected shortfall. *Risk*, December 2014. +- Gneiting, T. (2011). Making and evaluating point forecasts. *Journal of the American Statistical Association*, 106(494), 746–762. +- Jorion, P. (2007). *Value at Risk: The New Benchmark for Managing Financial Risk*, 3rd edition. McGraw-Hill (chapter on backtesting VaR). +- Davison, A. C. & Hinkley, D. V. (1997). *Bootstrap Methods and their Application*. Cambridge University Press (Monte Carlo tests). diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..835be77 --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,98 @@ +# Examples and figures + +The scripts in [`examples/`](../examples) regenerate the figures and tables below with +`uv run python examples/.py`. All of them use synthetic prices from seeded +simulations, so the numbers repeat from run to run. The walk-forward example writes CSVs +instead of a figure. + +## Binomial convergence + +Script: [`binomial_convergence.py`](../examples/binomial_convergence.py). + +CRR tree prices for Hull's call approach the Black–Scholes–Merton value with an error that shrinks like 1/steps, oscillating as tree nodes move relative to the strike. + +![CRR binomial price converging to Black-Scholes-Merton](images/binomial_convergence.png) + +## Efficient frontier + +Script: [`efficient_frontier.py`](../examples/efficient_frontier.py). + +The figure shows the long-only frontier from SLSQP, the unconstrained frontier from its closed form (always to the left, since removing constraints can only lower risk), the minimum-variance and maximum-Sharpe portfolios and the capital market line. The means are estimated from five years of simulated prices, so `EEE`, simulated with an 11% drift, shows a negative estimated return. + +![Efficient frontier on synthetic data](images/efficient_frontier.png) + +## Backtest + +Script: [`backtest_trend.py`](../examples/backtest_trend.py). + +A 50/200-day moving-average crossover against equal-weight buy-and-hold over ten simulated years, with 10 bps costs. On this history (seed 42) the crossover ends ahead. The script then repeats the comparison on 100 independent synthetic histories: + +```text +over 100 independent synthetic histories, CAGR(MA) - CAGR(buy-and-hold): mean -3.41%, standard error 0.29%; MA crossover came out ahead in 14 of 100 +``` + +Seed 42 happens to be one of the 14. With no trends in the data, the crossover mostly sits in cash while the assets drift upward. + +![Equity curves and drawdowns on synthetic data](images/backtest_equity.png) + +## VaR backtest + +Script: [`var_backtest.py`](../examples/var_backtest.py). + +The returns follow a GARCH(1,1) model, whose volatility jumps after large moves, with fat-tailed Student-t shocks. Historical simulation weights a year of history equally, so its VaR (red) reacts late to the volatility burst and its exceptions cluster; filtered historical simulation (blue) rescales history by the current EWMA volatility. Over 1,250 days at 99%: + +```text + historical Gaussian EWMA FHS +exceptions 20 24 24 16 +expected 12.5 12.5 12.5 12.5 +Kupiec p 0.0499 0.0037 0.0037 0.3403 +independence p 0.0389 0.0833 0.4815 0.5193 +cond. coverage p 0.0172 0.0033 0.0115 0.5142 +Basel zone (last 250 days) yellow (5) yellow (7) green (2) green (2) +Z2 -0.79 -1.25 -1.14 -0.33 +Z2 p 0.0014 0.0002 0.0005 0.0484 +``` + +All three exception tests reject historical simulation, and the independence test shows why: its exceptions bunch up in the volatile period. EWMA follows the volatility (independence p = 0.48) but keeps normal tails, so it has as many exceptions as the Gaussian model, 24 against 12.5 expected. FHS passes the three exception tests; Z2 still rejects it, narrowly, at 5%. + +![VaR forecasts, exceptions and cumulative exception counts on synthetic data](images/var_backtest.png) + +## Size and power of the backtests + +Script: [`var_size_power.py`](../examples/var_size_power.py). + +Each row simulates 1,000 independent paths and forecasts with a 250-day window. It then runs the 5% tests on the last 250 or 1,000 days. The script takes about 40 seconds: + +```text +Share of 1,000 SYNTHETIC paths rejected by each 5% test (Monte Carlo standard error at most 1.6%) + + LR_uc LR_ind LR_cc Z2 exceptions +size: EWMA forecasts on returns from the EWMA model, 99% + 250 days, measured 10.5% 1.8% 1.0% 5.1% 1.00% + 250 days, exact 9.5% 1.4% 1.1% 5.0% + 1,000 days, measured 4.2% 2.2% 2.2% 4.4% 1.00% + 1,000 days, exact 5.5% 1.8% 2.7% 5.0% +power: Gaussian forecasts, 99% + normal returns, 250 days 8.1% 1.3% 0.8% 5.3% 1.02% + Student-t(4) returns, 250 days 13.0% 1.4% 6.8% 42.2% 1.65% + normal returns, 1,000 days 2.2% 1.2% 0.9% 3.9% 1.05% + Student-t(4) returns, 1,000 days 46.9% 1.9% 31.3% 87.3% 1.63% +power: Gaussian forecasts, 97.5% + normal returns, 1,000 days 1.2% 2.3% 2.1% 4.0% 2.57% + Student-t(4) returns, 1,000 days 3.4% 2.0% 3.2% 43.3% 2.70% +``` + +- Size: the paths are drawn from the EWMA model itself, so the forecasts are right and every rejection is a false alarm. The measured rates agree with the exact ones from `null_rejection_rates` within the Monte Carlo error, and they are not 5%. In 250 days only 2.5 exceptions are expected, and zero exceptions (8.1% likely) already fails Kupiec's test. The independence test rarely has a pair of exceptions to work with. +- Power: Student-t returns with 4 degrees of freedom have the same variance as the normal ones but fatter tails, so Gaussian VaR is too low. Exception counts catch this in only 13% of 250-day samples. Z2, which also weighs how large the tail losses are, catches it in 42% of them, and in 87% of 1,000-day samples. At 97.5% (the level of the Basel FRTB expected shortfall) the Gaussian VaR happens to have about the right number of exceptions. The count-based tests then see nothing (3.4%), while Z2 rejects 43% of samples. + +## Walk-forward portfolio construction + +Script: [`walk_forward_risk_parity.py`](../examples/walk_forward_risk_parity.py). + +This example connects trailing-return estimation, risk-parity allocation and backtesting. Each monthly allocation uses only closes before the rebalance date. A monthly equal-weight benchmark shares the same cash warmup, dates and costs. + +```bash +uv run python examples/walk_forward_risk_parity.py +``` + +The command exports prices, both target tables, estimation windows, net wealth and a common-period summary to `walk-forward-output/`. The [walkthrough](WALK_FORWARD.md) explains the timing, CSV fields, reproducible inputs and limitations. From 2bbdd86176065880b05ef0ba3ccb22a1a7da900e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:46:26 +0000 Subject: [PATCH 2/2] Keep the data-flow diagram in DESIGN.md The README's mermaid diagram of how prices, portfolios, VaR forecasts, strategies and pricing connect moved into the architecture section of docs/DESIGN.md instead of being dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016h2RvhZFj77hPxFn8TQUqq --- docs/DESIGN.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index af368bd..7b4fbab 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -32,6 +32,19 @@ src/quantkit/ └── cli.py, __main__.py argparse front end (`quantkit ...`, `python -m quantkit`) ``` +Data flows through the subpackages like this: + +```mermaid +flowchart LR + CSV["CSV file or synthetic GBM
quantkit.data"] --> PX["wide price table
(dates x tickers)"] + PX --> PF["quantkit.portfolio
returns, covariance, optimizers, VaR"] + PF --> VR["quantkit.var
rolling VaR/ES forecasts, exception tests,
Basel traffic light, ES backtest"] + PX --> ST["strategy
target weights per close"] + ST --> EN["backtest engine
weights held from t+1, costs"] + EN --> MT["metrics
CAGR, Sharpe, drawdown, ..."] + OS["OptionSpec"] --> PR["quantkit.pricing
BSM, IV, CRR tree, Monte Carlo, Asian"] +``` + Dependencies point one way: `pricing`, `portfolio`, `backtest` and `var` depend only on `_validation` and `exceptions` (and `backtest.engine` on `backtest.metrics`; inside `var`, `forecasts` uses `backtests` and `es_backtest` uses `forecasts`); `data` depends on `_validation`; only `cli` depends on everything, and only `__main__` imports `cli`. This keeps each subpackage testable on its own. The tail size shared by historical VaR and rolling historical simulation, `ceil(n (1 - level))`, lives in `_validation.tail_count` so that the two cannot drift apart. **Conventions used everywhere**