diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 6756b781..96b393fe 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -36,6 +36,8 @@ jobs: run: | mkdir -p docs/html/artifacts cp -R docs/artifacts/. docs/html/artifacts/ + mkdir -p docs/html/docs/images + cp -R docs/images/. docs/html/docs/images/ - name: Create friendly doc aliases run: | declare -A alias_sources=( diff --git a/CHANGELOG.md b/CHANGELOG.md index 2159273e..bcac14e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## v0.4.0 + +- feat(portfolio,risk): add a public C++ vectorized vanilla portfolio engine and installed Python `bs_portfolio_risk` API with quantity-weighted price, value, delta, gamma, vega, theta, and rho surfaces. +- feat(stress): add `bs_portfolio_scenarios` for deterministic multi-factor exact repricing with spot, volatility, rate, dividend, and elapsed-time shocks; aggregate-only mode avoids the potentially large scenario-by-position allocation. +- perf(portfolio): fuse shared Black-Scholes analytic terms across price and six Greeks; the final installed-wheel Apple M3 Pro/Python 3.12 benchmark measures 20.18x risk-batch and 27.92x aggregate scenario speedups versus existing scalar binding orchestration. +- test(portfolio): add native validation/identity coverage plus independent QuantLib 1.42.1 parity across mixed call/put, carry, moneyness, volatility, maturity, long/short, and scenario cases, with deterministic concurrent replay. +- build(release): prepare the 0.4.0 wheel/source distribution and retain the installed-wheel contract, release tag gates, source distribution checks, deterministic manifest, and explicit rule that PyPI and TestPyPI availability are not asserted. +- docs(release): make portfolio risk and exact stress the README and Pages lead, publish the frozen evidence hub and benchmark visualization, and retain the broader v0.3.7 numerical and packaging story. + ## Unreleased ## v0.3.7 diff --git a/CMakeLists.txt b/CMakeLists.txt index f81b81c8..941adf25 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(quant_pricer_cpp VERSION 0.3.7 LANGUAGES CXX) +project(quant_pricer_cpp VERSION 0.4.0 LANGUAGES CXX) set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(CMAKE_CXX_STANDARD 20) @@ -108,6 +108,7 @@ add_library(quant_pricer src/lookback.cpp src/heston.cpp src/risk.cpp + src/portfolio.cpp src/multi.cpp ) @@ -150,6 +151,7 @@ add_executable(unit_tests tests/test_multi.cpp tests/test_barrier_mc_regression.cpp tests/test_risk.cpp + tests/test_portfolio.cpp tests/test_heston.cpp tests/test_rng_repro.cpp) target_sources(unit_tests PRIVATE tests/test_lookback.cpp) @@ -306,6 +308,12 @@ endif() if(QUANT_ENABLE_PYBIND) add_subdirectory(python) + add_test( + NAME python_portfolio_risk_fast + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/test_python_portfolio_risk_fast.py + --module-dir $ + ) + set_tests_properties(python_portfolio_risk_fast PROPERTIES LABELS "FAST") endif() # Install/export package metadata diff --git a/Doxyfile b/Doxyfile index 97bc2294..1b359af4 100644 --- a/Doxyfile +++ b/Doxyfile @@ -4,7 +4,8 @@ OUTPUT_DIRECTORY = docs GENERATE_HTML = YES GENERATE_LATEX = NO RECURSIVE = YES -INPUT = include src README.md docs/api docs/Results.md docs/WRDS_Results.md +INPUT = include src README.md docs/api docs/product docs/releases docs/Results.md docs/WRDS_Results.md +IMAGE_PATH = docs/images FILE_PATTERNS = *.hpp *.cpp *.md EXTRACT_ALL = YES QUIET = YES diff --git a/PROGRESS.md b/PROGRESS.md index 0ae21fb1..134bdde6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -100,3 +100,19 @@ Added explicit `results_commit_sha` + `manifest_git_sha` fields to `project_stat ### Done - Ticket-99: rebuilt the public README around the supported C++20/Python pricing surface, install/use examples, architecture, frozen validation snapshot, reproducibility, and explicit release limitations. Added a bounded evidence note for the verified Heston calibration-grid candidate without advertising it as v0.3.2 or PyPI availability. Run log: `docs/agent_runs/20260714_220933_ticket-99_portfolio-presentation/`. + +## 2026-07-15 Vectorized Portfolio Risk and Stress v0.4.0 + +Selected the largest disjoint product gap after reviewing the consumed-model +ledger: cross-position risk rather than another SSVI/Heston/PDE/MC experiment. +Added public C++ portfolio types and installed Python `bs_portfolio_risk` and +`bs_portfolio_scenarios` APIs, exact five-factor repricing, aggregate-only +memory control, native and independent QuantLib tests, examples, v0.4.0 release +surfaces, and deterministic performance/resource receipts. The first unfused +risk batch missed its frozen 10x gate at 9.94x; fused shared analytic terms +passed without relaxing the gate. Final installed-wheel evidence: 20.18x risk, +27.92x scenario, 20.25M positions/s, 32.13M cells/s; worst independent price, +Greek, and portfolio-scenario errors were 3.91e-14, 3.40e-12, and 2.66e-13. +ASan/UBSan and installed-wheel checks passed. Full FAST: 89 passed, one existing +skip, and only the two pre-existing locked SSVI hedge failures caused by their +intentional CMake hash boundary. Implementation commit: `60c4e9da`. diff --git a/README.md b/README.md index 027874ae..a36ebc8c 100644 --- a/README.md +++ b/README.md @@ -4,36 +4,83 @@ [![Docs](https://img.shields.io/badge/docs-results%20%26%20API-0969da)](https://mateobodon.github.io/quant-pricer-cpp/) [![License: MIT](https://img.shields.io/badge/license-MIT-1a7f37)](LICENSE) -**A modern C++20 option-pricing library with Python bindings and reproducible -numerical evidence across analytic, Monte Carlo, PDE, and reference engines.** +**Portfolio risk and exact stress in C++20 and Python—backed by independent, +artifact-bound numerical evidence.** -Use it to price vanilla and exotic options, estimate Greeks with uncertainty, -cross-check independent methods, and carry the exact validation artifacts with -the result. +v0.4.0 adds native vectorized valuation, six quantity-weighted portfolio +measures, and exact five-factor scenario P&L through `bs_portfolio_risk` and +`bs_portfolio_scenarios`. The broader library retains analytic, Monte Carlo, +QMC, PDE, Heston, SSVI, exotic-option, and reproducibility surfaces. -> **Release status — v0.3.7.** Build from source or use the signed-off GitHub +> **Release status — v0.4.0.** Build from source or use the signed-off GitHub > release assets. `pyquant-pricer` is **not published on PyPI**. Benchmark and > accuracy numbers below are dated snapshots bound to their artifacts and > hardware—not universal guarantees. -| Artifact-backed proof | Result | Scope | +| v0.4.0 proof | Result | Evidence boundary | | --- | ---: | --- | -| QuantLib parity | max difference `0.862¢` | Vanilla, barrier, and American sample cases; 2026-01-25 snapshot | -| QMC vs PRNG | median RMSE ratio `4.76×` | Equal-time European + Asian benchmark; 2026-01-25 snapshot | -| MC throughput | `12.75M` paths/s | One thread, AMD EPYC 9454P, Linux; historical hardware result | +| Portfolio risk batch | **`20.18×`**; `20.25M` positions/s | 100,000 positions; Apple M3 Pro; 7-run median | +| Exact aggregate stress | **`27.92×`**; `32.13M` cells/s | 20,000 positions × 16 shocks; same host/protocol | +| Independent QuantLib parity | price `3.91e-14`; Greek `3.40e-12`; portfolio P&L `2.66e-13` | 60 mixed positions and 72 scenario cells | +| Determinism | zero-shock exactly zero; **32/32** concurrent replays identical | Frozen installed-wheel evaluator | + +

+ Recorded v0.4.0 native speedups: 20.18 times for portfolio risk and 27.92 times for exact aggregate stress +

+ +These are deterministic Black–Scholes European valuation and user-supplied +stress results—not trading alpha, forecasting, probabilistic market-risk +validation, or live P&L. [Read the exact contract and receipts.](docs/product/DERIVATIVES_SYSTEM_HUB.md) + +The established evidence remains available: `4.76×` median QMC/PRNG RMSE ratio +on the frozen equal-time cases, `-2.012` PDE convergence slope, `12.75M` +one-thread MC paths/s on the recorded EPYC host, and the exact 12-date SSVI +confirmation with its retained loss and no-trading boundary. ## At a glance | Area | Coverage | | --- | --- | +| Portfolio risk / stress | Vectorized mixed call/put valuation; value, delta, gamma, vega, theta, rho totals; exact five-factor scenario P&L | | Analytic | Black–Scholes prices/Greeks/implied vol, digitals, Reiner–Rubinstein barriers, Heston European calls | | Monte Carlo / QMC | Deterministic counter RNG, OpenMP, antithetic/control variates, Sobol + Brownian bridge, confidence intervals | | PDE / early exercise | Crank–Nicolson + Rannacher, stretched grids, PSOR, CRR tree, Longstaff–Schwartz | | Exotics / models | Asian, lookback, barrier, basket, Merton jump-diffusion, Heston Euler/QE | -| Risk | VaR/CVaR, Student-t variants, Kupiec/Christoffersen backtests | -| Interfaces | C++ library and CLI, CMake package, optional pybind11 module | +| Risk statistics | VaR/CVaR, Student-t variants, Kupiec/Christoffersen backtests | +| Interfaces | C++ library and CLI, CMake package, pybind11 module, GitHub release wheels | + +## Portfolio risk in 60 seconds + +Build from source with `python -m pip install .`, or download the wheel matching +your Python and platform from the [v0.4.0 GitHub release](https://github.com/MateoBodon/quant-pricer-cpp/releases/tag/v0.4.0). -## First price +```python +import numpy as np +import pyquant_pricer as qp + +# option type, quantity, spot, strike, rate, dividend, volatility, time +positions = np.array([ + [ 1, 120, 100, 95, .03, .01, .22, 90/365], + [-1, -80, 100, 105, .03, .01, .25, 90/365], + [ 1, 50, 100, 110, .03, .01, .28, 180/365], +], dtype=np.float64) + +risk = qp.bs_portfolio_risk(positions) +totals = dict(zip(risk["total_columns"], risk["portfolio_totals"])) + +# spot return, absolute vol/rate/dividend shifts, elapsed years +shocks = np.array([[0, 0, 0, 0, 0], [-.10, .08, .01, 0, 1/365]]) +stress = qp.bs_portfolio_scenarios(positions, shocks, detail=False) +print(totals) +print(stress["portfolio_pnl"]) +``` + +`detail=False` avoids allocating the scenario-by-position attribution matrix. +For validation rules, units, C++ types, and limitations, use the +[product hub](docs/product/DERIVATIVES_SYSTEM_HUB.md) and the runnable +[`portfolio_risk.py`](python/examples/portfolio_risk.py) example. + +## Build and scalar pricing ### C++ @@ -80,6 +127,13 @@ python -m pip install --upgrade pip python -m pip install . ``` +The original v0.4.0 evaluator verified the wheel build/install/import path on +Python 3.12 macOS arm64 at source commit `60c4e9da`; its exact wheel SHA-256 is +`bfc005727c385f8c7978e670cc0de6295f7540746040dbd1c92771602a6760d1`. +The public release's `release-manifest.json` separately binds every supported +platform wheel and the sdist to the tag commit. PyPI is not claimed; the source +build above remains the portable fallback. + ```python import pyquant_pricer as qp @@ -100,8 +154,178 @@ print(f"BS={call:.4f} delta={delta:.4f}") print(f"MC={mc.estimate.value:.4f} ± {1.96 * mc.estimate.std_error:.4f}") ``` -See [`python/examples/quickstart.py`](python/examples/quickstart.py) for barriers -and Heston helpers. +See [`python/examples/quickstart.py`](python/examples/quickstart.py) for a fuller +walkthrough, including barriers and Heston helpers. + +### Vectorized analytic Heston calls + +`heston_calls_analytic_batch` accepts two contiguous `float64` matrices. Market +columns are `(spot, strike, rate, dividend, time)`; parameter columns are +`(kappa, theta, sigma, rho, v0)`. + +```python +import numpy as np +import pyquant_pricer as qp + +markets = np.array([ + [100.0, 90.0, 0.015, 0.005, 0.5], + [100.0, 100.0, 0.015, 0.005, 1.0], + [100.0, 110.0, 0.015, 0.005, 2.0], +]) +params = np.array([[1.5, 0.04, 0.6, -0.45, 0.04]]) +call_prices = qp.heston_calls_analytic_batch(markets, params) +put_prices = qp.heston_puts_analytic_batch(markets, params) +implied_vols = qp.heston_implied_vols_batch(markets, params) +call_metrics = qp.heston_call_metrics_batch(markets, params) +candidate_grid = qp.heston_call_metrics_grid(markets, params) +print(call_prices, put_prices, implied_vols, call_metrics, candidate_grid) +``` + +Inputs must have nonzero row counts and valid finite Heston values. Supply one parameter row +to broadcast a calibration across every market, one market row to evaluate many parameter +candidates, or matching row counts for pairwise evaluation. Every other row-count mismatch is +rejected. The +runtime uses one worker per 32 rows, capped by a process-wide four-worker budget +shared across concurrent callers. Inspect the fixed policy with +`qp.heston_analytic_batch_policy()`. `heston_implied_vols_batch` applies the +same contract and returns the Black-Scholes implied volatility of each analytic +Heston call. + +When both values are needed, `heston_call_metrics_batch` returns contiguous +`(call_price, implied_vol)` columns while evaluating the analytic Heston call +only once per market row. + +For calibration sweeps, `heston_call_metrics_grid(markets, params)` evaluates +every parameter candidate against every market row without expanded Cartesian +inputs. Its contiguous `(p, m, 2)` output is candidate-major, with final-axis +columns `(call_price, implied_vol)`. + +--- + +## Validation Pack + +The repo can generate a `validation_pack.zip` containing committed CSV/PNG/JSON artifacts plus `docs/artifacts/manifest.json`, so reviewers can diff published numbers without rebuilding. The T-001/T-101 evidence pass did not verify current release-asset availability; regenerate locally with: + +```bash +WRDS_USE_SAMPLE=1 ./scripts/reproduce_all.sh +python scripts/package_validation.py --artifacts docs/artifacts --output docs/validation_pack.zip +``` + +Upload the resulting `docs/validation_pack.zip` when drafting a GitHub release to keep reproducible evidence alongside the tag. + +--- + +## Results at a Glance + +Curated figures (plus precise reproduction commands) live on the [Results page](https://mateobodon.github.io/quant-pricer-cpp/Results.html). + +- **Metrics snapshot (latest committed artifact snapshot):** + - Generate: `WRDS_USE_SAMPLE=1 ./scripts/reproduce_all.sh && python scripts/generate_metrics_summary.py --artifacts docs/artifacts --manifest docs/artifacts/manifest.json` + - Browse: `docs/artifacts/metrics_summary.md` (artifact-derived; current committed snapshot is historical until current-HEAD reproduction is repaired) + +- **Real-data SSVI temporal confirmation:** On a published, one-use 12-pair + 2020–2025 OptionMetrics panel, arbitrage-aware SSVI passed every + analytic/numerical/finite/QuantLib gate and won next-day price MAE on 11/12 + dates versus repaired Heston and 12/12 versus tenor-flat Black–Scholes. + Median relative changes were `-8.88%` and `-79.90%`, respectively. This is an + exact-panel, SSVI-unseen but not dataset-blind result; hedge behavior and + future returns were not tested. Machine-readable aggregate evidence: + [`ssvi_temporal_holdout_v1_summary.json`](docs/artifacts/ssvi_temporal_holdout_v1_summary.json). + +- **Native C++ power-law SSVI:** The confirmed formulation now has a typed C++20 + surface/calibration API, analytic total-variance derivatives, call/put + pricing, sticky-strike smile delta/gamma, local vega, deterministic + three-start calibration, dense arbitrage tests, and Python/QuantLib oracle + parity. On the recorded Mac15,6 Release benchmark, median price+risk latency + was `116 ns`, a 1,024-node batch ran at `7.16M nodes/s`, and a 65-point + three-start calibration took `1.336 ms`. These numbers are hardware/protocol + specific. API: [`include/quant/ssvi.hpp`](include/quant/ssvi.hpp); benchmark: + [`ssvi_cpp_benchmark_v1.json`](docs/artifacts/ssvi_cpp_benchmark_v1.json). + +- Tri-engine agreement
+ **Tri-Engine Agreement (BS / MC / PDE)** – Analytic, deterministic MC, and Crank–Nicolson agree to <5 bps across strikes; MC CI is shown.
+ Reproduce: `python scripts/tri_engine_agreement.py --quant-cli build/quant_cli --output docs/artifacts/tri_engine_agreement.png --csv docs/artifacts/tri_engine_agreement.csv` + Data: [tri_engine_agreement.csv](https://mateobodon.github.io/quant-pricer-cpp/artifacts/tri_engine_agreement.csv) +- QMC vs PRNG equal-time RMSE
+ **QMC vs PRNG (equal wall-clock)** – In the committed artifact snapshot, the median PRNG/QMC RMSE ratio is 4.76346 for the tested European + Asian scenarios; this is scenario/protocol-specific, not a universal QMC claim.
+ Reproduce: `python scripts/qmc_vs_prng_equal_time.py --output docs/artifacts/qmc_vs_prng_equal_time.png --csv docs/artifacts/qmc_vs_prng_equal_time.csv --fast` + Data: [qmc_vs_prng_equal_time.csv](https://mateobodon.github.io/quant-pricer-cpp/artifacts/qmc_vs_prng_equal_time.csv) +- WRDS panel summary
+ **WRDS Heston (multi-date Vega + Δ-hedge)** – Aggregated deterministic sample/regression bundle with vega×quote-weighted IV/OOS errors (DTE ≥21d, 0.75–1.25 wings with soft taper) and Δ-hedged 1d buckets; snapshot values live in `docs/artifacts/metrics_summary.md`. Live/local WRDS evidence is gated and is not promoted by the sample bundle.
+ Reproduce (sample): `python wrds_pipeline/pipeline.py --dateset wrds_pipeline_dates_panel.yaml --use-sample` + Data: [wrds_agg_pricing.csv](https://mateobodon.github.io/quant-pricer-cpp/artifacts/wrds_agg_pricing.csv), [wrds_agg_oos.csv](https://mateobodon.github.io/quant-pricer-cpp/artifacts/wrds_agg_oos.csv), [wrds_agg_pnl.csv](https://mateobodon.github.io/quant-pricer-cpp/artifacts/wrds_agg_pnl.csv) +- BS vs Heston IV RMSE by tenor
+ **WRDS BS vs Heston (sample comparison)** – On the bundled sample dates Heston and BS are now near parity (per-tenor IV RMSE deltas within ±0.0002 vol pts; OOS deltas single-digit bps). Live IvyDB pulls remain the source of truth; sample bundle is a smoke test/regression harness.
+ + | Tenor | BS IV RMSE | Heston IV RMSE | OOS IV MAE BS (bps) | OOS IV MAE Heston (bps) | Δ‑hedged σ (Heston, ticks) | + | --- | --- | --- | --- | --- | --- | + | 30d | 0.0237 | 0.0237 | 166.9 | 167.4 | 96.1 | + | 60d | 0.0157 | 0.0158 | 122.5 | 121.1 | 64.2 | + | 90d | 0.0146 | 0.0146 | 126.3 | 128.8 | 47.0 | + + See `docs/WRDS_Results.md` for narrative, heatmaps, and the tracked `docs/artifacts/wrds/wrds_bs_heston_comparison.csv`. +- QuantLib parity
+ **QuantLib Parity (vanilla/barrier/American)** – quant-pricer-cpp prices match QuantLib within ≈1¢ while exposing runtime deltas for each product.
+ Reproduce: `python scripts/ql_parity.py --output docs/artifacts/ql_parity/ql_parity.png --csv docs/artifacts/ql_parity/ql_parity.csv` + Data: [ql_parity.csv](https://mateobodon.github.io/quant-pricer-cpp/artifacts/ql_parity/ql_parity.csv) + +--- + +## Table of Contents + +- [Features](#features) +- [Architecture](#architecture) +- [Numerical Methods](#numerical-methods) +- [Variance Reduction & Greeks](#variance-reduction--greeks) +- [Determinism & Reproducibility](#determinism--reproducibility) +- [Build & Install](#build--install) +- [CLI Usage](#cli-usage) +- [Library API Overview](#library-api-overview) +- [Validation & Results](#validation--results) +- [Benchmarks](#benchmarks) +- [Testing & CI](#testing--ci) +- [Docs](#docs) +- [Limitations](#limitations) +- [Roadmap](#roadmap) +- [License](#license) + +--- + +## Features + +### 🚀 **Core Pricing Engines** +- **Black–Scholes Analytics**: Complete European option pricing with all major Greeks (Delta, Gamma, Vega, Theta, Rho) +- **Monte Carlo Engine**: High-performance GBM simulation with deterministic counter-based RNG (thread-invariant), optional PCG/MT streams, and OpenMP parallelization +- **PDE Solver**: Crank–Nicolson with Rannacher start-up, optional tanh-stretched grids around the strike, and direct Δ/Γ/Θ extraction +- **Barrier Options**: Continuous single-barrier (up/down, in/out) pricing via Reiner–Rubinstein closed-form, Brownian-bridge Monte Carlo, and absorbing-boundary PDE +- **American Options**: PSOR (finite-difference LCP) and Longstaff–Schwartz Monte Carlo with polynomial basis, covered by FAST consistency checks. +- **Exotics**: Arithmetic Asian MC with geometric CV, lookback MC (fixed/floating), digitals (analytic and MC hooks) +- **Heston**: Analytic European call via characteristic-function Gauss–Laguerre **plus Andersen QE Monte Carlo** with deterministic counter-based RNG for variance paths +- **Portfolio Risk & Stress**: vectorized mixed call/put valuation, quantity-weighted price/Greek aggregation, and exact multi-factor scenario P&L with allocation-safe aggregate-only mode +- **Risk Statistics**: VaR/CVaR via MC and historical backtesting with Kupiec and Christoffersen tests + - **Multi‑Asset & Jumps**: Basket MC with Cholesky correlation; Merton jump‑diffusion MC for European options + +### ⚡ **Advanced Monte Carlo** +- **Variance Reduction**: Antithetic variates and control variates for improved convergence +- **Quasi-Monte Carlo**: **Sobol** (optional Owen/digital shift) **+ Brownian bridge** path construction; antithetic and control variates. *Legacy:* an earlier version used a Van der Corput scalar sequence with inverse-normal transform for single-step paths. +- **MC Greeks**: Pathwise estimators (Delta, Vega) and Likelihood Ratio Method (Gamma) +- **Streaming Architecture**: Cache-friendly memory access patterns for optimal performance +- **Piecewise-Constant Schedules**: Optional rate/dividend/vol term structures for vanilla and barrier engines via CSV or `PiecewiseConstant` + +### 🎯 **Production-Ready Quality** +- **Cross-Validation**: Three independent pricing methods for result verification +- **Comprehensive Testing**: Unit tests, edge cases, put-call parity, and convergence validation +- **Performance Benchmarks**: Google Benchmark integration with detailed timing analysis +- **Modern C++20**: Clean, type-safe API with constexpr optimizations + +### 🔧 **Developer Experience** +- **CLI Interface**: Command-line tool for interactive pricing and parameter exploration +- **CMake Build System**: Cross-platform support with optional dependencies +- **CI/CD Pipeline**: Multi-compiler, multi-OS testing with sanitizers and static analysis +- **Documentation**: Doxygen-generated API docs with mathematical formulations +- **Python Bindings**: Optional `pyquant_pricer` module (pybind11) with BS portfolio risk/stress plus MC/PDE/Heston coverage, enums (`OptionType`, `BarrierType`, `McSampler`, `McBridge`), and `PiecewiseConstant` schedules; wheels via cibuildwheel + +--- ## Architecture @@ -159,7 +383,7 @@ fit or trading performance. ## Heston -The public v0.3.7 surface includes analytic European calls and puts, +The public v0.4.0 surface includes analytic European calls and puts, characteristic functions, implied-volatility helpers, bounded batch/grid interfaces, and Euler/QE Monte Carlo: @@ -305,9 +529,9 @@ ctest --test-dir build-asan --output-on-failure - **Heston is not shown to dominate Black–Scholes.** The bundled comparison is near parity/mixed by tenor, and older hedge labeling is not a valid Heston-specific hedge claim. -- **SSVI is not a v0.3.7 public API.** Newer research exists outside this public - release, but is intentionally excluded here until its release/evidence path is - coherent. No SSVI hedge, PnL, or universal-superiority claim is made. +- **SSVI evidence is bounded.** The public C++ surface and exact frozen panels + support their documented pricing, calibration, and fit claims. No SSVI hedge, + P&L, return, trading, or universal-superiority claim is made. - **Artifact freshness matters.** The headline snapshot is dated 2026-01-25. Re-run the reproduction pack before using it as current-machine evidence. - **Package availability is explicit.** PyPI returned no `pyquant-pricer` @@ -316,7 +540,7 @@ ctest --test-dir build-asan --output-on-failure ## Releases, contribution, and citation -The latest public release is [v0.3.7](https://github.com/MateoBodon/quant-pricer-cpp/releases/tag/v0.3.7), +The latest public release is [v0.4.0](https://github.com/MateoBodon/quant-pricer-cpp/releases/tag/v0.4.0), with source/wheel assets, a deterministic release manifest, and a validation pack. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for focused changes and diff --git a/docs/agent_runs/v0.4.0-public-release/EVALUATOR.md b/docs/agent_runs/v0.4.0-public-release/EVALUATOR.md new file mode 100644 index 00000000..aaa4a50b --- /dev/null +++ b/docs/agent_runs/v0.4.0-public-release/EVALUATOR.md @@ -0,0 +1,28 @@ +# v0.4.0 public-release evaluator + +Goal: publish a truthful, polished quant-pricer-cpp v0.4.0 GitHub release that +makes the verified portfolio-risk and exact-stress APIs the public project's +strongest story without weakening v0.3.7 packaging or prior numerical evidence. + +The release is complete only when all of the following are provider-verified: + +1. The reviewed integration branch is based on GitHub's current public main and + contains only the reconciled v0.4.0 implementation, evidence, presentation, + and release changes. +2. Native, Python, installed-wheel, sdist, data-policy, sanitizer, and relevant + regression checks pass; any inherited SSVI locked-hash failures are unchanged + and do not appear in public CI. +3. Public main, tag `v0.4.0`, and the GitHub release identify one exact commit. +4. CI, Docs Pages, Wheels, and release workflows succeed for that commit/tag. +5. Release assets contain the supported cross-platform wheel matrix, one sdist, + deterministic release manifest, artifact manifest, and validation payload; + provider digests and the manifest agree. +6. Live GitHub README, release page, and Pages are inspected at desktop and + narrow widths with no broken image, clipped content, page-level horizontal + overflow, stale lead copy, or misleading claim. +7. The canonical dirty checkout's non-ledger diff and untracked-byte digests + match the pre-release preservation snapshot. + +Final provider URLs, commit/tag, run conclusions, asset inventory/digests, +visual-QA result, and preservation digests are recorded in the Project OS goal +receipt before the goal is finished. diff --git a/docs/api/index.md b/docs/api/index.md index d24ab725..5e5081ba 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,8 +1,16 @@ # quant-pricer-cpp API Reference -Welcome to the generated reference for the quant-pricer-cpp library. These pages are -published automatically from the main branch and mirror the exact code that ships -in the repository. +Welcome to the generated reference for quant-pricer-cpp. v0.4.0 leads with +vectorized Black–Scholes portfolio risk and exact five-factor stress, while +retaining the library's analytic, Monte Carlo/QMC, PDE, Heston, SSVI, and exotic +pricing surfaces. These pages are built from the public tag source. + +@image html portfolio-risk-v040.svg "Recorded v0.4.0 portfolio-risk and aggregate-stress speedups" + +The chart is a presentation of the frozen +`portfolio_risk_benchmark_v1.json` receipt, not a rerun or universal throughput +claim. See the [v0.4.0 product and evidence hub](../product/DERIVATIVES_SYSTEM_HUB.md) +for workloads, independent parity, determinism, resource use, and limitations. ## How to Navigate @@ -17,6 +25,9 @@ in the repository. ## Frequently Visited Areas +- **Portfolio risk and stress**: `quant::portfolio` namespace – typed positions, + quantity-weighted price/Greek aggregation, and exact scenario repricing with + optional position attribution. - **Black–Scholes analytics**: `quant::bs` namespace – closed-form prices, Greeks, parity, and implied volatility solvers used across the project. - **Monte Carlo engine**: `quant::mc` namespace – deterministic counter-based diff --git a/docs/artifacts/README.md b/docs/artifacts/README.md index 7a46011b..2803a675 100644 --- a/docs/artifacts/README.md +++ b/docs/artifacts/README.md @@ -1 +1,14 @@ -Curated results only (tracked). Include a run manifest (manifest.json) and keep outputs small/reviewable. +Curated results only (tracked). Include a run manifest (`manifest.json`) and +keep outputs small and reviewable. + +The authoritative v0.4.0 portfolio evidence is: + +- `portfolio_risk_quantlib_parity_v1.json` for independent QuantLib parity and + deterministic replay; +- `portfolio_risk_benchmark_v1.json` for the frozen host/workloads, speedups, + throughput, and resource measurements; +- `portfolio_risk_release_v040.json` for the original local wheel/sdist proof. + +`docs/images/portfolio-risk-v040.svg` is a hand-authored presentation of the +recorded benchmark JSON values. It is not a separately rerun benchmark and the +JSON receipt remains authoritative. diff --git a/docs/artifacts/portfolio_risk_benchmark_v1.json b/docs/artifacts/portfolio_risk_benchmark_v1.json new file mode 100644 index 00000000..f13c1997 --- /dev/null +++ b/docs/artifacts/portfolio_risk_benchmark_v1.json @@ -0,0 +1,84 @@ +{ + "schema_version": 1, + "benchmark_id": "bs_portfolio_risk_v1", + "generated_at": "2026-07-15T08:19:53.333002+00:00", + "protocol": { + "repetitions": 7, + "statistic": "median_after_one_warmup", + "risk_positions": 100000, + "scenario_positions": 20000, + "scenario_count": 16, + "performance_gate_speedup": 10.0, + "seed_or_randomness": "none; formula-generated deterministic matrices" + }, + "results": { + "risk_native_median_seconds": 0.004937916, + "risk_scalar_median_seconds": 0.099627333, + "risk_speedup": 20.175987805381865, + "risk_native_positions_per_second": 20251458.307512727, + "scenario_native_median_seconds": 0.009958458, + "scenario_scalar_median_seconds": 0.278084792, + "scenario_speedup": 27.924483087642688, + "scenario_native_cells_per_second": 32133488.939753525, + "risk_native_samples_seconds": [ + 0.004982208, + 0.004548708, + 0.004981666, + 0.004937916, + 0.004768708, + 0.004617459, + 0.004997917 + ], + "risk_scalar_samples_seconds": [ + 0.103245834, + 0.100538292, + 0.098322417, + 0.098933667, + 0.099627333, + 0.099785959, + 0.099399875 + ], + "scenario_native_samples_seconds": [ + 0.010017292, + 0.009945166, + 0.009942041, + 0.010069042, + 0.010073792, + 0.009958458, + 0.009932666 + ], + "scenario_scalar_samples_seconds": [ + 0.279878292, + 0.277834666, + 0.275931583, + 0.275628, + 0.283728917, + 0.278084792, + 0.28828225 + ], + "deterministic_repetitions": 5, + "scalar_parity_max_abs_pnl": 4.3655745685100555e-11 + }, + "resources": { + "peak_process_rss_bytes": 121831424, + "risk_input_bytes": 6400000, + "risk_output_bytes": 5600048, + "scenario_input_bytes": 1280640, + "scenario_aggregate_output_bytes": 128, + "scenario_detail_output_bytes_if_requested": 2560000 + }, + "environment": { + "platform": "macOS-26.5.1-arm64-arm-64bit", + "machine": "arm64", + "cpu": "Apple M3 Pro", + "logical_cpus": 11, + "memory_bytes": "38654705664", + "compiler": "Apple clang version 21.0.0 (clang-2100.1.1.101)", + "python": "3.12.2", + "numpy": "2.5.0", + "quantlib": "1.42.1", + "pyquant_pricer": "0.4.0", + "git_head": "60c4e9daefbbe481b3002eaa6c1429b069ae79b3" + }, + "claim_boundary": "Hardware/protocol-specific Python orchestration comparison; deterministic Black-Scholes pricing and conditional stress only, not a market-risk, hedge, PnL, or trading claim." +} diff --git a/docs/artifacts/portfolio_risk_quantlib_parity_v1.json b/docs/artifacts/portfolio_risk_quantlib_parity_v1.json new file mode 100644 index 00000000..76acf030 --- /dev/null +++ b/docs/artifacts/portfolio_risk_quantlib_parity_v1.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "evaluator_id": "bs_portfolio_quantlib_parity_v1", + "generated_at": "2026-07-15T08:19:49.686282+00:00", + "position_case_count": 60, + "scenario_cell_count": 72, + "price_greek_tolerance": { + "absolute": 1e-10, + "relative": 1e-10 + }, + "scenario_pnl_tolerance": { + "absolute": 1e-09, + "relative": 1e-10 + }, + "metric_max_abs_error": { + "price": 3.907985046680551e-14, + "value": 1.7053025658242404e-13, + "delta": 8.659739592076221e-15, + "gamma": 6.661338147750939e-16, + "vega": 2.8421709430404007e-13, + "theta": 3.3999469906120794e-12, + "rho": 5.684341886080801e-13 + }, + "scenario_position_pnl_max_abs_error": 2.0605739337042905e-13, + "scenario_portfolio_pnl_max_abs_error": 2.6645352591003757e-13, + "zero_shock_exact": true, + "concurrent_replays": 32, + "concurrent_replays_bitwise_identical": true, + "invalid_position_cases_rejected": 4, + "invalid_post_shock_case_rejected": true, + "quantlib_version": "1.42.1", + "pyquant_pricer_version": "0.4.0", + "claim_boundary": "Independent deterministic Black-Scholes parity only; no forecast, hedge, market-risk, PnL, or trading claim." +} diff --git a/docs/artifacts/portfolio_risk_release_v040.json b/docs/artifacts/portfolio_risk_release_v040.json new file mode 100644 index 00000000..6b05f460 --- /dev/null +++ b/docs/artifacts/portfolio_risk_release_v040.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "release_candidate": "pyquant-pricer 0.4.0", + "source_commit": "60c4e9daefbbe481b3002eaa6c1429b069ae79b3", + "wheel": { + "filename": "pyquant_pricer-0.4.0-cp312-cp312-macosx_26_0_arm64.whl", + "sha256": "bfc005727c385f8c7978e670cc0de6295f7540746040dbd1c92771602a6760d1", + "installed_version": "0.4.0", + "installed_api_smoke": "pass" + }, + "source_distribution": { + "filename": "pyquant_pricer-0.4.0.tar.gz", + "sha256": "ae4d41b9a3b283a7a401ca169f6702a9de5ea6ee9774433c499876b934296a3c", + "build_complete_contract": "pass", + "contains": [ + "include/quant/portfolio.hpp", + "src/portfolio.cpp", + "python/pybind_module.cpp" + ] + }, + "installed_apis": ["bs_portfolio_risk", "bs_portfolio_scenarios"], + "environment": { + "platform": "macOS 26.5.1 arm64", + "python": "3.12.2", + "compiler": "Apple clang 21.0.0" + }, + "effects": { + "pypi_upload": false, + "testpypi_upload": false, + "git_tag": false, + "github_release": false + }, + "claim_boundary": "Local release-candidate build and installed-wheel proof only; public index or cross-platform availability is not asserted." +} diff --git a/docs/images/portfolio-risk-v040.svg b/docs/images/portfolio-risk-v040.svg new file mode 100644 index 00000000..dc9780d3 --- /dev/null +++ b/docs/images/portfolio-risk-v040.svg @@ -0,0 +1,30 @@ + + quant-pricer-cpp v0.4.0 recorded native speedups + Portfolio risk batch measured 20.18 times faster and exact aggregate stress measured 27.92 times faster than scalar Python binding orchestration on the recorded Apple M3 Pro evaluator. + + v0.4.0 native portfolio engine + Recorded speedup vs scalar Python binding orchestration + + + + + + + + + + 10× + 20× + 30× + + + Portfolio risk + + 20.18× + + Aggregate stress + + 27.92× + + Apple M3 Pro · Python 3.12.2 · median after warm-up over 7 repetitions · frozen JSON receipt + diff --git a/docs/product/DERIVATIVES_SYSTEM_HUB.md b/docs/product/DERIVATIVES_SYSTEM_HUB.md new file mode 100644 index 00000000..fda105f4 --- /dev/null +++ b/docs/product/DERIVATIVES_SYSTEM_HUB.md @@ -0,0 +1,177 @@ +# Derivatives Pricing and Risk System + +Status: **v0.4.0 public product contract and evidence hub**. + +## What v0.4.0 ships + +The release adds two production-shaped Black–Scholes European portfolio APIs: + +- `bs_portfolio_risk`: vectorized price, value, delta, gamma, vega, theta, and + rho with quantity-weighted portfolio totals; +- `bs_portfolio_scenarios`: exact five-factor stress repricing with compact + aggregate-only output or optional position attribution. + +The same release retains the established analytic, Monte Carlo/QMC, PDE, +Heston, SSVI, exotic-option, VaR/ES, C++/CMake, Python, and reproducible +artifact surfaces from v0.3.7. It does not replace those engines or inflate +their claim boundaries. + +The recorded Apple M3 Pro evaluator measured `20.18x` risk-batch speedup and +`27.92x` aggregate-scenario speedup, with independent QuantLib errors no worse +than `3.91e-14` for price, `3.40e-12` for Greeks, and `2.66e-13` for portfolio +scenario P&L. Exact zero-shock identity and 32/32 concurrent replay identity +also passed. These numbers remain bound to their host, workloads, and receipts. + +Download signed-off wheels, the source distribution, validation payload, and +machine-readable release manifest from the +[v0.4.0 GitHub release](https://github.com/MateoBodon/quant-pricer-cpp/releases/tag/v0.4.0). + +## API contract + +The selected surface accepts a contiguous `float64` position matrix with eight +columns: + +`option_type, quantity, spot, strike, rate, dividend, volatility, time` + +`option_type` is `1` for a call and `-1` for a put. A risk batch returns +position-level columns: + +`price, value, delta, gamma, vega, theta, rho` + +Portfolio-total columns are the quantity-weighted fields: + +`value, delta, gamma, vega, theta, rho` + +Position sensitivities are quantity-weighted except `price`. Vega is per 1.0 +absolute volatility and theta is the analytic Black-Scholes calendar-time +sensitivity already used by the library. + +A scenario matrix has five columns: + +`spot_return, volatility_shift, rate_shift, dividend_shift, time_elapsed` + +Each scenario performs exact repricing with `spot * (1 + spot_return)`, additive +rate/dividend/volatility shifts, and `max(time - time_elapsed, 0)`. It returns +scenario-level portfolio P&L and may optionally return the contiguous +scenario-major position P&L matrix. Inputs that are non-finite, have an unknown +option type, make spot/strike/volatility invalid, use negative time or elapsed +time, or would overflow an output allocation fail closed before pricing. + +## Evaluator + +The capability is promotable only if all of the following pass without changing +the frozen tolerances after seeing results: + +- **Independent correctness:** deterministic calm, skew, carry, near-expiry, + deep-ITM/OTM, long/short, call/put cases agree with QuantLib analytic European + prices and available Greeks to `1e-10` absolute or `1e-10` relative tolerance; + exact scenario P&L agrees with an independently constructed QuantLib repricer + to `1e-9` absolute tolerance. +- **Internal identities:** portfolio totals equal the exact sum of returned + position contributions; zero shocks produce exactly zero P&L; scenario order, + position order, and repeated/concurrent calls are deterministic. +- **Robustness:** invalid shapes, values, option types, post-shock states, and + oversized allocations fail before partial output is returned. Expiry behavior + agrees with intrinsic-value conventions. +- **Performance:** on the recorded host/toolchain, the native Python risk batch + is at least `10x` faster than equivalent existing scalar binding calls for a + fixed 100,000-position canary, and exact aggregate-only scenario repricing is + at least `10x` faster than scalar binding orchestration on a fixed workload. + Median of at least seven measured repetitions is used after warm-up. +- **Resources:** wall time, throughput, peak RSS, input/output sizes, CPU model, + OS, compiler, Python, NumPy, QuantLib, and package version are recorded in a + deterministic JSON receipt. Detailed scenario output must document its + `scenario_count * position_count * 8`-byte payload; aggregate-only mode must + avoid allocating that matrix. +- **Product surface:** public C++ declarations, installed Python bindings, + focused native tests, an independent Python/QuantLib test, a runnable example, + and concise README/limitations agree on units and shapes. +- **Regression:** focused tests, the relevant FAST suite, data-policy guard, and + an installed-wheel smoke test pass. Sanitizer coverage is required for the new + native core unless the current toolchain cannot support it, in which case the + exact limitation is recorded. + +## Claim boundary + +This is deterministic Black-Scholes valuation and stress infrastructure, not a +market-risk model validation, forecast, hedge-profit, P&L, or live-trading +claim. Scenario outputs are conditional on user-provided shocks and unchanged +model assumptions. Model risk, volatility-surface dynamics, early exercise, +barriers, counterparty exposure, and cross-asset correlation remain outside the +first contract. + +## Verified outcome + +| Evaluator | Frozen gate | Current evidence | Result | +|---|---:|---:|---| +| QuantLib price/Greek parity | abs or rel `<=1e-10` | worst price `3.91e-14`; worst Greek `3.40e-12` (theta) | pass | +| QuantLib exact scenario P&L | abs `<=1e-9` | position `2.06e-13`; portfolio `2.66e-13` | pass | +| Zero-shock identity | exact zero | exact zero | pass | +| Concurrent determinism | bitwise identical | 32/32 concurrent replays identical | pass | +| Risk-batch speedup | `>=10x` | `20.18x`; 20.25M positions/s | pass | +| Scenario speedup | `>=10x` | `27.92x`; 32.13M cells/s | pass | +| Installed API | exact v0.4.0 wheel | wheel import, API smoke, version identity | pass | +| Native sanitizers | ASan + UBSan | five focused tests pass | pass | + +The installed-wheel benchmark used an Apple M3 Pro, Apple clang 21.0.0, +Python 3.12.2, NumPy 2.5.0, and QuantLib 1.42.1. It measured medians after one +warm-up over seven repetitions. The risk workload contains 100,000 positions; +the scenario workload contains 20,000 positions by 16 shocks. Peak process RSS +was 121,831,424 bytes. Aggregate scenario output was 128 bytes; requesting full +attribution for that workload would create a 2,560,000-byte matrix. + +The first unfused risk implementation reached only `9.94x` and failed the +frozen `10x` gate. The promoted fused analytic path reuses `d1`, `d2`, discount, +and density terms across price and all six Greeks; the gate was not relaxed. + +Evidence: + +- [`portfolio_risk_quantlib_parity_v1.json`](../artifacts/portfolio_risk_quantlib_parity_v1.json) +- [`portfolio_risk_benchmark_v1.json`](../artifacts/portfolio_risk_benchmark_v1.json) +- [`portfolio_risk_release_v040.json`](../artifacts/portfolio_risk_release_v040.json) +- implementation commit `60c4e9daefbbe481b3002eaa6c1429b069ae79b3` + +## Before and after + +| Product surface | Before v0.4.0 | v0.4.0 | +|---|---|---| +| Portfolio valuation | user-maintained scalar Python loop | one native `(n,8)` batch | +| Portfolio Greeks | no aggregate API | value, delta, gamma, vega, theta, rho | +| Stress testing | no cross-position scenario API | five-factor exact repricing | +| Attribution memory | user-controlled/unbounded | explicit aggregate-only or detailed mode | +| Correctness proof | scalar BS tests | independent QuantLib portfolio and scenario parity | +| Packaging | v0.3.7 cross-platform wheel baseline | v0.4.0 wheels + build-complete sdist + installed smoke + release manifest | + +## Verification inherited by the release + +- Five focused native tests passed in Release and under AddressSanitizer plus + UndefinedBehaviorSanitizer. Apple leak detection is unsupported, so only + `detect_leaks` was disabled; ASan/UBSan stayed active. +- The independent Python evaluator passed 60 mixed position cases and 72 + scenario cells, four invalid-position cases, one invalid post-shock case, + exact aggregation, expiry behavior, and concurrent replay. +- The full FAST selection produced 89 passes, one existing RNG skip, and the + same two locked SSVI hedge failures. Those one-use tests reject any changed + `CMakeLists.txt`; the consumed hedge contract was not altered or reopened. +- The data-policy guard passed. No WRDS query, raw data, simulation, or cloud + work was needed to establish the deterministic v0.4.0 product evidence. +- The exact local wheel SHA-256 is + `bfc005727c385f8c7978e670cc0de6295f7540746040dbd1c92771602a6760d1`; + the build-complete sdist SHA-256 is + `ae4d41b9a3b283a7a401ca169f6702a9de5ea6ee9774433c499876b934296a3c`. + Those hashes cover the original macOS arm64 evaluator build. The GitHub + release's `release-manifest.json` separately binds every cross-platform wheel + and source distribution to the public tag commit. PyPI is not claimed. + +## Limitations + +- The engine is Black-Scholes European vanilla risk, not volatility-surface or + model-risk dynamics. +- Inputs use one spot per position; shared-underlier normalization/netting is a + caller concern in v0.4.0. +- Scenario shocks are deterministic and user supplied. They have no + probability, correlation, forecast, VaR, hedge, or economic-optimality claim. +- Detailed attribution is intentionally materialized as a dense matrix; large + users should prefer aggregate-only mode or chunk scenarios. +- Early exercise, barriers, path dependence, counterparty exposure, and XVA are + outside this first portfolio contract. diff --git a/docs/releases/v0.4.0.md b/docs/releases/v0.4.0.md new file mode 100644 index 00000000..44b30a66 --- /dev/null +++ b/docs/releases/v0.4.0.md @@ -0,0 +1,35 @@ +# v0.4.0 — Portfolio Risk and Exact Stress + +v0.4.0 turns quant-pricer-cpp's verified Black–Scholes primitives into a +production-shaped portfolio surface while preserving the v0.3.7 cross-platform +packaging baseline and the project's broader numerical evidence. + +## Highlights + +- `bs_portfolio_risk` returns position price/value/Greeks and quantity-weighted + value, delta, gamma, vega, theta, and rho totals from one native batch. +- `bs_portfolio_scenarios` exact-reprices spot, volatility, rate, dividend, and + elapsed-time shocks, with compact aggregate-only output by default. +- Independent QuantLib proof: worst price error `3.91e-14`, worst Greek error + `3.40e-12`, position scenario P&L error `2.06e-13`, and portfolio scenario P&L + error `2.66e-13`. +- Deterministic proof: exact zero-shock identity and 32/32 concurrent replays + bitwise identical. +- Recorded Apple M3 Pro performance: `20.18x` risk-batch speedup / `20.25M` + positions per second and `27.92x` aggregate-scenario speedup / `32.13M` cells + per second. + +The release assets include the supported Python wheel matrix, source +distribution, deterministic release manifest, committed artifact manifest, and +validation payload. PyPI availability is not claimed. + +## Boundary + +This is deterministic Black–Scholes European portfolio valuation and +user-supplied stress infrastructure. It is not trading alpha, a forecast, +probabilistic market-risk validation, volatility-surface dynamics, a hedge or +return result, or live P&L. + +Start with the [README](../../README.md), the runnable +[`portfolio_risk.py`](../../python/examples/portfolio_risk.py) example, and the +[product/evidence hub](../product/DERIVATIVES_SYSTEM_HUB.md). diff --git a/include/quant/portfolio.hpp b/include/quant/portfolio.hpp new file mode 100644 index 00000000..5069d330 --- /dev/null +++ b/include/quant/portfolio.hpp @@ -0,0 +1,72 @@ +/// Vectorized vanilla-option portfolio valuation and deterministic stress P&L. +#pragma once + +#include +#include + +namespace quant::portfolio { + +enum class OptionType : int { Put = -1, Call = 1 }; + +struct VanillaPosition { + OptionType type; + double quantity; + double spot; + double strike; + double rate; + double dividend; + double volatility; + double time; +}; + +struct PositionRisk { + double price; + double value; + double delta; + double gamma; + double vega; + double theta; + double rho; +}; + +struct PortfolioTotals { + double value{}; + double delta{}; + double gamma{}; + double vega{}; + double theta{}; + double rho{}; +}; + +struct RiskResult { + std::vector positions; + PortfolioTotals totals; +}; + +struct MarketShock { + double spot_return; + double volatility_shift; + double rate_shift; + double dividend_shift; + double time_elapsed; +}; + +struct ScenarioResult { + std::size_t scenario_count{}; + std::size_t position_count{}; + double base_portfolio_value{}; + std::vector portfolio_pnl; + // Scenario-major (scenario_count, position_count); empty in aggregate-only mode. + std::vector position_pnl; +}; + +/// Validate and value a non-empty portfolio. Throws std::invalid_argument on +/// non-finite or economically invalid inputs. +RiskResult price_risk(const std::vector& positions); + +/// Exact-reprice each position under each shock. When include_position_pnl is +/// false, the potentially large scenario-by-position matrix is not allocated. +ScenarioResult scenario_pnl(const std::vector& positions, + const std::vector& shocks, bool include_position_pnl = false); + +} // namespace quant::portfolio diff --git a/include/quant/version.hpp b/include/quant/version.hpp index 3c18dbd4..2bcf5406 100644 --- a/include/quant/version.hpp +++ b/include/quant/version.hpp @@ -8,9 +8,9 @@ namespace quant { /// Project semantic version components constexpr int kVersionMajor = 0; /// Minor version -constexpr int kVersionMinor = 3; +constexpr int kVersionMinor = 4; /// Patch version -constexpr int kVersionPatch = 7; +constexpr int kVersionPatch = 0; /// Return the semantic version string "major.minor.patch". inline std::string version_string() { diff --git a/project_state/CURRENT_RESULTS.md b/project_state/CURRENT_RESULTS.md index 899a9adc..99c91d21 100644 --- a/project_state/CURRENT_RESULTS.md +++ b/project_state/CURRENT_RESULTS.md @@ -36,10 +36,41 @@ Status overview (from `docs/artifacts/metrics_summary.md`): - Benchmarks: MC paths/sec (1t)=1.27500e+07, eff@max=0.953402. - WRDS: median iv_rmse=0.00120828 (sample bundle regression harness). +## Vectorized portfolio risk and exact stress (v0.4.0) + +The library now exposes a public C++ and installed Python Black-Scholes +portfolio engine: + +- `bs_portfolio_risk` returns position price/value/Greeks plus + quantity-weighted portfolio totals from a contiguous `(n,8)` matrix; +- `bs_portfolio_scenarios` exact-reprices five-factor scenario shocks and avoids + the dense scenario-by-position matrix in aggregate-only mode; +- 60 mixed call/put QuantLib cases and 72 scenario cells passed the frozen + independent evaluator. Worst absolute price error was `3.91e-14`, worst + Greek error was `3.40e-12`, and worst portfolio scenario P&L error was + `2.66e-13`; +- the final installed-wheel benchmark on Apple M3 Pro measured `20.18x` risk + and `27.92x` scenario speedups, `20.25M` positions/s and `32.13M` cells/s; +- exact v0.4.0 wheel and source-distribution hashes, installed smoke, sanitizer, + regression, resource, and limitation evidence are linked from + `docs/product/DERIVATIVES_SYSTEM_HUB.md`. + +These are deterministic Black-Scholes valuation/stress claims only. They do not +support a forecast, market-risk model, hedge, P&L, return, or trading claim. + ## Key artifact locations - Validation figures + CSVs: `docs/artifacts/` (tri-engine, QMC vs PRNG, PDE order, MC Greeks, Heston QE, etc.). - Manifest metadata: `docs/artifacts/manifest.json`. - Metrics snapshot: `docs/artifacts/metrics_summary.md` and `docs/artifacts/metrics_summary.json`. +- Real-data SSVI temporal confirmation: `docs/artifacts/ssvi_temporal_holdout_v1_summary.json`. +- Native C++ SSVI benchmark: `docs/artifacts/ssvi_cpp_benchmark_v1.json`. +- Aggregate OptionMetrics SSVI robustness canary: `docs/artifacts/optionmetrics_ssvi_robustness_canary_v1.json`. +- Multi-date aggregate OptionMetrics SSVI robustness: `docs/artifacts/optionmetrics_ssvi_multidate_robustness_v1.json`. +- SSVI calibration-stability monitor status: `docs/artifacts/ssvi_calibration_stability_status_v1.json`. +- Portfolio-risk product hub: `docs/product/DERIVATIVES_SYSTEM_HUB.md`. +- Independent portfolio/QuantLib parity: `docs/artifacts/portfolio_risk_quantlib_parity_v1.json`. +- Installed-wheel performance/resource receipt: `docs/artifacts/portfolio_risk_benchmark_v1.json`. +- v0.4.0 wheel/sdist release receipt: `docs/artifacts/portfolio_risk_release_v040.json`. - WRDS aggregated outputs (if present): `docs/artifacts/wrds/`. - Validation bundle: `docs/validation_pack.zip`. diff --git a/pyproject.toml b/pyproject.toml index 180664af..c2f6664c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "scikit_build_core.build" [project] name = "pyquant-pricer" -version = "0.3.7" -description = "Python bindings for quant-pricer-cpp: Black–Scholes, MC, PDE, Heston" +version = "0.4.0" +description = "Production-grade derivatives pricing, portfolio risk, and stress APIs in C++ and Python" readme = "README.md" authors = [{ name = "Mateo Bodon" }] license = { file = "LICENSE" } diff --git a/python/examples/portfolio_risk.py b/python/examples/portfolio_risk.py new file mode 100644 index 00000000..bf11787e --- /dev/null +++ b/python/examples/portfolio_risk.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Vectorized vanilla portfolio risk and exact stress example.""" + +from __future__ import annotations + +import numpy as np +import pyquant_pricer as qp + +# option_type, quantity, spot, strike, rate, dividend, volatility, time +positions = np.array( + [ + [1, 120, 100, 95, 0.03, 0.01, 0.22, 90 / 365], + [-1, -80, 100, 105, 0.03, 0.01, 0.25, 90 / 365], + [1, 50, 100, 110, 0.03, 0.01, 0.28, 180 / 365], + ], + dtype=np.float64, +) + +risk = qp.bs_portfolio_risk(positions) +print(dict(zip(risk["total_columns"], risk["portfolio_totals"]))) + +# spot_return, volatility_shift, rate_shift, dividend_shift, time_elapsed +shocks = np.array( + [ + [0.00, 0.00, 0.000, 0.000, 0 / 365], + [-0.10, 0.08, 0.010, 0.000, 1 / 365], + [0.08, -0.03, -0.005, 0.002, 5 / 365], + ], + dtype=np.float64, +) + +aggregate = qp.bs_portfolio_scenarios(positions, shocks, detail=False) +print("scenario P&L:", aggregate["portfolio_pnl"]) + +# Request detail only when position attribution is needed. Its payload is +# scenario_count * position_count * 8 bytes. +detail = qp.bs_portfolio_scenarios(positions, shocks, detail=True) +print("position P&L attribution:\n", detail["position_pnl"]) diff --git a/python/examples/quickstart.py b/python/examples/quickstart.py index cf6be081..8b5df160 100644 --- a/python/examples/quickstart.py +++ b/python/examples/quickstart.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import importlib.util import subprocess import sys from pathlib import Path @@ -81,7 +82,38 @@ def price_heston_batch() -> None: print(f"Heston analytic batch: [call_price, implied_vol] {metrics.tolist()}") +def portfolio_risk_and_stress() -> None: + """Value and stress a mixed long/short call-put portfolio.""" + positions = np.array( + [ + [1, 120, 100, 95, 0.03, 0.01, 0.22, 90 / 365], + [-1, -80, 100, 105, 0.03, 0.01, 0.25, 90 / 365], + [1, 50, 100, 110, 0.03, 0.01, 0.28, 180 / 365], + ], + dtype=np.float64, + ) + risk = qp.bs_portfolio_risk(positions) + totals = dict(zip(risk["total_columns"], risk["portfolio_totals"])) + shocks = np.array( + [[0, 0, 0, 0, 0], [-0.10, 0.08, 0.01, 0, 1 / 365]], + dtype=np.float64, + ) + pnl = qp.bs_portfolio_scenarios(positions, shocks, detail=False)["portfolio_pnl"] + print(f"Portfolio risk: {totals}") + print(f"Exact scenario P&L: {pnl.tolist()}") + + def maybe_run_heston(repo_root: Path) -> None: + optional_modules = ("matplotlib", "pandas", "scipy") + missing = [ + name for name in optional_modules if importlib.util.find_spec(name) is None + ] + if missing: + print( + "Optional Heston calibration dependencies are unavailable " + f"({', '.join(missing)}); skipping calibration demo." + ) + return samples_dir = repo_root / "data" / "samples" normalized_dir = repo_root / "data" / "normalized" candidates = list(samples_dir.glob("spx_*.csv")) + list( @@ -114,6 +146,7 @@ def main() -> None: price_barrier() heston_helpers() price_heston_batch() + portfolio_risk_and_stress() maybe_run_heston(repo_root) diff --git a/python/pybind_module.cpp b/python/pybind_module.cpp index f1b8877d..ff1ad215 100644 --- a/python/pybind_module.cpp +++ b/python/pybind_module.cpp @@ -12,6 +12,7 @@ #include "quant/multi.hpp" #include "quant/pde.hpp" #include "quant/pde_barrier.hpp" +#include "quant/portfolio.hpp" #include "quant/risk.hpp" #include "quant/version.hpp" #include @@ -231,6 +232,109 @@ heston_call_metrics_grid(const py::array_t +parse_portfolio_positions(const py::array_t& positions) { + if (positions.ndim() != 2 || positions.shape(1) != 8) { + throw std::invalid_argument("positions must have shape (n, 8): option_type, quantity, spot, strike, " + "rate, dividend, volatility, time"); + } + if (positions.shape(0) == 0) { + throw std::invalid_argument("positions must be non-empty"); + } + std::vector parsed; + parsed.reserve(static_cast(positions.shape(0))); + const double* data = positions.data(); + for (py::ssize_t index = 0; index < positions.shape(0); ++index) { + const double* row = data + index * 8; + quant::portfolio::OptionType type; + if (row[0] == 1.0) { + type = quant::portfolio::OptionType::Call; + } else if (row[0] == -1.0) { + type = quant::portfolio::OptionType::Put; + } else { + throw std::invalid_argument("portfolio option_type must be exactly 1 (call) or -1 (put)"); + } + parsed.push_back({type, row[1], row[2], row[3], row[4], row[5], row[6], row[7]}); + } + return parsed; +} + +std::vector +parse_portfolio_shocks(const py::array_t& shocks) { + if (shocks.ndim() != 2 || shocks.shape(1) != 5) { + throw std::invalid_argument("shocks must have shape (m, 5): spot_return, volatility_shift, " + "rate_shift, dividend_shift, time_elapsed"); + } + if (shocks.shape(0) == 0) { + throw std::invalid_argument("shocks must be non-empty"); + } + std::vector parsed; + parsed.reserve(static_cast(shocks.shape(0))); + const double* data = shocks.data(); + for (py::ssize_t index = 0; index < shocks.shape(0); ++index) { + const double* row = data + index * 5; + parsed.push_back({row[0], row[1], row[2], row[3], row[4]}); + } + return parsed; +} + +py::dict +portfolio_risk_batch(const py::array_t& positions) { + const auto parsed = parse_portfolio_positions(positions); + quant::portfolio::RiskResult result; + { + py::gil_scoped_release release; + result = quant::portfolio::price_risk(parsed); + } + py::array_t position_metrics( + py::array::ShapeContainer{static_cast(result.positions.size()), py::ssize_t{7}}); + double* position_data = position_metrics.mutable_data(); + for (std::size_t index = 0; index < result.positions.size(); ++index) { + const auto& risk = result.positions[index]; + const double row[7]{risk.price, risk.value, risk.delta, risk.gamma, risk.vega, risk.theta, risk.rho}; + std::copy(row, row + 7, position_data + 7 * index); + } + py::array_t totals(py::array::ShapeContainer{py::ssize_t{6}}); + const double values[6]{result.totals.value, result.totals.delta, result.totals.gamma, + result.totals.vega, result.totals.theta, result.totals.rho}; + std::copy(values, values + 6, totals.mutable_data()); + py::dict output; + output["position_metrics"] = std::move(position_metrics); + output["portfolio_totals"] = std::move(totals); + output["position_columns"] = py::make_tuple("price", "value", "delta", "gamma", "vega", "theta", "rho"); + output["total_columns"] = py::make_tuple("value", "delta", "gamma", "vega", "theta", "rho"); + return output; +} + +py::dict +portfolio_scenario_pnl(const py::array_t& positions, + const py::array_t& shocks, + bool detail) { + const auto parsed_positions = parse_portfolio_positions(positions); + const auto parsed_shocks = parse_portfolio_shocks(shocks); + quant::portfolio::ScenarioResult result; + { + py::gil_scoped_release release; + result = quant::portfolio::scenario_pnl(parsed_positions, parsed_shocks, detail); + } + py::array_t portfolio_pnl( + py::array::ShapeContainer{static_cast(result.scenario_count)}); + std::copy(result.portfolio_pnl.begin(), result.portfolio_pnl.end(), portfolio_pnl.mutable_data()); + py::dict output; + output["base_portfolio_value"] = result.base_portfolio_value; + output["portfolio_pnl"] = std::move(portfolio_pnl); + if (detail) { + py::array_t position_pnl( + py::array::ShapeContainer{static_cast(result.scenario_count), + static_cast(result.position_count)}); + std::copy(result.position_pnl.begin(), result.position_pnl.end(), position_pnl.mutable_data()); + output["position_pnl"] = std::move(position_pnl); + } else { + output["position_pnl"] = py::none(); + } + return output; +} + } // namespace PYBIND11_MODULE(pyquant_pricer, m) { @@ -526,6 +630,15 @@ PYBIND11_MODULE(pyquant_pricer, m) { py::arg("horizon_years"), py::arg("position"), py::arg("num_sims"), py::arg("seed"), py::arg("alpha")); + // Vectorized vanilla portfolio valuation and exact-repricing scenarios. + m.def("bs_portfolio_risk", &portfolio_risk_batch, py::arg("positions"), + "Return position metrics and quantity-weighted portfolio Black-Scholes risk totals for an (n,8) " + "matrix."); + m.def("bs_portfolio_scenarios", &portfolio_scenario_pnl, py::arg("positions"), py::arg("shocks"), + py::arg("detail") = false, + "Exact-reprice an (n,8) vanilla portfolio under an (m,5) shock matrix; detail=False avoids the m*n " + "output."); + // Multi-asset & jumps py::class_(m, "BasketMcParams") .def(py::init<>()) diff --git a/python/scripts/cibw_smoke.py b/python/scripts/cibw_smoke.py index 5f41c391..b33ea41f 100644 --- a/python/scripts/cibw_smoke.py +++ b/python/scripts/cibw_smoke.py @@ -2,8 +2,8 @@ """ Minimal runtime smoke test executed inside cibuildwheel. -Ensures the pyquant_pricer wheel imports, exercises BS pricing, -and touches the Heston helpers (analytic IV + characteristic fn). +Ensures the pyquant_pricer wheel imports and exercises Black–Scholes, +portfolio risk/stress, and Heston analytic helpers. """ from __future__ import annotations @@ -64,6 +64,22 @@ def main() -> None: else: raise AssertionError("mismatched Heston batch inputs must fail closed") + positions = np.array( + [ + [1.0, 2.0, 100.0, 105.0, 0.02, 0.01, 0.25, 0.5], + [-1.0, -1.0, 100.0, 95.0, 0.02, 0.01, 0.30, 0.75], + ], + dtype=np.float64, + ) + risk = qp.bs_portfolio_risk(positions) + assert risk["position_metrics"].shape == (2, 7) + assert risk["portfolio_totals"].shape == (6,) + shocks = np.array([[0.0, 0.0, 0.0, 0.0, 0.0], [-0.1, 0.05, 0.01, 0.0, 1.0 / 365.0]]) + scenario = qp.bs_portfolio_scenarios(positions, shocks, detail=False) + assert scenario["position_pnl"] is None + assert scenario["portfolio_pnl"].shape == (2,) + assert scenario["portfolio_pnl"][0] == 0.0 + if __name__ == "__main__": main() diff --git a/python/scripts/validate_sdist.py b/python/scripts/validate_sdist.py index 19c34197..09bd9c5c 100644 --- a/python/scripts/validate_sdist.py +++ b/python/scripts/validate_sdist.py @@ -14,7 +14,9 @@ "python/pybind_module.cpp", "python/scripts/cibw_test_suite.py", "include/quant/heston.hpp", + "include/quant/portfolio.hpp", "src/heston.cpp", + "src/portfolio.cpp", "external/pcg/include/pcg_random.hpp", ) FORBIDDEN_PREFIXES = ( diff --git a/scripts/benchmark_portfolio_risk.py b/scripts/benchmark_portfolio_risk.py new file mode 100644 index 00000000..c5136c05 --- /dev/null +++ b/scripts/benchmark_portfolio_risk.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Deterministic before/after benchmark for the native portfolio-risk surface.""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import resource +import statistics +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--module-dir", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--repetitions", type=int, default=7) + return parser.parse_args() + + +def command_output(argv: list[str]) -> str: + try: + return ( + subprocess.run(argv, check=True, capture_output=True, text=True) + .stdout.strip() + .splitlines()[0] + ) + except (OSError, subprocess.CalledProcessError, IndexError): + return "unavailable" + + +def timed(repetitions: int, fn) -> tuple[float, list[float]]: + fn() + samples: list[float] = [] + for _ in range(repetitions): + started = time.perf_counter_ns() + fn() + samples.append((time.perf_counter_ns() - started) / 1e9) + return statistics.median(samples), samples + + +def make_positions(count: int) -> np.ndarray: + index = np.arange(count, dtype=np.float64) + positions = np.empty((count, 8), dtype=np.float64) + positions[:, 0] = np.where((index.astype(np.int64) & 1) == 0, 1.0, -1.0) + positions[:, 1] = np.where((index.astype(np.int64) % 3) == 0, -2.0, 1.5) + positions[:, 2] = 80.0 + np.mod(index * 0.37, 50.0) + positions[:, 3] = 75.0 + np.mod(index * 0.53, 60.0) + positions[:, 4] = -0.002 + np.mod(index.astype(np.int64), 7) * 0.01 + positions[:, 5] = np.mod(index.astype(np.int64), 5) * 0.005 + positions[:, 6] = 0.10 + np.mod(index.astype(np.int64), 9) * 0.045 + positions[:, 7] = (7.0 + np.mod(index.astype(np.int64), 720)) / 365.0 + return positions + + +def scalar_risk_baseline(qp, positions: np.ndarray) -> float: + # Existing installed surface exposes call price, delta, gamma, and vega as + # scalar calls. The native candidate computes these plus theta/rho and puts. + total = 0.0 + for row in positions: + _, quantity, spot, strike, rate, dividend, volatility, time_to_expiry = row + total += quantity * qp.bs_call( + spot, strike, rate, dividend, volatility, time_to_expiry + ) + total += quantity * qp.bs_delta_call( + spot, strike, rate, dividend, volatility, time_to_expiry + ) + total += quantity * qp.bs_gamma( + spot, strike, rate, dividend, volatility, time_to_expiry + ) + total += quantity * qp.bs_vega( + spot, strike, rate, dividend, volatility, time_to_expiry + ) + return total + + +def scalar_scenario_baseline( + qp, positions: np.ndarray, shocks: np.ndarray +) -> np.ndarray: + base: list[float] = [] + for row in positions: + ( + option_type, + quantity, + spot, + strike, + rate, + dividend, + volatility, + time_to_expiry, + ) = row + pricer = qp.bs_call if option_type == 1.0 else qp.bs_put + base.append( + quantity * pricer(spot, strike, rate, dividend, volatility, time_to_expiry) + ) + output = np.empty(len(shocks), dtype=np.float64) + for shock_index, shock in enumerate(shocks): + spot_return, vol_shift, rate_shift, dividend_shift, time_elapsed = shock + total = 0.0 + for position_index, row in enumerate(positions): + ( + option_type, + quantity, + spot, + strike, + rate, + dividend, + volatility, + time_to_expiry, + ) = row + pricer = qp.bs_call if option_type == 1.0 else qp.bs_put + shocked_price = pricer( + spot * (1.0 + spot_return), + strike, + rate + rate_shift, + dividend + dividend_shift, + volatility + vol_shift, + max(0.0, time_to_expiry - time_elapsed), + ) + total += quantity * shocked_price - base[position_index] + output[shock_index] = total + return output + + +def main() -> int: + args = parse_args() + if args.repetitions < 7: + raise SystemExit("at least seven repetitions are required") + sys.path.insert(0, str(args.module_dir.resolve())) + import pyquant_pricer as qp + import QuantLib as ql + + risk_positions = make_positions(100_000) + # Scalar baseline is call-only because the incumbent exposes no scalar put + # delta/theta/rho surface. Native still computes the full call risk vector. + scalar_risk_positions = risk_positions[:100_000].copy() + scalar_risk_positions[:, 0] = 1.0 + scenario_positions = make_positions(20_000) + shocks = np.asarray( + [ + [ + -0.30 + 0.04 * i, + 0.14 - 0.015 * (i % 7), + -0.02 + 0.004 * (i % 9), + -0.006 + 0.002 * (i % 6), + (i % 8) / 365.0, + ] + for i in range(16) + ], + dtype=np.float64, + ) + + risk_native_median, risk_native_samples = timed( + args.repetitions, lambda: qp.bs_portfolio_risk(scalar_risk_positions) + ) + risk_scalar_median, risk_scalar_samples = timed( + args.repetitions, lambda: scalar_risk_baseline(qp, scalar_risk_positions) + ) + scenario_native_median, scenario_native_samples = timed( + args.repetitions, + lambda: qp.bs_portfolio_scenarios(scenario_positions, shocks, False), + ) + scenario_scalar_median, scenario_scalar_samples = timed( + args.repetitions, + lambda: scalar_scenario_baseline(qp, scenario_positions, shocks), + ) + + native_scenarios = qp.bs_portfolio_scenarios(scenario_positions, shocks, False)[ + "portfolio_pnl" + ] + scalar_scenarios = scalar_scenario_baseline(qp, scenario_positions, shocks) + np.testing.assert_allclose( + native_scenarios, scalar_scenarios, rtol=1e-12, atol=1e-9 + ) + deterministic = [ + qp.bs_portfolio_scenarios(scenario_positions, shocks, False)["portfolio_pnl"] + for _ in range(5) + ] + for repeated in deterministic: + np.testing.assert_array_equal(repeated, native_scenarios) + + risk_speedup = risk_scalar_median / risk_native_median + scenario_speedup = scenario_scalar_median / scenario_native_median + if risk_speedup < 10.0 or scenario_speedup < 10.0: + raise AssertionError( + f"frozen performance gate failed: risk={risk_speedup:.3f}x scenario={scenario_speedup:.3f}x" + ) + + peak_rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + if platform.system() != "Darwin": + peak_rss *= 1024 + repo_root = Path(__file__).resolve().parents[1] + receipt = { + "schema_version": 1, + "benchmark_id": "bs_portfolio_risk_v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "protocol": { + "repetitions": args.repetitions, + "statistic": "median_after_one_warmup", + "risk_positions": len(scalar_risk_positions), + "scenario_positions": len(scenario_positions), + "scenario_count": len(shocks), + "performance_gate_speedup": 10.0, + "seed_or_randomness": "none; formula-generated deterministic matrices", + }, + "results": { + "risk_native_median_seconds": risk_native_median, + "risk_scalar_median_seconds": risk_scalar_median, + "risk_speedup": risk_speedup, + "risk_native_positions_per_second": len(scalar_risk_positions) + / risk_native_median, + "scenario_native_median_seconds": scenario_native_median, + "scenario_scalar_median_seconds": scenario_scalar_median, + "scenario_speedup": scenario_speedup, + "scenario_native_cells_per_second": len(scenario_positions) + * len(shocks) + / scenario_native_median, + "risk_native_samples_seconds": risk_native_samples, + "risk_scalar_samples_seconds": risk_scalar_samples, + "scenario_native_samples_seconds": scenario_native_samples, + "scenario_scalar_samples_seconds": scenario_scalar_samples, + "deterministic_repetitions": 5, + "scalar_parity_max_abs_pnl": float( + np.max(np.abs(native_scenarios - scalar_scenarios)) + ), + }, + "resources": { + "peak_process_rss_bytes": int(peak_rss), + "risk_input_bytes": int(scalar_risk_positions.nbytes), + "risk_output_bytes": int(len(scalar_risk_positions) * 7 * 8 + 6 * 8), + "scenario_input_bytes": int(scenario_positions.nbytes + shocks.nbytes), + "scenario_aggregate_output_bytes": int(len(shocks) * 8), + "scenario_detail_output_bytes_if_requested": int( + len(shocks) * len(scenario_positions) * 8 + ), + }, + "environment": { + "platform": platform.platform(), + "machine": platform.machine(), + "cpu": command_output(["sysctl", "-n", "machdep.cpu.brand_string"]), + "logical_cpus": os.cpu_count(), + "memory_bytes": command_output(["sysctl", "-n", "hw.memsize"]), + "compiler": command_output(["c++", "--version"]), + "python": platform.python_version(), + "numpy": np.__version__, + "quantlib": ql.__version__, + "pyquant_pricer": qp.__version__, + "git_head": command_output( + ["git", "-C", str(repo_root), "rev-parse", "HEAD"] + ), + }, + "claim_boundary": ( + "Hardware/protocol-specific Python orchestration comparison; deterministic Black-Scholes pricing and " + "conditional stress only, not a market-risk, hedge, PnL, or trading claim." + ), + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + print( + json.dumps( + { + "risk_speedup": risk_speedup, + "scenario_speedup": scenario_speedup, + "output": str(args.output), + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.cfg b/setup.cfg index 2a773964..dfdc62b2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pyquant-pricer -version = 0.3.7 +version = 0.4.0 description = Python bindings for quant-pricer-cpp long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/portfolio.cpp b/src/portfolio.cpp new file mode 100644 index 00000000..1fcef4ff --- /dev/null +++ b/src/portfolio.cpp @@ -0,0 +1,214 @@ +#include "quant/portfolio.hpp" + +#include "quant/black_scholes.hpp" + +#include +#include +#include +#include + +namespace quant::portfolio { +namespace { + +void validate_position(const VanillaPosition& position) { + const bool finite = std::isfinite(position.quantity) && std::isfinite(position.spot) && + std::isfinite(position.strike) && std::isfinite(position.rate) && + std::isfinite(position.dividend) && std::isfinite(position.volatility) && + std::isfinite(position.time); + if (!finite) { + throw std::invalid_argument("portfolio position contains a non-finite value"); + } + if (position.type != OptionType::Call && position.type != OptionType::Put) { + throw std::invalid_argument("portfolio option type must be Call or Put"); + } + if (position.spot <= 0.0 || position.strike <= 0.0 || position.volatility < 0.0 || position.time < 0.0) { + throw std::invalid_argument( + "portfolio position requires positive spot/strike and non-negative vol/time"); + } +} + +double option_price(const VanillaPosition& position) { + if (position.type == OptionType::Call) { + return quant::bs::call_price(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time); + } + return quant::bs::put_price(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time); +} + +PositionRisk position_risk(const VanillaPosition& position) { + const bool call = position.type == OptionType::Call; + double price; + double delta; + double gamma; + double vega; + double theta; + double rho; + if (position.time <= 0.0 || position.volatility <= 0.0) { + // Preserve the scalar library's explicit expiry/deterministic conventions. + price = option_price(position); + delta = call ? quant::bs::delta_call(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time) + : quant::bs::delta_put(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time); + gamma = quant::bs::gamma(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time); + vega = quant::bs::vega(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time); + theta = call ? quant::bs::theta_call(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time) + : quant::bs::theta_put(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time); + rho = call ? quant::bs::rho_call(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time) + : quant::bs::rho_put(position.spot, position.strike, position.rate, position.dividend, + position.volatility, position.time); + } else { + // Fused analytic path: every price/Greek shares one d1/d2 and discount calculation. + const double sqrt_time = std::sqrt(position.time); + const double d1 = + (std::log(position.spot / position.strike) + + (position.rate - position.dividend + 0.5 * position.volatility * position.volatility) * + position.time) / + (position.volatility * sqrt_time); + const double d2 = d1 - position.volatility * sqrt_time; + const double discount_rate = std::exp(-position.rate * position.time); + const double discount_dividend = std::exp(-position.dividend * position.time); + const double density = quant::bs::normal_pdf(d1); + const double carry_theta = + -(position.spot * discount_dividend * density * position.volatility) / (2.0 * sqrt_time); + gamma = discount_dividend * density / (position.spot * position.volatility * sqrt_time); + vega = position.spot * discount_dividend * density * sqrt_time; + if (call) { + const double cdf_d1 = quant::bs::normal_cdf(d1); + const double cdf_d2 = quant::bs::normal_cdf(d2); + price = position.spot * discount_dividend * cdf_d1 - position.strike * discount_rate * cdf_d2; + delta = discount_dividend * cdf_d1; + theta = carry_theta + position.dividend * position.spot * discount_dividend * cdf_d1 - + position.rate * position.strike * discount_rate * cdf_d2; + rho = position.strike * position.time * discount_rate * cdf_d2; + } else { + const double cdf_minus_d1 = quant::bs::normal_cdf(-d1); + const double cdf_minus_d2 = quant::bs::normal_cdf(-d2); + price = position.strike * discount_rate * cdf_minus_d2 - + position.spot * discount_dividend * cdf_minus_d1; + delta = -discount_dividend * cdf_minus_d1; + theta = carry_theta - position.dividend * position.spot * discount_dividend * cdf_minus_d1 + + position.rate * position.strike * discount_rate * cdf_minus_d2; + rho = -position.strike * position.time * discount_rate * cdf_minus_d2; + } + } + const double quantity = position.quantity; + return PositionRisk{price, quantity * price, quantity * delta, quantity * gamma, + quantity * vega, quantity * theta, quantity * rho}; +} + +void validate_shock(const MarketShock& shock) { + const bool finite = std::isfinite(shock.spot_return) && std::isfinite(shock.volatility_shift) && + std::isfinite(shock.rate_shift) && std::isfinite(shock.dividend_shift) && + std::isfinite(shock.time_elapsed); + if (!finite) { + throw std::invalid_argument("portfolio shock contains a non-finite value"); + } + if (shock.spot_return <= -1.0 || shock.time_elapsed < 0.0) { + throw std::invalid_argument( + "portfolio shock requires spot_return > -1 and non-negative time_elapsed"); + } +} + +} // namespace + +RiskResult price_risk(const std::vector& positions) { + if (positions.empty()) { + throw std::invalid_argument("portfolio positions must be non-empty"); + } + RiskResult result; + result.positions.reserve(positions.size()); + for (const auto& position : positions) { + validate_position(position); + const auto risk = position_risk(position); + result.positions.push_back(risk); + result.totals.value += risk.value; + result.totals.delta += risk.delta; + result.totals.gamma += risk.gamma; + result.totals.vega += risk.vega; + result.totals.theta += risk.theta; + result.totals.rho += risk.rho; + } + return result; +} + +ScenarioResult scenario_pnl(const std::vector& positions, + const std::vector& shocks, bool include_position_pnl) { + if (positions.empty() || shocks.empty()) { + throw std::invalid_argument("portfolio positions and shocks must be non-empty"); + } + for (const auto& position : positions) { + validate_position(position); + } + for (const auto& shock : shocks) { + validate_shock(shock); + for (const auto& position : positions) { + if (position.volatility + shock.volatility_shift < 0.0) { + throw std::invalid_argument("portfolio shock produces negative volatility"); + } + } + } + + const std::size_t scenario_count = shocks.size(); + const std::size_t position_count = positions.size(); + if (include_position_pnl && scenario_count > std::vector().max_size() / position_count) { + throw std::overflow_error("portfolio scenario detail matrix is too large"); + } + + std::vector base_values; + base_values.reserve(position_count); + double base_portfolio_value = 0.0; + for (const auto& position : positions) { + const double value = position.quantity * option_price(position); + base_values.push_back(value); + base_portfolio_value += value; + } + + ScenarioResult result; + result.scenario_count = scenario_count; + result.position_count = position_count; + result.base_portfolio_value = base_portfolio_value; + result.portfolio_pnl.resize(scenario_count); + if (include_position_pnl) { + result.position_pnl.resize(scenario_count * position_count); + } + + for (std::size_t scenario_index = 0; scenario_index < scenario_count; ++scenario_index) { + const auto& shock = shocks[scenario_index]; + const bool identity_shock = shock.spot_return == 0.0 && shock.volatility_shift == 0.0 && + shock.rate_shift == 0.0 && shock.dividend_shift == 0.0 && + shock.time_elapsed == 0.0; + if (identity_shock) { + result.portfolio_pnl[scenario_index] = 0.0; + if (include_position_pnl) { + std::fill_n(result.position_pnl.data() + scenario_index * position_count, position_count, + 0.0); + } + continue; + } + double total_pnl = 0.0; + for (std::size_t position_index = 0; position_index < position_count; ++position_index) { + auto shocked = positions[position_index]; + shocked.spot *= 1.0 + shock.spot_return; + shocked.volatility += shock.volatility_shift; + shocked.rate += shock.rate_shift; + shocked.dividend += shock.dividend_shift; + shocked.time = std::max(0.0, shocked.time - shock.time_elapsed); + const double pnl = shocked.quantity * option_price(shocked) - base_values[position_index]; + total_pnl += pnl; + if (include_position_pnl) { + result.position_pnl[scenario_index * position_count + position_index] = pnl; + } + } + result.portfolio_pnl[scenario_index] = total_pnl; + } + return result; +} + +} // namespace quant::portfolio diff --git a/tests/test_portfolio.cpp b/tests/test_portfolio.cpp new file mode 100644 index 00000000..7a7d7c42 --- /dev/null +++ b/tests/test_portfolio.cpp @@ -0,0 +1,99 @@ +#include + +#include "quant/black_scholes.hpp" +#include "quant/portfolio.hpp" + +#include +#include +#include + +using quant::portfolio::MarketShock; +using quant::portfolio::OptionType; +using quant::portfolio::VanillaPosition; + +namespace { +VanillaPosition call(double quantity = 2.0) { + return {OptionType::Call, quantity, 100.0, 105.0, 0.03, 0.01, 0.24, 0.75}; +} + +VanillaPosition put(double quantity = -1.5) { + return {OptionType::Put, quantity, 92.0, 100.0, 0.02, 0.005, 0.31, 0.40}; +} +} // namespace + +TEST(PortfolioRisk, PositionAndPortfolioValuesMatchScalarAnalytics) { + const std::vector positions{call(), put()}; + const auto result = quant::portfolio::price_risk(positions); + ASSERT_EQ(result.positions.size(), positions.size()); + + const double call_price = quant::bs::call_price(100.0, 105.0, 0.03, 0.01, 0.24, 0.75); + const double put_price = quant::bs::put_price(92.0, 100.0, 0.02, 0.005, 0.31, 0.40); + EXPECT_DOUBLE_EQ(result.positions[0].price, call_price); + EXPECT_DOUBLE_EQ(result.positions[0].value, 2.0 * call_price); + EXPECT_DOUBLE_EQ(result.positions[1].price, put_price); + EXPECT_DOUBLE_EQ(result.positions[1].value, -1.5 * put_price); + EXPECT_DOUBLE_EQ(result.totals.value, result.positions[0].value + result.positions[1].value); + EXPECT_DOUBLE_EQ(result.totals.delta, result.positions[0].delta + result.positions[1].delta); + EXPECT_DOUBLE_EQ(result.totals.gamma, result.positions[0].gamma + result.positions[1].gamma); + EXPECT_DOUBLE_EQ(result.totals.vega, result.positions[0].vega + result.positions[1].vega); + EXPECT_DOUBLE_EQ(result.totals.theta, result.positions[0].theta + result.positions[1].theta); + EXPECT_DOUBLE_EQ(result.totals.rho, result.positions[0].rho + result.positions[1].rho); +} + +TEST(PortfolioRisk, ZeroShockIsExactlyZeroAndDetailSumsInOrder) { + const std::vector positions{call(), put(), call(-0.25)}; + const std::vector shocks{{0.0, 0.0, 0.0, 0.0, 0.0}, + {-0.12, 0.08, 0.01, -0.002, 5.0 / 365.0}}; + const auto result = quant::portfolio::scenario_pnl(positions, shocks, true); + ASSERT_EQ(result.portfolio_pnl.size(), 2U); + ASSERT_EQ(result.position_pnl.size(), 6U); + EXPECT_DOUBLE_EQ(result.portfolio_pnl[0], 0.0); + for (std::size_t i = 0; i < positions.size(); ++i) { + EXPECT_DOUBLE_EQ(result.position_pnl[i], 0.0); + } + double detail_sum = 0.0; + for (std::size_t i = 0; i < positions.size(); ++i) { + detail_sum += result.position_pnl[positions.size() + i]; + } + EXPECT_DOUBLE_EQ(result.portfolio_pnl[1], detail_sum); +} + +TEST(PortfolioRisk, AggregateOnlyAvoidsDetailAllocationAndIsDeterministic) { + const std::vector positions{call(), put()}; + const std::vector shocks{{0.05, -0.02, 0.005, 0.001, 1.0 / 365.0}}; + const auto first = quant::portfolio::scenario_pnl(positions, shocks, false); + const auto second = quant::portfolio::scenario_pnl(positions, shocks, false); + EXPECT_TRUE(first.position_pnl.empty()); + EXPECT_EQ(first.portfolio_pnl, second.portfolio_pnl); + EXPECT_DOUBLE_EQ(first.base_portfolio_value, second.base_portfolio_value); +} + +TEST(PortfolioRisk, ExpiryUsesIntrinsicValue) { + auto expired = call(3.0); + expired.spot = 110.0; + expired.strike = 100.0; + expired.time = 0.0; + const auto result = quant::portfolio::price_risk({expired}); + EXPECT_DOUBLE_EQ(result.positions[0].price, 10.0); + EXPECT_DOUBLE_EQ(result.totals.value, 30.0); +} + +TEST(PortfolioRisk, InvalidInputsFailClosed) { + auto invalid = call(); + invalid.spot = 0.0; + EXPECT_THROW(quant::portfolio::price_risk({invalid}), std::invalid_argument); + invalid = call(); + invalid.quantity = std::numeric_limits::quiet_NaN(); + EXPECT_THROW(quant::portfolio::price_risk({invalid}), std::invalid_argument); + invalid = call(); + invalid.type = static_cast(0); + EXPECT_THROW(quant::portfolio::price_risk({invalid}), std::invalid_argument); + EXPECT_THROW(quant::portfolio::price_risk({}), std::invalid_argument); + EXPECT_THROW(quant::portfolio::scenario_pnl({call()}, {}, false), std::invalid_argument); + EXPECT_THROW(quant::portfolio::scenario_pnl({call()}, {{-1.0, 0.0, 0.0, 0.0, 0.0}}, false), + std::invalid_argument); + EXPECT_THROW(quant::portfolio::scenario_pnl({call()}, {{0.0, -0.25, 0.0, 0.0, 0.0}}, false), + std::invalid_argument); + EXPECT_THROW(quant::portfolio::scenario_pnl({call()}, {{0.0, 0.0, 0.0, 0.0, -0.01}}, false), + std::invalid_argument); +} diff --git a/tests/test_python_heston_batch_docs_fast.py b/tests/test_python_heston_batch_docs_fast.py index 24125b58..7a4f862a 100644 --- a/tests/test_python_heston_batch_docs_fast.py +++ b/tests/test_python_heston_batch_docs_fast.py @@ -8,6 +8,7 @@ import io import unittest from pathlib import Path +from unittest import mock import numpy as np import pyquant_pricer as qp @@ -37,6 +38,21 @@ def test_quickstart_batch_example_executes(self) -> None: module.price_heston_batch() self.assertIn("Heston analytic batch:", output.getvalue()) + def test_quickstart_skips_optional_calibration_without_dev_dependencies( + self, + ) -> None: + spec = importlib.util.spec_from_file_location( + "quant_pricer_quickstart_optional", QUICKSTART + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + output = io.StringIO() + with mock.patch.object(module.importlib.util, "find_spec", return_value=None): + with contextlib.redirect_stdout(output): + module.maybe_run_heston(REPO_ROOT) + self.assertIn("skipping calibration demo", output.getvalue()) + def test_documented_validation_is_fail_closed(self) -> None: markets = np.array([[100.0, 100.0, 0.01, 0.0, 1.0]]) params = np.array([[1.5, 0.04, 0.6, -0.45, 0.04]]) diff --git a/tests/test_python_portfolio_risk_fast.py b/tests/test_python_portfolio_risk_fast.py new file mode 100644 index 00000000..97057ec6 --- /dev/null +++ b/tests/test_python_portfolio_risk_fast.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Independent QuantLib and API-contract checks for native portfolio risk.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import json +import math +import sys +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np +import QuantLib as ql + +POSITION_COLUMNS = ("price", "value", "delta", "gamma", "vega", "theta", "rho") +TOTAL_COLUMNS = ("value", "delta", "gamma", "vega", "theta", "rho") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--module-dir", type=Path, required=True) + parser.add_argument("--json-out", type=Path) + return parser.parse_args() + + +def quantlib_metrics(row: np.ndarray) -> np.ndarray: + option_type, quantity, spot, strike, rate, dividend, volatility, time = row + evaluation_date = ql.Date(15, ql.July, 2026) + ql.Settings.instance().evaluationDate = evaluation_date + days = int(round(float(time) * 365.0)) + if days == 0: + price = ( + max(0.0, spot - strike) if option_type == 1.0 else max(0.0, strike - spot) + ) + delta = 1.0 if option_type == 1.0 and spot > strike else 0.0 + if option_type == -1.0: + delta = -1.0 if spot < strike else 0.0 + return np.array([price, quantity * price, quantity * delta, 0.0, 0.0, 0.0, 0.0]) + day_count = ql.Actual365Fixed() + risk_free = ql.YieldTermStructureHandle( + ql.FlatForward(evaluation_date, float(rate), day_count) + ) + dividend_curve = ql.YieldTermStructureHandle( + ql.FlatForward(evaluation_date, float(dividend), day_count) + ) + vol_curve = ql.BlackVolTermStructureHandle( + ql.BlackConstantVol( + evaluation_date, ql.NullCalendar(), float(volatility), day_count + ) + ) + process = ql.BlackScholesMertonProcess( + ql.QuoteHandle(ql.SimpleQuote(float(spot))), + dividend_curve, + risk_free, + vol_curve, + ) + payoff_type = ql.Option.Call if option_type == 1.0 else ql.Option.Put + option = ql.VanillaOption( + ql.PlainVanillaPayoff(payoff_type, float(strike)), + ql.EuropeanExercise(evaluation_date + days), + ) + option.setPricingEngine(ql.AnalyticEuropeanEngine(process)) + price = option.NPV() + return np.array( + [ + price, + quantity * price, + quantity * option.delta(), + quantity * option.gamma(), + quantity * option.vega(), + quantity * option.theta(), + quantity * option.rho(), + ], + dtype=np.float64, + ) + + +def deterministic_positions() -> np.ndarray: + rows: list[list[float]] = [] + ratios = (0.72, 0.95, 1.0, 1.08, 1.35) + days = (7, 30, 91, 365, 730) + for index in range(60): + option_type = 1.0 if index % 2 == 0 else -1.0 + strike = 80.0 + 5.0 * (index % 9) + spot = strike * ratios[index % len(ratios)] + quantity = (-1.0 if index % 3 == 0 else 1.0) * (0.25 + (index % 7)) + rate = (-0.005, 0.0, 0.02, 0.07)[index % 4] + dividend = (0.0, 0.01, 0.035)[index % 3] + volatility = (0.08, 0.18, 0.35, 0.8)[index % 4] + rows.append( + [ + option_type, + quantity, + spot, + strike, + rate, + dividend, + volatility, + days[index % 5] / 365.0, + ] + ) + return np.asarray(rows, dtype=np.float64) + + +def main() -> int: + args = parse_args() + sys.path.insert(0, str(args.module_dir.resolve())) + import pyquant_pricer as qp + + positions = deterministic_positions() + result = qp.bs_portfolio_risk(positions) + assert tuple(result["position_columns"]) == POSITION_COLUMNS + assert tuple(result["total_columns"]) == TOTAL_COLUMNS + actual = np.asarray(result["position_metrics"]) + reference = np.vstack([quantlib_metrics(row) for row in positions]) + metric_max_abs = dict( + zip(POSITION_COLUMNS, np.max(np.abs(actual - reference), axis=0)) + ) + np.testing.assert_allclose(actual, reference, rtol=1e-10, atol=1e-10) + sequential_totals = np.zeros(6, dtype=np.float64) + for row in actual: + sequential_totals += row[1:] + np.testing.assert_array_equal(result["portfolio_totals"], sequential_totals) + + scenario_positions = positions[:12].copy() + shocks = np.asarray( + [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [-0.20, 0.12, 0.015, 0.0, 1.0 / 365.0], + [-0.08, 0.05, -0.01, 0.003, 5.0 / 365.0], + [0.07, -0.03, 0.005, -0.002, 2.0 / 365.0], + [0.18, 0.02, 0.025, 0.01, 7.0 / 365.0], + [-0.35, 0.25, -0.02, 0.0, 6.0 / 365.0], + ], + dtype=np.float64, + ) + scenario_result = qp.bs_portfolio_scenarios(scenario_positions, shocks, detail=True) + base = np.vstack([quantlib_metrics(row) for row in scenario_positions])[:, 1] + reference_detail = np.empty((len(shocks), len(scenario_positions))) + for scenario_index, shock in enumerate(shocks): + shocked = scenario_positions.copy() + shocked[:, 2] *= 1.0 + shock[0] + shocked[:, 4] += shock[2] + shocked[:, 5] += shock[3] + shocked[:, 6] += shock[1] + shocked[:, 7] = np.maximum(0.0, shocked[:, 7] - shock[4]) + reference_detail[scenario_index] = ( + np.vstack([quantlib_metrics(row) for row in shocked])[:, 1] - base + ) + np.testing.assert_allclose( + scenario_result["position_pnl"], reference_detail, rtol=1e-10, atol=1e-9 + ) + np.testing.assert_allclose( + scenario_result["portfolio_pnl"], + reference_detail.sum(axis=1), + rtol=1e-10, + atol=1e-9, + ) + scenario_position_max_abs = float( + np.max(np.abs(scenario_result["position_pnl"] - reference_detail)) + ) + scenario_portfolio_max_abs = float( + np.max(np.abs(scenario_result["portfolio_pnl"] - reference_detail.sum(axis=1))) + ) + np.testing.assert_array_equal(scenario_result["portfolio_pnl"][0], np.array(0.0)) + aggregate_only = qp.bs_portfolio_scenarios(scenario_positions, shocks, detail=False) + assert aggregate_only["position_pnl"] is None + np.testing.assert_array_equal( + aggregate_only["portfolio_pnl"], scenario_result["portfolio_pnl"] + ) + + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + outputs = list( + pool.map( + lambda _: qp.bs_portfolio_scenarios(scenario_positions, shocks, False)[ + "portfolio_pnl" + ], + range(32), + ) + ) + for output in outputs: + np.testing.assert_array_equal(output, scenario_result["portfolio_pnl"]) + + invalid_cases = [ + np.ones((2, 7)), + np.array([[0.0, 1.0, 100.0, 100.0, 0.01, 0.0, 0.2, 1.0]]), + np.array([[1.0, 1.0, math.nan, 100.0, 0.01, 0.0, 0.2, 1.0]]), + np.array([[1.0, 1.0, 100.0, 100.0, 0.01, 0.0, -0.2, 1.0]]), + ] + for invalid in invalid_cases: + try: + qp.bs_portfolio_risk(invalid) + except (ValueError, RuntimeError): + pass + else: + raise AssertionError( + f"invalid position input did not fail closed: {invalid!r}" + ) + + try: + qp.bs_portfolio_scenarios( + scenario_positions, np.array([[0.0, -1.0, 0.0, 0.0, 0.0]]), False + ) + except (ValueError, RuntimeError): + pass + else: + raise AssertionError("negative post-shock volatility did not fail closed") + if args.json_out is not None: + payload = { + "schema_version": 1, + "evaluator_id": "bs_portfolio_quantlib_parity_v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "position_case_count": len(positions), + "scenario_cell_count": len(shocks) * len(scenario_positions), + "price_greek_tolerance": {"absolute": 1e-10, "relative": 1e-10}, + "scenario_pnl_tolerance": {"absolute": 1e-9, "relative": 1e-10}, + "metric_max_abs_error": { + key: float(value) for key, value in metric_max_abs.items() + }, + "scenario_position_pnl_max_abs_error": scenario_position_max_abs, + "scenario_portfolio_pnl_max_abs_error": scenario_portfolio_max_abs, + "zero_shock_exact": bool(scenario_result["portfolio_pnl"][0] == 0.0), + "concurrent_replays": 32, + "concurrent_replays_bitwise_identical": True, + "invalid_position_cases_rejected": len(invalid_cases), + "invalid_post_shock_case_rejected": True, + "quantlib_version": ql.__version__, + "pyquant_pricer_version": qp.__version__, + "claim_boundary": ( + "Independent deterministic Black-Scholes parity only; no forecast, hedge, market-risk, PnL, or trading claim." + ), + } + args.json_out.parent.mkdir(parents=True, exist_ok=True) + args.json_out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print( + f"portfolio QuantLib oracle: {len(positions)} positions and " + f"{len(shocks) * len(scenario_positions)} scenario cells passed" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_python_release_candidate_fast.py b/tests/test_python_release_candidate_fast.py index a7ce6648..3a45869a 100644 --- a/tests/test_python_release_candidate_fast.py +++ b/tests/test_python_release_candidate_fast.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Focused consistency checks for the v0.3.7 Python release candidate.""" +"""Focused consistency checks for the v0.4.0 Python release candidate.""" from __future__ import annotations @@ -10,7 +10,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.3.7" +VERSION = "0.4.0" class PythonReleaseCandidateTest(unittest.TestCase): @@ -20,25 +20,26 @@ def test_authoritative_version_surfaces_match(self) -> None: header = (ROOT / "include/quant/version.hpp").read_text(encoding="utf-8") setup = configparser.ConfigParser() setup.read(ROOT / "setup.cfg", encoding="utf-8") - self.assertRegex(pyproject, r'(?m)^version = "0\.3\.7"$') + self.assertRegex(pyproject, r'(?m)^version = "0\.4\.0"$') self.assertRegex( - cmake, r"(?m)^project\(quant_pricer_cpp VERSION 0\.3\.7 LANGUAGES CXX\)$" + cmake, r"(?m)^project\(quant_pricer_cpp VERSION 0\.4\.0 LANGUAGES CXX\)$" ) self.assertRegex(header, r"(?m)^constexpr int kVersionMajor = 0;$") - self.assertRegex(header, r"(?m)^constexpr int kVersionMinor = 3;$") - self.assertRegex(header, r"(?m)^constexpr int kVersionPatch = 7;$") + self.assertRegex(header, r"(?m)^constexpr int kVersionMinor = 4;$") + self.assertRegex(header, r"(?m)^constexpr int kVersionPatch = 0;$") self.assertEqual(setup["metadata"]["version"], VERSION) - def test_v033_release_note_covers_shipped_product_and_release_guarantees( + def test_v040_release_note_covers_shipped_product_and_release_guarantees( self, ) -> None: changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") - match = re.search(r"(?ms)^## v0\.3\.3\n(.*?)(?=^## )", changelog) + match = re.search(r"(?ms)^## v0\.4\.0(?: .*?)?\n(.*?)(?=^## )", changelog) self.assertIsNotNone(match) note = match.group(1) for required in ( - "heston_calls_analytic_batch", - "process-wide four-worker budget", + "bs_portfolio_risk", + "bs_portfolio_scenarios", + "QuantLib", "installed-wheel contract", "source distribution", "deterministic manifest", @@ -107,10 +108,10 @@ def test_reproduction_checks_committed_snapshot_before_regeneration(self) -> Non script.rindex("generate_metrics_summary"), ) - def test_existing_v032_note_remains_separate(self) -> None: + def test_existing_v033_note_remains_separate(self) -> None: changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") - self.assertLess(changelog.index("## v0.3.3"), changelog.index("## v0.3.2")) - self.assertEqual(changelog.count("## v0.3.3"), 1) + self.assertLess(changelog.index("## v0.4.0"), changelog.index("## v0.3.3")) + self.assertEqual(changelog.count("## v0.4.0"), 1) if __name__ == "__main__": diff --git a/tests/test_python_release_manifest_fast.py b/tests/test_python_release_manifest_fast.py index 052d1e54..ea0092f4 100644 --- a/tests/test_python_release_manifest_fast.py +++ b/tests/test_python_release_manifest_fast.py @@ -21,12 +21,12 @@ class ReleaseManifestTest(unittest.TestCase): def test_manifest_binds_hashes_version_commit_and_runtime(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) - sdist = root / "pyquant_pricer-0.3.7.tar.gz" - wheel = root / "pyquant_pricer-0.3.7-py3-none-any.whl" + sdist = root / "pyquant_pricer-0.4.0.tar.gz" + wheel = root / "pyquant_pricer-0.4.0-py3-none-any.whl" sdist.write_bytes(b"sdist") wheel.write_bytes(b"wheel") manifest = MODULE.build_manifest([wheel, sdist], "a" * 40, ROOT, "6.2.0") - self.assertEqual(manifest["version"], "0.3.7") + self.assertEqual(manifest["version"], "0.4.0") self.assertEqual(manifest["source_commit"], "a" * 40) self.assertEqual( [item["filename"] for item in manifest["artifacts"]], @@ -42,7 +42,7 @@ def test_manifest_binds_hashes_version_commit_and_runtime(self) -> None: def test_manifest_requires_one_sdist_and_at_least_one_wheel(self) -> None: with tempfile.TemporaryDirectory() as directory: - wheel = Path(directory) / "pyquant_pricer-0.3.7-py3-none-any.whl" + wheel = Path(directory) / "pyquant_pricer-0.4.0-py3-none-any.whl" wheel.write_bytes(b"wheel") with self.assertRaisesRegex(ValueError, "exactly one sdist"): MODULE.build_manifest([wheel], "b" * 40, ROOT, "6.2.0") diff --git a/tests/test_python_version_binding_fast.py b/tests/test_python_version_binding_fast.py index 09f27352..a76cc3ad 100644 --- a/tests/test_python_version_binding_fast.py +++ b/tests/test_python_version_binding_fast.py @@ -9,7 +9,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -EXPECTED_VERSION = "0.3.7" +EXPECTED_VERSION = "0.4.0" class PythonVersionBindingTest(unittest.TestCase): diff --git a/tests/test_release_manifest_identity_fast.py b/tests/test_release_manifest_identity_fast.py index fb17cce4..4d87902a 100644 --- a/tests/test_release_manifest_identity_fast.py +++ b/tests/test_release_manifest_identity_fast.py @@ -30,36 +30,36 @@ def test_complete_set_records_parsed_project_and_version(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) paths = [ - artifact(root, "pyquant_pricer-0.3.7.tar.gz"), + artifact(root, "pyquant_pricer-0.4.0.tar.gz"), artifact( - root, "pyquant_pricer-0.3.7-cp311-cp311-manylinux_2_28_x86_64.whl" + root, "pyquant_pricer-0.4.0-cp311-cp311-manylinux_2_28_x86_64.whl" ), artifact( - root, "pyquant_pricer-0.3.7-cp312-cp312-macosx_14_0_arm64.whl" + root, "pyquant_pricer-0.4.0-cp312-cp312-macosx_14_0_arm64.whl" ), ] manifest = self.build(paths) self.assertEqual(manifest["project"], "pyquant-pricer") - self.assertEqual(manifest["version"], "0.3.7") + self.assertEqual(manifest["version"], "0.4.0") self.assertTrue( all(item["project"] == "pyquant-pricer" for item in manifest["artifacts"]) ) self.assertTrue( - all(item["version"] == "0.3.7" for item in manifest["artifacts"]) + all(item["version"] == "0.4.0" for item in manifest["artifacts"]) ) def test_duplicate_filename_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) - sdist = artifact(root, "pyquant_pricer-0.3.7.tar.gz") - wheel = artifact(root, "pyquant_pricer-0.3.7-py3-none-any.whl") + sdist = artifact(root, "pyquant_pricer-0.4.0.tar.gz") + wheel = artifact(root, "pyquant_pricer-0.4.0-py3-none-any.whl") with self.assertRaisesRegex(ValueError, "duplicate artifact filenames"): self.build([sdist, wheel, wheel]) def test_mixed_version_wrong_project_and_malformed_names_are_rejected(self) -> None: bad_names = ( "pyquant_pricer-0.3.2-py3-none-any.whl", - "other_project-0.3.3-py3-none-any.whl", + "other_project-0.4.0-py3-none-any.whl", "pyquant_pricer-not-a-wheel.whl", ) for bad_name in bad_names: @@ -68,7 +68,7 @@ def test_mixed_version_wrong_project_and_malformed_names_are_rejected(self) -> N ), tempfile.TemporaryDirectory() as directory: root = Path(directory) paths = [ - artifact(root, "pyquant_pricer-0.3.3.tar.gz"), + artifact(root, "pyquant_pricer-0.4.0.tar.gz"), artifact(root, bad_name), ] with self.assertRaises(ValueError): @@ -77,8 +77,8 @@ def test_mixed_version_wrong_project_and_malformed_names_are_rejected(self) -> N def test_multiple_sdists_and_missing_wheels_are_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) - first = artifact(root, "pyquant_pricer-0.3.7.tar.gz") - second = artifact(root, "pyquant-pricer-0.3.7.tar.gz") + first = artifact(root, "pyquant_pricer-0.4.0.tar.gz") + second = artifact(root, "pyquant-pricer-0.4.0.tar.gz") with self.assertRaisesRegex(ValueError, "exactly one sdist"): self.build([first, second]) with self.assertRaisesRegex(ValueError, "at least one wheel"): diff --git a/tests/test_release_tag_version_gate_fast.py b/tests/test_release_tag_version_gate_fast.py index 085a3690..fe67d451 100644 --- a/tests/test_release_tag_version_gate_fast.py +++ b/tests/test_release_tag_version_gate_fast.py @@ -17,14 +17,14 @@ class ReleaseTagVersionGateTest(unittest.TestCase): def test_matching_canonical_tag_passes_all_version_surfaces(self) -> None: - self.assertEqual(MODULE.validate_ref("refs/tags/v0.3.7", ROOT), "0.3.7") + self.assertEqual(MODULE.validate_ref("refs/tags/v0.4.0", ROOT), "0.4.0") self.assertEqual( MODULE.authoritative_versions(ROOT), { - "cmake": "0.3.7", - "native": "0.3.7", - "pyproject": "0.3.7", - "setup": "0.3.7", + "cmake": "0.4.0", + "native": "0.4.0", + "pyproject": "0.4.0", + "setup": "0.4.0", }, ) @@ -32,10 +32,10 @@ def test_mismatch_and_malformed_refs_fail_closed(self) -> None: for ref in ( "refs/tags/v0.3.4", "refs/tags/v0.3", - "refs/tags/v00.3.3", - "refs/tags/v0.3.3-rc1", + "refs/tags/v00.4.0", + "refs/tags/v0.4.0-rc1", "refs/heads/main", - "v0.3.3", + "v0.4.0", "", ): with self.subTest(ref=ref), self.assertRaises(ValueError):