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
36 changes: 36 additions & 0 deletions frontend/src/blend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
61 changes: 60 additions & 1 deletion frontend/src/views/trade.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
}
100 changes: 94 additions & 6 deletions frontend/src/views/trade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
fetchPositionEvents,
aggregatePoolAccount,
projectRates,
projectDownsideScenario,
hfForLeverage,
maxLeverageFor,
buildApproveXdr,
Expand Down Expand Up @@ -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" }, [
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down