Files
momentum-system/storage/db_pg.py
T
momentum devandClaude Opus 4.8 f5dc7a8faa Cleanup: tidy the exit-manager / exits / db_pg changes (post-review)
Focused review pass on the session's safety-critical additions, applying the safe wins:
- exit_manager: drop the dead `_Tracked.last_stop` field (written, never read) and its
  assignment; guard `_rest_protective_stop` against a degenerate qty<1 (a 0-qty submit
  is rejected and would churn) + document the cur-falsy->breakeven fallback; reword the
  synthetic-R comment (the branch is also hit on a healthy trade once breakeven ratchets
  the stop to entry, not only the stripped-stop case).
- exits: surface default_trail_r_pct in ExitConfig.describe() (boot-log observability)
  and clamp it to [0, 0.95] in from_env (a >=1 / negative synthetic R would put the stop
  at/above entry).
- db_pg: comment the gate-vs-try-order distinction of _LAN_DB_HOSTS (superset incl.
  localhost) vs _LAN_DB_FALLBACKS so a future edit can't break the non-LAN-DSN guard.
- momentum_cli: drop an unused `import sys`.
Behavior-preserving; 29/29 exit/exits tests green. Deliberately did NOT extract a shared
flatten-vs-rest helper for the catastrophe/naked blocks — the explicit blocks read
clearer for this safety code and the tests guard against drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 22:53:24 -04:00

283 lines
10 KiB
Python

