From f10a8033a169c3af71325366ad73dc34147d145a Mon Sep 17 00:00:00 2001 From: momentum dev Date: Mon, 20 Jul 2026 14:28:15 -0500 Subject: [PATCH] chart: add volume histogram + Bollinger Bands + EXIT markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/main.py | 23 +++++++++++++++ api/static/dashboard.html | 42 +++++++++++++++++++++++++-- tests/unit/api/test_chart_endpoint.py | 24 +++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/api/main.py b/api/main.py index 203ca4e..df421d2 100644 --- a/api/main.py +++ b/api/main.py @@ -463,6 +463,10 @@ class DashboardState: raw_fills = query_fills_feed(store, limit=200, symbol=symbol) positions = query_account_positions_snapshot(store) orders = query_account_orders_snapshot(store) + try: # exits for the marker loop (store is open only in here) + closed_events = store.query_events(event_type="position_closed", limit=200) + except Exception: # noqa: BLE001 + closed_events = [] finally: store.close() @@ -492,6 +496,25 @@ class DashboardState: "price": r.get("price"), "quantity": r.get("quantity"), }) + # EXIT markers — the fills feed carries entries (order_submitted); exits + # live in position_closed (the loop fills brackets via position-diffing, + # so no order_filled ever fires). Without this the chart showed entries + # but never where a trade closed. kind='exit' so the UI can mark it + # distinctly (X / down-triangle) with the exit reason + P&L. + for e in closed_events: + p = json.loads(e.get("payload_json", "{}")) + if p.get("symbol") != symbol: + continue + ts = e.get("timestamp") + epoch = to_epoch(ts) if ts else None + if epoch is None or epoch < cutoff_epoch: + continue + markers.append({ + "time": epoch, "kind": "exit", "side": "sell", + "price": p.get("exit_price"), "quantity": p.get("quantity"), + "exit_reason": p.get("exit_reason"), + "pnl": p.get("realized_pnl"), + }) markers.sort(key=lambda m: m["time"]) levels = {"avg_entry": None, "stop": None, "target": None, "entry_limit": None} diff --git a/api/static/dashboard.html b/api/static/dashboard.html index ca216a0..c66484a 100644 --- a/api/static/dashboard.html +++ b/api/static/dashboard.html @@ -712,8 +712,20 @@ function openChart(symbol) { upColor: "#34d17e", downColor: "#ff5a60", borderVisible: false, wickUpColor: "#34d17e", wickDownColor: "#ff5a60", }); + // Bollinger Bands (20-period, 2σ) — momentum context: price riding the upper + // band = strong, a break back inside = fading. Three faint lines on the price + // scale, non-intrusive. + const bbLine = () => chart.addSeries(LightweightCharts.LineSeries, + { color: "#4f9cf9", lineWidth: 1, lineStyle: 2, priceLineVisible: false, lastValueVisible: false }); + const bbUpper = bbLine(), bbMid = bbLine(), bbLower = bbLine(); + bbMid.applyOptions({ color: "#3a4a66" }); + // Volume histogram on its own bottom overlay (20% height), coloured by candle. + const volSeries = chart.addSeries(LightweightCharts.HistogramSeries, + { priceFormat: { type: "volume" }, priceScaleId: "vol" }); + chart.priceScale("vol").applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } }); const markersApi = LightweightCharts.createSeriesMarkers(series, []); - chartState = { symbol, chart, series, markersApi, lastFetch: 0, priceLines: [] }; + chartState = { symbol, chart, series, markersApi, volSeries, + bbUpper, bbMid, bbLower, lastFetch: 0, priceLines: [] }; refreshChart(symbol); } @@ -761,9 +773,33 @@ async function refreshChart(symbol) { const data = await r.json(); const decData = rd.ok ? await rd.json() : { decisions: [] }; if (chartState.symbol !== symbol || !chartState.series) return; // closed/switched mid-fetch - chartState.series.setData((data.bars || []).map(b => + const bars = data.bars || []; + chartState.series.setData(bars.map(b => ({ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close }))); + // volume histogram — coloured by candle direction + if (chartState.volSeries) chartState.volSeries.setData(bars.map(b => + ({ time: b.time, value: b.volume || 0, color: (b.close >= b.open ? "rgba(52,209,126,.4)" : "rgba(255,90,96,.4)") }))); + // Bollinger Bands (20-period SMA ± 2σ on close) + if (chartState.bbMid) { + const N = 20, up = [], mid = [], lo = []; + for (let i = N - 1; i < bars.length; i++) { + let s = 0; for (let j = i - N + 1; j <= i; j++) s += bars[j].close; + const m = s / N; + let v = 0; for (let j = i - N + 1; j <= i; j++) v += (bars[j].close - m) ** 2; + const sd = Math.sqrt(v / N), t = bars[i].time; + mid.push({ time: t, value: m }); up.push({ time: t, value: m + 2 * sd }); lo.push({ time: t, value: m - 2 * sd }); + } + chartState.bbUpper.setData(up); chartState.bbMid.setData(mid); chartState.bbLower.setData(lo); + } const fillMarkers = (data.markers || []).map(m => { + if (m.kind === "exit") { // trade close — the EXIT marker + const win = (+m.pnl || 0) >= 0; + return { + time: m.time, position: "aboveBar", shape: "square", + color: win ? "#34d17e" : "#ff5a60", + text: "EXIT " + String(m.exit_reason || "").replace(/_/g, " ") + (m.pnl != null ? " " + money(m.pnl) : ""), + }; + } const sell = m.side === "sell"; const unfilled = m.kind === "order_submitted"; return { @@ -771,7 +807,7 @@ async function refreshChart(symbol) { position: sell ? "aboveBar" : "belowBar", shape: sell ? "arrowDown" : "arrowUp", color: unfilled ? "#7884a2" : (sell ? "#ff5a60" : "#34d17e"), - text: (sell ? "S " : "B ") + (m.quantity != null ? m.quantity : ""), + text: (unfilled ? "expected " : "") + (sell ? "S " : "B ") + (m.quantity != null ? m.quantity : ""), }; }); // rejected receipts only (a pass needs no marker — the fill arrow already diff --git a/tests/unit/api/test_chart_endpoint.py b/tests/unit/api/test_chart_endpoint.py index a40de6a..4e07609 100644 --- a/tests/unit/api/test_chart_endpoint.py +++ b/tests/unit/api/test_chart_endpoint.py @@ -269,3 +269,27 @@ def test_api_chart_route_wired(): 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