Source code for quantlab.dataset.massive.trade_bars

"""The Trade bar panel built from Massive's daily trade files, on the Massive ticker axis.

``MassiveTradeBarDataset`` reads the raw tier the Massive client writes
(``quantlab.dataset.massive.raw``) one trading day at a time, resamples the
trades with ``quantlab.dataset.massive.resample.TradeBarResampler``, and
returns the dense ``(timestamp, symbol)`` panel the store holds (ADRs 0030,
0031).

- **Sessions** are the XNYS calendar's, half days included; the window is
  ``session_start`` to ``session_end``, the regular session by default. A
  raw day that is not a session is refused.
- **The symbol axis is the Massive ticker**, exactly as the trade files
  spell it (``BRK.A``, ``AApB``), every ticker kept, exchange test symbols
  included (ADR 0031). Conversion reads no Sharadar data. A column is a
  ticker, not a security: one ticker can belong to different securities on
  different days, so reading the store as securities goes through the
  Ticker map (``quantlab.dataset.massive.ticker_map``). ``tickers``
  restricts a conversion to a roster of Massive tickers.
- **A cell without an eligible trade** has NaN prices and zero volumes and
  ``n_trades``; a ticker on the axis that did not trade on a day is
  zero volume on every bar of it.
- **Per-day statistics** (trades in, dropped by each rule, outside the
  session, excluded from volume, outside the roster) are
  written to the JSON sidecar ``<store>.massive_stats.json``
  (``stats_path``), merged day by day.
- **Each one-minute day is checked** against Massive's own minute
  aggregates of that day when the raw tier holds them
  (``quantlab.dataset.massive.vendor_check``), ticker by ticker: agreement counts, the worst
  difference of each variable and the bars present on one side only go to
  the day's ``vendor_check`` statistics. Differences are reported, never
  raised.
- **The store grows a day at a time.** A config whose range holds the
  next day, passed to ``update(granularity="day")``, appends that day: the
  day's new tickers are appended at the end of the store's axis in place
  (ADR 0032), with zero volume and NaN prices over the days before, so a
  backfill and a daily update are the same operation. Raw trade files are
  kept (ADR 0031).
- **The settings are the store's identity.** The bar interval, the session
  window, the roster and the counting rules (``_build_settings``) are
  recorded in the sidecar and in every read's data fingerprint; a
  conversion into a store recorded with other settings is refused before
  anything is written. Each bar interval has its own store
  (``trade_bar_store_name``).
- **Coarser bars come from Resample.** Each variable declares its
  aggregation (``DEFAULT_RESAMPLE_HOW``) and bars are cut per session, right
  closed and labelled at their end, so ``resample("5m")`` of the one-minute
  store equals converting the trades at 5m. ``"1d"`` gives one bar per
  session, labelled with its date.

Convert one day per window (``granularity="day"``): a day of the whole
market is tens of millions of trades.

- **A day can be converted elsewhere.** ``convert_day`` reads and
  resamples one day without touching the store, and returns a picklable
  ``ConvertedTradeDay``; ``add_converted_day`` hands it to the instance that
  appends, which then does not read the day again. A backfill converts many
  days in worker processes this way and appends them in date order.

Examples
--------
Needs a trade file and a condition table under ``raw_data_dir_path``::

    config = MassiveTradeBarsDatasetConfig(
        zarr_file_path="/data/zarrs/massive_trade_bars_1m.zarr",
        raw_data_dir_path="/data/downloads/massive",
        start_date="2024-11-29",
        end_date="2024-11-29",
    )
    MassiveTradeBarDataset(config).from_raw_data_chunked(granularity="day")
"""

from __future__ import annotations

import dataclasses
import json
from dataclasses import dataclass
from datetime import date
from pathlib import Path

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

