Files
momentum-system/tests/unit/api/test_chart_endpoint.py
T
momentum devandClaude Fable 5 f10a8033a1 chart: add volume histogram + Bollinger Bands + EXIT markers
Owner asks on the candlesticks:
- VOLUME: histogram series (bottom 20% overlay), coloured by candle direction.
- BOLLINGER BANDS: 20-period 2-sigma, computed client-side from closes; price
  riding the upper band = strong, break back inside = fading (momentum context).
- EXIT markers were NEVER wired — the fills feed carries only entries
  (order_submitted), and the loop fills brackets via position-diffing so no
  order_filled fires. Exits live in position_closed; now surfaced as square
  markers with reason + P&L (green win / red loss). Entry 'expected' (unfilled
  submit) markers labelled distinctly. Fixed a self-inflicted bug: queried the
  event store AFTER closing it — moved the position_closed fetch inside the
  open-store block. 11 chart tests green incl. new exit-marker test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:28:15 -05:00

296 lines
12 KiB
Python

"""Symbol drill-in chart endpoint (plan 013): 24h bars + fill markers + levels.
Drives ``DashboardState.chart()`` and the ``do_GET`` routes directly (no
loopback socket — see reviewer note: tests/unit/api's HTTP-fixture group hits
a pre-existing local-sandbox urllib failure unrelated to this feature)."""
import io
import json
import os
import uuid
from datetime import datetime, timedelta
import psycopg2
import pytest
from api.main import DashboardHandler, DashboardState
from storage.event_schema import (
AccountOrdersUpdatedEvent,
AccountPositionsUpdatedEvent,
EventMode,
OrderFilledEvent,
OrderSubmittedEvent,
)
from storage.event_store import EventStore
@pytest.fixture(autouse=True)
def _isolated_pg_schema(monkeypatch):
"""Pin every EventStore in this file to one throwaway schema (the
``EventStore(":memory:")`` idiom) so seeded fills never touch prod."""
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()
class _FakeCursor:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
class _FakeChartResearchCon:
"""Stands in for the research Postgres connection. Implements the WHERE
symbol=?/timestamp>=? semantics of the real /api/chart query so tests can
seed rows in and out of the window, the same contract chart() relies on."""
def __init__(self, rows):
# rows: (symbol, timestamp, open, high, low, close, volume)
self._rows = rows
def execute(self, sql, params=None):
symbol, cutoff = params
matched = [
r[1:] for r in self._rows if r[0] == symbol and r[1] >= cutoff
]
matched.sort(key=lambda r: r[0])
return _FakeCursor(matched)
class CaptureHandler(DashboardHandler):
"""Drives do_GET without a real socket; captures whichever send_* the
route used (mirrors the pattern in test_dashboard_command.py)."""
def __init__(self, state, path):
self.state = state
self.path = path
self.rfile = io.BytesIO(b"")
self.headers = {}
self.captured = []
def _send_json(self, payload, status=200):
self.captured.append(("json", status, payload))
def _send_static(self, path, content_type):
self.captured.append(("static", 200, path.read_bytes()[:64]))
def _get(state, path):
h = CaptureHandler(state, path)
h.do_GET()
return h.captured[-1]
# --------------------------------------------------------------------------
# Step 3: DashboardState.chart() — bars, markers, levels
# --------------------------------------------------------------------------
def test_chart_no_research_con_reports_missing_db(monkeypatch):
# self-heal (2026-07-20): None con now REOPENS lazily; the "missing db"
# path is reached only when the reopen ALSO fails.
import api.main as m
monkeypatch.setattr(m, "open_research_db", lambda name: (_ for _ in ()).throw(RuntimeError("db down")))
out = DashboardState("momentum", research_con=None).chart("AAPL")
assert out["bars"] == []
assert out.get("reason") == "no research db attached"
def test_chart_bars_sorted_ascending_with_open_and_window_exclusion():
now = datetime.now()
rows = [
# excluded: 30h old (hours=24 default cutoff)
("AAPL", now - timedelta(hours=30), 1.0, 1.1, 0.9, 1.05, 1000),
# included: crosses the session boundary from the excluded bar above
("AAPL", now - timedelta(hours=20), 2.0, 2.2, 1.9, 2.1, 2000),
("AAPL", now - timedelta(hours=1), 3.0, 3.1, 2.9, 3.05, 3000),
# different symbol: must never leak in
("MSFT", now - timedelta(hours=1), 9.0, 9.1, 8.9, 9.05, 500),
]
con = _FakeChartResearchCon(rows)
out = DashboardState("momentum", research_con=con).chart("AAPL", hours=24)
bars = out["bars"]
assert len(bars) == 2
assert [b["time"] for b in bars] == sorted(b["time"] for b in bars)
for b in bars:
assert isinstance(b["time"], int)
assert bars[0]["open"] == 2.0
assert bars[1]["open"] == 3.0
def test_chart_markers_scoped_to_symbol_kind_and_side_intact():
# NOT ":memory:" — that mints its OWN throwaway schema every call, so it
# would land in a different schema than DashboardState("momentum").open_store()
# below. Both need the ONE schema the autouse fixture pins via
# MOMENTUM_PG_SCHEMA (the same pattern test_dashboard_api.py's server
# fixture uses to share a seed store with the dashboard's reads).
store = EventStore("momentum")
try:
t0 = datetime.now() - timedelta(minutes=10)
t1 = datetime.now() - timedelta(minutes=9)
# X: a submitted+filled pair (side lives only on the submit event —
# OrderFilledEvent's schema has no `side` field; chart() backfills it
# via order_id join)
store.emit(OrderSubmittedEvent(
timestamp=t0, mode=EventMode.PAPER, message="m", symbol="X",
order_id="o1", side="buy", quantity=100, price=2.5))
store.emit(OrderFilledEvent(
timestamp=t1, mode=EventMode.PAPER, message="m", symbol="X",
order_id="o1", fill_price=2.51, fill_quantity=100))
# a different symbol — must not leak into X's markers
store.emit(OrderSubmittedEvent(
timestamp=t0, mode=EventMode.PAPER, message="m", symbol="OTHER",
order_id="o2", side="sell", quantity=50, price=9.0))
con = _FakeChartResearchCon([])
out = DashboardState("momentum", research_con=con).chart("X", hours=24)
finally:
store.close()
markers = out["markers"]
assert len(markers) == 2 # only X's two events; OTHER's must not leak in
kinds = sorted(m["kind"] for m in markers)
assert kinds == ["order_filled", "order_submitted"]
assert all(m["side"] == "buy" for m in markers) # backfilled onto the fill too
assert [m["time"] for m in markers] == sorted(m["time"] for m in markers)
def test_chart_levels_from_positions_and_working_orders():
# same schema-sharing note as the markers test above
store = EventStore("momentum")
try:
store.emit(AccountPositionsUpdatedEvent(
timestamp=datetime.now(), mode=EventMode.PAPER, message="m",
broker_name="alpaca", account_id="acct1",
positions=[{"symbol": "X", "avg_entry_price": 2.16, "quantity": 100}]))
store.emit(AccountOrdersUpdatedEvent(
timestamp=datetime.now(), mode=EventMode.PAPER, message="m",
broker_name="alpaca", account_id="acct1",
orders=[
# working protective stop -> levels.stop
{"symbol": "X", "side": "sell", "type": "stop", "status": "accepted",
"stop_price": 2.05},
# working target limit -> levels.target
{"symbol": "X", "side": "sell", "type": "limit", "status": "new",
"limit_price": 2.60},
# a CANCELLED order must never surface as a level
{"symbol": "X", "side": "buy", "type": "limit", "status": "cancelled",
"limit_price": 999.0},
# a different symbol must never leak in
{"symbol": "OTHER", "side": "sell", "type": "stop", "status": "accepted",
"stop_price": 1.0},
]))
con = _FakeChartResearchCon([])
out = DashboardState("momentum", research_con=con).chart("X", hours=24)
finally:
store.close()
levels = out["levels"]
assert levels["avg_entry"] == 2.16
assert levels["stop"] == 2.05
assert levels["target"] == 2.60
assert levels["entry_limit"] is None # only a cancelled buy limit existed
def test_chart_hours_capped_at_72():
# a bar 100h old must stay excluded even when hours=1000 is requested
now = datetime.now()
rows = [("AAPL", now - timedelta(hours=100), 1.0, 1.1, 0.9, 1.0, 10)]
con = _FakeChartResearchCon(rows)
out = DashboardState("momentum", research_con=con).chart("AAPL", hours=1000)
assert out["bars"] == []
# --------------------------------------------------------------------------
# Step 4/regression: query_fills_feed(symbol=None) behaves exactly as before
# --------------------------------------------------------------------------
def test_query_fills_feed_symbol_none_matches_unfiltered_call():
from storage.projections import query_fills_feed
store = EventStore(":memory:")
try:
store.emit(OrderSubmittedEvent(
timestamp=datetime.now(), mode=EventMode.PAPER, message="m",
symbol="A", order_id="o1", side="buy", quantity=10, price=1.0))
store.emit(OrderSubmittedEvent(
timestamp=datetime.now(), mode=EventMode.PAPER, message="m",
symbol="B", order_id="o2", side="sell", quantity=20, price=2.0))
before = query_fills_feed(store, limit=50)
after = query_fills_feed(store, limit=50, symbol=None)
assert before == after
assert len(before) == 2
finally:
store.close()
# --------------------------------------------------------------------------
# Step 2: /static/vendor/... route — serves the vendored lib, blocks traversal
# --------------------------------------------------------------------------
def test_static_vendor_serves_lightweight_charts():
kind, status, body = _get(DashboardState("momentum"),
"/static/vendor/lightweight-charts.standalone.production.js")
assert kind == "static" and status == 200
assert b"5.2" in body
def test_static_vendor_path_traversal_blocked():
kind, status, _ = _get(DashboardState("momentum"),
"/static/vendor/../dashboard.html")
assert status == 404
def test_static_vendor_unknown_file_404():
kind, status, _ = _get(DashboardState("momentum"), "/static/vendor/nope.js")
assert status == 404
# --------------------------------------------------------------------------
# Route wiring: GET /api/chart
# --------------------------------------------------------------------------
def test_api_chart_route_wired():
con = _FakeChartResearchCon([])
state = DashboardState("momentum", research_con=con)
kind, status, payload = _get(state, "/api/chart?symbol=AAPL&hours=24")
assert kind == "json" and status == 200
assert payload["symbol"] == "AAPL"
assert payload["bars"] == []
if __name__ == "__main__": # pragma: no cover
pytest.main([__file__, "-q"])
def test_chart_exit_markers_from_position_closed():
"""Exits (position_closed) must appear as chart markers with reason + P&L —
the fills feed only carries entries; without this the chart showed where a
trade opened but never where it closed."""
from storage.event_schema import PositionClosedEvent
store = EventStore("momentum")
try:
tc = datetime.now() - timedelta(minutes=5)
store.emit(PositionClosedEvent(
timestamp=tc, mode=EventMode.PAPER, message="closed",
symbol="X", position_id="p1", side="long", quantity=100,
entry_price=2.50, exit_price=2.80, stop_loss_price=2.30,
exit_reason="trail_stop", realized_pnl=30.0))
con = _FakeChartResearchCon([])
out = DashboardState("momentum", research_con=con).chart("X", hours=24)
finally:
store.close()
exits = [m for m in out["markers"] if m.get("kind") == "exit"]
assert len(exits) == 1
assert exits[0]["exit_reason"] == "trail_stop"
assert exits[0]["pnl"] == 30.0
assert exits[0]["price"] == 2.80