diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..d87509f 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -42,7 +42,7 @@ function htmlResponse(html: string, status = 200): Response { function corsHeaders(env: Env): Record { return { "Access-Control-Allow-Origin": env.FRONTEND_ORIGIN, - "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; } @@ -68,6 +68,47 @@ function workerUrl(request: Request): string { return `${url.protocol}//${url.host}`; } +// ── Rate snapshots endpoint ─────────────────────────────────────────────────── + +/** + * GET /rates + * + * Query params: + * pool — pool contract ID (required) + * asset — asset symbol, e.g. USDC (required) + * window — lookback in hours: 1 | 6 | 24 | 168 | 720 | 8760 (default 24) + * + * Returns an array of { ts, supply_rate, borrow_rate, util, blnd_eps } + * ordered oldest-first. + */ +const VALID_WINDOWS = new Set([1, 6, 24, 168, 720, 8760]); + +async function handleRates(request: Request, env: Env): Promise { + const url = new URL(request.url); + const pool = url.searchParams.get("pool"); + const asset = url.searchParams.get("asset"); + const winRaw = Number(url.searchParams.get("window") ?? "24"); + + if (!pool || !KNOWN_POOL_IDS.has(pool)) { + return jsonResponse({ ok: false, error: "Missing or unknown pool" }, 400, env); + } + if (!asset || !KNOWN_SYMBOLS.has(asset)) { + return jsonResponse({ ok: false, error: "Missing or unknown asset" }, 400, env); + } + const window = VALID_WINDOWS.has(winRaw) ? winRaw : 24; + + const rows = await env.DB.prepare(` + SELECT ts, supply_rate, borrow_rate, util, blnd_eps + FROM rate_snapshots + WHERE pool_id = ?1 + AND asset_symbol = ?2 + AND ts >= datetime('now', ?3) + ORDER BY ts ASC + `).bind(pool, asset, `-${window} hours`).all(); + + return jsonResponse({ ok: true, data: rows.results ?? [] }, 200, env); +} + // ── Route handlers ─────────────────────────────────────────────────────────── async function handleSubscribe(request: Request, env: Env): Promise { @@ -185,6 +226,11 @@ async function handleUnsubscribe(request: Request, env: Env): Promise async function handleCron(env: Env): Promise { console.log("[cron] APY alert check starting..."); + // Prune snapshots older than 365 days + await env.DB.prepare( + "DELETE FROM rate_snapshots WHERE ts < datetime('now', '-365 days')" + ).run(); + for (const pool of POOLS) { for (const asset of pool.assets) { let rates: ReserveRates | null = null; @@ -200,6 +246,12 @@ async function handleCron(env: Env): Promise { continue; } + // Snapshot this tick + await env.DB.prepare(` + INSERT INTO rate_snapshots (pool_id, asset_symbol, supply_rate, borrow_rate, util, blnd_eps) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + `).bind(pool.id, asset.symbol, rates.netSupplyApr, rates.netBorrowCost, rates.util, rates.blndEps).run(); + for (const bracket of LEVERAGE_BRACKETS) { const netApy = computeNetApy(rates, bracket); @@ -266,6 +318,12 @@ export default { } switch (url.pathname) { + case "/rates": + if (request.method !== "GET") { + return jsonResponse({ error: "Method not allowed" }, 405, env); + } + return handleRates(request, env); + case "/subscribe": if (request.method !== "POST") { return jsonResponse({ error: "Method not allowed" }, 405, env); diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..2de92d8 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -14,3 +14,18 @@ CREATE TABLE IF NOT EXISTS subscriptions ( CREATE INDEX IF NOT EXISTS idx_subs_pool_asset_lev ON subscriptions(pool_id, asset_symbol, leverage_bracket); + +-- Historical APY snapshots (one row per pool/asset per 15-min tick) +CREATE TABLE IF NOT EXISTS rate_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pool_id TEXT NOT NULL, + asset_symbol TEXT NOT NULL, + supply_rate REAL NOT NULL, -- netSupplyApr (%) + borrow_rate REAL NOT NULL, -- netBorrowCost (%) + util REAL NOT NULL, -- utilisation ratio 0-1 + blnd_eps REAL NOT NULL, -- supply-side BLND eps (raw) + ts TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_snapshots_pool_asset_ts + ON rate_snapshots(pool_id, asset_symbol, ts); diff --git a/alerts/src/stellar.ts b/alerts/src/stellar.ts index c263b46..055781d 100644 --- a/alerts/src/stellar.ts +++ b/alerts/src/stellar.ts @@ -101,6 +101,8 @@ export interface ReserveRates { interestBorrowApr: number; blndSupplyApr: number; blndBorrowApr: number; + util: number; // utilisation ratio 0-1 + blndEps: number; // raw supply-side eps } /** Simulate a contract call and return the decoded result. */ @@ -224,6 +226,8 @@ export async function fetchReserveRates(pool: PoolDef, asset: { id: string; symb interestBorrowApr, blndSupplyApr, blndBorrowApr, + util, + blndEps: supplyEps, }; } catch (e) { console.error(`fetchReserveRates failed for ${asset.symbol} on ${pool.name}:`, e); diff --git a/frontend/index.html b/frontend/index.html index f904f23..6336a95 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -307,6 +307,10 @@

Portfolio Overview

+
+ Rates + +
Supply
@@ -542,6 +546,34 @@

Position

+ +
+ What is the Smoothed (EWMA) rate? +
+

+ Pool rates fluctuate every 15 minutes as utilisation changes. A single snapshot can be + misleading — a brief liquidity spike may show an unusually high borrow rate that reverts + within hours. +

+

+ The Smoothed mode applies an exponentially-weighted moving average + (EWMA) with a 7-day half-life to the last 30 days of snapshots. Older + observations decay exponentially, so recent data still matters more, but short-lived noise + is dampened. +

+

+ Formula: for each 15-min tick i, + EWMAi = α × ratei + (1 − α) × EWMAi−1, + where α = 1 − e−ln2 / (7 × 24 × 4) ≈ 0.0029. +

+

+ Use Instant when you want to see the current on-chain rate. + Use Smoothed when comparing assets or deciding whether a rate is + sustainably attractive. +

+
+
+
diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index 1c4ad6c..b085bc3 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -1340,3 +1340,43 @@ export async function submitClassicXdr(signedXdr: string): Promise { const result = await horizon.submitTransaction(tx); return (result as any).hash; } + +// ── EWMA from B3 snapshot series ───────────────────────────────────────────── + +const ALERTS_BASE = "https://turbolong-alerts.workers.dev"; +const EWMA_HALF_LIFE_DAYS = 7; + +export interface EwmaRates { + netSupplyApr: number; + netBorrowCost: number; +} + +/** + * Fetch the last 30 days of rate snapshots and compute an exponentially-weighted + * moving average with a 7-day half-life. + * + * α per interval = 1 − exp(−ln2 / halfLifeIntervals) + * where halfLifeIntervals = halfLifeDays × 24 × 4 (15-min ticks) + */ +export async function fetchEwmaRates(poolId: string, assetSymbol: string): Promise { + try { + const url = `${ALERTS_BASE}/rates?pool=${encodeURIComponent(poolId)}&asset=${encodeURIComponent(assetSymbol)}&window=720`; + const res = await fetch(url, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) return null; + const json = await res.json() as { ok: boolean; data: { ts: string; supply_rate: number; borrow_rate: number }[] }; + if (!json.ok || !json.data.length) return null; + + const halfLifeIntervals = EWMA_HALF_LIFE_DAYS * 24 * 4; // 15-min ticks + const alpha = 1 - Math.exp(-Math.LN2 / halfLifeIntervals); + + let ewmaSupply = json.data[0].supply_rate; + let ewmaBorrow = json.data[0].borrow_rate; + for (let i = 1; i < json.data.length; i++) { + ewmaSupply = alpha * json.data[i].supply_rate + (1 - alpha) * ewmaSupply; + ewmaBorrow = alpha * json.data[i].borrow_rate + (1 - alpha) * ewmaBorrow; + } + return { netSupplyApr: ewmaSupply, netBorrowCost: ewmaBorrow }; + } catch { + return null; + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..6bb32a5 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -44,6 +44,7 @@ import { submitClassicXdr, hfForLeverage, maxLeverageFor, + fetchEwmaRates, type NetworkMode, type AssetInfo, type PoolDef, @@ -324,6 +325,50 @@ const MIN_HF_NORMAL = 1.01; const MIN_HF_EXPERT = 1.00001; function minHF() { return expertMode ? MIN_HF_EXPERT : MIN_HF_NORMAL; } +// ── APY display mode (instant | smoothed) ──────────────────────────────────── + +type ApyMode = "instant" | "smoothed"; +let apyMode: ApyMode = "instant"; +// Cache: keyed by `${poolId}:${assetSymbol}` +const ewmaCache = new Map(); + +function ewmaCacheKey() { return `${selectedPool.id}:${selectedAsset.symbol}`; } + +/** Return the active rates for the selected asset — instant or EWMA. */ +function activeRates(rs: ReserveStats): { netSupplyApr: number; netBorrowCost: number } { + if (apyMode === "smoothed") { + const cached = ewmaCache.get(ewmaCacheKey()); + if (cached) return cached; + } + return { netSupplyApr: rs.netSupplyApr, netBorrowCost: rs.netBorrowCost }; +} + +async function loadEwmaForAsset() { + const key = ewmaCacheKey(); + if (ewmaCache.has(key)) { applyEwmaToUI(); return; } + const result = await fetchEwmaRates(selectedPool.id, selectedAsset.symbol); + if (result) { + ewmaCache.set(key, result); + applyEwmaToUI(); + } +} + +function applyEwmaToUI() { + const rs = reserves.find(r => r.asset.id === selectedAsset.id); + if (!rs) return; + renderSelectedAsset(); + updatePreview(); +} + +function setApyMode(mode: ApyMode) { + apyMode = mode; + const btn = $("apy-mode-toggle"); + btn.textContent = mode === "instant" ? "Instant" : "Smoothed (7d EWMA)"; + btn.classList.toggle("apy-mode-smoothed", mode === "smoothed"); + if (mode === "smoothed") loadEwmaForAsset(); + else applyEwmaToUI(); +} + // ── Demo mode ──────────────────────────────────────────────────────────────── let demoMode = false; @@ -592,8 +637,7 @@ function selectPool(pool: PoolDef) { renderPoolFooter(); closeDrawer(); - if (userAddress) loadAll(); -} + if (userAddress) loadAll();} // ── Asset tabs ──────────────────────────────────────────────────────────────── @@ -657,6 +701,7 @@ function selectAsset(asset: AssetInfo) { renderSelectedAsset(); if (userAddress) refreshTabData(); + if (apyMode === "smoothed") loadEwmaForAsset(); } /** Fetch only balance for the current asset (BLND is pool-wide, fetched in loadAll). */ @@ -782,18 +827,20 @@ function renderSelectedAsset() { renderAprLine("supply-interest-apr", rs.interestSupplyApr, false); renderAprLine("supply-blnd-apr", rs.blndSupplyApr, false, true); - renderAprLine("supply-net-apr", aprToApy(rs.interestSupplyApr) + rs.blndSupplyApr, false, false, undefined, true); + const ar = activeRates(rs); + renderAprLine("supply-net-apr", aprToApy(ar.netSupplyApr), false, false, undefined, true); renderAprLine("borrow-interest-apr", rs.interestBorrowApr, true); renderAprLine("borrow-blnd-apr", rs.blndBorrowApr, false, true, "-"); - renderAprLine("borrow-net-cost", aprToApy(rs.interestBorrowApr) - rs.blndBorrowApr, true, false, undefined, true); + renderAprLine("borrow-net-cost", aprToApy(ar.netBorrowCost), true, false, undefined, true); // Update net tooltips with actual APR const supplyTip = $("supply-net-tip"); + const modeLabel = apyMode === "smoothed" ? "7-day EWMA smoothed" : "instant"; if (supplyTip) supplyTip.setAttribute("data-tip", - `Approximate APY: interest compounds but BLND emissions don't. Actual net APR: ${fmt(rs.netSupplyApr, 2)}%`); + `${modeLabel} net APR: ${fmt(ar.netSupplyApr, 2)}%. Approximate APY: interest compounds but BLND emissions don't.`); const borrowTip = $("borrow-net-tip"); if (borrowTip) borrowTip.setAttribute("data-tip", - `Approximate APY: interest compounds but BLND emissions don't. Actual net APR: ${fmt(rs.netBorrowCost, 2)}%`); + `${modeLabel} net APR: ${fmt(ar.netBorrowCost, 2)}%. Approximate APY: interest compounds but BLND emissions don't.`); // Don't auto-collapse — user controls visibility via the toggle @@ -992,7 +1039,8 @@ function renderPosition() { const netAprEl = $("pos-net-apr"); const heroApyEl = $("hero-net-apy"); if (rs && pos.leverage > 0) { - const posNetApr = rs.netSupplyApr * pos.leverage - rs.netBorrowCost * (pos.leverage - 1); + const ar = activeRates(rs); + const posNetApr = ar.netSupplyApr * pos.leverage - ar.netBorrowCost * (pos.leverage - 1); const netApy = aprToApy(posNetApr); const apyIcon = netApy > 0 ? "\u2713" : "\u2717"; netAprEl.textContent = `${apyIcon} ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}%`; @@ -2013,6 +2061,11 @@ function toggleExpert() { $("expert-toggle").addEventListener("click", toggleExpert); document.getElementById("mobile-expert-toggle")?.addEventListener("click", toggleExpert); +// APY mode toggle (instant ↔ smoothed) +$("apy-mode-toggle").addEventListener("click", () => { + setApyMode(apyMode === "instant" ? "smoothed" : "instant"); +}); + // Theme toggle (settings dropdown) function toggleTheme() { const current = document.documentElement.getAttribute("data-theme") as Theme || getSystemTheme();