diff --git a/AGENTS.md b/AGENTS.md index 43e8d32d..9e5c4cd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ Always consult these docs for detailed information: 4. Do not claim repo facts without evidence (no invented counts). 5. Prevent double-capture, noisy heuristics, or duplicate logic. + ## Post-Implementation Consolidation (Mandatory) Before closing any non-trivial change: diff --git a/src/features/market-detail/components/borrower-table-column-visibility.ts b/src/features/market-detail/components/borrower-table-column-visibility.ts new file mode 100644 index 00000000..90e575bb --- /dev/null +++ b/src/features/market-detail/components/borrower-table-column-visibility.ts @@ -0,0 +1,19 @@ +export type BorrowerTableColumnVisibility = { + daysToLiquidation: boolean; + liquidationPrice: boolean; +}; + +export const BORROWER_TABLE_COLUMN_LABELS: Record = { + daysToLiquidation: 'Days to Liquidation', + liquidationPrice: 'Liquidation Price', +}; + +export const BORROWER_TABLE_COLUMN_DESCRIPTIONS: Record = { + daysToLiquidation: 'Estimated days until position reaches liquidation threshold.', + liquidationPrice: 'Price where position becomes liquidatable, plus move from current oracle price.', +}; + +export const DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY: BorrowerTableColumnVisibility = { + daysToLiquidation: true, + liquidationPrice: true, +}; diff --git a/src/features/market-detail/components/borrower-table-settings-modal.tsx b/src/features/market-detail/components/borrower-table-settings-modal.tsx new file mode 100644 index 00000000..d16c4778 --- /dev/null +++ b/src/features/market-detail/components/borrower-table-settings-modal.tsx @@ -0,0 +1,90 @@ +import type { ReactNode } from 'react'; +import { FiSliders } from 'react-icons/fi'; +import { IconSwitch } from '@/components/ui/icon-switch'; +import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; +import { + BORROWER_TABLE_COLUMN_DESCRIPTIONS, + BORROWER_TABLE_COLUMN_LABELS, + DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, + type BorrowerTableColumnVisibility, +} from './borrower-table-column-visibility'; + +type BorrowerTableSettingsModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + columnVisibility: BorrowerTableColumnVisibility; + onColumnVisibilityChange: ( + visibilityOrUpdater: BorrowerTableColumnVisibility | ((prev: BorrowerTableColumnVisibility) => BorrowerTableColumnVisibility), + ) => void; +}; + +type SettingItemProps = { + title: string; + description: string; + children: ReactNode; +}; + +function SettingItem({ title, description, children }: SettingItemProps) { + return ( +
+
+

{title}

+

{description}

