Quickstart#
This tour runs the whole pipeline on synthetic data: write a price panel, define factors and
a forward-return label, train an XGBoost model, backtest a top-N strategy on its predictions,
read the run directory and rebuild the run from it. It needs no network, credential or GPU.
The code is taken from examples/quickstart.py; every output below is that script’s.
uv run python examples/quickstart.py
It takes about half a minute, mostly compiling the factor graph. In the snippets, root is
the temporary directory the script works in. The script first sets OMP_NUM_THREADS=1 on
macOS (see installation).
Step 1: write a price panel#
Stages exchange panels: an xarray.Dataset whose variables all sit on
(timestamp, symbol), stored as Zarr. The script fabricates a random walk for 16 symbols
over 400 business days, with the variable names the Tiingo converter produces (adj* are
split- and dividend-adjusted prices, the others unadjusted).
import numpy as np
import pandas as pd
import xarray as xr
from quantlab.dataset.config import DatasetConfig
SYMBOLS = [f"S{i:02d}" for i in range(16)]
N_BARS = 400
ADJUSTED = ("adjOpen", "adjHigh", "adjLow", "adjClose", "adjVolume")
rng = np.random.default_rng(0)
timestamps = pd.bdate_range("2022-01-03", periods=N_BARS)
shape = (N_BARS, len(SYMBOLS))
close = 50.0 * np.exp(np.cumsum(rng.normal(0.0, 0.02, shape), axis=0))
# ... open, high, low and volume are built the same way (see the script)
panel = xr.Dataset(
{name: (("timestamp", "symbol"), values) for name, values in columns.items()},
coords={"timestamp": timestamps, "symbol": SYMBOLS},
)
panel.to_zarr(root / "data" / "stock.zarr", mode="w")
A dataset object, built from a DatasetConfig, is how
the rest of the pipeline reads the store:
from quantlab.dataset.stock import StockDataset
dataset_config = DatasetConfig(
zarr_file_path=str(root / "data" / "stock.zarr"),
raw_data_dir_path=str(root / "downloads" / "tiingo"),
market="us_equity",
frequency="1d",
vendor="tiingo",
)
prices = StockDataset(dataset_config)
panel = prices.panel("2000-01-01", "2100-01-01")
print("Price panel:", dict(panel.sizes), "variables:", list(panel.data_vars))
Price panel: {'timestamp': 400, 'symbol': 16} variables: ['adjClose', 'adjHigh', 'adjLow', 'adjOpen', 'adjVolume', 'close', 'high', 'low', 'open', 'volume']
raw_data_dir_path is used only when converting raw downloads, so it need not exist.
panel(start, end) opens the stored bars from start to end inclusive, lazily, and leaves
the dataset and its config unchanged.
Step 2: define factors and a label#
A factor is a value per (timestamp, symbol) computed from data available at that time; a
label is the quantity a model learns to predict, a factor shifted forward by
Forward.
Alpha158Stock computes the Alpha158 library
(169 columns). Return with
n_forward_periods=5 is adjOpen[t + 6] / adjOpen[t + 1] - 1: a position entered at the
next bar’s open (delay=1) and held five bars (span=5), so its value at t reads
lookahead_bars() = 6 bars ahead. Both are compiled by KunQuant.
import dataclasses
from quantlab.factor.config import FactorConfig
from quantlab.factor.predefined.alpha158 import Alpha158Stock
from quantlab.label.predefined.fret import Return
factor = Alpha158Stock(
FactorConfig(
warmup_bars=60,
dataset=StockDataset(dataclasses.replace(dataset_config)),
mode="batch",
data_columns=ADJUSTED,
file_path=str(root / "factors" / "alpha158.zarr"),
njobs=4,
)
)
label = Return(
FactorConfig(
warmup_bars=0,
dataset=StockDataset(dataclasses.replace(dataset_config)),
mode="batch",
data_columns=("adjOpen",),
kwargs={"n_forward_periods": 5},
file_path=str(root / "labels" / "ret_5.zarr"),
njobs=4,
)
)
A factor config holds no dates. Asked for compute(start, end), the factor also reads
warmup_bars bars before start on the dataset’s calendar and trims them off. mode="batch"
compiles for a whole range ("stream" compiles for bar-by-bar updates). file_path is where
build(start, end) would write; this example computes in memory.
Step 3: train a model#
XGBoostRegressor takes the factors and labels
through a ModelConfig. It trains on bars 0 to 249, with
the last 20 % held out for early stopping (val_size=0.2), and is tested on bars 250 to 299.
At every split boundary it drops the label’s six lookahead bars from the earlier segment, so
the last label it trains on reads bar 249.
from quantlab.model.config import ModelConfig
from quantlab.model.predefined.xgb import XGBoostRegressor
model = XGBoostRegressor(
ModelConfig(
factors=[factor],
labels=[label],
model_save_dir=str(root / "models"),
factor_data_strategy="cal",
label_data_strategy="cal",
hyperparameters={
"num_boost_round": 50, "max_depth": 3, "eta": 0.1,
"early_stopping": True, "early_stopping_patience": 10,
},
val_size=0.2,
start_date="2022-01-03", # bar 0
end_date="2023-02-24", # bar 299
train_start="2022-01-03",
train_end="2022-12-16", # bar 249
test_start="2022-12-19", # bar 250
test_end="2023-02-24",
)
)
features = model.get_factor_names()
print(f"{len(features)} features, e.g. {features[:4]}; labels: {model.get_label_names()}")
169 features, e.g. ['KMID', 'KLEN', 'KMID2', 'KUP']; labels: ['ret_5']
factor_data_strategy="cal" computes the factors now; "read" reads them from stores a
previous build() wrote. collect() gathers factors and labels into one panel and
train() fits and returns the checkpoint path:
model.collect()
collected = model.data_backend.get_xarray_dataset()
print("Collected panel:", dict(collected.sizes), f"{len(collected.data_vars)} variables")
checkpoint = model.train()
print("Checkpoint:", checkpoint.relative_to(root))
Collected panel: {'timestamp': 300, 'symbol': 16} 170 variables
Checkpoint: models/XGBoostRegressor_trial_20260927_212057_563434/XGBoostRegressor_total.joblib
Each train() writes a new timestamped trial directory beside earlier ones, holding the
checkpoint and the model’s recipe and training record, read back by
TrainedRun. Walk-forward training with train_cv() is
in models.
Step 4: backtest the model#
USEquityCrossectionSelectStockVectorBt
asks its constructor for weights every rebalance_periods bars;
TopNConstructor holds the top_n symbols
with the highest predictions in equal weights. Orders decided at bar t’s close fill at bar
t+1’s open; vectorbt simulates them.
from quantlab.backtest.predefined.us_equity import USEquityCrossectionSelectStockVectorBt
from quantlab.backtest.config import CrossSectionBacktestConfig
from quantlab.portfolio.config import TopNConfig
from quantlab.portfolio.predefined.top_n import TopNConstructor
backtester = USEquityCrossectionSelectStockVectorBt(
CrossSectionBacktestConfig(
price_dataset=StockDataset(dataclasses.replace(dataset_config)),
model=make_model(root, dataset_config, dates), # same config as in step 3
model_mode="load",
checkpoint=str(checkpoint),
start_date="2023-01-02", # bar 260
end_date="2023-07-14", # bar 399
output_dir=str(root / "backtests"),
rebalance_periods=5,
constructor=TopNConstructor(TopNConfig(direction="long_only", top_n=4)),
)
)
result = backtester.run()
model_mode="load" restores the booster from checkpoint ("train" trains first). The
backtester computes the factors over its window with their warm-up, predicts, builds weights
and simulates, with 5 bp fees and 5 bp slippage on 1,000,000 of starting cash. Bars up to the
last one the fitted labels read (bar 249 here) count as in-sample and are reported apart; this
window has none. It also checks that the label’s delay equals the engine’s one-bar fill
delay.
Step 5: look at the results#
run() returns a BacktestResult and writes a new run
directory under output_dir, read back by
BacktestRun:
from quantlab.runs.backtest_run import BacktestRun
run = BacktestRun.open(result.run_dir)
print("Run:", run.kind, run.window, run.market)
print("Predictions:", list(result.predictions.data_vars), dict(result.predictions.sizes))
first_row = result.weights["weight"].isel(timestamp=0)
held = first_row.where(first_row > 0, drop=True)
print("First rebalance:", dict(zip(held.symbol.values.tolist(), held.values.tolist())))
Run: run ('2023-01-02', '2023-07-14') Market(fill_price_column='adjOpen', valuation_price_column='adjClose')
Predictions: ['ret_5'] {'timestamp': 140, 'symbol': 16}
First rebalance: {'S03': 0.25, 'S07': 0.25, 'S08': 0.25, 'S09': 0.25}
Predictions are a panel with one variable per label. Weights hold target fractions on
rebalance bars and NaN (“no change”) in between. The run directory also holds report.html,
an interactive report. Metrics are grouped into whole, in_sample and out_of_sample,
with their ranges and notes:
whole = result.metrics["whole"]
for key in ("Total Return [%]", "Sharpe Ratio", "Max Drawdown [%]"):
print(f" {key:<18} {whole[key]:.3f}")
print("Metric groups:", sorted(result.metrics))
print("In-sample range:", result.metrics["in_sample_range"])
print("Out-of-sample ranges:", result.metrics["out_of_sample_ranges"])
Total Return [%] 10.984
Sharpe Ratio 1.410
Max Drawdown [%] 8.645
Metric groups: ['execution', 'in_sample', 'in_sample_range', 'notes', 'out_of_sample', 'out_of_sample_ranges', 'portfolio_construction', 'training_window', 'whole']
In-sample range: None
Out-of-sample ranges: [('2023-01-02', '2023-07-14')]
The prices are a random walk, so the numbers mean nothing; only the shape of the output does.
Step 6: reproduce the run from its directory#
The run directory keeps every config of the run as a nested recipe, each entry naming its
class by import path. rebuild_backtester() reconstructs the backtester without the script:
rebuilt = run.rebuild_backtester()
again = rebuilt.run()
same = np.allclose(again.simulation.value.values, result.simulation.value.values)
print("Rebuilt from its run directory, same equity curve:", same)
Rebuilt from its run directory, same equity curve: True
The re-run compares the data it reads with the fingerprints the first run recorded and warns if the data, a component’s code or a library version changed.
Where to go next#
Concepts: stages, panels, configs and run directories.
Data sources and datasets: real data instead of the synthetic panel.
Factors, models and backtesting: each stage in depth.