From 165f5d9a8e2b5b356c0aa61e4ca3885647edda83 Mon Sep 17 00:00:00 2001 From: cyberdocs120 Date: Fri, 29 May 2026 15:36:07 +0100 Subject: [PATCH] B3: historical APY storage - Add rate_snapshots table + index to schema.sql - Cron writes (pool, asset, supply_rate, borrow_rate, util, blnd_eps) snapshot each 15-min tick - Cron prunes snapshots older than 365 days - Add GET /rates?pool=&asset=&window= public endpoint - Expose util + blndEps fields on ReserveRates --- alerts/src/index.ts | 60 ++++++++++++++++++++++++++++++++++++++++++- alerts/src/schema.sql | 15 +++++++++++ alerts/src/stellar.ts | 4 +++ 3 files changed, 78 insertions(+), 1 deletion(-) 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);