From ac0684304abbe39f637a2dfe45b228d2d32a82c0 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 7 Jun 2026 14:10:00 +0100 Subject: [PATCH 1/4] Better distribution api --- .github/workflows/release.yml | 10 ++ Makefile | 2 +- app/api/deps.py | 30 ++++- app/api/heston.py | 2 +- app/api/sampling.py | 2 +- app/scripts/heston_divfm_fit.py | 2 +- docs/api/dists/distributions.md | 9 ++ docs/api/dists/distributions1d.md | 9 ++ docs/api/dists/index.md | 15 +-- docs/api/dists/marginal1d.md | 9 ++ docs/api/index.md | 14 ++- docs/api/utils/distributions.md | 9 -- docs/api/utils/index.md | 2 - docs/api/utils/marginal1d.md | 9 -- docs/examples/heston_volatility_pricer.py | 2 +- docs/examples/pricing_method_comparison.py | 8 +- .../vol_surface_hestonj_calibration.py | 4 +- docs/theory/kalman.md | 2 +- docs/tutorials/cir.md | 2 +- docs/tutorials/heston_calibration.md | 6 +- docs/tutorials/option_pricing.md | 2 +- mkdocs.yml | 5 +- notebooks/heston_divfm_fit.py | 8 +- pyproject.toml | 2 +- quantflow/dists/__init__.py | 26 ++++- quantflow/dists/base.py | 8 +- .../distributions1d.py} | 77 +++++++----- .../marginal.py => dists/marginal1d.py} | 110 +++++++++++++++--- quantflow/dists/mv_normal.py | 4 +- quantflow/options/divfm/pricer.py | 2 +- quantflow/options/pricer.py | 2 +- quantflow/sp/base.py | 11 +- quantflow/sp/heston.py | 8 +- quantflow/sp/jump_diffusion.py | 4 +- quantflow/sp/ou.py | 4 +- quantflow/sp/poisson.py | 6 +- quantflow/ta/kalman.py | 8 +- quantflow/utils/plot.py | 2 +- quantflow_tests/test_app.py | 61 +++++++++- quantflow_tests/test_distributions.py | 2 +- quantflow_tests/test_heston.py | 2 +- quantflow_tests/test_heston_calibration.py | 2 +- quantflow_tests/test_jump_diffusion.py | 2 +- quantflow_tests/test_options.py | 2 +- quantflow_tests/test_options_pricer.py | 2 +- quantflow_tests/test_ou.py | 2 +- quantflow_tests/test_poisson.py | 2 +- quantflow_tests/test_wiener.py | 10 ++ quantflow_tests/utils.py | 2 +- 49 files changed, 378 insertions(+), 148 deletions(-) create mode 100644 docs/api/dists/distributions.md create mode 100644 docs/api/dists/distributions1d.md create mode 100644 docs/api/dists/marginal1d.md delete mode 100644 docs/api/utils/distributions.md delete mode 100644 docs/api/utils/marginal1d.md rename quantflow/{utils/distributions.py => dists/distributions1d.py} (78%) rename quantflow/{utils/marginal.py => dists/marginal1d.py} (87%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 052d708a..6a1b0ae4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,16 @@ permissions: jobs: release: runs-on: ubuntu-latest + services: + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 env: PYTHON_ENV: ci GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index d41b8c79..7c78bb20 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ app-serve: ## serve app docs: ## build documentation @cp docs/index.md readme.md @uv run ./dev/build-examples - @uv run mkdocs build + @uv run mkdocs build --strict .PHONY: docs-bib docs-bib: ## Regenerate docs bibliography diff --git a/app/api/deps.py b/app/api/deps.py index ca4dabba..55cba398 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -1,12 +1,13 @@ import io import json +import logging +import os from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Annotated, Generic, TypeVar, cast import pandas as pd from fastapi import Depends, FastAPI, Request -from fluid.utils import log from fluid.utils.redis import FluidRedis from pydantic import BaseModel from redis.asyncio import Redis @@ -14,7 +15,7 @@ from quantflow.data.fmp import FMP M = TypeVar("M", bound=BaseModel) -logger = log.get_logger(__name__) +logger = logging.getLogger(__name__) def instrument_app(app: FastAPI) -> None: @@ -44,7 +45,17 @@ class RedisCache(Generic[M]): redis: Redis Model: type[M] key: str - ttl: int = 60 + prefix: str = field( + default_factory=lambda: os.getenv( + "QUANTFLOW_REDIS_CACHE_PREFIX", "quantflow:cache" + ) + ) + ttl: int = field( + default_factory=lambda: int(os.getenv("QUANTFLOW_REDIS_CACHE_TTL", "60")) + ) + + def __post_init__(self) -> None: + self.key = f"{self.prefix}:{self.key}" async def from_cache(self, loader: Callable[[], Awaitable[M]]) -> M: """Get a value from the cache""" @@ -53,7 +64,7 @@ async def from_cache(self, loader: Callable[[], Awaitable[M]]) -> M: return await self.set_cache(await loader()) try: return self.Model.model_validate_json(value) - except json.JSONDecodeError: + except json.JSONDecodeError: # pragma: no cover logger.exception(f"Failed to decode cache value for key {self.key}") return await self.set_cache(await loader()) @@ -63,6 +74,15 @@ async def set_cache(self, value: M) -> M: await self.redis.set(self.key, payload, ex=self.ttl) return value + @classmethod + async def clear(cls, redis: Redis) -> int: + """Delete all cache entries under the prefix""" + cache = cls(redis=redis, Model=BaseModel, key="*") + keys = [key async for key in cache.redis.scan_iter(f"{cache.prefix}:*")] + if not keys: + return 0 + return await cache.redis.delete(*keys) + @dataclass class RedisDataframe: diff --git a/app/api/heston.py b/app/api/heston.py index ffdcedbe..da6da4a3 100644 --- a/app/api/heston.py +++ b/app/api/heston.py @@ -3,10 +3,10 @@ from pydantic import BaseModel, Field from app.api.docs import load_description +from quantflow.dists.distributions1d import DoubleExponential from quantflow.options.pricer import OptionPricer from quantflow.sp.heston import HestonJ from quantflow.sp.jump_diffusion import JumpDiffusion -from quantflow.utils.distributions import DoubleExponential heston_router = APIRouter() diff --git a/app/api/sampling.py b/app/api/sampling.py index db9a21bc..c00bec49 100644 --- a/app/api/sampling.py +++ b/app/api/sampling.py @@ -4,11 +4,11 @@ from scipy.stats import chisquare, ks_1samp from app.api.docs import load_description +from quantflow.dists.distributions1d import DoubleExponential from quantflow.sp.ou import Vasicek from quantflow.sp.poisson import PoissonProcess from quantflow.ta.paths import Paths from quantflow.utils import bins -from quantflow.utils.distributions import DoubleExponential sampling_router = APIRouter() diff --git a/app/scripts/heston_divfm_fit.py b/app/scripts/heston_divfm_fit.py index 8b08fd71..2420f1c6 100644 --- a/app/scripts/heston_divfm_fit.py +++ b/app/scripts/heston_divfm_fit.py @@ -20,7 +20,7 @@ from quantflow.options.divfm.trainer import DayData, DIVFMTrainer from quantflow.options.pricer import OptionPricer from quantflow.sp.heston import HestonJ -from quantflow.utils.distributions import DoubleExponential +from quantflow.dists.distributions1d import DoubleExponential # --------------------------------------------------------------------------- # Grid settings diff --git a/docs/api/dists/distributions.md b/docs/api/dists/distributions.md new file mode 100644 index 00000000..3d0687aa --- /dev/null +++ b/docs/api/dists/distributions.md @@ -0,0 +1,9 @@ +# Distributions + +::: quantflow.dists.Distribution + +::: quantflow.dists.MvDistribution + +::: quantflow.dists.MeanAndCov + +::: quantflow.dists.MvNormal diff --git a/docs/api/dists/distributions1d.md b/docs/api/dists/distributions1d.md new file mode 100644 index 00000000..59110d05 --- /dev/null +++ b/docs/api/dists/distributions1d.md @@ -0,0 +1,9 @@ +# 1D Distributions + +::: quantflow.dists.Distribution1D + +::: quantflow.dists.Normal + +::: quantflow.dists.Exponential + +::: quantflow.dists.DoubleExponential diff --git a/docs/api/dists/index.md b/docs/api/dists/index.md index 2a96a431..04a2df9f 100644 --- a/docs/api/dists/index.md +++ b/docs/api/dists/index.md @@ -1,12 +1,9 @@ # Distributions -Probability distributions with a uniform `sample` / `log_pdf` API, used as -return types of the -[StateSpaceModel](../ta/kalman.md#quantflow.ta.kalman.StateSpaceModel) -distribution-level interface. +The `dists` module collects the probability distributions used across quantflow, +both standalone parametric laws and the marginal distributions implied by a +stochastic process at a fixed time horizon. -::: quantflow.dists.MeanAndCov - -::: quantflow.dists.Distribution - -::: quantflow.dists.MvNormal +Every distribution derives from +[Distribution][quantflow.dists.Distribution], which exposes a common +[sample][quantflow.dists.Distribution.sample] method for drawing random variates. diff --git a/docs/api/dists/marginal1d.md b/docs/api/dists/marginal1d.md new file mode 100644 index 00000000..e2a8ed9f --- /dev/null +++ b/docs/api/dists/marginal1d.md @@ -0,0 +1,9 @@ +# Marginal 1D + +::: quantflow.dists.OptionPricingMethod + +::: quantflow.dists.OptionPricingResult + +::: quantflow.dists.OptionPricingCosResult + +::: quantflow.dists.Marginal1D diff --git a/docs/api/index.md b/docs/api/index.md index 7dffd0e8..f0a584c0 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -31,6 +31,16 @@ Option pricing, volatility surface construction, and model calibration. | [Calibration](options/calibration.md) | Calibrate Heston and Heston-jump-diffusion models to a surface | | [Deep IV Factor Model](options/divfm.md) | Neural-network option pricing via the DIVFM architecture | +### [Distributions](dists/index.md) + +Probability distributions: parametric laws and the marginals implied by a stochastic process via its characteristic function. + +| Module | Description | +|---|---| +| [Distributions](dists/distributions.md) | Base classes ([Distribution][quantflow.dists.Distribution], [MvDistribution][quantflow.dists.MvDistribution]) and the multivariate normal | +| [1D Distributions](dists/distributions1d.md) | Parametric 1D laws (Normal, Exponential, DoubleExponential) | +| [Marginal 1D](dists/marginal1d.md) | Marginal distribution via characteristic function inversion, with Fourier-based option pricing | + ### [Stochastic Processes](sp/index.md) Continuous-time stochastic processes used as underlying models for option pricing and simulation. @@ -68,7 +78,7 @@ Interest rate models and curve construction tools for discounting and term-struc | [CIR Curve](rates/cir.md) | Cox-Ingersoll-Ross short-rate term-structure model | | [Nelson Siegel Curve](rates/nelson_siegel.md) | Parametric yield curve with level, slope, and curvature factors | | [Vasicek Curve](rates/vasicek.md) | Gaussian mean-reverting short-rate term-structure model | -| [Options Discounting](rates/options.md) | Curve calibration from discount factors and put-call parity data | +| [Calibration](rates/calibration.md) | Curve calibration from discount factors and put-call parity data | ### [Utilities](utils/index.md) @@ -76,8 +86,6 @@ Low-level building blocks used throughout the library. | Module | Description | |---|---| -| [Distributions](utils/distributions.md) | Jump-size distributions (Normal, DoubleExponential) | -| [Marginal 1D](utils/marginal1d.md) | Marginal distribution via characteristic function inversion | | [Bins](utils/bins.md) | Histogram binning helpers | | [Numbers](utils/numbers.md) | Decimal and float numeric utilities | | [Types](utils/types.md) | Shared type aliases | diff --git a/docs/api/utils/distributions.md b/docs/api/utils/distributions.md deleted file mode 100644 index 771bace6..00000000 --- a/docs/api/utils/distributions.md +++ /dev/null @@ -1,9 +0,0 @@ -# Distributions - -::: quantflow.utils.distributions.Distribution1D - -::: quantflow.utils.distributions.Exponential - -::: quantflow.utils.distributions.DoubleExponential - -::: quantflow.utils.distributions.Normal diff --git a/docs/api/utils/index.md b/docs/api/utils/index.md index 921026f5..339685a0 100644 --- a/docs/api/utils/index.md +++ b/docs/api/utils/index.md @@ -8,8 +8,6 @@ users who want to extend the library or understand its inner workings. | Page | Description | |---|---| -| [Marginal 1D](marginal1d.md) | Abstract base class for 1D marginal distributions with Fourier-based option pricing (Carr-Madan and Lewis formulas) | -| [Distributions](distributions.md) | Parametric 1D distributions (e.g. Exponential) | | [Bins](bins.md) | Histogram and event-density utilities | | [Numbers](numbers.md) | Decimal number helpers | | [Types](types.md) | Shared type aliases (FloatArray, etc.) | diff --git a/docs/api/utils/marginal1d.md b/docs/api/utils/marginal1d.md deleted file mode 100644 index 3220e7f9..00000000 --- a/docs/api/utils/marginal1d.md +++ /dev/null @@ -1,9 +0,0 @@ -# Marginal 1D - -::: quantflow.utils.marginal.OptionPricingMethod - -::: quantflow.utils.marginal.OptionPricingResult - -::: quantflow.utils.marginal.OptionPricingCosResult - -::: quantflow.utils.marginal.Marginal1D diff --git a/docs/examples/heston_volatility_pricer.py b/docs/examples/heston_volatility_pricer.py index 6ff78556..c6c36c08 100644 --- a/docs/examples/heston_volatility_pricer.py +++ b/docs/examples/heston_volatility_pricer.py @@ -1,7 +1,7 @@ +from quantflow.dists.distributions1d import DoubleExponential from quantflow.options.inputs import OptionType from quantflow.options.pricer import OptionPricer from quantflow.sp.heston import HestonJ -from quantflow.utils.distributions import DoubleExponential pricer = OptionPricer( model=HestonJ.create( diff --git a/docs/examples/pricing_method_comparison.py b/docs/examples/pricing_method_comparison.py index 74c0fe06..318275a6 100644 --- a/docs/examples/pricing_method_comparison.py +++ b/docs/examples/pricing_method_comparison.py @@ -6,14 +6,14 @@ from pydantic import BaseModel, Field from docs.examples._utils import assets_path -from quantflow.options.bs import implied_black_volatility -from quantflow.sp.base import StochasticProcess1D -from quantflow.sp.heston import Heston -from quantflow.utils.marginal import ( +from quantflow.dists.marginal1d import ( OptionPricingCosResult, OptionPricingMethod, OptionPricingResult, ) +from quantflow.options.bs import implied_black_volatility +from quantflow.sp.base import StochasticProcess1D +from quantflow.sp.heston import Heston class ChartProps(BaseModel): diff --git a/docs/examples/vol_surface_hestonj_calibration.py b/docs/examples/vol_surface_hestonj_calibration.py index 0ce5cd74..5123ebd0 100644 --- a/docs/examples/vol_surface_hestonj_calibration.py +++ b/docs/examples/vol_surface_hestonj_calibration.py @@ -1,13 +1,13 @@ import json from docs.examples._utils import FIXTURES, assets_path, print_model +from quantflow.dists.distributions1d import DoubleExponential +from quantflow.dists.marginal1d import OptionPricingMethod from quantflow.options.calibration import HestonJCalibration from quantflow.options.calibration.base import ResidualKind from quantflow.options.pricer import OptionPricer from quantflow.options.surface import VolSurface, VolSurfaceInputs, surface_from_inputs from quantflow.sp.heston import HestonJ -from quantflow.utils.distributions import DoubleExponential -from quantflow.utils.marginal import OptionPricingMethod # Load a saved volatility surface snapshot and build the surface with open(FIXTURES / "volsurface_btc.json") as fp: diff --git a/docs/theory/kalman.md b/docs/theory/kalman.md index 8e9b01ec..ff766ec2 100644 --- a/docs/theory/kalman.md +++ b/docs/theory/kalman.md @@ -306,7 +306,7 @@ exposes a distribution-level API that mirrors the These methods return [Distribution](../api/dists/index.md) objects and are intended for particle-filter algorithms (sequential Monte Carlo). They are abstract on the base class; [LinearGaussianModel](../api/ta/kalman.md#quantflow.ta.kalman.LinearGaussianModel) -implements them by returning [MvNormal](../api/dists/index.md#quantflow.dists.MvNormal) +implements them by returning [MvNormal](../api/dists/distributions.md#quantflow.dists.MvNormal) instances. | Method | Signature | Returns | diff --git a/docs/tutorials/cir.md b/docs/tutorials/cir.md index 70ed74b9..fc71c740 100644 --- a/docs/tutorials/cir.md +++ b/docs/tutorials/cir.md @@ -48,7 +48,7 @@ The marginal PDF has two independent routes to the same result: * **Analytical**: the [scaled non-central chi-squared][quantflow.sp.cir.CIR.analytical_pdf] transition density in closed form. * **Characteristic function**: numerical inversion of $\Phi = e^{-\phi}$ via - [pdf_from_characteristic][quantflow.utils.marginal.Marginal1D.pdf_from_characteristic]. + [pdf_from_characteristic][quantflow.dists.Marginal1D.pdf_from_characteristic]. The charts below overlay both for a CIR process with $\kappa=1$, $\theta=0.5$, $\sigma=0.8$, $x_0=3$, starting well above the long-run mean diff --git a/docs/tutorials/heston_calibration.md b/docs/tutorials/heston_calibration.md index df3140c7..a920ea13 100644 --- a/docs/tutorials/heston_calibration.md +++ b/docs/tutorials/heston_calibration.md @@ -92,7 +92,7 @@ the motivation for the Heston jump-diffusion model described in the next section [HestonJCalibration][quantflow.options.calibration.heston.HestonJCalibration] extends the Heston calibration with a compound Poisson jump component via the [HestonJ][quantflow.sp.heston.HestonJ] model. Jumps are drawn from a -[DoubleExponential][quantflow.utils.distributions.DoubleExponential] distribution, +[DoubleExponential][quantflow.dists.DoubleExponential] distribution, which captures asymmetric jump behaviour common in equity and crypto markets. ```python @@ -126,7 +126,7 @@ whole surface, so short maturities — with fewer strikes — are outvoted and t systematically sacrificed. **The jump distribution is not rich enough.** The short-term smile in crypto is driven -by large, rare, asymmetric events. A [DoubleExponential][quantflow.utils.distributions.DoubleExponential] +by large, rare, asymmetric events. A [DoubleExponential][quantflow.dists.DoubleExponential] with fixed parameters cannot simultaneously match the wing curvature at short and long maturities. @@ -149,4 +149,4 @@ The calibrated parameter vector for the jump-diffusion model is: | `rho` | Spot-variance correlation | | `jump intensity` | Jump arrival rate (jumps per year) | | `jump variance` | Variance of a single jump | -| `jump asymmetry` | Asymmetry of the jump distribution ([DoubleExponential][quantflow.utils.distributions.DoubleExponential]) | +| `jump asymmetry` | Asymmetry of the jump distribution ([DoubleExponential][quantflow.dists.DoubleExponential]) | diff --git a/docs/tutorials/option_pricing.md b/docs/tutorials/option_pricing.md index fc0dab3d..9f9f498d 100644 --- a/docs/tutorials/option_pricing.md +++ b/docs/tutorials/option_pricing.md @@ -25,7 +25,7 @@ The first example shows how to price an option using the Black-Scholes model and The underlying model is [HestonJ][quantflow.sp.heston.HestonJ], a Heston stochastic volatility model extended with jumps drawn from a -[DoubleExponential][quantflow.utils.distributions.DoubleExponential] distribution. +[DoubleExponential][quantflow.dists.DoubleExponential] distribution. ```python --8<-- "docs/examples/heston_volatility_pricer.py" diff --git a/mkdocs.yml b/mkdocs.yml index b73d42b4..8befeed4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,9 @@ nav: - Yahoo: api/data/yahoo.md - Distributions: - api/dists/index.md + - Distributions: api/dists/distributions.md + - 1D Distributions: api/dists/distributions1d.md + - Marginal 1D: api/dists/marginal1d.md - Options: - api/options/index.md - Black-Scholes: api/options/black.md @@ -111,8 +114,6 @@ nav: - Utilities: - api/utils/index.md - Bins: api/utils/bins.md - - Distributions: api/utils/distributions.md - - Marginal 1D: api/utils/marginal1d.md - Numbers: api/utils/numbers.md - Price: api/utils/price.md - Types: api/utils/types.md diff --git a/notebooks/heston_divfm_fit.py b/notebooks/heston_divfm_fit.py index 61d6f3cb..774f9d4c 100644 --- a/notebooks/heston_divfm_fit.py +++ b/notebooks/heston_divfm_fit.py @@ -29,7 +29,7 @@ def _(): from quantflow.options.divfm.trainer import DayData, DIVFMTrainer from quantflow.options.pricer import OptionPricer from quantflow.sp.heston import HestonJ - from quantflow.utils.distributions import DoubleExponential + from quantflow.dists.distributions1d import DoubleExponential # --------------------------------------------------------------------------- # Grid settings @@ -245,9 +245,9 @@ def _(mo, net, np, torch): for i in range(1, 5): # Reshape the 1D factor output back into the 2D grid shape Z = factors_pred[:, i].reshape(M.shape) - + fig = go.Figure(data=[go.Surface(x=M, y=T, z=Z, colorscale='Viridis')]) - + fig.update_layout( title=f"DIVFM Learned Factor {i}", scene=dict( @@ -259,7 +259,7 @@ def _(mo, net, np, torch): ), margin=dict(l=0, r=0, b=0, t=40) ) - + tabs_dict[f"Factor {i}"] = fig # 4. Display them in an interactive tabbed interface diff --git a/pyproject.toml b/pyproject.toml index eb53f7a9..d5dd05aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,7 +131,7 @@ exclude_also = [ "pragma: no cover", "raise NotImplementedError", "if TYPE_CHECKING:", - "@abstract", + "@abstractmethod", ] [tool.coverage.html] diff --git a/quantflow/dists/__init__.py b/quantflow/dists/__init__.py index 14048423..9a4a0988 100644 --- a/quantflow/dists/__init__.py +++ b/quantflow/dists/__init__.py @@ -1,4 +1,26 @@ -from .base import Distribution, MeanAndCov +from .base import Distribution, MeanAndCov, MvDistribution +from .distributions1d import Distribution1D, DoubleExponential, Exponential, Normal +from .marginal1d import ( + Marginal1D, + OptionPricingCosResult, + OptionPricingMethod, + OptionPricingResult, + default_bounds, +) from .mv_normal import MvNormal -__all__ = ["Distribution", "MeanAndCov", "MvNormal"] +__all__ = [ + "Distribution", + "MeanAndCov", + "MvDistribution", + "MvNormal", + "Marginal1D", + "OptionPricingCosResult", + "OptionPricingMethod", + "OptionPricingResult", + "default_bounds", + "Exponential", + "Distribution1D", + "Normal", + "DoubleExponential", +] diff --git a/quantflow/dists/base.py b/quantflow/dists/base.py index 99bf66df..55aeae9e 100644 --- a/quantflow/dists/base.py +++ b/quantflow/dists/base.py @@ -25,14 +25,8 @@ def sample( """Draw random samples from the distribution.""" ... - @abstractmethod - def log_pdf( - self, - x: Annotated[FloatArray, Doc("Point at which to evaluate the log-density.")], - ) -> FloatArray: - """Log probability density at $x$.""" - ... +class MvDistribution(Distribution): @abstractmethod def mean_and_cov(self) -> MeanAndCov: """Mean vector and covariance matrix of the distribution.""" diff --git a/quantflow/utils/distributions.py b/quantflow/dists/distributions1d.py similarity index 78% rename from quantflow/utils/distributions.py rename to quantflow/dists/distributions1d.py index 40c4bdf4..7077da67 100644 --- a/quantflow/utils/distributions.py +++ b/quantflow/dists/distributions1d.py @@ -1,4 +1,3 @@ -from abc import abstractmethod from typing import Self import numpy as np @@ -6,8 +5,8 @@ from scipy import stats from typing_extensions import Annotated, Doc -from .marginal import Marginal1D -from .types import FloatArray, FloatArrayLike, Vector +from ..utils.types import FloatArray, FloatArrayLike, Vector +from .marginal1d import Marginal1D class Distribution1D(Marginal1D): @@ -15,10 +14,6 @@ class Distribution1D(Marginal1D): jump distributions in [CompoundPoisson][quantflow.sp.poisson.CompoundPoissonProcess] """ - @abstractmethod - def sample(self, n: Annotated[int, Doc("Number of samples to draw")]) -> np.ndarray: - """Sample from the distribution""" - @classmethod def from_variance_and_asymmetry(cls, variance: float, asymmetry: float) -> Self: """Create a distribution from variance and asymmetry""" @@ -44,9 +39,9 @@ class Exponential(Distribution1D): It is a special case of the gamma distribution and is given by - $$ - f(x) = \lambda e^{-\lambda x}\ \ \forall x \geq 0 - $$ + \begin{equation} + f(x) = \lambda e^{-\lambda x}\ \ \forall x \geq 0 + \end{equation} """ decay: float = Field( @@ -59,7 +54,7 @@ def scale(self) -> float: return 1 / self.decay @property - def scale2(self) -> float: + def _scale2(self) -> float: return self.scale**2 def characteristic(self, u: Vector) -> Vector: @@ -75,24 +70,28 @@ def mean(self) -> float: return self.scale def variance(self) -> float: - return self.scale2 + return self._scale2 - def sample(self, n: int) -> np.ndarray: - return np.random.exponential(scale=self.scale, size=n) + def sample( + self, + size: Annotated[int, Doc("Number of samples to draw.")] = 1, + ) -> FloatArray: + """Draw random samples from the exponential distribution.""" + return np.random.exponential(scale=self.scale, size=size) def support(self, points: int = 100, *, std_mult: float = 4) -> FloatArray: return np.linspace(0, std_mult * np.max(self.std()), points) - def pdf(self, x: FloatArrayLike) -> FloatArrayLike: + def pdf_analytical(self, x: FloatArrayLike) -> FloatArrayLike: """The analytical PDF of the exponential distribution as defined above""" return self.decay * np.exp(-self.decay * x) - def cdf(self, x: FloatArrayLike) -> FloatArrayLike: + def cdf_analytical(self, x: FloatArrayLike) -> FloatArrayLike: r"""The analytical CDF of the exponential distribution - $$ - F(x) = 1 - e^{-\lambda x}\ \ \forall x \geq 0 - $$ + \begin{equation} + F(x) = 1 - e^{-\lambda x}\ \ \forall x \geq 0 + \end{equation} """ return 1.0 - np.exp(-self.decay * x) @@ -129,8 +128,12 @@ def mean(self) -> float: def variance(self) -> float: return self.sigma2 - def sample(self, n: int) -> np.ndarray: - return np.random.normal(loc=self.mu, scale=self.sigma, size=n) + def sample( + self, + size: Annotated[int, Doc("Number of samples to draw.")] = 1, + ) -> FloatArray: + """Draw random samples from the normal distribution.""" + return np.random.normal(loc=self.mu, scale=self.sigma, size=size) def support(self, points: int = 100, *, std_mult: float = 4) -> FloatArray: return np.linspace( @@ -139,12 +142,20 @@ def support(self, points: int = 100, *, std_mult: float = 4) -> FloatArray: points, ) + def pdf_analytical(self, x: FloatArrayLike) -> FloatArrayLike: + """The analytical PDF of the normal distribution as defined above""" + return stats.norm.pdf(x, loc=self.mu, scale=self.sigma) + + def cdf_analytical(self, x: FloatArrayLike) -> FloatArrayLike: + """The analytical CDF of the normal distribution""" + return stats.norm.cdf(x, loc=self.mu, scale=self.sigma) + def set_variance(self, variance: float) -> None: """Set the variance of the distribution""" self.sigma = np.sqrt(variance) -class DoubleExponential(Exponential): +class DoubleExponential(Distribution1D): r"""The generalized double exponential distribution This is also know as the Asymmetric Laplace distribution which is @@ -180,10 +191,15 @@ class DoubleExponential(Exponential): ), ) + @property + def scale(self) -> float: + """The scale parameter, the inverse of the `decay` rate""" + return 1 / self.decay + @property def log_kappa(self) -> float: """The log of the - [kappa][quantflow.utils.distributions.DoubleExponential.kappa] parameter""" + [kappa][..kappa] parameter""" return np.log(self.kappa) @classmethod @@ -226,16 +242,25 @@ def mean(self) -> float: def variance(self) -> float: return stats.laplace_asymmetric.var(self.kappa, loc=self.loc, scale=self.scale) - def pdf(self, x: FloatArrayLike) -> FloatArrayLike: + def pdf_analytical(self, x: FloatArrayLike) -> FloatArrayLike: """The analytical PDF as defined above""" return stats.laplace_asymmetric.pdf( x, self.kappa, loc=self.loc, scale=self.scale ) - def sample(self, n: int) -> np.ndarray: + def cdf_analytical(self, x: FloatArrayLike) -> FloatArrayLike: + """The analytical CDF of the double exponential distribution""" + return stats.laplace_asymmetric.cdf( + x, self.kappa, loc=self.loc, scale=self.scale + ) + + def sample( + self, + size: Annotated[int, Doc("Number of samples to draw.")] = 1, + ) -> FloatArray: """Sample from the double exponential distribution""" return stats.laplace_asymmetric.rvs( - self.kappa, loc=self.loc, scale=self.scale, size=n + self.kappa, loc=self.loc, scale=self.scale, size=size ) def support(self, points: int = 100, *, std_mult: float = 4) -> FloatArray: diff --git a/quantflow/utils/marginal.py b/quantflow/dists/marginal1d.py similarity index 87% rename from quantflow/utils/marginal.py rename to quantflow/dists/marginal1d.py index e8303fdc..4862ae62 100644 --- a/quantflow/utils/marginal.py +++ b/quantflow/dists/marginal1d.py @@ -10,8 +10,10 @@ from scipy.optimize import Bounds from typing_extensions import Annotated, Doc, NamedTuple -from .transforms import Transform, TransformResult, default_bounds -from .types import FloatArray, FloatArrayLike, Vector +from quantflow.utils.transforms import Transform, TransformResult, default_bounds +from quantflow.utils.types import FloatArray, FloatArrayLike, Vector + +from .base import Distribution class OptionPricingMethod(enum.StrEnum): @@ -124,8 +126,8 @@ def call_greeks(self, log_strike: float) -> Greeks: ) -class Marginal1D(BaseModel, ABC, extra="forbid"): - r"""Abstract 1D marginal distribution with Fourier-based option pricing. +class Marginal1D(Distribution, extra="forbid"): + r"""Abstract 1D distribution with Fourier-based option pricing. This class represents the marginal distribution. It provides methods to compute the pdf, cdf, and option @@ -327,9 +329,9 @@ def call_option_cos( The $e^k$ factor converts from strike-normalised to forward-space pricing. Returns an - [OptionPricingCosResult][quantflow.utils.marginal.OptionPricingCosResult] + [OptionPricingCosResult][quantflow.dists.OptionPricingCosResult] with the precomputed coefficient vector. Use - [call_price][quantflow.utils.marginal.OptionPricingCosResult.call_price] + [call_price][quantflow.dists.OptionPricingCosResult.call_price] to evaluate at arbitrary log-strikes in $O(N)$ per strike. """ n = n or 128 @@ -425,8 +427,33 @@ def cdf( ), ], ) -> FloatArrayLike: + """Compute the cumulative distribution function. + + It returns the [cdf_analytical][..cdf_analytical] + when available, otherwise it interpolates the cdf obtained from the + characteristic function via + [cdf_from_characteristic][..cdf_from_characteristic]. """ - Compute the cumulative distribution function + try: + return self.cdf_analytical(x) + except NotImplementedError: + result = self.cdf_from_characteristic() + return np.interp(x, result.x, result.y) + + def cdf_analytical( + self, + x: Annotated[ + FloatArrayLike, + Doc( + "Location in the stochastic process domain space. If a numpy array," + " the output should have the same shape as the input." + ), + ], + ) -> FloatArrayLike: + """Analytical cumulative distribution function. + + Optional to implement; raises ``NotImplementedError`` if not available, + in which case [cdf][..cdf] falls back to the characteristic function. """ raise NotImplementedError("Analytical CDF not available") @@ -455,7 +482,25 @@ def cdf_from_characteristic( Doc("Number of points for the frequency grid. Overrides n if provided."), ] = None, ) -> TransformResult: - raise NotImplementedError("CDF not available") + """Compute the cumulative distribution function from the characteristic + function. + + The density from [pdf_from_characteristic][..pdf_from_characteristic] is + cumulatively integrated over the space grid and normalised to one. + """ + density = self.pdf_from_characteristic( + n, + max_frequency=max_frequency, + simpson_rule=simpson_rule, + use_fft=use_fft, + frequency_n=frequency_n, + ) + x = density.x + pdf = np.clip(np.real(density.y), 0.0, None) + cdf = np.concatenate( + ([0.0], np.cumsum(0.5 * (pdf[1:] + pdf[:-1]) * np.diff(x))) + ) + return TransformResult(x=x, y=cdf / cdf[-1]) def cdf_jacobian( self, @@ -688,13 +733,33 @@ def pdf( ), ], ) -> FloatArrayLike: + """Compute the probability density (or mass) function. + + It returns the analytical pdf from [pdf_analytical][..pdf_analytical] + when available, otherwise it interpolates the pdf obtained from the + characteristic function via + [pdf_from_characteristic][..pdf_from_characteristic]. """ - Computes the probability density (or mass) function of the process. + try: + return self.pdf_analytical(x) + except NotImplementedError: + density = self.pdf_from_characteristic() + return np.interp(x, density.x, np.real(density.y)) - It has a base implementation that computes the pdf from the - [cdf][quantflow.utils.marginal.Marginal1D.cdf] method, but a subclass should - overload this method if a - more optimized way of computing it is available. + def pdf_analytical( + self, + x: Annotated[ + FloatArrayLike, + Doc( + "Location in the stochastic process domain space. If a numpy array," + " the output should have the same shape as the input." + ), + ], + ) -> FloatArrayLike: + """Analytical probability density (or mass) function. + + Optional to implement; raises ``NotImplementedError`` if not available, + in which case [pdf][..pdf] falls back to the characteristic function. """ raise NotImplementedError("Analytical PDF not available") @@ -713,7 +778,7 @@ def pdf_from_characteristic( Doc( "The maximum frequency to use in the transform. If not provided," " the value from the [frequency_range]" - "[quantflow.utils.marginal.Marginal1D.frequency_range] method is used." + "[..frequency_range] method is used." " Only needed for special cases/testing." ), ] = None, @@ -750,12 +815,27 @@ def pdf_jacobian( """ Jacobian of the pdf with respect to the parameters of the process. It has a base implementation that computes it from the - [cdf_jacobian][quantflow.utils.marginal.Marginal1D.cdf_jacobian] method, + [cdf_jacobian][..cdf_jacobian] method, but a subclass should overload this method if a more optimized way of computing it is available. """ return self.cdf_jacobian(x) - self.cdf_jacobian(x - 1) + def sample( + self, + size: Annotated[int, Doc("Number of samples to draw.")] = 1, + ) -> FloatArray: + """Draw samples by inverse-transform sampling of the CDF. + + The [cdf][..cdf] is evaluated on the [support][..support] grid and + inverted by interpolation against uniform draws. + + Subclasses with a closed-form sampler should override this. + """ + x = self.support() + cdf = np.asarray(self.cdf(x), dtype=float) + return cast(FloatArray, np.interp(np.random.uniform(size=size), cdf, x)) + def std(self) -> FloatArrayLike: """Standard deviation at a time horizon""" return np.sqrt(self.variance()) diff --git a/quantflow/dists/mv_normal.py b/quantflow/dists/mv_normal.py index 783fb622..44b70af7 100644 --- a/quantflow/dists/mv_normal.py +++ b/quantflow/dists/mv_normal.py @@ -5,10 +5,10 @@ from quantflow.utils.types import FloatArray -from .base import Distribution, MeanAndCov +from .base import MeanAndCov, MvDistribution -class MvNormal(Distribution, arbitrary_types_allowed=True): +class MvNormal(MvDistribution, arbitrary_types_allowed=True): r"""Multivariate normal distribution $\mathcal{N}(\mu, \Sigma)$.""" mean: Annotated[ diff --git a/quantflow/options/divfm/pricer.py b/quantflow/options/divfm/pricer.py index d4553eb3..d02f71a6 100644 --- a/quantflow/options/divfm/pricer.py +++ b/quantflow/options/divfm/pricer.py @@ -3,7 +3,7 @@ import numpy as np from pydantic import Field -from quantflow.utils.marginal import Greeks, OptionPricingResult +from quantflow.dists.marginal1d import Greeks, OptionPricingResult from quantflow.utils.types import FloatArray, FloatArrayLike from ..bs import black_call diff --git a/quantflow/options/pricer.py b/quantflow/options/pricer.py index f3a65910..4c5863c5 100644 --- a/quantflow/options/pricer.py +++ b/quantflow/options/pricer.py @@ -9,9 +9,9 @@ from pydantic import BaseModel, Field, computed_field from typing_extensions import Annotated, Doc +from quantflow.dists.marginal1d import OptionPricingMethod, OptionPricingResult from quantflow.sp.base import StochasticProcess1D from quantflow.utils import plot -from quantflow.utils.marginal import OptionPricingMethod, OptionPricingResult from quantflow.utils.numbers import DecimalNumber, to_decimal from ..utils.types import FloatArray, FloatArrayLike diff --git a/quantflow/sp/base.py b/quantflow/sp/base.py index cd255c35..d59c9a32 100755 --- a/quantflow/sp/base.py +++ b/quantflow/sp/base.py @@ -4,12 +4,12 @@ from typing import Generic, TypeVar import numpy as np -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from scipy.optimize import Bounds from typing_extensions import Annotated, Doc +from quantflow.dists.marginal1d import Marginal1D, default_bounds from quantflow.ta.paths import Paths -from quantflow.utils.marginal import Marginal1D, default_bounds from quantflow.utils.numbers import sigfig from quantflow.utils.transforms import bound_from_any from quantflow.utils.types import FloatArray, FloatArrayLike, Vector @@ -140,8 +140,7 @@ def support(self, mean: float, std: float, points: int) -> FloatArray: P = TypeVar("P", bound=StochasticProcess1D) -class StochasticProcess1DMarginal(Marginal1D, Generic[P]): - model_config = ConfigDict(arbitrary_types_allowed=True) +class StochasticProcess1DMarginal(Marginal1D, Generic[P], arbitrary_types_allowed=True): process: P t: FloatArrayLike @@ -159,10 +158,10 @@ def frequency_range(self, max_frequency: float | None = None) -> Bounds: std = float(np.min(self.std())) return self.process.frequency_range(std, max_frequency=max_frequency) - def pdf(self, x: FloatArrayLike) -> FloatArrayLike: + def pdf_analytical(self, x: FloatArrayLike) -> FloatArrayLike: return self.process.analytical_pdf(self.t, x) - def cdf(self, x: FloatArrayLike) -> FloatArrayLike: + def cdf_analytical(self, x: FloatArrayLike) -> FloatArrayLike: return self.process.analytical_cdf(self.t, x) def mean(self) -> FloatArrayLike: diff --git a/quantflow/sp/heston.py b/quantflow/sp/heston.py index 550d694b..2cc09642 100644 --- a/quantflow/sp/heston.py +++ b/quantflow/sp/heston.py @@ -209,7 +209,7 @@ class HestonJ(Heston, Generic[D]): distributions **D**. The Bates model is obtained by using the - [Normal][quantflow.utils.distributions.Normal] distribution for the jump sizes. + [Normal][quantflow.dists.Normal] distribution for the jump sizes. """ jumps: CompoundPoissonProcess[D] = Field(description="Jump process") @@ -221,8 +221,8 @@ def create( # type: ignore [override] type[D], Doc( "The distribution of jump size (currently only" - " [Normal][quantflow.utils.distributions.Normal] and" - " [DoubleExponential][quantflow.utils.distributions.DoubleExponential]" + " [Normal][quantflow.dists.Normal] and" + " [DoubleExponential][quantflow.dists.DoubleExponential]" " are supported)" ), ], @@ -267,7 +267,7 @@ def create( # type: ignore [override] ] = 0.0, ) -> HestonJ[D]: r"""Create an Heston model with - [DoubleExponential][quantflow.utils.distributions.DoubleExponential] jumps. + [DoubleExponential][quantflow.dists.DoubleExponential] jumps. To understand the parameters lets introduce the following notation: diff --git a/quantflow/sp/jump_diffusion.py b/quantflow/sp/jump_diffusion.py index 0186b8e3..07f02d72 100644 --- a/quantflow/sp/jump_diffusion.py +++ b/quantflow/sp/jump_diffusion.py @@ -78,8 +78,8 @@ def create( type[D], Doc( "The distribution of jump sizes. Currently " - "[Normal][quantflow.utils.distributions.Normal] and " - "[DoubleExponential][quantflow.utils.distributions.DoubleExponential] " + "[Normal][quantflow.dists.Normal] and " + "[DoubleExponential][quantflow.dists.DoubleExponential] " "are supported. If the jump distribution is set to the Normal " "distribution, the model reduces to a Merton jump-diffusion." ), diff --git a/quantflow/sp/ou.py b/quantflow/sp/ou.py index 4359ecef..518060a9 100755 --- a/quantflow/sp/ou.py +++ b/quantflow/sp/ou.py @@ -8,8 +8,8 @@ from scipy.stats import gamma, norm from typing_extensions import Annotated, Doc +from ..dists.distributions1d import Exponential from ..ta.paths import Paths -from ..utils.distributions import Exponential from ..utils.types import Float, FloatArrayLike, Vector from .base import IntensityProcess, StochasticProcess1D from .poisson import CompoundPoissonProcess, D @@ -212,7 +212,7 @@ def characteristic_exponent(self, t: FloatArrayLike, u: Vector) -> Vector: \end{equation} Derivation. The characteristic function of the - [Exponential][quantflow.utils.distributions.Exponential.characteristic] + [Exponential][quantflow.dists.Exponential.characteristic] jumps is \begin{equation} diff --git a/quantflow/sp/poisson.py b/quantflow/sp/poisson.py index 5ba20bc4..b13c2ff5 100755 --- a/quantflow/sp/poisson.py +++ b/quantflow/sp/poisson.py @@ -10,8 +10,8 @@ from scipy.stats import poisson from typing_extensions import Annotated, Doc +from quantflow.dists.distributions1d import Distribution1D from quantflow.ta.paths import Paths -from quantflow.utils.distributions import Distribution1D from quantflow.utils.functions import factorial from quantflow.utils.transforms import TransformResult from quantflow.utils.types import FloatArray, FloatArrayLike, Vector @@ -218,8 +218,8 @@ def create( type[D], Doc( "The distribution of jump size (currently only" - " [Normal][quantflow.utils.distributions.Normal] and" - " [DoubleExponential][quantflow.utils.distributions.DoubleExponential]" + " [Normal][quantflow.dists.Normal] and" + " [DoubleExponential][quantflow.dists.DoubleExponential]" " are supported)" ), ], diff --git a/quantflow/ta/kalman.py b/quantflow/ta/kalman.py index 450e914f..4227ab8c 100644 --- a/quantflow/ta/kalman.py +++ b/quantflow/ta/kalman.py @@ -7,7 +7,7 @@ from scipy import linalg from typing_extensions import Annotated, Doc -from quantflow.dists import Distribution, MeanAndCov, MvNormal +from quantflow.dists import MeanAndCov, MvDistribution, MvNormal from quantflow.utils.types import FloatArray # --------------------------------------------------------------------------- @@ -30,7 +30,7 @@ class StateSpaceModel(BaseModel, ABC): """ @abstractmethod - def get_px0(self) -> Distribution: + def get_px0(self) -> MvDistribution: r"""Distribution $p_x(x_0)$ of the initial state $x_0$.""" @abstractmethod @@ -38,7 +38,7 @@ def get_px( self, t: Annotated[int, Doc("Time index $t$.")], xp: Annotated[FloatArray, Doc("State at time $t-1$.")], - ) -> Distribution: + ) -> MvDistribution: r"""Distribution $p_x\left(x_t \mid x_{t-1}\right)$ of $x_t$ given $x_{t-1} = x_p$.""" @@ -48,7 +48,7 @@ def get_py( t: Annotated[int, Doc("Time index $t$.")], xp: Annotated[FloatArray, Doc("State at time $t-1$.")], x: Annotated[FloatArray, Doc("State at time $t$.")], - ) -> Distribution: + ) -> MvDistribution: r"""Distribution of $y_t$ given $x_{t-1}=x_p$ and $x_t=x$.""" # -- Simulation helpers ---------------------------------------------- diff --git a/quantflow/utils/plot.py b/quantflow/utils/plot.py index e5bc45ef..43f85ba4 100644 --- a/quantflow/utils/plot.py +++ b/quantflow/utils/plot.py @@ -5,7 +5,7 @@ import pandas as pd from scipy.stats import norm -from .marginal import Marginal1D +from ..dists.marginal1d import Marginal1D from .types import FloatArray if TYPE_CHECKING: diff --git a/quantflow_tests/test_app.py b/quantflow_tests/test_app.py index db85a3c5..14bc099b 100644 --- a/quantflow_tests/test_app.py +++ b/quantflow_tests/test_app.py @@ -1,4 +1,6 @@ +import asyncio from datetime import date, timedelta +from typing import Iterator from unittest.mock import AsyncMock import numpy as np @@ -6,8 +8,10 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from fluid.utils.redis import FluidRedis from app.__main__ import crate_app +from app.api.deps import RedisCache @pytest.fixture @@ -33,8 +37,24 @@ def app(mock_fmp: AsyncMock) -> FastAPI: @pytest.fixture -def client(app: FastAPI) -> TestClient: - return TestClient(app) +def clear_cache() -> None: + async def _clear() -> None: + # Use a dedicated redis client so the temporary event loop created by + # asyncio.run does not leave a stale connection in the app's shared + # pool (which the TestClient lifespan would later fail to close). + redis = FluidRedis.create() + try: + await RedisCache.clear(redis.redis_cli) + finally: + await redis.close() + + asyncio.run(_clear()) + + +@pytest.fixture +def client(app: FastAPI, clear_cache: None) -> Iterator[TestClient]: + with TestClient(app) as test_client: + yield test_client def test_status(client: TestClient) -> None: @@ -154,3 +174,40 @@ def test_yield_curve(client: TestClient) -> None: assert "curve" in data assert "ttm" in data assert "rates" in data + + +def test_cointegration_endpoint(app: FastAPI, client: TestClient) -> None: + rng = np.random.default_rng(4) + days = pd.date_range("2024-01-01", periods=120, freq="D") + trend = np.cumsum(rng.normal(0, 0.015, len(days))) + 5 + series = { + "BTCUSD": trend + rng.normal(0, 0.01, len(days)), + "ETHUSD": 0.9 * trend + rng.normal(0, 0.01, len(days)) + 0.3, + "SOLUSD": 1.1 * trend + rng.normal(0, 0.01, len(days)) - 0.4, + } + + async def prices( + symbol: str, convert_to_date: bool, frequency: object + ) -> pd.DataFrame: + return pd.DataFrame( + { + "date": days.date if convert_to_date else days, + "close": np.exp(series[symbol]), + } + ) + + app.state.fmp.prices = AsyncMock(side_effect=prices) + + response = client.get("/.api/cointegration?frequency=1min") + cached_response = client.get("/.api/cointegration?frequency=1min") + + assert response.status_code == 200 + assert cached_response.status_code == 200 + assert cached_response.json() == response.json() + data = response.json() + assert data["dates"][:2] == ["2024-01-01", "2024-01-02"] + assert len(data["dates"]) == 120 + assert len(data["residuals"]) == 120 + assert len(data["deltas"]) == 3 + assert np.mean(data["residuals"]) == pytest.approx(0.0, abs=1.0e-12) + assert app.state.fmp.prices.await_count == 3 diff --git a/quantflow_tests/test_distributions.py b/quantflow_tests/test_distributions.py index ec60f94a..139aaa1e 100644 --- a/quantflow_tests/test_distributions.py +++ b/quantflow_tests/test_distributions.py @@ -1,7 +1,7 @@ import numpy as np import pytest -from quantflow.utils.distributions import DoubleExponential +from quantflow.dists.distributions1d import DoubleExponential def test_double_exponential(): diff --git a/quantflow_tests/test_heston.py b/quantflow_tests/test_heston.py index 71f68722..ba506de8 100644 --- a/quantflow_tests/test_heston.py +++ b/quantflow_tests/test_heston.py @@ -1,8 +1,8 @@ import numpy as np import pytest +from quantflow.dists.distributions1d import DoubleExponential from quantflow.sp.heston import DoubleHeston, DoubleHestonJ, Heston, HestonJ -from quantflow.utils.distributions import DoubleExponential from quantflow_tests.utils import characteristic_tests diff --git a/quantflow_tests/test_heston_calibration.py b/quantflow_tests/test_heston_calibration.py index 1c841db5..e07c0e6a 100644 --- a/quantflow_tests/test_heston_calibration.py +++ b/quantflow_tests/test_heston_calibration.py @@ -2,6 +2,7 @@ import numpy as np +from quantflow.dists.distributions1d import DoubleExponential from quantflow.options.calibration import ( DoubleHestonCalibration, DoubleHestonJCalibration, @@ -10,7 +11,6 @@ ) from quantflow.options.pricer import OptionPricer from quantflow.sp.heston import DoubleHeston, DoubleHestonJ, Heston, HestonJ -from quantflow.utils.distributions import DoubleExponential def test_heston_calibration_get_set_and_penalize(vol_surface) -> None: diff --git a/quantflow_tests/test_jump_diffusion.py b/quantflow_tests/test_jump_diffusion.py index 168ec4c8..84364c82 100644 --- a/quantflow_tests/test_jump_diffusion.py +++ b/quantflow_tests/test_jump_diffusion.py @@ -2,8 +2,8 @@ import pytest from scipy.stats import norm, poisson +from quantflow.dists.distributions1d import Normal from quantflow.sp.jump_diffusion import JumpDiffusion -from quantflow.utils.distributions import Normal @pytest.fixture diff --git a/quantflow_tests/test_options.py b/quantflow_tests/test_options.py index f88cf533..aa209b2d 100644 --- a/quantflow_tests/test_options.py +++ b/quantflow_tests/test_options.py @@ -6,6 +6,7 @@ import pytest from ccy.core.daycounter import DayCounter +from quantflow.dists.distributions1d import DoubleExponential from quantflow.options import bs from quantflow.options.calibration import ( DoubleHestonCalibration, @@ -26,7 +27,6 @@ ) from quantflow.sp.heston import DoubleHeston, DoubleHestonJ, Heston, HestonJ from quantflow.utils.dates import utcnow -from quantflow.utils.distributions import DoubleExponential from quantflow_tests.utils import has_plotly a = np.asarray diff --git a/quantflow_tests/test_options_pricer.py b/quantflow_tests/test_options_pricer.py index 1da27194..c48509b3 100644 --- a/quantflow_tests/test_options_pricer.py +++ b/quantflow_tests/test_options_pricer.py @@ -2,10 +2,10 @@ import pytest +from quantflow.dists.distributions1d import DoubleExponential from quantflow.options.pricer import OptionPricer, OptionType from quantflow.sp.heston import Heston, HestonJ from quantflow.sp.wiener import WienerProcess -from quantflow.utils.distributions import DoubleExponential from quantflow_tests.utils import has_plotly diff --git a/quantflow_tests/test_ou.py b/quantflow_tests/test_ou.py index 9cd76be9..b3064d0a 100644 --- a/quantflow_tests/test_ou.py +++ b/quantflow_tests/test_ou.py @@ -2,9 +2,9 @@ import pytest from scipy.integrate import cumulative_trapezoid +from quantflow.dists.distributions1d import Exponential from quantflow.sp.ou import GammaOU, Vasicek from quantflow.sp.poisson import CompoundPoissonProcess -from quantflow.utils.distributions import Exponential from quantflow_tests.utils import analytical_tests, characteristic_tests diff --git a/quantflow_tests/test_poisson.py b/quantflow_tests/test_poisson.py index dab84a6f..875f9a0b 100644 --- a/quantflow_tests/test_poisson.py +++ b/quantflow_tests/test_poisson.py @@ -3,10 +3,10 @@ import numpy as np import pytest +from quantflow.dists.distributions1d import DoubleExponential, Exponential, Normal from quantflow.sp.dsp import DSP from quantflow.sp.ou import GammaOU from quantflow.sp.poisson import CompoundPoissonProcess, PoissonProcess -from quantflow.utils.distributions import DoubleExponential, Exponential, Normal from quantflow_tests.utils import analytical_tests, characteristic_tests diff --git a/quantflow_tests/test_wiener.py b/quantflow_tests/test_wiener.py index af9dc561..94289a69 100644 --- a/quantflow_tests/test_wiener.py +++ b/quantflow_tests/test_wiener.py @@ -32,6 +32,16 @@ def test_sampling(wiener: WienerProcess) -> None: assert std[0] == 0 +def test_marginal_sample(wiener: WienerProcess) -> None: + np.random.seed(42) + marginal = wiener.marginal(1) + samples = marginal.sample(50000) + assert samples.shape == (50000,) + # inverse-transform sampling of the normal CDF recovers mean and std + assert samples.mean() == pytest.approx(0, abs=1e-2) + assert samples.std() == pytest.approx(float(marginal.std()), rel=5e-2) + + def test_support(wiener: WienerProcess) -> None: m = wiener.marginal(0.01) pdf = m.pdf_from_characteristic(32) diff --git a/quantflow_tests/utils.py b/quantflow_tests/utils.py index f000380c..34696878 100644 --- a/quantflow_tests/utils.py +++ b/quantflow_tests/utils.py @@ -7,8 +7,8 @@ from aiohttp.client_exceptions import ClientError from docs.examples._utils import FIXTURES +from quantflow.dists.marginal1d import Marginal1D from quantflow.sp.base import StochasticProcess1D -from quantflow.utils.marginal import Marginal1D from quantflow.utils.plot import check_plotly try: From 3bb1023920358fa88eed5286bb554a3a9bc46d60 Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 7 Jun 2026 14:32:44 +0100 Subject: [PATCH 2/4] Fix lint --- app/api/deps.py | 4 +-- quantflow/dists/__init__.py | 2 -- quantflow_tests/test_app.py | 57 +++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/app/api/deps.py b/app/api/deps.py index 55cba398..88c85ebe 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -64,7 +64,7 @@ async def from_cache(self, loader: Callable[[], Awaitable[M]]) -> M: return await self.set_cache(await loader()) try: return self.Model.model_validate_json(value) - except json.JSONDecodeError: # pragma: no cover + except json.JSONDecodeError: # pragma: no cover logger.exception(f"Failed to decode cache value for key {self.key}") return await self.set_cache(await loader()) @@ -77,7 +77,7 @@ async def set_cache(self, value: M) -> M: @classmethod async def clear(cls, redis: Redis) -> int: """Delete all cache entries under the prefix""" - cache = cls(redis=redis, Model=BaseModel, key="*") + cache = cls(redis=redis, Model=cast(type[M], BaseModel), key="*") keys = [key async for key in cache.redis.scan_iter(f"{cache.prefix}:*")] if not keys: return 0 diff --git a/quantflow/dists/__init__.py b/quantflow/dists/__init__.py index 9a4a0988..c908aaef 100644 --- a/quantflow/dists/__init__.py +++ b/quantflow/dists/__init__.py @@ -5,7 +5,6 @@ OptionPricingCosResult, OptionPricingMethod, OptionPricingResult, - default_bounds, ) from .mv_normal import MvNormal @@ -18,7 +17,6 @@ "OptionPricingCosResult", "OptionPricingMethod", "OptionPricingResult", - "default_bounds", "Exponential", "Distribution1D", "Normal", diff --git a/quantflow_tests/test_app.py b/quantflow_tests/test_app.py index 14bc099b..6827c4aa 100644 --- a/quantflow_tests/test_app.py +++ b/quantflow_tests/test_app.py @@ -10,8 +10,12 @@ from fastapi.testclient import TestClient from fluid.utils.redis import FluidRedis +import app.api.volatility as volatility from app.__main__ import crate_app from app.api.deps import RedisCache +from quantflow.options.inputs import VolSurfaceInputs +from quantflow.options.surface import VolSurfaceLoader +from quantflow_tests.utils import load_fixture_dict @pytest.fixture @@ -57,6 +61,32 @@ def client(app: FastAPI, clear_cache: None) -> Iterator[TestClient]: yield test_client +@pytest.fixture +def vol_surface_loader() -> VolSurfaceLoader: + inputs = VolSurfaceInputs(**load_fixture_dict("volsurface_eth.json")) + loader = VolSurfaceLoader( + asset=inputs.asset, + quote_curve=inputs.quote_curve, + asset_curve=inputs.asset_curve, + ) + for input in inputs.inputs: + loader.add(input) + return loader + + +@pytest.fixture +def mock_surface(vol_surface_loader: VolSurfaceLoader) -> Iterator[AsyncMock]: + # Patch _load_surface so the endpoint runs offline against the fixture + # loader instead of hitting Deribit/Yahoo. + load = AsyncMock(return_value=vol_surface_loader) + original = volatility._load_surface + volatility._load_surface = load + try: + yield load + finally: + volatility._load_surface = original + + def test_status(client: TestClient) -> None: response = client.get("/status") assert response.status_code == 200 @@ -176,6 +206,33 @@ def test_yield_curve(client: TestClient) -> None: assert "rates" in data +def test_volatility_surface(client: TestClient, mock_surface: AsyncMock) -> None: + response = client.get("/.api/volatility-surface?asset=ETH") + assert response.status_code == 200 + data = response.json() + assert "inputs" in data + assert "options" in data + assert len(data["options"]) > 0 + assert "rates" in data["quote_curve"] + assert "rates" in data["asset_curve"] + assert "forward" in data["forward_curve"] + assert len(data["forward_curve"]["ttm"]) == len(data["forward_curve"]["forward"]) + assert isinstance(data["pcp_forwards"], list) + option = data["options"][0] + assert "ttm" in option + assert mock_surface.await_count == 1 + + +def test_volatility_surface_cached(client: TestClient, mock_surface: AsyncMock) -> None: + response = client.get("/.api/volatility-surface?asset=ETH") + cached_response = client.get("/.api/volatility-surface?asset=ETH") + assert response.status_code == 200 + assert cached_response.status_code == 200 + assert cached_response.json() == response.json() + # second call served from redis, so the loader is built only once + assert mock_surface.await_count == 1 + + def test_cointegration_endpoint(app: FastAPI, client: TestClient) -> None: rng = np.random.default_rng(4) days = pd.date_range("2024-01-01", periods=120, freq="D") From 88a0b491205097166f0cc39462242c220d4a384a Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 7 Jun 2026 14:59:17 +0100 Subject: [PATCH 3/4] Remove redis --- .github/workflows/build.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 76520395..1ca8a86a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,16 +10,6 @@ on: jobs: build: runs-on: ubuntu-latest - services: - redis: - image: redis:7 - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 env: PYTHON_ENV: ci GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 9980eca0ca3aabe40f0f603d6565b9854d226b4f Mon Sep 17 00:00:00 2001 From: Luca Date: Sun, 7 Jun 2026 15:07:37 +0100 Subject: [PATCH 4/4] Start redis --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1ca8a86a..62b752d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Start Redis + uses: supercharge/redis-github-action@1.8.1 + with: + redis-version: 7 - name: Install uv run: pip install -U pip uv - name: Install dependencies