diff --git a/.vscode/launch.json b/.vscode/launch.json index 02ba5ff0..f52ec37b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,7 @@ "args": [ "-x", "-vvv", - "quantflow_tests/test_data.py::test_fed_yc", + "quantflow_tests/test_options.py", ] }, ] diff --git a/docs/api/options/black.md b/docs/api/options/black.md index 968c8802..16ff588d 100644 --- a/docs/api/options/black.md +++ b/docs/api/options/black.md @@ -17,3 +17,7 @@ where \(K\) is the strike price and \(F\) is the forward price of the underlying ::: quantflow.options.bs.black_vega ::: quantflow.options.bs.implied_black_volatility + +::: quantflow.options.bs.ImpliedVols + +::: quantflow.options.bs.ImpliedVol diff --git a/docs/index.md b/docs/index.md index ce864123..7c5c4d13 100644 --- a/docs/index.md +++ b/docs/index.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) diff --git a/pyproject.toml b/pyproject.toml index b08563c8..14892657 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quantflow" -version = "0.5.1" +version = "0.6.0" description = "quantitative analysis" authors = [ { name = "Luca Sbardella", email = "luca@quantmind.com" } ] license = "BSD-3-Clause" diff --git a/quantflow/__init__.py b/quantflow/__init__.py index d52bdc6c..48d40272 100644 --- a/quantflow/__init__.py +++ b/quantflow/__init__.py @@ -1,3 +1,3 @@ """Quantitative analysis and pricing""" -__version__ = "0.5.1" +__version__ = "0.6.0" diff --git a/quantflow/options/bs.py b/quantflow/options/bs.py index dead9262..2ea57dbf 100644 --- a/quantflow/options/bs.py +++ b/quantflow/options/bs.py @@ -1,29 +1,60 @@ +from typing import NamedTuple + import numpy as np -from scipy.optimize import RootResults, newton +from scipy.optimize import newton from scipy.stats import norm from typing_extensions import Annotated, Doc -from ..utils.types import FloatArray, FloatArrayLike +from ..utils.types import BoolArray, Float, FloatArray, FloatArrayLike + + +class ImpliedVol(NamedTuple): + """Result of implied volatility calculation""" + + value: Float + """The implied volatility in decimals (0.2 for 20%)""" + converged: bool + """Whether the root finding algorithm converged""" + + +class ImpliedVols(NamedTuple): + """Result of root finding algorithm""" + + values: FloatArray + """The implied volatilities in decimals (0.2 for 20%)""" + converged: BoolArray + """Whether the root finding algorithm converged""" + + def single(self) -> ImpliedVol: + """Return the first implied volatility and convergence status a + a single ImpliedVol""" + if len(self.values) != 1 or len(self.converged) != 1: + raise ValueError("Expected exactly one root and convergence status") + return ImpliedVol(value=self.values[0], converged=self.converged[0]) def black_call( - k: Annotated[np.ndarray, Doc("Vector or single value of log-strikes")], + k: Annotated[FloatArrayLike, Doc("Vector or single value of log-strikes")], sigma: Annotated[ FloatArrayLike, Doc( - "Corresponding vector or single value of implied volatilities (0.2 for 20%)" + "Corresponding vector or single value of implied volatilities " + "(0.2 for 20%)" ), ], ttm: Annotated[ FloatArrayLike, Doc("Corresponding vector or single value of Time to Maturity") ], -) -> np.ndarray: +) -> FloatArrayLike: kk = np.asarray(k) return black_price(kk, np.asarray(sigma), np.asarray(ttm), np.ones(kk.shape)) def black_price( - k: Annotated[np.ndarray, Doc("Vector of log-strikes")], + k: Annotated[ + FloatArrayLike, + Doc("Vector or single value of log-strikes"), + ], sigma: Annotated[ FloatArrayLike, Doc( @@ -36,7 +67,13 @@ def black_price( ttm: Annotated[ FloatArrayLike, Doc("Corresponding vector or single value of Time to Maturity") ], - s: Annotated[FloatArrayLike, Doc("Call/Put Flag (1 for call, -1 for put)")], + s: Annotated[ + FloatArrayLike, + Doc( + "Corresponding vector or single value of call/put flag " + "(1 for call, -1 for put)" + ), + ], ) -> np.ndarray: r"""Calculate the Black call/put option prices in forward terms from the following params @@ -61,17 +98,23 @@ def black_price( def black_delta( - k: Annotated[np.ndarray, Doc("a vector of moneyness, see above")], + k: Annotated[np.ndarray, Doc("Vector of log-strikes")], sigma: Annotated[ FloatArrayLike, - Doc("a corresponding vector of implied volatilities (0.2 for 20%)"), + Doc( + "Corresponding vector or single value of implied volatilities " + "(0.2 for 20%)" + ), ], ttm: Annotated[ FloatArrayLike, Doc("Corresponding vector or single value of Time to Maturity") ], s: Annotated[ FloatArrayLike, - Doc("Call/Put vector or single value Flag (1 for call, -1 for put)"), + Doc( + "Corresponding vector or single value of call/put flag " + "(1 for call, -1 for put)" + ), ], ) -> np.ndarray: r"""Calculate the Black call/put option delta from the moneyness, @@ -91,13 +134,18 @@ def black_delta( def black_vega( - k: Annotated[np.ndarray, Doc("a vector of moneyness, see above")], + k: Annotated[FloatArrayLike, Doc("Vector of log-strikes")], sigma: Annotated[ - np.ndarray, Doc("corresponding vector of implied volatilities (0.2 for 20%)") + FloatArrayLike, + Doc( + "Corresponding vector or single value of implied volatilities (0.2 for 20%)" + ), ], - ttm: Annotated[FloatArrayLike, Doc("Time to Maturity")], -) -> np.ndarray: - r"""Calculate the Black option vega from the moneyness, + ttm: Annotated[ + FloatArrayLike, Doc("Corresponding vector or single value of Time to Maturity") + ], +) -> FloatArrayLike: + r"""Calculate the Black option vega from the log-strikes, volatility and time to maturity. The vega is the same for calls and puts. $$ @@ -117,16 +165,49 @@ def black_vega( def implied_black_volatility( - k: Annotated[np.ndarray, Doc("Vector of log strikes")], - price: Annotated[np.ndarray, Doc("Corresponding vector of option_price/forward")], - ttm: Annotated[FloatArrayLike, Doc("Time to Maturity")], - initial_sigma: Annotated[FloatArray, Doc("Initial Volatility")], - call_put: Annotated[FloatArrayLike, Doc("Call/Put Flag")], -) -> RootResults: - """Calculate the implied block volatility via Newton's method""" - return newton( + k: Annotated[ + FloatArrayLike, + Doc("Vector or scalar of log strikes"), + ], + price: Annotated[ + FloatArrayLike, + Doc( + "Corresponding vector or scalar of option price in forward terms " + "(price divided by forward price)" + ), + ], + ttm: Annotated[ + FloatArrayLike, + Doc("Corresponding vector or single value of Time to Maturity"), + ], + initial_sigma: Annotated[ + FloatArrayLike, + Doc("Corresponding vector or single value of initial volatility"), + ], + call_put: Annotated[ + FloatArrayLike, + Doc( + "Corresponding vector or single value of call/put flag " + "(1 for call, -1 for put)" + ), + ], +) -> ImpliedVols: + """Calculate the implied black volatility via Newton's method + + It returns a scipy `RootResults` object which contains the implied volatility + in the `root` attribute. Implied volatility is in decimals (0.2 for 20%). + """ + if not np.isscalar(k) and np.isscalar(initial_sigma): + initial_sigma = np.full_like(k, initial_sigma) + result = newton( lambda x: black_price(k, x, ttm, call_put) - price, initial_sigma, fprime=lambda x: black_vega(k, x, ttm), full_output=True, ) + if hasattr(result, "root"): + return ImpliedVols(values=result.root, converged=result.converged) + else: + return ImpliedVols( + values=np.asarray([result[0]]), converged=np.asarray([result[1]]) + ) diff --git a/quantflow/options/calibration.py b/quantflow/options/calibration.py index 68908c1e..7f4b47a7 100644 --- a/quantflow/options/calibration.py +++ b/quantflow/options/calibration.py @@ -34,7 +34,7 @@ class OptionEntry: ttm: float moneyness: float options: list[OptionPrice] = field(default_factory=list) - _prince_range: Bounds | None = None + _price_range: Bounds | None = None def implied_vol_range(self) -> Bounds: """Get the range of implied volatilities""" @@ -50,10 +50,10 @@ def residual(self, price: float) -> float: def price_range(self) -> Bounds: """Get the range of prices""" - if self._prince_range is None: + if self._price_range is None: prices = tuple(float(option.call_price) for option in self.options) - self._prince_range = Bounds(min(prices), max(prices)) - return self._prince_range + self._price_range = Bounds(min(prices), max(prices)) + return self._price_range class VolModelCalibration(BaseModel, ABC, Generic[M], arbitrary_types_allowed=True): diff --git a/quantflow/options/inputs.py b/quantflow/options/inputs.py index f60b176b..b2654cc9 100644 --- a/quantflow/options/inputs.py +++ b/quantflow/options/inputs.py @@ -2,12 +2,11 @@ import enum from datetime import datetime -from decimal import Decimal from typing import Self, TypeVar from pydantic import BaseModel, Field -from quantflow.utils.numbers import ZERO +from quantflow.utils.numbers import ZERO, DecimalNumber P = TypeVar("P") @@ -70,12 +69,12 @@ def option(cls) -> Self: class VolSurfaceInput(BaseModel): """Base class for volatility surface inputs""" - bid: Decimal = Field(description="Bid price of the security") - ask: Decimal = Field(description="Ask price of the security") - open_interest: Decimal = Field( + bid: DecimalNumber = Field(description="Bid price of the security") + ask: DecimalNumber = Field(description="Ask price of the security") + open_interest: DecimalNumber = Field( default=ZERO, description="Open interest of the security" ) - volume: Decimal = Field(default=ZERO, description="Volume of the security") + volume: DecimalNumber = Field(default=ZERO, description="Volume of the security") class SpotInput(VolSurfaceInput): @@ -100,18 +99,26 @@ class ForwardInput(VolSurfaceInput): class OptionInput(VolSurfaceInput): """Input data for an option in the volatility surface""" - strike: Decimal = Field(description="Strike price of the option") + strike: DecimalNumber = Field(description="Strike price of the option") maturity: datetime = Field(description="Expiry date of the option") option_type: OptionType = Field(description="Type of the option - call or put") security_type: VolSecurityType = Field( default=VolSecurityType.option, description="Type of security for the volatility surface", ) - iv_bid: Decimal | None = Field( - default=None, description="Implied volatility based on the bid price" + iv_bid: DecimalNumber | None = Field( + default=None, + description=( + "Implied volatility based on the bid price as decimal number " + "(e.g. 0.2 for 20%)" + ), ) - iv_ask: Decimal | None = Field( - default=None, description="Implied volatility based on the ask price" + iv_ask: DecimalNumber | None = Field( + default=None, + description=( + "Implied volatility based on the ask price as decimal number " + "(e.g. 0.2 for 20%)" + ), ) diff --git a/quantflow/options/pricer.py b/quantflow/options/pricer.py index 758ba651..9f9972e3 100644 --- a/quantflow/options/pricer.py +++ b/quantflow/options/pricer.py @@ -60,7 +60,7 @@ def implied_vols(self) -> FloatArray: ttm=self.ttm, initial_sigma=0.5 * np.ones_like(self.moneyness), call_put=1.0, - ).root + ).values @property def df(self) -> pd.DataFrame: @@ -97,7 +97,9 @@ def max_moneyness_ttm( def black(self) -> MaturityPricer: """Calculate the Maturity Result for the Black model with same std""" return self._replace( - call=black_call(self.moneyness, self.std / np.sqrt(self.ttm), ttm=self.ttm), + call=np.asarray( + black_call(self.moneyness, self.std / np.sqrt(self.ttm), ttm=self.ttm) + ), name="Black", ) @@ -116,7 +118,7 @@ class OptionPricer(BaseModel, Generic[M], arbitrary_types_allowed=True): ) """Cache for :class:`.MaturityPricer` for different time to maturity""" n: int = 128 - """NUmber of discretization points for the marginal distribution""" + """Number of discretization points for the marginal distribution""" max_moneyness_ttm: float = 1.5 """Max time-adjusted moneyness to calculate prices""" diff --git a/quantflow/options/surface.py b/quantflow/options/surface.py index 25cbdde8..bc111b02 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -15,7 +15,14 @@ from quantflow.utils import plot from quantflow.utils.dates import utcnow from quantflow.utils.interest_rates import rate_from_spot_and_forward -from quantflow.utils.numbers import ZERO, Number, sigfig, to_decimal, to_decimal_or_none +from quantflow.utils.numbers import ( + ZERO, + DecimalNumber, + Number, + sigfig, + to_decimal, + to_decimal_or_none, +) from .bs import black_price, implied_black_volatility from .inputs import ( @@ -58,8 +65,8 @@ class OptionSelection(enum.Enum): class Price(BaseModel, Generic[S]): security: S = Field(description="The underlying security of the price") - bid: Decimal = Field(description="Bid price") - ask: Decimal = Field(description="Ask price") + bid: DecimalNumber = Field(description="Bid price") + ask: DecimalNumber = Field(description="Ask price") @property def mid(self) -> Decimal: @@ -67,10 +74,10 @@ def mid(self) -> Decimal: class SpotPrice(Price[S]): - open_interest: Decimal = Field( + open_interest: DecimalNumber = Field( default=ZERO, description="Open interest of the spot price" ) - volume: Decimal = Field(default=ZERO, description="Volume of the spot price") + volume: DecimalNumber = Field(default=ZERO, description="Volume of the spot price") def inputs(self) -> SpotInput: return SpotInput( @@ -83,10 +90,12 @@ def inputs(self) -> SpotInput: class FwdPrice(Price[S]): maturity: datetime = Field(description="Maturity date of the forward price") - open_interest: Decimal = Field( + open_interest: DecimalNumber = Field( default=ZERO, description="Open interest of the forward price" ) - volume: Decimal = Field(default=ZERO, description="Volume of the forward price") + volume: DecimalNumber = Field( + default=ZERO, description="Volume of the forward price" + ) def inputs(self) -> ForwardInput: return ForwardInput( @@ -99,24 +108,24 @@ def inputs(self) -> ForwardInput: class OptionMetadata(BaseModel): - strike: Decimal = Field(description="Strike price of the option") + strike: DecimalNumber = Field(description="Strike price of the option") option_type: OptionType = Field(description="Type of the option, call or put") maturity: datetime = Field(description="Maturity date of the option") - forward: Decimal = Field( + forward: DecimalNumber = Field( default=ZERO, description="Forward price of the underlying" ) ttm: float = Field(default=0, description="Time to maturity in years") - open_interest: Decimal = Field( + open_interest: DecimalNumber = Field( default=ZERO, description="Open interest of the option" ) - volume: Decimal = Field(default=ZERO, description="Volume of the option") + volume: DecimalNumber = Field(default=ZERO, description="Volume of the option") class OptionPrice(BaseModel): """Represents the price of an option quoted in the market along with its metadata and implied volatility information.""" - price: Decimal = Field( + price: DecimalNumber = Field( description="Price of the option as a percentage of the forward price" ) meta: OptionMetadata = Field(description="Metadata of the option price") @@ -334,12 +343,12 @@ def inputs(self) -> OptionInput: iv_bid=to_decimal_or_none( None if np.isnan(self.bid.implied_vol) - else round(self.bid.implied_vol, 5) + else round(self.bid.implied_vol, 7) ), iv_ask=to_decimal_or_none( None if np.isnan(self.ask.implied_vol) - else round(self.ask.implied_vol, 5) + else round(self.ask.implied_vol, 7) ), ) @@ -347,7 +356,7 @@ def inputs(self) -> OptionInput: class Strike(BaseModel, Generic[S]): """Option prices for a single strike""" - strike: Decimal = Field(description="Strike price of the options") + strike: DecimalNumber = Field(description="Strike price of the options") call: OptionPrices[S] | None = Field( default=None, description="Call option prices for the strike" ) @@ -486,9 +495,9 @@ class VolSurface(BaseModel, Generic[S], arbitrary_types_allowed=True): """Sorted tuple of :class:`.VolCrossSection` for different maturities""" day_counter: DayCounter = default_day_counter """Day counter for time to maturity calculations - by default it uses Act/Act""" - tick_size_forwards: Decimal | None = None + tick_size_forwards: DecimalNumber | None = None """Tick size for rounding forward and spot prices - optional""" - tick_size_options: Decimal | None = None + tick_size_options: DecimalNumber | None = None """Tick size for rounding option prices - optional""" def securities(self) -> Iterator[SpotPrice[S] | FwdPrice[S] | OptionPrices[S]]: @@ -600,7 +609,7 @@ def bs( call_put=d.call_put, ) for option, implied_vol, converged in zip( - d.options, result.root, result.converged + d.options, result.values, result.converged ): option.implied_vol = float(implied_vol) option.converged = converged @@ -810,18 +819,18 @@ class GenericVolSurfaceLoader(BaseModel, Generic[S], arbitrary_types_allowed=Tru ), ) """Day counter for time to maturity calculations - by default it uses Act/Act""" - tick_size_forwards: Decimal | None = Field( + tick_size_forwards: DecimalNumber | None = Field( default=None, description="Tick size for rounding forward and spot prices - optional", ) - tick_size_options: Decimal | None = Field( + tick_size_options: DecimalNumber | None = Field( default=None, description="Tick size for rounding option prices - optional" ) - exclude_open_interest: Decimal | None = Field( + exclude_open_interest: DecimalNumber | None = Field( default=None, description="Exclude options with open interest at or below this value", ) - exclude_volume: Decimal | None = Field( + exclude_volume: DecimalNumber | None = Field( default=None, description="Exclude options with volume at or below this value" ) diff --git a/quantflow/sp/base.py b/quantflow/sp/base.py index 498439c8..b92eec96 100755 --- a/quantflow/sp/base.py +++ b/quantflow/sp/base.py @@ -11,7 +11,7 @@ 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 lower_bound, upper_bound +from quantflow.utils.transforms import bound_from_any from quantflow.utils.types import FloatArray, FloatArrayLike, Vector Im = complex(0, 1) @@ -113,8 +113,8 @@ def frequency_range(self, std: float, max_frequency: float | None = None) -> Bou def support(self, mean: float, std: float, points: int) -> FloatArray: """Support of the process at time `t`""" bounds = self.domain_range() - start = float(sigfig(lower_bound(bounds.lb, mean - std))) - end = float(sigfig(upper_bound(bounds.ub, mean + std))) + start = float(sigfig(bound_from_any(bounds.lb, mean - std))) + end = float(sigfig(bound_from_any(bounds.ub, mean + std))) return np.linspace(start, end, points + 1) diff --git a/quantflow/sp/bns.py b/quantflow/sp/bns.py index 5c683990..96139a29 100644 --- a/quantflow/sp/bns.py +++ b/quantflow/sp/bns.py @@ -33,7 +33,7 @@ def sample(self, n: int, time_horizon: float = 1, time_steps: int = 100) -> Path def sample_from_draws(self, path_dw: Paths, *args: Paths) -> Paths: if args: - args[0] + path_dz = args[0] else: # generate the background driving process samples if not provided path_dz = self.variance_process.bdlp.sample( diff --git a/quantflow/sp/poisson.py b/quantflow/sp/poisson.py index 9c3c81cd..bffc3ac5 100755 --- a/quantflow/sp/poisson.py +++ b/quantflow/sp/poisson.py @@ -223,7 +223,7 @@ def create( jump_asymmetry: float = 0.0, ) -> CompoundPoissonProcess[D]: """Create a Compound Poisson process with a given jump distribution, volatility, - jump intensity a nd jump asymmetry . + jump intensity and jump asymmetry. :param jump_distribution: The distribution of jump size (currently only :class:`.Normal` and :class:`.DoubleExponential` are supported) diff --git a/quantflow/ta/paths.py b/quantflow/ta/paths.py index f00145ab..8f3d8147 100755 --- a/quantflow/ta/paths.py +++ b/quantflow/ta/paths.py @@ -65,7 +65,10 @@ def path(self, i: int) -> FloatArray: return self.data[:, i] def dates( - self, *, start: datetime | None = None, unit: str = "d" + self, + *, + start: datetime | None = None, + unit: str = "D", ) -> pd.DatetimeIndex: """Dates of paths as a pandas DatetimeIndex""" start = start or utcnow() @@ -110,7 +113,10 @@ def paths_var(self, *, scaled: bool = False) -> FloatArray: return np.var(np.diff(self.data, axis=0), axis=0) / scale def as_datetime_df( - self, *, start: datetime | None = None, unit: str = "d" + self, + *, + start: datetime | None = None, + unit: str = "D", ) -> pd.DataFrame: """Paths as pandas DataFrame with datetime index""" return pd.DataFrame(self.data, index=self.dates(start=start, unit=unit)) @@ -203,7 +209,6 @@ def normal_draws( ] = True, ) -> Self: """Create paths from normal draws""" - time_horizon / time_steps odd = 0 if antithetic_variates: odd = paths % 2 diff --git a/quantflow/utils/marginal.py b/quantflow/utils/marginal.py index 2253c6ab..b8aca5dc 100644 --- a/quantflow/utils/marginal.py +++ b/quantflow/utils/marginal.py @@ -84,7 +84,7 @@ def cdf(self, x: FloatArrayLike) -> FloatArrayLike: :param n: Location in the stochastic process domain space. If a numpy array, the output should have the same shape as the input. """ - raise NotImplementedError("Analytical CFD not available") + raise NotImplementedError("Analytical CDF not available") def pdf(self, x: FloatArrayLike) -> FloatArrayLike: """ @@ -97,7 +97,7 @@ def pdf(self, x: FloatArrayLike) -> FloatArrayLike: :param n: Location in the stochastic process domain space. If a numpy array, the output should have the same shape as the input. """ - raise NotImplementedError("Analytical PFD not available") + raise NotImplementedError("Analytical PDF not available") def pdf_from_characteristic( self, @@ -138,7 +138,7 @@ def cdf_from_characteristic( use_fft: bool = False, frequency_n: int | None = None, ) -> TransformResult: - raise NotImplementedError("CFD not available") + raise NotImplementedError("CDF not available") def call_option( self, @@ -249,7 +249,7 @@ def cdf_jacobian(self, x: FloatArrayLike) -> np.ndarray: Optional to implement, otherwise raises ``NotImplementedError`` if called. """ - raise NotImplementedError("Analytical CFD Jacobian not available") + raise NotImplementedError("Analytical CDF Jacobian not available") def option_support( self, points: int = 101, max_moneyness: float = 1.0 @@ -274,7 +274,7 @@ def option_time_value_transform(self, u: Vector, alpha: float = 1.1) -> Vector: """Option time value transform This transform does not require any additional correction since - the integrant is already bounded for positive and negative moneyess""" + the integrand is already bounded for positive and negative moneyness""" ia = 1j * alpha return 0.5 * ( self._option_time_value_transform(u - ia) @@ -285,7 +285,7 @@ def _option_time_value_transform(self, u: Vector) -> Vector: """Option time value transform This transform does not require any additional correction since - the integrant is already bounded for positive and negative moneyess""" + the integrand is already bounded for positive and negative moneyness""" iu = 1j * u return ( 1 / (1 + iu) - 1 / iu - self.characteristic_corrected(u - 1j) / (u * u - iu) diff --git a/quantflow/utils/numbers.py b/quantflow/utils/numbers.py index 96d2b408..91b3293a 100644 --- a/quantflow/utils/numbers.py +++ b/quantflow/utils/numbers.py @@ -1,11 +1,16 @@ import math from decimal import Decimal from enum import IntEnum, auto, unique +from typing import Annotated + +from pydantic import WithJsonSchema Number = Decimal | float | int | str ZERO = Decimal(0) ONE = Decimal(1) +DecimalNumber = Annotated[Decimal, WithJsonSchema({"type": "number"})] + @unique class Rounding(IntEnum): diff --git a/quantflow/utils/transforms.py b/quantflow/utils/transforms.py index abc50b90..46012510 100644 --- a/quantflow/utils/transforms.py +++ b/quantflow/utils/transforms.py @@ -61,15 +61,11 @@ def default_bounds() -> Bounds: return Bounds(-np.inf, np.inf) -def lower_bound(b: Any, value: float) -> float: - try: - v = float(b[0]) - return value if np.isinf(v) else v - except TypeError: - return value - - -def upper_bound(b: Any, value: float) -> float: +def bound_from_any(b: Any, value: float) -> float: + """Return a bound value from b, falling back to value if b is a plain scalar + (not subscriptable) or infinite. b is expected to be a scalar bound value + (e.g. domain_range.lb or domain_range.ub) — b[0] is attempted purely to + handle sequence-like inputs.""" try: v = float(b[0]) return value if np.isinf(v) else v @@ -120,8 +116,8 @@ def fft_delta_x(self) -> float: def space_domain(self, delta_x: float) -> FloatArray: """Return the space domain discretization points""" - b0 = lower_bound(self.domain_range.lb, -0.5 * delta_x * self.n) - b1 = upper_bound(self.domain_range.ub, delta_x * self.n + b0) + b0 = bound_from_any(self.domain_range.lb, -0.5 * delta_x * self.n) + b1 = bound_from_any(self.domain_range.ub, delta_x * self.n + b0) if not np.isclose((b1 - b0) / self.n, delta_x): raise TransformError("Incompatible delta_x with domain bounds") return delta_x * grid(self.n) + b0 @@ -170,7 +166,7 @@ def characteristic_df(self, psi: np.ndarray) -> pd.DataFrame: dict( frequency=self.frequency_domain, characteristic=psi.imag, - name="iamg", + name="imag", ) ), ) diff --git a/quantflow/utils/types.py b/quantflow/utils/types.py index fd841381..ec9cb67b 100644 --- a/quantflow/utils/types.py +++ b/quantflow/utils/types.py @@ -1,25 +1,27 @@ from decimal import Decimal -from typing import Any, Optional, Union +from typing import Any import numpy as np import numpy.typing as npt import pandas as pd +from typing_extensions import TypeAlias Number = Decimal Float = float | np.floating[Any] -Numbers = Union[int, Float, np.number] -NumberType = Union[float, int, str, Number] -Vector = Union[int, float, complex, np.ndarray, pd.Series] +Numbers = int | Float | np.number +NumberType = float | int | str | Number +Vector: TypeAlias = int | float | complex | np.ndarray | pd.Series FloatArray = npt.NDArray[np.floating[Any]] IntArray = npt.NDArray[np.signedinteger[Any]] -FloatArrayLike = FloatArray | float +BoolArray = npt.NDArray[np.bool_] +FloatArrayLike = Float | FloatArray -def as_number(num: Optional[NumberType] = None) -> Number: +def as_number(num: NumberType | None = None) -> Number: return Number(0 if num is None else str(num)) -def as_float(num: Optional[NumberType] = None) -> float: +def as_float(num: NumberType | None = None) -> float: return float(0 if num is None else num) diff --git a/quantflow_tests/test_options.py b/quantflow_tests/test_options.py index a5723a88..c0acca7b 100644 --- a/quantflow_tests/test_options.py +++ b/quantflow_tests/test_options.py @@ -33,18 +33,30 @@ def vol_surface() -> VolSurface: return surface_from_inputs(VolSurfaceInputs(**json.load(fp))) +def test_atm_black_pricing_multi(): + k = np.asarray([-0.1, 0, 0.1]) + price = bs.black_call(k, sigma=0.2, ttm=0.4) + result = bs.implied_black_volatility( + k, price, ttm=0.4, initial_sigma=0.5, call_put=1 + ) + assert len(result.values) == 3 + assert len(result.converged) == 3 + for value in result.values: + assert pytest.approx(value) == 0.2 + + @pytest.mark.parametrize("ttm", [0.4, 0.8, 1.4, 2]) def test_atm_black_pricing(ttm): price = bs.black_call(0, 0.2, ttm) - result = bs.implied_black_volatility(0, price, ttm, 0.5, 1) - assert pytest.approx(result[0]) == 0.2 + result = bs.implied_black_volatility(0, price, ttm, 0.5, 1).single() + assert pytest.approx(result.value) == 0.2 @pytest.mark.parametrize("ttm", [0.4, 0.8, 1.4, 2]) def test_otm_black_pricing(ttm): price = bs.black_call(math.log(1.1), 0.25, ttm) - result = bs.implied_black_volatility(math.log(1.1), price, ttm, 0.5, 1) - assert pytest.approx(result[0]) == 0.25 + result = bs.implied_black_volatility(math.log(1.1), price, ttm, 0.5, 1).single() + assert pytest.approx(result.value) == 0.25 @pytest.mark.parametrize("ttm", [0.4, 0.8, 1.4, 2]) @@ -127,7 +139,7 @@ def test_inputs_implied_vols_rounded(vol_surface: VolSurface) -> None: for iv in (opt.iv_bid, opt.iv_ask): if iv is not None: v = float(iv) - assert v == round(v, 5) + assert v == round(v, 7) def test_same_vol_surface(vol_surface: VolSurface): diff --git a/readme.md b/readme.md index ce864123..7c5c4d13 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) diff --git a/uv.lock b/uv.lock index 464d25ad..085aba41 100644 --- a/uv.lock +++ b/uv.lock @@ -992,6 +992,7 @@ wheels = [ name = "griffelib" version = "2.0.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] @@ -3145,7 +3146,7 @@ wheels = [ [[package]] name = "quantflow" -version = "0.5.1" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "ccy" },