"""Map the tickers of Sharadar's raw files to permatickers, as of each file's pull.
Sharadar keys every table but TICKERS by ticker, and renames a security's
whole history when its ticker changes (LESL became LESLQ: every LESL row
reads LESLQ from then on). Each raw file therefore names its securities by
the tickers *of its own pull*: a bulk file pulled before a change holds the
old ticker, a window pulled after it the new one. TICKERS, pulled whole every
run, holds only each permaticker's current ticker, so the old ticker of a
file pulled before a change is not in it.
``PermatickerResolver`` maps one file at a time (``annotate``, the hook of
``scan_raw_table``), each ticker in this order:
1. the TICKERS snapshot of the file's own run (``tables.snapshot_for``),
which knows exactly the tickers of that pull. A ticker the snapshot gives
to two permatickers is refused, as TICKERS always was.
2. otherwise the ticker's *holders*, the securities known to have used it:
the permaticker current TICKERS gives it to (a ticker current TICKERS
gives to two permatickers is refused); the securities the ACTIONS
``tickerchangefrom`` chain says used it, each from its change into the
ticker (or its first price) until its change away
(``ticker_changes``); the security TICKERS renamed to the ticker plus a
number when another took the ticker over (NSTR became NSTR1), often with
no ACTIONS row: its ``relatedtickers`` lists the old ticker, held from its
first to its last price (``related_spans``); and, for a store with one,
the securities its ticker sidecar (``<store>.sharadar_tickers.json``)
names by it, the sidecar having been written from an earlier TICKERS. A ticker with a single
holder maps to it, whatever the dates (so a vendor slow to rename a
security still maps). Several holders (the ticker was reused) are told
apart by the file's pull date: the holder whose span covers it, the
latest-starting one if several do, or the latest to have used it if none
does. A change dated on the pull's own day counts from the next day, as
the vendor applies it in its evening update.
The TICKERS history (``quantlab.acquisition.sharadar.raw_tier.fold_tickers_history``) adds holders too:
each security a folded snapshot showed under the ticker, until the day
before the history first shows it renamed or the ticker reused (a rename
TICKERS made before ACTIONS has a row of it). A holder the history shows
under another ticker on or after the file's pull, and under this one
only later, is not one of the file's, whatever date ACTIONS gives its
change: the vendor renames rows when TICKERS shows the new ticker.
A holder is not taken when another security, under any table, held the
ticker at the file's pull: the old ticker of a stock that a fund (SFP)
has taken over names the fund in a file of that pull (ACTIONS and SF3A
hold funds; ACTIONS names the FB fund's dividends FB, not Meta's, #235).
Who held it is the file's own TICKERS snapshot, or without one current
TICKERS, each security counted from the day after it took the ticker (its
last ACTIONS ticker change, else its first price). A security first priced
after the file's latest row of the ticker does not count: it owns none of
them (a fund listed under a dead stock's old ticker after the file's rows).
A row whose ticker none of them maps is left out rather than refused:
``left_out`` drops it, logs a warning and writes the report
``<store>.unmapped.json`` (``UNMAPPED_SUFFIX``) naming each ticker's first
and last date, row count and raw files, so a daily update completes. So does
a ticker whose holders tie (two securities starting on the same day).
One security can then have rows under two tickers for one key: SF3A, pulled
whole, keeps some quarters of a security under its current ticker and under
a former one. ``settle`` keeps one row per security and key (the security's
own ticker as of the file's pull, else the ticker it used on the row's date,
else one of rows identical but for their ticker) and leaves out, as
unmapped, the rows it cannot choose between.
Examples
--------
>>> resolver = PermatickerResolver("/data/downloads/sharadar", "sep")
>>> prices = scan_raw_table("/data/downloads/sharadar", "sep", annotate=resolver.annotate).collect()
>>> prices = resolver.left_out(prices, owner="demo", report_path="/data/market/sharadar/sep/sep.zarr.unmapped.json")
"""
from __future__ import annotations
import json
import re
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import polars as pl
from loguru import logger
from quantlab.dataset.sharadar.tables import (
permaticker_mapping,
pull_time,
scan_raw_table,
snapshot_for,
table,
tickers_history,
)
from quantlab.utils.atomic import write_json_atomically
__all__ = [
"TICKER_CHANGE",
"UNMAPPED_SUFFIX",
"HolderSpan",
"PermatickerResolver",
"SeenSpan",
"map_raw_table",
"related_spans",
"ticker_changes",
]
#: The ACTIONS type recording a ticker change: ``contraticker`` became ``ticker``.
TICKER_CHANGE = "tickerchangefrom"
#: Suffix of the report of raw rows left out for want of a permaticker.
UNMAPPED_SUFFIX = ".unmapped.json"
#: How the vendor fills an unused contra column.
_NOT_APPLICABLE = "N/A"
#: Number of offending keys a message lists.
_SAMPLE = 5
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
[docs]
def ticker_changes(
vendor_root: str | Path, code: str | None
) -> dict[int, list[tuple[str, str, str | None]]]:
"""Return each permaticker's ticker changes, ``(date, old ticker, old company)``, by date.
A ``tickerchangefrom`` row of ACTIONS says that on its ``date`` the
security trading as ``contraticker`` (company ``contraname``) became
``ticker``. If a security changed away from that ticker later (``AAX``
-> ``BBX`` -> ``BBB``: the change into ``BBX``), the row is that
security's, the one whose change away from it comes first after the
row's date, even when another company trades under the ticker now.
Otherwise the row is the security the TICKERS rows of ``code`` (of every
table for ``None``) give the ticker to. A row that maps to nothing, or to
several permatickers, is left out.
Parameters
----------
vendor_root : str or Path
``<download-dir>/sharadar``, holding TICKERS and ACTIONS.
code : str or None
The table whose TICKERS rows own the tickers (``"sep"``); ``None``
for the rows of every table (funds included).
Returns
-------
dict
``{permaticker: [(date as YYYY-MM-DD, old ticker, old company), ...]}``.
Examples
--------
>>> ticker_changes("/data/downloads/sharadar", "sep")[194817]
[('2022-06-09', 'FB', 'FACEBOOK INC')]
>>> ticker_changes("/data/downloads/sharadar", None)[117736] # a fund (SFP)
[('2026-04-09', 'BJK', None)]
"""
rows = (
scan_raw_table(vendor_root, "actions")
.filter(pl.col("action") == TICKER_CHANGE)
.select("date", "ticker", "contraticker", "contraname")
.collect()
.sort("date", descending=True)
)
owners: dict[str, set[int]] = {}
if code is None:
mapping = scan_raw_table(vendor_root, "tickers").select("ticker", "permaticker").unique().collect()
else:
mapping = permaticker_mapping(vendor_root, code)
for ticker, permaticker in mapping.drop_nulls().iter_rows():
owners.setdefault(str(ticker), set()).add(int(permaticker))
# Latest first, so a later change has resolved the ticker an earlier one
# changed into before that earlier one is looked at.
resolved: dict[int, list[tuple[str, str, str | None]]] = {}
later: dict[str, list[tuple[str, int]]] = {}
for day, ticker, old, old_company in rows.iter_rows():
old = _text_or_none(old)
if old is None or ticker is None:
continue
day = str(day)[:10]
left = [(since, perm) for since, perm in later.get(str(ticker), []) if since > day]
if left:
permaticker = min(left)[1]
else:
candidates = owners.get(str(ticker), set())
if len(candidates) != 1:
continue
(permaticker,) = candidates
resolved.setdefault(permaticker, []).append((day, old, _text_or_none(old_company)))
later.setdefault(old, []).append((day, permaticker))
return {perm: sorted(changes) for perm, changes in resolved.items()}
[docs]
@dataclass(frozen=True)
class HolderSpan:
"""One security's use of one ticker: from ``start`` (``None``: always) to ``end`` (``None``: still)."""
permaticker: int
start: date | None
end: date | None
[docs]
def covers(self, day: date) -> bool:
"""Return whether the span holds ``day``."""
return (self.start is None or self.start <= day) and (self.end is None or day < self.end)
[docs]
def held_at_pull(self, day: date) -> bool:
"""Return whether a file pulled on ``day`` names the security by the ticker.
A change dated ``day`` shows only in the vendor's update that
evening, so a file pulled that day still has the ticker's earlier
holder: the span starts the day after its start and ends the day
after its end.
"""
return (self.start is None or self.start < day) and (self.end is None or day <= self.end)
[docs]
@dataclass(frozen=True)
class SeenSpan:
"""One security under one ticker in the TICKERS history (``raw_tier.fold_tickers_history``).
``first`` and ``last`` are the pull times (UTC) of the first and last
snapshots holding the pair; ``end`` is the day before a snapshot after
``last`` first shows the security under another ticker or the ticker
under another security (``None``: none has).
"""
first: datetime
last: datetime
end: date | None
def _day(value) -> date | None:
"""Return a ``YYYY-MM-DD`` text, a date or ``None`` as a date or ``None``."""
if value is None or value == "":
return None
if isinstance(value, date):
return value
return date.fromisoformat(str(value)[:10])
[docs]
class PermatickerResolver:
"""Map each raw file's tickers to permatickers as of the file's pull.
See the module docstring for the order. One resolver serves one
mapping table (``code``: the TICKERS rows of ``tables.SharadarTable.mapping_labels``),
and may annotate the files of several raw tables mapped through it (a
price table and ACTIONS).
Parameters
----------
vendor_root : str or Path
``<download-dir>/sharadar``.
code : str
The table whose TICKERS rows map the tickers (``"sep"``, ``"sf1"``).
sidecar_path : str or Path, optional
The store's ticker sidecar (``<store>.sharadar_tickers.json``): the
securities it names keep their tickers as of when it was written,
including the former ones it carries. Missing or unreadable, it is
ignored.
Examples
--------
>>> resolver = PermatickerResolver("/data/downloads/sharadar", "sep")
>>> frame = scan_raw_table("/data/downloads/sharadar", "sep", annotate=resolver.annotate)
>>> "permaticker" in frame.collect_schema().names()
True
"""
def __init__(
self, vendor_root: str | Path, code: str, *, sidecar_path: str | Path | None = None
) -> None:
"""Initialize without reading anything; see the class docstring."""
self.vendor_root = Path(vendor_root)
self.code = table(code).code
self.sidecar_path = None if sidecar_path is None else Path(sidecar_path)
self._holders: dict[str, list[HolderSpan]] | None = None
self._current: dict[str, set[int]] | None = None
self._snapshots: dict[Path, dict[str, set[int]]] = {}
self._snapshots_all: dict[Path, dict[str, set[int]]] = {}
self._since: dict[str, dict[int, date | None]] | None = None
self._seen: dict[int, dict[str, SeenSpan]] | None = None
self._first_price: dict[int, date] | None = None
#: Per raw file, the tickers it names that were not mapped, and why.
self.unresolved: dict[Path, dict[str, str]] = {}
#: ``{ticker: permatickers}`` mapped as the security's own ticker of a
#: pull: by the file's TICKERS snapshot, or by current TICKERS for a
#: file without one.
self.own: dict[str, set[int]] = {}
#: Tickers whose rows ``settle`` left out on some key, and why.
self.ambiguous: dict[str, str] = {}
# -- the sources ----------------------------------------------------------
def _pairs(self, frame: pl.LazyFrame) -> dict[str, set[int]]:
"""Return ``{ticker: permatickers}`` from TICKERS-shaped rows of this table."""
pairs: dict[str, set[int]] = {}
rows = (
frame.filter(pl.col("table").is_in(table(self.code).mapping_labels))
.select("ticker", "permaticker")
.unique()
.collect()
)
for ticker, permaticker in rows.iter_rows():
if ticker is not None and permaticker is not None:
pairs.setdefault(str(ticker), set()).add(int(permaticker))
return pairs
def _snapshot(self, path: Path) -> dict[str, set[int]]:
"""Return the ``{ticker: permatickers}`` of one TICKERS snapshot, read once."""
if path not in self._snapshots:
self._snapshots[path] = self._pairs(pl.scan_parquet(path))
return self._snapshots[path]
def _snapshot_any_table(self, path: Path) -> dict[str, set[int]]:
"""Return the ``{ticker: permatickers}`` of every table's rows of one TICKERS snapshot, read once."""
if path not in self._snapshots_all:
pairs: dict[str, set[int]] = {}
rows = pl.scan_parquet(path).select("ticker", "permaticker").unique().collect()
for ticker, permaticker in rows.iter_rows():
if ticker is not None and permaticker is not None:
pairs.setdefault(str(ticker), set()).add(int(permaticker))
self._snapshots_all[path] = pairs
return self._snapshots_all[path]
def _current_since(self) -> dict[str, dict[int, date | None]]:
"""Return ``{ticker: {permaticker: since}}`` of current TICKERS, every table's rows, read once.
``since`` is the day the security took its ticker: its last ACTIONS
ticker change, else its first price (``None`` without either).
"""
if self._since is None:
rows = (
scan_raw_table(self.vendor_root, "tickers")
.group_by("ticker", "permaticker")
.agg(pl.col("firstpricedate").min())
.collect()
)
try:
changes = ticker_changes(self.vendor_root, None)
except FileNotFoundError:
changes = {}
since: dict[str, dict[int, date | None]] = {}
for ticker, permaticker, first in rows.iter_rows():
if ticker is None or permaticker is None:
continue
changed = changes.get(int(permaticker))
start = _day(changed[-1][0]) if changed else _day(first)
since.setdefault(str(ticker), {})[int(permaticker)] = start
self._since = since
return self._since
def _holding_on(self, day: date) -> dict[str, set[int]]:
"""Return ``{ticker: permatickers}`` current TICKERS (every table) shows holding it on ``day``.
A security counts from the day after it took the ticker
(``_current_since``): a change shows in the vendor's update that
evening, so a file pulled on the day of the change, or before it,
names the ticker's earlier holder.
"""
return {
ticker: held
for ticker, starts in self._current_since().items()
if (held := {p for p, start in starts.items() if start is None or start < day})
}
def _current_pairs(self) -> dict[str, set[int]]:
"""Return the ``{ticker: permatickers}`` of current TICKERS, read once."""
if self._current is None:
self._current = self._pairs(scan_raw_table(self.vendor_root, "tickers"))
return self._current
def _history_spans(self) -> dict[int, dict[str, SeenSpan]]:
"""Return ``{permaticker: {ticker: SeenSpan}}`` of the TICKERS history, read once.
Over this table's TICKERS rows of every snapshot folded
(``quantlab.acquisition.sharadar.raw_tier.fold_tickers_history``). A span ends only on a pair seen
after its last sighting: a pair merely first seen late (the vendor
dropped its row from earlier snapshots) overlaps it and ends nothing.
"""
if self._seen is None:
history = tickers_history(self.vendor_root)
pairs: list[tuple[str, int, datetime, datetime]] = []
if history is not None and history.height:
pairs = [
(str(t), int(p), first, last)
for t, p, first, last in history.filter(
pl.col("table").is_in(table(self.code).mapping_labels)
)
.group_by("ticker", "permaticker")
.agg(pl.col("first_seen").min(), pl.col("last_seen").max())
.iter_rows()
]
firsts_of_ticker: dict[str, list[datetime]] = {}
firsts_of_permaticker: dict[int, list[datetime]] = {}
for ticker, permaticker, first, _ in pairs:
firsts_of_ticker.setdefault(ticker, []).append(first)
firsts_of_permaticker.setdefault(permaticker, []).append(first)
seen: dict[int, dict[str, SeenSpan]] = {}
for ticker, permaticker, first, last in pairs:
# The pair's own first sighting is not after its last one.
after = [
f
for f in firsts_of_ticker[ticker] + firsts_of_permaticker[permaticker]
if f > last
]
end = min(after).date() - timedelta(days=1) if after else None
seen.setdefault(permaticker, {})[ticker] = SeenSpan(first, last, end)
self._seen = seen
return self._seen
def _taken_after(self, ticker: str, permaticker: int, day: date) -> bool:
"""Return whether the TICKERS history shows the security taking ``ticker`` only after ``day``.
True when a snapshot on or after ``day`` shows the security under
another ticker, before any shows it under ``ticker``: a file pulled
on ``day`` cannot name the security by ``ticker``, whatever date
ACTIONS gives the change (the vendor renames rows when TICKERS shows
it). A pair merely first seen late (the vendor dropped the row from
earlier snapshots) is not evidence.
"""
tickers = self._history_spans().get(permaticker, {})
span = tickers.get(ticker)
if span is None:
return False
return any(
other != ticker and seen.last.date() >= day and seen.last < span.first
for other, seen in tickers.items()
)
def _listed_after(self, permaticker: int, day: date) -> bool:
"""Return whether the security's first price (TICKERS, any table) is after ``day``.
Such a security, a later listing that took a ticker, owns no row of
the ticker dated on or before ``day``. Without a first price, False.
"""
if self._first_price is None:
self._first_price = {
int(permaticker): _day(first)
for permaticker, first in scan_raw_table(self.vendor_root, "tickers")
.group_by("permaticker")
.agg(pl.col("firstpricedate").min())
.collect()
.iter_rows()
if permaticker is not None and first is not None
}
first = self._first_price.get(permaticker)
return first is not None and first > day
def _sidecar_spans(self) -> list[tuple[str, int, date | None, date | None]]:
"""Return ``(ticker, permaticker, start, end)`` of every span the store's sidecar names.
A span runs to the next span's start, the last one open-ended; a
former ticker (``former``) runs from its recorded start, open-ended.
"""
if self.sidecar_path is None or not self.sidecar_path.exists():
return []
try:
payload = json.loads(self.sidecar_path.read_text(encoding="utf-8"))
spans = []
for permaticker, entries in payload.get("intervals", {}).items():
starts = [_day(entry.get("start")) for entry in entries]
for k, entry in enumerate(entries):
end = starts[k + 1] if k + 1 < len(entries) else None
spans.append((str(entry["ticker"]), int(permaticker), starts[k], end))
for permaticker, entries in payload.get("former", {}).items():
for entry in entries:
spans.append((str(entry["ticker"]), int(permaticker), _day(entry.get("start")), None))
return spans
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
logger.warning(
f"{type(self).__name__}: the ticker sidecar {str(self.sidecar_path)!r} is "
f"unusable ({type(exc).__name__}: {exc}); tickers are mapped without it."
)
return []
[docs]
def ticker_holders(self) -> dict[str, list[HolderSpan]]:
"""Return each ticker's holders, one span per permaticker, built once.
Every security known to have used the ticker is a holder, whatever
its dates; ``resolve`` then chooses among them, and the Massive
Ticker map (``quantlab.dataset.massive.ticker_map``) checks each
against the day's close instead.
A span's start is the security's change into the ticker (ACTIONS),
else the sidecar's start, else its first price date (TICKERS); its
end the change away (ACTIONS, an effective date: kept to the TICKERS
history's last sighting of the pair when that is later, as the vendor
renames rows only when it publishes the change), else none for its
current ticker,
else the day before the TICKERS history first shows it renamed or
the ticker reused (``_history_spans``), else the sidecar's next
span, else the day after its last price for a ``relatedtickers``
former ticker (``related_spans``).
Returns
-------
dict
``{ticker: [HolderSpan, ...]}``.
Examples
--------
Needs the Sharadar raw tier::
spans = PermatickerResolver("/data/downloads/sharadar", "sep").ticker_holders()["TWO"]
[(span.permaticker, span.covers(date(2020, 3, 2))) for span in spans]
"""
if self._holders is not None:
return self._holders
first_price = {
int(permaticker): first
for permaticker, first in scan_raw_table(self.vendor_root, "tickers")
.filter(pl.col("table").is_in(table(self.code).mapping_labels))
.group_by("permaticker")
.agg(pl.col("firstpricedate").min())
.collect()
.iter_rows()
}
chain: dict[tuple[str, int], tuple[date | None, date | None]] = {}
current_of = {perm: ticker for ticker, perms in self._current_pairs().items() for perm in perms}
try:
changes_by_permaticker = ticker_changes(self.vendor_root, self.code)
except FileNotFoundError:
# No ACTIONS pulled (a raw tier of prices and holdings only).
changes_by_permaticker = {}
for permaticker, changes in changes_by_permaticker.items():
start = None
for day, old, _ in changes:
chain[(old, permaticker)] = (start, _day(day))
start = _day(day)
current = current_of.get(permaticker)
if current is not None and start is not None:
chain[(current, permaticker)] = (start, None)
sidecar = {}
for ticker, permaticker, start, end in self._sidecar_spans():
sidecar.setdefault((ticker, permaticker), (start, end))
related = related_spans(self.vendor_root, self.code)
seen = {
(ticker, permaticker): span
for permaticker, tickers in self._history_spans().items()
for ticker, span in tickers.items()
}
keys = set(chain) | set(sidecar) | set(related) | set(seen)
keys |= {(t, p) for t, perms in self._current_pairs().items() for p in perms}
holders: dict[str, list[HolderSpan]] = {}
for ticker, permaticker in keys:
chain_start, chain_end = chain.get((ticker, permaticker), (None, None))
side_start, side_end = sidecar.get((ticker, permaticker), (None, None))
rel_start, rel_end = related.get((ticker, permaticker), (None, None))
start = chain_start or side_start or rel_start or first_price.get(permaticker)
span = seen.get((ticker, permaticker))
if (ticker, permaticker) in chain:
end = chain_end
# ACTIONS dates a change when it takes effect; the vendor renames
# rows when it publishes it, which TICKERS shows, often weeks later.
if end is not None and span is not None:
end = max(end, span.end or span.last.date())
elif ticker in self._current_pairs() and permaticker in self._current_pairs()[ticker]:
end = None
elif span is not None:
end = span.end
elif (ticker, permaticker) in sidecar:
end = side_end
else:
end = rel_end
holders.setdefault(ticker, []).append(HolderSpan(permaticker, start, end))
self._holders = holders
return holders
# -- mapping --------------------------------------------------------------
def _refuse_ambiguous(self, pairs: dict[str, set[int]], tickers: Iterable[str], where: str) -> None:
"""Raise if one of ``tickers`` maps to several permatickers in ``pairs``."""
ambiguous = {t: sorted(pairs[t]) for t in sorted(tickers) if len(pairs.get(t, ())) > 1}
if ambiguous:
sample = dict(list(ambiguous.items())[:_SAMPLE])
raise ValueError(
f"{len(ambiguous)} {self.code!r} ticker(s) map to several "
f"permatickers in {where}, first {sample}. Refusing rather than "
f"guessing which company a row belongs to; re-pull TICKERS."
)
[docs]
def several_permatickers(self, path: str | Path, tickers: Iterable[str]) -> dict[str, list[int]]:
"""Return those of ``tickers`` that ``resolve`` would refuse for ``path``, each with its permatickers.
A ticker is refused when the TICKERS snapshot of the file's pull, or
current TICKERS for a ticker the snapshot does not hold, gives it to
several permatickers. A caller that can leave such a ticker unmapped
(an ACTIONS counterparty) asks this first and resolves the rest.
Returns
-------
dict of str to list of int
``{ticker: [permaticker, ...]}``, sorted.
Examples
--------
Needs the Sharadar raw tier::
resolver.several_permatickers("/data/downloads/sharadar/actions/actions.parquet", ["DUP", "AAPL"])
# {'DUP': [501, 502]}
"""
left = {str(t) for t in tickers if t is not None}
several: dict[str, list[int]] = {}
snapshot = snapshot_for(self.vendor_root, pull_time(Path(path)))
if snapshot is not None:
pairs = self._snapshot(snapshot)
known = left & set(pairs)
several.update({t: sorted(pairs[t]) for t in known if len(pairs[t]) > 1})
left -= known
current = self._current_pairs()
several.update({t: sorted(current[t]) for t in left if len(current.get(t, ())) > 1})
return dict(sorted(several.items()))
[docs]
def resolve(
self, path: str | Path, tickers: Iterable[str], *, latest: dict[str, date] | None = None
) -> dict[str, int]:
"""Return the permaticker of each of ``tickers`` as named in the raw file ``path``.
Tickers left unmapped are recorded in ``unresolved`` under ``path``;
those mapped as the security's own ticker of the pull in ``own``.
Parameters
----------
path : str or Path
The raw file; its pull time decides the TICKERS snapshot and the
holders (see the module docstring).
tickers : iterable of str
The tickers the file names.
latest : dict of str to date, optional
Each ticker's latest row date in the file. A security first
priced after it owns none of the ticker's rows, so it does not
stop them mapping to an earlier holder. ``annotate`` passes it
for a table with a ``date`` column.
Returns
-------
dict of str to int
``{ticker: permaticker}`` of the tickers mapped.
Raises
------
ValueError
If the file's TICKERS snapshot, or current TICKERS, gives one of
the tickers to several permatickers.
Examples
--------
>>> resolver.resolve("/data/downloads/sharadar/sep/sep.parquet", ["LESL"])
{'LESL': 632479}
"""
path = Path(path)
mapped, unresolved = self._resolve(pull_time(path), tickers, latest)
if unresolved:
self.unresolved.setdefault(path, {}).update(unresolved)
return mapped
[docs]
def resolve_on(self, day: date, tickers: Iterable[str]) -> tuple[dict[str, int], dict[str, str]]:
"""Return the permaticker of each of ``tickers`` as traded on ``day``, and why the rest are not mapped.
For a vendor that names each security by the ticker it traded under
that day (Massive's trade files), rather than by the tickers of a
later pull. A ticker change dated ``day`` is in force on ``day``, so
the tickers are mapped as a file pulled at the start of the next day
would name them; the order is the module docstring's.
Returns
-------
mapped : dict of str to int
``{ticker: permaticker}``.
unresolved : dict of str to str
``{ticker: reason}`` for the tickers left unmapped. Nothing is
recorded in ``unresolved``, which is keyed by raw file.
Raises
------
ValueError
If TICKERS (or the TICKERS snapshot of that pull) gives one of
the tickers to several permatickers.
Examples
--------
>>> resolver.resolve_on(date(2016, 11, 25), ["AAPL", "ZVZZT"])
({'AAPL': 199059}, {'ZVZZT': 'no permaticker'})
"""
pulled = datetime.combine(day + timedelta(days=1), datetime.min.time(), tzinfo=UTC)
return self._resolve(pulled, tickers)
def _resolve(
self, pulled: datetime, tickers: Iterable[str], latest: dict[str, date] | None = None
) -> tuple[dict[str, int], dict[str, str]]:
"""Map ``tickers`` as named by a pull at ``pulled``; return the mapped and the unresolved.
``latest`` is ``resolve``'s: each ticker's latest row date in the file.
"""
latest = latest or {}
left = {str(t) for t in tickers if t is not None}
mapped: dict[str, int] = {}
snapshot = snapshot_for(self.vendor_root, pulled)
if snapshot is not None:
pairs = self._snapshot(snapshot)
known = left & set(pairs)
self._refuse_ambiguous(pairs, known, f"the TICKERS snapshot {snapshot.name}")
mapped.update({t: next(iter(pairs[t])) for t in known})
left -= known
if left:
self._refuse_ambiguous(self._current_pairs(), left, "TICKERS")
holders = self.ticker_holders() if left else {}
day = pulled.date()
if snapshot is not None:
others = self._snapshot_any_table(snapshot)
else:
others = self._holding_on(day) if left else {}
unresolved: dict[str, str] = {}
for ticker in left:
spans = [s for s in holders.get(ticker, []) if not self._taken_after(ticker, s.permaticker, day)]
held = set(others.get(ticker, ()))
if snapshot is None:
held = {p for p in held if not self._taken_after(ticker, p, day)}
if ticker in latest:
held = {p for p in held if not self._listed_after(p, latest[ticker])}
permatickers = {span.permaticker for span in spans}
if not spans:
unresolved[ticker] = "no permaticker"
continue
chosen = spans[0].permaticker if len(permatickers) == 1 else self._holder_on(spans, day)
if chosen is None:
unresolved[ticker] = f"several securities used it ({sorted(permatickers)})"
elif held and chosen not in held:
where = "the TICKERS snapshot of its pull" if snapshot is not None else "TICKERS"
unresolved[ticker] = (
f"{where} gives it to another security as of its pull ({sorted(held)})"
)
else:
mapped[ticker] = chosen
for ticker, permaticker in mapped.items():
if ticker not in left or (
snapshot is None and permaticker in self._current_pairs().get(ticker, ())
):
self.own.setdefault(ticker, set()).add(permaticker)
return mapped, unresolved
@staticmethod
def _holder_on(spans: list[HolderSpan], day: date) -> int | None:
"""Return the holder of a reused ticker on ``day``, or ``None`` on a tie.
The span held at a pull on ``day`` (``HolderSpan.held_at_pull``), the
latest-starting one if several are; with none, the one that ended
last before it.
"""
covering = [s for s in spans if s.held_at_pull(day)]
if covering:
best = max((s.start or date.min) for s in covering)
chosen = {s.permaticker for s in covering if (s.start or date.min) == best}
else:
ended = [s for s in spans if s.end is not None and s.end < day]
if not ended:
return None
best = max(s.end for s in ended)
chosen = {s.permaticker for s in ended if s.end == best}
return next(iter(chosen)) if len(chosen) == 1 else None
def _used_on(self, ticker: str, permaticker: int, day) -> bool:
"""Return whether a span of the security's use of ``ticker`` holds ``day``."""
if isinstance(day, datetime):
day = day.date()
day = _day(day)
if day is None:
return False
spans = self.ticker_holders().get(ticker, [])
return any(s.permaticker == permaticker and s.covers(day) for s in spans)
[docs]
def settle(
self, frame: pl.DataFrame, key: Iterable[str], *, date_column: str = "date"
) -> pl.DataFrame:
"""Keep one row per security and key where several of its tickers give one.
Rows of one permaticker sharing ``key`` under *different* tickers
(several rows of one ticker are the caller's to judge) are settled,
in order:
1. the row of the security's own ticker as of a pull (``own``);
2. else the row of the ticker the security used on the row's
``date_column`` (its holder span, see the module docstring);
3. else, rows equal in every column but ``ticker`` and ``name``
keep one.
The other rows of the key are dropped (another name for the row
kept). Rows none of these chooses between keep no permaticker:
``left_out`` then leaves them out and reports them, with the reason
recorded in ``ambiguous``.
Parameters
----------
frame : pl.DataFrame
Annotated rows (``annotate``) with ``ticker``, ``permaticker``,
``key`` and ``date_column``.
key : iterable of str
The columns that, with the permaticker, name one row
(``("date",)`` for a price table).
date_column : str, default "date"
The date the row is of, checked against the tickers' spans.
Returns
-------
pl.DataFrame
``frame`` with the settled rows dropped and the ambiguous ones
without a permaticker, in its order.
Examples
--------
>>> rows = resolver.settle(rows, ("date",))
>>> rows = resolver.left_out(rows, owner="demo")
"""
key = list(key)
identity = ["permaticker", *key]
indexed = frame.with_row_index("_settle_row")
clashes = (
indexed.filter(pl.col("permaticker").is_not_null())
.filter(pl.col("ticker").n_unique().over(identity) > 1)
.sort("_settle_row")
)
if clashes.height == 0:
return frame
compared = [c for c in frame.columns if c not in ("ticker", "name", "permaticker")]
drop: list[int] = []
unmap: list[int] = []
for _, group in clashes.group_by(identity, maintain_order=True):
permaticker = int(group["permaticker"][0])
day = group[date_column][0]
tickers = sorted(set(group["ticker"].to_list()))
pool = [t for t in tickers if permaticker in self.own.get(t, ())] or tickers
if len(pool) > 1:
pool = [t for t in pool if self._used_on(t, permaticker, day)] or pool
kept = group.filter(pl.col("ticker").is_in(pool))
if len(pool) > 1 and kept.select(compared).unique().height == 1:
pool = pool[:1]
kept = kept.filter(pl.col("ticker") == pool[0])
rows = group["_settle_row"].to_list()
if len(pool) == 1:
keep = set(kept["_settle_row"].to_list())
drop.extend(r for r in rows if r not in keep)
else:
unmap.extend(rows)
reason = (
f"several tickers {tickers} give security {permaticker} one "
f"{'/'.join(key)}, none its own nor the one it used then"
)
for ticker in tickers:
self.ambiguous.setdefault(ticker, reason)
if drop:
logger.info(
f"{type(self).__name__}: {len(drop)} {self.code!r} row(s) name a security "
f"by a second ticker on a key it has a row of under its own; dropped."
)
return (
indexed.filter(~pl.col("_settle_row").is_in(drop))
.with_columns(
pl.when(pl.col("_settle_row").is_in(unmap))
.then(None)
.otherwise(pl.col("permaticker"))
.alias("permaticker")
)
.drop("_settle_row")
)
[docs]
def annotate(self, path: Path, frame: pl.LazyFrame) -> pl.LazyFrame:
"""Return one raw file's scan with a ``permaticker`` column, null where none maps.
The hook ``scan_raw_table(..., annotate=resolver.annotate)`` calls for
every file; it reads the file's distinct tickers and, for a table
with a ``date`` column (SF3A's is text), each one's latest date.
"""
schema = frame.collect_schema()
if "date" in schema.names():
# SF3A writes its date as text; a malformed one is no date, not an error.
day = (
pl.col("date").str.to_date("%Y-%m-%d", strict=False)
if schema["date"] == pl.String
else pl.col("date").cast(pl.Date)
)
dated = frame.group_by("ticker").agg(day.max()).collect()
tickers = dated.get_column("ticker").to_list()
latest = {str(t): d for t, d in dated.iter_rows() if t is not None and d is not None}
else:
tickers = frame.select(pl.col("ticker").unique()).collect().get_column("ticker").to_list()
latest = {}
mapped = self.resolve(path, tickers, latest=latest)
pairs = pl.DataFrame(
{"ticker": list(mapped), "permaticker": list(mapped.values())},
schema={"ticker": pl.String, "permaticker": pl.Int64},
)
return frame.join(pairs.lazy(), on="ticker", how="left")
[docs]
def left_out(
self,
frame: pl.DataFrame,
*,
owner: str,
report_path: str | Path | None = None,
date_column: str = "date",
quiet: bool = False,
report_unmapped: bool = True,
) -> pl.DataFrame:
"""Drop the rows no permaticker was found for, report them, and return the rest.
Parameters
----------
frame : pl.DataFrame
Annotated rows (``annotate``), with ``ticker`` and ``date_column``.
owner : str
Named in the warning.
report_path : str or Path, optional
Where to write the report (``<store>.unmapped.json``), atomically:
``{"table", "owner", "unmapped": [{"ticker", "first_date",
"last_date", "rows", "raw_files", "reason"}, ...]}``. An existing
report is deleted when nothing is left out, so its presence
means bars are missing. ``None`` writes nothing.
date_column : str, default "date"
The column whose first and last values the report gives.
quiet : bool, default False
Log at ``info`` instead of ``warning``, for a table where rows
of securities outside the mapping are expected (SF3A holds funds).
report_unmapped : bool, default True
List the rows of tickers that map to nothing in the report.
``False`` lists only the rows ``settle`` could not choose
between, for such a table.
Returns
-------
pl.DataFrame
``frame`` without the rows whose ``permaticker`` is null.
Examples
--------
>>> kept = resolver.left_out(frame, owner="demo", report_path="sep.zarr.unmapped.json")
"""
missing = frame.filter(pl.col("permaticker").is_null())
reported = missing if report_unmapped else missing.filter(
pl.col("ticker").is_in(list(self.ambiguous))
)
if reported.height == 0 and report_path is not None:
Path(report_path).unlink(missing_ok=True)
if missing.height == 0:
if report_path is not None:
Path(report_path).unlink(missing_ok=True)
return frame
summary = (
reported.group_by("ticker")
.agg(
pl.col(date_column).min().alias("first_date"),
pl.col(date_column).max().alias("last_date"),
pl.len().alias("rows"),
)
.sort("ticker")
)
entries = []
for ticker, first, last, rows in summary.iter_rows():
files = sorted(p.name for p, names in self.unresolved.items() if ticker in names)
reasons = sorted({names[ticker] for names in self.unresolved.values() if ticker in names})
if ticker in self.ambiguous:
reasons.append(self.ambiguous[ticker])
entries.append(
{
"ticker": ticker,
"first_date": None if first is None else str(first)[:10],
"last_date": None if last is None else str(last)[:10],
"rows": int(rows),
"raw_files": files,
"reason": "; ".join(reasons) or "no permaticker",
}
)
if report_path is not None and entries:
write_json_atomically(
report_path,
{"table": self.code, "owner": owner, "unmapped": entries},
indent=2,
sort_keys=True,
)
log = logger.info if quiet else logger.warning
where = f"; listed in {report_path}" if report_path is not None and entries else ""
tickers = missing.get_column("ticker").unique().sort().to_list()
log(
f"{owner}: {missing.height} raw row(s) of {len(tickers)} ticker(s) have "
f"no permaticker (as of their own pull, through TICKERS, the ACTIONS "
f"ticker changes and the store's ticker sidecar, or several tickers "
f"give one security a row) and are left out{where}, first "
f"{tickers[:_SAMPLE]}."
)
if ambiguous_rows := missing.filter(pl.col("ticker").is_in(list(self.ambiguous))).height:
logger.warning(
f"{owner}: {ambiguous_rows} raw row(s) are left out because several "
f"tickers give one security a row on one key and none could be "
f"chosen{where}."
)
return frame.filter(pl.col("permaticker").is_not_null())
[docs]
def map_raw_table(
vendor_root: str | Path,
code: str,
*,
owner: str,
query=None,
store_path: str | Path | None = None,
sidecar_path: str | Path | None = None,
date_column: str = "date",
quiet: bool = False,
key: Iterable[str] | None = None,
report_unmapped: bool = True,
) -> pl.DataFrame:
"""Read one raw table with each row's permaticker, leaving out and reporting the unmapped rows.
The rows are mapped through the TICKERS rows of ``code``'s mapping
labels (``tables.SharadarTable.mapping_labels``), each raw file as of its
own pull (``PermatickerResolver``).
Parameters
----------
vendor_root : str or Path
``<download-dir>/sharadar``.
code : str
The raw table to read (``"sf1"``, ``"daily"``, ``"events"``, ...).
owner : str
Named in messages.
query : callable, optional
Applied to the annotated scan before it is collected (filters, a
column selection keeping ``ticker``, ``permaticker`` and
``date_column``).
store_path : str or Path, optional
The store the rows feed: the report goes to
``<store_path>.unmapped.json`` (``UNMAPPED_SUFFIX``). ``None`` writes
no report.
sidecar_path : str or Path, optional
A ticker sidecar to map old tickers with (``PermatickerResolver``).
date_column : str, default "date"
The column the report dates rows by.
quiet : bool, default False
Log the left-out rows at ``info`` (``PermatickerResolver.left_out``).
key : iterable of str, optional
The columns (after ``query``) that, with the permaticker, name one
row: rows of one security under several tickers on one key are
settled (``PermatickerResolver.settle``). ``None`` settles nothing.
report_unmapped : bool, default True
``PermatickerResolver.left_out``.
Returns
-------
pl.DataFrame
The rows with a ``permaticker`` column, unmapped ones left out.
Raises
------
ValueError
If a ticker maps to several permatickers in TICKERS or in its file's
TICKERS snapshot.
Examples
--------
>>> rows = map_raw_table("/data/downloads/sharadar", "daily", owner="demo",
... store_path="/data/market/sharadar/sharadar_daily_1d/sharadar_daily_1d.zarr")
>>> rows.columns[-1]
'permaticker'
"""
resolver = PermatickerResolver(vendor_root, code, sidecar_path=sidecar_path)
frame = scan_raw_table(vendor_root, code, annotate=resolver.annotate)
if query is not None:
frame = query(frame)
report = None if store_path is None else f"{store_path}{UNMAPPED_SUFFIX}"
rows = frame.collect()
if key is not None:
rows = resolver.settle(rows, key, date_column=date_column)
return resolver.left_out(
rows,
owner=owner,
report_path=report,
date_column=date_column,
quiet=quiet,
report_unmapped=report_unmapped,
)