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>
296 lines
12 KiB
Python
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
|