Source code for quantlab.backend.parquet

"""The Parquet storage backend: long-format tables read lazily through polars.

``PlBackend`` holds a ``polars.LazyFrame`` and persists it as Parquet. It is used
for reference tables that are tabular rather than panel shaped (the universe
catalog in ``quantlab.universe``), and implements ``DataBackend`` from
``quantlab.backend.base``; see ``docs/user-guide/datasets.md``.
"""

from pathlib import Path
from typing import Optional, Self

import pandas as pd
import polars as pl
import xarray as xr

from quantlab.backend.base import DataBackend


[docs] class PlBackend(DataBackend): """Parquet-backed storage for a long-format table held as a lazy frame. ``data`` is a ``polars.LazyFrame`` produced by ``scan_parquet``, so reads and filters stay lazy until ``write`` or ``get_xarray_dataset`` collects them. Used for reference tables that are tabular rather than panel shaped. Examples -------- >>> table = PlBackend().read("universe.parquet") >>> frame = table.filter_by_symbol("symbol", ("AAPL",)).get_lazyframe() >>> frame.collect() """
[docs] def read(self, path: str, **kwargs) -> Self: """Lazily scan the Parquet file at ``path`` into ``data``. Parameters ---------- path : str Path of the Parquet file. **kwargs Accepted for interface compatibility and ignored. Raises ------ FileNotFoundError If ``path`` does not exist. Examples -------- >>> table = PlBackend().read("universe.parquet") >>> type(table.data).__name__ LazyFrame """ if not Path(path).exists(): raise FileNotFoundError(f"File {path} does not exist.") self.data = pl.scan_parquet(path) return self
[docs] def write(self, path: str, **kwargs) -> Self: """Collect ``data`` and write it to ``path`` as Parquet. Parameters ---------- path : str Path of the Parquet file to write. **kwargs Passed through to ``polars.DataFrame.write_parquet``. Examples -------- >>> PlBackend().to_internal(frame.lazy()).write("universe.parquet") PlBackend() """ self.data.collect().write_parquet(path, **kwargs) return self
[docs] def to_internal(self, data: pl.LazyFrame) -> Self: """Adopt an in-memory ``polars.LazyFrame`` as ``data``. Parameters ---------- data : pl.LazyFrame The table to hold. Examples -------- >>> PlBackend().to_internal(frame.lazy()) PlBackend() """ self.data = data return self
[docs] def filter_by_date(self, col: str, start_date: str, end_date: str) -> Self: """Narrow ``data`` in place to rows whose ``col`` lies in the range. Parameters ---------- col : str The date column to filter on. start_date : str First date to keep, inclusive. end_date : str Last date to keep, inclusive. Examples -------- >>> table.filter_by_date("timestamp", "2024-01-02", "2024-01-03") PlBackend() >>> table.get_lazyframe().collect().height # two days of two symbols 4 """ self.data = self.data.filter( pl.col(col).is_between( pl.lit(pd.to_datetime(start_date)), pl.lit(pd.to_datetime(end_date)), ) ) return self
[docs] def filter_by_symbol(self, col: str, symbols: tuple[str, ...]) -> Self: """Narrow ``data`` in place to rows whose ``col`` is in ``symbols``. Parameters ---------- col : str The column to filter on. symbols : tuple[str, ...] The values to keep. Examples -------- >>> table = PlBackend().read("universe.parquet") >>> table.filter_by_symbol("symbol", ("BBB",)) PlBackend() >>> table.get_lazyframe().collect()["symbol"].unique().to_list() ['BBB'] """ self.data = self.data.filter(pl.col(col).is_in(symbols)) return self
[docs] def get_lazyframe(self) -> pl.LazyFrame: """Return the held lazy frame. Examples -------- >>> table.get_lazyframe().collect().shape (8, 3) """ return self.data
[docs] def resample(self, labels: pd.Series, how: dict[str, str]) -> Self: """Aggregate the long-format frame onto the bars ``labels`` assigns. Rows are grouped by their label and by every non-``timestamp`` index column the frame has (``symbol``, in a panel). ``how`` names a method for every other column. The result replaces ``data`` lazily; nothing is collected here. Parameters ---------- labels : pd.Series Source timestamp to target timestamp; see ``DataBackend``. how : dict[str, str] Column name to aggregation method, one entry per value column. Examples -------- >>> table.resample(labels, {"close": "last"}) >>> table.get_lazyframe().collect().shape (2, 3) """ schema = self.data.collect_schema().names() keys = [name for name in schema if name == "symbol"] missing = [name for name in schema if name not in how and name not in ("timestamp", *keys)] if missing: raise ValueError(f"PlBackend.resample: no method for {missing}.") mapping = pl.LazyFrame( { "timestamp": pd.DatetimeIndex(labels.index).values, "_resample_label": pd.DatetimeIndex(labels.values).values, } ).with_columns( pl.col("timestamp").cast(pl.Datetime("ns")), pl.col("_resample_label").cast(pl.Datetime("ns")), ) aggregations = [] for name, method in how.items(): if name in ("timestamp", *keys): continue column = pl.col(name) aggregations.append( { "first": column.drop_nulls().first(), "last": column.drop_nulls().last(), "max": column.max(), "min": column.min(), "sum": column.sum(), "mean": column.mean(), "count": column.count(), }[method].alias(name) ) self.data = ( self.data.with_columns(pl.col("timestamp").cast(pl.Datetime("ns"))) .join(mapping, on="timestamp", how="left") .sort("timestamp") .group_by(["_resample_label", *keys], maintain_order=True) .agg(aggregations) .rename({"_resample_label": "timestamp"}) .sort(["timestamp", *keys]) ) return self
[docs] def head(self, path: str, n: int) -> pl.LazyFrame: """Return at most ``n`` rows scanned lazily from ``path``. Parameters ---------- path : str Path of the Parquet file. n : int Maximum number of rows to return. The limit is pushed down into the Parquet reader, and a fresh frame is returned without touching ``data``. The existence check is done here because ``scan_parquet`` on a missing file only fails at collect time. Raises ------ FileNotFoundError If ``path`` does not exist. Examples -------- >>> PlBackend().head("universe.parquet", 3).collect().shape (3, 3) """ if not Path(path).exists(): raise FileNotFoundError(f"File {path} does not exist.") return pl.scan_parquet(path).head(n)
[docs] def get_xarray_dataset( self, indexes: Optional[list[str]] = None ) -> xr.Dataset: """Collect ``data`` and convert it to a dataset indexed by ``indexes``. The named columns become the dataset's dimensions and every other column becomes a data variable. Parameters ---------- indexes : list[str] The columns to index by. Required: a lazy frame has no dimensions to fall back on. Raises ------ ValueError If ``indexes`` is ``None``. Examples -------- >>> ds = table.get_xarray_dataset(["timestamp", "symbol"]) >>> tuple(ds.dims), list(ds.data_vars) (('timestamp', 'symbol'), ['close']) """ if indexes is None: raise ValueError( "PlBackend.get_xarray_dataset: `indexes` is required. A " "LazyFrame has no dimensions to fall back on; name the " "columns that should become the dataset's index, e.g. " '["timestamp", "symbol"].' ) data = self.data.collect().to_pandas() data = data.set_index(indexes) return xr.Dataset.from_dataframe(data)