Source code for quantlab.api

"""Frame-in, frame-out functions for callers who hold their own DataFrames.

``quantlab.api`` lets a researcher with market data in a pandas or polars DataFrame use
one quantlab capability without adopting the project's stores and configs (ADR 0011). A
function takes a *frame*, a DataFrame in long form with one row per ``timestamp`` and
``symbol``, and returns a frame of the same library; ``as_xarray=True`` returns the
library's own ``xarray`` panel instead. Nothing is written to disk unless an
``output_dir`` is given.

The canonical columns are ``timestamp``, ``symbol``, ``open``, ``high``, ``low``,
``close`` and ``volume``, plus ``amount`` (traded value) where a function needs it. The
``columns`` argument maps differently named columns onto them. The input rules:

- a pandas ``(timestamp, symbol)`` MultiIndex is reset automatically;
- a repeated ``(timestamp, symbol)`` pair raises, listing the first ones;
- missing ``(timestamp, symbol)`` cells become NaN;
- timezone-aware timestamps are converted to UTC and made naive;
- symbols are converted to ``str``.

Underneath, the frame becomes a ``quantlab.dataset.memory.FrameDataset``, a market
dataset held in memory, which advanced users can also plug into the full pipeline.

Examples
--------
>>> import pandas as pd
>>> import quantlab.api as qa
>>> frame = pd.DataFrame({
...     "timestamp": pd.to_datetime(["2024-01-02", "2024-01-02", "2024-01-03", "2024-01-03"]),
...     "symbol": ["AAA", "BBB", "AAA", "BBB"],
...     "open": [10.0, 20.0, 10.5, 19.0], "high": [11.0, 21.0, 11.5, 20.5],
...     "low": [9.5, 19.5, 10.0, 18.5], "close": [10.5, 20.5, 11.0, 19.5],
...     "volume": [1e6, 2e6, 1.1e6, 1.8e6],
... })
>>> qa.compute_factors(frame, "alpha158")[["timestamp", "symbol", "KMID"]]
   timestamp symbol      KMID
0 2024-01-02    AAA  0.707107
1 2024-01-02    BBB -0.707107
2 2024-01-03    AAA  0.707107
3 2024-01-03    BBB -0.707107
"""

from collections.abc import Mapping

from quantlab.api import _factors, _labels
from quantlab.api._factor_report import FactorReport
from quantlab.api._report import BacktestReport
from quantlab.backtest.config import BacktestConfig


