f/scripts/check_corrections.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

94 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""Integrity check for overrides/corrections/*.json.
Every remove/replace op must reference a (date, amount) row that actually
exists in the file it targets (frozen copy if present, else the data root) —
_apply_corrections silently no-ops a remove that matches nothing, so a row
filed under the wrong section (dividends vs capitalGains) or with a stale
amount would be applied as a do-nothing correction. add ops must not
reference an existing row (double-add risk).
Usage: python3 scripts/check_corrections.py
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DATA = Path.home() / "prog/fin/stocks"
FROZEN = ROOT / "overrides" / "frozen"
CORR = ROOT / "overrides" / "corrections"
def load(sfx: str, sym: str) -> dict:
p = FROZEN / f"{sym}-{sfx}.csv"
if not p.exists():
p = DATA / f"{sym}-{sfx}.csv"
if not p.exists():
return None
out = {}
for line in p.read_text(errors="replace").splitlines()[1:]:
a = line.split(",")
if len(a) >= 2:
out.setdefault(a[0][:10], []).append(float(a[1]))
return out
def main() -> int:
bad = 0
for f in sorted(CORR.glob("*.json")):
sym = f.stem.lower()
d = json.loads(f.read_text())
files = {"dividends": load("dividend", sym),
"capitalGains": load("capitalGain", sym)}
divf = files["dividends"] or {}
capgf = files["capitalGains"] or {}
for date, amt in d.get("dedup", []):
if amt not in divf.get(date, []) and amt not in capgf.get(date, []):
print(f"BAD {sym} dedup {date} {amt}: row in neither file")
bad += 1
for date in d.get("drop_capg_copy", []):
if not capgf.get(date):
print(f"BAD {sym} drop_capg_copy {date}: no capitalGain row")
bad += 1
for date, cells in (d.get("history") or {}).items():
p = FROZEN / f"{sym}-history.csv"
if not p.exists():
p = DATA / f"{sym}-history.csv"
dates = {l.split(",")[0][:10] for l in p.read_text(errors="replace").splitlines()[1:]} if p.exists() else set()
if date not in dates:
print(f"BAD {sym} history {date}: date not in history file")
bad += 1
for col in cells:
if col not in ("Open", "High", "Low", "Close", "Adj Close", "Volume"):
print(f"BAD {sym} history {date}: unknown column {col!r}")
bad += 1
for key, rows_f in files.items():
ops = d.get(key)
if not ops or rows_f is None:
if rows_f is None:
print(f"?? {sym}: no data file for {key} correction")
continue
for date, amt in ops.get("remove", []):
if amt not in rows_f.get(date, []):
print(f"BAD {sym} {key}.remove {date} {amt}: "
f"row not present (values: {rows_f.get(date, 'missing')})")
bad += 1
for date, amt in ops.get("replace", {}).items():
if date not in rows_f:
print(f"BAD {sym} {key}.replace {date}: date not present")
bad += 1
for date, amt in ops.get("add", []):
if amt in rows_f.get(date, []):
print(f"BAD {sym} {key}.add {date} {amt}: row already present")
bad += 1
if bad:
print(f"\n{bad} problem(s) — fix the corrections above")
return 1
print(f"all {len(list(CORR.glob('*.json')))} correction files consistent")
return 0
if __name__ == "__main__":
sys.exit(main())