From 2669c5d3915daa17fa4559d7a37667d7b84dfbba Mon Sep 17 00:00:00 2001 From: metodi96 Date: Fri, 15 May 2026 13:30:32 +0300 Subject: [PATCH 1/4] fix(share-pnl): pin leverage to lease-open snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The share-pnl card computed leverage as `(downPayment + totalDebt) / downPayment` with downPayment in USD and totalDebt in LPN units — dimensionally correct only for longs (LPN = USDC ≈ $1). For shorts the LPN is the volatile asset (OSMO, WBTC, etc.) so totalDebt was a raw asset quantity, producing absurd leverages like x12.3 on a real 1.7x position. Plumb `LS_loan_amnt_stable` through from ETL (loan principal in stable USD at open) and compute `leverageAtOpen = (downPayment + loanAtOpen) / downPayment` once in calculateDisplayData. The share-pnl dialog prefers this frozen value so the displayed leverage stays pinned to what the user actually opened at — 2.5x at the protocol max stays 2.5x for the life of the lease, regardless of accruing interest or manual repayments. Falls back to a live computation using totalDebtUsd (instead of totalDebt) when the lease pre-dates the new field, fixing the dimensional bug even in the fallback path. --- backend/src/external/etl.rs | 5 +++++ backend/src/handlers/leases.rs | 5 +++++ src/common/api/types/leases.ts | 3 +++ src/common/utils/LeaseCalculator.ts | 15 +++++++++++++++ .../components/single-lease/SharePnLDialog.vue | 18 ++++++++++++------ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/backend/src/external/etl.rs b/backend/src/external/etl.rs index 1ead2f05..79bcc0ca 100644 --- a/backend/src/external/etl.rs +++ b/backend/src/external/etl.rs @@ -387,6 +387,11 @@ pub struct EtlLeaseInfo { pub downpayment_amount: Option, #[serde(rename = "LS_loan_amnt_asset")] pub loan_amount: Option, + /// LS_loan_amnt_stable — the loan principal in stable USD at lease open + /// (6 decimals). Used to compute the frozen initial leverage, which + /// doesn't drift with interest accrual or partial repayments. + #[serde(rename = "LS_loan_amnt_stable")] + pub loan_amount_stable: Option, /// LS_asset_symbol is the leveraged asset (e.g., ALL_BTC) #[serde(rename = "LS_asset_symbol")] pub lease_position_ticker: Option, diff --git a/backend/src/handlers/leases.rs b/backend/src/handlers/leases.rs index 50b8f4f3..7bd68194 100644 --- a/backend/src/handlers/leases.rs +++ b/backend/src/handlers/leases.rs @@ -163,6 +163,10 @@ pub struct LeaseOpeningStateInfo { pub struct LeaseEtlData { /// Downpayment amount (LS_cltr_amnt_stable: asset_micro_units × price) pub downpayment_amount: Option, + /// Loan principal in stable USD at lease open (LS_loan_amnt_stable, 6 + /// decimals). Used to compute frozen initial leverage that doesn't drift + /// with accruing interest or manual repayments. + pub loan_amount_stable: Option, /// Collateral symbol (e.g., "USDC_NOBLE" or "ALL_BTC") — determines downpayment decimals pub collateral_symbol: Option, /// Opening price per asset @@ -861,6 +865,7 @@ async fn fetch_lease_info( // Note: Some fields are at the top level of EtlLeaseOpening, not inside lease let etl_info = etl_data.as_ref().map(|d| LeaseEtlData { downpayment_amount: d.lease.downpayment_amount.clone(), + loan_amount_stable: d.lease.loan_amount_stable.clone(), collateral_symbol: d.lease.collateral_symbol.clone(), // Opening price is inside lease.opening_price (was LS_opening_price) price: d.lease.opening_price.clone(), diff --git a/src/common/api/types/leases.ts b/src/common/api/types/leases.ts index 57fa12f3..1acbe986 100644 --- a/src/common/api/types/leases.ts +++ b/src/common/api/types/leases.ts @@ -86,6 +86,9 @@ export interface LeaseOpeningStateInfo { export interface LeaseEtlData { /** Downpayment amount (LS_cltr_amnt_stable: asset_micro_units × price) */ downpayment_amount?: string; + /** Loan principal in stable USD at lease open (LS_loan_amnt_stable, 6 decimals). + * Used to compute frozen initial leverage. */ + loan_amount_stable?: string; /** Collateral symbol (e.g., "USDC_NOBLE" or "ALL_BTC") — determines downpayment decimals */ collateral_symbol?: string; /** Opening price per asset */ diff --git a/src/common/utils/LeaseCalculator.ts b/src/common/utils/LeaseCalculator.ts index fe9fd07f..fc54b11d 100644 --- a/src/common/utils/LeaseCalculator.ts +++ b/src/common/utils/LeaseCalculator.ts @@ -57,6 +57,10 @@ export interface LeaseDisplayData { openingPrice: Dec; fee: Dec; repaymentValue: Dec; + /** Frozen initial leverage from the lease-open snapshot + * `(downPayment + loanAtOpen) / downPayment`. null when ETL didn't expose + * `loan_amount_stable` (older leases ingested before that field shipped). */ + leverageAtOpen: Dec | null; // In progress status inProgressType: "opening" | "repayment" | "close" | "liquidation" | "slippage_protection" | null; // Asset amounts @@ -132,6 +136,16 @@ export class LeaseCalculator { currency?.decimal_digits ?? 8 ); + // Frozen initial leverage from the lease-open snapshot. LS_loan_amnt_stable + // is in stable USDC decimals (6) regardless of position type, matching + // the downPayment unit produced by parseEtlData above. Falls back to null + // if the ETL row predates the loan_amount_stable exposure — the share-pnl + // card then uses the live (drifting) formula as a degraded fallback. + const leverageAtOpen = + lease.etl_data?.loan_amount_stable && downPayment.isPositive() + ? downPayment.add(new Dec(lease.etl_data.loan_amount_stable, 6)).quo(downPayment) + : null; + // Calculate PnL const { pnlAmount, pnlPercent, pnlPositive } = this.calculatePnl( assetValueUsd, @@ -174,6 +188,7 @@ export class LeaseCalculator { openingPrice, fee, repaymentValue, + leverageAtOpen, inProgressType, unitAsset, stableAsset diff --git a/src/modules/leases/components/single-lease/SharePnLDialog.vue b/src/modules/leases/components/single-lease/SharePnLDialog.vue index 325155d7..eda9b7e8 100644 --- a/src/modules/leases/components/single-lease/SharePnLDialog.vue +++ b/src/modules/leases/components/single-lease/SharePnLDialog.vue @@ -201,16 +201,22 @@ const positionSizeUsd = () => { const pnlNumber = () => Number(leaseDisplayData?.pnlPercent.toString(2) ?? "0"); -// Effective leverage at open: (downPayment + debt) / downPayment. For a -// freshly opened lease the debt equals the borrowed principal, so this is the -// classic "I went 2.5x on this trade" number. As interest accrues totalDebt -// drifts up, which would creep this slightly over its initial value — small -// effect over the lifetime of a lease and acceptable for a share card. +// Prefer the frozen initial leverage from the lease-open snapshot — it +// matches what the user actually opened at (e.g. exactly 2.5x at protocol +// max) and doesn't drift with interest accrual or manual repayments. Falls +// back to a live computation when ETL hasn't exposed loan_amount_stable for +// the lease (older rows pre-dating that field). The previous live formula +// added downPayment (USD) to totalDebt (LPN units) which produced a wildly +// inflated leverage on shorts; the fallback uses totalDebtUsd to keep the +// magnitude sane even when frozen data is unavailable. const leverageMultiple = (): string | null => { if (!leaseDisplayData) return null; + if (leaseDisplayData.leverageAtOpen && leaseDisplayData.leverageAtOpen.isPositive()) { + return `x${Number(leaseDisplayData.leverageAtOpen.toString()).toFixed(1)}`; + } const dp = leaseDisplayData.downPayment; if (!dp.isPositive()) return null; - const lev = dp.add(leaseDisplayData.totalDebt).quo(dp); + const lev = dp.add(leaseDisplayData.totalDebtUsd).quo(dp); return `x${Number(lev.toString()).toFixed(1)}`; }; From 43ace199fc1d8951115d6468cdcbc1400cebbe69 Mon Sep 17 00:00:00 2001 From: metodi96 Date: Fri, 15 May 2026 14:26:12 +0300 Subject: [PATCH 2/4] fix(tests): add loan_amount_stable to EtlLeaseInfo test constructor Mirrors the new field added to EtlLeaseInfo in the previous commit; without it the `make_etl_with_collateral` helper fails to compile in the test target. --- backend/src/handlers/leases.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/handlers/leases.rs b/backend/src/handlers/leases.rs index 7bd68194..c141bc8a 100644 --- a/backend/src/handlers/leases.rs +++ b/backend/src/handlers/leases.rs @@ -1805,6 +1805,7 @@ mod tests { timestamp: None, downpayment_amount: Some(downpayment.to_string()), loan_amount: None, + loan_amount_stable: None, lease_position_ticker: None, collateral_symbol: collateral_symbol.map(|s| s.to_string()), opening_price: None, From 3f7c31c649f0c5c42f63445c11458ba483b66343 Mon Sep 17 00:00:00 2001 From: metodi96 Date: Fri, 15 May 2026 14:31:16 +0300 Subject: [PATCH 3/4] chore: regenerate OpenAPI snapshot for loan_amount_stable Adds the new `LeaseEtlData.loan_amount_stable` field to the committed OpenAPI spec snapshot. Generated via `UPDATE_OPENAPI_SNAPSHOT=1 cargo test openapi`. --- backend/openapi.snapshot.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/openapi.snapshot.json b/backend/openapi.snapshot.json index b6a7b5c8..d2b9b153 100644 --- a/backend/openapi.snapshot.json +++ b/backend/openapi.snapshot.json @@ -5194,6 +5194,13 @@ ], "description": "Downpayment amount (LS_cltr_amnt_stable: asset_micro_units × price)" }, + "loan_amount_stable": { + "type": [ + "string", + "null" + ], + "description": "Loan principal in stable USD at lease open (LS_loan_amnt_stable, 6\ndecimals). Used to compute frozen initial leverage that doesn't drift\nwith accruing interest or manual repayments." + }, "collateral_symbol": { "type": [ "string", From a4fcc8f2e13032ec90c38d5f51f47d2af1725214 Mon Sep 17 00:00:00 2001 From: metodi96 Date: Fri, 15 May 2026 14:55:36 +0300 Subject: [PATCH 4/4] fix(share-pnl): use LPN decimals for loan_amount_stable in leverage calc A real BTC short surfaced a denomination quirk: LS_loan_amnt_stable on ETL /ls-opening is scaled by the LPN's native decimals (1e6 for OSMO, 1e8 for ALL_BTC, etc.), not by stable USDC decimals. The previous `new Dec(loan_amount_stable, 6)` inflated the loan 100x on BTC shorts and produced x76 leverage on a position actually opened at x1.75. Pass `lpnDecimals` (already in scope from the surrounding calculateDisplayData) to the Dec constructor so the scaling matches the LPN's native decimals. OSMO continues to work because its LPN decimals coincide with stable decimals. --- src/common/utils/LeaseCalculator.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/common/utils/LeaseCalculator.ts b/src/common/utils/LeaseCalculator.ts index fc54b11d..e1db516d 100644 --- a/src/common/utils/LeaseCalculator.ts +++ b/src/common/utils/LeaseCalculator.ts @@ -137,13 +137,15 @@ export class LeaseCalculator { ); // Frozen initial leverage from the lease-open snapshot. LS_loan_amnt_stable - // is in stable USDC decimals (6) regardless of position type, matching - // the downPayment unit produced by parseEtlData above. Falls back to null - // if the ETL row predates the loan_amount_stable exposure — the share-pnl - // card then uses the live (drifting) formula as a degraded fallback. + // is scaled by the LPN's native decimals (1e6 for OSMO, 1e8 for ALL_BTC, + // etc.), not by stable decimals — using a fixed 6 here inflated the loan + // 100x on BTC shorts and produced ~76x leverage on a real 1.75x position. + // Falls back to null if ETL hasn't exposed the field for this lease — the + // share-pnl card then uses the live (drifting) formula as a degraded + // fallback. const leverageAtOpen = lease.etl_data?.loan_amount_stable && downPayment.isPositive() - ? downPayment.add(new Dec(lease.etl_data.loan_amount_stable, 6)).quo(downPayment) + ? downPayment.add(new Dec(lease.etl_data.loan_amount_stable, lpnDecimals)).quo(downPayment) : null; // Calculate PnL