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
18 changes: 0 additions & 18 deletions .coveragerc

This file was deleted.

49 changes: 49 additions & 0 deletions .github/instructions/observable.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
name: quantflow-observable-instructions
description: 'Instructions for Observable Framework pages in quantflow'
applyTo: '/frontend/src/**'
---


# Observable Framework Instructions

These instructions apply to markdown files in `frontend/src/`, which are rendered by Observable Framework.

## Math

Observable Framework uses KaTeX for math rendering. The syntax is different from standard markdown math (no `$...$` or `$$...$$`).

- **Display math** (block, centered): use a `tex` fenced code block:

````
```tex
E = mc^2
```
````

- **Inline math**: use the `tex` tagged template literal inside an inline expression:

```
The smoothing factor ${tex`\alpha`} controls decay.
```

- Do not use `$...$` or `$$...$$` for math in Observable Framework pages.
- Do not use `\begin{equation}...\end{equation}` (that convention applies only to mkdocs pages in `docs/`).

## Downloads

Always render download actions as a `<button>` element (not an `<a>` link). Use the pattern:

```js
const downloadData = () => {
const blob = new Blob([JSON.stringify(data, null, 2)], {type: "application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "filename.json";
a.click();
URL.revokeObjectURL(url);
};

display(html`<button onclick=${downloadData} style="cursor: pointer; background: var(--qf-primary); color: #fff; border: none; padding: 0.5em 1em; border-radius: 4px;">Download (JSON)</button>`);
```
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@readme.md
@.github/copilot-instructions.md
@.github/instructions/makefile.instructions.md
@.github/instructions/observable.instructions.md
@.github/instructions/release.instructions.md
@.github/instructions/tutorial.instructions.md
4 changes: 3 additions & 1 deletion app/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,6 @@ async def api_redoc() -> HTMLResponse:

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)
uvicorn.run(
crate_app(), host=HOST, port=PORT, proxy_headers=True, forwarded_allow_ips="*"
)
7 changes: 5 additions & 2 deletions app/api/cointegration.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ async def _cointegration(fmp: FMP, frequency: FMP.freq) -> CointegrationResponse
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]
std = log_prices_3.std()
scaled = log_prices_3 / std
johansen_result = coint_johansen(scaled, det_order=0, k_ar_diff=1)
deltas = johansen_result.evec[:, 0] / std.values
deltas = deltas / np.linalg.norm(deltas)

