f/data.py
Greg Pomerantz 9263283995 Corrections overlay: per-symbol distribution fixes survive re-downloads
overrides/corrections/{SYM}.json (git-tracked, with as_of/source/note)
holds remove/replace/add ops on the dividend and capital-gain series.
data.py applies them on top of whatever the data root (or the frozen
snapshot) provides, in both the full build and the incremental refresh
path, and the corrections dir joins the cache manifest so a change
invalidates the cache. A goget re-download of the base CSV can never
clobber a confirmed correction. Format and usage documented in data.py.
2026-08-31 14:49:39 -04:00

497 lines
19 KiB
Python

"""Data ingestion: Yahoo Finance CSV dumps -> parquet cache.
Layout expected in the data root (default ~/prog/fin/stocks):
{SYM}-history.csv Date,Open,High,Low,Close,Adj Close,Volume
{SYM}-dividend.csv Date,Dividends (optional)
{SYM}-capitalGain.csv Date,Capital Gains (optional)
{SYM}.json Yahoo chart API payload (metadata only)
All panels are date x symbol DataFrames. The "adj" panel (Adj Close)
already bakes in dividends and capital gains, so it is the correct
input for pre-tax total returns. The dividend/capgain panels are used
only by the tax engine (to tax distributions).
First call builds a parquet cache (default: .cache/) in ~1 min for
4k symbols; subsequent calls load in well under a second.
"""
from __future__ import annotations
import json
import os
import threading
import time
from dataclasses import dataclass
from pathlib import Path
import pandas as pd
DEFAULT_ROOT = Path("~/prog/fin/stocks").expanduser()
CACHE_DIR = Path(__file__).parent / ".cache"
# Frozen overrides: stale (Yahoo-empty) tickers whose final series is
# snapshotted here by scripts/audit_stale.py. For symbols that have a
# frozen copy, the data-root files are ignored so a future goget
# re-download can never clobber the last known-good series.
FROZEN_DIR = Path(__file__).parent / "overrides" / "frozen"
# Corrections: per-symbol distribution fixes confirmed against official
# sources (see scripts/verify_official.py). Applied on top of whatever the
# data root / frozen dir says, so re-downloads can never clobber them.
# Format, one file per symbol in overrides/corrections/:
# {"dividends": {"remove": [["2008-12-18", 0.292], ...],
# "replace": {"2008-12-18": 0.0},
# "add": [["2007-12-18", 0.292], ...]},
# "capitalGains": {...same ops...},
# "as_of": "2026-08-31", "source": "<filing URL>", "note": "..."}
CORRECTIONS_DIR = Path(__file__).parent / "overrides" / "corrections"
_corr_cache: dict = {}
def _load_corrections(sym: str) -> dict:
f = CORRECTIONS_DIR / f"{sym.upper()}.json"
if f.exists():
try:
if f not in _corr_cache or _corr_cache[f][0] != f.stat().st_mtime_ns:
_corr_cache[f] = (f.stat().st_mtime_ns,
json.loads(f.read_text()))
return _corr_cache[f][1]
except Exception:
return {}
return {}
def _apply_corrections(sym: str, suffix: str, ser: pd.Series) -> pd.Series:
key = {"dividend": "dividends", "capitalGain": "capitalGains"}.get(suffix)
ops = _load_corrections(sym).get(key) if key else None
if not ops:
return ser
d = {ts: float(v) for ts, v in ser.items()}
for r in ops.get("remove", []):
ts = pd.Timestamp(r[0])
amt = r[1] if len(r) > 1 else None
if ts in d and (amt is None or abs(d[ts] - amt) < 1e-9):
del d[ts]
for ts_s, amt in ops.get("replace", {}).items():
d[pd.Timestamp(ts_s)] = float(amt)
for r in ops.get("add", []):
ts = pd.Timestamp(r[0])
d[ts] = d.get(ts, 0.0) + float(r[1])
if not d:
return pd.Series(dtype=float)
return pd.Series(d).sort_index()
_SUFFIXES = ("history", "dividend", "capitalGain")
_PANELS = ("adj", "close", "div", "capg")
@dataclass
class Bundle:
adj: pd.DataFrame # adjusted close (total-return adjusted)
close: pd.DataFrame # raw close
div: pd.DataFrame # dividend distributions per share
capg: pd.DataFrame # capital gain distributions per share
names: dict # symbol -> long name (best effort)
def _read_csv_clean(f: Path) -> pd.DataFrame:
df = pd.read_csv(f, index_col=0)
df.index = pd.to_datetime(df.index, format="mixed", errors="coerce")
df = df[~df.index.isna()]
df = df[~df.index.duplicated(keep="last")].sort_index()
return df
def frozen_syms() -> set:
"""Symbols with a frozen history copy in the overrides dir."""
if not FROZEN_DIR.is_dir():
return set()
return {f.stem[: -len("-history")] for f in FROZEN_DIR.glob("*-history.csv")}
def _panel_paths(root: Path, suffix: str) -> list[tuple[str, Path]]:
"""(symbol, path) for every panel file; frozen copies shadow data-root."""
frozen = frozen_syms()
out = []
for f in sorted(root.glob(f"*-{suffix}.csv")):
sym = f.name[: -len(f"-{suffix}.csv")]
if sym in frozen:
continue
out.append((sym, f))
for f in sorted(FROZEN_DIR.glob(f"*-{suffix}.csv")) if FROZEN_DIR.is_dir() else []:
out.append((f.name[: -len(f"-{suffix}.csv")], f))
return out
def _read_panel(root: Path, suffix: str, col: str) -> pd.DataFrame:
cols, syms = [], []
for sym, f in _panel_paths(root, suffix):
try:
df = _read_csv_clean(f)
except Exception:
continue
if col in df.columns:
cols.append(_apply_corrections(sym, suffix, df[col]))
syms.append(sym)
if not cols:
return pd.DataFrame()
out = pd.concat(cols, axis=1)
out.columns = syms
return out.sort_index()
def _read_name(f: Path) -> str:
try:
meta = json.loads(f.read_text())["chart"]["result"][0]["meta"]
return meta.get("longName", f.stem)
except Exception:
return f.stem
def _read_names(root: Path, max_files: int = 5000) -> dict:
files = sorted(root.glob("*.json"))[:max_files]
return {f.stem: _read_name(f) for f in files}
# ---------------------------------------------------------------- caching
# a bundle is ~2 GB resident; memoize the last one so Streamlit re-runs
# (every widget change!) don't reload the parquet panels
_BUNDLE_CACHE: dict[tuple, Bundle] = {}
_LOCK = threading.Lock()
# Background cache maintenance. The data root is written by an independent
# downloader (goget) while Streamlit serves requests; the two must not
# interfere. `load_bundle` therefore never refreshes on the request path:
# it serves the in-memory bundle immediately and, if the data dir changed,
# a background thread refreshes the cache at its own pace.
WATCH_INTERVAL = 5.0 # seconds between staleness scans
REFRESH_CADENCE = 30.0 # min seconds between actual cache rebuilds
_watchers: dict[tuple, threading.Thread] = {}
_gen: dict[tuple, int] = {} # (root, cache) -> bundle generation counter
def _scan_files(root: Path) -> dict:
"""{filename: (mtime_ns, size)} for every csv/json file in the data root
plus the frozen overrides (namespaced under 'frozen/')."""
out = {}
with os.scandir(root) as it:
for e in it:
if e.is_file() and e.name.endswith((".csv", ".json")):
st = e.stat()
out[e.name] = (st.st_mtime_ns, st.st_size)
if FROZEN_DIR.is_dir():
with os.scandir(FROZEN_DIR) as it:
for e in it:
if e.is_file() and e.name.endswith((".csv", ".json")):
st = e.stat()
out["frozen/" + e.name] = (st.st_mtime_ns, st.st_size)
if CORRECTIONS_DIR.is_dir():
with os.scandir(CORRECTIONS_DIR) as it:
for e in it:
if e.is_file() and e.name.endswith(".json"):
st = e.stat()
out["corrections/" + e.name] = (st.st_mtime_ns, st.st_size)
return out
def _manifest_path(cache: Path) -> Path:
return cache / "manifest.json"
def _cache_up_to_date(root: Path, cache: Path) -> bool:
"""Cheap staleness check: dir scan vs manifest.
A scan is a handful of milliseconds for ~16k files, so this is done on
every call (caching the scan result would mask data updates)."""
new = _scan_files(root)
try:
old = json.loads(_manifest_path(cache).read_text())
except Exception:
return False
return {f: tuple(v) for f, v in old.items()} == new
def _full_build(root: Path, cache: Path, cache_files: list) -> None:
"""(Re)read every CSV and write all parquet panels + manifest."""
adj = _read_panel(root, "history", "Adj Close")
close = _read_panel(root, "history", "Close")
div = _read_panel(root, "dividend", "Dividends").fillna(0.0)
capg = _read_panel(root, "capitalGain", "Capital Gains").fillna(0.0)
for df, f in zip((adj, close, div, capg), cache_files):
df.to_parquet(f)
_manifest_path(cache).write_text(json.dumps(_scan_files(root)))
def _apply_changes(root: Path, cache: Path, old: dict, new: dict) -> None:
"""Incremental refresh: re-read only files that were added/changed,
drop symbols whose files vanished, refresh names, rewrite the touched
parquet panels.
Per touched panel this is ONE read + ONE concat + ONE write (inserting
column-by-column into a ~500 MB frame is prohibitively slow).
"""
old = {f: tuple(v) for f, v in old.items()} # JSON round-trip turns tuples into lists
changed = [f for f, v in new.items() if old.get(f) != v]
removed = [f for f in old if f not in new]
if not changed and not removed:
return
def _suffix(fname: str):
for sfx in _SUFFIXES:
if fname.endswith(f"-{sfx}.csv"):
return sfx, fname[: -len(f"-{sfx}.csv")]
return None, None
def _path(fname: str) -> Path:
# frozen/ namespaced files live in the overrides dir
return FROZEN_DIR / fname[8:] if fname.startswith("frozen/") else root / fname
updates: dict[str, dict] = {k: {} for k in _PANELS} # panel -> {sym: series}
drops: set[str] = set()
# data-root files of frozen symbols are ignored: their canonical series
# lives in the overrides dir, which is tracked in the same scan
frozen = frozen_syms()
def _recompute_corrected(symfile: str) -> None:
"""corrections/{SYM}.json changed: re-derive the symbol's
dividend/capital-gain series from the base file + corrections."""
for sfx, colname, key in (("dividend", "Dividends", "div"),
("capitalGain", "Capital Gains", "capg")):
sym = None
p = None
for case in (symfile[:-5].lower(), symfile[:-5].upper()):
for base in (FROZEN_DIR, root):
q = base / f"{case}-{sfx}.csv"
if q.exists():
sym, p = case, q
break
if sym:
break
if p is None:
continue
try:
df = _read_csv_clean(p)
except Exception:
continue
if colname not in df.columns:
continue
updates[key][sym] = _apply_corrections(sym, sfx, df[colname]) \
.fillna(0.0)
for fname in changed:
if fname.startswith("corrections/"):
_recompute_corrected(fname[12:])
continue
sfx, sym = _suffix(fname)
if sfx is None:
continue
if not fname.startswith("frozen/") and sym in frozen:
continue
try:
df = _read_csv_clean(_path(fname))
except Exception:
continue # e.g. a file mid-download
if sfx == "history":
if "Adj Close" in df.columns:
updates["adj"][sym] = df["Adj Close"]
if "Close" in df.columns:
updates["close"][sym] = df["Close"]
elif sfx == "dividend" and "Dividends" in df.columns:
updates["div"][sym] = _apply_corrections(sym, sfx, df["Dividends"]) \
.fillna(0.0)
elif sfx == "capitalGain" and "Capital Gains" in df.columns:
updates["capg"][sym] = _apply_corrections(sym, sfx, df["Capital Gains"]) \
.fillna(0.0)
for fname in removed:
sfx, sym = _suffix(fname)
if fname.startswith("corrections/"):
_recompute_corrected(fname[12:])
continue
if fname.startswith("frozen/"):
continue # frozen files are not data-root symbols
if sym in frozen:
continue # data-root file of a frozen symbol vanished; the
# frozen copy remains authoritative
sfx, sym = _suffix(fname)
if sfx is not None:
drops.add(sym)
# names: only the json metadata files that changed
j_changed = [f for f in changed if f.endswith(".json")]
j_removed = [f for f in removed if f.endswith(".json")]
if j_changed or j_removed:
names_path = cache / "names.json"
try:
names = json.loads(names_path.read_text())
except Exception:
names = {}
for f in j_removed:
names.pop(f[:-5], None)
for f in j_changed:
names[f[:-5]] = _read_name(root / f)
names_path.write_text(json.dumps(names))
for k in _PANELS:
upd = {s: ser for s, ser in updates[k].items() if s not in drops}
if not upd and not drops:
continue
panel = pd.read_parquet(cache / f"panel_{k}.parquet")
old = {s: panel[s] for s in upd if s in panel.columns}
drop_cols = [s for s in (drops | set(upd)) if s in panel.columns]
if drop_cols:
panel = panel.drop(columns=drop_cols)
if upd:
extras = []
for sym, s in upd.items():
s = s.rename(sym)
if sym in old:
# new values win where present; keep old values for dates
# the new file doesn't cover (e.g. a partial re-download)
uidx = panel.index.union(s.index)
s = s.reindex(uidx).combine_first(old[sym].reindex(uidx))
extras.append(s)
extra = pd.concat(extras, axis=1)
extra.columns = list(upd)
panel = pd.concat([panel, extra], axis=1).sort_index()
panel.to_parquet(cache / f"panel_{k}.parquet")
def up_to_date(root: Path = DEFAULT_ROOT, cache: Path = CACHE_DIR) -> bool:
"""Cheap check whether the cache matches the data dir right now."""
return _cache_up_to_date(Path(root), Path(cache))
def generation(root: Path = DEFAULT_ROOT, cache: Path = CACHE_DIR) -> int:
"""Monotonic counter, bumped each time the bundle is replaced.
Feed it into st.cache_data keys so cached computations invalidate when
the data changes."""
return _gen.get((str(root), str(cache)), 0)
def refresh(root: Path = DEFAULT_ROOT, cache: Path = CACHE_DIR,
rebuild: bool = False) -> Bundle:
"""Synchronously refresh the parquet cache if stale and return the bundle.
The cache tracks the data dir via a manifest of (mtime, size) per file:
changed/added files are re-read and merged in incrementally, removed
files are dropped, so an updated download refreshes in seconds instead
of the ~1 min full rebuild. `rebuild=True` forces the full rebuild.
"""
root, cache = Path(root), Path(cache)
key = (str(root), rebuild)
with _LOCK:
hit = _BUNDLE_CACHE.get(key)
if hit is not None and not rebuild and _cache_up_to_date(root, cache):
return hit
cache.mkdir(parents=True, exist_ok=True)
cache_files = [cache / f"panel_{k}.parquet" for k in _PANELS]
cache_ok = all(f.exists() for f in cache_files)
if rebuild or not cache_ok:
_full_build(root, cache, cache_files)
else:
try:
old = json.loads(_manifest_path(cache).read_text())
except Exception:
old = None
new = _scan_files(root)
if old is None:
# parquet without a manifest: state unknown -> rebuild once
_full_build(root, cache, cache_files)
else:
_apply_changes(root, cache, old, new)
_manifest_path(cache).write_text(json.dumps(new))
panels = {k: pd.read_parquet(f) for k, f in zip(_PANELS, cache_files)}
names_path = cache / "names.json"
if names_path.exists():
names = json.loads(names_path.read_text())
else:
names = _read_names(root)
names_path.write_text(json.dumps(names))
bundle = Bundle(panels["adj"], panels["close"], panels["div"],
panels["capg"], names)
# keep only the newest bundle: there is no room for two
_BUNDLE_CACHE.clear()
_BUNDLE_CACHE[key] = bundle
_gen[(str(root), str(cache))] = _gen.get((str(root), str(cache)), 0) + 1
return bundle
def load_bundle(root: Path = DEFAULT_ROOT, cache: Path = CACHE_DIR,
rebuild: bool = False) -> Bundle:
"""Return the current bundle without blocking on data changes.
The first call (and `rebuild=True`) build synchronously — there is
nothing to serve yet. Otherwise the in-memory bundle is returned
immediately; if the data dir has changed, a background thread refreshes
the cache on its own schedule (every REFRESH_CADENCE seconds while the
downloader keeps writing), so serving requests and ingesting new data
run independently.
"""
root, cache = Path(root), Path(cache)
key = (str(root), rebuild)
with _LOCK:
hit = _BUNDLE_CACHE.get(key)
if hit is None:
return refresh(root, cache, rebuild)
if rebuild:
return refresh(root, cache, True)
if not _cache_up_to_date(root, cache):
_ensure_watcher(root, cache, key)
return hit
def _ensure_watcher(root: Path, cache: Path, key: tuple) -> None:
wk = (str(root), str(cache))
t = _watchers.get(wk)
if t is not None and t.is_alive():
return
t = threading.Thread(target=_watcher_loop, args=(root, cache, key),
daemon=True)
_watchers[wk] = t
t.start()
def _watcher_loop(root: Path, cache: Path, key: tuple) -> None:
last = 0.0
while True:
time.sleep(WATCH_INTERVAL)
with _LOCK:
active = _BUNDLE_CACHE.get(key) is not None
if not active:
return # bundle evicted (another key loaded) -> stop
if _cache_up_to_date(root, cache):
continue
now = time.monotonic()
if now - last < REFRESH_CADENCE:
continue
try:
refresh(root, cache)
except Exception:
continue # transient (e.g. partial read); retry next tick
last = time.monotonic()
def search_symbols(bundle: Bundle, query: str, limit: int = 200) -> list[str]:
"""Case-insensitive search over symbols and names.
Ranked: exact symbol, symbol prefix, name prefix, symbol substring,
name substring.
"""
q = query.lower()
buckets: dict[int, list[str]] = {}
for s in bundle.adj.columns:
sl, nl = s.lower(), bundle.names.get(s, "").lower()
if sl == q:
buckets.setdefault(0, []).append(s)
elif sl.startswith(q):
buckets.setdefault(1, []).append(s)
elif nl.startswith(q):
buckets.setdefault(2, []).append(s)
elif q in sl:
buckets.setdefault(3, []).append(s)
elif q in nl:
buckets.setdefault(4, []).append(s)
out: list[str] = []
for k in sorted(buckets):
out.extend(buckets[k])
return out[:limit]