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
132 lines
5.7 KiB
Python
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())
|