From 80a2a0bde6feec7786537dd8edbbb3aaee54b0b4 Mon Sep 17 00:00:00 2001 From: momentum dev Date: Thu, 18 Jun 2026 21:29:07 -0400 Subject: [PATCH] Dashboard: intraday equity sparkline + per-position R bars; sync stop_price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more visualizations + the bug building them exposed: - Equity-curve sparkline in the P&L strip: query_equity_curve returns the day's total_equity series + prior-close baseline (dashed); rendered as an inline SVG (green/red vs baseline) so the day's P&L shape reads at a glance. - Per-position R bar in Open Positions: shows current open profit/loss in R units (-1R = the stop, 0R = breakeven), green right / red left, with a per-row ⚠ when a position has no resting protective stop. Falls back to % when no stop known. Also fixed the P/L column to compute from entry/last (was reading a None field). - BUG: sync_orders dropped stop_price (kept only limit_price), so the synced snapshot showed stop=None on real stop orders — breaking the R bar and spark stop-line. Now captured. (The naked alarm was unaffected — it counts qty coverage, not the level.) Verified live: equity curve renders the -1,886 day (94,124 -> 92,238); R bars render (fall back to % until the loop re-syncs stop_price). Tests pass. Co-Authored-By: Claude Opus 4.8 --- alpaca_paper/sync.py | 3 ++ api/main.py | 2 ++ api/static/dashboard.html | 76 +++++++++++++++++++++++++++++++++++---- storage/projections.py | 26 ++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/alpaca_paper/sync.py b/alpaca_paper/sync.py index ab47ba9..818415a 100644 --- a/alpaca_paper/sync.py +++ b/alpaca_paper/sync.py @@ -132,6 +132,9 @@ class AlpacaPaperSync: "type": o.get("type"), "status": o.get("status"), "limit_price": o.get("limit_price"), + # capture the stop trigger too — needed for the dashboard R bar / + # spark stop-line / any stop-level read (only limit_price was kept). + "stop_price": o.get("stop_price"), "filled_avg_price": o.get("filled_avg_price"), "submitted_at": o.get("submitted_at"), } diff --git a/api/main.py b/api/main.py index cb56ebf..540b33c 100644 --- a/api/main.py +++ b/api/main.py @@ -37,6 +37,7 @@ from storage.projections import ( query_approval_queue, query_fills_feed, query_armed_triggers, + query_equity_curve, query_order_lifecycle_snapshot, query_ready_signals_snapshot, query_risk_state, @@ -89,6 +90,7 @@ class DashboardState: "pnl": query_session_pnl(store), "risk": query_risk_state(store), "triggers": query_armed_triggers(store), + "equity_curve": query_equity_curve(store), "approval_queue": query_approval_queue(store), "ready_signals": query_ready_signals_snapshot(store), "watch_states": query_watch_states_snapshot(store), diff --git a/api/static/dashboard.html b/api/static/dashboard.html index 3659338..691f6bc 100644 --- a/api/static/dashboard.html +++ b/api/static/dashboard.html @@ -168,6 +168,14 @@ .st-weak .trig-state { color: #7d8590; } .st-weak .trig-fill { background: #39414d; } .st-waiting .trig-state { color: #7d8590; } .st-waiting .trig-fill { background: #30363d; } .st-armed.near .trig-bar { box-shadow: 0 0 0 1px #f1c40f55; } + /* intraday equity sparkline */ + .pnl-spark { flex: 1.5 1 200px; } + .eqspark { margin-top: 2px; } .eqspark svg { display: block; } + /* per-position R / risk bar */ + .rbar { position: relative; display: inline-block; width: 72px; height: 13px; + background: #0d1117; border: 1px solid var(--border); border-radius: 2px; + vertical-align: middle; overflow: hidden; } + .rtxt { font: 700 11px var(--mono); margin-left: 7px; vertical-align: middle; } /* activity feed */ .feed-row { display: flex; align-items: center; gap: 10px; padding: 7px 14px; border-bottom: 1px solid #151a26; font: 500 12px var(--mono); } @@ -225,6 +233,7 @@
Win Rate
—
Avg R
—
Open / Closed
—
+
Day Curve
—
@@ -501,6 +510,49 @@ function renderTriggers(tw) { }).join(''); } +// intraday equity sparkline — the day's P&L shape vs the prior close (dashed) +function renderEquity(ec) { + const host = $("equity-spark"); if (!host) return; + const eq = (ec && ec.equity) || []; const base = ec && ec.baseline; + if (eq.length < 2) { host.innerHTML = '—'; return; } + const W = 190, H = 34, pad = 2; + const lo = Math.min(...eq, base != null ? base : Infinity); + const hi = Math.max(...eq, base != null ? base : -Infinity); + const rng = (hi - lo) || 1; + const X = i => (pad + i * (W - 2 * pad) / (eq.length - 1)); + const Y = v => (pad + (H - 2 * pad) * (1 - (v - lo) / rng)); + const pts = eq.map((v, i) => X(i).toFixed(1) + ',' + Y(v).toFixed(1)).join(' '); + const up = eq[eq.length - 1] >= (base != null ? base : eq[0]); + const col = up ? '#2ecc71' : '#ff4d4f'; + let baseLine = ''; + if (base != null) { const by = Y(base).toFixed(1); baseLine = ''; } + const area = 'M' + X(0).toFixed(1) + ',' + (H - pad) + ' L' + eq.map((v, i) => X(i).toFixed(1) + ',' + Y(v).toFixed(1)).join(' L') + ' L' + X(eq.length - 1).toFixed(1) + ',' + (H - pad) + ' Z'; + host.innerHTML = '' + + '' + baseLine + + ''; +} + +// per-position R bar — current open profit/loss in R units; -1R = the stop. +// breakeven (0R) is the white tick; the stop (-1R) is the red tick; fill shows +// where the position sits (green right = in profit, red left = toward the stop). +function rBar(entry, cur, stop) { + if (entry == null || cur == null) return '—'; + let r = (stop != null && entry > stop) ? (cur - entry) / (entry - stop) : null; + const W = 72, LOR = -1.5, HIR = 3.0; // visible R range + const xR = rr => W * (Math.max(LOR, Math.min(HIR, rr)) - LOR) / (HIR - LOR); + let inner = ''; + if (r != null) { + const z = xR(0), c = xR(r), col = r >= 0 ? '#2ecc71' : '#ff4d4f'; + inner += '
'; + inner += '
'; // stop + inner += '
'; // breakeven + } + const txt = r != null ? (r >= 0 ? '+' : '') + r.toFixed(2) + 'R' + : ((cur - entry) / entry >= 0 ? '+' : '') + ((cur - entry) / entry * 100).toFixed(1) + '%'; + const sgn = (r != null ? r : (cur - entry)) >= 0 ? 'pos' : 'neg'; + return '
' + inner + '
' + txt + ''; +} + function render(d) { latest = d; @@ -540,6 +592,7 @@ function render(d) { fr.style.color = (rk.data_age_s == null) ? "" : (rk.data_age_s > 180 ? "#ff4d4f" : rk.data_age_s > 90 ? "#f1c40f" : ""); renderTriggers(d.triggers); + renderEquity(d.equity_curve); const accts = d.accounts || []; $("acct-count").textContent = accts.length; @@ -584,20 +637,31 @@ function render(d) { '' + timeOf(s.timestamp) + '') ), "No ready signals this session."); + // map each symbol -> its resting protective stop (for the R bar + spark stop line) + const stopBy = {}; + (d.orders || []).forEach(snap => (snap.orders || []).forEach(o => { + if (o.side === "sell" && (o.type === "stop" || o.type === "stop_limit") && o.stop_price != null && !(o.symbol in stopBy)) + stopBy[o.symbol] = +o.stop_price; + })); const posRows = []; (d.positions || []).forEach(snap => (snap.positions || []).forEach(pos => { const qty = +(pos.quantity != null ? pos.quantity : pos.qty != null ? pos.qty : 0); - posRows.push('' + esc(pos.symbol) + '' + - '' + + const entry = pos.avg_entry_price != null ? +pos.avg_entry_price : (pos.average_price != null ? +pos.average_price : null); + const cur = pos.current_price != null ? +pos.current_price : (pos.market_price != null ? +pos.market_price : null); + const stop = stopBy[pos.symbol] != null ? stopBy[pos.symbol] : null; + const upnl = (entry != null && cur != null) ? (cur - entry) * Math.abs(qty) : (pos.unrealized_pl || pos.unrealized_pnl); + posRows.push('' + esc(pos.symbol) + (stop == null ? ' ⚠' : '') + '' + + '' + '' + (qty < 0 ? "short" : "long") + '' + '' + esc(Math.abs(qty)) + '' + - '' + fmt(pos.avg_entry_price != null ? pos.avg_entry_price : pos.average_price) + '' + - '' + fmt(pos.current_price != null ? pos.current_price : pos.market_price) + '' + - '' + money(pos.unrealized_pl) + '' + + '' + fmt(entry) + '' + + '' + fmt(cur) + '' + + '' + rBar(entry, cur, stop) + '' + + '' + money(upnl) + '' + ''); })); $("pos-count").textContent = posRows.length; - fill("positions", table(["Symbol", "Chart", "Side", "Qty", "Avg", "Last", "P/L", ""], posRows), "No open positions."); + fill("positions", table(["Symbol", "Chart", "Side", "Qty", "Avg", "Last", "R / risk", "P/L", ""], posRows), "No open positions."); renderWatch(d.watch_states || []); diff --git a/storage/projections.py b/storage/projections.py index c5135cb..5bd13dc 100644 --- a/storage/projections.py +++ b/storage/projections.py @@ -680,3 +680,29 @@ def query_armed_triggers(store) -> dict: "armed": m.get("armed", 0), "timestamp": event.get("timestamp")} return {"triggers": [], "armed": 0, "timestamp": None} + + +def query_equity_curve(store, points: int = 200) -> dict: + """Recent total-equity series for an intraday P&L-shape sparkline, plus the + prior-session-close baseline (last_equity). Bounded to the last 10h so it's a + cheap range scan and naturally scopes to the trading day.""" + from datetime import datetime, timedelta + since = (datetime.now() - timedelta(hours=10)).isoformat() + events = store.query_events(event_type="account_summary_updated", since=since, limit=None) + series: list[float] = [] + baseline = None + for e in events: + p = json.loads(e.get("payload_json", "{}")) + try: + eq = float(p.get("total_equity") or 0.0) + if eq: + series.append(round(eq, 2)) + if baseline is None: + le = float(p.get("last_equity") or 0.0) + if le: + baseline = round(le, 2) + except (TypeError, ValueError): + pass + if len(series) > points: # keep the most recent N + series = series[-points:] + return {"equity": series, "baseline": baseline if baseline else (series[0] if series else None)}