Source code for quantlab.dataset.sharadar.tickers

"""The ticker sidecar of a Sharadar store: permatickers shown as the ticker in use that day.

A Sharadar panel's ``symbol`` axis is the permaticker, Sharadar's unchanging
integer id of one share class: right for code, but not what a person reads in
a backtest's Holdings tab or its ``settlements.json``. Sharadar renames a
security's whole history to its current ticker, so the price table alone
never says that permaticker 194817 traded as FB before it was META. The
names come from two raw tables:

- TICKERS gives each permaticker its current ticker and company, from the
  rows labelled with the store's table (``SEP`` or ``stocks`` for SEP), so a
  fund's row never names a stock;
- ACTIONS gives each ticker change as a ``tickerchangefrom`` row: on its
  ``date`` the security, then trading as ``contraticker`` (company
  ``contraname``), became ``ticker``.

``ticker_sidecar_payload`` builds, for given permatickers, a list of spans
per permaticker (the first day each ticker was in use, ``null`` for the
first), and ``SharadarTickerLookup`` answers "which ticker and company did
this permaticker have on this day" from the JSON file
``<store>.sharadar_tickers.json`` beside the store. A conversion of
``SharadarStockDataset`` writes the file; ``write_ticker_sidecar`` and
``scripts/sharadar/ticker_sidecar.py`` write it for an existing store.

The lookup is for display: ``names`` never raises. A permaticker the file
does not know reads as its own id; so does every permaticker when the file is
missing or damaged, with one warning per lookup object.

Examples
--------
>>> lookup = SharadarTickerLookup("/data/market/sharadar/sharadar_sep_1d/sharadar_sep_1d.zarr.sharadar_tickers.json")
>>> lookup.label([194817], date(2022, 6, 8)), lookup.label([194817], date(2022, 6, 9))
(['FB'], ['META'])
"""

from __future__ import annotations

import bisect
import json
from collections.abc import Iterable, Sequence
from datetime import date
from pathlib import Path

import polars as pl
from loguru import logger

from quantlab.dataset.base import SymbolName, TickerLookup
from quantlab.dataset.sharadar.permatickers import ticker_changes
from quantlab.dataset.sharadar.tables import scan_raw_table, table

__all__ = ["TICKER_SIDECAR_SUFFIX", "SharadarTickerLookup", "ticker_sidecar_payload"]

#: Suffix of the ticker sidecar written beside a Sharadar store.
TICKER_SIDECAR_SUFFIX = ".sharadar_tickers.json"

#: How the vendor fills an unused contra column.
_NOT_APPLICABLE = "N/A"


def _text_or_none(value) -> str | None:
    """Return ``value`` as text, or ``None`` for a null, blank or ``N/A``."""
    if value is None:
        return None
    text = str(value).strip()
    return None if not text or text == _NOT_APPLICABLE else text


def _current_names(vendor_root: str | Path, code: str, permatickers: set[int]) -> dict[int, tuple[str, str | None]]:
    """Return each permaticker's current ticker and company from its TICKERS rows of ``code``.

    A permaticker with several rows (an old ticker listed beside the new one)
    takes the row with the latest ``lastpricedate``, then ``lastupdated``.
    """
    rows = (
        scan_raw_table(vendor_root, "tickers")
        .filter(
            pl.col("table").is_in(table(code).mapping_labels)
            & pl.col("permaticker").is_in(list(permatickers))
        )
        .select("permaticker", "ticker", "name", "lastpricedate", "lastupdated")
        .collect()
        .sort(["permaticker", "lastpricedate", "lastupdated", "ticker"], nulls_last=False)
    )
    current: dict[int, tuple[str, str | None]] = {}
    for permaticker, ticker, name, _, _ in rows.iter_rows():
        current[int(permaticker)] = (str(ticker), _text_or_none(name))
    return current