residuals = log_prices_3.dot(deltas)
residual_mean = residuals.mean()
Expand Down
2 changes: 1 addition & 1 deletion app/api/hurst.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ async def hurst_wiener(
seconds_in_day = 24 * 60 * 60
wiener = WienerProcess(sigma=sigma)
paths = wiener.sample(n=1, time_horizon=1, time_steps=seconds_in_day)
wiener_df = paths.as_datetime_df(start=start_of_day(), unit="d").reset_index()
wiener_df = paths.as_datetime_df(start=start_of_day(), unit="D").reset_index()

dates = [str(d) for d in wiener_df.iloc[:, 0]]
values = [float(v) for v in wiener_df.iloc[:, 1]]
Expand Down
5 changes: 4 additions & 1 deletion app/api/rates.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ async def yield_curve(
curve_class = YieldCurve.get_curve_class(curve_type)
if curve_class is None:
raise ValueError(f"Unsupported curve type: {curve_type}")
curve = cast(AnyYieldCurve, curve_class.calibrate(ttm, rates))
calibrator = curve_class().calibrator()
if calibrator is None:
raise ValueError(f"Curve type {curve_type!r} does not support calibration")
curve = cast(AnyYieldCurve, calibrator.calibrate(ttm, rates))
if max_ttm is not None:
ttm = list(np.geomspace(1 / 365, max_ttm, num_points))
rates = [float(r) for r in np.atleast_1d(curve.continuously_compounded_rate(ttm))]
Expand Down
18 changes: 4 additions & 14 deletions app/api/smoother.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,7 @@ async def supersmoother(
.sort_values("date", ascending=True)
.reset_index(drop=True)
)
smoother = SuperSmoother(period=period)
ewma = EWMA(period=period)
sm["supersmoother"] = sm["close"].apply(smoother.update)
sm["ewma"] = sm["close"].apply(ewma.update)
data = [
SmootherPoint(
date=str(row["date"]),
close=float(row["close"]),
supersmoother=float(row["supersmoother"]),
ewma=float(row["ewma"]),
)
for _, row in sm.iterrows()
]
return SmootherResponse(data=data)
sm["supersmoother"] = sm["close"].apply(SuperSmoother(period=period).update)
sm["ewma"] = sm["close"].apply(EWMA(period=period).update)
sm["date"] = sm["date"].astype(str)
return SmootherResponse(data=sm.to_dict(orient="records"))
47 changes: 45 additions & 2 deletions app/api/volatility.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from quantflow.data.yahoo import Yahoo
from quantflow.options.inputs import VolSurfaceInputs
from quantflow.options.surface import OptionInfo, VolSurfaceLoader
from quantflow.rates.cir import CIRCurve
from quantflow.rates.nelson_siegel import NelsonSiegel

from .deps import RedisCache, RedisDep
Expand All @@ -21,6 +22,19 @@
ALL_ASSETS = sorted(DERIBIT_ASSETS) + sorted(YAHOO_ASSETS)


class ForwardPoint(BaseModel):
maturity: str = Field(description="Maturity date")
ttm: float = Field(description="Time to maturity in years")
forward: float = Field(description="Implied forward price from put-call parity")


class ForwardCurveResponse(BaseModel):
ttm: list[float] = Field(description="Time to maturity in years")
forward: list[float] = Field(
description="Forward price from calibrated discount factors"
)


class VolSurfaceResponse(BaseModel):
inputs: VolSurfaceInputs = Field(description="Volatility surface inputs")
options: list[OptionInfo] = Field(
Expand All @@ -32,6 +46,12 @@ class VolSurfaceResponse(BaseModel):
asset_curve: YieldCurveResponse = Field(
description="Asset discount curve with rates"
)
forward_curve: ForwardCurveResponse = Field(
description="Model forward curve from calibrated discount factors"
)
pcp_forwards: list[ForwardPoint] = Field(
description="Per-maturity implied forward from put-call parity"
)


@volatility_router.get("/volatility-surface")
Expand Down Expand Up @@ -61,16 +81,29 @@ async def _load_surface(asset: str) -> VolSurfaceLoader:
if asset in DERIBIT_ASSETS:
async with Deribit() as cli:
loader = await cli.volatility_surface_loader(asset.lower(), inverse=True)
loader.calibrate_curves(quote_curve=NelsonSiegel)
loader.calibrate_curves(quote_curve=CIRCurve, asset_curve=NelsonSiegel)
return loader
else:
async with Yahoo() as cli:
loader = await cli.volatility_surface_loader(asset)
loader.calibrate_spot()
loader.calibrate_curves(quote_curve=NelsonSiegel)
loader.calibrate_curves(quote_curve=CIRCurve, asset_curve=NelsonSiegel)
return loader


def _forward_curve_response(
surface: Any, ttm_grid: list[float]
) -> ForwardCurveResponse:
spot = float(surface.spot_price())
forward = [
spot
* float(surface.asset_curve.discount_factor(t))
/ float(surface.quote_curve.discount_factor(t))
for t in ttm_grid
]
return ForwardCurveResponse(ttm=ttm_grid, forward=forward)


async def _volatility_surface(asset: str) -> VolSurfaceResponse:
loader = await _load_surface(asset)
surface = loader.surface()
Expand All @@ -81,10 +114,20 @@ async def _volatility_surface(asset: str) -> VolSurfaceResponse:
options = [op.info() for op in surface.option_prices(converged=True)]

max_ttm = max(float(op.ttm) for op in options) if options else 1.0
ttm_grid = list(np.linspace(1 / 365, max_ttm, 50))

return VolSurfaceResponse(
inputs=inputs,
options=options,
quote_curve=_curve_response(surface.quote_curve, max_ttm),
asset_curve=_curve_response(surface.asset_curve, max_ttm),
forward_curve=_forward_curve_response(surface, ttm_grid),
pcp_forwards=[
ForwardPoint(
maturity=str(maturity)[:10],
ttm=ttm,
forward=forward,
)
for maturity, ttm, forward in loader.implied_forward_term_structure()
],
)
1 change: 1 addition & 0 deletions dev/blocks/quantflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ routes:
protocols:
- https
strip_path: false
preserve_host: true
tags:
- public
- {{ space }}
2 changes: 2 additions & 0 deletions docs/api/rates/cir.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# CIR Curve

::: quantflow.rates.cir.CIRCurve

::: quantflow.rates.cir.CIRCurveCalibration
2 changes: 2 additions & 0 deletions docs/api/rates/vasicek.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Vasicek Curve

::: quantflow.rates.vasicek.VasicekCurve

::: quantflow.rates.vasicek.VasicekCurveCalibration
3 changes: 3 additions & 0 deletions docs/api/rates/yield_curve.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@


::: quantflow.rates.yield_curve.YieldCurve


::: quantflow.rates.no_discount.NoDiscount
22 changes: 20 additions & 2 deletions frontend/src/cointegration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ display(Plot.plot({
marginLeft: 60,
marginBottom: 50,
style: {background: "transparent"},
x: {label: "Date", type: "utc"},
y: {label: "Residual (Spread)"},
x: {label: "Date", type: "utc", grid: true},
y: {label: "Residual (Spread)", grid: true},
marks: [
Plot.line(residuals, {x: "date", y: "residual", stroke: "var(--theme-foreground-focus)", strokeWidth: 1.5, tip: true}),
Plot.ruleY([0], {stroke: "var(--theme-foreground-muted)", strokeDasharray: "4,4"}),
Expand All @@ -55,6 +55,24 @@ In the Johansen cointegration test, the eigenvalues are sorted in descending ord
2. **Statistical Significance:** The test statistics (Trace and Maximum Eigenvalue tests) are functions of these eigenvalues, helping determine how many significant cointegrating relationships exist.
3. **Practical Application:** For pairs trading, we want the most reliable long-run equilibrium. The vector associated with the largest eigenvalue gives the most mean-reverting portfolio.

## Normalization of the Cointegrating Vector

The Johansen test normalizes eigenvectors with respect to the cross-product matrix S11 of the input series, not the identity matrix. This means the Euclidean norm of the returned eigenvector depends on the scale of the input data and can be arbitrarily large.

To obtain a unit-norm vector that applies directly to raw log-prices, the API does the following:

1. **Standardize inputs:** each log-price series is divided by its standard deviation before running the Johansen test. This makes S11 approximately equal to the identity matrix, so the eigenvectors come out with unit Euclidean norm in the scaled space.
2. **Rescale back:** the eigenvector is divided by the same standard deviations to convert the weights back into log-price space.
3. **Re-normalize:** the rescaled vector is divided by its L2 norm to restore unit length.

The resulting vector `[δ_BTC, δ_ETH, δ_SOL]` can be applied directly to raw log-prices to compute the spread:

```tex
\text{spread}(t) = \delta_\text{BTC} \ln P_\text{BTC}(t) + \delta_\text{ETH} \ln P_\text{ETH}(t) + \delta_\text{SOL} \ln P_\text{SOL}(t)
```

The cointegrating direction is only defined up to a scalar multiple, so the final L2 normalization is statistically valid and ensures the vector components are comparable across different time periods or asset sets.

## Should You Use Log Prices?

Yes, using log prices is generally recommended for cointegration analysis in finance:
Expand Down
41 changes: 37 additions & 4 deletions frontend/src/supersmoother.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ title: SuperSmoother & EWMA

# SuperSmoother & EWMA

Compare the SuperSmoother and EWMA filters applied to BTCUSD daily close prices.
Both the [SuperSmoother](https://quantflow.quantmind.com/api/ta/supersmoother/) and [EWMA](https://quantflow.quantmind.com/api/ta/ewma/) are online filters that smooth noisy time series one observation at a time.
They share a single tuning knob (the period ${tex`p`}) so they can be compared directly.

The SuperSmoother is a two-pole Butterworth filter that removes high-frequency noise with very little lag.
EWMA applies exponential weighting with a decay derived from the same period, giving a simpler but slightly laggier result.

Use the slider below to see how the period affects both filters on BTCUSD daily closes.

```js
import {fetchJson} from "./lib/api.js";
Expand All @@ -20,6 +26,12 @@ const period = Generators.input(periodInput);
display(periodInput);
```

```js
const alpha = 1 - Math.exp(-1 / period);
const halfLife = period * Math.LN2;
display(html`<p><strong>EWMA α</strong> = ${alpha.toFixed(4)} · <strong>half-life</strong> = ${halfLife.toFixed(2)}</p>`);
```

```js
await new Promise(r => setTimeout(r, 300));
const result = await fetchJson(`/.api/supersmoother?period=${period}`);
Expand All @@ -38,11 +50,32 @@ display(Plot.plot({
marginLeft: 70,
marginBottom: 50,
style: {background: "transparent"},
x: {label: "Date", type: "utc"},
y: {label: "Price (USD)"},
color: {legend: true, label: "Signal", domain: ["Close", "SuperSmoother", "EWMA"], range: ["#4c78a8", "#f58518", "#e45756"]},
x: {label: "Date", type: "utc", grid: true},
y: {label: "Price (USD)", grid: true},
color: {legend: true, label: "Signal", domain: ["Close", "SuperSmoother", "EWMA"], range: ["#94a3b8", "#2563eb", "#dc2626"]},
marks: [
Plot.line(long, {x: "date", y: "price", stroke: "signal", strokeWidth: 1.5, tip: true}),
]
}));
```

## EWMA smoothing factor

The EWMA smoothing factor ${tex`\alpha`} is derived from the period:

```tex
\alpha = 1 - \exp\left(-\frac{1}{p}\right)
```

## Period and half-life

The half-life ${tex`h`} is the number of steps after which an observation's weight decays to half.
For an exponential decay with half-life ${tex`h`}, the sum of all weights equals ${tex`h / \ln 2`}.
This makes the period the continuous-time equivalent of the number of observations in a simple moving average:

```tex
p = \frac{h}{\ln 2}
```

The period is always larger than the half-life (by a factor of ${tex`1/\ln 2 \approx 1.44`}), which means
a period of 10 corresponds to a half-life of about 6.9 steps.
Loading
Loading