f/scripts/check_adj_consistency.py
Greg Pomerantz 5e5a10d725 Fix Yahoo total-distribution double count (1,957 syms)
Yahoo's dividend endpoint returns the fund's TOTAL per-share distribution
(dividend + capital gain) for many share classes while the capitalGains
endpoint returns the cap-gain portion separately; summing both double-counted
cap gains and broke pre/post-tax comparability (after-tax engine could beat
the adj-based pre-tax return: impossible).

Add _fix_total_distributions to the bundle-assembly invariants
(layout-agnostic, survives re-downloads): on same-date div>capg events, the
exact Yahoo-adj implied distribution (d = P - Q*A_{t-1}/A_t) must match the
div-file amount (not the sum) on >=3 dates before rewriting div -= capg.
Self-validating: genuine separate same-date distributions are untouched.
Rewrote 17,900 cells on 1,957 symbols.

Pool check (scripts/check_adj_consistency.py): all 14 symbols now
TR(close+events) == TR(adj) to 0.01pt and post < pre with plausible drags.

Tests: rewrite case, no-rewrite case, idempotency (tests/test_data.py).
2026-09-01 16:23:03 -04:00

145 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Check Yahoo internal consistency for a set of symbols.
For each symbol we compare three "total return" views over a window:
1. TR(Adj) : CAGR of Yahoo's Adj Close series (their dividend/split
adjustment, whatever vintage they computed it from)
2. TR(sim) : CAGR of a pre-tax reinvestment simulation built from the
RAW Close series + the declared dividend/capitalGain event
files (the same inputs the after-tax engine consumes)
3. declared yield: (sum div + sum capg) / avg price over the window
Views 1 and 2 must agree if Yahoo's adj factor was built from the same event
history as the event files. A large gap means the two data sources are
inconsistent (stale adj vintage, restated/stacked events, mislabeled events,
or double-counting of the same distribution in both files).
Also checks the "post > pre is impossible" invariant directly, and analyzes
same-date div/capG event overlap (double-listing patterns the dedup invariant
may not catch, e.g. dividend file carrying the TOTAL distribution =
dividend + capgain, with a different amount than the capGain file).
Usage: python scripts/check_adj_consistency.py [SYM ...]
(default: the pool in POOL below)
Output: stdout table + reports/adj_consistency/check.md
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
import data as D # noqa: E402
POOL = [
"bntx", "pmaix", "slg", "atesx", "cosix", "egrax", "pfe", "qspnx",
"svarx", "cvsix", "flcsx", "menkx", "flpsx", "spy",
]
START = "2016-01-01"
def cagr(a: float, b: float, yrs: float) -> float:
return (b / a) ** (1 / yrs) - 1
def sim_tr(close: pd.Series, div: pd.Series | None, capg: pd.Series | None,
start: str) -> float | None:
"""Pre-tax total-return CAGR from raw close + declared events."""
import numpy as np
c = close.loc[start:].dropna()
if len(c) < 250:
return None
parts = []
for s_ in (div, capg):
parts.append(s_.loc[start:].reindex(c.index).fillna(0.0)
if s_ is not None else pd.Series(0.0, index=c.index))
dist = parts[0] + parts[1]
prev = c.shift(1)
# standard dividend adjustment: on ex-date units *= P_prev / (P_prev - d)
adj_factor = pd.Series(1.0, index=c.index)
m = (dist > 0) & prev.notna() & (prev > dist)
adj_factor[m] = prev[m] / (prev[m] - dist[m])
units = adj_factor.cumprod() / c.iloc[0]
value = units * c
yrs = (c.index[-1] - c.index[0]).days / 365.25
return float((value.iloc[-1] / value.iloc[0]) ** (1 / yrs) - 1)
def main(syms: list[str]) -> None:
import numpy as np # noqa: F401
b = D.load_bundle()
rows = []
for s in syms:
if s not in b.close.columns:
rows.append((s, "MISSING", 0, 0, 0, 0, 0, "", ""))
continue
c = b.close[s].loc[START:].dropna()
a = b.adj[s].loc[START:].dropna()
if len(c) < 250 or len(a) < 250:
rows.append((s, "TOO_SHORT", 0, 0, 0, 0, 0, "", ""))
continue
yrs = (c.index[-1] - c.index[0]).days / 365.25
po = cagr(float(c.iloc[0]), float(c.iloc[-1]), yrs)
tr_adj = cagr(float(a.iloc[0]), float(a.iloc[-1]), yrs)
d = b.div[s].loc[START:].fillna(0.0) if s in b.div.columns else None
g = b.capg[s].loc[START:].fillna(0.0) if s in b.capg.columns else None
sdiv = float(d.sum()) if d is not None else 0.0
scap = float(g.sum()) if g is not None else 0.0
decl = (sdiv + scap) / float(c.mean())
sim = sim_tr(b.close[s], d, g, START)
gap = (sim - tr_adj) if sim is not None else float("nan")
# same-date overlap analysis
overlap = ""
if d is not None and g is not None:
both = (d > 0) & (g > 0)
n = int(both.sum())
if n:
dv, gv = d[both], g[both]
exact = int((abs(dv - gv) < 1e-6 * gv).sum())
divgt = int((dv > gv * 1.05).sum())
overlap = f"same-date {n} (exact {exact}, div>cap {divgt})"
else:
overlap = "same-date 0"
flag = ""
if sim is not None and abs(gap) > 0.003:
flag = "INCONSISTENT"
if sim is not None and sim > tr_adj + 0.001:
flag += " POST>PRE"
rows.append((s, flag, po, tr_adj, sim if sim is not None else float("nan"),
decl, gap if sim is not None else float("nan"),
f"div {sdiv:.2f} capg {scap:.2f}", overlap))
w = max(len(r[0]) for r in rows)
print(f"{'sym':<{w}} {'flag':18s} {'price-only':>10s} {'TR(adj)':>8s} "
f"{'TR(sim)':>8s} {'decl.yld':>9s} {'gap':>8s} {'dist':22s} {''}")
for r in rows:
s, flag, po, tr, sim, decl, gap, dist, ov = r
print(f"{s:<{w}} {flag:18s} {po:10.2%} {tr:8.2%} {sim:8.2%} {decl:9.2%} "
f"{gap:8.2%} {dist:22s} {ov}")
# report
rep = ROOT / "reports" / "adj_consistency"
rep.mkdir(parents=True, exist_ok=True)
with open(rep / "check.md", "w") as fh:
fh.write(f"# Adj/event consistency check (window from {START})\n\n")
fh.write("TR(sim) = pre-tax reinvestment of raw Close + declared "
"dividend/capitalGain events. Must match TR(adj) if Yahoo's\n"
"adj factor was built from the same history. POST>PRE = "
"post-tax engine\nwould beat pre-tax: impossible, data broken.\n\n")
fh.write("| sym | flag | price-only | TR(adj) | TR(sim) | decl yield | gap |\n")
fh.write("|---|---|---|---|---|---|---|\n")
for r in rows:
s, flag, po, tr, sim, decl, gap, dist, ov = r
fh.write(f"| {s} | {flag} | {po:.2%} | {tr:.2%} | {sim:.2%} | "
f"{decl:.2%} | {gap:+.2%} |\n")
fh.write("\n## details\n\n")
for r in rows:
s, flag, po, tr, sim, decl, gap, dist, ov = r
fh.write(f"- **{s}**: {dist}; {ov}\n")
print(f"\nreport: {rep / 'check.md'}")
if __name__ == "__main__":
import pandas as pd # noqa: E402 (type hint in sim_tr)
main(sys.argv[1:] or POOL)