Source code for quantlab.backend.zarr

"""The Zarr storage backend: xarray panels persisted as Zarr stores.

``XrBackend`` is the backend every dataset, factor and model object in the
pipeline owns by default. It keeps an ``xarray.Dataset`` in memory, persists
it as a Zarr store, and carries the machinery chunked ingestion needs to grow
a store safely over time: ``append`` extends the time axis with a set of
corruption checks that raw ``to_zarr(mode="a")`` does not perform, and the
``widen_*`` family reconciles a store whose symbol roster or variable set has
grown between runs. It implements ``DataBackend`` from
``quantlab.backend.base``; the lazy Parquet counterpart for long-format
reference tables is ``quantlab.backend.parquet.PlBackend``. See
``docs/user-guide/datasets.md`` and ``docs/user-guide/datasets.md``.
"""

import glob
import json
import os
import shutil
from pathlib import Path
from typing import Mapping, Optional, Self, Sequence

import numpy as np
import pandas as pd
import polars as pl
import xarray as xr
from loguru import logger

from quantlab.backend.base import DataBackend
from quantlab.utils.symbol_axis import normalize_to_axis_dtype, sort_symbol_axis


[docs] class XrBackend(DataBackend): """Zarr-backed storage for an ``xarray.Dataset`` panel. ``read`` and ``write`` move the whole panel between memory and a Zarr directory. ``append`` extends an existing store along one dimension (``timestamp`` by default) after checking that the incoming window cannot silently corrupt it. ``widen_symbol_axis``, ``widen_data_vars`` and ``widen_and_append`` handle the case where the panel written today has more symbols or more variables than the store already holds. Two class constants govern the on-disk layout: ``APPEND_DIM_CHUNK`` is the chunk length pinned along the append dimension when a store is created, and ``MAX_WIDEN_BYTES`` is the largest panel a widen will hold in memory at once before switching to a block-by-block rewrite. Examples -------- >>> backend = XrBackend().to_internal(panel) >>> backend.write("prices.zarr") >>> backend.to_internal(next_month).widen_and_append("prices.zarr") >>> ds = XrBackend().read("prices.zarr").get_xarray_dataset( ... ["timestamp", "symbol"] ... ) """ def __init__(self) -> None: """Create an empty backend; call ``read`` or ``to_internal`` to fill it.""" super().__init__()
[docs] def read(self, path: "str | os.PathLike", **kwargs) -> Self: """Open the Zarr store at ``path`` lazily into ``data``. Every call opens the store again and replaces whatever ``data`` held; nothing is cached across calls. Zarr reads a string dimension coordinate (such as ``symbol``) back as numpy's variable-width ``StringDType``; it is handed back as object strings, the form a panel built from a frame has, so labels from a store and from memory compare, align and cast to ``str`` alike. Parameters ---------- path : str or os.PathLike Directory of the Zarr store. **kwargs Passed through to ``xarray.open_dataset``. Raises ------ FileNotFoundError If ``path`` does not exist. Examples -------- >>> import tempfile >>> from pathlib import Path >>> import numpy as np >>> import pandas as pd >>> import xarray as xr >>> from quantlab.backend.zarr import XrBackend >>> panel = xr.Dataset( ... {"close": (("timestamp", "symbol"), np.ones((4, 2)))}, ... coords={"timestamp": pd.date_range("2024-01-02", periods=4), ... "symbol": np.array(["AAA", "BBB"], dtype=object)}, ... ) >>> path = Path(tempfile.mkdtemp()) / "prices.zarr" >>> _ = XrBackend().to_internal(panel).write(str(path)) >>> backend = XrBackend().read(path) >>> dict(backend.data.sizes) {'timestamp': 4, 'symbol': 2} >>> backend.data["symbol"].dtype dtype('O') """ if not Path(path).exists(): raise FileNotFoundError(f"File {path} does not exist.") data = xr.open_dataset(path, **kwargs) strings = { name: data[name].values.astype(object) for name in data.indexes if data[name].dtype.kind == "T" } self.data = data.assign_coords(strings) if strings else data return self
#: Chunk length pinned along the append dimension when a store is created #: by ``append``, ``widen_symbol_axis`` or ``widen_data_vars``. Without an #: explicit encoding Zarr would adopt the first window's own length as the #: chunk size, and later windows of a different length would be misaligned #: with the on-disk grid. The grid a store ends up with is #: ``min(APPEND_DIM_CHUNK, extent)``, where ``extent`` is the panel's #: length along the append dimension or, when the caller states it with #: ``append_dim_size``, the store's eventual total length. Zarr fixes the #: grid at creation; an append cannot revise it, and a ``mode="w"`` rewrite #: re-pins it. APPEND_DIM_CHUNK = 512 #: Chunk width pinned along the symbol axis when a store is created by #: ``append``, ``widen_and_append`` or a widen rewrite. A store's symbol #: axis grows at its end (``extend_symbol_axis``, ADR 0032), so a width #: taken from the first window would leave a store that started with a #: few symbols in thousands of narrow chunks. Stores created before the #: constant keep their own width; Zarr fixes it at creation. SYMBOL_DIM_CHUNK = 1024 #: Largest number of bytes a symbol-axis widen may materialise at once. #: At or under this budget ``widen_symbol_axis`` reindexes the whole store #: in memory and writes it once; above it, the store is rewritten block by #: block along the append dimension. The value routes between the two #: strategies and never refuses a widen. It is a constant of this module #: rather than an import because the storage layer must not depend on the #: acquisition layer for a scalar. MAX_WIDEN_BYTES = 4 * 1024**3
[docs] def write(self, path: str, **kwargs) -> Self: """Write ``data`` to ``path``, replacing any store already there. The parent directory is created if needed and ``mode`` defaults to ``"w"``. No chunk encoding is applied, so the store lands on Zarr's default chunk grid. Parameters ---------- path : str Directory of the Zarr store. **kwargs Passed through to ``Dataset.to_zarr``. Examples -------- >>> XrBackend().to_internal(panel).write("prices.zarr") XrBackend() >>> Path("prices.zarr").is_dir() True """ if not Path(path).exists(): Path(path).parent.mkdir(parents=True, exist_ok=True) kwargs.setdefault("mode", "w") self.data.to_zarr(path, **kwargs) return self
[docs] def create( self, path: str, fill_values: Optional[Mapping[str, object]] = None, *, dim: str = "symbol", append_dim: str = "timestamp", **kwargs, ) -> Self: """Write ``data`` as a new store at ``path``, laid out to grow. The ``dim`` axis is put in ``sort_symbol_axis`` order (``sort_symbols``) and the chunk grid and Zarr fill values are pinned as ``append`` and ``widen_and_append`` pin them (``_append_encoding``): ``APPEND_DIM_CHUNK`` along ``append_dim``, ``SYMBOL_DIM_CHUNK`` along ``dim``, and the ``fill_values`` entry as the fill of each variable on ``dim``, so a symbol appended later (``extend_symbol_axis``) reads as it without being written. Any store already at ``path`` is replaced, as by ``write``. Parameters ---------- path : str Directory of the Zarr store. fill_values : Mapping[str, object], optional Per-variable fill of the cells a later symbol extension adds. dim : str, default "symbol" The symbol axis. append_dim : str, default "timestamp" The dimension the store grows along. **kwargs Passed through to ``write``; an ``encoding`` given here wins. Examples -------- >>> import tempfile >>> from pathlib import Path >>> import numpy as np >>> import pandas as pd >>> import xarray as xr >>> import zarr >>> from quantlab.backend.zarr import XrBackend >>> panel = xr.Dataset( ... {"close": (("timestamp", "symbol"), np.ones((2, 2)))}, ... coords={"timestamp": pd.date_range("2024-01-02", periods=2), ... "symbol": np.array(["BBB", "AAA"], dtype=object)}, ... ) >>> path = str(Path(tempfile.mkdtemp()) / "prices.zarr") >>> _ = XrBackend().to_internal(panel).create(path) >>> xr.open_zarr(path)["symbol"].values.tolist() ['AAA', 'BBB'] >>> zarr.open_group(path)["close"].chunks (2, 1024) """ self.sort_symbols(dim) kwargs.setdefault( "encoding", self._append_encoding( append_dim, dim=dim, fill_values=fill_values, zarr_format=kwargs.get("zarr_format"), ), ) kwargs.setdefault("mode", "w") return self.write(path, **kwargs)
[docs] def sort_symbols(self, dim: str = "symbol") -> Self: """Put ``data``'s ``dim`` axis in ``sort_symbol_axis`` order. Every store keeps its symbol axis in that order, the one ``widen_and_append`` writes, so a store has one layout whether it was written whole or appended to. Labels and values move together; a panel without ``dim`` is left as it is. Parameters ---------- dim : str, default "symbol" The axis to order. Returns ------- Self ``self``, for chaining. Examples -------- >>> backend = XrBackend().to_internal(panel).sort_symbols() >>> backend.data["symbol"].values.tolist() ['AAA', 'BBB', 'CCC'] """ if dim not in self.data.coords: return self labels = self.data[dim].values.tolist() rank = {label: position for position, label in enumerate(labels)} order = [rank[label] for label in sort_symbol_axis(labels)] if order != list(range(len(labels))): self.data = self.data.isel({dim: order}) return self
[docs] def append( self, path: str, append_dim: str = "timestamp", *, append_dim_size: Optional[int] = None, **kwargs, ) -> Self: """Create the store at ``path``, or extend it along ``append_dim``. When no store exists, ``data`` is written as the first window with the chunk grid pinned by ``_append_encoding``. Otherwise the window is appended after ``_assert_append_compatible`` has checked that it cannot silently corrupt the store. Raw ``to_zarr(mode="a")`` performs none of those checks: a changed coordinate overwrites the stored labels, a float NaN written into an integer variable becomes ``0``, an overlapping window leaves the axis non-monotonic, and a changed variable set leaves the store unopenable. A gap between the stored end and the incoming start is allowed; overlap is refused and has no opt-out, because ``append`` extends a store and does not recompute one. To replace a range the store already holds, rewrite the store with ``write``. After the append the store's 1-D index coordinates are rewritten in one chunk by ``rechunk_index_coordinates``: Zarr fixes a coordinate's chunk grid at creation, so without it a store created from a one-bar window would keep ``timestamp`` in one chunk per bar. A panel that has grown a variable must go through ``widen_data_vars`` or ``widen_and_append`` first; a panel missing a stored variable is refused outright, since the only fill would be NaN over the incoming dates of a variable that was complete. Parameters ---------- path : str Directory of the Zarr store. append_dim : str, default "timestamp" The dimension the store grows along. append_dim_size : int, optional The store's eventual total length along ``append_dim``, if the caller knows it. Only the creating write reads it; it pins the chunk grid to the value a single whole-range write would have chosen instead of the first window's length. Ignored against an existing store, so an incremental writer may pass it on every window. **kwargs Passed through to ``Dataset.to_zarr``. An ``encoding`` entry is dropped on the append path because xarray rejects it there. Raises ------ ValueError If the window fails any compatibility check. Examples -------- Two windows on the same symbol axis; the first call creates the store, the second extends it: >>> XrBackend().to_internal(first_window).append("prices.zarr") XrBackend() >>> XrBackend().to_internal(next_window).append("prices.zarr") XrBackend() >>> xr.open_zarr("prices.zarr").sizes["timestamp"] 6 """ target = Path(path) if not target.exists(): target.parent.mkdir(parents=True, exist_ok=True) kwargs.setdefault( "encoding", self._append_encoding( append_dim, append_dim_size=append_dim_size ), ) self.data.to_zarr(path, mode="w", **kwargs) return self # A rechunk killed midway may have left the store without a coordinate. _recover_interrupted_writes(target) self._assert_append_compatible(path, append_dim) # xarray rejects `encoding` on an append; the grid was pinned by the # creating write above. kwargs.pop("encoding", None) # The other dimensions' labels were just checked equal to the stored # ones, so they are not written again and the window aligns by # position, as the filler of `widen_data_vars` does. Writing them back # would compare their in-memory dtype with the stored one: xarray # 2026.9 refuses fixed-width `<U` labels on a variable-length store. stored = [ dim for dim in self.data.dims if dim != append_dim and dim in self.data.indexes ] self.data.drop_vars(stored).to_zarr( path, mode="a", append_dim=append_dim, **kwargs ) # The coordinate's grid was fixed by the creating write; without this # a store created from a one-bar window keeps one chunk per bar. rechunk_index_coordinates(path) return self
#: Sidecar suffixes used by ``widen_symbol_axis``'s atomic directory #: swap. ``.widening.tmp`` holds the rewritten store before it becomes #: authoritative and is never read by anything. ``.superseded.tmp`` briefly #: holds the original store between the two renames and is the one #: artefact a crash can leave behind that still contains real data. WIDENING_SUFFIX = ".widening.tmp" SUPERSEDED_SUFFIX = ".superseded.tmp"
[docs] def widen_symbol_axis( self, path: str, symbols: Sequence[str], dim: str = "symbol", append_dim: str = "timestamp", fill_values: Optional[Mapping[str, object]] = None, *, append_dim_size: Optional[int] = None, ) -> Self: """Rewrite the store at ``path`` onto a superset ``dim`` axis. Every label already in the store keeps its history unchanged; each new label is filled across the whole stored extent (NaN for a floating-point variable, the value named in ``fill_values`` otherwise). ``data`` is not touched, so a caller can widen a store without holding a panel. This is the explicit opt-in for a roster that grew between runs; a plain ``append`` still refuses a changed coordinate. The rewrite lands in a ``.widening.tmp`` sidecar and is then swapped in with two same-parent renames (store to ``.superseded.tmp``, sidecar to store) followed by removal of the superseded copy. A crash between the renames leaves no store at ``path`` and a superseded sidecar holding the only copy; the next call refuses and names the manual move rather than guessing which directory is authoritative. A non-empty superseded sidecar beside a live store is also refused, because it may be a real store left by an interrupted rebuild. Empty or never-authoritative residue is removed and the widen proceeds. The strategy is chosen by size: at or under ``MAX_WIDEN_BYTES`` the whole store is reindexed in memory and written once; above it the store is rewritten block by block along ``append_dim`` and a warning names the figures involved. Both leave an identical store. ``symbols`` is re-spelled in the stored axis's dtype before anything is compared, so a ticker handed to an integer axis raises from ``normalize_to_axis_dtype`` instead of matching nothing and writing an all-NaN store. Widening backfills NaN over a new label's history; it does not recover data a vendor may have had. Rebuilding from raw data is the route when that history matters. Parameters ---------- path : str Directory of the Zarr store. symbols : Sequence[str] The labels the rewritten ``dim`` axis must contain. Must be a superset of the stored axis. dim : str, default "symbol" The axis being widened. append_dim : str, default "timestamp" The store's append dimension, used to size blocks and to pin the rewritten chunk grid. fill_values : Mapping[str, object], optional Per-variable fill for variables carrying ``dim`` that are not floating-point. Without an entry such a variable is refused, because an unfilled reindex would silently upcast it to float64. append_dim_size : int, optional The store's eventual length along ``append_dim``. This rewrite re-pins the chunk grid, so a caller widening a store that has not yet reached its final extent states the extent here; ``None`` sizes the grid from the store as it is now. Raises ------ FileNotFoundError If no store exists at ``path``. ValueError If crash residue makes the store's identity ambiguous, if ``symbols`` would drop a stored label, or if a non-floating variable has no fill value. Examples -------- >>> XrBackend().widen_symbol_axis("prices.zarr", ["AAA", "BBB", "CCC"]) XrBackend() >>> stored = xr.open_zarr("prices.zarr") >>> stored["symbol"].values.tolist() ['AAA', 'BBB', 'CCC'] >>> stored["close"].sel(symbol="CCC").values # backfilled history array([nan, nan, nan, nan, nan, nan]) """ target = Path(path) widening = Path(f"{path}{self.WIDENING_SUFFIX}") superseded = Path(f"{path}{self.SUPERSEDED_SUFFIX}") if superseded.exists() and not target.exists(): raise ValueError( f"XrBackend.widen_symbol_axis: refusing to widen {path} -- " f"there is no store there, but a superseded sidecar exists at " f"{superseded}. A previous widen crashed between its two " f"renames, so that sidecar holds the only copy of the store. " f"Recover it by hand (`mv {superseded} {path}`) and re-run. " f"This method does not recover automatically, because it " f"cannot know which directory is authoritative." ) if superseded.exists() and any(superseded.iterdir()): raise ValueError( f"XrBackend.widen_symbol_axis: refusing to widen {path} -- " f"a non-empty superseded directory sits beside the store at " f"{superseded}, and it holds real data. Two kinds of run leave " f"a directory with that name: a widen that crashed between its " f"two renames, and an `on_new_listing=\"rebuild\"` run that " f"was killed outright (SIGKILL), whose set-aside copy uses the " f"same name and is only cleaned up after an exception or a " f"cancel. Refusing now avoids wasted work: otherwise the final " f"rename would fail with 'Directory not empty' only after the " f"whole store had been rewritten into a sidecar. Decide by " f"hand which of {path} and {superseded} is authoritative, " f"remove the other, and re-run. This method does not recover " f"automatically, because the leftover directory may be the " f"only complete copy of the store." ) if superseded.exists(): # Empty, so it holds nothing; removing it keeps the retry clean. shutil.rmtree(superseded, ignore_errors=True) if widening.exists(): # A never-authoritative orphan from a crashed rewrite. shutil.rmtree(widening, ignore_errors=True) # So are the sidecars of a rechunk of that orphan (`_widen_chunked`). for suffix in (_RECHUNK_SUFFIX, _REPLACED_SUFFIX): pattern = f"{glob.escape(widening.name)}.*{suffix}" for orphan in widening.parent.glob(pattern): shutil.rmtree(orphan, ignore_errors=True) if not target.exists(): raise FileNotFoundError(f"File {path} does not exist.") _recover_interrupted_writes(target) fills = dict(fill_values or {}) stored = xr.open_zarr(path) try: # The request is converted to the stored axis's dtype, which is # only known once the store is open. stored_index = stored[dim].to_index() requested = normalize_to_axis_dtype(symbols, stored_index) # Compare the converted values, which is what `reindex` matches. dropped = stored_index.difference(pd.Index(requested)).tolist() if dropped: raise ValueError( f"XrBackend.widen_symbol_axis: refusing to widen {path} -- " f"the target '{dim}' axis is not a superset of the stored " f"one; it would drop {dropped}. Reindexing silently " f"deletes the history of every label it is not asked for, " f"and afterwards the store looks as if it never held " f"them. Pass the union of the stored and incoming labels, " f"as widen_and_append() does." ) offenders = [ (name, variable.dtype) for name, variable in stored.data_vars.items() if dim in variable.dims and name not in fills and not np.issubdtype(variable.dtype, np.floating) ] if offenders: described = ", ".join( f"'{name}' ({dtype})" for name, dtype in offenders ) integer = [ name for name, dtype in offenders if np.issubdtype(dtype, np.integer) ] remedy = ( f"Pass fill_values={{...}} with a value for each, for " f"example fill_values={{'anomaly_flag': False}}; that " f"keeps the stored dtype exactly." ) if integer: remedy += ( f" For the integer variable(s) {integer}, the promotion " f"normally happens in RawDataset._pin_append_dtypes, " f"which converts integer variables to float before an " f"append; a store written before that step existed is " f"the usual cause." ) raise ValueError( f"XrBackend.widen_symbol_axis: refusing to widen {path} -- " f"variable(s) {described} carry '{dim}' but are not " f"floating-point and no fill value was given. An unfilled " f"reindex converts them to float64 and writes NaN into the " f"new columns, silently changing the existing store's " f"schema. The append path refuses the same kind of silent " f"corruption in _assert_append_compatible. {remedy}" ) estimate = self._estimate_widen_bytes( stored, requested, dim, append_dim ) block_rows = self._widen_block_rows(estimate["row_bytes"]) chunked = estimate["widened_bytes"] > self.MAX_WIDEN_BYTES self._report_widen_strategy( path, dim, append_dim, estimate, block_rows, chunked ) # The strategy reads through `stored`, so it must run while the # handle is open. On any failure the sidecar is removed and # `path` stays the authoritative store. strategy = self._widen_chunked if chunked else self._widen_whole_store try: strategy( stored, widening, requested, dim=dim, append_dim=append_dim, fills=fills, block_rows=block_rows, append_dim_size=append_dim_size, ) except BaseException: shutil.rmtree(widening, ignore_errors=True) raise finally: stored.close() os.replace(target, superseded) os.replace(widening, target) shutil.rmtree(superseded, ignore_errors=True) return self
@staticmethod def _estimate_widen_bytes( stored: xr.Dataset, requested: Sequence[str], dim: str, append_dim: str, ) -> dict: """Estimate the size of the panel a widen onto ``requested`` produces. Only metadata is read, so the estimate is free relative to the rewrite it decides. Parameters ---------- stored : xr.Dataset The already-open store. requested : Sequence[str] The target ``dim`` axis. dim : str The axis being widened. append_dim : str The store's append dimension. Returns ------- dict A dict with ``stored_symbols``, ``symbols``, ``timestamps``, ``variables``, ``widened_bytes`` (the whole widened panel, every variable) and ``row_bytes`` (the bytes of one ``append_dim`` row, counting only variables that carry ``append_dim``). """ widened_bytes = 0 row_bytes = 0 for variable in stored.data_vars.values(): sizes = {str(name): int(size) for name, size in variable.sizes.items()} if dim in sizes: sizes[dim] = len(requested) count = 1 for extent in sizes.values(): count *= extent nbytes = count * variable.dtype.itemsize widened_bytes += nbytes if sizes.get(append_dim): row_bytes += nbytes // sizes[append_dim] return { "stored_symbols": int(stored.sizes.get(dim, 0)), "symbols": len(requested), "timestamps": int(stored.sizes.get(append_dim, 0)), "variables": len(stored.data_vars), "row_bytes": row_bytes, "widened_bytes": widened_bytes, } @staticmethod def _widen_block_rows(row_bytes: int) -> int: """Return how many ``append_dim`` rows one chunked-rewrite block holds. The block is the largest multiple of ``APPEND_DIM_CHUNK`` that fits under ``MAX_WIDEN_BYTES``, and never less than one ``APPEND_DIM_CHUNK``. The floor bounds memory per block; a single block that is still over budget is accepted rather than refused, because a bounded loop is still better than the whole-store allocation it replaces. Parameters ---------- row_bytes : int Bytes of one ``append_dim`` row, from ``_estimate_widen_bytes``. """ chunk = XrBackend.APPEND_DIM_CHUNK raw = XrBackend.MAX_WIDEN_BYTES // row_bytes if row_bytes > 0 else 0 return max(chunk, (raw // chunk) * chunk) @staticmethod def _widen_blocks(timestamps: int, block_rows: int) -> range: """Return the block start offsets the chunked rewrite walks. A store with a zero-length append dimension still yields exactly one (empty) block, so the swap always has a sidecar to rename in. """ return range(0, max(int(timestamps), 1), block_rows) def _report_widen_strategy( self, path: str, dim: str, append_dim: str, estimate: Mapping[str, int], block_rows: int, chunked: bool, ) -> None: """Log which widen strategy was chosen. The whole-store path logs one ``info`` line; the chunked path logs a ``warning`` naming every figure it decided on, so a slow run reads as the strategy rather than the machine. The asymmetry is deliberate: a warning on every routine widen would train operators to ignore it. """ gib = 1024**3 mib = 1024**2 if not chunked: logger.info( f"XrBackend.widen_symbol_axis: {path} -- widening '{dim}' from " f"{estimate['stored_symbols']} to {estimate['symbols']} " f"label(s) materialises " f"{estimate['widened_bytes'] / gib:.3f} GiB, within the " f"{self.MAX_WIDEN_BYTES / gib:.2f} GiB MAX_WIDEN_BYTES budget; " f"taking the whole-store rewrite." ) return blocks = len(self._widen_blocks(estimate["timestamps"], block_rows)) logger.warning( f"XrBackend.widen_symbol_axis: {path} -- widening '{dim}' from " f"{estimate['stored_symbols']} to {estimate['symbols']} label(s) " f"would materialise {estimate['widened_bytes'] / gib:.3f} GiB at " f"once, over the {self.MAX_WIDEN_BYTES / gib:.2f} GiB " f"MAX_WIDEN_BYTES budget. Rewriting the store block by block along " f"'{append_dim}' instead: {blocks} block(s) of {block_rows} row(s), " f"~{estimate['row_bytes'] * block_rows / mib:.1f} MiB per block, so " f"peak memory is one block rather than the whole store. The " f"block-by-block rewrite takes roughly 3.6-4.0x as long as the " f"whole-store rewrite, so a slow run here is expected. Raise " f"XrBackend.MAX_WIDEN_BYTES to take the whole-store path anyway." ) def _widen_whole_store( self, stored: xr.Dataset, widening: Path, requested: Sequence[str], *, dim: str, append_dim: str, fills: Mapping[str, object], block_rows: int, append_dim_size: Optional[int] = None, ) -> None: """Reindex the whole store in memory and write it once to ``widening``. The faster strategy wherever the widened panel fits under ``MAX_WIDEN_BYTES``. ``block_rows`` is accepted and ignored so that both strategies share one signature. This path has no block floor, so ``append_dim_size`` is the only thing that keeps the rewritten chunk grid from being sized by whatever the store happens to hold right now. """ # `.load()` is required: without it the arrays are read lazily from # the store directory, which the swap later renames away. widened = stored.reindex({dim: requested}, fill_value=fills).load() encoding = self._append_encoding( append_dim, data=widened, append_dim_size=append_dim_size, dim=dim, fill_values=fills, ) widened.to_zarr(str(widening), mode="w", encoding=encoding) def _widen_chunked( self, stored: xr.Dataset, widening: Path, requested: Sequence[str], *, dim: str, append_dim: str, fills: Mapping[str, object], block_rows: int, append_dim_size: Optional[int] = None, ) -> None: """Rewrite the store to ``widening`` one ``append_dim`` block at a time. Produces the same store as ``_widen_whole_store`` (values, both coordinates, chunk grid and coordinate encoding) with a peak allocation of one block. The first block creates the sidecar with the chunk grid pinned by ``_append_encoding``; every later block is appended after dropping any variable that does not carry ``append_dim``, since the first block already wrote those at full extent. Each block is loaded before its own write so that no lazily indexed reference outlives its iteration. """ timestamps = int(stored.sizes.get(append_dim, 0)) for index, low in enumerate(self._widen_blocks(timestamps, block_rows)): block = stored.isel( {append_dim: slice(low, low + block_rows)} ).load() block = block.reindex({dim: requested}, fill_value=fills) if index == 0: block.to_zarr( str(widening), mode="w", encoding=self._append_encoding( append_dim, data=block, append_dim_size=append_dim_size, dim=dim, fill_values=fills, ), ) continue # The first block also wrote the other dimensions' labels; writing # them again would trip xarray 2026.9's dtype check (see `append`). block = block.drop_vars( [ name for name, variable in block.data_vars.items() if append_dim not in variable.dims ] + [name for name in block.indexes if name != append_dim] ) block.to_zarr(str(widening), mode="a", append_dim=append_dim) rechunk_index_coordinates(widening)
[docs] def widen_data_vars( self, path: str, variables: Mapping[str, object], append_dim: str = "timestamp", fill_values: Optional[Mapping[str, object]] = None, *, append_dim_size: Optional[int] = None, ) -> Self: """Add data variables to the store at ``path``, backfilled over its extent. The variable-set counterpart of ``widen_symbol_axis``. Each name in ``variables`` that the store does not yet hold is materialised across the store's whole existing extent with the fill value (NaN for a floating-point dtype, the ``fill_values`` entry otherwise). Names the store already holds are left alone, and when nothing is new the method returns without writing. This is the explicit opt-in for a panel that grew a column; a plain ``append`` still refuses a changed variable set because Zarr would otherwise write the new variable over the incoming window only and leave the store unopenable. The filler carries dimensions but no coordinates: the store already holds them, and writing a decoded coordinate back can land on a different dtype than the one Zarr recorded. No directory swap is needed because a failed partial write raises and leaves the store intact. Parameters ---------- path : str Directory of the Zarr store. variables : Mapping[str, object] Maps each variable name to its dtype. The dtype is all the filler needs, so the caller's panel is never held. append_dim : str, default "timestamp" The store's append dimension, used to pin the filler's chunk grid to the store's. fill_values : Mapping[str, object], optional Per-variable fill for new variables that are not floating-point. Without an entry such a variable is refused, because ``np.full`` with NaN yields ``0`` for integers and ``True`` for booleans, fabricating history. append_dim_size : int, optional The store's eventual length along ``append_dim``, so a filler added to a store that has not reached its final extent joins on the same grid as the variables already there. Raises ------ FileNotFoundError If no store exists at ``path``. ValueError If a new non-floating variable has no fill value. Examples -------- >>> XrBackend().widen_data_vars("prices.zarr", {"volume": "float64"}) XrBackend() >>> XrBackend().widen_data_vars( ... "prices.zarr", {"flag": "bool"}, fill_values={"flag": False} ... ) XrBackend() >>> stored = xr.open_zarr("prices.zarr") >>> sorted(stored.data_vars), stored["flag"].dtype (['close', 'flag', 'volume'], dtype('bool')) """ if not Path(path).exists(): raise FileNotFoundError(f"File {path} does not exist.") _recover_interrupted_writes(Path(path)) fills = dict(fill_values or {}) requested = { str(name): np.dtype(dtype) for name, dtype in variables.items() } stored = xr.open_zarr(path) try: absent = { name: dtype for name, dtype in requested.items() if name not in stored.data_vars } if not absent: return self offenders = [ (name, dtype) for name, dtype in absent.items() if name not in fills and not np.issubdtype(dtype, np.floating) ] if offenders: described = ", ".join( f"'{name}' ({dtype})" for name, dtype in offenders ) raise ValueError( f"XrBackend.widen_data_vars: refusing to widen {path} -- " f"new variable(s) {described} are not floating-point and " f"no fill value was given. The new variable spans the " f"store's entire existing history, and without a fill " f"value that history would be invented rather than marked " f"missing: np.full(shape, np.nan) yields 0 for int64 and " f"True for bool, so a boolean flag would read as set on " f"every historical row. The append path refuses the same " f"kind of silent corruption in _assert_append_compatible. " f"Pass fill_values={{...}} with a value for each, for " f"example fill_values={{'anomaly_flag': False}}; that " f"keeps the dtype exactly." ) # Take the layout from a variable that already spans `append_dim` # so the filler reproduces the store's actual shape. layout = next( ( variable for variable in stored.data_vars.values() if append_dim in variable.dims ), None, ) dims = tuple(layout.dims) if layout is not None else tuple(stored.sizes) shape = tuple(int(stored.sizes[name]) for name in dims) # No coords on the filler: the store already holds them, and # writing a decoded coordinate back can change its dtype (a string # coordinate decodes to numpy's StringDType and is then rejected). # A filler with only dims aligns by position with the store. filler = xr.Dataset( { name: ( dims, np.full(shape, fills.get(name, np.nan), dtype=dtype), ) for name, dtype in absent.items() } ) encoding = self._append_encoding( append_dim, data=filler, append_dim_size=append_dim_size, fill_values=fills, zarr_format=_zarr_format(Path(path)), ) # Across every other dimension the filler joins the grid the # store's variables already have (a store created before # SYMBOL_DIM_CHUNK keeps its own symbol chunk width). stored_chunks = ( layout.encoding.get("chunks") if layout is not None else None ) if stored_chunks is not None: for entry in encoding.values(): entry["chunks"] = tuple( chunk if axis == append_dim else int(stored_chunks[index]) for index, (axis, chunk) in enumerate( zip(dims, entry["chunks"]) ) ) finally: stored.close() filler.to_zarr(path, mode="a", encoding=encoding) return self
[docs] def extend_symbol_axis( self, path: str, symbols: Sequence, dim: str = "symbol", fill_values: Optional[Mapping[str, object]] = None, ) -> Self: """Append ``symbols`` at the end of the store's ``dim`` axis, in place. Each Zarr array carrying ``dim`` is resized; no stored chunk is rewritten, so the cost is a few metadata writes whatever the store's size. The new cells read as each array's fill value: NaN for a floating-point variable, the ``fill_values`` entry otherwise, an empty string for a text coordinate on ``dim``. A variable whose Zarr fill value is not that (a store created before ``_append_encoding`` pinned it, a datetime variable) has its new cells written, which rewrites at most the chunks the old axis ends in. The coordinate is then swapped for one holding the old labels followed by ``symbols``, in one chunk, and the metadata consolidated; arrays without ``dim`` are untouched. ``data`` is not touched. Labels the store already holds are skipped; the others keep the order given (``widen_and_append`` sorts them among themselves). They are re-spelled in the stored axis's dtype first, so a ticker handed to an integer axis raises. A fixed-width text axis is stored at the width of its longest label. The swap of the coordinate is the commit: a process killed before it leaves arrays longer than the coordinate, which the next writing call cuts back (``_recover_axis_extension``), so the store is as it was before. Do not run this while another process reads the store. Parameters ---------- path : str Directory of the Zarr store. symbols : Sequence The labels to append. dim : str, default "symbol" The axis to extend. fill_values : Mapping[str, object], optional Per-variable fill of the new cells. A variable on ``dim`` that is not floating-point needs one, as in ``widen_symbol_axis``. Raises ------ FileNotFoundError If no store exists at ``path``. ValueError If a non-floating variable on ``dim`` has no fill value. Examples -------- >>> import tempfile >>> from pathlib import Path >>> import numpy as np >>> import pandas as pd >>> import xarray as xr >>> from quantlab.backend.zarr import XrBackend >>> panel = xr.Dataset( ... {"close": (("timestamp", "symbol"), np.ones((2, 2))), ... "volume": (("timestamp", "symbol"), np.ones((2, 2)))}, ... coords={"timestamp": pd.date_range("2024-01-02", periods=2), ... "symbol": np.array(["BBB", "DDD"], dtype=object)}, ... ) >>> path = str(Path(tempfile.mkdtemp()) / "prices.zarr") >>> _ = XrBackend().to_internal(panel).append(path) >>> _ = XrBackend().extend_symbol_axis( ... path, ["AAA"], fill_values={"volume": 0.0} ... ) >>> stored = xr.open_zarr(path) >>> stored["symbol"].values.tolist() ['BBB', 'DDD', 'AAA'] >>> stored["close"].sel(symbol="AAA").values.tolist() [nan, nan] >>> stored["volume"].sel(symbol="AAA").values.tolist() [0.0, 0.0] """ import zarr target = Path(path) if not target.exists(): raise FileNotFoundError(f"File {path} does not exist.") _recover_interrupted_writes(target) fills = dict(fill_values or {}) # The decoded view gives each variable's dtype and its encoding. opened = xr.open_zarr(path) try: stored_index = opened[dim].to_index() requested = normalize_to_axis_dtype(symbols, stored_index) known = set(stored_index.tolist()) added = [] for label in requested: if label not in known: known.add(label) added.append(label) if not added: return self on_dim = { str(name): variable for name, variable in opened.variables.items() if dim in variable.dims and name != dim } # A text coordinate on the axis (a name beside each symbol) # reads empty for a new symbol, as a reindex fills it. for name in opened.coords: if str(name) in on_dim and on_dim[str(name)].dtype.kind in "OSTU": fills.setdefault(str(name), "") offenders = [ f"'{name}' ({variable.dtype})" for name, variable in on_dim.items() if name not in fills and not np.issubdtype(variable.dtype, np.floating) ] if offenders: raise ValueError( f"XrBackend.extend_symbol_axis: refusing to extend {path} " f"-- variable(s) {', '.join(offenders)} carry '{dim}' but " f"are not floating-point and no fill value was given. " f"Their new cells would read as the array's raw fill (0, " f"False, the epoch), an invented history. Pass " f"fill_values={{...}} with a value for each." ) # Metadata only: how each variable is decoded, to encode a fill. templates = { name: xr.Variable( variable.dims, np.empty((0,) * variable.ndim, dtype=variable.dtype), attrs=dict(variable.attrs), encoding=dict(variable.encoding), ) for name, variable in on_dim.items() } finally: opened.close() group = zarr.open_group(str(target), mode="r+", use_consolidated=False) old = int(group[dim].shape[0]) new = old + len(added) for name, template in templates.items(): array = group[name] axis = template.dims.index(dim) fill = fills.get(name, np.nan) shape = list(array.shape) shape[axis] = new array.resize(tuple(shape)) if not _reads_as(array, template.dtype, fill): _write_extension_fill( array, axis, old, fill, template, self.MAX_WIDEN_BYTES ) values = np.concatenate( [ np.asarray(group[dim][...], dtype=object), np.asarray(added, dtype=object), ] ) _swap_index_coordinate(target, dim, values) return self
[docs] def shorten_axis(self, path: str, last, dim: str = "timestamp") -> Self: """Drop the labels of the store's ``dim`` axis after ``last``, in place. A write killed midway is settled first (``_recover_interrupted_writes``), and the axis is then read without the consolidated metadata, so a shortening killed midway is finished by running it again; with no label after ``last`` nothing is written. The index coordinate of ``dim`` is cut first, then every other array carrying ``dim``, and the metadata is consolidated again. No stored chunk is rewritten: the cut cells stay in the axis's last chunk until the axis grows over them, and an append writes every cell it adds. Cutting the coordinate is the commit: a process killed after it leaves arrays longer than their axis, which the next writing call cuts back. The other axes are untouched. Do not run this while another process reads the store. Parameters ---------- path : str Directory of the Zarr store. last : label The last label of ``dim`` kept (a ``pd.Timestamp`` on the time axis); the axis is ordered. dim : str, default "timestamp" The axis to shorten. Raises ------ FileNotFoundError If no store exists at ``path``. Examples -------- >>> import tempfile >>> from pathlib import Path >>> import numpy as np >>> import pandas as pd >>> import xarray as xr >>> from quantlab.backend.zarr import XrBackend >>> panel = xr.Dataset( ... {"close": (("timestamp", "symbol"), np.arange(6.0).reshape(3, 2))}, ... coords={"timestamp": pd.date_range("2024-01-02", periods=3), ... "symbol": np.array(["AAA", "BBB"], dtype=object)}, ... ) >>> path = str(Path(tempfile.mkdtemp()) / "prices.zarr") >>> _ = XrBackend().to_internal(panel).append(path) >>> _ = XrBackend().shorten_axis(path, pd.Timestamp("2024-01-03")) >>> xr.open_zarr(path)["close"].values.tolist() [[0.0, 1.0], [2.0, 3.0]] """ import zarr target = Path(path) if not target.exists(): raise FileNotFoundError(f"File {path} does not exist.") _recover_interrupted_writes(target) with xr.open_zarr(path, consolidated=False) as opened: labels = opened[dim].values length = int((labels <= np.asarray(last, dtype=labels.dtype)).sum()) if length == len(labels): return self group = zarr.open_group(str(target), mode="r+", use_consolidated=False) group[dim].resize((length,)) for name, array in group.arrays(): dims = _dimension_names(array) or [] if name == dim or dim not in dims: continue shape = list(array.shape) shape[dims.index(dim)] = length array.resize(tuple(shape)) zarr_format = group.metadata.zarr_format if _is_consolidated(target, zarr_format): zarr.consolidate_metadata(str(target), zarr_format=zarr_format) return self
[docs] def widen_and_append( self, path: str, append_dim: str = "timestamp", dim: str = "symbol", fill_values: Optional[Mapping[str, object]] = None, **kwargs, ) -> Self: """Reconcile every axis with the store at ``path``, then ``append``. The opt-in sibling of ``append`` for a panel that has grown. In order: the incoming labels the store lacks are appended at the end of its ``dim`` axis, sorted among themselves (``extend_symbol_axis``, which rewrites no stored chunk), and ``data`` is reindexed onto the stored axis followed by them; then any variable the store lacks is added with ``widen_data_vars``; then the unchanged ``append`` runs. A window carrying the store's labels in another order is only reindexed. Variables are widened after the symbol axis so the filler is built once at the final width. Calling this unconditionally is cheap: when both axes already agree it delegates straight to ``append``, and when no store exists yet it puts the ``dim`` axis in sorted order (``sort_symbols``) and creates the store with its chunk grid and the Zarr fill values of ``fill_values`` pinned (``_append_encoding``). The closing ``append`` is what keeps the compatibility checks in force. Because the widens commit before it runs, a window it then refuses (an overlapping ``append_dim`` range, say) can leave the store with the grown symbol axis and variable set while its append dimension and stored history are untouched. That is the accepted cost of inheriting the refusal rather than duplicating it. ``append_dim_size`` may ride in ``kwargs``. It is read, not consumed, so the same value reaches the symbol widen, the variable widen and the closing ``append``; those three cannot disagree on the chunk grid for what this call writes. Variables the store held from an earlier write keep whatever grid they were created with. Parameters ---------- path : str Directory of the Zarr store. append_dim : str, default "timestamp" The dimension the store grows along. dim : str, default "symbol" The symbol axis to reconcile. fill_values : Mapping[str, object], optional Per-variable fill of the cells a new symbol or a new variable adds (NaN when absent), passed to both widens, pinned as the Zarr fill value of a new store and used to reindex ``data``. **kwargs Passed through to ``append``. Examples -------- A store holding ``AAA`` and ``CCC``, a window carrying ``DDD``, ``BBB`` and a variable the store has not seen: >>> XrBackend().to_internal(window).widen_and_append( ... "prices.zarr", fill_values={"flag": False} ... ) XrBackend() >>> stored = xr.open_zarr("prices.zarr") >>> stored["symbol"].values.tolist(), sorted(stored.data_vars) (['AAA', 'CCC', 'BBB', 'DDD'], ['amount', 'close', 'flag', 'volume']) """ # `get`, not `pop`: the closing `append` also needs the key in # `kwargs` when it creates the store. append_dim_size = kwargs.get("append_dim_size") if not Path(path).exists(): self.sort_symbols(dim) kwargs.setdefault( "encoding", self._append_encoding( append_dim, append_dim_size=append_dim_size, dim=dim, fill_values=fill_values, zarr_format=kwargs.get("zarr_format"), ), ) return self.append(path, append_dim, **kwargs) _recover_interrupted_writes(Path(path)) # Labels keep their own type and `sort_symbol_axis` orders integers # numerically, so an integer axis is never sorted as strings. stored = xr.open_zarr(path) try: stored_labels = ( list(stored[dim].values.tolist()) if dim in stored.coords else [] ) stored_names = set(stored.data_vars) finally: stored.close() incoming = ( list(self.data[dim].values.tolist()) if dim in self.data.coords else [] ) # The filler must use the incoming dtype, or the closing `append` # refuses the window on a dtype mismatch. incoming_names = { str(name): variable.dtype for name, variable in self.data.data_vars.items() } known = set(stored_labels) added = sort_symbol_axis( {label for label in incoming if label not in known} ) axis = stored_labels + added unstored = [ name for name in incoming_names if name not in stored_names ] if not added and axis == incoming and not unstored: return self.append(path, append_dim, **kwargs) if added: self.extend_symbol_axis( path, added, dim=dim, fill_values=fill_values ) if axis != incoming: self.data = self.data.reindex( {dim: axis}, fill_value=dict(fill_values or {}) ) if unstored: self.widen_data_vars( path, incoming_names, append_dim=append_dim, fill_values=fill_values, append_dim_size=append_dim_size, ) return self.append(path, append_dim, **kwargs)
def _append_encoding( self, append_dim: str, data: Optional[xr.Dataset] = None, *, append_dim_size: Optional[int] = None, dim: str = "symbol", fill_values: Optional[Mapping[str, object]] = None, zarr_format: Optional[int] = None, ) -> dict: """Build the Zarr ``encoding`` that pins each variable's chunk shape. Along ``append_dim`` the chunk is ``min(APPEND_DIM_CHUNK, extent)``; along ``dim`` it is ``SYMBOL_DIM_CHUNK``, whatever the panel's width; along every other dimension it is the panel's full length. Variables that do not carry ``append_dim`` are left unencoded. Every creating write in this class routes through here so the chunk rule has one source. A variable on ``dim`` whose ``fill_values`` entry is a plain number or boolean of its own kind also gets that value as its Zarr fill value, so the cells ``extend_symbol_axis`` adds read as it without being written. Parameters ---------- append_dim : str The store's append dimension. data : xr.Dataset, optional The panel whose shape to encode; defaults to ``data``. The widen paths pass the widened panel so non-append dimensions are pinned at their widened length. append_dim_size : int, optional A lower bound on the store's total extent along ``append_dim``. The effective extent is ``max(panel length, append_dim_size)``, so a caller holding only a window of a larger store can raise the grid to what a whole-range write would have chosen, while a stated value narrower than the panel cannot shrink it. ``None`` uses the panel's own length. dim : str, default "symbol" The symbol axis, chunked at ``SYMBOL_DIM_CHUNK``. fill_values : Mapping[str, object], optional Per-variable fill of the cells a symbol extension adds. zarr_format : int, optional The format of the store written. Zarr v2 takes no ``fill_value`` encoding from xarray, so a v2 store gets none and ``extend_symbol_axis`` writes its fills. Returns ------- dict A dict mapping variable names to ``{"chunks": (...)}`` entries. """ panel = self.data if data is None else data fills = dict(fill_values or {}) encoding = {} for name, variable in panel.data_vars.items(): if append_dim not in variable.dims: continue chunks = [] for axis in variable.dims: if axis == append_dim and append_dim_size is not None: # A lower bound only: shrinking the grid would be # irreversible once a `mode="w"` rewrite pins it. size = max(int(panel.sizes[axis]), int(append_dim_size)) else: size = int(panel.sizes[axis]) if axis == append_dim: chunks.append(max(min(self.APPEND_DIM_CHUNK, size), 1)) elif axis == dim: chunks.append(self.SYMBOL_DIM_CHUNK) else: chunks.append(max(size, 1)) encoding[name] = {"chunks": tuple(chunks)} if dim in variable.dims and name in fills and zarr_format != 2: fill = _plain_fill(variable.dtype, fills[name]) if fill is not None: encoding[name]["fill_value"] = fill return encoding @staticmethod def _format_append_label(value) -> str: """Render one append-dimension label for an error message. A ``datetime64`` label reads as an ISO string; anything else falls back to ``str``, since ``append_dim`` need not be a timestamp. """ if np.issubdtype(np.asarray(value).dtype, np.datetime64): return pd.Timestamp(value).isoformat() return str(value) def _assert_append_compatible(self, path: str, append_dim: str) -> None: """Raise before an append that would silently corrupt the store. Four checks run against the store at ``path``, in this order: every shared non-append coordinate must match exactly; the incoming window must start strictly after the stored end of ``append_dim`` (a gap is fine, overlap is not); every shared variable must keep its dtype; and the data-variable sets must be equal, with the missing direction reported before the added one because it has no remedy short of recomputing. Raises ------ ValueError On the first check that fails. """ existing = xr.open_zarr(path) try: for dim in self.data.dims: if dim == append_dim or dim not in existing.dims: continue if dim not in self.data.coords or dim not in existing.coords: continue incoming = self.data[dim].values stored = existing[dim].values if len(incoming) != len(stored) or not (incoming == stored).all(): raise ValueError( f"XrBackend.append: refusing to append to {path} -- " f"the '{dim}' coordinate does not match the store " f"({len(incoming)} incoming label(s) vs " f"{len(stored)} stored). Zarr would overwrite the " f"stored labels without complaint, silently assigning " f"every previously written row to a different label. " f"Fix the '{dim}' axis over the whole range before the " f"first window, as " f"RawDataset.from_raw_data_chunked() does." ) # The append dimension itself; skipped when either side has no # coordinate on it or is empty, since then there is no order. if ( append_dim in self.data.coords and append_dim in existing.coords and self.data[append_dim].size and existing[append_dim].size ): # min/max rather than positional indexing, so an unsorted # axis on either side cannot fool the comparison. incoming_start = self.data[append_dim].values.min() stored_end = existing[append_dim].values.max() # `<=`: a window starting exactly on the stored end would # duplicate that label. if incoming_start <= stored_end: raise ValueError( f"XrBackend.append: refusing to append to {path} -- " f"the incoming '{append_dim}' window starts at " f"{self._format_append_label(incoming_start)} but the " f"store already ends at " f"{self._format_append_label(stored_end)}. Zarr would " f"extend the axis without complaint and leave " f"'{append_dim}' no longer strictly increasing -- " f"duplicate labels, out-of-order labels, or both -- " f"which breaks every downstream reader that assumes a " f"unique, ordered index. append() only extends a " f"store; to recompute a range it already holds, " f"replace the store with save(mode=\"w\") instead." ) for name, variable in self.data.data_vars.items(): if name not in existing.data_vars: continue if variable.dtype != existing[name].dtype: raise ValueError( f"XrBackend.append: refusing to append to {path} -- " f"variable '{name}' has dtype {variable.dtype} but " f"the store holds {existing[name].dtype}. Zarr would " f"cast silently, and a float NaN cast into an " f"integer store becomes 0: a fabricated observation " f"where the data was missing." ) # The variable set is checked after dtypes, so a dtype mismatch is # reported first. A missing variable is reported before an added # one because it has no widen remedy. incoming_names = set(self.data.data_vars) stored_names = set(existing.data_vars) absent = sorted(stored_names - incoming_names) if absent: raise ValueError( f"XrBackend.append: refusing to append to {path} -- the " f"store holds data variable(s) {absent} that the incoming " f"panel does not. Zarr extends exactly the variables it " f"is handed, so the absent one(s) would stay at their " f"stored length while every other variable grows, and " f"the store could no longer be opened (xarray reports " f"conflicting sizes for dimension '{append_dim}'). There " f"is no option to allow this: filling the absent variable " f"with NaN over the incoming window would make a complete " f"variable look as if recent values were genuinely " f"missing. Recompute this window over the store's full " f"variable set, or replace the store with " f"save(mode=\"w\")." ) unstored = sorted(incoming_names - stored_names) if unstored: raise ValueError( f"XrBackend.append: refusing to append to {path} -- the " f"incoming panel carries data variable(s) {unstored} that " f"the store does not hold. Zarr would write them over the " f"incoming window only, leaving them shorter along " f"'{append_dim}' than every stored variable, and the " f"store could no longer be opened (xarray reports " f"conflicting sizes for dimension '{append_dim}'). If the " f"panel really gained a column, first add the new " f"variable(s) over the store's existing history with " f"widen_data_vars(), or call widen_and_append(), which " f"does that while reconciling every axis." ) finally: existing.close()
[docs] def to_internal(self, data: xr.Dataset) -> Self: """Adopt an in-memory ``xarray.Dataset`` as ``data``. Parameters ---------- data : xr.Dataset The panel to hold. Examples -------- >>> backend = XrBackend().to_internal(panel) >>> backend.data is panel True """ 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 the label slice ``start_date..end_date``. Parameters ---------- col : str The date dimension to slice. start_date : str First label to keep, inclusive. end_date : str Last label to keep, inclusive. Examples -------- >>> backend.filter_by_date("timestamp", "2024-01-02", "2024-01-03") XrBackend() >>> backend.data["timestamp"].values.astype("datetime64[D]") array(['2024-01-02', '2024-01-03'], dtype='datetime64[D]') """ self.data = self.data.sel({col: slice(start_date, end_date)}) return self
[docs] def filter_by_symbol(self, col: str, symbols: tuple[str, ...]) -> Self: """Narrow ``data`` in place to the given labels on ``col``. Parameters ---------- col : str The dimension to select on. symbols : tuple[str, ...] The labels to keep. Examples -------- >>> backend.filter_by_symbol("symbol", ("BBB",)) XrBackend() >>> backend.data["symbol"].values.tolist() ['BBB'] """ self.data = self.data.sel({col: list(symbols)}) return self
[docs] def resample(self, labels: pd.Series, how: dict[str, str]) -> Self: """Aggregate ``data`` onto the bars ``labels`` assigns, in place. Every variable with a ``timestamp`` dimension is grouped by its label and reduced with its method from ``how``; the other dimensions are kept as they are. A variable without a ``timestamp`` dimension is carried over unchanged. ``count`` yields integers; the other methods keep pandas' skip-NaN semantics, so an all-NaN bar is NaN under ``first``, ``last``, ``max``, ``min`` and ``mean`` and 0 under ``sum``. Parameters ---------- labels : pd.Series Source timestamp to target timestamp; see ``DataBackend``. how : dict[str, str] Variable name to aggregation method, one entry per variable. Raises ------ ValueError If a held timestamp has no label, or a variable has no method. Examples -------- >>> labels = pd.Series(minutes.floor("D"), index=minutes) >>> backend.resample(labels, {"close": "last", "volume": "sum"}) >>> backend.data["timestamp"].values.astype("datetime64[D]").tolist() [datetime.date(2024, 1, 2), datetime.date(2024, 1, 3)] """ data = self.data source = pd.DatetimeIndex(data["timestamp"].values) target = labels.reindex(source) if target.isna().any(): first = source[target.isna().values][0] raise ValueError( f"XrBackend.resample: timestamp {first} has no label." ) missing = [name for name in data.data_vars if name not in how] if missing: raise ValueError( f"XrBackend.resample: no method for {missing}." ) groups = pd.DatetimeIndex(target.values) new_timestamps = pd.DatetimeIndex(groups.unique()).sort_values() variables = {} for name, variable in data.data_vars.items(): if "timestamp" not in variable.dims: variables[name] = variable continue ordered = variable.transpose("timestamp", ...) other_dims = ordered.dims[1:] other_shape = ordered.shape[1:] frame = pd.DataFrame( ordered.values.reshape(len(source), -1), index=source ) reduced = frame.groupby(groups, sort=True).agg(how[name]) reduced = reduced.reindex(new_timestamps) values = reduced.to_numpy().reshape( (len(new_timestamps),) + other_shape ) coords = { dim: data[dim] for dim in other_dims if dim in data.coords } variables[name] = xr.DataArray( values, dims=("timestamp",) + other_dims, coords=coords, attrs=variable.attrs, ) coords = { name: coord for name, coord in data.coords.items() if "timestamp" not in coord.dims } coords["timestamp"] = new_timestamps.values.astype("datetime64[ns]") self.data = xr.Dataset(variables, coords=coords, attrs=data.attrs) return self
[docs] def get_xarray_dataset( self, indexes: Optional[list[str]] = None ) -> xr.Dataset: """Return ``data`` indexed by exactly the dimensions in ``indexes``. ``indexes`` names the result's dimensions, in order: data variables laid out on any other dimension are dropped, dimensions no longer used are dropped with their coordinates, and the survivors are transposed onto the requested order. This is the same meaning ``PlBackend.get_xarray_dataset`` gives the argument. ``None`` returns ``data`` itself, not a copy, since callers rely on receiving the object the backend holds. ``data`` is never modified here; the ``filter_by_*`` methods are the in-place ones. Parameters ---------- indexes : list[str], optional The dimensions to index by, or ``None`` for no shape request. Raises ------ ValueError If a requested name is not a dimension of ``data``. Examples -------- >>> ds = backend.get_xarray_dataset(["timestamp", "symbol"]) >>> tuple(ds.dims) ('timestamp', 'symbol') >>> backend.get_xarray_dataset(["symbol", "timestamp"])["close"].dims ('symbol', 'timestamp') >>> backend.get_xarray_dataset() is backend.data True """ if indexes is None: return self.data data = self.data missing = [name for name in indexes if name not in data.dims] if missing: raise ValueError( f"XrBackend.get_xarray_dataset: requested index(es) " f"{missing} are not dimensions of this dataset. Present " f"dimensions: {tuple(data.dims)}." ) dropped_vars = [ name for name, variable in data.data_vars.items() if not set(variable.dims) <= set(indexes) ] result = data.drop_vars(dropped_vars) unused_dims = [dim for dim in result.dims if dim not in indexes] if unused_dims: result = result.drop_dims(unused_dims) return result.transpose(*indexes, ...)
[docs] def get_lazyframe(self) -> pl.LazyFrame: """Return ``data`` as a long-format ``polars.LazyFrame``. Examples -------- >>> backend.get_lazyframe().collect().columns ['timestamp', 'symbol', 'close'] """ data = self.data.to_dataframe().reset_index() return pl.from_pandas(data).lazy()
[docs] def head(self, path: str, n: int) -> pl.LazyFrame: """Return at most ``n`` rows of the store at ``path`` as a lazy frame. Parameters ---------- path : str Directory of the Zarr store. n : int Maximum number of rows (and of labels per dimension) to read. The store is opened by path with the same opener ``read`` uses, and every dimension is sliced to ``n`` before conversion, so the whole store is never materialised. ``data`` is neither read nor written: a probe must not observe or inherit the in-place narrowing that ``filter_by_date`` leaves behind. Raises ------ FileNotFoundError If ``path`` does not exist. Examples -------- >>> XrBackend().head("prices.zarr", 2).collect().shape (2, 3) """ if not Path(path).exists(): raise FileNotFoundError(f"File {path} does not exist.") opened = xr.open_dataset(path) try: bounded = opened.isel({dim: slice(0, n) for dim in opened.dims}) frame = bounded.to_dataframe().reset_index() finally: opened.close() return pl.from_pandas(frame).lazy().head(n)
#: Suffixes of the two sidecars ``rechunk_index_coordinates`` keeps beside #: the store while it swaps one coordinate array: the rewritten array before #: it becomes authoritative, and the original array from the first rename #: until the swap is complete (consolidated metadata included). _RECHUNK_SUFFIX = ".rechunk.tmp" _REPLACED_SUFFIX = ".replaced.tmp" #: Metadata documents of one array: Zarr v3 keeps one, Zarr v2 two. _ARRAY_METADATA_FILES = ("zarr.json", ".zarray", ".zattrs") def _dimension_names(array) -> Optional[list[str]]: """Return the dimension names of a Zarr v2 or v3 array, if recorded.""" if array.metadata.zarr_format == 3: dims = array.metadata.dimension_names else: dims = array.attrs.get("_ARRAY_DIMENSIONS") return None if dims is None else [str(dim) for dim in dims] def _index_coordinate_names(group) -> list[str]: """Return the names of the 1-D arrays that index their own dimension.""" names = [] for name, array in group.arrays(): if array.ndim == 1 and _dimension_names(array) == [str(name)]: names.append(str(name)) return sorted(names) def _rechunk_sidecars(store: Path, name: str) -> tuple[Path, Path]: """Return the staging and replaced sidecars of coordinate ``name``.""" return ( Path(f"{store}.{name}{_RECHUNK_SUFFIX}"), Path(f"{store}.{name}{_REPLACED_SUFFIX}"), ) def _is_consolidated(store: Path, zarr_format: int) -> bool: """Return whether the store at ``store`` carries consolidated metadata.""" if zarr_format == 3: root = json.loads((store / "zarr.json").read_text()) return root.get("consolidated_metadata") is not None return (store / ".zmetadata").exists() def _recover_index_coordinate_swap(store: Path) -> list[str]: """Finish or undo a ``rechunk_index_coordinates`` swap a crash cut short. The swap of one coordinate renames the original array aside to its ``.replaced.tmp`` sidecar, renames the rewritten ``.rechunk.tmp`` sidecar into the store, rewrites consolidated metadata, and only then removes the ``.replaced.tmp`` sidecar. The sidecars left on disk tell deterministically how far it got, coordinate by coordinate: - only ``.rechunk.tmp``: killed before the swap; the store is intact and the sidecar, never authoritative, is removed. - ``.replaced.tmp`` and no array in the store: killed between the two renames; the original array is renamed back (rolled back) and the rewritten sidecar removed, so the store is exactly as it was before. - ``.replaced.tmp`` and an array in the store: the swap completed (the rename is atomic and the rewritten array was verified before it); consolidated metadata is rewritten, then the original removed. Every coordinate is a dimension of some array of the store, so the candidates are read from the dimension names the remaining arrays record, never guessed from file names. Called on entry by ``rechunk_index_coordinates`` and by every ``XrBackend`` method that writes to an existing store. Not safe against a swap running concurrently in another process. Returns ------- list[str] Names of the coordinates whose swap residue was resolved, sorted. """ import zarr group = zarr.open_group(str(store), mode="r", use_consolidated=False) candidates = set(group.array_keys()) for _, array in group.arrays(): candidates.update(_dimension_names(array) or []) resolved, completed = [], [] for name in sorted(candidates): staging, replaced = _rechunk_sidecars(store, name) if not staging.exists() and not replaced.exists(): continue resolved.append(name) if replaced.exists() and not (store / name).exists(): logger.warning( f"rechunk_index_coordinates: a previous rewrite of '{name}' " f"in {store} was killed between its two renames; rolling the " f"original array back from {replaced}." ) os.replace(replaced, store / name) elif replaced.exists(): completed.append(replaced) shutil.rmtree(staging, ignore_errors=True) if completed: zarr_format = group.metadata.zarr_format if _is_consolidated(store, zarr_format): zarr.consolidate_metadata(str(store), zarr_format=zarr_format) for replaced in completed: shutil.rmtree(replaced) return resolved def _coordinate_document(document: dict, length: int, values=None) -> dict: """Return an array metadata document of one chunk of ``length``. The shape is set to ``length`` too. With ``values``, a fixed-width text array is widened to hold the longest of them. """ document = dict(document) if "shape" in document: document["shape"] = [length] width = ( max((len(str(value)) for value in values), default=0) if values is not None else 0 ) data_type = document.get("data_type") if ( isinstance(data_type, dict) and data_type.get("name") == "fixed_length_utf32" ): held = int(data_type["configuration"]["length_bytes"]) // 4 if width > held: document["data_type"] = { **data_type, "configuration": { **data_type["configuration"], "length_bytes": 4 * width, }, } dtype = document.get("dtype") if isinstance(dtype, str) and dtype[1:2] == "U" and width > int(dtype[2:]): document["dtype"] = f"{dtype[:2]}{width}" if document.get("zarr_format") == 3: grid = dict(document["chunk_grid"]) if grid.get("name") != "regular": raise ValueError( f"rechunk_index_coordinates: unsupported chunk grid {grid}" ) grid["configuration"] = { **grid["configuration"], "chunk_shape": [length], } document["chunk_grid"] = grid else: document["chunks"] = [length] return document
[docs] def rechunk_index_coordinates(path: "str | os.PathLike") -> list[str]: """Rewrite each 1-D index coordinate of a store in one chunk, in place. Zarr fixes an array's chunk grid when the array is created, and xarray writes a dimension coordinate as one chunk of the creating write's length. A store created from a short window and grown by appends therefore holds ``timestamp`` in many small chunks (one per bar when the first window was one bar), and every open of the store reads each of them. This function rewrites every such coordinate (a 1-D array whose only dimension is its own name, such as ``timestamp`` or ``symbol``) as one chunk spanning the whole axis. ``XrBackend.append`` calls it after every append, and it is the maintenance step for a store written before that. Only the chunk grid changes. The stored values are copied as they are encoded on disk (the int64 ticks of a datetime coordinate, the text of a string one), so no value, dtype, codec, attribute or data variable is touched and a data fingerprint of the store is unchanged. A store with no fragmented coordinate is not written at all. Each rewritten array is built and read back in a ``.rechunk.tmp`` sidecar beside the store, then swapped in by two renames: the original array to a ``.replaced.tmp`` sidecar, the rewritten one into its place. Consolidated metadata, when the store has it, is rewritten after every swap, and only then are the originals removed. A process killed at any point leaves sidecars that tell how far it got; the next call of this function, or of any ``XrBackend`` method that writes to the store, resolves them first: before the first rename the store is intact and the sidecar is dropped, between the renames the original array is renamed back, after them the swap is finished. Nothing beside the store is ever needed to read it, except in two windows a reader can fall into: between the two renames the store has no array for that coordinate, and between the swap and the consolidation its consolidated metadata records the old chunk grid. Both last microseconds to milliseconds when the process runs to the end; after a kill inside one, the store stays unreadable until the next writing call recovers it. Do not run this while another process reads or writes the store. Parameters ---------- path : str or os.PathLike Directory of the Zarr store (format 2 or 3). Returns ------- list[str] Names of the coordinates rewritten, sorted; empty when every index coordinate was already one chunk. Raises ------ FileNotFoundError If ``path`` does not exist. RuntimeError If a rewritten coordinate does not read back equal to the original; the store is then left as it was. Examples -------- A store created from a one-bar window and grown by an append holds ``timestamp`` in one-element chunks: >>> import tempfile >>> from pathlib import Path >>> import numpy as np >>> import pandas as pd >>> import xarray as xr >>> import zarr >>> from quantlab.backend.zarr import rechunk_index_coordinates >>> panel = xr.Dataset( ... {"close": (("timestamp", "symbol"), np.ones((3, 2)))}, ... coords={"timestamp": pd.date_range("2024-01-02", periods=3), ... "symbol": ["AAA", "BBB"]}, ... ) >>> path = Path(tempfile.mkdtemp()) / "prices.zarr" >>> _ = panel.isel(timestamp=[0]).to_zarr(path, mode="w") >>> _ = panel.isel(timestamp=[1, 2]).to_zarr( ... path, mode="a", append_dim="timestamp" ... ) >>> zarr.open_group(path, mode="r")["timestamp"].chunks (1,) >>> rechunk_index_coordinates(path) ['timestamp'] >>> zarr.open_group(path, mode="r")["timestamp"].chunks (3,) >>> xr.open_zarr(path)["timestamp"].dt.day.values.tolist() [2, 3, 4] >>> rechunk_index_coordinates(path) [] """ import zarr store = Path(path) if not store.exists(): raise FileNotFoundError(f"File {path} does not exist.") _recover_index_coordinate_swap(store) group = zarr.open_group(str(store), mode="r", use_consolidated=False) zarr_format = group.metadata.zarr_format consolidated = _is_consolidated(store, zarr_format) rewritten = [] for name in _index_coordinate_names(group): array = group[name] length = int(array.shape[0]) if length == 0 or tuple(array.chunks) == (length,): continue _stage_index_coordinate(store, name, array[...]) rewritten.append(name) if rewritten and consolidated: zarr.consolidate_metadata(str(store), zarr_format=zarr_format) for name in rewritten: shutil.rmtree(_rechunk_sidecars(store, name)[1]) return rewritten
def _stage_index_coordinate(store: Path, name: str, values) -> None: """Swap index coordinate ``name`` of ``store`` for ``values`` in one chunk. The new array is built and read back in the ``.rechunk.tmp`` sidecar, then swapped in by two renames, the original going to the ``.replaced.tmp`` sidecar. The caller consolidates the metadata and then removes that sidecar; until then a kill leaves residue ``_recover_index_coordinate_swap`` resolves. ``values`` may be longer than the stored array (``XrBackend.extend_symbol_axis``); a fixed-width text array is widened to its longest label. Raises ------ RuntimeError If the new array does not read back equal to ``values``; the store is then left as it was. """ import zarr length = len(values) source = store / name staging, replaced = _rechunk_sidecars(store, name) try: staging.mkdir(parents=True) for document in _ARRAY_METADATA_FILES: if not (source / document).exists(): continue content = json.loads((source / document).read_text()) if document != ".zattrs": content = _coordinate_document(content, length, values) (staging / document).write_text(json.dumps(content, indent=2)) target = zarr.open_array(str(staging), mode="r+") target[...] = np.asarray( values, dtype=None if target.dtype.kind in "OT" else target.dtype ) check = zarr.open_array(str(staging), mode="r") if tuple(check.chunks) != (length,) or not np.array_equal( np.asarray(check[...], dtype=object), np.asarray(values, dtype=object), ): raise RuntimeError( f"XrBackend: rewriting coordinate '{name}' of {store} did not " f"read back equal; the store is unchanged." ) except BaseException: shutil.rmtree(staging, ignore_errors=True) raise # From here a kill leaves residue `_recover_index_coordinate_swap` # resolves on the next call; the original stays on disk until the # consolidated metadata describes the rewritten array. os.replace(source, replaced) os.replace(staging, source) def _swap_index_coordinate(store: Path, name: str, values) -> None: """Swap index coordinate ``name`` for ``values`` and finish the swap. ``_stage_index_coordinate``, then consolidation (when the store has it), then removal of the original. """ import zarr group = zarr.open_group(str(store), mode="r", use_consolidated=False) zarr_format = group.metadata.zarr_format consolidated = _is_consolidated(store, zarr_format) _stage_index_coordinate(store, name, values) if consolidated: zarr.consolidate_metadata(str(store), zarr_format=zarr_format) shutil.rmtree(_rechunk_sidecars(store, name)[1]) def _recover_interrupted_writes(store: Path) -> None: """Settle what a killed writer left in ``store``, before writing to it. A coordinate swap first (``_recover_index_coordinate_swap``), then an extension that never committed (``_recover_axis_extension``). Called on entry by every ``XrBackend`` method that writes to an existing store. """ _recover_index_coordinate_swap(store) _recover_axis_extension(store) def _recover_axis_extension(store: Path) -> list[str]: """Cut back arrays an ``extend_symbol_axis`` or a ``shorten_axis`` killed midway left too long. An extension resizes every array on the axis first and swaps the coordinate last; a shortening cuts the coordinate first and the arrays after. Either way an array longer along a dimension than that dimension's index coordinate belongs to a write that the coordinate says is not there. It is resized back to the coordinate's length. Run after ``_recover_index_coordinate_swap``, which settles the coordinate itself. Returns ------- list[str] Names of the arrays cut back, sorted. """ import zarr group = zarr.open_group(str(store), mode="r", use_consolidated=False) lengths = { name: int(group[name].shape[0]) for name in _index_coordinate_names(group) } cut = [] for name, array in group.arrays(): dims = _dimension_names(array) or [] shape = list(array.shape) for axis, dim in enumerate(dims): if dim != name and dim in lengths and shape[axis] > lengths[dim]: shape[axis] = lengths[dim] if tuple(shape) != tuple(array.shape): logger.warning( f"XrBackend: '{name}' of {store} is longer than its axis, left " f"by an axis extension or shortening killed midway; cutting it back " f"to {tuple(shape)}." ) zarr.open_array(str(store / name), mode="r+").resize(tuple(shape)) cut.append(str(name)) if cut: zarr_format = group.metadata.zarr_format if _is_consolidated(store, zarr_format): zarr.consolidate_metadata(str(store), zarr_format=zarr_format) return sorted(cut) def _write_extension_fill(array, axis, old, fill, template, max_bytes) -> None: """Write ``fill`` into the cells past ``old`` along ``axis`` of ``array``. The fill is encoded the way xarray encodes ``template``'s variable (a datetime's units, a mask value), and written in blocks along the array's first other axis, each block under ``max_bytes`` (``XrBackend.MAX_WIDEN_BYTES``). """ shape = list(array.shape) shape[axis] -= old if not all(shape): return other = next((index for index in range(array.ndim) if index != axis), None) rows = shape[other] if other is not None else 1 row_bytes = int(np.prod(shape)) // rows * template.dtype.itemsize step = max(max_bytes // max(row_bytes, 1), 1) for low in range(0, rows, step): region: list = [slice(None)] * array.ndim region[axis] = slice(old, None) block = list(shape) if other is not None: region[other] = slice(low, low + step) block[other] = min(step, rows - low) decoded = xr.Variable( template.dims, np.full(block, fill, dtype=template.dtype), attrs=template.attrs, encoding=template.encoding, ) encoded = xr.conventions.encode_cf_variable(decoded) array[tuple(region)] = np.asarray(encoded.values, dtype=array.dtype) def _zarr_format(store: Path) -> int: """Return the Zarr format (2 or 3) of the store at ``store``.""" import zarr group = zarr.open_group(str(store), mode="r", use_consolidated=False) return int(group.metadata.zarr_format) def _plain_fill(dtype, fill) -> Optional[object]: """Return ``fill`` as a Zarr fill value of ``dtype``, or None. Only a plain number or boolean of the variable's own kind qualifies (a float, integer or boolean variable stored as such), since anything else is encoded by xarray and its Zarr fill would be the encoded value. """ dtype = np.dtype(dtype) if isinstance(fill, (bool, np.bool_)): return bool(fill) if dtype.kind == "b" else None if dtype.kind == "f" and isinstance(fill, (int, float, np.integer, np.floating)): return float(fill) if dtype.kind in "iu" and isinstance(fill, (int, np.integer)): return int(fill) return None def _reads_as(array, dtype, fill) -> bool: """Return whether the cells Zarr pads ``array`` with decode to ``fill``. True only for a variable stored as itself (the same numeric or boolean dtype in memory and on disk, so no encoding stands between them) whose Zarr fill value equals ``fill``, NaN equal to NaN. """ dtype = np.dtype(dtype) if dtype.kind not in "fiub" or np.dtype(array.dtype) != dtype: return False stored = array.fill_value if stored is None: return False try: if dtype.kind == "f" and np.isnan(fill): return bool(np.isnan(stored)) return bool(stored == fill) except TypeError: return False