Files
fin/.claude/skills/fin-diagnostics-and-tooling/scripts/check_freshness.py
T
EislM0203andClaude Opus 5 cb97d97d81 docs: check in the fin skill library, ignore local Claude settings
Twelve skills that had been sitting uncommitted in the working tree: the
architecture contract, accounting reference, change-control rulebook, failure
archaeology, debugging playbook, diagnostics scripts, ledger-analysis toolkit,
research frontier, run/operate runbook, build-and-env, test-coverage campaign,
and validation/QA guide.

Updates three frontier statuses to match what actually shipped today rather
than checking in a roadmap that describes closed work as open:

- A1 double-entry hardening -> partly-done. The create half is enforced; what
  remains is running the existing audit_linked_legs.py against a prod backup.
- B3 safety rails -> partly-done. Idempotency shipped, including the note that
  the index is not unique-when-not-null and why. dry_run is still open.
- D3 CSV import -> unblocked but still gated. Its stated blocker was B3's
  idempotency keys, which now exist.

Also gitignores .claude/settings.local.json, which is per-machine and was
showing up as untracked on every status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3qYLuu9jgXsCRXmJ18YGY
2026-08-22 22:09:22 +00:00

132 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Check price_cache freshness, required FX rows, and net_worth_snapshot gaps.
READ-ONLY. Usage:
python3 check_freshness.py /path/to/fin.db [--hours-stale 26] [--days 30] [--json]
Ground truth (as of 2026-07-05):
- api/app/services/price_fetcher.py fetches EUR/USD and EUR/GBP from
frankfurter.app plus one yfinance price per tracked ticker. Tracked =
holdings with shares > 0 UNION tickers of pending (unvested) rsu_grants.
- api/app/services/scheduler.py refreshes prices every 5 minutes and writes
one net_worth_snapshot per calendar day at midnight (date.today() of the
api container). fetched_at is datetime.utcnow().
- api/app/services/net_worth.py falls back to EUR/USD = 1.0 when the row is
missing (silently wrong totals) and values a ticker without a price row
at 0.0. Missing EUR/GBP excludes GBP amounts entirely (_to_eur returns 0).
Anomalies:
- price_cache row older than --hours-stale (default 26h; healthy is minutes
while the api container is up, thanks to the 5-minute job)
- price_cache row with price <= 0
- EUR/USD row missing (always required)
- EUR/GBP row missing while anything GBP exists (account, holding, or cached
price in GBP)
- tracked ticker with no price_cache row at all
- missing snapshot days in the last --days calendar days (window ends today,
local date)
Exit 0 = clean, 1 = anomalies found.
"""
import argparse
import json
import sqlite3
import sys
from datetime import date, datetime, timedelta, timezone
def parse_dt(s: str) -> datetime | None:
try:
return datetime.fromisoformat(s.replace("T", " "))
except (ValueError, TypeError):
return None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("db")
ap.add_argument("--hours-stale", type=float, default=26.0)
ap.add_argument("--days", type=int, default=30)
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
con = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
now = datetime.now(timezone.utc).replace(tzinfo=None) # fetched_at is utcnow()
anomalies: list[dict] = []
def flag(check, detail):
anomalies.append({"check": check, "detail": detail})
prices = {r["symbol"]: dict(r) for r in con.execute(
"SELECT symbol, price, currency, source, fetched_at FROM price_cache")}
report = []
for sym, p in sorted(prices.items()):
dt = parse_dt(p["fetched_at"])
age_h = round((now - dt).total_seconds() / 3600, 1) if dt else None
report.append({"symbol": sym, "price": p["price"], "currency": p["currency"],
"source": p["source"], "age_hours": age_h})
if age_h is None:
flag("unparseable-fetched-at", f"{sym}: fetched_at={p['fetched_at']!r}")
elif age_h > args.hours_stale:
flag("stale-price", f"{sym}: {age_h}h old (> {args.hours_stale}h)")
if p["price"] is None or p["price"] <= 0:
flag("zero-price", f"{sym}: cached price {p['price']} — net worth "
f"values it at 0")
# required FX rows
if "EUR/USD" not in prices:
flag("missing-fx", "EUR/USD not in price_cache — net worth silently "
"falls back to rate 1.0")
gbp_seen = con.execute(
"SELECT EXISTS(SELECT 1 FROM accounts WHERE currency='GBP') "
"OR EXISTS(SELECT 1 FROM holdings WHERE currency='GBP') "
"OR EXISTS(SELECT 1 FROM price_cache WHERE currency='GBP' "
" AND symbol NOT LIKE 'EUR/%')").fetchone()[0]
if gbp_seen and "EUR/GBP" not in prices:
flag("missing-fx", "EUR/GBP not in price_cache but GBP data exists — "
"GBP amounts are excluded from net worth (_to_eur -> 0)")
# tracked tickers must have a price row
tracked = [r[0] for r in con.execute(
"SELECT DISTINCT ticker FROM holdings WHERE shares > 0 "
"UNION SELECT DISTINCT ticker FROM rsu_grants WHERE vested_at IS NULL")]
for t in tracked:
if t not in prices:
flag("missing-ticker-price", f"{t} is tracked (holding or pending "
f"grant) but has no price_cache row")
# snapshot gaps
today = date.today()
start = today - timedelta(days=args.days - 1)
have = {r[0] for r in con.execute(
"SELECT snapshot_date FROM net_worth_snapshots WHERE snapshot_date >= ?",
(start.isoformat(),))}
missing = [(start + timedelta(days=i)).isoformat()
for i in range(args.days)
if (start + timedelta(days=i)).isoformat() not in have]
for m in missing:
flag("missing-snapshot", f"no net_worth_snapshot for {m}")
if args.json:
print(json.dumps({"prices": report, "tracked_tickers": tracked,
"snapshot_window": [start.isoformat(), today.isoformat()],
"missing_snapshot_days": missing,
"anomalies": anomalies}, indent=2))
else:
print(f"{'symbol':<12} {'price':>12} {'ccy':<4} {'source':<11} {'age_h':>7}")
for p in report:
print(f"{p['symbol']:<12} {p['price']:>12.4f} {p['currency']:<4} "
f"{p['source']:<11} {p['age_hours'] if p['age_hours'] is not None else '?':>7}")
print(f"tracked tickers: {', '.join(tracked) or '(none)'}")
print(f"snapshot window {start} .. {today}: "
f"{args.days - len(missing)}/{args.days} days present")
for a in anomalies:
print(f"ANOMALY [{a['check']}]: {a['detail']}")
print("CLEAN" if not anomalies else f"{len(anomalies)} anomalies")
return 1 if anomalies else 0
if __name__ == "__main__":
sys.exit(main())