From 31a38e85eba2a82372e4146b46ae4f1030ab65ae Mon Sep 17 00:00:00 2001 From: Gozirimdev Date: Fri, 26 Jun 2026 03:44:05 +0100 Subject: [PATCH] feat: add downside rate calculator --- frontend/src/blend.ts | 36 +++++++++++++ frontend/src/views/trade.css | 61 ++++++++++++++++++++- frontend/src/views/trade.ts | 100 ++++++++++++++++++++++++++++++++--- 3 files changed, 190 insertions(+), 7 deletions(-) diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index 5ddc025..c55010c 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -693,6 +693,42 @@ export function projectRates(rs: ReserveStats, addSupply: number, addBorrow: num // ── User position ───────────────────────────────────────────────────────────── +export interface DownsideScenario { + utilization: number; // 0..1 + rates: ProjectedRates; + netApy: number; // leveraged APY, % + hfDecayApr: number; // interest-only HF decay, %/yr + daysToLiquidation: number | null; // null when already liquidatable / invalid +} + +/** + * Stress a reserve at a future utilization while reusing the same projectRates + * path as the trade preview. The pool supply is held constant and borrow is + * moved to the requested utilization, matching "util hits X%" scenarios. + */ +export function projectDownsideScenario( + rs: ReserveStats, + targetUtilization: number, + leverage: number, + healthFactor: number, +): DownsideScenario { + const utilization = Math.min(0.9999, Math.max(0, targetUtilization)); + const targetBorrow = rs.totalSupply * utilization; + const rates = projectRates(rs, 0, targetBorrow - rs.totalBorrow); + const netApr = rates.netSupplyApr * leverage - rates.netBorrowCost * (leverage - 1); + const netApy = (Math.exp(netApr / 100) - 1) * 100; + const hfDecayApr = rates.interestBorrowApr - rates.interestSupplyApr; + + let daysToLiquidation: number | null = null; + if (hfDecayApr <= 0) { + daysToLiquidation = Number.POSITIVE_INFINITY; + } else if (Number.isFinite(healthFactor) && healthFactor > 1) { + daysToLiquidation = Math.log(healthFactor) / (hfDecayApr / 100) * 365; + } + + return { utilization, rates, netApy, hfDecayApr, daysToLiquidation }; +} + export interface AssetPosition { asset: AssetInfo; bTokens: bigint; diff --git a/frontend/src/views/trade.css b/frontend/src/views/trade.css index 04cf996..f6dab18 100644 --- a/frontend/src/views/trade.css +++ b/frontend/src/views/trade.css @@ -324,10 +324,67 @@ /* ── Position card ────────────────────────────────────────────────────────── */ .trade-heroes { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: minmax(0, 0.85fr) minmax(0, 0.85fr) minmax(280px, 1.3fr); gap: var(--tl-space-3); margin-bottom: var(--tl-space-4); } +.trade-apy-stack { + display: flex; + flex-direction: column; + gap: var(--tl-space-2); + min-width: 0; +} +.trade-downside { + background: var(--tl-input-bg); + border: 1px solid var(--tl-border); + border-radius: var(--tl-radius); + padding: 10px 12px; +} +.trade-downside__head { + display: grid; + grid-template-columns: minmax(0, 1fr) 118px; + align-items: center; + gap: var(--tl-space-2); + margin-bottom: var(--tl-space-2); +} +.trade-downside__title { + display: inline-flex; + align-items: center; + gap: var(--tl-space-1); + min-width: 0; + font-size: var(--tl-text-xs); + font-weight: 700; + color: var(--tl-text-2); +} +.trade-downside .tl-input__field { + min-height: 32px; + padding-top: 6px; + padding-bottom: 6px; + font-size: var(--tl-text-xs); +} +.trade-downside__table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 11px; +} +.trade-downside__table th, +.trade-downside__table td { + padding: 5px 4px; + border-top: 1px solid var(--tl-border); + text-align: right; + white-space: nowrap; +} +.trade-downside__table th { + color: var(--tl-text-3); + font-weight: 700; +} +.trade-downside__table th:first-child, +.trade-downside__table td:first-child { + width: 18%; + text-align: left; + color: var(--tl-text-2); +} .trade-position .trade-hf-well { background: var(--tl-input-bg); border: 1px solid var(--tl-border); @@ -459,6 +516,8 @@ @media (max-width: 720px) { .trade-stats { grid-template-columns: repeat(2, 1fr); } .trade-rates { grid-template-columns: 1fr; } + .trade-heroes { grid-template-columns: 1fr; } + .trade-downside__head { grid-template-columns: 1fr; } .trade-cols { grid-template-columns: 1fr; } .trade-pool-menu { left: 0; } } diff --git a/frontend/src/views/trade.ts b/frontend/src/views/trade.ts index 5b51f75..210c47c 100644 --- a/frontend/src/views/trade.ts +++ b/frontend/src/views/trade.ts @@ -37,6 +37,7 @@ import { fetchPositionEvents, aggregatePoolAccount, projectRates, + projectDownsideScenario, hfForLeverage, maxLeverageFor, buildApproveXdr, @@ -1017,6 +1018,7 @@ export function tradeScreen(): HTMLElement { const netApy = agg.netApy; const effLev = agg.effLeverage; const cFactor = rs ? rs.cFactor : ts.asset.cFactor; + const downside = rs ? downsideCalculator(rs, accountHF, effLev) : null; // 3 hero metrics. const heroes = el("div", { class: "trade-heroes" }, [ @@ -1026,12 +1028,15 @@ export function tradeScreen(): HTMLElement { value: `${effLev.toFixed(1)}×`, tone: effLev > 7 ? "danger" : effLev > 4 ? "warning" : "default", }), - MetricHero({ - label: "Net APY", - value: `${netApy >= 0 ? "+" : ""}${fmt(netApy, 1)}%`, - tone: netApy >= 0 ? "success" : "danger", - sub: "projected", - }), + el("div", { class: "trade-apy-stack" }, [ + MetricHero({ + label: "Net APY", + value: `${netApy >= 0 ? "+" : ""}${fmt(netApy, 1)}%`, + tone: netApy >= 0 ? "success" : "danger", + sub: "projected", + }), + downside, + ].filter(Boolean) as HTMLElement[]), ]); // Account Health. @@ -1141,6 +1146,89 @@ export function tradeScreen(): HTMLElement { return el("div", {}, [heroes, hfWell, detailRows, timeline, blndRow, actions]); } + function downsideCalculator(rs: ReserveStats, healthFactor: number, leverage: number): HTMLElement { + const currentUtilPct = rs.totalSupply > 0 ? (rs.totalBorrow / rs.totalSupply) * 100 : 0; + let targetPct = Math.min(99.99, Math.max(currentUtilPct + 1, 97)); + + const tableBody = el("tbody"); + const input = Input({ + value: targetPct.toFixed(1), + inputMode: "decimal", + suffix: "%", + title: "Future pool utilization", + onChange: (value) => { + const next = Number.parseFloat(value.replace(/[^\d.]/g, "")); + if (Number.isFinite(next)) targetPct = Math.min(99.99, Math.max(0, next)); + renderRows(); + }, + }); + + const daysText = (days: number | null) => + days === null + ? "N/A" + : !Number.isFinite(days) + ? "Never" + : days > 3650 + ? ">10y" + : `${Math.max(0, Math.round(days))}d`; + + const decayText = (decay: number) => + decay <= 0 ? "No decay" : `${fmt(decay, 2)}%/yr`; + + const row = (label: string, pct: number): HTMLElement => { + const s = projectDownsideScenario(rs, pct / 100, leverage, healthFactor); + return el("tr", {}, [ + el("td", {}, [label]), + el("td", { class: "trade-mono" }, [`${fmt(s.utilization * 100, 1)}%`]), + el( + "td", + { class: `trade-mono ${s.netApy >= 0 ? "trade-tone-up" : "trade-tone-down"}` }, + [`${s.netApy >= 0 ? "+" : ""}${fmt(s.netApy, 1)}%`], + ), + el("td", { class: `trade-mono ${s.hfDecayApr > 0 ? "trade-tone-warn" : "trade-tone-up"}` }, [ + decayText(s.hfDecayApr), + ]), + el("td", { class: "trade-mono" }, [daysText(s.daysToLiquidation)]), + ]); + }; + + function renderRows(): void { + const severe = Math.min(99.99, Math.max(targetPct + 1, 99)); + tableBody.replaceChildren( + row("Now", currentUtilPct), + row("Input", targetPct), + row("Stress", severe), + ); + } + + renderRows(); + + return el("div", { class: "trade-downside" }, [ + el("div", { class: "trade-downside__head" }, [ + el("span", { class: "trade-downside__title" }, [ + "What if rates move against you?", + Tooltip({ + text: + "Uses the live Blend rate projection with a future utilization scenario, then estimates HF decay and liquidation time at current leverage.", + }), + ]), + input, + ]), + el("table", { class: "trade-downside__table" }, [ + el("thead", {}, [ + el("tr", {}, [ + el("th", {}, ["Case"]), + el("th", {}, ["Util"]), + el("th", {}, ["Net APY"]), + el("th", {}, ["HF decay"]), + el("th", {}, ["Liq"]), + ]), + ]), + tableBody, + ]), + ]); + } + // ── Close confirmation (inline, replaces position body) ─────────────────── function askClose(pos: AssetPosition): void { const sym = ts.asset.symbol;