Dashboard: intraday equity sparkline + per-position R bars; sync stop_price
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0d1c3f106
commit
80a2a0bde6
4 files changed
+101
-6
No files matched your search
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 @@
|
||||
<div class="pnl-cell"><div class="label">Win Rate</div><div class="value num" id="pnl-winrate">—</div></div>
|
||||
<div class="pnl-cell"><div class="label">Avg R</div><div class="value num" id="pnl-avgr">—</div></div>
|
||||
<div class="pnl-cell"><div class="label">Open / Closed</div><div class="value num" id="pnl-counts">—</div></div>
|
||||
<div class="pnl-cell pnl-spark"><div class="label">Day Curve</div><div id="equity-spark" class="eqspark">—</div></div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
@@ -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 = '<span class="muted" style="font:600 11px var(--mono)">—</span>'; 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 = '<line x1="0" y1="' + by + '" x2="' + W + '" y2="' + by + '" stroke="#7d8590" stroke-width="1" stroke-dasharray="3 3" opacity=".55"/>'; }
|
||||
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 = '<svg width="' + W + '" height="' + H + '" viewBox="0 0 ' + W + ' ' + H + '">'
|
||||
+ '<path d="' + area + '" fill="' + col + '" opacity=".12"/>' + baseLine
|
||||
+ '<polyline points="' + pts + '" fill="none" stroke="' + col + '" stroke-width="1.5"/></svg>';
|
||||
}
|
||||
|
||||
// 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 '<span class="muted">—</span>';
|
||||
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 += '<div style="position:absolute;top:0;bottom:0;left:' + Math.min(z, c).toFixed(1) + 'px;width:' + Math.abs(c - z).toFixed(1) + 'px;background:' + col + ';opacity:.6"></div>';
|
||||
inner += '<div style="position:absolute;top:-1px;bottom:-1px;left:' + xR(-1).toFixed(1) + 'px;width:1px;background:#ff4d4f;opacity:.6"></div>'; // stop
|
||||
inner += '<div style="position:absolute;top:-1px;bottom:-1px;left:' + z.toFixed(1) + 'px;width:1px;background:#e6edf3;opacity:.7"></div>'; // 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 '<div class="rbar">' + inner + '</div><span class="rtxt ' + sgn + '">' + txt + '</span>';
|
||||
}
|
||||
|
||||
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) {
|
||||
'<td class="num">' + timeOf(s.timestamp) + '</td></tr>')
|
||||
), "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('<tr><td class="sym">' + esc(pos.symbol) + '</td>' +
|
||||
'<td class="spark-wrap"><canvas class="spark" data-spark="' + esc(pos.symbol) + '" data-entry="' + (pos.avg_entry_price != null ? pos.avg_entry_price : pos.average_price != null ? pos.average_price : "") + '" data-stop=""></canvas></td>' +
|
||||
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('<tr><td class="sym">' + esc(pos.symbol) + (stop == null ? ' <span class="rtxt neg" title="no protective stop">⚠</span>' : '') + '</td>' +
|
||||
'<td class="spark-wrap"><canvas class="spark" data-spark="' + esc(pos.symbol) + '" data-entry="' + (entry != null ? entry : "") + '" data-stop="' + (stop != null ? stop : "") + '"></canvas></td>' +
|
||||
'<td><span class="pill ' + (qty < 0 ? "short" : "long") + '">' + (qty < 0 ? "short" : "long") + '</span></td>' +
|
||||
'<td class="num">' + esc(Math.abs(qty)) + '</td>' +
|
||||
'<td class="num">' + fmt(pos.avg_entry_price != null ? pos.avg_entry_price : pos.average_price) + '</td>' +
|
||||
'<td class="num">' + fmt(pos.current_price != null ? pos.current_price : pos.market_price) + '</td>' +
|
||||
'<td class="num ' + (+(pos.unrealized_pl || 0) >= 0 ? "pos" : "neg") + '">' + money(pos.unrealized_pl) + '</td>' +
|
||||
'<td class="num">' + fmt(entry) + '</td>' +
|
||||
'<td class="num">' + fmt(cur) + '</td>' +
|
||||
'<td>' + rBar(entry, cur, stop) + '</td>' +
|
||||
'<td class="num ' + (+(upnl || 0) >= 0 ? "pos" : "neg") + '">' + money(upnl) + '</td>' +
|
||||
'<td><button class="btn-exit" onclick="exitPos(\'' + esc(pos.symbol) + '\')">Exit</button></td></tr>');
|
||||
}));
|
||||
$("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 || []);
|
||||
|
||||
|
||||
@@ -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)}
|
||||
Reference in new issue
Block a user