Running the suite against the cleanup exposed pre-existing breakage (these
tests had not been run since the Postgres migration / recent sync changes):
- _latest_event_payload ordered by `timestamp DESC` with no tie-break, so two
account snapshots emitted in the same second returned the arbitrary/older one
(test wanted 101.5k, got 100k). Tie-break on `created_at DESC`.
- query_watch_states_snapshot windowed BOTH event types to the live 45-min
window; symbol_state_changed (~2k all-time, the watch list itself) must be
unbounded so the list isn't empty off-hours, while criteria_evaluated (~120k,
the 112s killer) stays windowed.
- Test isolation: the dashboard tests pass file-path db_paths expecting a fresh
DB, but under Postgres the path is ignored — they read AND wrote the
production `public` schema. get_connection now honours MOMENTUM_PG_SCHEMA
(test-only) to pin all connections in a test to one throwaway schema; an
autouse fixture sets/drops it. (Cleaned 125 leaked fixture events from prod.)
- FakeClient.get_orders missed the `nested=` kwarg added in ddf93d1.
tests/unit: 134 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
281 lines
9.6 KiB
Python
281 lines
9.6 KiB
Python
"""Dashboard API endpoint tests over a real local HTTP server."""
|
|
|
|
import json
|
|
import os
|
|
import urllib.request
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
import psycopg2
|
|
import pytest
|
|
|
|
from api.main import DashboardState, serve_in_background
|
|
from storage.event_schema import EventMode, SignalReadyEvent
|
|
from storage.event_store import EventStore
|
|
from tests.unit.test_trading_execution import FakeExecutor, make_service
|
|
|
|
T0 = datetime(2026, 6, 11, 9, 45)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_pg_schema(monkeypatch):
|
|
"""Pin every connection in this test to one throwaway schema, so the
|
|
seed store and the dashboard's per-request stores share an ISOLATED
|
|
database. Without this the file-path db_paths these tests pass fall
|
|
through to the production ``public`` schema (the path is ignored under
|
|
Postgres) — they'd read live data and pollute it with fixtures. The
|
|
``mem_`` prefix lets the session-scoped conftest cleanup catch any leak."""
|
|
schema = f"mem_{uuid.uuid4().hex[:12]}"
|
|
monkeypatch.setenv("MOMENTUM_PG_SCHEMA", schema)
|
|
yield
|
|
url = os.environ.get("DATABASE_URL")
|
|
if url:
|
|
conn = psycopg2.connect(url)
|
|
conn.autocommit = True
|
|
conn.cursor().execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')
|
|
conn.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def server(tmp_path):
|
|
db_path = str(tmp_path / "events.duckdb")
|
|
# seed a ready signal
|
|
store = EventStore(db_path)
|
|
store.emit(
|
|
SignalReadyEvent(
|
|
timestamp=T0, mode=EventMode.PAPER, correlation_id="t",
|
|
message="GOOD ready", symbol="GOOD", signal_type="bull_flag",
|
|
confidence=0.9,
|
|
signal_data={"entry_price": 14.0, "stop_loss_price": 13.45,
|
|
"quality_score": 0.7},
|
|
)
|
|
)
|
|
service = make_service(store, FakeExecutor())
|
|
service.request_approvals_for_ready_signals()
|
|
|
|
state = DashboardState(db_path, execution_service=service,
|
|
execution_mode="alpaca_paper")
|
|
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
|
|
base = f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
yield base, store
|
|
httpd.shutdown()
|
|
store.close()
|
|
|
|
|
|
def get(base, path):
|
|
with urllib.request.urlopen(base + path, timeout=5) as resp:
|
|
return resp.status, json.loads(resp.read())
|
|
|
|
|
|
def post(base, path, body):
|
|
request = urllib.request.Request(
|
|
base + path, data=json.dumps(body).encode(),
|
|
headers={"Content-Type": "application/json"}, method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=5) as resp:
|
|
return resp.status, json.loads(resp.read())
|
|
except urllib.error.HTTPError as exc:
|
|
return exc.code, json.loads(exc.read())
|
|
|
|
|
|
def test_snapshots_endpoint_shape(server):
|
|
base, _ = server
|
|
status, data = get(base, "/api/snapshots")
|
|
assert status == 200
|
|
for key in ("execution_mode", "approval_queue", "ready_signals",
|
|
"watch_states", "accounts", "positions", "orders",
|
|
"order_lifecycle"):
|
|
assert key in data
|
|
assert data["execution_mode"] == "alpaca_paper"
|
|
assert data["ready_signals"][0]["symbol"] == "GOOD"
|
|
assert len(data["approval_queue"]) == 1
|
|
|
|
|
|
def test_approval_queue_endpoint(server):
|
|
base, _ = server
|
|
status, data = get(base, "/api/approval-queue")
|
|
assert status == 200
|
|
assert data["approval_queue"][0]["symbol"] == "GOOD"
|
|
|
|
|
|
def test_approve_endpoint_executes_and_clears_queue(server):
|
|
base, _ = server
|
|
_, data = get(base, "/api/approval-queue")
|
|
order_id = data["approval_queue"][0]["order_id"]
|
|
status, result = post(base, "/api/trading/approvals/approve",
|
|
{"order_id": order_id})
|
|
assert status == 200 and result["ok"]
|
|
_, after = get(base, "/api/approval-queue")
|
|
assert after["approval_queue"] == []
|
|
|
|
|
|
def test_reject_endpoint(server):
|
|
base, _ = server
|
|
_, data = get(base, "/api/approval-queue")
|
|
order_id = data["approval_queue"][0]["order_id"]
|
|
status, result = post(base, "/api/trading/approvals/reject",
|
|
{"order_id": order_id, "reason": "test"})
|
|
assert status == 200 and result["ok"]
|
|
|
|
|
|
def test_approve_unknown_order_returns_400(server):
|
|
base, _ = server
|
|
status, result = post(base, "/api/trading/approvals/approve",
|
|
{"order_id": "nope"})
|
|
assert status == 400
|
|
assert not result["ok"]
|
|
|
|
|
|
def test_unknown_route_404(server):
|
|
base, _ = server
|
|
try:
|
|
urllib.request.urlopen(base + "/api/nope", timeout=5)
|
|
raised = False
|
|
except urllib.error.HTTPError as exc:
|
|
raised = exc.code == 404
|
|
assert raised
|
|
|
|
|
|
def test_dashboard_html_served(server):
|
|
base, _ = server
|
|
with urllib.request.urlopen(base + "/", timeout=5) as resp:
|
|
assert resp.status == 200
|
|
body = resp.read().decode()
|
|
assert "Momentum" in body and "/api/snapshots" in body
|
|
|
|
|
|
def test_actions_503_without_execution_service(tmp_path):
|
|
state = DashboardState(str(tmp_path / "e.duckdb"), execution_service=None)
|
|
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
|
|
base = f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
try:
|
|
status, result = post(base, "/api/trading/exit-order", {"symbol": "GOOD"})
|
|
assert status == 503
|
|
assert not result["ok"]
|
|
finally:
|
|
httpd.shutdown()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive endpoints (SSE, criteria, bars, manual inject) + new snapshot keys
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_snapshot_includes_pnl_and_fills(server):
|
|
base, _ = server
|
|
_, data = get(base, "/api/snapshots")
|
|
assert "pnl" in data and "fills" in data and "has_execution" in data
|
|
assert data["has_execution"] is True
|
|
# pnl has the expected stat keys even with no closed trades yet
|
|
for key in ("realized_pnl", "unrealized_pnl", "total_pnl", "win_rate",
|
|
"open_positions", "closed_trades"):
|
|
assert key in data["pnl"]
|
|
|
|
|
|
def test_criteria_endpoint(tmp_path):
|
|
from storage.event_schema import CriteriaEvaluatedEvent
|
|
db_path = str(tmp_path / "crit.duckdb")
|
|
store = EventStore(db_path)
|
|
store.emit(
|
|
CriteriaEvaluatedEvent(
|
|
timestamp=T0, mode=EventMode.PAPER, message="c", symbol="GOOD",
|
|
criteria_results={"passed": ["gap", "vwap"], "failed": ["impulse"]},
|
|
total_criteria=9, passed_criteria=2, success_score_pct=22.0,
|
|
)
|
|
)
|
|
state = DashboardState(db_path, execution_mode="alpaca_paper")
|
|
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
|
|
base = f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
try:
|
|
status, data = get(base, "/api/criteria?symbol=GOOD")
|
|
assert status == 200
|
|
assert data["symbol"] == "GOOD"
|
|
assert data["score"] == 22.0
|
|
by_key = {c["key"]: c["passed"] for c in data["criteria"]}
|
|
assert by_key["gap"] is True
|
|
assert by_key["impulse"] is False
|
|
finally:
|
|
httpd.shutdown()
|
|
store.close()
|
|
|
|
|
|
def test_bars_endpoint_with_research_db(tmp_path):
|
|
from research.ingestion.market_data import (
|
|
ingest_daily_history,
|
|
ingest_live_minute_bars,
|
|
)
|
|
from storage.db import get_connection
|
|
from tests.integration.test_end_to_end import FakeAlpaca
|
|
|
|
con = get_connection(":memory:")
|
|
client = FakeAlpaca()
|
|
ingest_daily_history(con, client, ["GOOD"])
|
|
ingest_live_minute_bars(con, client, ["GOOD"])
|
|
|
|
db_path = str(tmp_path / "bars.duckdb")
|
|
EventStore(db_path).close()
|
|
state = DashboardState(db_path, execution_mode="alpaca_paper", research_con=con)
|
|
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
|
|
base = f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
try:
|
|
status, data = get(base, "/api/bars?symbol=GOOD&minutes=40")
|
|
assert status == 200
|
|
# falls back to the latest available session for the symbol
|
|
assert len(data["bars"]) == 12
|
|
assert all("c" in b for b in data["bars"])
|
|
finally:
|
|
httpd.shutdown()
|
|
|
|
|
|
def test_bars_endpoint_no_research_db(server):
|
|
base, _ = server
|
|
status, data = get(base, "/api/bars?symbol=GOOD")
|
|
assert status == 200
|
|
assert data["bars"] == []
|
|
|
|
|
|
def test_watch_add_endpoint(tmp_path):
|
|
db_path = str(tmp_path / "watch.duckdb")
|
|
EventStore(db_path).close()
|
|
injected = []
|
|
state = DashboardState(
|
|
db_path, execution_mode="alpaca_paper",
|
|
watch_inject=lambda sym: injected.append(sym),
|
|
)
|
|
httpd, _ = serve_in_background(state, host="127.0.0.1", port=0)
|
|
base = f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
try:
|
|
status, result = post(base, "/api/watch/add", {"symbol": "tsla"})
|
|
assert status == 200 and result["ok"]
|
|
assert result["symbol"] == "TSLA"
|
|
assert injected == ["TSLA"]
|
|
finally:
|
|
httpd.shutdown()
|
|
|
|
|
|
def test_watch_add_503_without_inject(server):
|
|
base, _ = server # this server has no watch_inject configured
|
|
status, result = post(base, "/api/watch/add", {"symbol": "TSLA"})
|
|
assert status == 503
|
|
assert not result["ok"]
|
|
|
|
|
|
def test_stream_delivers_snapshot_frame(server):
|
|
base, _ = server
|
|
# read the SSE stream until we see one snapshot event, then bail
|
|
req = urllib.request.urlopen(base + "/api/stream", timeout=5)
|
|
try:
|
|
buf = b""
|
|
got = False
|
|
for _ in range(50):
|
|
line = req.readline()
|
|
if not line:
|
|
break
|
|
buf += line
|
|
if b"event: snapshot" in buf and buf.rstrip().endswith(b"}"):
|
|
got = True
|
|
break
|
|
assert got, "no snapshot frame received from SSE stream"
|
|
finally:
|
|
req.close()
|