diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4336990a..6ef0b2f2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -56,7 +56,7 @@ applyTo: '/**' * Do not repeat concept definitions inline in tutorials or docstrings, link to the glossary instead using a relative markdown link (e.g. `[moneyness](../glossary.md#moneyness)`). * Use relative links for all mkdocs page links (e.g. `[Option Pricing](../theory/option_pricing.md)`) — prefer relative over absolute URLs to keep links shorter and portable. * Prefer mkdocstrings relative cross-references whenever the target is visible from the current scope: write `[label][.member]` (same class) or `[label][..Sibling]` (same module) instead of repeating the fully-qualified path. Use the full path only when the target lives in a different module than the current docstring. -* To rebuild doc examples run `uv run ./dev/build-examples` — runs all scripts in `docs/examples/` and writes their output to `docs/examples/output/` +* To rebuild doc examples run `uv run python dev/tools/build_examples.py` — runs all scripts in `docs/examples/` and writes their output to `docs/examples/output/` ## Pydantic models diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index af31d5c2..20e1a6c4 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: uv sync --frozen --no-install-project --group docs --extra data - name: build examples - run: uv run ./dev/build-examples + run: uv run python dev/tools/build_examples.py - name: upload examples output if: inputs.upload uses: actions/upload-artifact@v4 diff --git a/.vscode/launch.json b/.vscode/launch.json index 37d7d864..fefb53cc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,6 +2,18 @@ "version": "0.2.0", "configurations": [ + { + "name": "Record Fixtures", + "type": "debugpy", + "request": "launch", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "program": "dev/tools/record_fixtures.py", + "justMyCode": false, + "args": [ + "--dry-run", + ] + }, { "name": "PyTest", "type": "debugpy", diff --git a/Makefile b/Makefile index b75360c0..3cd8bbec 100644 --- a/Makefile +++ b/Makefile @@ -12,21 +12,25 @@ app-serve: ## serve python api app only .PHONY: docs docs: ## build documentation @cp docs/index.md readme.md - @uv run ./dev/build-examples + @uv run python dev/tools/build_examples.py @uv run mkdocs build --strict .PHONY: docs-bib docs-bib: ## Regenerate docs bibliography - @uv run ./docs/bib2md.py + @uv run python dev/tools/bib2md.py .PHONY: docs-examples docs-examples: ## Regenerate docs examples - @uv run ./dev/build-examples + @uv run python dev/tools/build_examples.py .PHONY: docs-serve docs-serve: ## serve docs, examples, and API with auto-reload @bash ./dev/serve/all +.PHONY: fixtures +fixtures: ## Record market data fixtures from live sources + @uv run python dev/tools/record_fixtures.py + .PHONY: frontend-build frontend-build: ## build Observable frontend examples @rm -rf app/examples diff --git a/dev/quantflow.dockerfile b/dev/quantflow.dockerfile index 623f2c45..c02397d8 100644 --- a/dev/quantflow.dockerfile +++ b/dev/quantflow.dockerfile @@ -18,7 +18,7 @@ RUN uv sync --frozen --no-install-project --group docs --extra data # Copy source and build docs # Example outputs and images must be prebuilt in the build context -# (run `uv run ./dev/build-examples` locally, or the build-examples CI job) +# (run `uv run python dev/tools/build_examples.py` locally, or the build-examples CI job) COPY mkdocs.yml ./ COPY dev/ ./dev/ COPY docs/ ./docs/ diff --git a/docs/bib2md.py b/dev/tools/bib2md.py similarity index 98% rename from docs/bib2md.py rename to dev/tools/bib2md.py index 3764f31a..9e996607 100644 --- a/docs/bib2md.py +++ b/dev/tools/bib2md.py @@ -2,7 +2,7 @@ """Convert docs/references.bib to docs/bibliography.md. Usage: - uv run python docs/bib2md.py [--bib PATH] [--out PATH] + uv run python dev/tools/bib2md.py [--bib PATH] [--out PATH] The BibTeX file is the source of truth; run this script whenever it changes. """ @@ -179,7 +179,7 @@ def convert(bib_path: Path, out_path: Path) -> None: def main() -> None: - docs = Path(__file__).parent + docs = Path(__file__).parent.parent.parent / "docs" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--bib", diff --git a/dev/build-examples b/dev/tools/build_examples.py similarity index 100% rename from dev/build-examples rename to dev/tools/build_examples.py diff --git a/dev/tools/record_fixtures.py b/dev/tools/record_fixtures.py new file mode 100644 index 00000000..c3b5107c --- /dev/null +++ b/dev/tools/record_fixtures.py @@ -0,0 +1,88 @@ +"""Record market data fixtures from live sources. + +Each recorder fetches live data, trims it down to the fields consumed by the +loaders and writes it to docs/examples/fixtures. The regularMarketTime entry +of the underlying quote is preserved so loaders can recover the as of date of +the snapshot. +""" + +import asyncio +import gzip +import json +from datetime import datetime, timezone +from typing import Awaitable, Callable + +import click + +from docs.examples._utils import FIXTURES +from quantflow.data.yahoo import Yahoo + +OPTION_FIELDS = ("strike", "bid", "ask", "openInterest", "volume") +QUOTE_FIELDS = ("bid", "ask", "regularMarketPrice", "regularMarketTime") + + +def trim_chain(chain: dict) -> dict: + """Trim a Yahoo option chain payload to the fields used by the loaders""" + quote = chain.get("quote") or {} + return { + "underlyingSymbol": chain.get("underlyingSymbol", ""), + "quote": {key: quote.get(key) for key in QUOTE_FIELDS}, + "options": [ + { + "expirationDate": expiry["expirationDate"], + "calls": trim_contracts(expiry.get("calls", [])), + "puts": trim_contracts(expiry.get("puts", [])), + } + for expiry in chain.get("options", []) + ], + } + + +def trim_contracts(contracts: list[dict]) -> list[dict]: + return [ + {key: c.get(key) for key in OPTION_FIELDS if c.get(key) is not None} + for c in contracts + ] + + +async def record_yahoo(dry_run: bool) -> None: + """Record the SPX option chain fixture from Yahoo Finance""" + async with Yahoo() as cli: + chain = await cli.option_chain("^SPX") + trimmed = trim_chain(chain) + path = FIXTURES / "yahoo_spx.json.gz" + expiries = len(trimmed["options"]) + as_of = "unknown" + if market_time := trimmed["quote"].get("regularMarketTime"): + as_of = datetime.fromtimestamp(market_time, tz=timezone.utc).isoformat() + if dry_run: + click.echo( + f"dry run, would record {path} as of {as_of} with {expiries} expiries" + ) + return + path.write_bytes(gzip.compress(json.dumps(trimmed).encode())) + click.echo(f"recorded {path} as of {as_of}") + + +RECORDERS: dict[str, Callable[[bool], Awaitable[None]]] = { + "yahoo": record_yahoo, +} + + +@click.command() +@click.argument("sources", nargs=-1, type=click.Choice(sorted(RECORDERS))) +@click.option( + "--dry-run", is_flag=True, help="Fetch and trim but do not write fixture files." +) +def main(sources: tuple[str, ...], dry_run: bool) -> None: + """Record market data fixtures from live sources. + + With no arguments all fixtures are recorded, otherwise only the given + SOURCES are recorded. + """ + for name in sources or sorted(RECORDERS): + asyncio.run(RECORDERS[name](dry_run)) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/fixtures/yahoo_spx.json.gz b/docs/examples/fixtures/yahoo_spx.json.gz index 2789fe68..b5b504fa 100644 Binary files a/docs/examples/fixtures/yahoo_spx.json.gz and b/docs/examples/fixtures/yahoo_spx.json.gz differ diff --git a/docs/glossary.md b/docs/glossary.md index 6155f76f..557737d9 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -108,7 +108,7 @@ the forward-space prices are Forward-space prices are dimensionless and depend only on the [log-strike](#log-strike) $k = \log(K/F)$, the implied volatility, -and the time to maturity. They are the natural output of Fourier-based +and the [time to maturity](#time-to-maturity-ttm). They are the natural output of Fourier-based pricers and of [Black pricing](api/options/black.md). The conversion to quote-currency prices is a single multiplication by $F$: @@ -166,7 +166,7 @@ Moneyness is used in the context of option pricing in order to compare options w m = \frac{1}{\sqrt{\tau}}\ln{\frac{K}{F}} = \frac{k}{\sqrt{\tau}} \end{equation} -where $K$ is the strike, $F$ is the Forward price, and $\tau$ is the time to maturity. It is used to compare options with different maturities by scaling the [log-strike](#log-strike) by the square root of time to maturity. This is because the price of the underlying asset is subject to random fluctuations, if these fluctuations follow a Brownian motion than the standard deviation of the price movement will increase with the square root of time. +where $K$ is the strike, $F$ is the Forward price, and $\tau$ is the [time to maturity](#time-to-maturity-ttm). It is used to compare options with different maturities by scaling the [log-strike](#log-strike) by the square root of time to maturity. This is because the price of the underlying asset is subject to random fluctuations, if these fluctuations follow a Brownian motion then the standard deviation of the price movement will increase with the square root of time. ## Moneyness Convexity Adjusted @@ -177,7 +177,7 @@ The convexity-adjusted moneyness, often called standardized moneyness in the lit m_c = \frac{1}{\sigma\sqrt{\tau}}\ln\frac{K}{F} + \frac{\sigma\sqrt{\tau}}{2} = m_\sigma + \frac{\sigma\sqrt{\tau}}{2} = -d_2 \end{equation} -where $K$ is the strike, $F$ is the Forward price, $\tau$ is the time to maturity, $\sigma$ is the implied Black volatility and $d_2$ is the standard argument of the [Black formula](api/options/black.md#quantflow.options.bs.black_price). +where $K$ is the strike, $F$ is the Forward price, $\tau$ is the [time to maturity](#time-to-maturity-ttm), $\sigma$ is the implied Black volatility and $d_2$ is the standard argument of the [Black formula](api/options/black.md#quantflow.options.bs.black_price). Unlike the [vol-adjusted moneyness](#moneyness-vol-adjusted), which is centered at the forward, this measure is zero at the median of the risk-neutral distribution, $K = F e^{-\sigma^2\tau/2}$. @@ -189,7 +189,7 @@ The vol-adjusted moneyness is used in the context of option pricing in order to m_\sigma = \frac{1}{\sigma\sqrt{\tau}}\ln\frac{K}{F} = \frac{m}{\sigma} \end{equation} -where $K$ is the strike, $F$ is the Forward price, $\tau$ is the time to maturity, $\sigma$ is the implied Black volatility and $m$ is the [moneyness](#moneyness). +where $K$ is the strike, $F$ is the Forward price, $\tau$ is the [time to maturity](#time-to-maturity-ttm), $\sigma$ is the implied Black volatility and $m$ is the [moneyness](#moneyness). ## Parseval's Theorem @@ -221,7 +221,7 @@ The [probability density function](https://en.wikipedia.org/wiki/Probability_den ## 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 time to maturity. +and put options with the same strike $K$ and [time to maturity](#time-to-maturity-ttm). \begin{equation} C - P = D_q \left(F - K\right) @@ -231,9 +231,16 @@ where $D_q$ is the [discount factor](#discount-factor) of the quoting asset (gen at maturity and $F$ is the forward price of the underlying asset at maturity. -Denoting forward-space prices -$c = C/(D_q\ F)$ and $p = P/(D_q\ F)$ (see [Black Pricing](api/options/black.md)), the relationship -reads: +Denoting forward-space prices for calls and puts as: + +\begin{equation} +\begin{aligned} + c &= \frac{C}{D_q\ F} \\ + p &= \frac{P}{D_q\ F} +\end{aligned} +\end{equation} + +see [Black Pricing](api/options/black.md), the relationship reads: \begin{equation} c - p = 1 - \frac{K}{F} = 1 - e^k diff --git a/docs/tutorials/volatility_surface.md b/docs/tutorials/volatility_surface.md index 2249623b..2309062b 100644 --- a/docs/tutorials/volatility_surface.md +++ b/docs/tutorials/volatility_surface.md @@ -101,13 +101,21 @@ surface2 = surface_from_inputs(inputs) # VolSurfaceInputs -> VolSurface ## Extracting Forwards and Discount Factors Pricing an option requires two market inputs beyond the option price itself: the forward -price $F$ of the underlying at expiry, and the discount factor $D$ for that maturity. +price $F$ of the underlying at expiry, and the quote discount factor $D_q$ for that +maturity, where the quote currency is usually USD. In liquid markets these quantities are directly observable. Futures and forward contracts -give $F$ outright, and interest rate swaps or government bond strips give $D$. In many -option markets, however, neither is quoted directly. Crypto options on Deribit are a -clear example: there is no liquid term structure of interest rates and the forward for -each expiry must be inferred from the options themselves. +give $F$ outright, and interest rate swaps or government bond strips give $D_q$. In many +option markets, however, neither is quoted directly. + +!!! note "Deribit Forward" + + Crypto options on Deribit are a clear example. There is no liquid term structure + of interest rates, and while Deribit quotes futures for each expiry, they are + often illiquid, with wide bid-ask spreads and stale or outright wrong prices. + + The forward for each expiry must therefore be inferred from the options + themselves. Even when forwards are available, the discount factor used to value options may differ from the rate implied by the forward-spot basis. For equity options the carry includes @@ -115,10 +123,40 @@ dividends and repo costs that are not captured by a simple interest rate curve. crypto inverse options the discount factor reflects funding in the underlying asset rather than in dollars. -For these reasons, quantflow can extract $D_q$ and $D_a$ directly from the market prices -of options using put-call parity. The +For these reasons, quantflow extracts $D_q$ and $D_a$ directly from the market prices +of options using [put-call parity](../glossary.md#put-call-parity). + +### Put-call parity and the implied forward + +For each maturity, the parity relationship is fitted in the normalized form: + +\begin{equation} +\frac{C - P}{S} = D_a - D_q \frac{K}{S} +\end{equation} + +where $S$ is the spot price and $D_a$ the asset discount factor. The same equation +holds for inverse options with the left hand side replaced by $c - p$, the price +difference in units of the underlying. + +The price difference is linear in the strike, so a regression across strikes +identifies $D_a$ and $D_q$, and the line crosses zero exactly at the forward: + +\begin{equation} +F = S \frac{D_a}{D_q} +\end{equation} + +[put_call_parities][quantflow.options.surface.VolCrossSectionLoader.put_call_parities] +collects the most liquid pairs at each maturity, ranked by the bid-ask spread of the +parity price, and +[implied_forward][quantflow.options.parity.PutCallParities.implied_forward] fits the +regression and returns the implied forward. + +### Discount curve calibration + +The [calibrate_curves][quantflow.options.surface.GenericVolSurfaceLoader.calibrate_curves] -method supports three modes: +method fits smooth yield curves to the same put-call parity data across all +maturities. It supports three modes: - **Both curves**: pass a [YieldCurve][quantflow.rates.yield_curve.YieldCurve] type for both `quote_curve` and `asset_curve`. A single OLS regression per maturity identifies diff --git a/quantflow/data/yahoo.py b/quantflow/data/yahoo.py index 43d90591..36f54a63 100644 --- a/quantflow/data/yahoo.py +++ b/quantflow/data/yahoo.py @@ -132,8 +132,15 @@ def loader_from_chain( US equity options are non-inverse: prices are in the quote currency and the spot is taken from the underlying quote. Forwards are not provided by Yahoo, so they are recovered from put-call parity by the loader. + + When ref_date is None and the underlying quote carries a + regularMarketTime entry, that time is used as the reference date for + the yield curves, falling back to the current time otherwise. """ symbol = chain.get("underlyingSymbol", "") + quote = chain.get("quote") or {} + if ref_date is None and (market_time := quote.get("regularMarketTime")): + ref_date = datetime.fromtimestamp(market_time, tz=timezone.utc) ref = ref_date or utcnow() loader = VolSurfaceLoader( asset=symbol, @@ -144,7 +151,6 @@ def loader_from_chain( quote_curve=NoDiscountCurve(ref_date=ref), asset_curve=NoDiscountCurve(ref_date=ref), ) - quote = chain.get("quote") or {} bid = quote.get("bid") or quote.get("regularMarketPrice") ask = quote.get("ask") or quote.get("regularMarketPrice") if bid and ask: diff --git a/quantflow/options/parity.py b/quantflow/options/parity.py index aef8a107..b320853a 100644 --- a/quantflow/options/parity.py +++ b/quantflow/options/parity.py @@ -20,8 +20,9 @@ class DiscountPair(BaseModel, frozen=True): class PutCallParity(BaseModel, frozen=True): - """A matched put-call parity at a single strike, - used for discount curve calibration.""" + """A [put-call parity](../../glossary.md#put-call-parity) at a single strike + + used for forward and discount curve calibration.""" strike: Decimal = Field(description="Strike price") call: Price = Field(description="Call option bid/ask prices") @@ -91,10 +92,15 @@ def fit_discounts( min_rate_q: float = 0.0, min_rate_a: float = 0.0, ) -> DiscountPair | None: - """Return the fitted discount factors, or None if the result is invalid. + r"""Return the fitted discount factors, or None if the result is invalid. Both direct and inverse options satisfy the same normalized equation - y = Da - (Dq/S) * K, where y = mid/S for direct and y = mid for inverse. + + \begin{equation} + y = Da - K \frac{Dq}{S} + \end{equation} + + where y = mid/S for direct and y = mid for inverse. When both known values are None a full OLS is run via constrained least squares. When one is provided the other is solved analytically as the mean over pairs. diff --git a/quantflow/options/surface.py b/quantflow/options/surface.py index aad02aea..80ceef5d 100644 --- a/quantflow/options/surface.py +++ b/quantflow/options/surface.py @@ -1441,9 +1441,10 @@ def calibrate_spot( """Calibrate the spot price from short-dated put-call parity. For short-dated options where discount factors are approximately 1, - put-call parity simplifies to C - P = S - K, so S = C - P + K. + put-call parity simplifies to `C - P = S - K`, so `S = C - P + K`. + This method computes the median implied spot across all put-call pairs - with time to maturity at or below max_ttm and updates the spot price. + with time to maturity at or below `max_ttm` and updates the spot price. Returns the implied spot, or None if no maturities fall within max_ttm. """