Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion alerts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function htmlResponse(html: string, status = 200): Response {
function corsHeaders(env: Env): Record<string, string> {
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",
};
}
Expand All @@ -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<Response> {
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<Response> {
Expand Down Expand Up @@ -185,6 +226,11 @@ async function handleUnsubscribe(request: Request, env: Env): Promise<Response>
async function handleCron(env: Env): Promise<void> {
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;
Expand All @@ -200,6 +246,12 @@ async function handleCron(env: Env): Promise<void> {
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);

Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions alerts/src/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
4 changes: 4 additions & 0 deletions alerts/src/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ <h2>Portfolio Overview</h2>
</div>

<!-- APR breakdown -->
<div class="apr-grid-header">
<span class="apr-grid-title">Rates</span>
<button id="apy-mode-toggle" class="apy-mode-toggle tooltip" data-tip="Toggle between live rates and a 7-day exponentially-weighted moving average (EWMA) to smooth out short-term noise.">Instant</button>
</div>
<div class="apr-grid">
<div class="apr-card">
<div class="apr-card-label">Supply</div>
Expand Down Expand Up @@ -542,6 +546,34 @@ <h2>Position</h2>

<footer id="pool-footer" class="pool-footer"></footer>

<!-- EWMA docs panel -->
<details class="ewma-docs" id="ewma-docs">
<summary>What is the Smoothed (EWMA) rate?</summary>
<div class="ewma-docs-body">
<p>
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.
</p>
<p>
The <strong>Smoothed</strong> mode applies an <em>exponentially-weighted moving average</em>
(EWMA) with a <strong>7-day half-life</strong> to the last 30 days of snapshots. Older
observations decay exponentially, so recent data still matters more, but short-lived noise
is dampened.
</p>
<p>
<strong>Formula:</strong> for each 15-min tick <em>i</em>,
<code>EWMA<sub>i</sub> = α × rate<sub>i</sub> + (1 − α) × EWMA<sub>i−1</sub></code>,
where <code>α = 1 − e<sup>−ln2 / (7 × 24 × 4)</sup> ≈ 0.0029</code>.
</p>
<p>
Use <strong>Instant</strong> when you want to see the current on-chain rate.
Use <strong>Smoothed</strong> when comparing assets or deciding whether a rate is
sustainably attractive.
</p>
</div>
</details>

</div><!-- #dashboard -->

<!-- Swap view -->
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/blend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1340,3 +1340,43 @@ export async function submitClassicXdr(signedXdr: string): Promise<string> {
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<EwmaRates | null> {
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;
}
}
67 changes: 60 additions & 7 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
submitClassicXdr,
hfForLeverage,
maxLeverageFor,
fetchEwmaRates,
type NetworkMode,
type AssetInfo,
type PoolDef,
Expand Down Expand Up @@ -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<string, { netSupplyApr: number; netBorrowCost: number }>();

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;
Expand Down Expand Up @@ -592,8 +637,7 @@ function selectPool(pool: PoolDef) {
renderPoolFooter();
closeDrawer();

if (userAddress) loadAll();
}
if (userAddress) loadAll();}

// ── Asset tabs ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)}%`;
Expand Down Expand Up @@ -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();
Expand Down