slipstream / static /app.js
s-ttp's picture
Move Top AI Picks of the Month to Signalflow; Slipstream shows the fund only
d6a28c7 verified
Raw History Blame Contribute Delete
12.6 kB
/* Slipstream dashboard β€” the automated paper fund (no manual trading). Picks moved to Signalflow. */
const $ = (s, el = document) => el.querySelector(s);
const escapeHtml = s => String(s ?? "").replace(/[&<>"']/g,
c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const usd = n => (n ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
async function loadHealth() {
const h = await fetch("/api/health").then(r => r.json()).catch(() => ({}));
const b = $("#env-badge");
if (!h.alpaca_configured) { b.textContent = "Alpaca: not configured"; return; }
b.textContent = h.alpaca_paper ? "PAPER" : "LIVE";
b.classList.add(h.alpaca_paper ? "paper" : "live");
}
/* ---------- status line ---------- */
async function loadStatus() {
const el = $("#statusline"); if (!el) return;
const s = await fetch("/api/status").then(r => r.ok ? r.json() : null).catch(() => null);
if (!s) { el.innerHTML = `<span class="st muted">Status unavailable β€” add Alpaca secrets on the Space</span>`; return; }
const open = s.market?.is_open;
const fmt = t => t ? new Date(t).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "β€”";
const next = open ? `closes ${fmt(s.market.next_close)}` : `opens ${fmt(s.market.next_open)}`;
const done = s.rebalanced_this_month;
el.innerHTML = `
<span class="st"><i class="dotk ${open ? "open" : "closed"}"></i> Market <b>${open ? "open" : "closed"}</b> Β· ${next}</span>
<span class="st"><i class="dotk ${done ? "yes" : "no"}"></i> This month: <b>${done ? "rebalanced" : "pending"}</b></span>
<span class="st">Last order: <b>${s.last_order || "none yet"}</b></span>
<span class="st">Auto-rebalance: <b>${s.rebalance_enabled ? "on" : "off (set REBALANCE_TOKEN)"}</b> Β· monthly, 1st trading day</span>`;
}
/* ---------- fund ---------- */
async function loadFund() {
const resp = await fetch("/api/fund");
const hold = $("#holdings");
if (!resp.ok) { hold.innerHTML = `<p class="error">${(await resp.json().catch(()=>({}))).detail || "Fund unavailable β€” add Alpaca secrets"}</p>`; return; }
const p = await resp.json();
const dayPl = p.equity - p.last_equity, dayPct = p.last_equity ? dayPl / p.last_equity * 100 : 0;
const invested = p.equity ? (1 - p.cash / p.equity) * 100 : 0;
$("#kpis").innerHTML = [
["Equity", `$${usd(p.equity)}`, ""],
["Day P/L", `${dayPl >= 0 ? "+" : "βˆ’"}$${usd(Math.abs(dayPl))}`, dayPl >= 0 ? "pl-pos" : "pl-neg"],
["Day %", `${dayPct >= 0 ? "β–² +" : "β–Ό "}${dayPct.toFixed(2)}%`, dayPct >= 0 ? "pl-pos" : "pl-neg"],
["Cash", `$${usd(p.cash)}`, ""],
["Invested", `${invested.toFixed(0)}%`, ""],
].map(([l, v, c]) => `<div class="kpi"><div class="kpi-l">${l}</div><div class="kpi-v ${c}">${v}</div></div>`).join("");
$("#holdings-n").textContent = `${p.positions.length} names`;
const maxw = Math.max(0.01, ...p.positions.map(x => x.weight));
hold.innerHTML = p.positions.length ? `<table><thead><tr>
<th>Symbol</th><th>Weight</th><th>Qty</th><th>Price</th><th>P/L %</th><th>Value</th></tr></thead><tbody>
${p.positions.map(x => `<tr>
<td class="sym">${escapeHtml(x.symbol)}<div class="wbar"><i style="width:${(x.weight/maxw*100).toFixed(0)}%"></i></div></td>
<td>${(x.weight*100).toFixed(1)}%</td><td>${x.qty.toFixed(2)}</td><td>$${x.current.toFixed(2)}</td>
<td class="${x.pl_pct>=0?"pl-pos":"pl-neg"}">${x.pl_pct>=0?"+":""}${x.pl_pct}%</td>
<td>$${usd(x.market_value)}</td></tr>`).join("")}</tbody></table>`
: "<p class='muted'>No open positions yet β€” run a rebalance to build the book.</p>";
}
/* ---------- target book ---------- */
async function loadTarget() {
const t = await fetch("/api/target").then(r => r.json()).catch(() => null);
if (!t) return;
const on = t.book === "base";
const chip = $("#regime");
chip.textContent = t.regime;
chip.classList.add(on ? "on" : "off");
$("#target").innerHTML = t.holdings.map(h => `<div class="tgt-row">
<span class="t-sym">${escapeHtml(h.ticker)}</span><span class="t-sec">${escapeHtml(h.sector)}</span>
<span class="t-w">${(h.weight*100).toFixed(1)}%</span></div>`).join("")
+ `<p class="as-of">${t.n_holdings} holdings Β· signals as-of ${escapeHtml(t.signals_as_of)} Β· bundle prices ${escapeHtml(t.prices_through)}`
+ (t.spy_through && t.spy_through !== t.prices_through ? ` Β· SPY live through ${escapeHtml(t.spy_through)}` : "")
+ `<br>${escapeHtml(t.rule)}</p>`;
renderRiskScreen(t.risk_screen);
}
/* ---------- AI event-risk screen (shows the overlay's power) ---------- */
function renderRiskScreen(rs) {
const el = $("#riskscreen"); if (!el) return;
if (!rs || !rs.enabled) { el.innerHTML = ""; return; }
const ex = rs.excluded || [];
const head = ex.length
? `<span class="rs-hit">${ex.length} excluded</span> Β· ${rs.cleared}/${rs.scanned} candidates cleared`
: `<span class="rs-ok">all clear</span> Β· ${rs.scanned} candidates scanned, no material adverse events`;
const li = e => `<li><span class="rs-sev rs-${escapeHtml(e.severity)}">${escapeHtml(e.severity)}</span>
<b>${escapeHtml(e.ticker)}</b> β€” ${escapeHtml(e.event)} <span class="rs-cat">${escapeHtml(e.category)}</span>
${e.headline ? `<span class="rs-src">β€œ${escapeHtml(e.headline)}”</span>` : ""}</li>`;
const rows = ex.map(li).join("");
const med = (rs.flags_medium || []);
const medRows = med.map(li).join("");
el.innerHTML = `<div class="rs-head">πŸ›‘ AI Risk Screen β€” ${head}</div>` +
(rows ? `<ul class="rs-list">${rows}</ul>` : "") +
(medRows ? `<div class="rs-sub">Monitoring (below exclusion threshold β€” held):</div><ul class="rs-list">${medRows}</ul>` : "") +
`<p class="rs-note">A second-stage AI overlay scans the final candidates for <b>concrete, recent adverse events</b> β€”
legal Β· regulatory/FDA Β· geopolitical/trade Β· operational Β· competitive β€” and hard-excludes only <b>critical</b> hits
(threats to the company itself; replaced by the next-ranked name). High and medium events are shown above and held.
Ongoing, already-priced risks are deliberately ignored.</p>`;
}
async function previewRebalance() {
const out = $("#rebal-out");
out.textContent = "Computing plan…";
const r = await fetch("/api/rebalance?dry=1", { method: "POST" }).then(r => r.json()).catch(() => null);
if (!r || r.detail) { out.innerHTML = `<span class="error">${r?.detail || "unavailable"}</span>`; return; }
const cashLine = r.projected_cash != null
? ` Β· cash after: $${usd(r.projected_cash)} (buffer $${usd(r.cash_buffer_usd)})` : "";
if (!r.n_trades) { out.textContent = `Portfolio already matches target β€” no trades needed${cashLine}.`; return; }
out.innerHTML = `<div class="muted">${r.n_trades} trades to match target (min $${usd(r.min_trade_usd)})${cashLine} Β· dry-run</div>`
+ r.trades.slice(0, 24).map(t => `<div class="trade"><span class="t-${escapeHtml(t.action)}">${escapeHtml(t.action.toUpperCase())} ${escapeHtml(t.symbol)}</span>
<span>${t.usd>=0?"+":"βˆ’"}$${usd(Math.abs(t.usd))}${t.note ? ` <small class="muted">${escapeHtml(t.note)}</small>` : ""}</span></div>`).join("")
+ `<p class="as-of">Execution runs monthly (or via the token-protected API). This preview places no orders.</p>`;
}
/* ---------- NAV chart ---------- */
async function loadNav() {
const d = await fetch("/api/nav").then(r => r.json()).catch(() => null);
const cv = $("#navchart"); if (!d || !cv) return;
const fund = (d.fund || []).map(p => ({ t: p.t * 1000, v: p.equity })).filter(p => p.v);
if (fund.length < 2) { $("#perf-note").textContent = "performance history builds after the first trades"; return; }
const spyRaw = (d.spy || []).map(p => ({ t: Date.parse(p.t), v: p.c }));
const norm = arr => { const b = arr[0].v; return arr.map(p => ({ t: p.t, v: p.v / b * 100 })); };
const f = norm(fund), s = spyRaw.length > 1 ? norm(spyRaw) : [];
const t0 = f[0].t, t1 = f[f.length-1].t;
const all = f.concat(s).map(p => p.v);
const lo = Math.min(...all) * 0.995, hi = Math.max(...all) * 1.005;
const W = cv.clientWidth, H = 240, P = 34;
cv.width = W * devicePixelRatio; cv.height = H * devicePixelRatio;
cv.style.width = W + "px"; cv.style.height = H + "px"; // display size == coord space (no stretch; exact hover)
const g = cv.getContext("2d"); g.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
const X = t => P + (W-P-8) * (t-t0)/(t1-t0||1), Y = v => 12 + (H-12-22) * (1-(v-lo)/(hi-lo||1));
const invX = px => t0 + (px - P) / ((W-P-8)||1) * (t1-t0);
const nearest = (arr, t) => arr.reduce((b, p) => Math.abs(p.t-t) < Math.abs(b.t-t) ? p : b, arr[0]);
function draw(hoverT) {
g.clearRect(0,0,W,H);
g.strokeStyle = "#1e2c42"; g.lineWidth = 1; g.font = "10px ui-monospace"; g.fillStyle = "#5d6b83";
[100, (lo+hi)/2, hi, lo].forEach(v => { g.beginPath(); g.moveTo(P, Y(v)); g.lineTo(W-8, Y(v)); g.stroke(); g.fillText(v.toFixed(0), 6, Y(v)+3); });
const line = (arr, col, w) => { if (arr.length<2) return; g.strokeStyle = col; g.lineWidth = w; g.beginPath();
arr.forEach((p,i) => i?g.lineTo(X(p.t),Y(p.v)):g.moveTo(X(p.t),Y(p.v))); g.stroke(); };
line(s, "#8ea3c0", 1.4); line(f, "#37b8a4", 2.2);
if (hoverT != null) {
const fx = nearest(f, hoverT), sx = s.length ? nearest(s, hoverT) : null;
const px = X(fx.t);
g.strokeStyle = "#37506e"; g.setLineDash([3,3]); g.beginPath(); g.moveTo(px,10); g.lineTo(px,H-22); g.stroke(); g.setLineDash([]);
const dot = (p, col) => { if (!p) return; g.fillStyle = col; g.beginPath(); g.arc(X(p.t), Y(p.v), 3.5, 0, 7); g.fill(); };
dot(sx, "#8ea3c0"); dot(fx, "#37b8a4");
showTip(px, fx, sx);
} else hideTip();
}
let tip = $("#navtip");
if (!tip) { tip = document.createElement("div"); tip.id = "navtip"; tip.className = "navtip";
cv.parentElement.style.position = "relative"; cv.parentElement.appendChild(tip); }
const pct = v => `${v>=100?"+":""}${(v-100).toFixed(2)}%`;
function showTip(px, fx, sx) {
tip.innerHTML = `<div class="nt-date">${new Date(fx.t).toISOString().slice(0,10)}</div>` +
`<div class="nt-row"><span style="color:#37b8a4">β– </span> Slipstream <b>${pct(fx.v)}</b></div>` +
(sx ? `<div class="nt-row"><span style="color:#8ea3c0">β– </span> SPY <b>${pct(sx.v)}</b></div>` : "");
tip.style.display = "block";
tip.style.left = Math.min(Math.max(px + 12, 4), W - 132) + "px"; tip.style.top = "8px";
}
function hideTip() { if (tip) tip.style.display = "none"; }
draw();
cv.onmousemove = e => { const r = cv.getBoundingClientRect(); const mx = e.clientX - r.left;
if (mx < P || mx > W - 8) { draw(); return; } draw(invX(mx)); };
cv.onmouseleave = () => draw();
const last = f[f.length-1].v, sl = s.length ? s[s.length-1].v : null;
$("#perf-note").textContent = `since ${new Date(t0).toISOString().slice(0,10)} Β· Slipstream ${last>=100?"+":""}${(last-100).toFixed(1)}%` + (sl!=null?` vs SPY ${sl>=100?"+":""}${(sl-100).toFixed(1)}%`:"");
}
/* ---------- init ---------- */
$("#rebal-preview").addEventListener("click", previewRebalance);
/* tabs */
document.querySelectorAll(".tab").forEach(t => t.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach(x => x.classList.toggle("active", x === t));
document.querySelectorAll(".tab-panel").forEach(p => p.classList.toggle("active", p.id === `tab-${t.dataset.tab}`));
if (t.dataset.tab === "fund") loadNav(); // canvas needs a visible container to size
}));
loadHealth();
loadStatus();
loadFund();
loadTarget();
loadNav();
addEventListener("resize", () => loadNav());
/* ── supervisor health strip (daily flag-only monitor) ───────────────────── */
(async function healthStrip(){
try{
const r = await fetch('/api/supervisor').then(x=>x.json());
const el = document.getElementById('healthstrip'); if(!el || !r.checks) return;
const cls = s => s==='OK' ? 'hs-ok' : s==='ALERT' ? 'hs-alert' : 'hs-watch';
const icon = s => s==='OK' ? '●' : s==='ALERT' ? 'β–²' : 'β—†';
el.innerHTML = r.checks.map(c =>
`<span class="hs-chip ${cls(c.status)}" title="${(c.detail||'').replace(/"/g,'&quot;')}">${icon(c.status)} ${c.name.split(' (')[0]} <b>${c.status}</b></span>`
).join('') + `<span class="hs-note">supervisor Β· ${r.computed_at} Β· hover for detail</span>`;
el.hidden = false;
}catch(e){ /* strip is optional */ }
})();