diff --git a/api/main.py b/api/main.py index f1198fd..5cdc3a0 100644 --- a/api/main.py +++ b/api/main.py @@ -63,6 +63,12 @@ from storage.projections import ( query_watch_states_snapshot, ) +import logging + +from research.multi_schema import open_research_db + +logger = logging.getLogger(__name__) + STATIC_DIR = Path(__file__).parent / "static" @@ -250,6 +256,26 @@ class DashboardState: self.research_con = research_con self.watch_inject = watch_inject self._lock = threading.Lock() + self._research_lock = threading.Lock() + + def _research(self): + """The research DB connection, opened LAZILY and self-healing. It used + to be opened once at boot; if the DB was unreachable then (container + started before Postgres, or a blip), it stayed None forever and every + chart/sparkline/backtest returned 'no research db attached' — which + read as broken TradingView charts when the real cause was an empty data + pipe. Now a None connection is (re)opened on demand; the facade itself + auto-reconnects a dropped-but-open one.""" + if self.research_con is not None: + return self.research_con + with self._research_lock: + if self.research_con is None: + try: + self.research_con = open_research_db("market") + except Exception as exc: # noqa: BLE001 — degrade to None, retry next call + logger.warning("research DB open failed (charts empty): %s", exc) + self.research_con = None + return self.research_con # lazily-built, process-wide SSE change signal (shared by all clients) self._change_signal: _ChangeSignal | None = None # backtest replay cache (plan 015) — a DEDICATED lock, not self._lock, @@ -355,22 +381,23 @@ class DashboardState: pipeline). If today has no bars yet (pre-market, weekend, or replay of an older session), falls back to the most recent session that does. """ - if self.research_con is None: + con = self._research() + if con is None: return {"symbol": symbol, "bars": [], "reason": "no research db attached"} from research.query import query_minute_bars with self._lock: try: session_date = _eastern_session_date() - df = query_minute_bars(self.research_con, symbol, session_date) + df = query_minute_bars(con, symbol, session_date) if df is None or df.empty: # fall back to the latest session with bars for this symbol - row = self.research_con.execute( + row = con.execute( "SELECT MAX(session_date) FROM minute_bars WHERE symbol = ?", [symbol], ).fetchone() if row and row[0] is not None: - df = query_minute_bars(self.research_con, symbol, row[0]) + df = query_minute_bars(con, symbol, row[0]) except Exception as exc: # noqa: BLE001 return {"symbol": symbol, "bars": [], "reason": str(exc)} if df is None or df.empty: @@ -403,7 +430,8 @@ class DashboardState: arbitrary past sessions the plain ``hours``-back-from-now window can never reach (it's capped at 72h). """ - if self.research_con is None: + con = self._research() + if con is None: return {"symbol": symbol, "bars": [], "reason": "no research db attached"} from zoneinfo import ZoneInfo @@ -421,7 +449,7 @@ class DashboardState: with self._lock: try: - rows = self.research_con.execute( + rows = con.execute( "SELECT timestamp, open, high, low, close, volume " "FROM minute_bars WHERE symbol = ? AND timestamp >= ? " "ORDER BY timestamp", diff --git a/tests/unit/api/test_research_con_selfheal.py b/tests/unit/api/test_research_con_selfheal.py new file mode 100644 index 0000000..aa7accd --- /dev/null +++ b/tests/unit/api/test_research_con_selfheal.py @@ -0,0 +1,41 @@ +"""DashboardState research-DB connection must self-heal (2026-07-20). + +The dashboard opened its research connection ONCE at boot; if the DB was +unreachable then (container started before Postgres, or a blip), it stayed +None forever and every chart/sparkline/backtest returned 'no research db +attached' — which read as 'the TradingView charts are broken' when the real +cause was an empty data pipe. _research() reopens lazily on demand.""" + +import api.main as m + + +def test_research_con_reopens_when_none(monkeypatch): + opened = {"n": 0} + + class FakeCon: + def execute(self, *a, **k): + class R: + def fetchall(self_): # noqa: N805 + return [] + def df(self_): # noqa: N805 + import pandas as pd + return pd.DataFrame() + return R() + + monkeypatch.setattr(m, "open_research_db", lambda name: (opened.__setitem__("n", opened["n"] + 1) or FakeCon())) + st = m.DashboardState("momentum", research_con=None) # boot-time open FAILED -> None + c1 = st._research() + assert c1 is not None and opened["n"] == 1 # lazily opened + c2 = st._research() + assert c2 is c1 and opened["n"] == 1 # cached, not reopened each call + + +def test_research_none_when_open_keeps_failing(monkeypatch): + def boom(name): + raise RuntimeError("db down") + monkeypatch.setattr(m, "open_research_db", boom) + st = m.DashboardState("momentum", research_con=None) + assert st._research() is None # degrades cleanly, no raise + # and bars() still returns the graceful shape, not an exception + out = st.bars("AAPL") + assert out["bars"] == [] and "reason" in out