Source code for quantlab.dataset.nbbo

"""NBBO quote-bar panel built from raw WRDS TAQ quote files.

The NBBO (National Best Bid and Offer) is the best bid and the best ask
price for a US stock across all exchanges at each moment. TAQ (Trade and
Quote) is NYSE's tick-level database of every trade and quote, sold through
WRDS (Wharton Research Data Services). A *panel* is an ``xarray.Dataset``
indexed by ``timestamp`` and ``symbol``, the format every quantlab layer
exchanges.

``NbboPanelDataset`` reads the tick-level raw files that the WRDS TAQ
download writes (``.../wrds/nbbo/date=.../symbol=.../*.pqt``),
resamples each trading session onto a regular bar grid with
``quantlab.dataset.nbbo.resample``, and returns the panel the rest of the
pipeline stores and uses. Bars are *right-closed*: a bar labelled 09:31
covers quotes after 09:30 up to and including 09:31. The bar size is
``NbboDatasetConfig.bar_interval``, while the raw data's ``frequency`` stays
``"tick"``. Session opens and closes come from the NYSE (XNYS) exchange
calendar, which handles half days, daylight-saving changes and non-trading
days.

The panel's ``symbol`` axis is the integer PERMNO, CRSP's permanent security
id, the same axis the CRSP daily panels use. TAQ itself knows only tickers:
the raw files are keyed by the ticker a security traded under on that day,
and the conversion maps each raw ``(date, ticker)`` to its PERMNO through
the CRSP symbology (``quantlab.dataset.crsp.symbology``) read from
``NbboDatasetConfig.reference_dir``. A renamed security (FB, then META) is
therefore one column, and a ticker reused by two securities over time is
two. A raw ticker that no PERMNO used on that date is dropped, logged and
recorded in the filter-stats sidecar. The conversion also writes the same
ticker sidecar as the CRSP conversion (``<store>.crsp_tickers.json``), which
``quantlab.dataset.crsp.tickers.CrspTickerLookup`` reads.

Everything but turning tick records into bars is shared with any NBBO bar
panel and lives in ``quantlab.dataset.nbbo.panel.NbboPanelBase``.
"""

from __future__ import annotations

import polars as pl

from quantlab.dataset.config import NbboDatasetConfig
from quantlab.dataset.nbbo.panel import NbboPanelBase
from quantlab.dataset.nbbo.resample import NbboResampler
from quantlab.enums.data import BAR_INTERVAL_SECONDS


