diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 23173f96..f01c6219 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,7 +28,7 @@ applyTo: '/**' * The documentation for quantflow is available at `https://quantflow.quantmid.com` * Documentation is built using [mkdocs](https://www.mkdocs.org/) and stored in the `docs/` directory. The documentation source files are written in markdown format. * Do not use em dashes (—) in documentation files or docstrings. Use colons, parentheses, or restructure the sentence instead. -* Math in documentation and docstrings uses `$...$` for inline and `$$...$$` or `\begin{equation}...\end{equation}` for block equations. Do not use `.. math::` or `:math:` (RST syntax). +* Math in documentation and docstrings: always use `\begin{equation}...\end{equation}` for any formula or equation. Use `$...$` only for brief inline references to variables (e.g. $F$, $K$). Do not use `$$...$$`, `` `...` ``, or RST syntax (`.. math::`, `:math:`). * Glossary entries in `docs/glossary.md` must be kept in alphabetical order. * To rebuild doc examples run `uv run ./dev/build-examples` — runs all scripts in `docs/examples/` and writes their output to `docs/examples_output/` diff --git a/.vscode/launch.json b/.vscode/launch.json index 418ba6af..37d7d864 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,7 @@ "args": [ "-x", "-vvv", - "quantflow_tests/test_divfm.py", + "quantflow_tests/test_data_deribit.py", ] }, ] diff --git a/app/volatility_surface.py b/app/volatility_surface.py index 5f56aef5..7c30ceb2 100644 --- a/app/volatility_surface.py +++ b/app/volatility_surface.py @@ -42,12 +42,6 @@ def _(mo): return -@app.cell -def _(): - kwargs = dict() - return - - @app.cell def _(mo): asset = mo.ui.dropdown(["btc", "eth", "sol"], value="btc", label="asset") @@ -57,7 +51,7 @@ def _(mo): @app.cell -async def _(asset, inverse): +async def _(asset, inverse, mo): import pandas as pd from quantflow.data.deribit import Deribit @@ -74,18 +68,43 @@ async def _(asset, inverse): surface.bs() # disable outliers surface.disable_outliers() - surface.plot3d() - return pd, surface + # + def int_or_none(v): + try: + return int(v) + except TypeError: + return None + + maturites = [c.maturity for c in surface.maturities] + maturity_dropdown = mo.ui.dropdown( + options={m.strftime("%Y-%m-%d"): i for i, m in enumerate(maturites)}, + label="Maturity" + ) + maturity_dropdown + return int_or_none, maturity_dropdown, pd, surface @app.cell -def _(pd, surface): +def _(int_or_none, maturity_dropdown, surface): + index = int_or_none(maturity_dropdown.value) + surface.plot3d(index=index) + return (index,) + + +@app.cell +def _(index, pd, surface): # display inputs - only options with converged implied volatility - surface_inputs = surface.inputs(converged=True) + surface_inputs = surface.inputs(converged=True, index=index) pd.DataFrame([i.model_dump() for i in surface_inputs.inputs]) return +@app.cell +def _(surface): + surface.term_structure() + return + + @app.cell def _(): return diff --git a/docs/api/rates/index.md b/docs/api/rates/index.md new file mode 100644 index 00000000..bb62d8ed --- /dev/null +++ b/docs/api/rates/index.md @@ -0,0 +1 @@ +# Interest Rates diff --git a/docs/api/rates/interest_rate.md b/docs/api/rates/interest_rate.md new file mode 100644 index 00000000..9899e16e --- /dev/null +++ b/docs/api/rates/interest_rate.md @@ -0,0 +1,4 @@ +# Interest Rates + + +::: quantflow.rates.interest_rate.Rate diff --git a/docs/api/rates/yield_curve.md b/docs/api/rates/yield_curve.md new file mode 100644 index 00000000..f2f41c8b --- /dev/null +++ b/docs/api/rates/yield_curve.md @@ -0,0 +1,6 @@ +# Yield Curve + + +::: quantflow.rates.yield_curve.YieldCurve + +::: quantflow.rates.nelson_siegel.NelsonSiegel diff --git a/docs/glossary.md b/docs/glossary.md index 83fced83..e30fc393 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -97,6 +97,24 @@ The [probability density function](https://en.wikipedia.org/wiki/Probability_den F_x(x) = \int_{-\infty}^x f_x(s) ds \end{equation} +## Put-Call Parity + +Put-call parity is a no-arbitrage relationship between the prices of European call +and put options with the same strike $K$ and maturity. Denoting forward-space prices +$c = C/F$ and $p = P/F$ (see [Black Pricing](api/options/black.md)), the relationship +reads: + +\begin{equation} + c - p = 1 - \frac{K}{F} = 1 - e^k +\end{equation} + +where $k$ is the [log-strike](#log-strike). +In quoting currency terms, multiplying through by $F$: + +\begin{equation} + C - P = F - K +\end{equation} + ## Time To Maturity (TTM) Time to maturity is the time remaining until an option or forward contract expires, diff --git a/mkdocs.yml b/mkdocs.yml index bb1c5c14..794c1a42 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -91,6 +91,10 @@ nav: - OHLC: api/ta/ohlc.md - Paths: api/ta/paths.md - Supersmoother: api/ta/supersmoother.md + - Rates: + - api/rates/index.md + - Interest Rate: api/rates/interest_rate.md + - Yield Curve: api/rates/yield_curve.md - Utilities: - api/utils/index.md - Bins: api/utils/bins.md diff --git a/pyproject.toml b/pyproject.toml index c81314d3..80d89612 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,3 +119,8 @@ module = [ ] ignore_missing_imports = true disallow_untyped_defs = false + +[dependency-groups] +dev = [ + "hypothesis>=6.152.2", +] diff --git a/quantflow/options/inputs.py b/quantflow/options/inputs.py index 09e198c7..9c4bb913 100644 --- a/quantflow/options/inputs.py +++ b/quantflow/options/inputs.py @@ -47,6 +47,10 @@ class VolSurfaceSecurity(BaseModel): def vol_surface_type(self) -> VolSecurityType: raise NotImplementedError("vol_surface_type must be implemented by subclasses") + @classmethod + def forward(cls) -> Self: + raise NotImplementedError("forward_input must be implemented by subclasses") + class DefaultVolSecurity(VolSurfaceSecurity): security_type: VolSecurityType = Field( diff --git a/quantflow/options/surface.py b/quantflow/options/surface.py index 037bf2af..0bfd16c1 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -1,6 +1,7 @@ from __future__ import annotations import enum +import math import warnings from datetime import datetime, timedelta from decimal import Decimal @@ -12,9 +13,9 @@ from pydantic import BaseModel, Field from typing_extensions import Annotated, Doc +from quantflow.rates.interest_rate import Rate 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, DecimalNumber, @@ -78,8 +79,24 @@ class Price(BaseModel, Generic[S]): @property def mid(self) -> Decimal: + """Calculate the mid price by averaging the bid and ask prices""" return (self.bid + self.ask) / 2 + @property + def spread(self) -> Decimal: + """Calculate the bid-ask spread""" + return self.ask - self.bid + + @property + def bp_spread(self) -> Decimal: + """Bid-ask spread in basis points, calculated as spread divided by mid + price and multiplied by 10000""" + mid = self.mid + if mid > ZERO: + return 10000 * self.spread / mid + else: + return Decimal("inf") + class SpotPrice(Price[S]): """Represents the spot bid/ask price of an underlying asset""" @@ -117,6 +134,148 @@ def inputs(self) -> ForwardInput: volume=self.volume, ) + def is_valid(self) -> bool: + """Check if the forward price is valid, which means that the bid and ask + are positive and the bid is less than or equal to the ask""" + return self.bid > ZERO and self.ask > ZERO and self.bid <= self.ask + + +class ImpliedFwdPrice(FwdPrice[S]): + """Represents the implied forward price of an underlying asset at a specific + maturity, extracted from option prices via put-call parity""" + + strike: DecimalNumber = Field( + description="Strike price of the options used to extract the forward price" + ) + + def moneyness(self, ttm: float) -> float: + """Moneyness of the implied forward""" + return math.log(float(self.strike / self.mid)) / math.sqrt(ttm) + + @classmethod + def aggregate( + cls, + forwards: list[Self], + ttm: float, + default: FwdPrice[S] | None = None, + previous_forward: Decimal | None = None, + ) -> FwdPrice[S] | None: + r"""Aggregate implied forward prices extracted from put-call parity into a + single best-estimate forward price. + + Each implied forward is an independent noisy estimate of the true forward, + obtained at a different strike. Strikes near the money tend to produce the + most reliable estimates (tightest bid-ask spreads, smallest put-call parity + error), so the aggregation weights each estimate by three independent factors + that all reward quality: + + \begin{equation} + w_i = w^{\text{moneyness}}_i + \cdot w^{\text{spread}}_i + \cdot w^{\text{proximity}}_i + \end{equation} + + **Moneyness weight** rewards strikes close to the current mid price. + It uses the same Gaussian shape as the standard normal density, where + $m_i = \log(K_i / F_i) / \sqrt{\tau}$ is the normalised log-moneyness: + + \begin{equation} + w^{\text{moneyness}}_i = \exp\!\left(-\tfrac{m_i^2}{2}\right) + \end{equation} + + **Spread weight** penalises wide bid-ask markets, which indicate either + low liquidity or high uncertainty in the put-call parity estimate. It + decays exponentially with the bid-ask spread relative to the adaptive + cutoff $c$: + + \begin{equation} + w^{\text{spread}}_i + = \exp\!\left(-\frac{\text{bp\_spread}_i}{c}\right) + \end{equation} + + **Proximity weight** anchors the result to the previous maturity's forward + $F_{\text{prev}}$ when provided. It down-weights estimates whose mid is + far from the known anchor, acting as a soft prior that limits how much the + result can deviate from a recent reliable observation: + + \begin{equation} + w^{\text{proximity}}_i + = \exp\!\left( + -\frac{1}{2} + \left(\frac{F_i - F_{\text{prev}}}{F_{\text{prev}}}\right)^{\!2} + \right) + \end{equation} + + **Adaptive spread filter**: the cutoff $c$ starts at 10 bp and doubles + until at least half the valid forwards survive. This ensures that in + illiquid markets with universally wide spreads, the algorithm still + produces a result rather than discarding everything. + + **Default blending**: the actual market forward (e.g. from futures) is + optionally blended into the weighted average using only the spread weight. + This forward may be unreliable (wide spread, stale price), so it receives + no moneyness or proximity weight. + + **Default fallback**: if the computed mid lies within one default + spread-width of the default mid, the default is returned unchanged, + avoiding unnecessary deviation from the observable market price when + the implied estimate is consistent with it. + """ + forwards = [f for f in forwards if f.is_valid()] + if not forwards: + return default + weights = 0.0 + values = 0.0 + spreads = 0.0 + target_len = max(1, len(forwards) // 2) + cleaned: list[Self] = [] + spread_bp_cutoff = 10 + while True: + cleaned = [ + forward for forward in forwards if forward.bp_spread < spread_bp_cutoff + ] + if len(cleaned) < target_len: + spread_bp_cutoff *= 2 + else: + forwards = cleaned + break + for forward in forwards: + m = forward.moneyness(ttm) + moneyness_weight = math.exp(-(m**2) / 2) + spread_weight = math.exp(-forward.bp_spread / spread_bp_cutoff) + if previous_forward is not None: + d = float((forward.mid - previous_forward) / previous_forward) + proximity_weight = math.exp(-(d**2) / 2) + else: + proximity_weight = 1.0 + w = moneyness_weight * spread_weight * proximity_weight + weights += w + values += w * float(forward.mid) + spreads += w * float(forward.spread) + if ( + default is not None + and default.is_valid() + and default.bp_spread < spread_bp_cutoff + ): + w = math.exp(-10000 * float(default.spread) / float(default.mid)) + weights += w + values += w * float(default.mid) + spreads += w * float(default.spread) + mid = to_decimal(values / weights) + spread = to_decimal(spreads / weights) + if ( + default is not None + and default.is_valid() + and abs(mid - default.mid) / default.spread < 1 + ): + return default + return FwdPrice( + security=forwards[0].security.forward(), + bid=mid - spread / 2, + ask=mid + spread / 2, + maturity=forwards[0].maturity, + ) + class OptionMetadata(BaseModel): """Represents the metadata of an option, including its strike, type, maturity, @@ -370,6 +529,11 @@ def converged(self) -> bool: for both bid and ask""" return self.bid.converged and self.ask.converged + @property + def mid(self) -> Decimal: + """Calculate the mid option price by averaging the bid and ask prices""" + return (self.bid.price + self.ask.price) / 2 + def iv_bid_ask_spread(self) -> float: """Calculate the bid-ask spread of the implied volatility""" return self.ask.implied_vol - self.bid.implied_vol @@ -440,6 +604,52 @@ class Strike(BaseModel, Generic[S]): default=None, description="Put option prices for the strike" ) + def implied_forward(self) -> ImpliedFwdPrice[S] | None: + r"""Extract the implied forward price from put-call parity. + + Requires both a call and a put at this strike. Uses mid prices. + For inverse options (prices quoted in the underlying currency) + put-call parity reads + + \begin{equation} + F = \frac{K}{1 - c + p} + \end{equation} + + For non-inverse options (prices quoted in the quote currency) + + \begin{equation} + F = K + C - P + \end{equation} + + Returns None when the strike does not have both a call and a put, + or when the denominator is non-positive (arbitrage condition violated). + """ + if self.call is None or self.put is None: + return None + cp_bid = self.call.bid.price - self.put.ask.price + cp_ask = self.call.ask.price - self.put.bid.price + if self.call.meta.inverse: + d_bid = 1 - cp_bid + d_ask = 1 - cp_ask + if d_bid <= ZERO or d_ask <= ZERO: + return None + bid = self.strike / d_bid + ask = self.strike / d_ask + else: + bid = self.strike + cp_bid + ask = self.strike + cp_ask + if bid <= ZERO or ask <= ZERO: + return None + if bid > ask: + return None + return ImpliedFwdPrice( + security=self.call.security.forward(), + strike=self.strike, + maturity=self.call.meta.maturity, + bid=bid, + ask=ask, + ) + def options_iter( self, forward: Annotated[Decimal, Doc("Forward price of the underlying asset")], @@ -548,6 +758,23 @@ def ttm(self, ref_date: datetime) -> float: """Time to maturity in years""" return self.day_counter.dcf(ref_date, self.maturity) + def forward_rate(self, ref_date: datetime, spot: SpotPrice[S]) -> Rate: + """Compute the implied continuous rate from spot and forward mid""" + return Rate.from_spot_and_forward( + spot.mid, + self.forward.mid, + ref_date, + self.maturity, + day_counter=self.day_counter, + ) + + def forward_spread_fraction(self) -> Decimal: + """Bid-ask spread of the forward as a fraction of its mid price""" + mid = self.forward.mid + if mid <= ZERO: + return Decimal("Inf") + return (self.forward.ask - self.forward.bid) / mid + def info_dict(self, ref_date: datetime, spot: SpotPrice[S]) -> dict: """Return a dictionary with information about the cross section""" return dict( @@ -555,9 +782,8 @@ def info_dict(self, ref_date: datetime, spot: SpotPrice[S]) -> dict: ttm=self.ttm(ref_date), forward=self.forward.mid, basis=self.forward.mid - spot.mid, - rate_percent=rate_from_spot_and_forward( - spot.mid, self.forward.mid, self.maturity - ref_date - ).percent, + rate_percent=self.forward_rate(ref_date, spot).percent, + fwd_spread_pct=round(100 * self.forward_spread_fraction(), 4), open_interest=self.forward.open_interest, volume=self.forward.volume, ) @@ -753,6 +979,9 @@ def securities( select: Annotated[ OptionSelection, Doc("Option selection method") ] = OptionSelection.all, + index: Annotated[ + int | None, Doc("Index of the cross section to use, if None use all") + ] = None, converged: Annotated[ bool, Doc( @@ -764,8 +993,13 @@ def securities( ) -> Iterator[SpotPrice[S] | FwdPrice[S] | OptionPrices[S]]: """Iterator over securities in the volatility surface""" yield self.spot - for maturity in self.maturities: - yield from maturity.securities(select=select, converged=converged) + if index is not None: + yield from self.maturities[index].securities( + select=select, converged=converged + ) + else: + for maturity in self.maturities: + yield from maturity.securities(select=select, converged=converged) def inputs( self, @@ -773,6 +1007,9 @@ def inputs( select: Annotated[ OptionSelection, Doc("Option selection method") ] = OptionSelection.all, + index: Annotated[ + int | None, Doc("Index of the cross section to use, if None use all") + ] = None, converged: Annotated[ bool, Doc( @@ -788,12 +1025,15 @@ def inputs( asset=self.asset, ref_date=self.ref_date, inputs=list( - s.inputs() for s in self.securities(select=select, converged=converged) + s.inputs() + for s in self.securities( + select=select, converged=converged, index=index + ) ), ) def term_structure(self) -> pd.DataFrame: - """Return the term structure of the volatility surface""" + """Return the term structure of the volatility surface as a DataFrame""" return pd.DataFrame( cross.info_dict(self.ref_date, self.spot) for cross in self.maturities ) @@ -1038,6 +1278,70 @@ def disable_outliers( ) return self + def calibrate_forwards( + self, + *, + max_spread_fraction: Annotated[ + float, + Doc( + "Maximum allowed forward bid-ask spread as a fraction of the mid " + "price. Forwards exceeding this threshold are considered unreliable " + "and replaced with a synthetic price derived from interpolated rates. " + "A value of 0.05 flags forwards whose spread is more than 5% of mid." + ), + ] = 0.05, + ) -> Self: + """Replace forwards with wide bid-ask spreads with synthetic prices + interpolated from the smooth rate term structure. + + For each maturity the implied continuous rate is computed as + `r = log(F_mid / S) / T`. Maturities whose forward bid-ask spread + exceeds `max_spread_fraction` of the mid are treated as unreliable. + A piecewise-linear interpolation (with flat extrapolation at the + boundaries) is fitted through the reliable `(T, r)` pairs, and the + synthetic forward is: + + `F_synth = S * exp(r_interp * T)` + + The synthetic bid and ask are both set to this value, giving a + zero spread, and the cross-section forward is replaced accordingly. + Returns a new `VolSurface` instance leaving the original unchanged. + """ + spot = self.spot.mid + max_spread = to_decimal(max_spread_fraction) + good_ttms: list[float] = [] + good_rates: list[float] = [] + bad_indices: list[int] = [] + + for i, cross in enumerate(self.maturities): + ttm = cross.ttm(self.ref_date) + spread_frac = cross.forward_spread_fraction() + rate = cross.forward_rate(self.ref_date, self.spot) + if ttm > 0 and spread_frac <= max_spread: + good_ttms.append(ttm) + good_rates.append(float(rate.rate)) + else: + bad_indices.append(i) + + if not good_ttms or not bad_indices: + return self + + ttm_arr = np.array(good_ttms) + rate_arr = np.array(good_rates) + + new_maturities = list(self.maturities) + for i in bad_indices: + cross = self.maturities[i] + ttm = cross.ttm(self.ref_date) + if ttm <= 0: + continue + r_synth = float(np.interp(ttm, ttm_arr, rate_arr)) + f_synth = to_decimal(float(spot) * math.exp(r_synth * ttm)) + new_fwd = cross.forward.model_copy(update=dict(bid=f_synth, ask=f_synth)) + new_maturities[i] = cross.model_copy(update=dict(forward=new_fwd)) + + return self.model_copy(update=dict(maturities=tuple(new_maturities))) + def plot( self, *, @@ -1059,13 +1363,16 @@ def plot3d( select: Annotated[ OptionSelection, Doc("Option selection method") ] = OptionSelection.best, + index: Annotated[ + int | None, Doc("Index of the cross section to use, if None use all") + ] = None, dragmode: Annotated[ str, Doc("Drag interaction mode for the 3D scene") ] = "turntable", **kwargs: Any, ) -> Any: """Plot the volatility surface""" - df = self.options_df(select=select, converged=True) + df = self.options_df(select=select, index=index, converged=True) return plot.plot_vol_surface_3d(df, dragmode=dragmode, **kwargs) @@ -1123,19 +1430,44 @@ def add_option( else: self.strikes[strike].put = option - def cross_section(self) -> VolCrossSection[S] | None: - if self.forward is None or self.forward.mid == ZERO: - return None + def cross_section( + self, + ref_date: Annotated[ + datetime | None, Doc("Reference date for the volatility surface") + ] = None, + previous_forward: Annotated[ + Decimal | None, + Doc( + "Previous forward price for the volatility surface " + "Usaed by the implied forward calculation to replace missing " + "or unreliable forwards" + ), + ] = None, + ) -> VolCrossSection[S] | None: strikes = [] + implied_forwards = [] for strike in sorted(self.strikes): sk = self.strikes[strike] if sk.call is None and sk.put is None: continue + if implied_forward := sk.implied_forward(): + implied_forwards.append(implied_forward) strikes.append(sk) + forward = self.forward + if implied_forwards: + ttm = self.day_counter.dcf(ref_date or utcnow(), self.maturity) + forward = ImpliedFwdPrice.aggregate( + implied_forwards, + ttm, + default=self.forward, + previous_forward=previous_forward, + ) + if forward is None or not forward.is_valid(): + return None return ( VolCrossSection( maturity=self.maturity, - forward=self.forward, + forward=forward, strikes=tuple(strikes), day_counter=self.day_counter, ) @@ -1280,12 +1612,18 @@ def surface( if not self.spot or self.spot.mid == ZERO: raise ValueError("No spot price provided") maturities = [] + ref_date = ref_date or utcnow() + previous_forward = self.spot.mid for maturity in sorted(self.maturities): - if section := self.maturities[maturity].cross_section(): + if section := self.maturities[maturity].cross_section( + ref_date=ref_date, + previous_forward=previous_forward, + ): + previous_forward = section.forward.mid maturities.append(section) return VolSurface( asset=self.asset, - ref_date=ref_date or utcnow(), + ref_date=ref_date, spot=self.spot, maturities=tuple(maturities), day_counter=self.day_counter, diff --git a/quantflow/rates/__init__.py b/quantflow/rates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/quantflow/rates/interest_rate.py b/quantflow/rates/interest_rate.py new file mode 100644 index 00000000..c083a287 --- /dev/null +++ b/quantflow/rates/interest_rate.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import math +from datetime import datetime +from decimal import Decimal +from typing import Self + +from ccy import DayCounter, Period +from pydantic import BaseModel, Field +from typing_extensions import Annotated, Doc + +from ..utils.numbers import ONE, ZERO, Number, to_decimal + +ROUND_RATE = 7 + + +class Rate(BaseModel, arbitrary_types_allowed=True): + """Class representing an interest rate with optional compounding frequency""" + + rate: Decimal = Field( + default=ZERO, description="Interest rate as a decimal (e.g. 0.05 for 5%)" + ) + day_counter: DayCounter = Field( + default=DayCounter.ACTACT, + description="Day count convention to use when calculating time to maturity", + ) + frequency: Period | None = Field( + default=None, + description=( + "Compounding frequency, when None it is considered as " + "continuous compounding" + ), + ) + + @property + def percent(self) -> Decimal: + """Interest rate as a percentage""" + return round(100 * self.rate, ROUND_RATE - 2) + + @property + def bps(self) -> Decimal: + """Interest rate as basis points, 1 bps = 0.01% = 0.0001 in decimal""" + return round(10000 * self.rate, ROUND_RATE - 4) + + @classmethod + def from_number( + cls, + rate: Annotated[Number, Doc("interest rate as a decimal (e.g. 0.05 for 5%)")], + *, + frequency: Annotated[ + Period | None, + Doc( + "Compounding frequency, when None it is considered as " + "continuous compounding" + ), + ] = None, + day_counter: Annotated[ + DayCounter, Doc("Day count convention to use") + ] = DayCounter.ACTACT, + ) -> Self: + """Create a Rate instance from a Number""" + return cls( + rate=round(to_decimal(rate), ROUND_RATE), + frequency=frequency, + day_counter=day_counter, + ) + + @classmethod + def from_spot_and_forward( + cls, + spot: Annotated[Decimal, Doc("Spot price of the underlying asset")], + forward: Annotated[Decimal, Doc("Forward price of the underlying asset")], + ref_date: Annotated[datetime, Doc("Reference date for the calculation")], + maturity_date: Annotated[datetime, Doc("Maturity date for the calculation")], + *, + frequency: Annotated[ + Period | None, + Doc( + "Compounding frequency, when None it is considered as " + "continuous compounding" + ), + ] = None, + day_counter: Annotated[ + DayCounter, Doc("Day count convention to use") + ] = DayCounter.ACTACT, + ) -> Self: + """Calculate rate from spot and forward""" + # use Act/365 for now + ttm = day_counter.dcf(ref_date, maturity_date) + if ttm <= 0: + return cls(frequency=frequency, day_counter=day_counter) + if frequency is None: + return cls.from_number( + rate=math.log(float(forward / spot)) / ttm, + day_counter=day_counter, + frequency=frequency, + ) + else: + # TODO: implement this + raise NotImplementedError("Discrete compounding is not implemented yet") + + def discount_factor(self, ref_date: datetime, maturity_date: datetime) -> Decimal: + """Calculate discount factor from the rate""" + ttm = self.day_counter.dcf(ref_date, maturity_date) + if ttm <= 0: + return ONE + if self.frequency is None: + return Decimal(math.exp(-float(self.rate) * ttm)) + else: + raise NotImplementedError("Discrete compounding is not implemented yet") diff --git a/quantflow/rates/nelson_siegel.py b/quantflow/rates/nelson_siegel.py new file mode 100644 index 00000000..fceb18b8 --- /dev/null +++ b/quantflow/rates/nelson_siegel.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from decimal import Decimal + +import numpy as np +from numpy.typing import ArrayLike +from pydantic import Field +from scipy.optimize import minimize_scalar +from typing_extensions import Annotated, Doc + +from quantflow.utils.numbers import ONE, Number, to_decimal + +from .yield_curve import YieldCurve + + +class NelsonSiegel(YieldCurve): + r"""Class representing a Nelson-Siegel yield curve + + The Nelson-Siegel model is a popular parametric model for fitting + the term structure of interest rates. + It is defined by the following formula for the instantaneous forward rate: + + \begin{equation} + f(\tau) = \beta_1 + \beta_2 e^{-\lambda \tau} + + \beta_3 \lambda \tau e^{-\lambda \tau} + \end{equation} + + where $\tau$ is the time to maturity, $\beta_1$ is the level parameter, + $\beta_2$ is the slope parameter, + $\beta_3$ is the curvature parameter and $\lambda$ is the decay factor. + """ + + beta1: Decimal = Field(..., description="Level parameter") + beta2: Decimal = Field(..., description="Slope parameter") + beta3: Decimal = Field(..., description="Curvature parameter") + lambda_: Decimal = Field(..., description="Decay factor") + + def instanteous_forward_rate(self, ttm: Number) -> Decimal: + ttmd = to_decimal(ttm) + if ttmd <= 0: + return self.beta1 + self.beta2 + else: + tt = ttmd * self.lambda_ + et = (-tt).exp() + return self.beta1 + self.beta2 * et + self.beta3 * tt * et + + def discount_factor(self, ttm: Number) -> Decimal: + r"""Calculate the discount factor for a given time to maturity. + + The discount factor is calculated using the formula: + + \begin{align*} + D(\tau) &= e^{-r(\tau) \tau} \\ + r(\tau) &= \beta_1 + \beta_2 \frac{1 - e^{-\lambda \tau}} + {\lambda \tau} + \beta_3 + \left(\frac{1 - e^{-\lambda \tau}}{\lambda \tau} + - e^{-\lambda \tau}\right) + \end{align*} + """ + ttmd = to_decimal(ttm) + if ttmd <= 0: + return ONE + else: + tt = ttmd * self.lambda_ + et = (-tt).exp() + ett = (1 - et) / tt + zero_coupon_rate = self.beta1 + self.beta2 * ett + self.beta3 * (ett - et) + return (-zero_coupon_rate * ttmd).exp() + + @classmethod + def fit( + cls, + ttm: Annotated[ + ArrayLike, + Doc("times to maturity in years (1-D, length >= 3)"), + ], + rates: Annotated[ + ArrayLike, Doc("observed zero-coupon rates, same length as ttm") + ], + lambda_bounds: Annotated[ + tuple[float, float], + Doc("search bounds for the decay parameter $\\lambda$"), + ] = (0.01, 10.0), + ) -> NelsonSiegel: + r"""Fit a Nelson-Siegel curve to observed zero-coupon rates. + + Uses a profile OLS approach: for each candidate $\lambda$ the betas are + solved exactly via least squares, so only a 1-D scalar minimisation over + $\lambda$ is needed. + """ + ttm_arr = np.asarray(ttm, dtype=float) + rates_arr = np.asarray(rates, dtype=float) + result = minimize_scalar( + _rss, + bounds=lambda_bounds, + method="bounded", + args=(ttm_arr, rates_arr), + ) + lam: float = result.x + b1, b2, b3 = _ols_betas(ttm_arr, rates_arr, lam) + return cls( + beta1=Decimal(str(round(b1, 10))), + beta2=Decimal(str(round(b2, 10))), + beta3=Decimal(str(round(b3, 10))), + lambda_=Decimal(str(round(lam, 10))), + ) + + +def _design_matrix(ttm: np.ndarray, lam: float) -> np.ndarray: + lt = lam * ttm + with np.errstate(divide="ignore", invalid="ignore"): + ett = np.where(lt > 1e-10, (1.0 - np.exp(-lt)) / lt, 1.0 - lt / 2.0) + return np.column_stack([np.ones_like(ttm), ett, ett - np.exp(-lt)]) + + +def _ols_betas(ttm: np.ndarray, rates: np.ndarray, lam: float) -> np.ndarray: + betas, _, _, _ = np.linalg.lstsq(_design_matrix(ttm, lam), rates, rcond=None) + return betas + + +def _rss(lam: float, ttm: np.ndarray, rates: np.ndarray) -> float: + residuals = rates - _design_matrix(ttm, lam) @ _ols_betas(ttm, rates, lam) + return float(np.dot(residuals, residuals)) diff --git a/quantflow/rates/yield_curve.py b/quantflow/rates/yield_curve.py new file mode 100644 index 00000000..29d292e6 --- /dev/null +++ b/quantflow/rates/yield_curve.py @@ -0,0 +1,37 @@ +from abc import ABC, abstractmethod +from decimal import Decimal + +from pydantic import BaseModel + + +class YieldCurve(BaseModel, ABC, extra="forbid"): + """Abstract base class for yield curves""" + + @abstractmethod + def instanteous_forward_rate(self, ttm: float) -> Decimal: + r"""Calculate the instantaneous forward rate for a given time to maturity + + The instantaneous forward rate is related to discount factor + by the following formula: + + \begin{equation} + f(\tau) = -\frac{\partial \ln D(\tau)}{\partial \tau} + \end{equation} + + where $D(\tau)$ is the discount factor for a given time to maturity $\tau$. + """ + + @abstractmethod + def discount_factor(self, ttm: float) -> Decimal: + r"""Calculate the discount factor for a given time to maturity + + The discount factor is related to the instantaneous forward rate + by the following formula: + + \begin{equation} + D(\tau) = \exp{\left(-\int_0^\tau f(s) ds\right)} + \end{equation} + + where $f(\tau)$ is the instantaneous forward rate for a given time to + maturity $\tau$. + """ diff --git a/quantflow/utils/interest_rates.py b/quantflow/utils/interest_rates.py deleted file mode 100644 index 3c2cdd2c..00000000 --- a/quantflow/utils/interest_rates.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import math -from datetime import timedelta -from decimal import Decimal -from typing import NamedTuple - -from .numbers import to_decimal - - -class Rate(NamedTuple): - rate: Decimal = Decimal("0") - frequency: int = 0 - - @classmethod - def from_number(cls, rate: float, frequency: int = 0) -> Rate: - return cls(rate=round(to_decimal(rate), 7), frequency=frequency) - - @property - def percent(self) -> Decimal: - return round(100 * self.rate, 5) - - @property - def bps(self) -> Decimal: - return round(10000 * self.rate, 3) - - -def rate_from_spot_and_forward( - spot: Decimal, forward: Decimal, maturity: timedelta, frequency: int = 0 -) -> Rate: - """Calculate rate from spot and forward - - Args: - basis: basis point - maturity: maturity in years - frequency: number of payments per year - 0 for continuous compounding - - Returns: - Rate - """ - # use Act/365 for now - ttm = maturity.days / 365 - if ttm <= 0: - return Rate(frequency=frequency) - if frequency == 0: - return Rate.from_number( - rate=math.log(forward / spot) / ttm, frequency=frequency - ) - else: - # TODO: implement this - raise NotImplementedError diff --git a/quantflow_tests/conftest.py b/quantflow_tests/conftest.py index 76acad39..730a3925 100644 --- a/quantflow_tests/conftest.py +++ b/quantflow_tests/conftest.py @@ -1,3 +1,13 @@ import dotenv +import pytest + +from quantflow.options.surface import VolSurfaceInputs, surface_from_inputs +from quantflow_tests.utils import load_fixture_dict dotenv.load_dotenv() + + +@pytest.fixture +def vol_surface(): + inputs = load_fixture_dict("volsurface.json") + return surface_from_inputs(VolSurfaceInputs(**inputs)) diff --git a/quantflow_tests/fixtures/deribit_instruments.json b/quantflow_tests/fixtures/deribit_instruments.json index f8ba679a..67544a11 100644 --- a/quantflow_tests/fixtures/deribit_instruments.json +++ b/quantflow_tests/fixtures/deribit_instruments.json @@ -4,7 +4,7 @@ "kind": "future", "settlement_period": "perpetual", "tick_size": 0.5, - "expiration_timestamp": 32503680000000, + "expiration_timestamp": 32503708800000, "is_active": true }, { @@ -12,7 +12,7 @@ "kind": "future", "settlement_period": "month", "tick_size": 0.5, - "expiration_timestamp": 1745568000000, + "expiration_timestamp": 1777104000000, "is_active": true }, { @@ -22,7 +22,7 @@ "settlement_period": "month", "tick_size": 0.0001, "strike": 70000, - "expiration_timestamp": 1745568000000, + "expiration_timestamp": 1777104000000, "is_active": true }, { @@ -32,7 +32,7 @@ "settlement_period": "month", "tick_size": 0.0001, "strike": 70000, - "expiration_timestamp": 1745568000000, + "expiration_timestamp": 1777104000000, "is_active": true }, { @@ -42,7 +42,7 @@ "settlement_period": "month", "tick_size": 0.0001, "strike": 75000, - "expiration_timestamp": 1745568000000, + "expiration_timestamp": 1777104000000, "is_active": true } ] diff --git a/quantflow_tests/volsurface.json b/quantflow_tests/fixtures/volsurface.json similarity index 100% rename from quantflow_tests/volsurface.json rename to quantflow_tests/fixtures/volsurface.json diff --git a/quantflow_tests/test_ai.py b/quantflow_tests/test_ai.py index 0c0ddcdd..ebd25963 100644 --- a/quantflow_tests/test_ai.py +++ b/quantflow_tests/test_ai.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -14,7 +13,6 @@ from quantflow.ai.tools import charts, crypto, fred, stocks, vault from quantflow.ai.tools.base import McpTool from quantflow.data.vault import Vault -from quantflow.options.surface import VolSurfaceInputs, surface_from_inputs # --------------------------------------------------------------------------- # Helpers @@ -67,12 +65,6 @@ def mock_fred() -> AsyncMock: return mock -@pytest.fixture -def vol_surface(): - with open("quantflow_tests/volsurface.json") as fp: - return surface_from_inputs(VolSurfaceInputs(**json.load(fp))) - - @pytest.fixture def vault_server(mcp_tool: McpTool) -> FastMCP: mcp = FastMCP("test-vault") diff --git a/quantflow_tests/test_data_deribit.py b/quantflow_tests/test_data_deribit.py index 00f26d04..30b6fe54 100644 --- a/quantflow_tests/test_data_deribit.py +++ b/quantflow_tests/test_data_deribit.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from datetime import date, datetime from pathlib import Path from typing import Any, AsyncIterator from unittest.mock import AsyncMock, patch @@ -10,6 +11,7 @@ import pytest from quantflow.data.deribit import Deribit +from quantflow.utils.dates import as_utc FIXTURES = Path(__file__).parent / "fixtures" @@ -33,6 +35,11 @@ def instruments() -> list[dict]: return load_fixture("deribit_instruments.json") +@pytest.fixture +def ref_date() -> datetime: + return as_utc(date(2026, 4, 5)) + + @pytest.fixture async def deribit_cli( futures: list[dict], options: list[dict], instruments: list[dict] @@ -50,24 +57,26 @@ async def get_path(path: str, **kw: Any) -> list[dict]: yield cli -async def test_loader_loads_known_options(deribit_cli: Deribit) -> None: +async def test_loader_loads_known_options( + deribit_cli: Deribit, ref_date: datetime +) -> None: """Options present in both book summary and instruments are loaded.""" loader = await deribit_cli.volatility_surface_loader("btc") - surface = loader.surface() + surface = loader.surface(ref_date=ref_date) # fixture has 2 strikes (70000 C+P, 75000 C) all on one maturity total_strikes = sum(len(m.strikes) for m in surface.maturities) assert total_strikes == 2 async def test_loader_skips_option_missing_from_instruments( - deribit_cli: Deribit, options: list[dict] + deribit_cli: Deribit, options: list[dict], ref_date: datetime ) -> None: """Options absent from the instruments list are silently skipped.""" ghost = "BTC-10APR26-67500-P" assert any(o["instrument_name"] == ghost for o in options) loader = await deribit_cli.volatility_surface_loader("btc") - surface = loader.surface() + surface = loader.surface(ref_date=ref_date) all_strikes = { strike.strike for mat in surface.maturities for strike in mat.strikes } @@ -75,10 +84,11 @@ async def test_loader_skips_option_missing_from_instruments( async def test_loader_skips_future_missing_from_instruments( - deribit_cli: Deribit, futures: list[dict] + deribit_cli: Deribit, futures: list[dict], ref_date: datetime ) -> None: """Futures absent from the instruments list are silently skipped.""" assert any(f["instrument_name"] == "BTC-GHOST-26" for f in futures) loader = await deribit_cli.volatility_surface_loader("btc") - assert loader is not None + surface = loader.surface(ref_date=ref_date) + assert surface is not None diff --git a/quantflow_tests/test_implied_fwd.py b/quantflow_tests/test_implied_fwd.py new file mode 100644 index 00000000..132b8d91 --- /dev/null +++ b/quantflow_tests/test_implied_fwd.py @@ -0,0 +1,172 @@ +"""Tests for ImpliedFwdPrice.aggregate""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +from hypothesis import given +from hypothesis import strategies as st + +from quantflow.options.inputs import DefaultVolSecurity +from quantflow.options.surface import FwdPrice, ImpliedFwdPrice + +MATURITY = datetime(2026, 12, 31, tzinfo=timezone.utc) + + +def make_implied( + mid: float, spread_bp: float, strike: float | None = None +) -> ImpliedFwdPrice: + mid_d = Decimal(str(round(mid, 6))) + half_spread = Decimal(str(round(mid * spread_bp / 20000, 8))) + return ImpliedFwdPrice( + security=DefaultVolSecurity.forward(), + bid=mid_d - half_spread, + ask=mid_d + half_spread, + strike=Decimal(str(round(strike if strike is not None else mid, 6))), + maturity=MATURITY, + ) + + +def make_fwd(mid: float, spread_bp: float) -> FwdPrice: + mid_d = Decimal(str(round(mid, 6))) + half_spread = Decimal(str(round(mid * spread_bp / 20000, 8))) + return FwdPrice( + security=DefaultVolSecurity.forward(), + bid=mid_d - half_spread, + ask=mid_d + half_spread, + maturity=MATURITY, + ) + + +def test_aggregate_empty_no_default_returns_none() -> None: + assert ImpliedFwdPrice.aggregate([], ttm=1.0) is None + + +def test_aggregate_empty_with_default_returns_default() -> None: + default = make_fwd(100, 20) + assert ImpliedFwdPrice.aggregate([], ttm=1.0, default=default) is default + + +def make_implied_market( + theoretical_forward: float, + mid_fraction: float, + spread_multiplier: float, + strike_fraction: float, +) -> ImpliedFwdPrice: + """Implied forward whose spread scales with distance from theoretical_forward.""" + mid = theoretical_forward * mid_fraction + spread_bp = max( + 1.0, + abs(mid - theoretical_forward) + / theoretical_forward + * 10000 + * spread_multiplier, + ) + return make_implied(mid, spread_bp, strike=theoretical_forward * strike_fraction) + + +def test_aggregate_all_invalid_returns_default() -> None: + invalid = ImpliedFwdPrice( + security=DefaultVolSecurity.forward(), + bid=Decimal("101"), + ask=Decimal("99"), # bid > ask + strike=Decimal("100"), + maturity=MATURITY, + ) + default = make_fwd(100, 20) + assert ImpliedFwdPrice.aggregate([invalid], ttm=1.0, default=default) is default + + +@given( + theoretical_forward=st.floats( + min_value=100.0, max_value=100_000.0, allow_nan=False, allow_infinity=False + ), + anchor=st.floats( + min_value=0.9, max_value=0.99, allow_nan=False, allow_infinity=False + ), + mid_fractions=st.lists( + st.floats( + min_value=0.95, max_value=1.05, allow_nan=False, allow_infinity=False + ), + min_size=2, + max_size=8, + ), + spread_multipliers=st.lists( + st.floats(min_value=0.5, max_value=3.0, allow_nan=False, allow_infinity=False), + min_size=2, + max_size=8, + ), + strike_fractions=st.lists( + st.floats(min_value=0.8, max_value=1.2, allow_nan=False, allow_infinity=False), + min_size=2, + max_size=8, + ), + ttm=st.floats(min_value=0.1, max_value=2.0, allow_nan=False, allow_infinity=False), +) +def test_aggregate_with_previous_forward( + theoretical_forward: float, + anchor: float, + mid_fractions: list[float], + spread_multipliers: list[float], + strike_fractions: list[float], + ttm: float, +) -> None: + previous_forward = Decimal(str(round(theoretical_forward * anchor, 4))) + n = min(len(mid_fractions), len(spread_multipliers), len(strike_fractions)) + forwards = [ + make_implied_market( + theoretical_forward, + mid_fractions[i], + spread_multipliers[i], + strike_fractions[i], + ) + for i in range(n) + ] + result = ImpliedFwdPrice.aggregate( + forwards, ttm=ttm, previous_forward=previous_forward + ) + assert result is not None + assert result.is_valid() + mids = [float(f.mid) for f in forwards if f.is_valid()] + assert min(mids) - 1e-4 <= float(result.mid) <= max(mids) + 1e-4 + + +def test_aggregate_default_returned_when_implied_agrees() -> None: + # implied forwards all near 100, default also at 100 with tight spread + # result mid ≈ 100, |result - default.mid| / default.spread < 1 → return default + forwards = [make_implied(100, 5), make_implied(100, 5)] + default = make_fwd(100, 20) + result = ImpliedFwdPrice.aggregate(forwards, ttm=1.0, default=default) + assert result is default + + +def test_aggregate_previous_forward_pulls_result_toward_anchor() -> None: + # two forwards: one at 90 (the anchor), one at 110, same tight spread + # without previous_forward: result ≈ 100 (equal weights) + # with previous_forward=90: forward at 90 gets proximity_weight=1, + # forward at 110 is penalised → result pulled below 100 + fwd_at_anchor = make_implied(90, 5) + fwd_above = make_implied(110, 5) + previous_forward = Decimal("90") + + result_without = ImpliedFwdPrice.aggregate([fwd_at_anchor, fwd_above], ttm=1.0) + result_with = ImpliedFwdPrice.aggregate( + [fwd_at_anchor, fwd_above], ttm=1.0, previous_forward=previous_forward + ) + assert result_without is not None + assert result_with is not None + assert float(result_with.mid) < float(result_without.mid) + + +def test_aggregate_outlier_with_wide_spread_does_not_move_result() -> None: + # three tight forwards near 100, one outlier far away with enormous spread + tight = [make_implied(100, 5) for _ in range(3)] + outlier = make_implied(200, 500) + result_without_outlier = ImpliedFwdPrice.aggregate(tight, ttm=1.0) + result_with_outlier = ImpliedFwdPrice.aggregate(tight + [outlier], ttm=1.0) + assert result_without_outlier is not None + assert result_with_outlier is not None + assert ( + abs(float(result_with_outlier.mid) - float(result_without_outlier.mid)) < 0.01 + ) diff --git a/quantflow_tests/test_options.py b/quantflow_tests/test_options.py index 888dd078..6700a5c8 100644 --- a/quantflow_tests/test_options.py +++ b/quantflow_tests/test_options.py @@ -1,4 +1,3 @@ -import json import math import numpy as np @@ -12,7 +11,6 @@ OptionPrice, OptionType, VolSurface, - VolSurfaceInputs, surface_from_inputs, ) from quantflow.sp.heston import Heston @@ -27,12 +25,6 @@ def heston() -> OptionPricer[Heston]: return OptionPricer(model=Heston.create(vol=0.5, kappa=1, sigma=0.8, rho=0)) -@pytest.fixture -def vol_surface() -> VolSurface: - with open("quantflow_tests/volsurface.json") as fp: - 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) @@ -95,6 +87,7 @@ def test_term_structure(vol_surface: VolSurface) -> None: "forward", "basis", "rate_percent", + "fwd_spread_pct", "open_interest", "volume", ] diff --git a/quantflow_tests/test_rates.py b/quantflow_tests/test_rates.py new file mode 100644 index 00000000..0b947bbf --- /dev/null +++ b/quantflow_tests/test_rates.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import math +from datetime import datetime, timezone +from decimal import Decimal + +import numpy as np +import pytest + +from quantflow.rates.interest_rate import Rate +from quantflow.rates.nelson_siegel import NelsonSiegel + +REF_DATE = datetime(2024, 1, 1, tzinfo=timezone.utc) +ONE_YEAR = datetime(2025, 1, 1, tzinfo=timezone.utc) +TWO_YEARS = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +# --------------------------------------------------------------------------- +# Rate +# --------------------------------------------------------------------------- + + +def test_rate_from_number_stores_rate() -> None: + r = Rate.from_number(0.05) + assert float(r.rate) == pytest.approx(0.05, rel=1e-6) + + +def test_rate_percent() -> None: + r = Rate.from_number(0.05) + assert float(r.percent) == pytest.approx(5.0, rel=1e-6) + + +def test_rate_bps() -> None: + r = Rate.from_number(0.0025) + assert float(r.bps) == pytest.approx(25.0, rel=1e-6) + + +def test_rate_zero_default() -> None: + r = Rate() + assert r.rate == Decimal("0") + assert float(r.percent) == 0.0 + assert float(r.bps) == 0.0 + + +def test_discount_factor_continuous_one_year() -> None: + rate = 0.05 + r = Rate.from_number(rate) + df = r.discount_factor(REF_DATE, ONE_YEAR) + expected = math.exp(-rate * 1.0) + assert float(df) == pytest.approx(expected, rel=1e-6) + + +def test_discount_factor_continuous_two_years() -> None: + rate = 0.03 + r = Rate.from_number(rate) + df = r.discount_factor(REF_DATE, TWO_YEARS) + expected = math.exp(-rate * 2.0) + assert float(df) == pytest.approx(expected, rel=1e-5) + + +def test_discount_factor_zero_rate() -> None: + r = Rate.from_number(0.0) + df = r.discount_factor(REF_DATE, ONE_YEAR) + assert float(df) == pytest.approx(1.0, rel=1e-9) + + +def test_discount_factor_expired_returns_one() -> None: + r = Rate.from_number(0.05) + # maturity before ref_date => ttm <= 0 => discount factor = 1 + df = r.discount_factor(ONE_YEAR, REF_DATE) + assert df == Decimal("1") + + +def test_from_spot_and_forward_continuous() -> None: + spot = Decimal("100") + forward = Decimal("105") + r = Rate.from_spot_and_forward(spot, forward, REF_DATE, ONE_YEAR) + expected_rate = math.log(105 / 100) / 1.0 + assert float(r.rate) == pytest.approx(expected_rate, rel=1e-5) + + +def test_from_spot_and_forward_roundtrip() -> None: + spot = Decimal("100") + forward = Decimal("110") + r = Rate.from_spot_and_forward(spot, forward, REF_DATE, TWO_YEARS) + # applying the rate as a growth factor should recover the forward/spot ratio + ttm = 2.0 + growth = math.exp(float(r.rate) * ttm) + assert growth == pytest.approx(float(forward / spot), rel=1e-5) + + +def test_from_spot_and_forward_expired_returns_zero_rate() -> None: + spot = Decimal("100") + forward = Decimal("105") + r = Rate.from_spot_and_forward(spot, forward, ONE_YEAR, REF_DATE) + assert r.rate == Decimal("0") + + +# --------------------------------------------------------------------------- +# NelsonSiegel +# --------------------------------------------------------------------------- + + +def _flat_curve(level: float = 0.05) -> NelsonSiegel: + """Flat curve: beta2=beta3=0, so yield = beta1 for all maturities.""" + return NelsonSiegel( + beta1=Decimal(str(level)), + beta2=Decimal("0"), + beta3=Decimal("0"), + lambda_=Decimal("1"), + ) + + +def test_nelson_siegel_flat_curve_discount_factor() -> None: + ns = _flat_curve(0.05) + ttm = 1.0 + df = ns.discount_factor(ttm) + expected = math.exp(-0.05 * ttm) + assert float(df) == pytest.approx(expected, rel=1e-5) + + +def test_nelson_siegel_flat_curve_two_year() -> None: + ns = _flat_curve(0.04) + df = ns.discount_factor(2.0) + expected = math.exp(-0.04 * 2.0) + assert float(df) == pytest.approx(expected, rel=1e-5) + + +def test_nelson_siegel_discount_factor_zero_ttm() -> None: + ns = _flat_curve(0.05) + assert ns.discount_factor(0) == Decimal("1") + + +def test_nelson_siegel_discount_factor_negative_ttm() -> None: + ns = _flat_curve(0.05) + assert ns.discount_factor(-1) == Decimal("1") + + +def test_nelson_siegel_instantaneous_forward_rate_at_zero() -> None: + ns = NelsonSiegel( + beta1=Decimal("0.04"), + beta2=Decimal("0.02"), + beta3=Decimal("0.01"), + lambda_=Decimal("1"), + ) + # at ttm=0: f(0) = beta1 + beta2 + fr = ns.instanteous_forward_rate(0) + assert float(fr) == pytest.approx(0.06, rel=1e-6) + + +def test_nelson_siegel_instantaneous_forward_rate_large_ttm() -> None: + ns = NelsonSiegel( + beta1=Decimal("0.04"), + beta2=Decimal("0.02"), + beta3=Decimal("0.01"), + lambda_=Decimal("1"), + ) + # as ttm -> inf, e^{-ttm/lambda} -> 0, so f -> beta1 + fr = ns.instanteous_forward_rate(100) + assert float(fr) == pytest.approx(0.04, abs=1e-5) + + +def test_nelson_siegel_discount_factor_increases_with_rate() -> None: + # higher rate => smaller discount factor + ns_low = _flat_curve(0.02) + ns_high = _flat_curve(0.08) + ttm = 1.0 + assert float(ns_low.discount_factor(ttm)) > float(ns_high.discount_factor(ttm)) + + +def test_nelson_siegel_discount_factor_decreases_with_ttm() -> None: + ns = _flat_curve(0.05) + df1 = float(ns.discount_factor(1.0)) + df5 = float(ns.discount_factor(5.0)) + assert df1 > df5 + + +def test_nelson_siegel_fit_recovers_parameters() -> None: + ns_true = NelsonSiegel( + beta1=Decimal("0.04"), + beta2=Decimal("-0.02"), + beta3=Decimal("0.03"), + lambda_=Decimal("1.5"), + ) + ttm = np.linspace(0.25, 10.0, 20) + rates = np.array([float(ns_true.discount_factor(t)) for t in ttm]) + # convert discount factors back to zero rates for fitting + zero_rates = -np.log(rates) / ttm + ns_fit = NelsonSiegel.fit(ttm, zero_rates) + for t in [1.0, 2.0, 5.0]: + assert float(ns_fit.discount_factor(t)) == pytest.approx( + float(ns_true.discount_factor(t)), rel=1e-4 + ) + + +def test_nelson_siegel_fit_flat_curve() -> None: + ttm = np.array([0.5, 1.0, 2.0, 5.0, 10.0]) + rates = np.full_like(ttm, 0.05) + ns = NelsonSiegel.fit(ttm, rates) + for t in ttm: + assert float(ns.discount_factor(t)) == pytest.approx( + math.exp(-0.05 * t), rel=1e-4 + ) + + +def test_nelson_siegel_consistency_forward_and_discount() -> None: + """Numerical check: f(tau) ≈ -d ln D / d tau.""" + ns = NelsonSiegel( + beta1=Decimal("0.04"), + beta2=Decimal("0.015"), + beta3=Decimal("0.008"), + lambda_=Decimal("2"), + ) + ttm = 1.5 + h = 1e-5 + df_plus = float(ns.discount_factor(ttm + h)) + df_minus = float(ns.discount_factor(ttm - h)) + numerical_fwd = -(math.log(df_plus) - math.log(df_minus)) / (2 * h) + analytic_fwd = float(ns.instanteous_forward_rate(ttm)) + assert numerical_fwd == pytest.approx(analytic_fwd, rel=1e-4) diff --git a/quantflow_tests/utils.py b/quantflow_tests/utils.py index 4af57891..a0125636 100644 --- a/quantflow_tests/utils.py +++ b/quantflow_tests/utils.py @@ -1,3 +1,5 @@ +import json +from pathlib import Path from typing import cast import numpy as np @@ -14,6 +16,16 @@ except ImportError: has_plotly = False +FIXTURES = Path(__file__).parent / "fixtures" + + +def load_fixture(name: str) -> list[dict]: + return json.loads((FIXTURES / name).read_text()) + + +def load_fixture_dict(name: str) -> dict: + return json.loads((FIXTURES / name).read_text()) + def characteristic_tests(m: Marginal1D): assert m.characteristic(0) == 1 diff --git a/uv.lock b/uv.lock index be22fb70..9db86069 100644 --- a/uv.lock +++ b/uv.lock @@ -1211,6 +1211,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "hypothesis" +version = "6.152.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/7f/67a06c5f19368e0fa04612bbc446c535bf45b0b51bc6aa56055b112f7604/hypothesis-6.152.2.tar.gz", hash = "sha256:11fd5725958fe75597d1b831f703fdf7e636b7cf1f249117f381ad5cee4d888f", size = 466358, upload-time = "2026-04-24T04:26:18.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/ed/77eed094bceae845a994f936721293afae40a346c0005d85208407fd40e8/hypothesis-6.152.2-py3-none-any.whl", hash = "sha256:1ad5b87f0e6c0ab7a9a35b1378cc4963d23eaf0cb1e47e94f1d574b41155907a", size = 532034, upload-time = "2026-04-24T04:26:16.394Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -3474,6 +3486,11 @@ ml = [ { name = "torch" }, ] +[package.dev-dependencies] +dev = [ + { name = "hypothesis" }, +] + [package.metadata] requires-dist = [ { name = "aio-fluid", extras = ["http"], marker = "extra == 'data'", specifier = ">=1.2.1" }, @@ -3516,6 +3533,9 @@ requires-dist = [ ] provides-extras = ["ai", "book", "data", "dev", "docs", "ml"] +[package.metadata.requires-dev] +dev = [{ name = "hypothesis", specifier = ">=6.152.2" }] + [[package]] name = "redis" version = "7.4.0" @@ -3872,6 +3892,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sphinx" version = "9.0.4" @@ -4195,24 +4224,24 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp311-cp311-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp311-cp311-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp312-cp312-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp312-cp312-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp312-cp312-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313t-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314t-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314t-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314t-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp311-cp311-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:31Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:32Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp311-cp311-win_amd64.whl", upload-time = "2026-03-23T14:59:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp312-cp312-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp312-cp312-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp312-cp312-win_amd64.whl", upload-time = "2026-03-23T14:59:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:44Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313-win_amd64.whl", upload-time = "2026-03-23T14:59:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313t-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:56Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313t-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T15:00:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp313-cp313t-win_amd64.whl", upload-time = "2026-03-23T15:00:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T15:00:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T15:00:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314-win_amd64.whl", upload-time = "2026-03-23T15:00:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314t-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T15:00:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314t-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T15:00:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.11.0%2Bcu126-cp314-cp314t-win_amd64.whl", upload-time = "2026-03-23T15:00:39Z" }, ] [[package]]