"""PostgreSQL connection layer for the momentum single datastore.
A thin DB-API compatibility shim over psycopg2 that mimics the small slice of
DuckDB's connection API the codebase actually uses:
con.execute(sql, params).fetchall() / .fetchone() / .df()
con.executemany(sql, rows)
con.cursor().execute(sql, params).fetchall()
con.commit() / con.close()
so the existing call sites in research/query.py, storage/event_store.py and the
ingestion upserts keep working with minimal change. This is deliberately NOT a
swappable-backend abstraction or an ORM — it is Postgres-only, and exists only
to absorb two mechanical dialect differences:
* placeholders: callers write DuckDB-style ``?``; we rewrite to psycopg2 ``%s``
(escaping any literal ``%`` to ``%%`` first).
* auto-persist: DuckDB persists each statement immediately. We set
``autocommit = True`` so the same call sites (some of which never call
commit) behave identically; EventStore's explicit commit() becomes a no-op.
Connection config comes from the environment (loaded from .env by the app):
DATABASE_URL, or PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE. For local dev/tests
PGHOST defaults to the unix-socket Postgres at /home/philip/pgrun.
Test isolation: a db_path of ":memory:" (what the DuckDB tests passed for a
fresh database) maps to a throwaway Postgres schema that is dropped on close —
giving each test the same isolation DuckDB ":memory:" gave it.
"""
from __future__ import annotations
import os
import urllib.parse as _urlparse
import uuid
from pathlib import Path
from typing import Any
import pandas as pd
import psycopg2
from psycopg2.extras import execute_batch
SCHEMA_SQL_PATH = Path(__file__).with_name("pg_schema.sql")
def _translate(sql: str) -> str:
"""DuckDB ``?`` placeholders -> psycopg2 ``%s`` (escaping literal ``%``)."""
return sql.replace("%", "%%").replace("?", "%s")
def _schema_sql() -> str:
return SCHEMA_SQL_PATH.read_text()
class _Result:
"""Wraps a cursor so DuckDB-style ``.execute(...).fetchall()/.df()`` works."""
def __init__(self, cur):
self._cur = cur
def fetchall(self) -> list[tuple]:
return self._cur.fetchall()
def fetchone(self):
return self._cur.fetchone()
def df(self) -> pd.DataFrame:
cols = [d[0] for d in self._cur.description] if self._cur.description else []
return pd.DataFrame(self._cur.fetchall(), columns=cols)
class _Cursor:
"""psycopg2 cursor facade that translates ``?`` placeholders."""
def __init__(self, cur):
self._cur = cur
def execute(self, sql: str, params: Any = None) -> "_Cursor":
self._cur.execute(_translate(sql), params)
return self
def executemany(self, sql: str, seq) -> "_Cursor":
self._cur.executemany(_translate(sql), list(seq))
return self
def fetchall(self) -> list[tuple]:
return self._cur.fetchall()
def fetchone(self):
return self._cur.fetchone()
@property
def description(self):
return self._cur.description
def close(self) -> None:
self._cur.close()
class PgConnection:
"""DuckDB-ish facade over a psycopg2 connection (autocommit)."""
def __init__(self, conn, temp_schema: str | None = None):
self._conn = conn
self._temp_schema = temp_schema
def _reconnect(self) -> None:
try:
self._conn.close()
except Exception:
pass
self._conn = _connect()
def _run(self, fn):
"""Run a DB op, reconnecting ONCE on a dropped connection.
A long-lived connection (e.g. the `momentum watch` board) gets closed by
the server on idle/restart; without this the next query raised
OperationalError and crashed. Isolated test schemas (temp_schema) can't
be safely re-created, so they don't auto-reconnect.
"""
try:
return fn()
except (psycopg2.OperationalError, psycopg2.InterfaceError):
if self._temp_schema:
raise
self._reconnect()
return fn()
# --- DuckDB-style convenience -------------------------------------
def execute(self, sql: str, params: Any = None) -> _Result:
def go():
cur = self._conn.cursor()
cur.execute(_translate(sql), params)
return _Result(cur)
return self._run(go)
def executemany(self, sql: str, seq) -> "PgConnection":
seq = list(seq)
def go():
cur = self._conn.cursor()
# execute_batch sends page_size statements per round-trip; plain
# psycopg2 executemany sends ONE per row. Over the remote LAN
# Postgres that was ~771 round-trips for a daily-bar upsert and
# froze the live loop's discovery step for ~40s each pass.
execute_batch(cur, _translate(sql), seq, page_size=200)
cur.close()
return self
return self._run(go)
# --- psycopg2-style passthrough -----------------------------------
def cursor(self) -> _Cursor:
return self._run(lambda: _Cursor(self._conn.cursor()))
def commit(self) -> None: # no-op under autocommit; kept for callers
self._conn.commit()
def rollback(self) -> None:
self._conn.rollback()
def close(self) -> None:
if self._temp_schema:
try:
cur = self._conn.cursor()
cur.execute(f'DROP SCHEMA IF EXISTS "{self._temp_schema}" CASCADE')
cur.close()
except Exception:
pass
self._conn.close()
@property
def raw(self):
return self._conn
# The LAN Postgres is reached from WSL via EITHER the Windows netsh portproxy on
# 127.0.0.1 OR a direct route to the host 192.168.1.5 — and which one works FLIPS across
# WSL/host reboots and network-mode (mirrored vs NAT) changes. So when DATABASE_URL points
# at a known LAN endpoint, try the configured host first and then the alternate, so the
# loop self-heals on boot regardless of the current tunnel state (this is the recurring
# crash-loop landmine). "connection refused" returns instantly, so the fallback is cheap.
# _LAN_DB_HOSTS is the GATE (superset, incl. localhost): only a DSN pointing at one of
# these gets host-swapped, so a non-LAN DSN (e.g. a cloud RDS host) is never redirected
# to the wrong database. _LAN_DB_FALLBACKS is the TRY-ORDER (the two real endpoints).
# Keep the gate a superset of the fallbacks.
_LAN_DB_HOSTS = ("127.0.0.1", "localhost", "192.168.1.5")
_LAN_DB_FALLBACKS = ("192.168.1.5", "127.0.0.1")
def _dsn_with_host(dsn: str, host: str) -> str:
p = _urlparse.urlsplit(dsn)
userinfo = ""
if p.username:
userinfo = _urlparse.quote(p.username, safe="")
if p.password:
userinfo += ":" + _urlparse.quote(p.password, safe="")
userinfo += "@"
netloc = f"{userinfo}{host}:{p.port or 5432}"
return _urlparse.urlunsplit((p.scheme, netloc, p.path, p.query, p.fragment))
def _connect():
dsn = os.environ.get("DATABASE_URL")
if not dsn:
conn = psycopg2.connect(
host=os.environ.get("PGHOST", "/home/philip/pgrun"),
port=os.environ.get("PGPORT", "5432"),
user=os.environ.get("PGUSER", "admin"),
password=os.environ.get("PGPASSWORD", ""),
dbname=os.environ.get("PGDATABASE", "momentum"),
)
conn.autocommit = True
return conn
candidates = [dsn]
host = _urlparse.urlsplit(dsn).hostname
if host in _LAN_DB_HOSTS: # only swap hosts for the LAN PG
candidates += [_dsn_with_host(dsn, h) for h in _LAN_DB_FALLBACKS if h != host]
last_err: Exception | None = None
for cand in candidates:
try:
conn = psycopg2.connect(cand, connect_timeout=5)
conn.autocommit = True
return conn
except psycopg2.OperationalError as exc:
last_err = exc
raise last_err if last_err else psycopg2.OperationalError("no DATABASE_URL candidates")
def _apply_schema(conn, search_path: str | None = None) -> None:
cur = conn.cursor()
if search_path:
cur.execute(f'SET search_path TO "{search_path}"')
cur.execute(_schema_sql())
cur.close()
def _isolated_connection() -> PgConnection:
"""A throwaway schema for test isolation (DuckDB ':memory:' equivalent)."""
conn = _connect()
schema = f"mem_{uuid.uuid4().hex[:12]}"
cur = conn.cursor()
cur.execute(f'CREATE SCHEMA "{schema}"')
cur.execute(f'SET search_path TO "{schema}"')
cur.close()
_apply_schema(conn, search_path=schema)
return PgConnection(conn, temp_schema=schema)
def get_connection(db_path: str | Path = "momentum") -> PgConnection:
"""Get a Postgres connection, creating the schema if needed.
``db_path`` is retained for call-site compatibility but the datastore is a
single Postgres database. The special value ":memory:" yields an isolated
throwaway schema (used by the test suite for a fresh database per test).
``MOMENTUM_PG_SCHEMA`` (set only by the test harness) pins every connection
to one named schema, so a test that seeds via one EventStore and reads via
another (e.g. the dashboard server fixture) shares an isolated database
instead of falling through to the production ``public`` schema. The fixture
that sets it owns cleanup, so the connection does not auto-drop the schema.
"""
if str(db_path) == ":memory:":
return _isolated_connection()
conn = _connect()
pinned = os.environ.get("MOMENTUM_PG_SCHEMA", "").strip()
if pinned:
cur = conn.cursor()
cur.execute(f'CREATE SCHEMA IF NOT EXISTS "{pinned}"')
cur.execute(f'SET search_path TO "{pinned}"')
cur.close()
_apply_schema(conn, search_path=pinned)
return PgConnection(conn)
if os.environ.get("PG_SKIP_SCHEMA", "").strip().lower() not in {"1", "true", "yes"}:
_apply_schema(conn)
return PgConnection(conn)
def get_memory_connection() -> PgConnection:
"""Isolated throwaway-schema connection for testing."""
return _isolated_connection()