+
+
{children}
+
+ ); +} + +export function BorrowerTableSettingsModal({ + isOpen, + onOpenChange, + columnVisibility, + onColumnVisibilityChange, +}: BorrowerTableSettingsModalProps) { + const columnKeys = Object.keys(BORROWER_TABLE_COLUMN_LABELS) as (keyof BorrowerTableColumnVisibility)[]; + + return ( + + {(onClose) => ( + <> + } + onClose={onClose} + /> + +
+

Visible Columns

+
+ {columnKeys.map((key) => ( + + onColumnVisibilityChange((prev) => ({ ...prev, [key]: value }))} + size="xs" + color="primary" + aria-label={`Toggle ${BORROWER_TABLE_COLUMN_LABELS[key]} column`} + /> + + ))} +
+
+
+ + )} +
+ ); +} diff --git a/src/features/market-detail/components/borrowers-table.tsx b/src/features/market-detail/components/borrowers-table.tsx index 9d9bfc7a..ffb98e9d 100644 --- a/src/features/market-detail/components/borrowers-table.tsx +++ b/src/features/market-detail/components/borrowers-table.tsx @@ -1,4 +1,5 @@ import { useState, useMemo } from 'react'; +import { GearIcon } from '@radix-ui/react-icons'; import { Tooltip } from '@/components/ui/tooltip'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHead } from '@/components/ui/table'; import { GoFilter } from 'react-icons/go'; @@ -11,11 +12,22 @@ import { TablePagination } from '@/components/shared/table-pagination'; import { TokenIcon } from '@/components/shared/token-icon'; import { TooltipContent } from '@/components/shared/tooltip-content'; import { useAppSettings } from '@/stores/useAppSettings'; +import { useMarketDetailPreferences } from '@/stores/useMarketDetailPreferences'; import { MONARCH_PRIMARY } from '@/constants/chartColors'; import { useMarketBorrowers } from '@/hooks/useMarketBorrowers'; import { formatSimple } from '@/utils/balance'; import type { Market } from '@/utils/types'; import { LiquidateModal } from '@/modals/liquidate/liquidate-modal'; +import { + computeLiquidationOraclePrice, + computeLtv, + computeOraclePriceChangePercent, + formatMarketOraclePriceWithSymbol, + formatRelativeLiquidationPriceMove, + isInfiniteLtv, +} from '@/modals/borrow/components/helpers'; +import { BorrowerTableSettingsModal } from './borrower-table-settings-modal'; +import { DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY } from './borrower-table-column-visibility'; type BorrowersTableProps = { chainId: number; @@ -25,11 +37,20 @@ type BorrowersTableProps = { onOpenFiltersModal: () => void; }; +type BorrowerRowMetric = { + ltvPercent: number | null; + daysToLiquidation: number | null; + liquidationPrice: string; + liquidationPriceMove: string; +}; + export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpenFiltersModal }: BorrowersTableProps) { const [currentPage, setCurrentPage] = useState(1); const [liquidateBorrower, setLiquidateBorrower] = useState
(null); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); const pageSize = 10; const { showDeveloperOptions } = useAppSettings(); + const { borrowerTableColumnVisibility, setBorrowerTableColumnVisibility } = useMarketDetailPreferences(); const { data: paginatedData, isLoading, isFetching } = useMarketBorrowers(market?.uniqueKey, chainId, minShares, currentPage, pageSize); @@ -43,49 +64,72 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen const hasActiveFilter = minShares !== '0'; const tableKey = `borrowers-table-${currentPage}`; + const showDaysToLiquidation = + borrowerTableColumnVisibility.daysToLiquidation ?? DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY.daysToLiquidation; + const showLiquidationPrice = borrowerTableColumnVisibility.liquidationPrice ?? DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY.liquidationPrice; - // Calculate LTV and Days to Liquidation for each borrower - // LTV = borrowAssets / (collateral * oraclePrice) - // Days to Liquidation = ln(lltv/ltv) / ln(1 + borrowApy) * 365 - // (using continuous compounding: r = ln(1 + APY) to convert annual APY to continuous rate) const borrowersWithMetrics = useMemo(() => { if (!oraclePrice) return []; - const lltv = Number(market.lltv) / 1e16; // lltv in WAD format (e.g., 8e17 = 80%) + const lltv = BigInt(market.lltv); const borrowApy = market.state.borrowApy; return borrowers.map((borrower) => { const borrowAssets = BigInt(borrower.borrowAssets); - const collateral = BigInt(borrower.collateral); - - // Calculate collateral value in loan asset terms - // oraclePrice is scaled by 10^36, need to adjust for token decimals - const collateralValueInLoan = (collateral * oraclePrice) / BigInt(10 ** 36); - - // Calculate LTV as a percentage - let ltv = 0; - if (collateralValueInLoan > 0n) { - ltv = Number((borrowAssets * 10000n) / collateralValueInLoan) / 100; - } + const collateralAssets = BigInt(borrower.collateral); + const ltvWad = computeLtv({ borrowAssets, collateralAssets, oraclePrice }); + const ltvPercent = isInfiniteLtv(ltvWad) ? null : Number(ltvWad) / 1e16; - // Calculate Days to Liquidation - // Only calculate if borrower has position, LTV > 0, and borrow rate > 0 let daysToLiquidation: number | null = null; - if (ltv > 0 && borrowApy > 0 && lltv > ltv) { - // Use continuous compounding: LTV(t) = LTV * e^(r * t) where r = ln(1 + APY) - // Solve for t when LTV(t) = lltv: t = ln(lltv/ltv) / r + if (!isInfiniteLtv(ltvWad) && ltvWad > 0n && borrowApy > 0 && lltv > ltvWad) { const continuousRate = Math.log(1 + borrowApy); - const yearsToLiquidation = Math.log(lltv / ltv) / continuousRate; + const yearsToLiquidation = Math.log(Number(lltv) / Number(ltvWad)) / continuousRate; daysToLiquidation = Math.max(0, Math.round(yearsToLiquidation * 365)); } + const liquidationOraclePrice = + !isInfiniteLtv(ltvWad) && ltvWad > 0n && lltv > 0n + ? computeLiquidationOraclePrice({ + oraclePrice, + ltv: ltvWad, + lltv, + }) + : null; + const liquidationPrice = + liquidationOraclePrice == null + ? '—' + : formatMarketOraclePriceWithSymbol({ + oraclePrice: liquidationOraclePrice, + collateralDecimals: market.collateralAsset.decimals, + loanDecimals: market.loanAsset.decimals, + loanSymbol: market.loanAsset.symbol, + }); + const liquidationPriceMove = formatRelativeLiquidationPriceMove({ + percentChange: + liquidationOraclePrice == null + ? null + : computeOraclePriceChangePercent({ + currentOraclePrice: oraclePrice, + targetOraclePrice: liquidationOraclePrice, + }), + wrapInParentheses: true, + }); + + const metrics: BorrowerRowMetric = { + ltvPercent, + daysToLiquidation, + liquidationPrice, + liquidationPriceMove, + }; + return { ...borrower, - ltv, - daysToLiquidation, + ...metrics, }; }); - }, [borrowers, oraclePrice, market.lltv, market.state.borrowApy]); + }, [borrowers, oraclePrice, market]); + + const emptyStateColSpan = 5 + (showDaysToLiquidation ? 1 : 0) + (showLiquidationPrice ? 1 : 0) + (showDeveloperOptions ? 1 : 0); return (
@@ -114,11 +158,29 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen /> + + + } + > + +
- {/* Loading overlay */} {isFetching && (
@@ -136,18 +198,35 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen BORROWED COLLATERAL LTV - - - } - > - DAYS TO LIQ. - - + {showDaysToLiquidation && ( + + + } + > + DAYS TO LIQ. + + + )} + {showLiquidationPrice && ( + + + } + > + LIQ. PRICE + + + )} % OF BORROW {showDeveloperOptions && ACTIONS} @@ -156,7 +235,7 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen {borrowersWithMetrics.length === 0 && !isLoading ? ( No borrowers found for this market @@ -168,9 +247,8 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen const borrowerAssets = BigInt(borrower.borrowAssets); const percentOfBorrow = totalBorrow > 0n ? (Number(borrowerAssets) / Number(totalBorrow)) * 100 : 0; const percentDisplay = percentOfBorrow < 0.01 && percentOfBorrow > 0 ? '<0.01%' : `${percentOfBorrow.toFixed(2)}%`; - - // Days to liquidation display const daysDisplay = borrower.daysToLiquidation !== null ? `${borrower.daysToLiquidation}` : '—'; + const ltvDisplay = borrower.ltvPercent !== null ? `${borrower.ltvPercent.toFixed(2)}%` : '∞'; return ( @@ -210,8 +288,16 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen )}
- {borrower.ltv.toFixed(2)}% - {daysDisplay} + {ltvDisplay} + {showDaysToLiquidation && {daysDisplay}} + {showLiquidationPrice && ( + +
+ {borrower.liquidationPrice} + {borrower.liquidationPriceMove} +
+
+ )} {percentDisplay} {showDeveloperOptions && ( @@ -254,6 +340,13 @@ export function BorrowersTable({ chainId, market, minShares, oraclePrice, onOpen }} /> )} + +
); } diff --git a/src/features/positions/components/borrowed-morpho-blue-row-detail.tsx b/src/features/positions/components/borrowed-morpho-blue-row-detail.tsx index 12a51c9e..315110ab 100644 --- a/src/features/positions/components/borrowed-morpho-blue-row-detail.tsx +++ b/src/features/positions/components/borrowed-morpho-blue-row-detail.tsx @@ -10,7 +10,8 @@ import { computeLtv, computeOraclePriceChangePercent, formatLtvPercent, - formatMarketOraclePrice, + formatMarketOraclePriceWithSymbol, + formatRelativeLiquidationPriceMove, getLTVColor, getLTVProgressColor, isInfiniteLtv, @@ -40,36 +41,13 @@ type MetricRowProps = { valueClassName?: string; }; -function formatPercent(value: number): string { - return value.toFixed(2).replace(/\.?0+$/u, ''); -} - -function formatPriceMove(percentChange: number | null): string { - if (percentChange == null || !Number.isFinite(percentChange)) { - return '—'; - } - - if (percentChange > 0) { - return `-${formatPercent(percentChange)}%`; - } - - if (percentChange < 0) { - return `+${formatPercent(Math.abs(percentChange))}%`; - } - - return '0%'; -} - function formatBorrowPositionPrice(row: BorrowPositionRow, oraclePrice: bigint): string { - if (oraclePrice <= 0n) { - return '—'; - } - - return `${formatMarketOraclePrice({ + return formatMarketOraclePriceWithSymbol({ oraclePrice, collateralDecimals: row.market.collateralAsset.decimals, loanDecimals: row.market.loanAsset.decimals, - })} ${row.market.loanAsset.symbol}`; + loanSymbol: row.market.loanAsset.symbol, + }); } export function deriveBorrowPositionMetrics(row: BorrowPositionRow): BorrowPositionMetrics { @@ -177,7 +155,7 @@ export function BorrowedMorphoBlueRowDetail({ row }: BorrowedMorphoBlueRowDetail } - secondaryDetail={priceMove == null ? undefined : `Relative to current: ${formatPriceMove(priceMove)}`} + secondaryDetail={priceMove == null ? undefined : `Relative to current: ${formatRelativeLiquidationPriceMove({ percentChange: priceMove })}`} /> ); @@ -225,7 +203,7 @@ export function BorrowedMorphoBlueRowDetail({ row }: BorrowedMorphoBlueRowDetail label="Price Move" tooltipTitle="Price Move" tooltipDetail="Relative move from the current oracle price to liquidation." - value={formatPriceMove(priceMove)} + value={formatRelativeLiquidationPriceMove({ percentChange: priceMove })} /> diff --git a/src/modals/borrow/components/helpers.ts b/src/modals/borrow/components/helpers.ts index bcad1306..bd7b087d 100644 --- a/src/modals/borrow/components/helpers.ts +++ b/src/modals/borrow/components/helpers.ts @@ -157,6 +157,51 @@ export const computeOraclePriceChangePercent = ({ return Number(percentChangeBps) / 100; }; +const formatPercentForDisplay = (value: number): string => value.toFixed(2).replace(/\.?0+$/u, ''); + +export const formatRelativeLiquidationPriceMove = ({ + percentChange, + wrapInParentheses = false, +}: { + percentChange: number | null; + wrapInParentheses?: boolean; +}): string => { + if (percentChange == null || !Number.isFinite(percentChange)) { + return '—'; + } + + const signedValue = + percentChange > 0 + ? `-${formatPercentForDisplay(percentChange)}%` + : percentChange < 0 + ? `+${formatPercentForDisplay(Math.abs(percentChange))}%` + : '0%'; + + return wrapInParentheses ? `(${signedValue})` : signedValue; +}; + +export const formatMarketOraclePriceWithSymbol = ({ + oraclePrice, + collateralDecimals, + loanDecimals, + loanSymbol, +}: { + oraclePrice: bigint; + collateralDecimals: number; + loanDecimals: number; + loanSymbol: string; +}): string => { + if (oraclePrice <= 0n) { + return '—'; + } + + return `${formatMarketOraclePrice({ + oraclePrice, + collateralDecimals, + loanDecimals, + })} ${loanSymbol}`; +}; + export const computeRequiredCollateralAssets = ({ borrowAssets, oraclePrice, diff --git a/src/stores/useMarketDetailPreferences.ts b/src/stores/useMarketDetailPreferences.ts index 6e94c09d..828d0aef 100644 --- a/src/stores/useMarketDetailPreferences.ts +++ b/src/stores/useMarketDetailPreferences.ts @@ -1,14 +1,22 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { + DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, + type BorrowerTableColumnVisibility, +} from '@/features/market-detail/components/borrower-table-column-visibility'; type MarketDetailTab = 'trend' | 'activities' | 'positions' | 'analysis'; type MarketDetailPreferencesState = { selectedTab: MarketDetailTab; + borrowerTableColumnVisibility: BorrowerTableColumnVisibility; }; type MarketDetailPreferencesActions = { setSelectedTab: (tab: MarketDetailTab) => void; + setBorrowerTableColumnVisibility: ( + visibilityOrUpdater: BorrowerTableColumnVisibility | ((prev: BorrowerTableColumnVisibility) => BorrowerTableColumnVisibility), + ) => void; setAll: (state: Partial) => void; }; @@ -18,11 +26,48 @@ export const useMarketDetailPreferences = create() persist( (set) => ({ selectedTab: 'trend', + borrowerTableColumnVisibility: DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, setSelectedTab: (tab) => set({ selectedTab: tab }), + setBorrowerTableColumnVisibility: (visibilityOrUpdater) => + set((state) => ({ + borrowerTableColumnVisibility: + typeof visibilityOrUpdater === 'function' ? visibilityOrUpdater(state.borrowerTableColumnVisibility) : visibilityOrUpdater, + })), setAll: (state) => set(state), }), { name: 'monarch_store_marketDetailPreferences', + version: 2, + migrate: (state, version) => { + if (!state || typeof state !== 'object') { + return { + selectedTab: 'trend', + borrowerTableColumnVisibility: DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, + } as MarketDetailPreferencesState; + } + + const persisted = state as Partial; + + if (version < 2) { + return { + ...persisted, + selectedTab: persisted.selectedTab ?? 'trend', + borrowerTableColumnVisibility: { + ...DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, + ...(persisted.borrowerTableColumnVisibility ?? {}), + }, + } as MarketDetailPreferencesState; + } + + return { + ...persisted, + selectedTab: persisted.selectedTab ?? 'trend', + borrowerTableColumnVisibility: { + ...DEFAULT_BORROWER_TABLE_COLUMN_VISIBILITY, + ...(persisted.borrowerTableColumnVisibility ?? {}), + }, + } as MarketDetailPreferencesState; + }, }, ), );