from quantlab.dataset._support.session_calendar import XnysSessionCalendar
from quantlab.dataset.config import DatasetConfig, MassiveTradeBarsDatasetConfig
from quantlab.dataset.massive.raw import (
    latest_conditions,
    raw_days,
    raw_file,
    read_aggregates,
    read_conditions,
    read_trades,
)
from quantlab.dataset.massive.resample import (
    CLOSE_AUCTION_CUTOFF_NS,
    CLOSING_PRINTS,
    KEPT_CORRECTIONS,
    OPENING_TRADE,
    ROUND_LOT,
    TRADE_BAR_SUMS,
    TRADE_BAR_VARIABLES,
    TRADE_STATS_COUNTS,
    TradeBarResampler,
)
from quantlab.dataset.massive.vendor_check import (
    NO_MINUTE_AGGREGATES,
    NOT_ONE_MINUTE,
    check_minute_bars,
)
from quantlab.dataset.stock import StockDataset
from quantlab.utils.atomic import write_json_atomically
from quantlab.utils.resample import session_labels
from quantlab.utils.symbol_axis import sort_symbol_axis
from quantlab.utils.timer import Timer

#: The end of the regular session, US/Eastern; the only window end with a closing auction.
REGULAR_SESSION_END = "16:00"

#: Appended to the store path to name the per-day statistics sidecar.
STATS_SUFFIX = ".massive_stats.json"

#: How the trades are counted, recorded with every store: a store built under
#: other rules is never appended to.
COUNTING_RULES = {
    "time": "sip_timestamp",
    "bars": "right-closed, labelled at their end, the last cut at the close",
    "update_rules": "consolidated, every condition must allow",
    "kept_corrections": list(KEPT_CORRECTIONS),
    "round_lot": ROUND_LOT,
    "tick_reference": "previous volume-eligible trade of the session",
    "open_auction_condition": OPENING_TRADE,
    "close_auction_condition": CLOSING_PRINTS,
    "close_auction_cutoff_seconds": CLOSE_AUCTION_CUTOFF_NS // 1_000_000_000,
}

#: Each Trade bar variable's aggregation onto coarser bars.
TRADE_BAR_RESAMPLE_HOW = {
    "open": "first",
    "high": "max",
    "low": "min",
    "close": "last",
    "open_auction_price": "first",
    "close_auction_price": "last",
    **{name: "sum" for name in TRADE_BAR_SUMS},
}


