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
7 changes: 7 additions & 0 deletions app/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
from app.utils.paths import APP_PATH
from quantflow import __version__

try:
from app.api.mcp import create_mcp as _create_mcp
except ImportError:
_create_mcp = None # type: ignore[assignment]

from .api.cointegration import cointegration_router
from .api.deps import instrument_app
from .api.heston import heston_router
Expand Down Expand Up @@ -64,6 +69,8 @@ async def api_redoc() -> HTMLResponse:
api.include_router(volatility_router)
app.include_router(api)
app.include_router(status_router, include_in_schema=False)
if _create_mcp is not None:
app.mount("/mcp", _create_mcp().streamable_http_app())
examples_dir = APP_PATH / "examples"
if examples_dir.is_dir():
app.mount(
Expand Down
23 changes: 4 additions & 19 deletions quantflow/ai/tools/crypto.py → app/api/mcp.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"""Crypto tools for the quantflow MCP server."""
"""MCP server for quantflow - crypto volatility tools served via HTTP."""

from pathlib import Path

from mcp.server.fastmcp import FastMCP

from quantflow.data.deribit import Deribit, InstrumentKind

from .base import McpTool


def register(mcp: FastMCP, tool: McpTool) -> None:
def create_mcp() -> FastMCP:
mcp = FastMCP("quantflow")

@mcp.tool()
async def crypto_instruments(currency: str, kind: str = "spot") -> str:
Expand Down Expand Up @@ -88,18 +87,4 @@ async def vol_surface_snapshot(currency: str, path: str) -> str:
Path(path).write_text(inputs.model_dump_json(indent=2))
return f"Saved {len(vs.maturities)} maturities to {path}"

@mcp.tool()
async def crypto_prices(symbol: str, frequency: str = "") -> str:
"""Get OHLC price history for a cryptocurrency via FMP.

Args:
symbol: Cryptocurrency symbol e.g. BTCUSD
frequency: Data frequency - 1min, 5min, 15min, 30min, 1hour, 4hour,
or empty for daily
"""
async with tool.fmp() as client:
df = await client.prices(symbol, frequency=frequency)
if df.empty:
return f"No price data for {symbol}"
df = df[["date", "open", "high", "low", "close", "volume"]].sort_values("date")
return df.tail(50).to_csv(index=False)
return mcp
57 changes: 51 additions & 6 deletions app/api/sampling.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import numpy as np
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from scipy.stats import chisquare, ks_1samp

from quantflow.sp.ou import Vasicek
from quantflow.sp.poisson import PoissonProcess
from quantflow.ta.paths import Paths
from quantflow.utils import bins
from quantflow.utils.distributions import DoubleExponential

Expand All @@ -16,6 +18,18 @@ class SamplingResponse(BaseModel):
analytical: list[float] = Field(description="Analytical PDF values")


class GaussianSamplingResponse(SamplingResponse):
ks_statistic: float = Field(
description="Kolmogorov-Smirnov statistic vs analytical CDF"
)
ks_pvalue: float = Field(description="Kolmogorov-Smirnov p-value")


class PoissonSamplingResponse(SamplingResponse):
chi2_statistic: float = Field(description="Chi-squared goodness-of-fit statistic")
chi2_pvalue: float = Field(description="Chi-squared p-value")


class DoubleExponentialResponse(SamplingResponse):
char_x: list[float] = Field(description="X values from characteristic function")
char_y: list[float] = Field(description="Y values from characteristic function")
Expand All @@ -25,28 +39,59 @@ class DoubleExponentialResponse(SamplingResponse):
async def gaussian_sampling(
kappa: float = Query(1.0, description="Mean reversion speed", ge=0.1, le=5.0),
samples: int = Query(1000, description="Number of sample paths", ge=100, le=10000),
) -> SamplingResponse:
antithetic: bool = Query(
True, description="Use antithetic variates variance reduction"
),
) -> GaussianSamplingResponse:
pr = Vasicek(rate=0.5, kappa=kappa)
paths = pr.sample(samples, 1, 1000)
draws = Paths.normal_draws(samples, 1, 1000, antithetic_variates=antithetic)
paths = pr.sample_from_draws(draws)
pdf = paths.pdf(num_bins=50)
x = [float(v) for v in pdf.index]
simulation = [float(v) for v in pdf["pdf"]]
analytical = [float(v) for v in np.atleast_1d(pr.marginal(1).pdf(pdf.index))]
return SamplingResponse(x=x, simulation=simulation, analytical=analytical)
final_values = paths.data[-1, :]
ks = ks_1samp(final_values, lambda v: pr.analytical_cdf(1, v))
return GaussianSamplingResponse(
x=x,
simulation=simulation,
analytical=analytical,
ks_statistic=float(ks.statistic),
ks_pvalue=float(ks.pvalue),
)