[docs] def compute_factors( frame, factor, *, columns: Mapping[str, str] | None = None, as_xarray: bool = False, ): """Compute a factor set over every bar of a frame. The whole frame is computed at once, with no history before its first bar, so the leading bars of each rolling window are NaN rather than dropped. Parameters ---------- frame : pandas.DataFrame or polars.DataFrame Bars in long form: ``timestamp``, ``symbol`` and the price and volume columns the factor reads. factor : str or type A short name, or any ``quantlab.factor.base.Factor`` subclass: - ``"alpha158"``, ``"alpha101"``: the equity variants (``Alpha158Stock``, ``Alpha101Stock``), z-scored across symbols with VWAP taken as ``(high + low + close) / 3``; read ``open``, ``high``, ``low``, ``close``, ``volume``, meant to be adjusted prices. - ``"alpha158_crypto"``, ``"alpha101_crypto"``: the crypto spot variants (``Alpha158SpotKline``, ``Alpha101SpotKline``), z-scored along time over 20 bars; also read ``amount``. A class from this list, or a subclass of one, reads the columns of its short name. Any other class is built with its ``config_cls``, no warm-up and batch mode, on a dataset holding every column of the frame under its canonical name; a KunQuant factor is fed every numeric column. Factors needing fundamentals or factor-return series (``LiteratureAlpha``, ``ResidualMomentumFF3``, ``MarketFeatures``) are not in the catalog. columns : mapping of str to str, optional Renames the frame's columns onto the canonical names, ``{"date": "timestamp", "ticker": "symbol", "Close": "close"}``. as_xarray : bool, default False Return the ``xarray.Dataset`` panel instead of a frame. Returns ------- pandas.DataFrame, polars.DataFrame or xarray.Dataset One row per ``(timestamp, symbol)`` of the frame's full grid, columns ``timestamp``, ``symbol`` and one per factor, in the frame's library; or the panel on ``(timestamp, symbol)`` with ``as_xarray=True``. Raises ------ ValueError If the short name is unknown (the message lists the valid ones), a column the factor reads is missing (the message names it), ``columns`` names an absent column, or a ``(timestamp, symbol)`` pair repeats. TypeError If ``frame`` is not a pandas or polars DataFrame, or ``factor`` is neither a short name nor a ``Factor`` subclass. Examples -------- >>> import numpy as np >>> import pandas as pd >>> import quantlab.api as qa >>> rng = np.random.default_rng(0) >>> timestamps = pd.bdate_range("2024-01-01", periods=30) >>> frame = pd.DataFrame({ ... "timestamp": np.repeat(timestamps, 3), ... "symbol": ["AAA", "BBB", "CCC"] * 30, ... "close": 100 + rng.normal(size=90).cumsum(), ... }) >>> frame["open"] = frame["close"] * 0.99 >>> frame["high"] = frame["close"] * 1.01 >>> frame["low"] = frame["close"] * 0.98 >>> frame["volume"] = 1e6 >>> factors = qa.compute_factors(frame, "alpha158") >>> factors.shape (90, 171) >>> factors[["timestamp", "symbol", "KMID"]].head(3) timestamp symbol KMID 0 2024-01-01 AAA 0.865969 1 2024-01-01 BBB -1.089445 2 2024-01-01 CCC 0.251410 >>> int(factors["STD5"].isna().sum()) # 4 warm-up bars x 3 symbols 12 """ return _factors.compute_factors(frame, factor, columns=columns, as_xarray=as_xarray)
[docs] def forward_returns( frame, *, price: str = "open", span: int = 1, delay: int = 1, binary: bool = False, columns: Mapping[str, str] | None = None, as_xarray: bool = False, ): """Compute the forward return of each bar, the label factors are judged against. The label at bar ``t`` is the return of a position entered ``delay`` bars later and held ``span`` bars, both at the ``price`` column:: price[t + delay + span] / price[t + delay] - 1 ``delay + span`` is the label's lookahead: the last ``delay + span`` bars of the frame have no later bars to read and are NaN. The defaults match the library's ``Return`` label, a signal at bar ``t`` filled at bar ``t + 1``'s open. The whole frame is computed at once. Parameters ---------- frame : pandas.DataFrame or polars.DataFrame Bars in long form: ``timestamp``, ``symbol`` and the ``price`` column. price : str, default "open" The column the return is computed on, after ``columns`` renames. There is no fallback to another column: use the column a backtest fills at, so the label and the fill agree. span : int, default 1 Bars the return is held over; at least 1. delay : int, default 1 Bars between the signal bar and the entry bar; at least 0. With 0 the position is entered at the signal bar's own price. binary : bool, default False Return 1.0 where the forward return is positive and 0.0 elsewhere (the library's ``BinaryReturn``) instead of the return (``Return``). columns : mapping of str to str, optional Renames the frame's columns onto the canonical names, ``{"date": "timestamp", "ticker": "symbol", "Open": "open"}``. as_xarray : bool, default False Return the ``xarray.Dataset`` panel instead of a frame. Returns ------- pandas.DataFrame, polars.DataFrame or xarray.Dataset One row per ``(timestamp, symbol)`` of the frame's full grid, columns ``timestamp``, ``symbol`` and ``ret_{span}`` (``ret_binary_{span}`` with ``binary=True``), in the frame's library; or the panel on ``(timestamp, symbol)`` with ``as_xarray=True``. The values are float32, computed by the library's KunQuant label path, so compare them with float64 returns at a float32-level tolerance, not exactly. Raises ------ ValueError If the ``price`` column is missing (the message lists the columns present and suggests one as ``price=``), ``span`` is below 1, ``delay`` is below 0, ``columns`` names an absent column, or a ``(timestamp, symbol)`` pair repeats. TypeError If ``frame`` is not a pandas or polars DataFrame, ``price`` is not a string, ``span`` or ``delay`` is not an int, or ``binary`` is not a bool. Examples -------- >>> import pandas as pd >>> import quantlab.api as qa >>> frame = pd.DataFrame({ ... "timestamp": pd.to_datetime(["2024-01-02", "2024-01-03", "2024-01-04"] * 2), ... "symbol": ["AAA"] * 3 + ["BBB"] * 3, ... "open": [10.0, 11.0, 12.1, 20.0, 19.0, 19.0], ... "close": [10.5, 11.5, 12.5, 19.5, 19.2, 18.0], ... }) >>> qa.forward_returns(frame) 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 >>> qa.forward_returns(frame, price="close", delay=0, binary=True) timestamp symbol ret_binary_1 0 2024-01-02 AAA 1.0 1 2024-01-02 BBB 0.0 2 2024-01-03 AAA 1.0 3 2024-01-03 BBB 0.0 4 2024-01-04 AAA NaN 5 2024-01-04 BBB NaN """ return _labels.forward_returns( frame, price=price, span=span, delay=delay, binary=binary, columns=columns, as_xarray=as_xarray, )
[docs] def analyze_factors( factors, returns=None, *, prices=None, price: str = "open", span: int | None = None, delay: int = 1, quantiles: int = 5, plot: bool = True, columns: Mapping[str, str] | None = None, ) -> FactorReport: """Report how well each factor orders symbols by their forward return. Every factor column is paired with the forward returns, and the library's factor report (``quantlab.analysis.factor_report``) measures each pair on the bars and symbols the two share: the per-bar IC (Pearson and Spearman rank correlation across symbols) and its statistics, the mean forward return of each of ``quantiles`` equal-count buckets by factor value, the top-minus-bottom spread, turnover and rank autocorrelation. With two or more factor columns it also measures their correlation. Exactly one source of forward returns is given: ``returns``, the caller's own, or ``prices``, from which they are computed exactly as ``forward_returns(prices, price=price, span=span, delay=delay)`` computes them. Parameters ---------- factors : pandas.DataFrame, polars.DataFrame or xarray.Dataset Factor values in long form: ``timestamp``, ``symbol`` and one numeric column per factor, such as ``compute_factors`` returns. Its library is the one ``FactorReport.summary()`` returns (pandas for a panel). returns : DataFrame, optional Forward returns, long (``timestamp``, ``symbol`` and one value column, whose name becomes the fret's) or wide (timestamps in a ``timestamp`` column or a pandas ``DatetimeIndex``, one column per symbol; the fret is named ``"returns"``). prices : DataFrame, optional Bars in long form holding the ``price`` column. price : str, default "open" With ``prices``, the column the returns are computed on. span : int, optional Bars the forward returns span, at least 1. With ``prices`` the holding period, 1 when not given. With ``returns`` it is required: the horizon the returns were computed over, which only the caller knows. Cumulative bucket returns compound the per-bar rate ``(1 + r) ** (1 / span) - 1``, and the IC's Newey-West t-statistic allows for the overlap of multi-bar returns. delay : int, default 1 With ``prices``, the bars between the signal bar and the entry bar. quantiles : int, default 5 Buckets per bar, at least 2 and at most the largest cross-section (the most symbols on one bar with both a factor value and a return). A bar with fewer symbols than buckets has no bucket returns. plot : bool, default True Draw one figure per factor into ``FactorReport.figures``. Drawing dominates the cost of a large report (all of Alpha158 on 50 bars x 300 symbols: about 19 s with figures, 1.4 s without); with ``False`` the figures are empty and ``save`` draws them only then. columns : mapping of str to str, optional Renames caller columns onto the canonical names, ``{"date": "timestamp"}``, applied to each input where it has the column. Returns ------- FactorReport ``summary()`` (the headline metrics per factor, a frame of the ``factors``' library), ``figures``, ``save(dir)`` and ``raw``, the library's ``FactorAnalysis`` with every metric and table. Raises ------ ValueError If both or neither of ``returns`` and ``prices`` are given, ``returns`` comes without ``span``, ``price`` or ``delay`` is set with ``returns``, ``span`` is below 1, ``delay`` below 0 or ``quantiles`` below 2 or above the largest cross-section (named), the ``price`` column is missing, the factors have no factor column or a non-numeric one, ``columns`` names a column no input has, the returns are long with other than one value column, a ``(timestamp, symbol)`` pair repeats, the factors and returns differ in bar spacing, or they share no bar and symbol (naming both time zones when they differ). TypeError If an input is not a pandas or polars DataFrame (or an xarray panel), ``price`` is not a string, ``span``, ``delay`` or ``quantiles`` is not an int, or ``plot`` is not a bool. Examples -------- >>> import numpy as np >>> import pandas as pd >>> import quantlab.api as qa >>> rng = np.random.default_rng(0) >>> bars = pd.bdate_range("2024-01-01", periods=60) >>> symbols = [f"S{i}" for i in range(10)] >>> close = 100 * np.exp(rng.normal(0, 0.01, (60, 10)).cumsum(axis=0)) >>> prices = pd.DataFrame({ ... "timestamp": np.repeat(bars, 10), "symbol": symbols * 60, ... "open": (close * 0.999).ravel(), "close": close.ravel(), ... }) >>> factors = prices.assign( ... momentum=prices.groupby("symbol")["close"].pct_change(5), ... reversal=-prices.groupby("symbol")["close"].pct_change(1), ... )[["timestamp", "symbol", "momentum", "reversal"]] >>> report = qa.analyze_factors(factors, prices=prices, span=5) >>> report FactorReport(2 pairs: momentum__ret_5, reversal__ret_5) >>> summary = report.summary() >>> list(summary.columns) ['factor', 'fret', 'ic', 'rank_ic', 'icir', 'rank_icir', 'long_short_return', 'turnover'] >>> summary.sort_values("rank_ic", ascending=False)[["factor", "rank_ic", "turnover"]].round(4) factor rank_ic turnover 0 momentum 0.0474 0.3958 1 reversal 0.0001 0.7885 The caller's own forward returns, here in wide form, with three buckets and no figures: >>> returns = qa.forward_returns(prices, span=5).pivot( ... index="timestamp", columns="symbol", values="ret_5") >>> report = qa.analyze_factors(factors, returns, span=5, quantiles=3, plot=False) >>> report.raw.pairs["momentum__returns"].quantiles, report.figures (3, {}) """ from quantlab.api import _analysis return _analysis.analyze_factors( factors, returns, prices=prices, price=price, span=span, delay=delay, quantiles=quantiles, plot=plot, columns=columns, )
[docs] def backtest( prices, *, weights=None, scores=None, top_n: int | None = None, direction: str = "long_only", rebalance_periods: int = 1, fill: str = "open", valuation: str = "close", market: str = "equity", trading_days_per_year: int | None = None, session_minutes_per_day: int | None = None, fees: float = BacktestConfig.fees, slippage: float = BacktestConfig.slippage, init_cash: float = BacktestConfig.init_cash, benchmark=None, output_dir=None, columns: Mapping[str, str] | None = None, ) -> BacktestReport: """Backtest target weights, or the top-N names of a score, on a price frame. Exactly one signal is given. ``weights`` are simulated as given; ``scores`` become equal weights on the ``top_n`` highest-scoring names (and, for ``direction= "long_short"``, minus the ``top_n`` lowest) every ``rebalance_periods`` bars, a name being picked only when it has a score and a fill price at that bar, and a held name without one keeping its weight. A weight formed at bar t fills at bar t+1's ``fill`` price and the portfolio is valued at the ``valuation`` price. The run covers every bar of ``prices``; the bar interval is the most common spacing of its timestamps. There is no training window, so the metrics cover the whole window. Nothing is written and no W&B run is started, unless ``output_dir`` is given. Weights follow the target-weight contract: on a bar that has weights every symbol has a finite weight and the gross exposure (sum of absolute weights) is at most 1; a bar without weights holds the current positions. A weight frame may leave things out: - a symbol without a weight on a bar that has weights for other symbols gets weight 0: a row left out of a long frame, or a NaN cell of a wide frame, so a sparse long frame listing only the names held and its pivot mean the same; - a bar without any weight is a hold: absent from a long frame, or an all-NaN row of a wide frame. A frame listing only the rebalance bars is therefore enough; to go flat on a bar, give it explicit zeros; - a NaN written in a long frame beside finite weights on the same bar keeps that symbol's holding there, untraded; a bar whose weights have a gross exposure above 1 raises. Timestamps in a time zone are converted to UTC and naive ones are taken as UTC; when the weights', scores' or benchmark's bars miss the prices' and the inputs came in different zones, the error names both zones. Parameters ---------- prices : pandas.DataFrame, polars.DataFrame or xarray.Dataset Bars in long form, one row per ``timestamp`` and ``symbol``, holding the ``fill`` and ``valuation`` columns. The report's frames are of this library. weights : DataFrame, optional Target weights, long (``timestamp``, ``symbol`` and one value column, of any name) or wide (timestamps in a ``timestamp`` column or in a pandas ``DatetimeIndex`` of any name; one column per symbol). scores : DataFrame, optional Scores ranking the symbols, higher is better, long or wide like ``weights``. A symbol without a score on a bar (a left-out row, a NaN cell) is not selected. top_n : int, optional Names held per side; required with ``scores``, refused with ``weights``. direction : {"long_only", "long_short"}, default "long_only" The selection side, with ``scores`` only. rebalance_periods : int, default 1 With ``scores``, rebalance every this many bars. With ``weights``, which are traded as given, it is recorded with the run only: the turnover and win-rate metrics count the bars that have weights, which are the rebalances. fill, valuation : str, default "open", "close" The price columns orders fill at and the portfolio is valued at. market : {"equity", "crypto"}, default "equity" The annualization: 252 trading days of 390 minutes, or 365 days of 1440. trading_days_per_year, session_minutes_per_day : int, optional Override one half of ``market``'s annualization. fees, slippage : float, default 0.0005 Proportional cost per trade, as in ``BacktestConfig``. init_cash : float, default 1_000_000.0 Starting cash. benchmark : DataFrame, optional One symbol's bars, with the ``fill`` and ``valuation`` columns, bought and held on the same bars for comparison; adds ``"benchmark"`` and ``"relative"`` (excess return and drawdown) to the metrics. output_dir : str or Path, optional Also write the library's run directory under this directory, input panels included, so the run rebuilds from it (see ``BacktestReport.save``). columns : mapping of str to str, optional Renames caller columns onto the canonical names, ``{"date": "timestamp"}``. The prices must have every key; the other frames are renamed where they have one. Returns ------- BacktestReport Equity, returns, weights, orders and trades as frames, the metrics as a dict, the benchmark curve, ``plot()``, ``save()`` and the library result as ``raw``. Raises ------ ValueError If both or neither of ``weights`` and ``scores`` are given, ``top_n`` is missing with scores or given with weights, ``direction`` is set with weights, ``market`` is unknown, a price column is missing (named), the prices have fewer than two bars, the weights or scores name a bar or symbol the prices lack, or a weight row breaks the contract (naming the bar). TypeError If a frame is not a pandas or polars DataFrame (or an xarray panel). Examples -------- >>> import numpy as np >>> import pandas as pd >>> import quantlab.api as qa >>> rng = np.random.default_rng(0) >>> bars = pd.bdate_range("2024-01-01", periods=60) >>> symbols = ["AAA", "BBB", "CCC", "DDD"] >>> close = 100 * np.exp(rng.normal(0, 0.01, (60, 4)).cumsum(axis=0)) >>> prices = pd.DataFrame({ ... "timestamp": np.repeat(bars, 4), "symbol": symbols * 60, ... "open": (close * 0.999).ravel(), "close": close.ravel(), ... }) Hold ``AAA`` and ``BBB`` half and half from the first bar: >>> weights = pd.DataFrame({"timestamp": bars[0], "symbol": ["AAA", "BBB"], ... "weight": [0.5, 0.5]}) >>> report = qa.backtest(prices, weights=weights) >>> report BacktestReport(60 bars x 4 symbols, total return 0.86%) >>> report.orders[["timestamp", "symbol", "side"]] timestamp symbol side 0 2024-01-02 AAA Buy 1 2024-01-02 BBB Buy Or hold the two names with the highest past 5-bar return, rebalanced every five bars (the first rebalance has no scores yet, so it stays flat): >>> momentum = prices.assign( ... score=prices.groupby("symbol")["close"].pct_change(5) ... )[["timestamp", "symbol", "score"]] >>> report = qa.backtest(prices, scores=momentum, top_n=2, rebalance_periods=5) >>> sorted(report.metrics) ['execution', 'notes', 'whole'] >>> report.weights[report.weights["timestamp"] == bars[5]] timestamp symbol weight 20 2024-01-08 AAA 0.0 21 2024-01-08 BBB 0.5 22 2024-01-08 CCC 0.0 23 2024-01-08 DDD 0.5 """ from quantlab.api import _backtest return _backtest.backtest( prices, weights=weights, scores=scores, top_n=top_n, direction=direction, rebalance_periods=rebalance_periods, fill=fill, valuation=valuation, market=market, trading_days_per_year=trading_days_per_year, session_minutes_per_day=session_minutes_per_day, fees=fees, slippage=slippage, init_cash=init_cash, benchmark=benchmark, output_dir=output_dir, columns=columns, )