f/scripts/fix_adj_misalign.py
Greg Pomerantz 43ddd3ec7f Repair Yahoo adj-close/event-date misalignment (JLPSX, JLPYX)
JLPSX showed a bogus +29.3%/-22.9% 3-day wiggle in Dec 2020: Yahoo dated
the 6.824 year-end cap-gain distribution on the 12-11 record date but the
market went ex-div on 12-14 (close 30.10 -> 23.22), so the raw Adj Close
column pre-applied the adjustment 3 days before the price actually fell.

New 'history' correction op ({date: {col: value}}) patches individual OHLC
cells at bundle assembly (full build, incremental, and correction-changed
recompute paths); check_corrections validates dates/columns; 4 new tests.

scripts/scan_adj_misalign.py finds the artifact set-wide: 481 hits on 278
symbols, overwhelmingly December year-end distributions of value funds
(JLPSX's class of fund). scripts/fix_adj_misalign.py repairs it
arithmetic-only (rescale adj in [event, ex-div) by (1-f); cumulative
returns unchanged, cross-checked implied dist vs the price drop). Applied
to the only curated-fund hit (JLPSX) and its sister class JLPYX (implied
dist 6.824 both = official amount; ex-div 2020-12-14). The remaining ~276
symbols are reported in reports/adj_misalign/scan.md for a bulk run.
2026-08-31 23:08:42 -04:00

145 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Repair Yahoo adj-close / event-date misalignment (see
scripts/scan_adj_misalign.py).
For a hit date D (adj spikes, close flat, price drops later) the repair is
purely arithmetic and leaves cumulative returns unchanged:
r(t) = adj(t)/close(t); k = r(D)/r(D-1) # the bogus spike
f = 1 - 1/k # dist fraction Yahoo applied
d = f * close(D-1) # implied per-share dist
E = first t > D with close(t) < close(t-1) - 0.5*d # true ex-div date
for t in [D, E): adj(t) *= (1 - f) # restore pre-event ratio
For t before D and from E on the series is already consistent, so only the
[D, E) window is patched. The patch is written as a 'history' correction
(overrides/corrections/{SYM}.json, merged, with a note), so it survives
re-downloads that rewrite the raw CSVs with the same Yahoo values.
Usage:
python3 scripts/fix_adj_misalign.py [--only SYM[,SYM...]] [--dry-run]
"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.scan_adj_misalign import scan # noqa: E402
ROOT_DEFAULT = Path.home() / "prog/fin/stocks"
CORR = Path(__file__).resolve().parent.parent / "overrides" / "corrections"
NOTE = ("adj-close repair: Yahoo dated a distribution on {d} (record date) "
"but the price went ex-div on {e} (close {c0:.2f} -> {c1:.2f}, drop "
"~{d_amt:.3f}); the raw Adj Close column spiked {spike:+.1%} on {d} "
"and snapped back later. Cells in [{d}, {e}) rescaled by (1-f) so "
"the adjusted path is smooth; cumulative returns are unchanged. "
"Mechanism: scripts/fix_adj_misalign.py, "
"scan: reports/adj_misalign/scan.md.")
def load_rows(sym: str, root: Path):
import data as D
fr = D.FROZEN_DIR / f"{sym}-history.csv"
f = fr if (D.FROZEN_DIR.is_dir() and fr.exists()) else root / f"{sym}-history.csv"
rows = []
for l in f.read_text(errors="replace").splitlines()[1:]:
if not l.strip():
continue
a = l.split(",")
if len(a) < 6:
continue
try:
rows.append((a[0][:10], float(a[4]), float(a[5])))
except ValueError:
continue
return rows
def repair(sym: str, date: str, rows) -> dict:
"""Return {date: {"Adj Close": value}} patch, or {} if not repairable."""
i = next((k for k, r in enumerate(rows) if r[0] == date), None)
if i is None or i == 0:
return {}
dates, closes, adjs = (list(x) for x in zip(*rows))
r = [a / c for _, c, a in rows]
if r[i - 1] <= 0 or r[i] <= 0 or r[i] <= r[i - 1]:
return {}
f = 1.0 - r[i - 1] / r[i]
if not (0.005 < f < 0.95):
return {}
d_amt = f * closes[i - 1]
E = None
for t in range(i + 1, min(i + 11, len(rows))):
if closes[t] < closes[t - 1] - 0.5 * d_amt:
E = t
break
if E is None:
return {}
k = 1.0 - f
patch = {}
for t in range(i, E):
patch[dates[t]] = {"Adj Close": round(adjs[t] * k, 6)}
return patch, f, d_amt, E
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--data", type=Path, default=ROOT_DEFAULT)
ap.add_argument("--only", default="", help="comma-separated symbols")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
only = {s.strip().lower() for s in args.only.split(",") if s.strip()}
hits = scan(args.data, 0.05, 0.02, 3)
if only:
hits = [h for h in hits if h["symbol"] in only]
by_sym: dict[str, list] = {}
for h in hits:
by_sym.setdefault(h["symbol"], []).append(h)
applied = 0
for sym, hs in sorted(by_sym.items()):
rows = load_rows(sym, args.data)
cells, notes = {}, []
for h in hs:
try:
patch, f, d_amt, E = repair(sym, h["date"], rows)
except Exception as ex:
print(f" !! {sym} {h['date']}: {ex}")
continue
if not patch:
print(f" -- {sym} {h['date']}: not repairable (skipped)")
continue
cells.update(patch)
dates = [x[0] for x in rows]
di = next(k for k, r in enumerate(rows) if r[0] == h["date"])
notes.append(NOTE.format(d=h["date"], e=dates[E], c0=rows[di - 1][1],
c1=rows[E][1], d_amt=d_amt,
spike=h["adj_ret"]))
print(f" {sym} {h['date']}: patch {len(patch)} cell(s), "
f"implied dist {d_amt:.3f}, ex-div {dates[E]}")
if not cells:
continue
pf = CORR / f"{sym.upper()}.json"
d = json.loads(pf.read_text()) if pf.exists() else {"symbol": sym}
d.setdefault("symbol", sym)
d.setdefault("note", "")
if notes:
d["note"] = (d["note"] + " " if d["note"] else "") + \
" ".join(notes).strip()
d.setdefault("history", {})
for dt, cells_ in cells.items():
d["history"].setdefault(dt, {}).update(cells_)
if not args.dry_run:
pf.write_text(json.dumps(d, indent=2) + "\n")
applied += 1
print(f"{'(dry) ' if args.dry_run else ''}wrote {pf.name}: "
f"{sum(len(v) for v in d['history'].values())} cell(s)")
print(f"{'dry-run: ' if args.dry_run else ''}{applied} symbol(s) repaired "
f"of {len(by_sym)} candidate(s)")
return 0
if __name__ == "__main__":
main()