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", 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..c141bc8a 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(), @@ -1800,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, 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..e1db516d 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,18 @@ export class LeaseCalculator { currency?.decimal_digits ?? 8 ); + // Frozen initial leverage from the lease-open snapshot. LS_loan_amnt_stable + // 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, lpnDecimals)).quo(downPayment) + : null; + // Calculate PnL const { pnlAmount, pnlPercent, pnlPositive } = this.calculatePnl( assetValueUsd, @@ -174,6 +190,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)}`; };