diff --git a/frontend/index.html b/frontend/index.html
index f904f23..b24839a 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -339,6 +339,18 @@
Portfolio Overview
+
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index fcc1ceb..e2a64d3 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -502,6 +502,82 @@ function removePnlEntry(assetId: string, poolId: string) {
localStorage.removeItem(`pnl_${poolId}_${assetId}`);
}
+// ── Rate history (B3) ────────────────────────────────────────────────────────
+
+const RATE_HISTORY_KEY = "blendlev_rate_history";
+const RATE_HISTORY_MAX = 200; // keep up to 200 snapshots per key
+
+interface RateSnapshot { ts: number; val: number; }
+
+function _rateKey(poolId: string, assetId: string, field: string) {
+ return `${RATE_HISTORY_KEY}:${poolId}:${assetId}:${field}`;
+}
+
+function recordRateSnapshot(poolId: string, assetId: string, field: string, val: number) {
+ const key = _rateKey(poolId, assetId, field);
+ const raw = localStorage.getItem(key);
+ const snaps: RateSnapshot[] = raw ? JSON.parse(raw) : [];
+ const now = Date.now();
+ // Deduplicate: skip if last snapshot is < 5 min old
+ if (snaps.length > 0 && now - snaps[snaps.length - 1].ts < 5 * 60_000) {
+ snaps[snaps.length - 1] = { ts: now, val }; // update in place
+ } else {
+ snaps.push({ ts: now, val });
+ if (snaps.length > RATE_HISTORY_MAX) snaps.splice(0, snaps.length - RATE_HISTORY_MAX);
+ }
+ localStorage.setItem(key, JSON.stringify(snaps));
+}
+
+function getRateAtWindow(poolId: string, assetId: string, field: string, windowMs: number): number | null {
+ const key = _rateKey(poolId, assetId, field);
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ const snaps: RateSnapshot[] = JSON.parse(raw);
+ const cutoff = Date.now() - windowMs;
+ // Find the oldest snapshot within the window (closest to windowMs ago)
+ const candidates = snaps.filter(s => s.ts <= cutoff);
+ if (candidates.length === 0) return null;
+ return candidates[candidates.length - 1].val;
+}
+
+/** Render a trend arrow element next to an APY value element. */
+function renderTrendArrow(
+ targetEl: HTMLElement,
+ current: number,
+ past24h: number | null,
+ past7d: number | null
+) {
+ // Remove any existing arrow
+ const existing = targetEl.parentElement?.querySelector(".rate-trend");
+ if (existing) existing.remove();
+
+ if (past24h === null && past7d === null) return;
+
+ const arrow = document.createElement("span");
+ arrow.className = "rate-trend";
+
+ const parts: string[] = [];
+ const tipParts: string[] = [];
+
+ for (const [label, past, ms] of [
+ ["24h", past24h, 24 * 3600_000],
+ ["7d", past7d, 7 * 24 * 3600_000],
+ ] as [string, number | null, number][]) {
+ if (past === null) continue;
+ const delta = past !== 0 ? (current - past) / Math.abs(past) * 100 : 0;
+ const up = delta >= 0;
+ const sym = up ? "▲" : "▼";
+ const cls = up ? "rate-trend-up" : "rate-trend-down";
+ parts.push(`${sym}${fmt(Math.abs(delta), 1)}%${label}`);
+ tipParts.push(`${label}: ${fmt(past, 2)}% → ${fmt(current, 2)}% (${delta >= 0 ? "+" : ""}${fmt(delta, 1)}%)`);
+ }
+
+ if (parts.length === 0) return;
+ arrow.innerHTML = parts.join(" ");
+ arrow.setAttribute("title", tipParts.join(" | "));
+ targetEl.insertAdjacentElement("afterend", arrow);
+}
+
// ── Sign + submit ─────────────────────────────────────────────────────────────
async function signAndSubmit(xdrStr: string, label: string, stepIndex?: number): Promise {
@@ -749,6 +825,101 @@ function renderApyChart(rs: ReserveStats | undefined, currentLev: number, equity
`;
}
+// ── APY history chart (B4) ────────────────────────────────────────────────────
+
+let _histWin: "7d" | "30d" | "1y" = "30d";
+
+const HIST_WIN_MS: Record = {
+ "7d": 7 * 24 * 3600_000,
+ "30d": 30 * 24 * 3600_000,
+ "1y": 365 * 24 * 3600_000,
+};
+
+function renderHistoryChart() {
+ const container = $("hist-chart");
+ const raw = localStorage.getItem(`blendlev_rate_history:${selectedPool.id}:${selectedAsset.id}:supply-net`);
+ const all: { ts: number; val: number }[] = raw ? JSON.parse(raw) : [];
+ const cutoff = Date.now() - HIST_WIN_MS[_histWin];
+ const snaps = all.filter(s => s.ts >= cutoff);
+
+ if (snaps.length < 2) {
+ container.innerHTML = `Not enough history yet — check back after data accumulates.
`;
+ return;
+ }
+
+ const W = 400, H = 80, padL = 32, padR = 8, padT = 10, padB = 18;
+ const vals = snaps.map(s => s.val);
+ const minV = Math.min(...vals), maxV = Math.max(...vals);
+ const rangeV = maxV - minV || 0.01;
+ const minTs = snaps[0].ts, maxTs = snaps[snaps.length - 1].ts;
+ const rangeTs = maxTs - minTs || 1;
+
+ const px = (ts: number) => padL + (ts - minTs) / rangeTs * (W - padL - padR);
+ const py = (v: number) => padT + (1 - (v - minV) / rangeV) * (H - padT - padB);
+
+ const pts = snaps.map(s => `${px(s.ts).toFixed(1)},${py(s.val).toFixed(1)}`).join(" ");
+
+ // X-axis tick labels (3 ticks: start, mid, end)
+ const fmtDate = (ts: number) => {
+ const d = new Date(ts);
+ return _histWin === "1y"
+ ? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
+ : d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
+ };
+ const ticks = [minTs, (minTs + maxTs) / 2, maxTs];
+ const tickSvg = ticks.map(ts =>
+ `${fmtDate(ts)}`
+ ).join("");
+
+ // Y-axis labels
+ const yLabelSvg =
+ `${fmt(maxV, 1)}%` +
+ `${fmt(minV, 1)}%`;
+
+ // Invisible hit-area rects for tooltip
+ const segW = (W - padL - padR) / snaps.length;
+ const hitRects = snaps.map((s, i) => {
+ const cx = px(s.ts);
+ const cy = py(s.val);
+ const tip = `${fmtDate(s.ts)}: ${fmt(s.val, 2)}%`;
+ return ``;
+ }).join("");
+
+ container.innerHTML = `
+ `;
+
+ // Pointer tooltip
+ const svg = document.getElementById("hist-chart-svg")!;
+ svg.addEventListener("mouseleave", () => {
+ (document.getElementById("hist-cursor") as SVGCircleElement | null)?.setAttribute("style", "display:none");
+ (document.getElementById("hist-tip-text") as SVGTextElement | null)?.setAttribute("style", "display:none");
+ });
+ svg.querySelectorAll(".hist-hit").forEach(rect => {
+ rect.addEventListener("mouseenter", () => {
+ const cx = rect.dataset.cx!, cy = rect.dataset.cy!, tip = rect.dataset.tip!;
+ const cursor = document.getElementById("hist-cursor") as SVGCircleElement | null;
+ const tipEl = document.getElementById("hist-tip-text") as SVGTextElement | null;
+ if (!cursor || !tipEl) return;
+ cursor.setAttribute("cx", cx);
+ cursor.setAttribute("cy", cy);
+ cursor.setAttribute("style", "");
+ const tx = Math.min(Number(cx), W - padR - 60);
+ const ty = Number(cy) > padT + 20 ? Number(cy) - 8 : Number(cy) + 14;
+ tipEl.setAttribute("x", String(tx));
+ tipEl.setAttribute("y", String(ty));
+ tipEl.textContent = tip;
+ tipEl.setAttribute("style", "");
+ });
+ });
+}
+
// ── Render reserve stats for selected asset ───────────────────────────────────
function renderSelectedAsset() {
@@ -797,6 +968,32 @@ function renderSelectedAsset() {
// Don't auto-collapse — user controls visibility via the toggle
+ // Rate-trend arrows (A10 / B3) ─────────────────────────────────────────────
+ const supplyNetVal = aprToApy(rs.interestSupplyApr) + rs.blndSupplyApr;
+ const borrowNetVal = aprToApy(rs.interestBorrowApr) - rs.blndBorrowApr;
+ const W24 = 24 * 3600_000;
+ const W7D = 7 * 24 * 3600_000;
+
+ // Record current values
+ recordRateSnapshot(selectedPool.id, selectedAsset.id, "supply-net", supplyNetVal);
+ recordRateSnapshot(selectedPool.id, selectedAsset.id, "borrow-net", borrowNetVal);
+
+ // Render arrows on net rows
+ renderTrendArrow(
+ $("supply-net-apr"),
+ supplyNetVal,
+ getRateAtWindow(selectedPool.id, selectedAsset.id, "supply-net", W24),
+ getRateAtWindow(selectedPool.id, selectedAsset.id, "supply-net", W7D),
+ );
+ renderTrendArrow(
+ $("borrow-net-cost"),
+ borrowNetVal,
+ getRateAtWindow(selectedPool.id, selectedAsset.id, "borrow-net", W24),
+ getRateAtWindow(selectedPool.id, selectedAsset.id, "borrow-net", W7D),
+ );
+
+ renderHistoryChart();
+
updatePreview();
renderPosition();
renderPortfolioSummary();
@@ -2092,6 +2289,16 @@ $("stats-toggle").addEventListener("click", () => {
$("stats-collapsible").classList.toggle("collapsed");
});
+// History chart window selector (B4)
+$("hist-win-btns").addEventListener("click", (e) => {
+ const btn = (e.target as HTMLElement).closest(".hist-win-btn");
+ if (!btn) return;
+ _histWin = btn.dataset.win as "7d" | "30d" | "1y";
+ $("hist-win-btns").querySelectorAll(".hist-win-btn").forEach(b => b.classList.remove("active"));
+ btn.classList.add("active");
+ renderHistoryChart();
+});
+
// Vault deposit/withdraw tabs
document.querySelectorAll(".vault-tab").forEach(btn => {
btn.addEventListener("click", () => {
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 0d4348f..41fa647 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -427,6 +427,29 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24
.apr-warn { color: var(--warning); }
.apr-bad { color: var(--danger); }
+/* ── Rate-trend arrows (A10) ─────────────────────────────────────────────── */
+.rate-trend { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-family: var(--mono); margin-left: 5px; opacity: .85; }
+.rate-trend-label { color: var(--text-3); font-family: var(--sans); font-size: 10px; margin-right: 3px; }
+.rate-trend-up { color: var(--success); }
+.rate-trend-down { color: var(--danger); }
+
+/* ── APY history chart (B4) ──────────────────────────────────────────────── */
+.hist-chart-wrap { margin-top: 10px; }
+.hist-chart-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
+.hist-chart-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .7px; color: var(--text-3); }
+.hist-win-btns { display: flex; gap: 3px; }
+.hist-win-btn { background: none; border: 1px solid var(--border); border-radius: 4px; color: var(--text-2); font-size: 11px; padding: 2px 7px; cursor: pointer; transition: background .15s, color .15s; }
+.hist-win-btn:hover { border-color: var(--primary); color: var(--primary); }
+.hist-win-btn.active { background: var(--primary); border-color: var(--primary); color: #fff; }
+.hist-chart { min-height: 80px; }
+.hist-chart-svg { width: 100%; height: 80px; display: block; overflow: visible; }
+.hist-chart-line { fill: none; stroke: var(--primary); stroke-width: 1.5; stroke-linejoin: round; }
+.hist-chart-label { font-size: 9px; fill: var(--text-3); font-family: var(--mono); }
+.hist-cursor { fill: var(--primary); }
+.hist-tip-text { font-size: 10px; fill: var(--text); font-family: var(--mono); font-weight: 600; }
+.hist-hit { cursor: crosshair; }
+.hist-chart-empty { font-size: 12px; color: var(--text-3); padding: 16px 0; text-align: center; }
+
/* ── Frozen banner ───────────────────────────────────────────────────────── */
.pool-frozen-banner {