[docs] def ticker_sidecar_payload( vendor_root: str | Path, code: str, permatickers: Iterable[int], previous: dict | None = None, ) -> dict: """Return the ticker sidecar of ``permatickers`` from the raw TICKERS and ACTIONS tables. The sidecar is also what a later conversion maps an old raw file's tickers with (``quantlab.dataset.sharadar.permatickers``), so what an earlier sidecar knew is carried forward rather than lost when TICKERS forgets it: a permaticker TICKERS no longer lists keeps its earlier spans, and a ticker the vendor renamed away without an ACTIONS row (a reused ticker's old holder gets a suffixed one) is kept under ``former``. Parameters ---------- vendor_root : str or Path ``<download-dir>/sharadar``, holding TICKERS and ACTIONS. code : str The store's price table (``"sep"`` or ``"sfp"``), whose TICKERS rows map tickers to permatickers. permatickers : iterable of int The store's symbols. A permaticker without a TICKERS row of the table and without spans in ``previous`` is left out of the sidecar, and reads as its id. previous : dict, optional The sidecar this one replaces, as read from its file. Returns ------- dict ``{"table": code, "intervals": {permaticker: [span, ...]}, "former": {permaticker: [span, ...]}}``, each span ``{"start", "ticker", "company"}``: the first day the ticker was in use (``None`` for the first span, or when unknown), the ticker and the company (``None`` when the tables record none; an earlier ticker's company is the ``contraname`` of its change). Spans are in date order and permatickers in numeric order. ``former`` lists tickers an earlier sidecar gave a permaticker that its current spans no longer name; the lookup does not show them. Examples -------- >>> payload = ticker_sidecar_payload("/data/downloads/sharadar", "sep", [194817]) >>> [span["ticker"] for span in payload["intervals"]["194817"]] ['FB', 'META'] """ wanted = {int(p) for p in permatickers} current = _current_names(vendor_root, code, wanted) changes = ticker_changes(vendor_root, code) intervals: dict[str, list[dict]] = {} for permaticker in sorted(current): ticker, company = current[permaticker] steps = changes.get(permaticker, []) spans = [] start = None for day, old, old_company in steps: spans.append({"start": start, "ticker": old, "company": old_company}) start = day spans.append({"start": start, "ticker": ticker, "company": company}) intervals[str(permaticker)] = spans former: dict[str, list[dict]] = {} if previous: old_intervals = previous.get("intervals", {}) or {} old_former = previous.get("former", {}) or {} for permaticker in sorted(wanted): key = str(permaticker) if key not in intervals and key in old_intervals: intervals[key] = old_intervals[key] if key in old_former: former[key] = old_former[key] continue named = {span["ticker"] for span in intervals.get(key, [])} kept = [ span for span in [*old_intervals.get(key, []), *old_former.get(key, [])] if span.get("ticker") not in named ] unique = {span["ticker"]: span for span in kept} if unique: former[key] = [unique[t] for t in sorted(unique)] intervals = {key: intervals[key] for key in sorted(intervals, key=int)} return {"table": code, "intervals": intervals, "former": former}
[docs] class SharadarTickerLookup(TickerLookup): """The ticker and company a permaticker had on a day, from one ``<store>.sharadar_tickers.json``. The file is read on first use, so a lookup costs nothing until a name is asked for. Parameters ---------- sidecar_path : str or Path The sidecar. ``SharadarStockDataset.ticker_lookup()`` builds one over its own. Examples -------- >>> lookup = SharadarTickerLookup("/data/market/sharadar/sharadar_sep_1d/sharadar_sep_1d.zarr.sharadar_tickers.json") >>> lookup.names([194817, 1], date(2022, 6, 9)) [SymbolName(ticker='META', company='META PLATFORMS INC'), SymbolName(ticker='1', company=None)] """ def __init__(self, sidecar_path: str | Path) -> None: """Initialize the lookup without reading the file; see the class docstring.""" self.sidecar_path = Path(sidecar_path) self._spans: dict[str, tuple[list[str], list[tuple[str, str | None]]]] | None = None def __repr__(self) -> str: """Return ``SharadarTickerLookup('<sidecar path>')``.""" return f"SharadarTickerLookup({str(self.sidecar_path)!r})" def _read_spans(self) -> dict[str, tuple[list[str], list[tuple[str, str | None]]]]: """Return the spans per permaticker, as start labels and names, read once. A missing or damaged file gives an empty table and one warning, so every permaticker reads as its id. """ if self._spans is None: self._spans = {} try: payload = json.loads(self.sidecar_path.read_text(encoding="utf-8")) for permaticker, spans in payload["intervals"].items(): # A null start sorts before every day. starts = [str(span["start"] or "")[:10] for span in spans] names = [(str(span["ticker"]), span.get("company")) for span in spans] self._spans[str(permaticker)] = (starts, names) except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc: self._spans = {} logger.warning( f"{type(self).__name__}: the ticker sidecar {str(self.sidecar_path)!r} " f"is unusable ({type(exc).__name__}: {exc}); permatickers are shown " f"as their ids. Write it with scripts/sharadar/ticker_sidecar.py." ) return self._spans
[docs] def names(self, symbols: Sequence, day: date) -> list[SymbolName]: """Return the ticker and company of each of ``symbols`` on ``day``, in order. Never raises: a symbol the sidecar does not know, or any symbol when the sidecar is missing or damaged, gets its own id as ticker. Parameters ---------- symbols : Sequence Permatickers as on the panel's ``symbol`` axis. day : datetime.date The date whose names to use; a span starts on its ``start``. Returns ------- list of SymbolName One name per input, in the input's order. Examples -------- >>> lookup.names([194817], date(2022, 6, 8)) [SymbolName(ticker='FB', company='FACEBOOK INC')] """ spans = self._read_spans() as_of = str(day)[:10] names = [] for symbol in symbols: try: key = str(int(symbol)) except (TypeError, ValueError): key = str(symbol) found = spans.get(key) k = -1 if found is None else bisect.bisect_right(found[0], as_of) - 1 if k < 0: names.append(SymbolName(key)) else: ticker, company = found[1][k] names.append(SymbolName(ticker, company)) return names