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:
1 parent
e7bd8fa279
commit
f10a8033a1
3 files changed
+86
-3
No files matched your search
+23
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user