Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dev/quantflow.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
4 changes: 2 additions & 2 deletions docs/bib2md.py → dev/tools/bib2md.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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",
Expand Down
File renamed without changes.
88 changes: 88 additions & 0 deletions dev/tools/record_fixtures.py
Original file line number Diff line number Diff line change
@@ -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()
Binary file modified docs/examples/fixtures/yahoo_spx.json.gz
Binary file not shown.
23 changes: 15 additions & 8 deletions docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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$:
Expand Down Expand Up @@ -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
Expand All @@ -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}$.

Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
54 changes: 46 additions & 8 deletions docs/tutorials/volatility_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,24 +101,62 @@ 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
dividends and repo costs that are not captured by a simple interest rate curve. For
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
Expand Down
8 changes: 7 additions & 1 deletion quantflow/data/yahoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
14 changes: 10 additions & 4 deletions quantflow/options/parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions quantflow/options/surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment on lines 1443 to +1444

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.
"""
Expand Down
Loading