feat: nightly off-box backup of the live DB, and phone alerts from the daily report

The live (Tradier) stack has no backup service. Only the paper stack backs up its
own DB, so the 80-trade test's whole record lived only on the TrueNAS pool.
- deploy/backup_live_db.py, run by Task Scheduler "Momentum\LiveDbBackup" at 20:40 ET
  nightly: pg_dump -Fc (PostgreSQL 18.6 client) of the live DB to
  C:\Users\Admin\momentum-backups. It is verified with pg_restore --list (the events
  and config_overrides data must be present), keeps the newest 7, and writes
  status.json. A broken dump is deleted rather than kept.
- research/daily_report.py alerts when the last backup failed or is more than 36 h
  old. When there are alerts it sends them to Telegram, using the stack's own sender
  and the credentials in .env. --no-telegram suppresses that for dry runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B3PXXn2tHBzdv6rYtGPz86
This commit is contained in:
momentum devandClaude Opus 5.5 committed 2026-10-02 21:46:05 -05:00
1 parent 7e6f881b17
commit b3f023f6e2
2 files changed
+97 -1

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
"""Nightly off-box backup of the LIVE (Tradier) Postgres, from the owner's workstation.
The live stack on TrueNAS has no db-backup service; the paper stack backs up only its own database. This keeps the
live test's full record (events, trades, settings) somewhere other than the TrueNAS pool.
- pg_dump -Fc (compressed, restorable with pg_restore) of the live database (DATABASE_URL host, port 5434)
- verified: pg_restore --list must read it and it must contain the events and config_overrides tables
- keeps the newest KEEP dumps (default 7) in LIVE_BACKUP_DIR (default C:\\Users\\Admin\\momentum-backups)
- writes <dir>/status.json for research.daily_report to alert on a missed or failed backup
usage: python deploy/backup_live_db.py (Task Scheduler "Momentum\\LiveDbBackup", nightly)
restore: pg_restore -d <empty database> --no-owner <dump>
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parent.parent
PG_BIN = Path(os.environ.get("PG_BIN", r"C:\Users\Admin\pgsql18\bin"))
OUT = Path(os.environ.get("LIVE_BACKUP_DIR", r"C:\Users\Admin\momentum-backups"))
KEEP = int(os.environ.get("LIVE_BACKUP_KEEP", "7"))
PORT = int(os.environ.get("LIVE_DB_PORT", "5434"))
ET = ZoneInfo("America/New_York")
MUST_HAVE = ("TABLE DATA public events", "TABLE DATA public config_overrides")
def main() -> int:
from dotenv import dotenv_values
u = urlparse(dotenv_values(ROOT / ".env")["DATABASE_URL"])
OUT.mkdir(parents=True, exist_ok=True)
stamp = datetime.now(ET).strftime("%Y-%m-%d_%H%M")
dump = OUT / f"live_{stamp}.dump"
env = dict(os.environ, PGPASSWORD=u.password or "")
status = dict(at=datetime.now(ET).isoformat(timespec="seconds"), file=str(dump), ok=False)
t0 = time.time()
try:
subprocess.run([str(PG_BIN / "pg_dump.exe"), "-h", u.hostname, "-p", str(PORT), "-U", u.username,
"-d", u.path[1:], "-Fc", "-Z", "6", "-f", str(dump)],
env=env, check=True, capture_output=True, text=True, timeout=3600)
listing = subprocess.run([str(PG_BIN / "pg_restore.exe"), "--list", str(dump)],
check=True, capture_output=True, text=True, timeout=600).stdout
missing = [m for m in MUST_HAVE if m not in listing]
if missing:
raise RuntimeError(f"dump is missing {missing}")
status.update(ok=True, size_mb=round(dump.stat().st_size / 1e6, 1), seconds=round(time.time() - t0),
entries=listing.count("\n"))
except subprocess.CalledProcessError as e:
status["error"] = (e.stderr or str(e))[-400:]
except Exception as e: # noqa: BLE001
status["error"] = str(e)[-400:]
if not status["ok"] and dump.exists():
dump.unlink() # never keep a broken dump around as if it were good
dumps = sorted(OUT.glob("live_*.dump"))
for old in dumps[:-KEEP]:
old.unlink()
status["kept"] = [p.name for p in sorted(OUT.glob("live_*.dump"))]
(OUT / "status.json").write_text(json.dumps(status, indent=1), encoding="utf-8")
print(json.dumps(status, indent=1))
return 0 if status["ok"] else 1
if __name__ == "__main__":
sys.exit(main())