diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a60913e..7ec3f14 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,6 +27,7 @@ applyTo: '/**' * The documentation for quantflow is available at `https://quantflow.quantmid.com` * Documentation is built using [mkdocs](https://www.mkdocs.org/) and stored in the `docs/` directory. The documentation source files are written in markdown format. +* Split prose into short paragraphs (one idea per paragraph) separated by blank lines. Never write a wall-of-text paragraph that strings together mechanism, rationale, caveats and usage advice. This applies to mkdocs tutorials, theory pages and long docstrings. * Do not use dashes (em dashes, en dashes, or hyphens used as dashes) in documentation files or docstrings. Use colons, parentheses, or restructure the sentence instead. * Math in documentation and docstrings: always use `\begin{equation}...\end{equation}` for any formula or equation. Use `$...$` only for brief inline references to variables (e.g. $F$, $K$). Do not use `$$...$$`, `` `...` ``, or RST syntax (`.. math::`, `:math:`). * Math notation convention: use $\Phi$ for the characteristic function and $\phi$ for the characteristic exponent, where $\Phi = e^{-\phi}$. diff --git a/docs/assets/linkedin-banner.png b/docs/assets/linkedin-banner.png deleted file mode 100644 index 97d245d..0000000 Binary files a/docs/assets/linkedin-banner.png and /dev/null differ diff --git a/docs/assets/quantflow-light.svg b/docs/assets/quantflow-light.svg deleted file mode 100644 index 242487e..0000000 --- a/docs/assets/quantflow-light.svg +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - Quantflow - - - - - - - - - - - diff --git a/docs/assets/quantflow-logo.png b/docs/assets/quantflow-logo.png deleted file mode 100644 index 8c16699..0000000 Binary files a/docs/assets/quantflow-logo.png and /dev/null differ diff --git a/docs/assets/quantflow-repo.png b/docs/assets/quantflow-repo.png deleted file mode 100644 index 1b34b62..0000000 Binary files a/docs/assets/quantflow-repo.png and /dev/null differ diff --git a/docs/assets/quantflow-repo.svg b/docs/assets/quantflow-repo.svg deleted file mode 100644 index 99084c6..0000000 --- a/docs/assets/quantflow-repo.svg +++ /dev/null @@ -1,203 +0,0 @@ - - - - - - - - - - - - Quantflow - - - - - - - - - - A modern python library for quantitative finance - - - - - - - - - - Qf - - - - - - - - - diff --git a/docs/assets/quantflow.svg b/docs/assets/quantflow.svg deleted file mode 100644 index 831faee..0000000 --- a/docs/assets/quantflow.svg +++ /dev/null @@ -1,252 +0,0 @@ - - - - - - - - - - - - - Quantflow - - - - - - - - - - - - - - - - - Quantflow - - - - - - - - - - A modern python library for quantitative finance - - - - diff --git a/docs/examples/vol_surface_bns2_calibration.py b/docs/examples/vol_surface_bns2_calibration.py index f051f98..7739d21 100644 --- a/docs/examples/vol_surface_bns2_calibration.py +++ b/docs/examples/vol_surface_bns2_calibration.py @@ -14,12 +14,15 @@ surface.disable_outliers() # Two-factor BNS: a fast factor for short maturities and a slow one for long. -# Opposite-sign leverages lets one factor lift the OTM-call wing (rho>0) while -# the other carries the equity-style downside skew (rho<0). +# Both factors share the same Gamma stationary marginal (vol, decay) following +# the BNS superposition-of-OU construction; only the timescale (kappa) and +# leverage (rho) differ. Opposite-sign leverages lets one factor lift the +# OTM-call wing (rho>0) while the other carries the equity-style downside +# skew (rho<0). pricer = OptionPricer( model=BNS2( - bns1=BNS.create(vol=0.4, kappa=20.0, decay=20.0, rho=-0.6), - bns2=BNS.create(vol=0.5, kappa=0.3, decay=5.0, rho=0.3), + bns1=BNS.create(vol=0.45, kappa=20.0, decay=10.0, rho=-0.6), + bns2=BNS.create(vol=0.45, kappa=0.3, decay=10.0, rho=0.3), weight=0.3, ) ) @@ -27,7 +30,7 @@ calibration: BNS2Calibration[BNS2] = BNS2Calibration( pricer=pricer, vol_surface=surface, - moneyness_weight=0.5, + moneyness_weight=0.2, ) result = calibration.fit() diff --git a/docs/index.md b/docs/index.md index 3abcf34..700c521 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,7 @@ pip install quantflow ## MCP Server Quantflow exposes its data tools as an [MCP](https://modelcontextprotocol.io) server for AI clients. -See [MCP Server](https://quanflow.quantmind.com/mcp) for setup and available tools. +See [MCP Server](https://quantflow.quantmind.com/mcp/) for setup and available tools. ## License diff --git a/docs/tutorials/bns_calibration.md b/docs/tutorials/bns_calibration.md index 55be5dd..e9ab00b 100644 --- a/docs/tutorials/bns_calibration.md +++ b/docs/tutorials/bns_calibration.md @@ -25,9 +25,11 @@ parameters to the surface: The BDLP intensity is set as $\lambda = \theta \beta$ so that the stationary mean of the Gamma-OU variance process equals $\theta$. This gives the same -$(v_0, \theta)$ parameterisation as Heston. Because the variance is built from -positive jumps and exponential mean reversion, it stays positive by -construction; no Feller-style penalty is needed. +$(v_0, \theta)$ parameterisation as Heston. + +Because the variance is built from positive jumps and exponential mean +reversion, it stays positive by construction. No Feller-style penalty is +needed. ### How BNS fits the surface @@ -43,10 +45,12 @@ A consequence of this structural difference is that the calibrator often settles at a small $\kappa$ together with a large $\theta$. The time scale of mean reversion is $1/\kappa$, so when $\kappa$ is small the variance process barely relaxes towards $\theta$ over the calibration horizon and stays close -to $v_0$ throughout. In that regime $\theta$ is only weakly identified by the -surface and the optimizer can move it freely as long as the jump-driven smile -dynamics are preserved. The headline number to read in the output is $v_0$, -which sets the at-the-money level. +to $v_0$ throughout. + +In that regime $\theta$ is only weakly identified by the surface and the +optimizer can move it freely as long as the jump-driven smile dynamics are +preserved. The headline number to read in the output is $v_0$, which sets +the at-the-money level. ### Calibrated parameters @@ -96,16 +100,26 @@ short-maturity skew from the long-maturity level, in the same spirit as the [DoubleHeston][quantflow.sp.heston.DoubleHeston] extension of Heston. [BNS2Calibration][quantflow.options.calibration.bns.BNS2Calibration] fits -eleven parameters: +nine parameters: -`[v01, theta1, kappa_delta, beta1, rho1, v02, theta2, kappa2, beta2, rho2, w]` +`[v01, v02, theta, beta, kappa2, kappa_delta, rho1, rho2, w]` with `kappa1 = kappa2 + kappa_delta` enforcing that the first factor -mean-reverts faster than the second. Both leverage parameters are free in -$[-0.9, 0.9]$: a positive $\rho_i$ produces up-jumps in the log-price that -lift the OTM call wing, while a negative one produces equity-style downside -skew. There is no warm start, so the optimiser starts from the user-supplied -initial parameters; pick distinct timescales for `bns1` and `bns2` (and +mean-reverts faster than the second. + +Following the BNS superposition-of-OU construction, both factors share the +same Gamma stationary marginal: the intensity and decay of the BDLP are tied +across factors so that $v^1$ and $v^2$ have the same long-run distribution. +Only the timescales and the leverages differ between the two factors. + +Tying $(\theta, \beta)$ removes a well-known degeneracy between the +marginal-distribution parameters and the timescales, and shrinks the search +space without losing skew flexibility. The leverages $\rho_1, \rho_2$ stay +independent because the empirical equity skew flattens with maturity, which a +single shared leverage cannot reproduce. + +There is no warm start, so the optimiser begins from the user-supplied +initial parameters. Pick distinct timescales for `bns1` and `bns2` (and consider opposite-sign leverages) to seed a meaningful two-factor fit. ### Calibrated parameters @@ -115,10 +129,12 @@ consider opposite-sign leverages) to seed a meaningful two-factor fit. [![BNS2 calibrated smile](../assets/examples/bns2_calibrated_smile.png)](../assets/examples/bns2_calibrated_smile.png){target="_blank"} The two-factor variant adds flexibility on the term structure: the fast factor -absorbs short-dated skew while the slow factor anchors the long end. The -remaining short-maturity gap is structural in the same way as the single-factor -case: BNS2 still injects jumps only through the variance process, so the -log-price wings are bounded by the jump sizes scaled by $|\rho_i|$. +absorbs short-dated skew while the slow factor anchors the long end. + +The remaining short-maturity gap is structural in the same way as the +single-factor case. BNS2 still injects jumps only through the variance +process, so the log-price wings are bounded by the jump sizes scaled by +$|\rho_i|$. ### Code diff --git a/docs/tutorials/volatility_surface.md b/docs/tutorials/volatility_surface.md index 9a5682e..31ef802 100644 --- a/docs/tutorials/volatility_surface.md +++ b/docs/tutorials/volatility_surface.md @@ -110,10 +110,18 @@ volatility surface using a two-stage optimisation: solution on the residual vector with tight tolerances and enforces parameter bounds. Residuals are computed as `weight * (model_call_price - mid_call_price)` where -`mid_call_price` is the average of the bid and ask call prices, and the weight is -$e^{-w \cdot |k|}$ controlled by `moneyness_weight`. A penalty for violating the -Feller condition ($2\kappa\theta \geq \sigma^2$) is added during stage 1 to keep the -variance process well-behaved. +`mid_call_price` is the average of the bid and ask call prices. + +The weight is $\min(e^{w \cdot m^2}, w_\text{max})$ controlled by +`moneyness_weight` (the coefficient $w$) and `max_cost_weight` (the cap +$w_\text{max}$), with $m = \log(K/F)/\sqrt{T}$ the standardised moneyness. +The quadratic exponent matches the gaussian shape of $1/\nu$ (inverse vega), +so a positive `moneyness_weight` puts wing residuals on the same footing as +ATM ones. The cap prevents a single deep-wing option from dominating the +loss. + +A penalty for violating the Feller condition ($2\kappa\theta \geq \sigma^2$) +is added during stage 1 to keep the variance process well-behaved. ```python --8<-- "docs/examples/vol_surface_heston_calibration.py" @@ -125,9 +133,10 @@ variance process well-behaved. ### Calibration Options -The `moneyness_weight` parameter down-weights far-from-the-money options via -$e^{-w \cdot |k|}$ where $k = \log(K/F)$. Setting `ttm_weight > 0` similarly -down-weights near-expiry options. +The `moneyness_weight` parameter up-weights far-from-the-money options via +$e^{w \cdot m^2}$ where $m = \log(K/F)/\sqrt{T}$ is the standardised +moneyness. The result is capped at `max_cost_weight` (default 10) so a +single deep-wing option cannot dominate the loss. ### Plotting the Calibrated Smile diff --git a/quantflow/options/calibration/base.py b/quantflow/options/calibration/base.py index 541296c..0ca3dd0 100644 --- a/quantflow/options/calibration/base.py +++ b/quantflow/options/calibration/base.py @@ -85,9 +85,22 @@ class VolModelCalibration(BaseModel, ABC, Generic[M]): default=0.0, ge=0.0, description=( - "Weight penalising options as moneyness moves away from 0." - " Applied as `exp(-moneyness_weight * |moneyness|)`." - " A value of 0 applies no penalisation." + "Coefficient that up-weights wing options in the cost function." + " Applied as `min(exp(moneyness_weight * moneyness**2)," + " max_cost_weight)`, with `moneyness = log(K/F) / sqrt(ttm)`." + " The quadratic form mimics the gaussian shape of `1/vega` and" + " puts wing residuals on the same footing as ATM ones. A value" + " of 0 applies no moneyness weighting; typical values are in" + " `[0.1, 0.5]`." + ), + ) + max_cost_weight: float = Field( + default=10.0, + gt=0.0, + description=( + "Hard cap on the per-option cost weight, to prevent a single" + " deep-wing option from dominating the loss when" + " `moneyness_weight` is large." ), ) options: dict[ModelCalibrationEntryKey, OptionEntry] = Field( @@ -170,9 +183,14 @@ def fit(self) -> OptimizeResult: return result def cost_weight(self, ttm: float, log_strike: float) -> float: - """Weight for a given time to maturity and log-strike""" + """Weight for a given time to maturity and log-strike. + + Up-weights wing options via `exp(moneyness_weight * moneyness**2)`, + capped at `max_cost_weight`. The quadratic form mimics `1/vega`. + """ moneyness = log_strike / np.sqrt(ttm) - return np.exp(-self.moneyness_weight * abs(moneyness)) + weight = np.exp(self.moneyness_weight * moneyness * moneyness) + return float(min(weight, self.max_cost_weight)) def penalize(self) -> float: """Additional scalar penalty added to the cost function (default: 0)""" diff --git a/quantflow/options/calibration/bns.py b/quantflow/options/calibration/bns.py index 99771d9..36e3beb 100644 --- a/quantflow/options/calibration/bns.py +++ b/quantflow/options/calibration/bns.py @@ -61,31 +61,35 @@ def set_params(self, params: np.ndarray) -> None: class BNS2Calibration(VolModelCalibration[B2], Generic[B2]): r"""Calibration of the [BNS2][quantflow.sp.bns.BNS2] two-factor BNS model. - The parameter vector is - - `[v01, theta1, kappa_delta, beta1, rho1, v02, theta2, kappa2, beta2, rho2, w]` - - where `kappa1 = kappa2 + kappa_delta` with `kappa_delta > 0`, enforcing that - the first (short-maturity) factor mean-reverts faster than the second, and - `w` is the convex-combination weight of the first variance factor. The same - $(v_0, \theta)$ parameterisation as - [BNSCalibration][quantflow.options.calibration.bns.BNSCalibration] is used - for each factor: the BDLP intensity is set as $\lambda_i = \theta_i \beta_i$ - so the stationary mean of $v^i$ equals $\theta_i$. - - Both leverage parameters are free in $[-0.9, 0.9]$: a positive $\rho_i$ - produces up-jumps in the log-price that lift the OTM call wing, while a - negative one produces equity-style downside skew. The joint fit relies on - the user-supplied initial parameters: pick distinct timescales for `bns1` - and `bns2` (and consider opposite-sign leverages) to give the optimiser a - meaningful two-factor starting point. - - TODO: improve this calibration. The 11-parameter fit is slow (finite-diff - Jacobian dominates) and tends to collapse the two timescales into a near - single-factor solution unless the initial conditions force them apart. - Candidate improvements: analytic Jacobian of the characteristic exponent, - a smarter warm start that does not bias the kappas to merge, and tighter - bounds on `kappa1` and `beta_i`. + Following the BNS superposition-of-OU construction, both factors share + the same Gamma stationary marginal: only the mean-reversion timescales + and the leverage parameters differ between the fast and slow factors. + The parameter vector has nine entries: + + `[v01, v02, theta, beta, kappa2, kappa_delta, rho1, rho2, w]` + + | Symbol | Description | + |---|---| + | `v01`, `v02` | Initial variances of the two factors | + | `theta` | Long-run variance shared by both factors ($\theta = \lambda / \beta$) | + | `beta` | Exponential decay of the BDLP jump-size distribution (shared) | + | `kappa2` | Mean-reversion speed of the slow factor | + | `kappa_delta` | Excess speed of the fast factor ($\kappa_1 - \kappa_2$) | + | `rho1`, `rho2` | Leverage of the two factors, free in $[-0.9, 0.9]$ | + | `w` | Weight of the first variance factor in the convex combination | + + Tying $(\theta, \beta)$ removes the degeneracy between the two + marginal-distribution parameters and the timescales: the long-dated smile + pins down a single stationary variance distribution, while the term + structure of vol identifies the two relaxation speeds. The leverages + $\rho_1, \rho_2$ stay independent because the empirical equity skew + flattens with maturity, which a single shared leverage cannot reproduce. + + The user-supplied initial model still seeds the fit: pick distinct + timescales for `bns1` and `bns2` (and consider opposite-sign leverages) so + the optimiser starts away from the single-factor collapse. Any difference + in `(theta, beta)` between the two seed factors is averaged when building + the starting parameter vector. """ def get_bounds(self) -> Bounds: @@ -95,43 +99,46 @@ def get_bounds(self) -> Bounds: v2 = vol_lb**2 v2u = vol_ub**2 return Bounds( - [v2, v2, 1e-4, 1.0, -0.9, v2, v2, 1e-3, 1.0, -0.9, 0.0], - [v2u, v2u, np.inf, np.inf, 0.9, v2u, v2u, 5.0, np.inf, 0.9, 1.0], + # v01, v02, theta, beta, kappa2, kappa_delta, rho1, rho2, w + [v2, v2, v2, 1.0, 1e-3, 1e-4, -0.9, -0.9, 0.0], + [v2u, v2u, v2u, np.inf, 5.0, np.inf, 0.9, 0.9, 1.0], ) def get_params(self) -> np.ndarray: vp1 = self.model.bns1.variance_process vp2 = self.model.bns2.variance_process - kappa_delta = max(vp1.kappa - vp2.kappa, 1e-4) theta1 = vp1.intensity / vp1.beta theta2 = vp2.intensity / vp2.beta + theta = 0.5 * (theta1 + theta2) + beta = 0.5 * (vp1.beta + vp2.beta) + kappa_delta = max(vp1.kappa - vp2.kappa, 1e-4) return np.asarray( [ vp1.rate, - theta1, - kappa_delta, - vp1.beta, - self.model.bns1.rho, vp2.rate, - theta2, + theta, + beta, vp2.kappa, - vp2.beta, + kappa_delta, + self.model.bns1.rho, self.model.bns2.rho, self.model.weight, ] ) def set_params(self, params: np.ndarray) -> None: + v01, v02, theta, beta, kappa2, kappa_delta, rho1, rho2, w = params vp1 = self.model.bns1.variance_process - vp1.rate = params[0] - vp1.bdlp.jumps.decay = params[3] - vp1.bdlp.intensity = params[1] * params[3] - self.model.bns1.rho = params[4] vp2 = self.model.bns2.variance_process - vp2.rate = params[5] - vp2.kappa = params[7] - vp2.bdlp.jumps.decay = params[8] - vp2.bdlp.intensity = params[6] * params[8] - self.model.bns2.rho = params[9] - vp1.kappa = vp2.kappa + params[2] # kappa2 + kappa_delta - self.model.weight = params[10] + vp1.rate = v01 + vp2.rate = v02 + vp1.bdlp.jumps.decay = beta + vp2.bdlp.jumps.decay = beta + intensity = theta * beta + vp1.bdlp.intensity = intensity + vp2.bdlp.intensity = intensity + vp2.kappa = kappa2 + vp1.kappa = kappa2 + kappa_delta + self.model.bns1.rho = rho1 + self.model.bns2.rho = rho2 + self.model.weight = w diff --git a/readme.md b/readme.md index adc1441..700c521 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# +# [![PyPI version](https://badge.fury.io/py/quantflow.svg)](https://badge.fury.io/py/quantflow) [![Python versions](https://img.shields.io/pypi/pyversions/quantflow.svg)](https://pypi.org/project/quantflow) @@ -33,7 +33,8 @@ pip install quantflow ## MCP Server -Quantflow exposes its data tools as an [MCP](https://modelcontextprotocol.io) server for AI clients. See [MCP Server](mcp.md) for setup and available tools. +Quantflow exposes its data tools as an [MCP](https://modelcontextprotocol.io) server for AI clients. +See [MCP Server](https://quantflow.quantmind.com/mcp/) for setup and available tools. ## License