slipstream / static /app.js
s-ttp's picture
Upload folder using huggingface_hub
261cef9 verified
Raw History Blame Contribute Delete
16.2 kB
/* Slipstream dashboard — fund (automated) + informational picks (no manual trading). */
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)}%`:"");
}
/* ---------- picks (no BUY) ---------- */
// The whole category card opens its dedicated top-20 page; interactive children
// (refresh button, per-ticker detail links, anchors) keep their own behavior.
document.querySelectorAll(".picks").forEach(card => {
card.style.cursor = "pointer";
card.addEventListener("click", e => {
if (e.target.closest("button, a, .detail-link")) return;
location.href = "/picks?panel=" + card.id.replace("panel-", "");
});
});
const panelData = {};
function openDetail(panel, ticker) {
const pick = (panelData[panel]?.picks || []).find(p => p.ticker === ticker);
if (!pick?.details) return;
$("#detail-title").textContent = `${ticker} — ${{superinvestor:"Super Investor Activity",insider:"Insider Activity",news:"News (30 days)"}[panel]||panel}`;
$("#detail-body").innerHTML = pick.details.map(sec => `<section class="detail-section"><h4>${escapeHtml(sec.title)}</h4>
<ul>${sec.items.map(it => `<li>${it.href?`<a href="${escapeHtml(it.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(it.text)} ↗</a>`:escapeHtml(it.text)}</li>`).join("")}</ul></section>`).join("");
$("#detail-modal").showModal();
}
async function loadPanel(panel, refresh = false) {
const body = $(`#panel-${panel} .picks-body`);
body.textContent = refresh ? "Refreshing…" : "Loading…";
const resp = await fetch(`/api/picks/${panel}${refresh ? "?refresh=1" : ""}`);
if (!resp.ok) { body.innerHTML = `<p class="error">${(await resp.json().catch(()=>({}))).detail || "Unavailable"}</p>`; return; }
const data = await resp.json(); panelData[panel] = data;
const meta = data.as_of ? `<p class="as-of${data.stale ? " stale" : ""}">${escapeHtml(data.as_of)}${data.disclaimer?" · "+escapeHtml(data.disclaimer):""}</p>` : "";
body.innerHTML = (data.picks.map(p => `<div class="pick">
<div class="pick-top"><span class="pick-ticker">${escapeHtml(p.ticker)}</span><span class="quote" data-quote="${escapeHtml(p.ticker)}"></span></div>
<span class="pick-detail">${escapeHtml(p.detail)}</span>
${p.llm ? `<span class="pick-llm ${escapeHtml((p.llm.verdict||"unverified").toLowerCase())}">LLM: ${escapeHtml(p.llm.verdict)}${p.llm.reason?" — "+escapeHtml(p.llm.reason):""}</span>`:""}
${p.details ? `<a class="detail-link" data-panel="${escapeHtml(panel)}" data-ticker="${escapeHtml(p.ticker)}">Details ›</a>`:""}
</div>`).join("") || "<p class='muted'>No qualifying picks.</p>") + meta;
body.querySelectorAll(".detail-link").forEach(l => l.addEventListener("click", () => openDetail(l.dataset.panel, l.dataset.ticker)));
loadQuotes(data.picks.map(p => p.ticker));
}
async function loadQuotes(tickers) {
if (!tickers.length) return;
const q = await fetch(`/api/quotes?symbols=${tickers.join(",")}`).then(r => r.ok ? r.json() : null).catch(() => null);
if (!q) return;
for (const [sym, v] of Object.entries(q)) {
document.querySelectorAll(`[data-quote="${CSS.escape(sym)}"]`).forEach(el => {
const up = v.change_pct != null && v.change_pct >= 0;
el.innerHTML = `$${v.price.toFixed(2)}` + (v.change_pct != null ? ` <span class="${up?"q-up":"q-down"}">${up?"▲ +":"▼ "}${v.change_pct}%</span>` : "");
});
}
}
/* ---------- init ---------- */
$("#detail-close").addEventListener("click", () => $("#detail-modal").close());
$("#detail-modal").addEventListener("click", e => { if (e.target === e.currentTarget) e.currentTarget.close(); });
$("#rebal-preview").addEventListener("click", previewRebalance);
document.querySelectorAll(".refresh").forEach(b => b.addEventListener("click", () => loadPanel(b.dataset.panel, true)));
/* 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();
["superinvestor", "insider", "news"].forEach(loadPanel);
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 */ }
})();