Files
momentum devandClaude Fable 5 12f7fefec0 feat: nightly event retention + external telemetry push
- runtime/retention.py: prune 8 high-volume debug event types older than
  RETENTION_DAYS (default 14) once/day at RETENTION_HOUR (default 03:00).
  Audit events (orders, fills, closes, risk rules) are never pruned. The
  events table hit 6 GB in 3 weeks at ~340k rows/day; this caps it.
- runtime/telemetry_push.py: POST a JSON snapshot (equity, day realized
  P&L, trades/wins, open positions, last-event age) to TELEMETRY_PUSH_URL
  every TELEMETRY_PUSH_INTERVAL_SECONDS (default 300). Unset URL = step
  disabled. Push failures are reported in the step result, never raised —
  an external outage can't touch the loop. Works as healthchecks.io
  dead-man ping + payload log in one.
- wired both as scheduler steps in run_live_paper (retention gated by
  RETENTION_ENABLED, telemetry by TELEMETRY_PUSH_URL presence)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:25:09 -05:00

83 lines
3.0 KiB
Python

"""Push a loop-health + day-P&L snapshot to an external webhook.
Point TELEMETRY_PUSH_URL at anything that accepts a POST body — a
healthchecks.io ping URL (gives dead-man alerting + a log of every payload),
a Grafana Cloud / Loki push proxy, or any collector. Unset = disabled. A
push failure never breaks the loop; the failure is the scheduler step's
return string so it shows in the loop console.
"""
import json
import os
import urllib.request
from datetime import datetime
def _payload_of(event: dict) -> dict:
try:
return json.loads(event.get("payload_json") or "{}")
except (ValueError, TypeError):
return {}
def build_payload(store, broker: str = "unknown",
now: datetime | None = None) -> dict:
now = now or datetime.now()
today = now.date().isoformat()
out = {
"ts": now.isoformat(timespec="seconds"),
"broker": broker,
"equity": None,
"day_realized": 0.0,
"day_trades": 0,
"day_wins": 0,
"open_positions": None,
"last_event_age_s": None,
}
# newest account snapshot today (query_events is ASC — take the last row)
acct = store.query_events(event_type="account_summary_updated",
since=today, limit=5000)
if acct:
out["equity"] = _payload_of(acct[-1]).get("total_equity")
pos = store.query_events(event_type="account_positions_updated",
since=today, limit=5000)
if pos:
held = _payload_of(pos[-1]).get("positions")
out["open_positions"] = len(held) if isinstance(held, list) else None
for e in store.query_events(event_type="position_closed",
since=today, limit=500):
pnl = float(_payload_of(e).get("realized_pnl") or 0.0)
out["day_realized"] = round(out["day_realized"] + pnl, 2)
out["day_trades"] += 1
out["day_wins"] += 1 if pnl > 0 else 0
latest = store.max_timestamp()
if latest is not None:
try:
out["last_event_age_s"] = int((now - latest).total_seconds())
except TypeError: # tz-aware vs naive mismatch — skip rather than crash
pass
return out
def push(url: str, payload: dict, timeout: float = 5.0) -> None:
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(req, timeout=timeout).read()
def push_snapshot(store, broker: str = "unknown") -> str:
"""Scheduler step: build + POST one snapshot. Never raises."""
url = os.environ.get("TELEMETRY_PUSH_URL", "").strip()
if not url:
return "disabled"
payload = build_payload(store, broker=broker)
try:
push(url, payload)
except Exception as exc: # noqa: BLE001 — external outage must not kill the loop
return f"push failed: {exc}"
return f"pushed pnl={payload['day_realized']:+.0f} trades={payload['day_trades']}"