[docs] class NbboPanelDataset(NbboPanelBase): """Dense NBBO bar panel resampled from WRDS TAQ ``complete_nbbo`` records. It inherits from ``NbboPanelBase`` everything but the bars themselves: the session calendar, the PERMNO axis and its symbology, the sidecars, densifying and cleaning. Its ``_read_bars`` reads one window's tick records and resamples them with ``NbboResampler``. The ``symbol`` axis is the int64 PERMNO, built as ``NbboPanelBase`` describes. Each record's raw ticker is replaced by its PERMNO before resampling, so the resampler groups quotes by security rather than by name. The session window (``session_start``/``session_end``, US Eastern clock time) defaults to regular hours, 09:30 to 16:00, and may be set anywhere inside 04:00 to 20:00; an edge outside that range is refused when the dataset is created. On a half day only regular-hours edges are clipped to the early close, so an extended-hours window still runs to its clock end, and its bars after the close hold after-close quotes. A ``date=`` directory that is not a trading session makes the conversion fail with ``ValueError``. Every window starts from the last valid quote at or before its start. The raw files are read by session date and hold the whole day, so the quote in force when the window opens is always available. Bar labels can fall outside the session date's UTC calendar day: an extended close lands on the next one. The config's filter fields (``drop_crossed``, ``drop_locked``, ``drop_nonpositive_price``, ``keep_qu_cond``) are passed to the resampler as an ``NbboFilterPolicy``, and the number of quotes dropped per session is written to a JSON sidecar next to the store (see ``filter_stats_path``). Convert bars shorter than a minute with ``granularity="day"``: one-second bars over a large universe are millions of rows per session, and one window is held in memory at a time. Parameters ---------- dataset_config : NbboDatasetConfig Must have ``frequency="tick"``, a ``bar_interval`` from ``BAR_INTERVAL_SECONDS`` and a ``reference_dir`` holding the CRSP reference tables; also sets the session window, the quote filters and optionally ``permnos``. Attributes ---------- last_filter_stats : dict or None The filter-stats sidecar content after the last window resampled. Examples -------- Needs a raw NBBO tier on disk under the configured vendor root and the CRSP reference tables in ``reference_dir``: >>> config = NbboDatasetConfig( ... raw_data_dir_path="downloads/us_equity/tick/wrds_taq/wrds", ... zarr_file_path="data/us_equity/tick/wrds_nbbo_1m.zarr", ... reference_dir="downloads/_reference", ... start_date="2024-01-24", ... end_date="2024-01-25", ... bar_interval="1m", ... ) >>> NbboPanelDataset(config).from_raw_data_chunked(granularity="day") >>> panel = NbboPanelDataset(config).panel("2024-01-24", "2024-01-25") >>> panel["bid"].dims ('timestamp', 'symbol') >>> panel.symbol.values.tolist() [14593, 83443] """ #: The data type whose directory under the vendor root holds the raw files. DATA_TYPE = "nbbo" def _check_bar_size(self, config: NbboDatasetConfig) -> None: """Refuse a frequency other than ``"tick"`` or an unknown ``bar_interval``. Parameters ---------- config : NbboDatasetConfig The config being normalised. Raises ------ ValueError If ``frequency`` is not ``"tick"`` or ``bar_interval`` is not in ``BAR_INTERVAL_SECONDS``. Examples -------- >>> NbboPanelDataset(replace(config, frequency="1m")) Traceback (most recent call last): ... ValueError: NbboPanelDataset: frequency must be 'tick' ... """ if config.frequency != "tick": raise ValueError( f"{self.class_name}: frequency must be 'tick' (the raw data " f"holds one row per NBBO record); the panel's bar size is " f"bar_interval. Got frequency {config.frequency!r}." ) if config.bar_interval not in BAR_INTERVAL_SECONDS: raise ValueError( f"{self.class_name}: bar_interval {config.bar_interval!r} is " f"not one of {list(BAR_INTERVAL_SECONDS)}." ) @property def _bar_interval(self) -> str: """Return ``config.bar_interval``; the raw data itself is ticks.""" return self.config.bar_interval @property def _resampler(self) -> NbboResampler: """Return a new resampler for the configured bar size and quote filters.""" return NbboResampler(self._bar_interval, self._filter_policy) def _read_bars( self, resolved: pl.DataFrame, sessions: pl.DataFrame ) -> tuple[pl.DataFrame | None, pl.DataFrame | None]: """Read the window's tick records and resample them onto bars. The raw files are filtered on the ``date`` hive key, never on a timestamp window, so the last quote before the open (which seeds the first bar) is kept. Only the tickers in ``resolved`` are read; each record's ticker is then replaced by its PERMNO before resampling, so a rename inside the window lands in one column. Parameters ---------- resolved : pl.DataFrame Columns ``date``, ``symbol`` (the raw ticker) and ``permno``. sessions : pl.DataFrame Columns ``date``, ``open`` and ``close`` of the window's sessions. Returns ------- bars : pl.DataFrame or None The resampler's bars, or ``None`` when no record was read. stats : pl.DataFrame or None The resampler's drop counts, or ``None`` when no record was read. """ dates = resolved.get_column("date").unique().to_list() tickers = resolved.get_column("symbol").unique().to_list() scan = pl.scan_parquet( str(self._scan_root() / "**" / f"*{self.RAW_SHARD_SUFFIX}"), hive_partitioning=True, hive_schema=self._scanned_hive_schema(), ).filter( pl.col("date").is_in(dates), pl.col("symbol").is_in(tickers), ) scan = self._assert_single_vendor_and_drop(scan) # The inner join keeps a record only where its (date, ticker) # resolved to a PERMNO on the axis, then the PERMNO replaces the # ticker as the record's `symbol`, so the resampler groups by # security. The resampler casts `symbol` to String itself; the digit # string is what its stats carry back. records = ( scan.collect() .join(resolved, on=["date", "symbol"], how="inner") .drop("symbol") .rename({"permno": "symbol"}) .with_columns(pl.col("symbol").cast(pl.String)) ) if not records.height: return None, None return self._resampler.resample_with_stats(records, sessions)