@sampling_router.get("/poisson-sampling")
async def poisson_sampling(
intensity: float = Query(2.0, description="Poisson intensity", ge=2.0, le=5.0),
intensity: float = Query(2.0, description="Poisson intensity", ge=2.0, le=20.0),
samples: int = Query(1000, description="Number of sample paths", ge=100, le=10000),
) -> SamplingResponse:
) -> PoissonSamplingResponse:
pr = PoissonProcess(intensity=intensity)
paths = pr.sample(samples, 1, 1000)
pdf = paths.pdf(delta=1)
x = [float(v) for v in pdf.index]
simulation = [float(v) for v in pdf["pdf"]]
analytical = [float(v) for v in np.atleast_1d(pr.marginal(1).pdf(pdf.index))]
return SamplingResponse(x=x, simulation=simulation, analytical=analytical)
f_obs = np.array(simulation) * samples
f_exp = np.array(analytical) * samples
f_exp = f_exp * (f_obs.sum() / f_exp.sum())
# merge tail bins with expected count < 5 to satisfy chi-squared requirements
while len(f_exp) > 1 and f_exp[0] < 5:
f_obs[1] += f_obs[0]
f_exp[1] += f_exp[0]
f_obs, f_exp = f_obs[1:], f_exp[1:]
while len(f_exp) > 1 and f_exp[-1] < 5:
f_obs[-2] += f_obs[-1]
f_exp[-2] += f_exp[-1]
f_obs, f_exp = f_obs[:-1], f_exp[:-1]
chi2 = chisquare(f_obs, f_exp)
return PoissonSamplingResponse(
x=x,
simulation=simulation,
analytical=analytical,
chi2_statistic=float(chi2.statistic),
chi2_pvalue=float(chi2.pvalue),
)


@sampling_router.get("/double-exponential-sampling")
Expand Down
2 changes: 1 addition & 1 deletion dev/quantflow.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY pyproject.toml uv.lock readme.md ./

# Install dependencies (no root package, with needed extras)
RUN uv sync --frozen --no-install-project --extra ai --extra docs --extra data
RUN uv sync --frozen --no-install-project --extra docs --extra data

# Copy source, generate example outputs and images, then build docs
COPY mkdocs.yml ./
Expand Down
3 changes: 0 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ pip install quantflow
### Optional dependencies

* `data` — data retrieval: `pip install quantflow[data]`
* `ai` — MCP server for AI clients: `pip install quantflow[ai,data]`
* `ml` — training the Deep Implied Volatility model: `pip install quantflow[ml]`

## Features
Expand All @@ -33,8 +32,6 @@ pip install quantflow

* **Time Series Analysis**: exponentially weighted moving averages (EWMA), Kalman filtering, super-smoothers, and OHLC bar utilities.

