Skip to content
Open
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
26 changes: 23 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,17 @@ applyTo: '/**'

* Always run `make lint` after code changes — runs taplo, isort, black, ruff, and mypy
* Never edit `readme.md` directly — it is generated from `docs/index.md` via `make docs`
* To install all dependencies (including all optional extras) run `make install-dev` — runs `uv sync --all-extras`
* To install all dependencies (including all optional extras) run `make install-dev`
* Do not modify code unless the developer explicitly asks for a code change.
* Never change code that works unless you have been asked by the developer to do so,
or you have a good reason to believe the code is wrong.
* Concentrate on fixing the problem, not on making the code look nice unless you are
extremely confident that your code style is better than the original and that the original code style
is not serving a purpose (e.g. readability, consistency with other code, etc.).
If you are unsure, ask the developer or leave the code style as it is.

## Run Tests

* To run all tests use `make test` — runs all tests in the `tests/` directory using pytest
* To run a specific test file, use `uv run pytest tests/path/to/test_file.py`

Expand All @@ -29,16 +39,26 @@ applyTo: '/**'
* Documentation is built using [mkdocs](https://www.mkdocs.org/) and stored in the `docs/` directory. The documentation source files are written in markdown format.
* Split prose into short paragraphs (one idea per paragraph) separated by blank lines. Never write a wall-of-text paragraph that strings together mechanism, rationale, caveats and usage advice. This applies to mkdocs tutorials, theory pages and long docstrings.
* Do not use dashes (em dashes, en dashes, or hyphens used as dashes) in documentation files or docstrings. Use colons, parentheses, or restructure the sentence instead.
* Always use `Annotated(..., Doc("..."))` for docstrings in code, never use triple-quoted strings below the definition of a function or class. For example:
```python
from typing_extensions import Annotated, Doc

def foo(x: Annotated[int, Doc("This is the docstring for x")]) -> float:
"""This is the docstring for foo"""
return float(x)
```
* Do not use Docstrings with markdown text that may genereate headings (e.g. `# Heading`, `## Heading`, etc.)
* 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:`).
* Math notation convention: use $\Phi$ for the characteristic function and $\phi$ for the characteristic exponent, where $\Phi = e^{-\phi}$.
* Glossary entries in `docs/glossary.md` must be kept in alphabetical order.
* 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)`).
* 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/`

## Pydantic models

* Always document Pydantic fields with `Field(description=...)` never use a docstring below a field assignment
* Always document Pydantic fields with `Field(description=...)`, never use a docstring below a field assignment
* Split long description strings across lines using implicit string concatenation rather than shortening the text
* When a docstring line exceeds the line length limit, split it across multiple lines rather than shortening the text

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ _build

# builds
app/docs
app/examples
app/frontend/node_modules
app/frontend/src/.observablehq
docs/assets/examples
docs/examples/output
17 changes: 15 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ help:
@fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//'
@echo ================================================================================

.PHONY: app-serve
app-serve: ## serve app
@MICRO_SERVICE_HOST=127.0.0.1 uv run python -m app

.PHONY: docs
docs: ## build documentation
@cp docs/index.md readme.md
Expand All @@ -20,8 +24,17 @@ docs-examples: ## Regenerate docs examples
@uv run ./dev/build-examples

.PHONY: docs-serve
docs-serve: ## serve documentation
@uv run mkdocs serve --livereload --watch quantflow --watch docs
docs-serve: ## serve docs, examples, and API with auto-reload
@bash ./dev/docs-serve

.PHONY: frontend-build
frontend-build: ## build Observable frontend examples
@rm -rf app/examples
@npm --prefix app/frontend run build

.PHONY: frontend-serve
frontend-serve: ## serve Observable frontend with auto-reload
@bash ./dev/frontend-serve

.PHONY: install-dev
install-dev: ## Install development dependencies
Expand Down
Empty file added app/__init__.py
Empty file.
91 changes: 64 additions & 27 deletions app/__main__.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,82 @@
import os
from pathlib import Path

import marimo
from dotenv import load_dotenv
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fluid.utils import log

from app.utils.paths import APP_PATH, head_snippet
from app.utils.paths import APP_PATH
from quantflow import __version__

PORT = int(os.environ.get("MICRO_SERVICE_PORT", "8001"))
status_router = APIRouter()
from .api.cointegration import cointegration_router
from .api.deps import instrument_app
from .api.heston import heston_router
from .api.hurst import hurst_router
from .api.rates import rates_router
from .api.sampling import sampling_router
from .api.smoother import smoother_router
from .api.status import status_router
from .api.volatility import volatility_router
from .utils.static import HtmlFallbackStaticFiles


def crate_app() -> FastAPI:
# Create a marimo asgi app
html_head = head_snippet(APP_PATH / "docs")
server = marimo.create_asgi_app(include_code=True, html_head=html_head)
for path in APP_PATH.glob("*.py"):
if path.name.startswith("_"):
continue
dashed = path.stem.replace("_", "-")
server = server.with_app(path=f"/{dashed}", root=f"./app/{path.name}")
# Create a FastAPI app
app = FastAPI()
app.include_router(status_router)
app.mount("/examples", server.build())
app.mount("/", StaticFiles(directory=APP_PATH / "docs", html=True), name="static")
return app

load_dotenv()
log.config()
app = FastAPI(
version=__version__,
title="Quantflow API",
description="API for Quantflow",
)
instrument_app(app)
cors_origins = [
origin.strip()
for origin in os.environ.get("QUANTFLOW_CORS_ORIGINS", "").split(",")
if origin.strip()
]
if cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

@status_router.get("/status")
async def service_status() -> dict:
return {"status": "ok"}
api = APIRouter(prefix="/.api")

@api.get("/redoc", include_in_schema=False)
async def api_redoc() -> HTMLResponse:
return get_redoc_html(
openapi_url="/openapi.json",
title="Quantflow API",
)

@status_router.get("/ready")
async def service_ready() -> dict:
return {"status": "ok"}
api.include_router(cointegration_router)
api.include_router(heston_router)
api.include_router(hurst_router)
api.include_router(rates_router)
api.include_router(sampling_router)
api.include_router(smoother_router)
api.include_router(volatility_router)
app.include_router(api)
app.include_router(status_router, include_in_schema=False)
app.mount(
"/examples",
HtmlFallbackStaticFiles(directory=APP_PATH / "examples", html=True),
name="examples",
)
app.mount("/", StaticFiles(directory=APP_PATH / "docs", html=True), name="static")
return app


# Run the server
if __name__ == "__main__":
import uvicorn

uvicorn.run(crate_app(), host="0.0.0.0", port=PORT)
PORT = int(os.environ.get("MICRO_SERVICE_PORT", "8001"))
HOST = os.environ.get("MICRO_SERVICE_HOST", "0.0.0.0")
uvicorn.run(crate_app(), host=HOST, port=PORT)
Empty file added app/api/__init__.py
Empty file.
61 changes: 61 additions & 0 deletions app/api/cointegration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field

from quantflow.data.fmp import FMP

cointegration_router = APIRouter()


class CointegrationResponse(BaseModel):
dates: list[str] = Field(description="Date strings")
residuals: list[float] = Field(description="Cointegration residual values")
deltas: list[float] = Field(
description="Cointegrating vector (eigenvector for largest eigenvalue)"
)


@cointegration_router.get("/cointegration")
async def cointegration(
frequency: str = Query(
"daily",
description="Price frequency",
enum=["1min", "5min", "15min", "30min", "1hour", "4hour", "daily"],
),
) -> CointegrationResponse:
import numpy as np
from statsmodels.tsa.vector_ar.vecm import coint_johansen

# daily uses the EOD endpoint (frequency=None)
freq = None if frequency == "daily" else frequency

async with FMP() as cli:
btc = await cli.prices("BTCUSD", convert_to_date=True, frequency=freq)
eth = await cli.prices("ETHUSD", convert_to_date=True, frequency=freq)
sol = await cli.prices("SOLUSD", convert_to_date=True, frequency=freq)

btc = btc.set_index("date")
eth = eth.set_index("date")
sol = sol.set_index("date")

prices_3 = (
btc[["close"]]
.join(eth[["close"]], lsuffix="_btc", rsuffix="_eth")
.join(sol[["close"]])
)
prices_3.columns = ["btc_close", "eth_close", "sol_close"]
prices_3 = prices_3.dropna()

log_prices_3 = np.log(prices_3)
johansen_result = coint_johansen(log_prices_3, det_order=0, k_ar_diff=1)
deltas = johansen_result.evec[:, 0]

residuals = log_prices_3.dot(deltas)
residual_mean = residuals.mean()
residuals = residuals - residual_mean

dates = [str(d)[:10] for d in residuals.index]
return CointegrationResponse(
dates=dates,
residuals=[float(v) for v in residuals.values],
deltas=[float(v) for v in deltas],
)
55 changes: 55 additions & 0 deletions app/api/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Annotated, Generic, TypeVar, cast

from fastapi import Depends, FastAPI, Request
from fluid.utils import log
from fluid.utils.redis import FluidRedis
from pydantic import BaseModel
from redis import Redis

M = TypeVar("M", bound=BaseModel)
logger = log.get_logger(__name__)


def instrument_app(app: FastAPI) -> None:
"""Instrument the app with the necessary dependencies"""
redis = FluidRedis.create()
app.state.redis = redis.redis_cli
app.router.on_shutdown.append(redis.close)


def get_redis(request: Request) -> Redis:
"""Get the redis client from the app state"""
if not hasattr(request.app.state, "redis"):
raise RuntimeError("Redis client not found in app state")
return cast(Redis, request.app.state.redis)


@dataclass
class RedisCache(Generic[M]):
redis: Redis
Model: type[M]
key: str
ttl: int = 60

async def from_cache(self, loader: Callable[[], Awaitable[M]]) -> M:
"""Get a value from the cache"""
value = await self.redis.get(self.key)
if value is None:
return await self.set_cache(await loader())
try:
return self.Model.model_validate_json(value)
except json.JSONDecodeError:
logger.exception(f"Failed to decode cache value for key {self.key}")
return await self.set_cache(await loader())

async def set_cache(self, value: M) -> M:
"""Set a value in the cache"""
payload = value.model_dump_json()
await self.redis.set(self.key, payload, ex=self.ttl)
return value


RedisDep = Annotated[Redis, Depends(get_redis)]
80 changes: 80 additions & 0 deletions app/api/heston.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import numpy as np
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field

from quantflow.options.pricer import OptionPricer
from quantflow.sp.heston import HestonJ
from quantflow.sp.jump_diffusion import JumpDiffusion
from quantflow.utils.distributions import DoubleExponential

heston_router = APIRouter()


class VolSurfaceGridResponse(BaseModel):
moneyness: list[float] = Field(description="Moneyness grid values")
ttm: list[float] = Field(description="Time to maturity grid values")
implied_vol: list[list[float]] = Field(
description="Implied vol grid (rows=ttm, cols=moneyness)"
)


@heston_router.get("/heston-vol-surface")
async def heston_vol_surface(
model: str = Query(
"jd",
description="Model type",
enum=["jd", "hj"],
),
vol: float = Query(0.4, description="Long term volatility", ge=0.1, le=0.8),
sigma: float = Query(0.5, description="Vol of vol", ge=0.1, le=2.0),
kappa: float = Query(0.5, description="Variance mean reversion", ge=0.1, le=2.0),
rho: float = Query(0.0, description="Correlation", ge=-0.6, le=0.6),
r: float = Query(1.0, description="Initial vol ratio", ge=0.6, le=1.6),
jump_fraction: float = Query(0.5, description="Jump fraction", ge=0.1, le=0.9),
jump_intensity: float = Query(
10.0, description="Jump intensity", ge=10.0, le=100.0
),
jump_asymmetry: float = Query(
0.0, description="Jump asymmetry (log kappa)", ge=-2.0, le=2.0
),
) -> VolSurfaceGridResponse:
pricer: OptionPricer
if model == "jd":
vm_jd = JumpDiffusion.create(
DoubleExponential,
vol=vol,
jump_fraction=jump_fraction,
jump_intensity=jump_intensity,
jump_asymmetry=jump_asymmetry,
)
pricer = OptionPricer(model=vm_jd)
else:
st = sigma / vol
k = max(kappa, 0.5 * st * st)
vm_hj = HestonJ.create(
DoubleExponential,
rate=r,
vol=vol,
sigma=sigma,
kappa=k,
rho=rho,
jump_fraction=jump_fraction,
jump_intensity=jump_intensity,
jump_asymmetry=jump_asymmetry,
)
pricer = OptionPricer(model=vm_hj)
ttm_arr = np.linspace(0.1, 1.0, 10)
moneyness_arr = np.linspace(-0.5, 0.5, 51)
implied = np.zeros((len(ttm_arr), len(moneyness_arr)))
for i, t in enumerate(ttm_arr):
maturity = pricer.maturity(float(t))
vols = maturity.prices(moneyness_arr * np.sqrt(t))["implied_vol"].values
# replace NaN/Inf/negative with 0
vols = np.where(np.isfinite(vols) & (vols > 0), vols, 0.0)
implied[i, :] = vols

return VolSurfaceGridResponse(
moneyness=[float(m) for m in moneyness_arr],
ttm=[float(t) for t in ttm_arr],
implied_vol=[[float(v) for v in row] for row in implied],
)
Loading
Loading