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