- 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>
83 lines
3.0 KiB
Python
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']}"
|