[docs] def no_trade_fills() -> dict[str, float]: """Return the values of a Trade bar column without a trade: 0 volumes and counts. Prices are not named: they stay NaN. A column a store adds over its earlier bars, and a permaticker without a ticker on a day, read so. Examples -------- >>> sorted(no_trade_fills())[:3] ['buy_volume', 'close_auction_volume', 'dollar_volume'] """ return {name: 0.0 for name in TRADE_BAR_SUMS}
[docs] def trade_bar_store_name(bar_interval: str) -> str: """Return the name of the Trade bar store of one bar interval. Parameters ---------- bar_interval : str A ``BarInterval`` token. Returns ------- str ``massive_trade_bars_<bar_interval>.zarr``. Examples -------- >>> trade_bar_store_name("1s") 'massive_trade_bars_1s.zarr' """ return f"massive_trade_bars_{bar_interval}.zarr"
[docs] @dataclass(frozen=True) class ConvertedTradeDay: """One converted trading day: its bars on the Massive ticker axis and its statistics. Returned by ``MassiveTradeBarDataset.convert_day``; picklable, so a worker process can convert a day and send it to the process that appends it. Attributes ---------- day : date The trading day. bars : pl.DataFrame The day's bars, ``symbol`` being the Massive ticker. stats : dict The day's statistics, as recorded in the sidecar. """ day: date bars: pl.DataFrame stats: dict
[docs] class MassiveTradeBarDataset(StockDataset): """Dense Trade bar panel on the Massive ticker axis; see the module docstring. Parameters ---------- dataset_config : MassiveTradeBarsDatasetConfig The raw tier, the store, the date range, the bar size, the session window and the roster. Attributes ---------- last_stats : dict or None The statistics sidecar content after the last day converted. Examples -------- >>> ds = MassiveTradeBarDataset(config).from_raw_data_chunked(granularity="day") >>> panel = ds.panel("2024-11-29", "2024-11-30") >>> list(panel.data_vars)[:5] ['open', 'high', 'low', 'close', 'volume'] """ #: The config class used to rebuild the dataset from a saved ``config.json``. config_cls = MassiveTradeBarsDatasetConfig #: The data type the panel is built from. DATA_TYPE = "trades" #: ``resample(freq)`` aggregates each variable this way unless told otherwise. DEFAULT_RESAMPLE_HOW = TRADE_BAR_RESAMPLE_HOW #: No ``"rebuild"``: a ticker a day brings has no trades before it, so a #: store is only refused or extended (and rebuilding years of the whole #: market from raw is a job for the conversion script, into a new store). NEW_LISTING_STRATEGIES = ("refuse", "widen") last_stats: dict | None = None def _normalize_config(self, config: DatasetConfig) -> MassiveTradeBarsDatasetConfig: """Normalise as the base class does, then refuse ``symbols`` and check the session window. Raises ------ TypeError If ``config`` is not a ``MassiveTradeBarsDatasetConfig``. ValueError If ``symbols`` is set, or the session window is malformed. """ config = super()._normalize_config(config) if not isinstance(config, self.config_cls): raise TypeError(f"{self.class_name} needs a {self.config_cls.__name__}, got {type(config).__name__}.") if config.symbols is not None: raise ValueError( f"{self.class_name}: config.symbols is not selectable; got {config.symbols!r}. " f"The panel's symbol axis is the Massive ticker; a roster of tickers is " f"config.tickers." ) TradeBarResampler(config.bar_interval) XnysSessionCalendar(config.session_start, config.session_end) if config.tickers is not None: config = dataclasses.replace(config, tickers=tuple(sorted({str(t) for t in config.tickers}))) return config def _on_config_installed(self) -> None: """Create the calendar and resampler, and clear the per-day cache.""" self._sessions = XnysSessionCalendar(self.config.session_start, self.config.session_end) # The closing auction prints after the exchange's close: only a window # ending there (at 13:00 on a half day) has one. self._resampler = TradeBarResampler( self.config.bar_interval, close_auction=self.config.session_end == REGULAR_SESSION_END ) self._days: dict[date, ConvertedTradeDay] = {} @property def _raw_data_type(self) -> str: """Return ``DATA_TYPE``.""" return self.DATA_TYPE @property def stats_path(self) -> Path: """Return the path of the per-day statistics sidecar beside the store. Examples -------- >>> ds.stats_path.name 'massive_trade_bars_1m.zarr.massive_stats.json' """ return Path(f"{self.config.zarr_file_path}{STATS_SUFFIX}")
[docs] def has_raw_data(self) -> bool: """Return whether the raw tier holds at least one trade file. Examples -------- >>> MassiveTradeBarDataset(config).has_raw_data() True """ return bool(raw_days(self.config.raw_data_dir_path, self.DATA_TYPE))
# -- days ------------------------------------------------------------------- def _days_in_range(self) -> list[date]: """Return the raw trade days inside the configured range, and the days converted already, ascending.""" start = None if self.config.start_date is None else date.fromisoformat(self.config.start_date[:10]) end = None if self.config.end_date is None else date.fromisoformat(self.config.end_date[:10]) return sorted( day for day in set(raw_days(self.config.raw_data_dir_path, self.DATA_TYPE)) | set(self._days) if (start is None or start <= day) and (end is None or day <= end) )
[docs] def convert_day(self, day: date) -> ConvertedTradeDay: """Read and resample one trading day, once per instance; the store is not touched. Parameters ---------- day : date A trading day with a raw trade file. Returns ------- ConvertedTradeDay The day's bars and statistics, vendor check included. Examples -------- In a worker process, then appended by another instance:: converted = MassiveTradeBarDataset(config).convert_day(date(2024, 11, 29)) appender.add_converted_day(converted) appender.update(granularity="day") """ if day in self._days: return self._days[day] with Timer(f" {self.class_name}: {day}"): trades = read_trades(raw_file(self.config.raw_data_dir_path, self.DATA_TYPE, day)) outside_roster = 0 if self.config.tickers is not None: on_roster = pl.col("ticker").is_in(list(self.config.tickers)) outside_roster = int(trades.filter(~on_roster).height) trades = trades.filter(on_roster) sessions = self._sessions.session_bounds([day]) records = trades.rename({"ticker": "symbol"}).with_columns(pl.lit(day).alias("date")) bars, stats = self._resampler.resample_with_stats( records, read_conditions(latest_conditions(self.config.raw_data_dir_path)), sessions ) day_stats = { **{name: int(stats[name].sum()) for name in TRADE_STATS_COUNTS}, "outside_roster_trades": outside_roster, } # Every trade of the file: the resampler saw only the rostered ones. day_stats["trades_in"] += outside_roster bars = bars.with_columns(pl.col("symbol").cast(pl.String)) day_stats["vendor_check"] = self._vendor_check(day, bars, sessions) result = ConvertedTradeDay(day=day, bars=bars, stats=day_stats) self._days[day] = result return result
[docs] def add_converted_day(self, converted: ConvertedTradeDay) -> None: """Use a day ``convert_day`` converted, possibly in another process, instead of reading it again. The day must have been converted under this instance's settings (interval, session window, roster); the caller vouches for that. Parameters ---------- converted : ConvertedTradeDay The converted day. Raises ------ TypeError If ``converted`` is not a ``ConvertedTradeDay``. Examples -------- >>> appender.add_converted_day(MassiveTradeBarDataset(config).convert_day(day)) """ if not isinstance(converted, ConvertedTradeDay): raise TypeError(f"{self.class_name}: expected a ConvertedTradeDay, got {type(converted).__name__}.") self._days[converted.day] = converted
def _config_settings(self) -> dict: """Return the settings this config converts with.""" return { "bar_interval": self.config.bar_interval, "session_start": self.config.session_start, "session_end": self.config.session_end, "tickers": None if self.config.tickers is None else list(self.config.tickers), "rules": COUNTING_RULES, } def _recorded_days(self) -> dict: """Return the per-day statistics recorded in the sidecar, keyed by ISO date.""" if not self.stats_path.exists(): return {} return json.loads(self.stats_path.read_text(encoding="utf-8")).get("days", {}) def _recorded_settings(self) -> dict | None: """Return the settings recorded in the sidecar, or ``None`` without one.""" if not self.stats_path.exists(): return None return json.loads(self.stats_path.read_text(encoding="utf-8")).get("settings") def _build_settings(self) -> dict: """Return the settings the store was converted with, recorded with every read. They are read from the store's sidecar, so they describe the store whatever config reads it; without a sidecar, from the config. Examples -------- >>> MassiveTradeBarDataset(config)._build_settings()["bar_interval"] '1m' """ recorded = self._recorded_settings() return recorded if recorded is not None else self._config_settings() def _check_settings(self) -> None: """Refuse to convert into a store recorded with other settings. Raises ------ ValueError If the store exists and its sidecar records other settings, or records none; or the store is gone and its sidecar is left. """ if not Path(self.config.zarr_file_path).exists(): if self.stats_path.exists(): raise ValueError( f"{self.class_name}: {str(self.stats_path)!r} is left from a store that is gone " f"({self.config.zarr_file_path!r}); delete it before converting a new store there." ) return recorded = self._recorded_settings() wanted = self._config_settings() if recorded is None: raise ValueError( f"{self.class_name}: the store {self.config.zarr_file_path!r} has no recorded settings " f"in {str(self.stats_path)!r}; refusing to append to it." ) differ = sorted(key for key in set(recorded) | set(wanted) if recorded.get(key) != wanted.get(key)) if differ: raise ValueError( f"{self.class_name}: the store {self.config.zarr_file_path!r} was converted with other " f"settings ({', '.join(f'{key}: {recorded.get(key)!r} there, {wanted.get(key)!r} here' for key in differ)}); " f"bars built under different settings are never mixed. Convert into another store." ) def _vendor_check(self, day: date, bars: pl.DataFrame, sessions: pl.DataFrame) -> dict: """Check a one-minute day against Massive's minute aggregates of that day, when present. Returns the ``check_minute_bars`` result, or a ``status`` saying why the day was not checked: bars other than one minute, or no minute-aggregate file in the raw tier. """ if self.config.bar_interval != "1m": return {"status": NOT_ONE_MINUTE} path = raw_file(self.config.raw_data_dir_path, "minute_aggs", day) if not path.exists(): return {"status": NO_MINUTE_AGGREGATES} check = check_minute_bars( bars, read_aggregates(path), self._resampler.labels(sessions)["timestamp"], self.config.tickers ) both = max(check["both"], 1) logger.info( f"{self.class_name}: {day} against Massive's minute bars: {check['both']} bar(s) on both sides, " f"{check['ours_only']} only ours, {check['vendor_only']} only theirs; close agrees on " f"{check['agree']['close'] / both:.4%}, volume on {check['agree']['volume'] / both:.4%}." ) return check
[docs] def converted_through(self) -> date | None: """Return the last day the store holds, from its chunk ledger, or ``None`` without one. Days are appended in date order, so every day up to this one that has a raw trade file is in the store. The statistics sidecar is not consulted: it is written before a day's bars, so a crash between the two leaves a day it records and the store lacks. Examples -------- After appending 2024-11-29, this returns ``date(2024, 11, 29)``:: MassiveTradeBarDataset(config).update(granularity="day").converted_through() """ from quantlab.dataset._support.ledger import ChunkLedger if not Path(self.config.zarr_file_path).exists(): return None last_end = ChunkLedger(ChunkLedger.default_path(self.config.zarr_file_path)).last_end return None if last_end is None else pd.Timestamp(last_end).date()
def _write_stats(self, days: dict[date, dict]) -> None: """Merge the statistics of ``days`` into the sidecar, with the settings.""" payload = {"days": {}} if self.stats_path.exists(): payload = json.loads(self.stats_path.read_text(encoding="utf-8")) payload["settings"] = self._config_settings() payload["days"].update({day.isoformat(): stats for day, stats in days.items()}) payload["days"] = dict(sorted(payload["days"].items())) write_json_atomically(self.stats_path, payload) self.last_stats = payload # -- the panel ------------------------------------------------------------- def _raw_axes_in_range(self) -> tuple[list[str], pd.DatetimeIndex]: """Return the tickers and bar labels of the configured range. Every day of the range the store lacks is converted (and kept for the windows), so the axis is every ticker that traded on one of them, with the store's own; ``from_raw_data_chunked`` then pins the store's axis followed by the new ones (ADR 0032). Raises ------ ValueError If the range has no raw trade day, or no trade of a rostered ticker and no store. """ self._check_settings() days = self._days_in_range() if not days: raise ValueError( f"{self.class_name}: no Massive trade file under " f"{str(Path(self.config.raw_data_dir_path) / self.DATA_TYPE)!r} in " f"[{self.config.start_date}, {self.config.end_date}]. Download it first." ) stored = self._stored_symbol_axis(self.config.zarr_file_path) # A day the store holds already has its tickers on the axis, and # its window is skipped: it is not converted again to find them. The # ledger says which days those are, not the sidecar, which a crash # may have left one day ahead of the store. through = self.converted_through() if stored is not None else None symbols: set[str] = set() for day in days: if through is None or day > through: symbols.update(self.convert_day(day).bars["symbol"].unique().to_list()) if not symbols and stored is None: raise ValueError( f"{self.class_name}: no trade of a rostered ticker in {days[0]}..{days[-1]}; " f"refusing to write an empty panel." ) labels = self._resampler.labels(self._sessions.session_bounds(days)) if stored is not None: symbols |= {str(symbol) for symbol in stored} return sort_symbol_axis(symbols), pd.DatetimeIndex(labels["timestamp"].to_list()) def _added_symbols_with_raw_history(self, added: list, start, end) -> dict[str, int]: """Return no history for the tickers an append adds. A converted day puts every ticker that traded on it on the axis, so one the store lacks did not trade on any of its days: the zero volume and NaN prices an extension gives it there are its real values, and ``update`` extends the axis. """ return {}
[docs] def resample_labels(self, timestamps: np.ndarray, freq: str) -> np.ndarray: """Return the bar each Trade bar belongs to, cut by XNYS session. As ``TradeBarResampler`` cuts bars: right-closed from the session's open, labelled at their end, the last cut at the close; ``"1d"`` is one bar per session, labelled with its date. The sessions are the store's recorded window, not the reading config's. Examples -------- >>> bars = pd.to_datetime(["2024-11-27 20:31", "2024-11-27 21:00"]) >>> ds.resample_labels(bars.values, "1h").astype("datetime64[m]").tolist() # doctest: +SKIP [2024-11-27T21:00, 2024-11-27T21:00] """ # The store's own session window, whatever config reads it. settings = self._build_settings() calendar = XnysSessionCalendar(settings["session_start"], settings["session_end"]) index = pd.DatetimeIndex(timestamps).normalize() candidates = sorted(set(index.date) | set((index - pd.Timedelta(days=1)).date)) sessions = calendar.session_bounds([day for day in candidates if calendar.is_session(day)]).to_pandas() return session_labels(timestamps, freq, sessions, self.class_name)
def _raw_data_to_xr_window(self, start_date, end_date, symbols: list[str] | None = None) -> xr.Dataset: """Build the bars of the days whose labels fall in the window, densely, on ``symbols``. Parameters ---------- start_date, end_date : date-like First and last bar label to include, inclusive. symbols : list of str, optional The ticker axis, in its order; ``None`` uses ``_raw_axes_in_range``'s. Returns ------- xr.Dataset ``TRADE_BAR_VARIABLES`` as float64 on ``(timestamp, symbol)``, ``symbol`` being the ticker. """ if symbols is None: symbols, _ = self._raw_axes_in_range() symbols = [str(symbol) for symbol in symbols] start, end = pd.Timestamp(start_date), pd.Timestamp(end_date) labels = self._resampler.labels(self._sessions.session_bounds(self._days_in_range())).filter( pl.col("timestamp").is_between(start, end, closed="both") ) days = labels["date"].unique().sort().to_list() frames = [self.convert_day(day).bars.drop("date") for day in days] self._write_stats({day: self.convert_day(day).stats for day in days}) grid = labels.select("timestamp").join( pl.DataFrame({"symbol": symbols, "_position": range(len(symbols))}, schema={"symbol": pl.String, "_position": pl.Int64}), how="cross", ) if frames: grid = grid.join(pl.concat(frames), on=["timestamp", "symbol"], how="left") else: grid = grid.with_columns(pl.lit(None, dtype=pl.Float64).alias(name) for name in TRADE_BAR_VARIABLES) grid = grid.with_columns(pl.col(name).fill_null(0) for name in TRADE_BAR_SUMS).sort("timestamp", "_position") timestamps = labels["timestamp"].to_list() shape = (len(timestamps), len(symbols)) variables = { name: ( ("timestamp", "symbol"), grid[name].cast(pl.Float64).fill_null(np.nan).to_numpy().reshape(shape), ) for name in TRADE_BAR_VARIABLES } return xr.Dataset( variables, coords={"timestamp": pd.DatetimeIndex(timestamps).values, "symbol": np.array(symbols, dtype=object)}, ) def _raw_data_to_xr(self) -> xr.Dataset: """Build the whole configured range as one window.""" symbols, timestamps = self._raw_axes_in_range() return self._raw_data_to_xr_window(timestamps[0], timestamps[-1], symbols=symbols) def _clean(self, data: xr.Dataset) -> xr.Dataset: """Return the panel as built: the resampler's rules are its cleaning.""" return data def _widen_fill_values(self) -> dict: """Return 0 for the volumes and counts of a ticker added to an existing store; prices stay NaN.""" return no_trade_fills()