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>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-07-20 14:28:15 -05:00
1 parent e7bd8fa279
commit f10a8033a1
3 files changed
+86 -3

No files matched your search

+23
View File
@@ -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}
+39 -3
View File
@@ -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
+24
View File
@@ -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