From 48dfd60c1b5c70781afb6d3df5358a7159bbb2e4 Mon Sep 17 00:00:00 2001 From: Sikkra <159844544+Sikkra@users.noreply.github.com> Date: Tue, 19 May 2026 10:43:45 -0500 Subject: [PATCH] Show post-loop projected rates --- frontend/index.html | 8 ++++++- frontend/src/blend.ts | 38 ++++++++++++++++------------- frontend/src/main.ts | 30 +++++++++++++++++++---- frontend/tsconfig.json | 1 + src/bin/simulate.rs | 54 ++++++++++++++++++++++++++---------------- 5 files changed, 90 insertions(+), 41 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index f904f23..7b6882c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -422,8 +422,14 @@

Open Position

Borrow headroom
+
+ Pool util now -> after +
+
+ Current net APY +
- Est. net APY ? + Projected APY after deposit ?
Days to liquidation diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index 1c4ad6c..0cb1a4b 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -487,21 +487,21 @@ export async function fetchAllReserves(pool: PoolDef, userAddress: string): Prom // ir_mod may be returned as BigInt (i128) or number (u32) const irMod_fp = reserveRaw ? Number(BigInt(reserveRaw.data.ir_mod)) : 1_000_000; - const curUtil_fp = Math.round(util * SCALAR_F); - const FIXED_95PCT = 9_500_000; + const curUtil_fp = Math.round(Math.min(1, util) * SCALAR_F); + const maxUtil_fp = Math.round(maxUtilActual * SCALAR_F); const BACKSTOP_FP = pool.backstopFP; let baseRate_fp: number; if (curUtil_fp <= utilOpt_fp) { // Branch 1: below or at target utilisation - baseRate_fp = rBase_fp + Math.ceil(rOne_fp * curUtil_fp / utilOpt_fp); - } else if (curUtil_fp <= FIXED_95PCT) { - // Branch 2: target < util ≤ 95% - const slope = Math.ceil((curUtil_fp - utilOpt_fp) * SCALAR_F / (FIXED_95PCT - utilOpt_fp)); + baseRate_fp = rBase_fp + Math.ceil(rOne_fp * curUtil_fp / Math.max(1, utilOpt_fp)); + } else if (curUtil_fp <= maxUtil_fp) { + // Branch 2: target < util <= reserve max_util + const slope = Math.ceil((curUtil_fp - utilOpt_fp) * SCALAR_F / Math.max(1, maxUtil_fp - utilOpt_fp)); baseRate_fp = rBase_fp + rOne_fp + Math.ceil(rTwo_fp * slope / SCALAR_F); } else { - // Branch 3: util > 95% — steep r_three slope - const slope = Math.ceil((curUtil_fp - FIXED_95PCT) * SCALAR_F / (SCALAR_F - FIXED_95PCT)); + // Branch 3: util > reserve max_util - steep r_three slope + const slope = Math.ceil((curUtil_fp - maxUtil_fp) * SCALAR_F / Math.max(1, SCALAR_F - maxUtil_fp)); baseRate_fp = rBase_fp + rOne_fp + rTwo_fp + Math.ceil(rThree_fp * slope / SCALAR_F); } @@ -573,6 +573,9 @@ export interface ProjectedRates { blndBorrowApr: number; netSupplyApr: number; netBorrowCost: number; + projectedUtil: number; + projectedSupply: number; + projectedBorrow: number; } /** @@ -585,22 +588,22 @@ export interface ProjectedRates { */ export function projectRates(rs: ReserveStats, addSupply: number, addBorrow: number): ProjectedRates { const { rBase, rOne, rTwo, rThree, utilOpt, irMod, backstopFP } = rs.rateConfig; - const FIXED_95PCT = 9_500_000; + const maxUtilFp = Math.round((rs.asset.maxUtil || 0.95) * SCALAR_F); - const projSupply = rs.totalSupply + addSupply; - const projBorrow = rs.totalBorrow + addBorrow; - const projUtil = projSupply > 0 ? projBorrow / projSupply : 0; + const projSupply = Math.max(0, rs.totalSupply + addSupply); + const projBorrow = Math.max(0, rs.totalBorrow + addBorrow); + const projUtil = projSupply > 0 ? Math.min(1, projBorrow / projSupply) : 0; const utilFp = Math.round(projUtil * SCALAR_F); // 3-kink interest rate model let baseRate: number; if (utilFp <= utilOpt) { - baseRate = rBase + Math.ceil(rOne * utilFp / utilOpt); - } else if (utilFp <= FIXED_95PCT) { - const slope = Math.ceil((utilFp - utilOpt) * SCALAR_F / (FIXED_95PCT - utilOpt)); + baseRate = rBase + Math.ceil(rOne * utilFp / Math.max(1, utilOpt)); + } else if (utilFp <= maxUtilFp) { + const slope = Math.ceil((utilFp - utilOpt) * SCALAR_F / Math.max(1, maxUtilFp - utilOpt)); baseRate = rBase + rOne + Math.ceil(rTwo * slope / SCALAR_F); } else { - const slope = Math.ceil((utilFp - FIXED_95PCT) * SCALAR_F / (SCALAR_F - FIXED_95PCT)); + const slope = Math.ceil((utilFp - maxUtilFp) * SCALAR_F / Math.max(1, SCALAR_F - maxUtilFp)); baseRate = rBase + rOne + rTwo + Math.ceil(rThree * slope / SCALAR_F); } @@ -628,6 +631,9 @@ export function projectRates(rs: ReserveStats, addSupply: number, addBorrow: num blndBorrowApr, netSupplyApr: interestSupplyApr + blndSupplyApr, netBorrowCost: interestBorrowApr - blndBorrowApr, + projectedUtil: projUtil, + projectedSupply: projSupply, + projectedBorrow: projBorrow, }; } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..027a2f4 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1213,11 +1213,21 @@ function updatePreview() { const proj = projectRates(rs, supply - oldSupply, borrow - oldBorrow); const netApr = proj.netSupplyApr * lev - proj.netBorrowCost * (lev - 1); const netApy = aprToApy(netApr); - $("prev-net-apr").textContent = `${fmt(netApy, 2)}% APY on equity`; + const currentNetApr = rs.netSupplyApr * lev - rs.netBorrowCost * (lev - 1); + const currentNetApy = aprToApy(currentNetApr); + const currentUtil = rs.totalSupply > 0 ? rs.totalBorrow / rs.totalSupply : 0; + $("prev-current-net-apr").textContent = `${fmt(currentNetApy, 2)}%`; + $("prev-current-net-apr").className = currentNetApy > 0 ? "apr-great" : "apr-bad"; + $("prev-pool-util").textContent = `${fmt(currentUtil * 100, 1)}% -> ${fmt(proj.projectedUtil * 100, 1)}%`; + $("prev-pool-util").className = proj.projectedUtil > 0.90 ? "hf-bad" : proj.projectedUtil > 0.75 ? "hf-warn" : "hf-ok"; + $("prev-net-label").textContent = actionMode === "open" ? "Projected APY after deposit" + : actionMode === "add-funds" ? "Projected APY after add" + : "Projected APY after change"; + $("prev-net-apr").textContent = `${fmt(netApy, 2)}% on equity`; $("prev-net-apr").className = `prev-net-apr ${netApy > 0 ? "apr-great" : "apr-bad"}`; const prevTip = $("prev-net-tip"); if (prevTip) prevTip.setAttribute("data-tip", - `Approximate APY — Blend interest does not auto-compound. Actual net APR: ${fmt(netApr, 2)}%`); + `Current net APY uses the pool snapshot before your loop. Projected APY re-runs the rate curve after adding ${fmt(proj.projectedSupply - rs.totalSupply, 2)} ${rs.asset.symbol} supplied and ${fmt(proj.projectedBorrow - rs.totalBorrow, 2)} ${rs.asset.symbol} borrowed. Approximate projected APR: ${fmt(netApr, 2)}%.`); // Days until liquidation at this leverage (interest-only, no BLND) const spreadPct = proj.interestBorrowApr - proj.interestSupplyApr; @@ -1236,6 +1246,12 @@ function updatePreview() { // APY chart (#14) renderApyChart(rs, lev, equity, oldSupply, oldBorrow); + } else { + $("prev-current-net-apr").textContent = "\u2014"; + $("prev-current-net-apr").className = ""; + $("prev-pool-util").textContent = "\u2014"; + $("prev-pool-util").className = ""; + $("prev-net-label").textContent = "Projected APY after deposit"; } // Risk zone labels (#9) @@ -1707,7 +1723,7 @@ function showConnected() { async function connect() { try { - const result = await StellarWalletsKit.authModal({ network: getActiveNetwork() === "testnet" ? Networks.TESTNET : Networks.PUBLIC }); + const result = await StellarWalletsKit.authModal(); // Verify wallet network matches app network const networkOk = await verifyWalletNetwork(); if (!networkOk) { @@ -1729,7 +1745,7 @@ async function connect() { /** Re-open wallet modal to switch to a different account without a full page reload. */ async function switchWallet() { try { - const result = await StellarWalletsKit.authModal({ network: getActiveNetwork() === "testnet" ? Networks.TESTNET : Networks.PUBLIC }); + const result = await StellarWalletsKit.authModal(); if (result.address === userAddress) return; // Verify wallet network matches app network const networkOk = await verifyWalletNetwork(); @@ -2238,6 +2254,12 @@ $("demo-btn").addEventListener("click", () => { asset: a, cFactor: a.cFactor, lFactor: 1, interestSupplyApr: 4.2, interestBorrowApr: 6.8, blndSupplyApr: 2.1, blndBorrowApr: 1.5, netSupplyApr: 6.3, netBorrowCost: 5.3, totalSupply: 1000000, totalBorrow: 650000, available: 350000, priceUsd: 1.0, + bRate: 1_000_000_000_000n, dRate: 1_000_000_000_000n, bSupply: 1_000_000_000_000n, dSupply: 650_000_000_000n, + supplyEps: 0n, borrowEps: 0n, supplyEmission: null, borrowEmission: null, + rateConfig: { + rBase: 0, rOne: 500_000, rTwo: 2_000_000, rThree: 150_000_000, + utilOpt: Math.round(a.maxUtil * 10_000_000), irMod: 10_000_000, backstopFP: selectedPool.backstopFP, + }, })); positions = { byAsset: new Map() }; // One sample position diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1616678..2e6e2a7 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["ES2020", "DOM"], "module": "ESNext", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, diff --git a/src/bin/simulate.rs b/src/bin/simulate.rs index 2dbfde4..aa96a83 100644 --- a/src/bin/simulate.rs +++ b/src/bin/simulate.rs @@ -130,6 +130,28 @@ fn which_stellar() -> String { // ── Interest rate model ─────────────────────────────────────────────────────── +fn rates_for_util(cfg: &ReserveConfig, ir_mod: f64, bstop_rate: f64, util: f64) -> (f64, f64) { + let util = util.clamp(0.0, 1.0); + let util_target = cfg.util as f64 / SCALAR_7; + let max_util = cfg.max_util as f64 / SCALAR_7; + let r_base = cfg.r_base as f64 / SCALAR_7; + let r_one = cfg.r_one as f64 / SCALAR_7; + let r_two = cfg.r_two as f64 / SCALAR_7; + let r_three = cfg.r_three as f64 / SCALAR_7; + + let base_rate = if util <= util_target { + r_base + r_one * util / util_target.max(f64::EPSILON) + } else if util <= max_util { + r_base + r_one + r_two * (util - util_target) / (max_util - util_target).max(f64::EPSILON) + } else { + r_base + r_one + r_two + r_three * (util - max_util) / (1.0 - max_util).max(f64::EPSILON) + }; + + let borrow_apr = base_rate * ir_mod; + let supply_apr = borrow_apr * util * (1.0 - bstop_rate); + (supply_apr, borrow_apr) +} + fn compute_rates(cfg: &ReserveConfig, data: &ReserveData, bstop_rate: f64) -> (f64, f64, f64) { let b_rate = data.b_rate.parse::().unwrap() as f64 / SCALAR_12; let d_rate = data.d_rate.parse::().unwrap() as f64 / SCALAR_12; @@ -146,24 +168,7 @@ fn compute_rates(cfg: &ReserveConfig, data: &ReserveData, bstop_rate: f64) -> (f 0.0 }; - let util_target = cfg.util as f64 / SCALAR_7; - let max_util = cfg.max_util as f64 / SCALAR_7; - let r_base = cfg.r_base as f64 / SCALAR_7; - let r_one = cfg.r_one as f64 / SCALAR_7; - let r_two = cfg.r_two as f64 / SCALAR_7; - let r_three = cfg.r_three as f64 / SCALAR_7; - - let base_rate = if util <= util_target { - r_base + r_one * util / util_target - } else if util <= max_util { - r_base + r_one + r_two * (util - util_target) / (max_util - util_target) - } else { - r_base + r_one + r_two + r_three * (util - max_util) / (1.0 - max_util) - }; - - let borrow_apr = base_rate * ir_mod; - let supply_apr = borrow_apr * util * (1.0 - bstop_rate); - + let (supply_apr, borrow_apr) = rates_for_util(cfg, ir_mod, bstop_rate, util); (util, supply_apr, borrow_apr) } @@ -315,7 +320,12 @@ fn main() { let hf = if borrowed > 0.0 { (supplied * c_factor) / borrowed } else { f64::INFINITY }; let hf_str = if borrowed > 0.0 { format!("{:.4}", hf) } else { " ∞".to_string() }; - let net_yield = supply_apr * supplied - borrow_apr * borrowed; + let post_supply = pool_supplied_usdc + supplied; + let post_borrow = pool_borrowed_usdc + borrowed; + let post_util = if post_supply > 0.0 { (post_borrow / post_supply).min(1.0) } else { 0.0 }; + let (projected_supply_apr, projected_borrow_apr) = rates_for_util(cfg, ir_mod, bstop_rate, post_util); + + let net_yield = projected_supply_apr * supplied - projected_borrow_apr * borrowed; let net_apy = net_yield / initial * 100.0; let blnd_yr = supplied * blnd_per_usdc_supply + borrowed * blnd_per_usdc_borrow; @@ -332,7 +342,11 @@ fn main() { // Theoretical ∞ row let max_sup = initial * max_lev; let max_bor = max_sup - initial; - let max_net_apy = (supply_apr * max_sup - borrow_apr * max_bor) / initial * 100.0; + let max_post_supply = pool_supplied_usdc + max_sup; + let max_post_borrow = pool_borrowed_usdc + max_bor; + let max_post_util = if max_post_supply > 0.0 { (max_post_borrow / max_post_supply).min(1.0) } else { 0.0 }; + let (max_supply_apr, max_borrow_apr) = rates_for_util(cfg, ir_mod, bstop_rate, max_post_util); + let max_net_apy = (max_supply_apr * max_sup - max_borrow_apr * max_bor) / initial * 100.0; let max_blnd_yr = max_sup * blnd_per_usdc_supply + max_bor * blnd_per_usdc_borrow; let max_cap_warn = if max_sup > pool_supplied_usdc + cap_room { "CAP" } else { "" };