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 @@
-
-
-
-
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 @@
-
-
-
-
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 @@
-
-
-
-
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.
[](../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 @@
-#
+#
[](https://badge.fury.io/py/quantflow)
[](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