* **AI Integration**: an [MCP server](https://quantflow.quantmind.com/mcp/) that exposes quantflow's data tools to AI assistants.

* **JSON Serializable**: all models and pricers are built on [Pydantic](https://docs.pydantic.dev), making them fully serializable to and from JSON.

## Citation
Expand Down
45 changes: 12 additions & 33 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
# MCP Server

Quantflow exposes its data tools as an [MCP](https://modelcontextprotocol.io) server, allowing AI clients such as Claude to query market data, crypto volatility surfaces, and economic indicators directly.
Quantflow exposes its crypto volatility tools as an [MCP](https://modelcontextprotocol.io) server, allowing AI clients such as Claude to query live Deribit data directly.

Install with the `ai` and `data` extras:

```bash
pip install quantflow[ai,data]
```

## API keys

Store your API keys in `~/.quantflow/.vault`:
The server is hosted at:

```
fmp=your-fmp-key
fred=your-fred-key
https://quantflow.quantmind.com/mcp
```

Or let the AI manage them for you via the `vault_add` tool once connected.
It uses the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport. No API key is required — all tools use the public Deribit API.

## Claude Code

```bash
claude mcp add quantflow -- uv run qf-mcp
claude mcp add --transport http quantflow https://quantflow.quantmind.com/mcp
```

## Claude Desktop
Expand All @@ -33,8 +24,8 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_
{
"mcpServers": {
"quantflow": {
"command": "uv",
"args": ["run", "qf-mcp"]
"type": "streamable-http",
"url": "https://quantflow.quantmind.com/mcp"
}
}
}
Expand All @@ -44,20 +35,8 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_

| Tool | Description |
|---|---|
| `vault_keys` | List stored API keys |
| `vault_add` | Add or update an API key |
| `vault_delete` | Delete an API key |
| `stock_indices` | List stock market indices |
| `stock_search` | Search companies by name or symbol |
| `stock_profile` | Get company profile |
| `stock_prices` | Get OHLC price history |
| `sector_performance` | Sector performance and PE ratios |
| `crypto_instruments` | List Deribit instruments |
| `crypto_historical_volatility` | Historical volatility from Deribit |
| `crypto_term_structure` | Volatility term structure |
| `crypto_implied_volatility` | Implied volatility surface |
| `crypto_prices` | Crypto OHLC price history |
| `ascii_chart` | ASCII chart for any stock or crypto symbol |
| `fred_subcategories` | Browse FRED categories |
| `fred_series` | List series in a FRED category |
| `fred_data` | Fetch FRED observations |
| `crypto_instruments` | List instruments for a currency on Deribit (spot, future, option) |
| `crypto_historical_volatility` | Historical volatility index from Deribit |
| `crypto_term_structure` | Volatility term structure across maturities |
| `crypto_implied_volatility` | Implied volatility surface (all maturities or a single one) |
| `vol_surface_snapshot` | Fetch a live vol surface and write it as a JSON file |
58 changes: 39 additions & 19 deletions frontend/src/sampling.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,28 +13,31 @@ import * as Plot from "npm:@observablehq/plot";

## Gaussian (Vasicek)

Sample the Gaussian OU (Vasicek) process for different mean reversion speeds and number of paths.
Sample the Gaussian OU (Vasicek) process for different mean reversion speeds ${tex`\kappa`} and number of paths. The process has unit volatility ${tex`\sigma = 1`} and initial value ${tex`x_0 = 0.5`}.

```js
const gKappaInput = Inputs.range([0.1, 5], {step: 0.1, value: 1.0, label: "Kappa (mean reversion)"});
const gKappaInput = Inputs.range([0.1, 5], {step: 0.1, value: 1.0, label: "kappa"});
const gKappa = Generators.input(gKappaInput);

const gSamplesInput = Inputs.range([100, 10000], {step: 100, value: 1000, label: "Samples"});
const gSamples = Generators.input(gSamplesInput);

const gAntitheticInput = Inputs.toggle({label: "Antithetic variates", value: true});
const gAntithetic = Generators.input(gAntitheticInput);
```

```js
display(html`<div style="display: flex; gap: 2rem; align-items: end; flex-wrap: wrap">${gKappaInput}${gSamplesInput}</div>`);
display(html`<div style="display: flex; gap: 2rem; align-items: end; flex-wrap: wrap">${gKappaInput}${gSamplesInput}${gAntitheticInput}</div>`);
```

```js
await new Promise(r => setTimeout(r, 300));
const gaussianData = await fetchJson(`/.api/gaussian-sampling?kappa=${gKappa}&samples=${gSamples}`);
const gaussianData = await fetchJson(`/.api/gaussian-sampling?kappa=${gKappa}&samples=${gSamples}&antithetic=${gAntithetic}`);
```

```js
const gBarData = gaussianData.x.map((x, i) => ({x, y: gaussianData.simulation[i]}));
const gLineData = gaussianData.x.map((x, i) => ({x, y: gaussianData.analytical[i]}));
const gBarData = gaussianData.x.map((x, i) => ({x, y: gaussianData.simulation[i], series: "Simulation"}));
const gLineData = gaussianData.x.map((x, i) => ({x, y: gaussianData.analytical[i], series: "Analytical"}));
const gBinWidth = gaussianData.x[1] - gaussianData.x[0];

display(Plot.plot({
Expand All @@ -45,19 +48,28 @@ display(Plot.plot({
style: {background: "transparent"},
x: {label: "Value"},
y: {label: "Density"},
color: {legend: true, domain: ["Simulation", "Analytical"], range: ["var(--theme-foreground-muted)", "var(--theme-foreground-focus)"]},
marks: [
Plot.rectY(gBarData, {x1: d => d.x - gBinWidth / 2, x2: d => d.x + gBinWidth / 2, y: "y", fill: "var(--theme-foreground-muted)", fillOpacity: 0.6, tip: true}),
Plot.line(gLineData, {x: "x", y: "y", stroke: "var(--theme-foreground-focus)", strokeWidth: 2}),
Plot.rectY(gBarData, {x1: d => d.x - gBinWidth / 2, x2: d => d.x + gBinWidth / 2, y: "y", fill: "series", fillOpacity: 0.6, inset: 1, tip: true}),
Plot.line(gLineData, {x: "x", y: "y", stroke: "series", strokeWidth: 2}),
]
}));

if (gaussianData.ks_statistic != null) {
const ksColor = gaussianData.ks_pvalue < 0.05 ? "var(--theme-red)" : "var(--theme-green)";
display(html`<p style="color: ${ksColor}; margin-top: 0.5rem;">
KS statistic: <strong>${gaussianData.ks_statistic.toFixed(4)}</strong> &nbsp;|&nbsp;
p-value: <strong>${gaussianData.ks_pvalue.toFixed(4)}</strong>
</p>`);
}
```

## Poisson

Evaluate the Monte Carlo simulation for the Poisson process against the analytical PDF.

```js
const pIntensityInput = Inputs.range([2, 5], {step: 0.1, value: 2.0, label: "Intensity (λ)"});
const pIntensityInput = Inputs.range([2, 20], {step: 0.1, value: 2.0, label: "Intensity (λ)"});
const pIntensity = Generators.input(pIntensityInput);

const pSamplesInput = Inputs.range([100, 10000], {step: 100, value: 1000, label: "Samples"});
Expand All @@ -76,7 +88,6 @@ const poissonData = await fetchJson(`/.api/poisson-sampling?intensity=${pIntensi
```js
const pBarSim = poissonData.x.map((x, i) => ({x, y: poissonData.simulation[i], type: "Simulation"}));
const pBarAna = poissonData.x.map((x, i) => ({x, y: poissonData.analytical[i], type: "Analytical"}));
const pBinWidth = poissonData.x.length > 1 ? poissonData.x[1] - poissonData.x[0] : 1;

display(Plot.plot({
width: 800,
Expand All @@ -86,12 +97,20 @@ display(Plot.plot({
style: {background: "transparent"},
x: {label: "Value"},
y: {label: "Probability"},
color: {legend: true, label: "Source"},
color: {legend: true, domain: ["Simulation", "Analytical"], range: ["var(--theme-foreground-muted)", "var(--theme-foreground-focus)"]},
marks: [
Plot.barY(pBarSim, {x: "x", y: "y", fill: "var(--theme-foreground-muted)", fillOpacity: 0.6, dx: -2, tip: true}),
Plot.barY(pBarAna, {x: "x", y: "y", fill: "var(--theme-foreground-focus)", fillOpacity: 0.6, dx: 2}),
Plot.rectY(pBarSim, {x1: d => d.x - 0.3, x2: d => d.x - 0.05, y: "y", fill: "type", fillOpacity: 0.6, tip: true}),
Plot.rectY(pBarAna, {x1: d => d.x + 0.05, x2: d => d.x + 0.3, y: "y", fill: "type", fillOpacity: 0.6, tip: true}),
]
}));

if (poissonData.chi2_statistic != null) {
const chi2Color = poissonData.chi2_pvalue < 0.05 ? "var(--theme-red)" : "var(--theme-green)";
display(html`<p style="color: ${chi2Color}; margin-top: 0.5rem;">
χ² statistic: <strong>${poissonData.chi2_statistic.toFixed(4)}</strong> &nbsp;|&nbsp;
p-value: <strong>${poissonData.chi2_pvalue.toFixed(4)}</strong>
</p>`);
}
```

## Double Exponential
Expand All @@ -116,9 +135,9 @@ const deData = await fetchJson(`/.api/double-exponential-sampling?log_kappa=${de
```

```js
const deBarData = deData.x.map((x, i) => ({x, y: deData.simulation[i]}));
const deLineData = deData.x.map((x, i) => ({x, y: deData.analytical[i]}));
const deCharData = deData.char_x.map((x, i) => ({x, y: deData.char_y[i]}));
const deBarData = deData.x.map((x, i) => ({x, y: deData.simulation[i], series: "Simulation"}));
const deLineData = deData.x.map((x, i) => ({x, y: deData.analytical[i], series: "Analytical"}));
const deCharData = deData.char_x.map((x, i) => ({x, y: deData.char_y[i], series: "Char. function"}));
const deBinWidth = deData.x[1] - deData.x[0];

display(Plot.plot({
Expand All @@ -129,10 +148,11 @@ display(Plot.plot({
style: {background: "transparent"},
x: {label: "Value"},
y: {label: "Density"},
color: {legend: true, domain: ["Simulation", "Analytical", "Char. function"], range: ["var(--theme-foreground-muted)", "var(--theme-foreground-focus)", "var(--theme-accent)"]},
marks: [
Plot.rectY(deBarData, {x1: d => d.x - deBinWidth / 2, x2: d => d.x + deBinWidth / 2, y: "y", fill: "var(--theme-foreground-muted)", fillOpacity: 0.6, tip: true}),
Plot.line(deLineData, {x: "x", y: "y", stroke: "var(--theme-foreground-focus)", strokeWidth: 2}),
Plot.dot(deCharData, {x: "x", y: "y", fill: "var(--theme-accent)", r: 3}),
Plot.rectY(deBarData, {x1: d => d.x - deBinWidth / 2, x2: d => d.x + deBinWidth / 2, y: "y", fill: "series", fillOpacity: 0.6, inset: 1, tip: true}),
Plot.line(deLineData, {x: "x", y: "y", stroke: "series", strokeWidth: 2}),
Plot.dot(deCharData, {x: "x", y: "y", fill: "series", r: 3}),
]
}));
```
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ nav:
- Inversion: theory/inversion.md
- Lévy Process: theory/levy.md
- Option Pricing: theory/option_pricing.md
- Live Examples: https://quantflow.quantmind.com/examples/
- Live Examples: https://quantflow.quantmind.com/examples
- MCP Server: mcp.md
- Glossary: glossary.md
- Contributing: contributing.md
Expand Down
Loading
Loading