Frame API#
quantlab.api serves callers who already hold market data in a pandas or polars
DataFrame and want one capability (a factor set, a forward-return label, a factor report, a
backtest of their own signal) without writing a Zarr store or building configs. Each
function takes a frame, a long DataFrame with one row per timestamp and symbol, and
returns a frame of the same library. Nothing is written to disk or sent to a tracker unless
you ask.
Function |
Takes |
Returns |
|---|---|---|
bars |
one column per factor |
|
bars |
the forward-return label |
|
factors, and forward returns or bars |
|
|
bars, and weights or scores |
|
Underneath, each frame becomes a FrameDataset, a market
dataset held in memory, and the library’s own factors, labels, factor report and backtester
run on it.
The examples form one session: each block continues from the ones before. Computing factors compiles KunQuant code, which takes a few seconds and prints timing lines on standard error (not shown).
A first session#
120 business days of synthetic bars for eight symbols, with the canonical column names:
>>> import numpy as np
>>> import pandas as pd
>>> import quantlab.api as qa
>>> rng = np.random.default_rng(42)
>>> bars = pd.bdate_range("2024-01-01", periods=120)
>>> symbols = ["AAA", "BBB", "CCC", "DDD", "EEE", "FFF", "GGG", "HHH"]
>>> close = 50 * np.exp(rng.normal(0.0003, 0.02, (120, 8)).cumsum(axis=0))
>>> open_ = close * np.exp(rng.normal(0, 0.005, (120, 8)))
>>> prices = pd.DataFrame({
... "timestamp": np.repeat(bars, 8), "symbol": symbols * 120,
... "open": open_.ravel(), "high": (np.maximum(open_, close) * 1.01).ravel(),
... "low": (np.minimum(open_, close) * 0.99).ravel(), "close": close.ravel(),
... "volume": rng.integers(100_000, 1_000_000, 960).astype(float),
... })
>>> prices.head(3)
timestamp symbol open high low close volume
0 2024-01-01 AAA 50.729202 51.236494 49.817534 50.320741 737969.0
1 2024-01-01 BBB 49.017553 49.507729 48.495596 48.985450 167515.0
2 2024-01-01 CCC 50.517525 51.279054 50.012350 50.771340 732593.0
Compute Alpha158, rank the factors by how well they order the symbols by their 5-bar forward return, and backtest the best one as a score, holding the top two, rebalanced every five bars:
>>> factors = qa.compute_factors(prices, "alpha158")
>>> factors.shape # timestamp, symbol and 169 factor columns
(960, 171)
>>> report = qa.analyze_factors(factors, prices=prices, span=5, plot=False)
>>> summary = report.summary().sort_values("rank_icir", ascending=False)
>>> summary[["factor", "rank_ic", "rank_icir", "turnover"]].head(3).round(4)
factor rank_ic rank_icir turnover
43 ROC60 0.1098 0.3219 0.0519
133 SUMN60 0.1049 0.3068 0.0425
58 MAX60 0.1100 0.2857 0.1065
>>> best = summary["factor"].iloc[0]
>>> result = qa.backtest(prices, scores=factors[["timestamp", "symbol", best]],
... top_n=2, rebalance_periods=5)
>>> result
BacktestReport(120 bars x 8 symbols, total return -7.16%)
>>> round(result.metrics["whole"]["Sharpe Ratio"], 4)
-0.9251
The factor was chosen on the bars it is backtested on, so this backtest is in-sample.
Frames#
A frame has a timestamp column, a symbol column and one column per field: open,
high, low, close, volume, and amount (traded value) for the crypto factor sets. Each
function reads only the columns it needs. columns= maps your names onto the canonical ones:
>>> mine = pd.DataFrame({
... "date": pd.to_datetime(["2024-01-02", "2024-01-03", "2024-01-04"] * 2),
... "ticker": ["AAA"] * 3 + ["BBB"] * 3,
... "Open": [10.0, 11.0, 12.1, 20.0, 19.0, 19.0],
... })
>>> qa.forward_returns(mine, columns={"date": "timestamp", "ticker": "symbol", "Open": "open"})
timestamp symbol ret_1
0 2024-01-02 AAA 0.1
1 2024-01-02 BBB 0.0
2 2024-01-03 AAA NaN
3 2024-01-03 BBB NaN
4 2024-01-04 AAA NaN
5 2024-01-04 BBB NaN
Every function accepts the long form and a pandas frame indexed by a (timestamp, symbol)
MultiIndex. Single-field inputs (the weights and scores of backtest, the returns of
analyze_factors) may also be wide: one row per timestamp, one column per symbol, the
timestamps in a timestamp column or a DatetimeIndex.
Every frame follows the same rules: a repeated (timestamp, symbol) pair or a row without a
timestamp or symbol raises; a cell without a row becomes NaN, so ragged data (listings,
delistings, holidays) needs no filling; timezone-aware timestamps become naive UTC; symbols
become str.
Results come back in the library the frame came in, the first frame deciding for functions
with several (factors for analyze_factors, prices for backtest). as_xarray=True on
compute_factors and forward_returns returns the library’s panel instead:
>>> import polars as pl
>>> type(qa.forward_returns(pl.from_pandas(mine), columns={
... "date": "timestamp", "ticker": "symbol", "Open": "open"})).__name__
'DataFrame'
Compute factors#
compute_factors(frame, factor) computes a factor set over every bar of the frame, one row
per cell of the full grid. factor is a short name or a factor class:
Short name |
Class |
Reads |
Normalization |
|---|---|---|---|
|
|
|
z-score across symbols on each bar |
|
|
|
z-score across symbols on each bar |
|
|
the above and |
z-score along time over 20 bars |
|
|
the above and |
z-score along time over 20 bars |
The equity sets expect adjusted prices and take the VWAP as (high + low + close) / 3.
Factors that need fundamentals or factor-return series have no short name. The frame is
computed with no history before its first bar, so the start of each rolling window is NaN;
values are float32.
Any Factor subclass can be passed instead. A subclass of a
catalog class reads its short name’s columns; any other class sees every column of the frame
under its canonical name. A Polars factor, for example:
>>> from quantlab.factor.polars import FactorPolars
>>> class RelativeVolume(FactorPolars):
... def _get_factor_lazyframe(self, lf):
... volume = pl.col("volume")
... return (
... lf.sort(["symbol", "timestamp"])
... .with_columns((volume / volume.rolling_mean(5).over("symbol") - 1.0)
... .alias("rel_volume_5"))
... .select(["timestamp", "symbol", "rel_volume_5"])
... )
>>> qa.compute_factors(prices, RelativeVolume).dropna().head(3)
timestamp symbol rel_volume_5
32 2024-01-05 AAA 0.177489
33 2024-01-05 BBB -0.135541
34 2024-01-05 CCC -0.555079
Forward returns#
forward_returns(frame, price="open", span=1, delay=1) is the return of a position entered
delay bars after bar t and held span bars, at the price column:
label[t] = price[t + delay + span] / price[t + delay] - 1
delay + span is the lookahead, so the last delay + span bars are NaN, as is any return
missing one of its two prices. The defaults match the library’s
Return: a signal at bar t fills at bar
t + 1’s open. Use the column your backtest fills at. The column is named ret_{span};
binary=True gives BinaryReturn, ret_binary_{span}
(1.0 where positive, 0.0 otherwise, NaN where the return is NaN). Values are float32.
>>> labels = qa.forward_returns(mine, columns={"date": "timestamp", "ticker": "symbol",
... "Open": "open"}, delay=0)
>>> labels
timestamp symbol ret_1
0 2024-01-02 AAA 0.10
1 2024-01-02 BBB -0.05
2 2024-01-03 AAA 0.10
3 2024-01-03 BBB 0.00
4 2024-01-04 AAA NaN
5 2024-01-04 BBB NaN
Analyze factors#
analyze_factors(factors, ...) runs the library’s factor report on every factor paired with
the forward returns, over the bars and symbols they share: the Pearson and rank IC on each bar
and their statistics, mean forward returns by quantiles buckets, the top-minus-bottom
spread, turnover and rank autocorrelation, and with two or more factors their correlation.
The returns come from exactly one source: prices= (computed as forward_returns does,
span defaulting to 1) or your own returns, long or wide, with span required since it sets
how bucket returns compound and the IC’s Newey-West lags.
>>> momentum = factors[["timestamp", "symbol", "ROC5", "ROC20"]]
>>> own = qa.forward_returns(prices, span=5).pivot(index="timestamp", columns="symbol",
... values="ret_5")
>>> by_returns = qa.analyze_factors(momentum, own, span=5, plot=False)
>>> by_prices = qa.analyze_factors(momentum, prices=prices, span=5, plot=False)
>>> by_returns
FactorReport(2 pairs: ROC5__returns, ROC20__returns)
>>> by_returns.summary().drop(columns="fret").equals(by_prices.summary().drop(columns="fret"))
True
>>> print(by_prices.summary().round(4).to_string())
factor fret ic rank_ic icir rank_icir long_short_return turnover
0 ROC5 ret_5 -0.0115 0.0090 -0.0288 0.0234 0.0019 0.3681
1 ROC20 ret_5 0.0409 0.0301 0.1010 0.0702 0.0148 0.2688
plot=True, the default, draws one figure per pair into report.figures, which dominates the
cost of a large report. raw is the library’s
FactorAnalysis, and save(directory) writes the
tables as CSV, the metrics as summary.json and a PNG per pair.
Backtest#
backtest(prices, ...) simulates a portfolio with the library’s vectorbt backtester. The
signal is exactly one of:
weights=: the fraction of portfolio value held in each symbol, negative for short, gross exposure at most 1 on every bar;scores=withtop_n=: everyrebalance_periodsbars thetop_nhighest-scoring symbols get equal weights, and withdirection="long_short"thetop_nlowest equal negative weights. A symbol needs a score and a fill price to be picked.
A weight formed at bar t fills at bar t + 1’s fill price (default open); the portfolio
is valued at valuation (default close). There is no training window, so metrics cover the
whole window.
In a weight frame, a symbol left out on a bar that has other weights gets 0; a bar left out
entirely holds the previous positions, so listing only the rebalance bars is enough and going
flat takes explicit zeros. A NaN beside finite weights on a bar keeps that holding untraded.
Here AAA and BBB are held from the first bar until the 60th, where only AAA is listed;
the wide pivot runs the same:
>>> weights = pd.DataFrame({
... "timestamp": [bars[0], bars[0], bars[59]], "symbol": ["AAA", "BBB", "AAA"],
... "weight": [0.5, 0.5, 0.0],
... })
>>> held = qa.backtest(prices, weights=weights)
>>> held
BacktestReport(120 bars x 8 symbols, total return -2.91%)
>>> held.orders
timestamp symbol size price fees side
0 2024-01-02 AAA 9890.799541 50.577307 250.125000 Buy
1 2024-01-02 BBB 10357.098123 48.203681 249.625125 Buy
2 2024-03-25 BBB 10357.098123 47.903232 248.069237 Sell
3 2024-03-25 AAA 9890.799541 48.053934 237.645913 Sell
>>> wide = weights.pivot(index="timestamp", columns="symbol", values="weight")
>>> qa.backtest(prices, weights=wide).equity.equals(held.equity)
True
fees and slippage are proportional costs per trade (0.0005 each by default) and
init_cash the starting cash (1,000,000). market sets the annualization: "equity" is 252
days of 390 minutes, "crypto" 365 days of 1,440 minutes, and trading_days_per_year /
session_minutes_per_day override either half. benchmark= takes one symbol’s bars, bought
and held with the same costs and cash, and adds "benchmark" and "relative" to the metrics:
>>> index = prices.groupby("timestamp", as_index=False)[["open", "close"]].mean()
>>> index["symbol"] = "INDEX"
>>> compared = qa.backtest(prices, weights=weights, benchmark=index)
>>> sorted(compared.metrics)
['benchmark', 'execution', 'notes', 'relative', 'whole']
BacktestReport holds frames of the library prices came in: equity, returns, weights
(NaN on hold bars), orders (one row per fill), trades (one row per round trip),
benchmark, the metrics dict and raw, the library’s
BacktestResult. plot() returns the report’s Performance
chart as a plotly figure.
Keeping a run#
By default nothing is written. output_dir= writes the library’s run directory, and
report.save(directory) writes it after the fact by simulating the same weights again. The
directory copies the price and benchmark panels next to its recipe, so it can be moved and
still rebuilds through BacktestRun:
>>> import shutil
>>> import tempfile
>>> from quantlab.runs.backtest_run import BacktestRun
>>> kept = qa.backtest(prices, weights=weights, benchmark=index, output_dir=tempfile.mkdtemp())
>>> kept.raw.run_dir.name.startswith("WeightsVectorBt_")
True
>>> run = BacktestRun.open(shutil.move(kept.raw.run_dir, tempfile.mkdtemp()))
>>> run.kind, sorted(run.data_fingerprint)
('run_weights', ['benchmark_dataset', 'price_dataset'])
>>> type(run.rebuild("price_dataset")).__name__
'FrameDataset'
>>> replay = run.rebuild_backtester().run_weights(run.weights())
>>> bool((replay.simulation.value == kept.raw.simulation.value).all())
True
From frames to the full pipeline#
To train a model on your frame, or use it wherever the library takes a market dataset, build
the FrameDataset yourself:
>>> from quantlab.dataset.memory import FrameDataset
>>> dataset = FrameDataset(mine, columns={"date": "timestamp", "ticker": "symbol", "Open": "open"})
>>> dataset.panel("2024-01-02", "2024-01-04")["open"].to_pandas()
symbol AAA BBB
timestamp
2024-01-02 10.0 20.0
2024-01-03 11.0 19.0
2024-01-04 12.1 19.0
A FrameDataset keeps each column under its own name, so name columns as the reading factor
expects (adjOpen, adjClose and so on for the equity factor classes; quantlab.api
renames for you). Datasets covers it in full; factors,
models and backtesting take the pipeline from there.