From 6dd13407a5c1524793326a55fa8fa014a15227be Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 15 Jan 2026 14:25:24 +0800 Subject: [PATCH 1/7] feat: charts --- src/constants/chartColors.ts | 22 ++ .../components/charts/borrowers-pie-chart.tsx | 294 ++++++++++++++++++ .../charts/collateral-at-risk-chart.tsx | 282 +++++++++++++++++ .../components/charts/suppliers-pie-chart.tsx | 249 +++++++++++++++ src/features/market-detail/market-view.tsx | 41 ++- src/hooks/useAllMarketPositions.ts | 102 ++++++ 6 files changed, 982 insertions(+), 8 deletions(-) create mode 100644 src/features/market-detail/components/charts/borrowers-pie-chart.tsx create mode 100644 src/features/market-detail/components/charts/collateral-at-risk-chart.tsx create mode 100644 src/features/market-detail/components/charts/suppliers-pie-chart.tsx create mode 100644 src/hooks/useAllMarketPositions.ts diff --git a/src/constants/chartColors.ts b/src/constants/chartColors.ts index a295b249..3d0bcc54 100644 --- a/src/constants/chartColors.ts +++ b/src/constants/chartColors.ts @@ -26,3 +26,25 @@ export const CHART_COLORS = { }, }, } as const; + +export const PIE_COLORS = [ + '#3B82F6', // Blue + '#10B981', // Green + '#F59E0B', // Amber + '#8B5CF6', // Violet + '#EC4899', // Pink + '#06B6D4', // Cyan + '#84CC16', // Lime + '#F97316', // Orange + '#6366F1', // Indigo + '#64748B', // Slate (for "Other") +] as const; + +export const RISK_COLORS = { + stroke: '#EF4444', + gradient: { + start: '#EF4444', + startOpacity: 0.3, + endOpacity: 0, + }, +} as const; diff --git a/src/features/market-detail/components/charts/borrowers-pie-chart.tsx b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx new file mode 100644 index 00000000..97edbc13 --- /dev/null +++ b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx @@ -0,0 +1,294 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import type { Address } from 'viem'; +import { formatUnits } from 'viem'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { PIE_COLORS } from '@/constants/chartColors'; +import { useVaultRegistry } from '@/contexts/VaultRegistryContext'; +import { useAllMarketBorrowers } from '@/hooks/useAllMarketPositions'; +import { formatSimple } from '@/utils/balance'; +import { getSlicedAddress } from '@/utils/address'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; + +type BorrowersPieChartProps = { + chainId: SupportedNetworks; + market: Market; + oraclePrice: bigint; +}; + +type PieDataItem = { + name: string; + value: number; + address: string; + percentage: number; + collateral: number; + ltv: number; + isOther?: boolean; + otherItems?: { name: string; value: number; address: string; percentage: number; collateral: number; ltv: number }[]; +}; + +const TOP_POSITIONS_COUNT = 10; +const MIN_PERCENTAGE_THRESHOLD = 1; // 1% + +export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPieChartProps) { + const { data: borrowers, isLoading, totalCount } = useAllMarketBorrowers(market.uniqueKey, chainId); + const { getVaultByAddress } = useVaultRegistry(); + const [expandedOther, setExpandedOther] = useState(false); + + // Helper to get display name for an address (vault name or shortened address) + const getDisplayName = (address: string): string => { + const vault = getVaultByAddress(address as Address, chainId); + if (vault?.name) return vault.name; + return getSlicedAddress(address as `0x${string}`); + }; + + const pieData = useMemo(() => { + if (!borrowers || borrowers.length === 0) return []; + + const totalBorrowAssets = BigInt(market.state.borrowAssets); + + if (totalBorrowAssets === 0n) return []; + + // Calculate data for each borrower and sort by borrow value + const borrowersWithData = borrowers + .map((borrower) => { + const borrowAssets = BigInt(borrower.borrowAssets); + const collateralBigInt = BigInt(borrower.collateral); + + const borrowAssetsNumber = Number(formatUnits(borrowAssets, market.loanAsset.decimals)); + const collateralNumber = Number(formatUnits(collateralBigInt, market.collateralAsset.decimals)); + + // Use scaled bigint math for precision + const percentageScaled = (borrowAssets * 10000n) / totalBorrowAssets; + const percentage = Number(percentageScaled) / 100; + + // Calculate LTV + let ltv = 0; + if (oraclePrice > 0n && collateralBigInt > 0n) { + const collateralValueInLoan = (collateralBigInt * oraclePrice) / BigInt(10 ** 36); + if (collateralValueInLoan > 0n) { + ltv = Number((borrowAssets * 10000n) / collateralValueInLoan) / 100; + } + } + + return { + name: getDisplayName(borrower.userAddress), + address: borrower.userAddress, + value: borrowAssetsNumber, + percentage, + collateral: collateralNumber, + ltv, + }; + }) + .sort((a, b) => b.value - a.value); + + // Split into top positions and "Other" + const topPositions: PieDataItem[] = []; + const otherPositions: { + name: string; + value: number; + address: string; + percentage: number; + collateral: number; + ltv: number; + }[] = []; + + for (const position of borrowersWithData) { + if (topPositions.length < TOP_POSITIONS_COUNT && position.percentage >= MIN_PERCENTAGE_THRESHOLD) { + topPositions.push(position); + } else { + otherPositions.push(position); + } + } + + // Add "Other" category if there are remaining positions + if (otherPositions.length > 0) { + const otherTotal = otherPositions.reduce((sum, p) => sum + p.value, 0); + const otherPercentage = otherPositions.reduce((sum, p) => sum + p.percentage, 0); + const otherCollateral = otherPositions.reduce((sum, p) => sum + p.collateral, 0); + + topPositions.push({ + name: `Other (${otherPositions.length})`, + address: 'other', + value: otherTotal, + percentage: otherPercentage, + collateral: otherCollateral, + ltv: 0, // Aggregated LTV doesn't make sense + isOther: true, + otherItems: otherPositions, + }); + } + + return topPositions; + }, [borrowers, market, oraclePrice, getDisplayName]); + + const handlePieClick = (data: PieDataItem) => { + if (data.isOther) { + setExpandedOther(!expandedOther); + } + }; + + // Extract the "Other" entry once for use in expanded section + const otherEntry = useMemo(() => pieData.find((d) => d.isOther), [pieData]); + + // Format percentage display (matches table) + const formatPercentDisplay = (percent: number): string => { + if (percent < 0.01 && percent > 0) return '<0.01%'; + return `${percent.toFixed(2)}%`; + }; + + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: { payload: PieDataItem }[] }) => { + if (!active || !payload || !payload[0]) return null; + const data = payload[0].payload; + + return ( +
+

{data.name}

+ {!data.isOther &&

{getSlicedAddress(data.address as `0x${string}`)}

} +
+
+ Borrowed +
+ {formatSimple(data.value)} + +
+
+
+ Collateral +
+ {formatSimple(data.collateral)} + +
+
+ {!data.isOther && ( +
+ LTV + {data.ltv.toFixed(2)}% +
+ )} +
+ % of Borrow + {formatPercentDisplay(data.percentage)} +
+
+ {data.isOther &&

Click to {expandedOther ? 'collapse' : 'expand'}

} +
+ ); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (pieData.length === 0) { + return ( + +

No borrowers found

+
+ ); + } + + return ( + +
+
+

Borrow Distribution

+ {totalCount} borrowers +
+
+ +
+ + + handlePieClick(pieData[index])} + style={{ cursor: 'pointer' }} + > + {pieData.map((entry, index) => ( + + ))} + + } /> + ( + + {value} + + )} + /> + + +
+ + {/* Expanded "Other" section */} + {expandedOther && otherEntry?.otherItems && ( +
+
Other Borrowers
+
+
+ {otherEntry.otherItems.slice(0, 20).map((item) => ( +
+ {item.name} + + {formatSimple(item.value)} ({formatPercentDisplay(item.percentage)}) + +
+ ))} + {otherEntry.otherItems.length > 20 && ( +

And {otherEntry.otherItems.length - 20} more...

+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx new file mode 100644 index 00000000..80b3d55f --- /dev/null +++ b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useMemo } from 'react'; +import { formatUnits } from 'viem'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { RISK_COLORS } from '@/constants/chartColors'; +import { useAllMarketBorrowers } from '@/hooks/useAllMarketPositions'; +import { formatReadable } from '@/utils/balance'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market, MarketBorrower } from '@/utils/types'; +import { ChartGradients, chartTooltipCursor } from './chart-utils'; + +type CollateralAtRiskChartProps = { + chainId: SupportedNetworks; + market: Market; + oraclePrice: bigint; +}; + +type RiskBucket = { + priceDropPercent: number; + cumulativeDebt: number; + debtInBucket: number; +}; + +// Gradient config for the risk chart +const RISK_GRADIENTS = [{ id: 'riskGradient', color: RISK_COLORS.stroke }]; + +// Price drop buckets from 0% to -100% +const BUCKET_INTERVALS = [0, -5, -10, -15, -20, -25, -30, -35, -40, -45, -50, -55, -60, -65, -70, -75, -80, -85, -90, -95, -100]; + +/** + * Calculate the price drop percentage at which a borrower would be liquidated. + * + * Liquidation happens when LTV >= LLTV. + * currentLTV = borrowAssets / (collateral * oraclePrice / 10^36) + * At liquidation: LLTV = borrowAssets / (collateral * liquidationPrice / 10^36) + * + * The price drop percentage is: (currentLTV / LLTV - 1) * 100 + * If currentLTV > LLTV, the position is already liquidatable (return 0). + */ +function calculateLiquidationPriceDropPercent( + borrowAssets: bigint, + collateral: bigint, + oraclePrice: bigint, + lltv: bigint, +): number { + if (collateral === 0n || oraclePrice === 0n) return 0; // Already liquidatable + + // Calculate collateral value in loan asset terms + const collateralValueScaled = collateral * oraclePrice; // Still has 10^36 scale + if (collateralValueScaled === 0n) return 0; + + // Current LTV = borrowAssets / collateralValue + // To avoid precision issues, we calculate: currentLTV / LLTV = (borrowAssets * 10^18) / (collateralValue * lltv / 10^36) + // Simplify: (borrowAssets * 10^18 * 10^36) / (collateral * oraclePrice * lltv) + + const numerator = borrowAssets * BigInt(10 ** 18) * BigInt(10 ** 36); + const denominator = collateralValueScaled * lltv; + + if (denominator === 0n) return 0; + + // ratio = currentLTV / LLTV + const ratioScaled = (numerator * BigInt(10000)) / denominator; // Scaled by 10000 for precision + const ratio = Number(ratioScaled) / 10000; + + // Price drop % = (ratio - 1) * 100 + // If ratio >= 1, position is at or above LLTV (already risky) + const priceDropPercent = (ratio - 1) * 100; + + // Clamp to [-100, 0] range + // Negative values mean the price needs to drop that much to liquidate + // Values > 0 mean already liquidatable + return Math.max(Math.min(priceDropPercent, 0), -100); +} + +/** + * Aggregate borrowers into risk buckets showing cumulative debt at each price drop level. + */ +function calculateRiskBuckets( + borrowers: MarketBorrower[], + oraclePrice: bigint, + lltv: bigint, + loanDecimals: number, +): RiskBucket[] { + // Calculate liquidation price drop for each borrower + const borrowersWithRisk = borrowers.map((borrower) => { + const borrowAssets = BigInt(borrower.borrowAssets); + const collateral = BigInt(borrower.collateral); + + const priceDropPercent = calculateLiquidationPriceDropPercent(borrowAssets, collateral, oraclePrice, lltv); + + const debtValue = Number(formatUnits(borrowAssets, loanDecimals)); + + return { + priceDropPercent, + debtValue, + }; + }); + + // Sort by price drop (least negative first = closest to liquidation) + borrowersWithRisk.sort((a, b) => b.priceDropPercent - a.priceDropPercent); + + // Create buckets + const buckets: RiskBucket[] = []; + let cumulativeDebt = 0; + + for (const bucket of BUCKET_INTERVALS) { + // Sum all debt that would be liquidated at this price level or higher + const debtAtThisLevel = borrowersWithRisk + .filter((b) => b.priceDropPercent >= bucket) + .reduce((sum, b) => sum + b.debtValue, 0); + + buckets.push({ + priceDropPercent: bucket, + cumulativeDebt: debtAtThisLevel, + debtInBucket: debtAtThisLevel - cumulativeDebt, + }); + + cumulativeDebt = debtAtThisLevel; + } + + return buckets; +} + +export function CollateralAtRiskChart({ chainId, market, oraclePrice }: CollateralAtRiskChartProps) { + const { data: borrowers, isLoading } = useAllMarketBorrowers(market.uniqueKey, chainId); + + const riskBuckets = useMemo(() => { + if (!borrowers || borrowers.length === 0 || !oraclePrice || oraclePrice === 0n) return []; + + const lltv = BigInt(market.lltv); + if (lltv === 0n) return []; + + return calculateRiskBuckets(borrowers, oraclePrice, lltv, market.loanAsset.decimals); + }, [borrowers, oraclePrice, market]); + + // Calculate key risk metrics + const riskMetrics = useMemo(() => { + if (riskBuckets.length === 0) return null; + + const totalDebt = riskBuckets.at(-1)?.cumulativeDebt ?? 0; + const debtAt10 = riskBuckets.find((b) => b.priceDropPercent === -10)?.cumulativeDebt ?? 0; + const debtAt25 = riskBuckets.find((b) => b.priceDropPercent === -25)?.cumulativeDebt ?? 0; + const debtAt50 = riskBuckets.find((b) => b.priceDropPercent === -50)?.cumulativeDebt ?? 0; + + return { + totalDebt, + debtAt10, + debtAt25, + debtAt50, + percentAt10: totalDebt > 0 ? (debtAt10 / totalDebt) * 100 : 0, + percentAt25: totalDebt > 0 ? (debtAt25 / totalDebt) * 100 : 0, + percentAt50: totalDebt > 0 ? (debtAt50 / totalDebt) * 100 : 0, + }; + }, [riskBuckets]); + + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: number }) => { + if (!active || !payload || !payload[0]) return null; + + return ( +
+

At {label}% price drop

+
+
+ Cumulative Debt at Risk + + {formatReadable(payload[0].value)} {market.loanAsset.symbol} + +
+
+
+ ); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (riskBuckets.length === 0) { + return ( + +

No borrower data available

+
+ ); + } + + return ( + +
+
+

Collateral at Risk

+ {riskMetrics && ( +
+
+ @-10%: + 10 ? RISK_COLORS.stroke : 'inherit' }}> + {formatReadable(riskMetrics.debtAt10)} ({riskMetrics.percentAt10.toFixed(1)}%) + +
+
+ @-25%: + + {formatReadable(riskMetrics.debtAt25)} ({riskMetrics.percentAt25.toFixed(1)}%) + +
+
+ @-50%: + + {formatReadable(riskMetrics.debtAt50)} ({riskMetrics.percentAt50.toFixed(1)}%) + +
+
+ )} +
+
+ +
+ + + + + `${value}%`} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + ticks={[0, -25, -50, -75, -100]} + /> + formatReadable(value)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + width={60} + /> + } + /> + + + +
+ +
+

+ Shows cumulative debt that would become liquidatable at each collateral price drop level. Current LLTV:{' '} + {(Number(BigInt(market.lltv)) / 1e16).toFixed(0)}% +

+
+
+ ); +} diff --git a/src/features/market-detail/components/charts/suppliers-pie-chart.tsx b/src/features/market-detail/components/charts/suppliers-pie-chart.tsx new file mode 100644 index 00000000..45f56dbf --- /dev/null +++ b/src/features/market-detail/components/charts/suppliers-pie-chart.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import type { Address } from 'viem'; +import { formatUnits } from 'viem'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { PIE_COLORS } from '@/constants/chartColors'; +import { useVaultRegistry } from '@/contexts/VaultRegistryContext'; +import { useAllMarketSuppliers } from '@/hooks/useAllMarketPositions'; +import { formatSimple } from '@/utils/balance'; +import { getSlicedAddress } from '@/utils/address'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; + +type SuppliersPieChartProps = { + chainId: SupportedNetworks; + market: Market; +}; + +type PieDataItem = { + name: string; + value: number; + address: string; + percentage: number; + isOther?: boolean; + otherItems?: { name: string; value: number; address: string; percentage: number }[]; +}; + +const TOP_POSITIONS_COUNT = 10; +const MIN_PERCENTAGE_THRESHOLD = 1; // 1% + +export function SuppliersPieChart({ chainId, market }: SuppliersPieChartProps) { + const { data: suppliers, isLoading, totalCount } = useAllMarketSuppliers(market.uniqueKey, chainId); + const { getVaultByAddress } = useVaultRegistry(); + const [expandedOther, setExpandedOther] = useState(false); + + // Helper to get display name for an address (vault name or shortened address) + const getDisplayName = (address: string): string => { + const vault = getVaultByAddress(address as Address, chainId); + if (vault?.name) return vault.name; + return getSlicedAddress(address as `0x${string}`); + }; + + const pieData = useMemo(() => { + if (!suppliers || suppliers.length === 0) return []; + + const totalSupplyShares = BigInt(market.state.supplyShares); + const totalSupplyAssets = BigInt(market.state.supplyAssets); + + if (totalSupplyShares === 0n) return []; + + // Calculate assets for each supplier and sort by value + const suppliersWithAssets = suppliers + .map((supplier) => { + const shares = BigInt(supplier.supplyShares); + const assets = (shares * totalSupplyAssets) / totalSupplyShares; + const assetsNumber = Number(formatUnits(assets, market.loanAsset.decimals)); + // Use scaled bigint math for precision + const percentageScaled = (shares * 10000n) / totalSupplyShares; + const percentage = Number(percentageScaled) / 100; + + return { + name: getDisplayName(supplier.userAddress), + address: supplier.userAddress, + value: assetsNumber, + percentage, + }; + }) + .sort((a, b) => b.value - a.value); + + // Split into top positions and "Other" + const topPositions: PieDataItem[] = []; + const otherPositions: { name: string; value: number; address: string; percentage: number }[] = []; + + for (const position of suppliersWithAssets) { + if (topPositions.length < TOP_POSITIONS_COUNT && position.percentage >= MIN_PERCENTAGE_THRESHOLD) { + topPositions.push(position); + } else { + otherPositions.push(position); + } + } + + // Add "Other" category if there are remaining positions + if (otherPositions.length > 0) { + const otherTotal = otherPositions.reduce((sum, p) => sum + p.value, 0); + const otherPercentage = otherPositions.reduce((sum, p) => sum + p.percentage, 0); + + topPositions.push({ + name: `Other (${otherPositions.length})`, + address: 'other', + value: otherTotal, + percentage: otherPercentage, + isOther: true, + otherItems: otherPositions, + }); + } + + return topPositions; + }, [suppliers, market, getDisplayName]); + + const handlePieClick = (data: PieDataItem) => { + if (data.isOther) { + setExpandedOther(!expandedOther); + } + }; + + // Extract the "Other" entry once for use in expanded section + const otherEntry = useMemo(() => pieData.find((d) => d.isOther), [pieData]); + + // Format percentage display (matches table) + const formatPercentDisplay = (percent: number): string => { + if (percent < 0.01 && percent > 0) return '<0.01%'; + return `${percent.toFixed(2)}%`; + }; + + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: { payload: PieDataItem }[] }) => { + if (!active || !payload || !payload[0]) return null; + const data = payload[0].payload; + + return ( +
+

{data.name}

+ {!data.isOther &&

{getSlicedAddress(data.address as `0x${string}`)}

} +
+
+ Supplied +
+ {formatSimple(data.value)} + +
+
+
+ % of Supply + {formatPercentDisplay(data.percentage)} +
+
+ {data.isOther &&

Click to {expandedOther ? 'collapse' : 'expand'}

} +
+ ); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (pieData.length === 0) { + return ( + +

No suppliers found

+
+ ); + } + + return ( + +
+
+

Supply Distribution

+ {totalCount} suppliers +
+
+ +
+ + + handlePieClick(pieData[index])} + style={{ cursor: 'pointer' }} + > + {pieData.map((entry, index) => ( + + ))} + + } /> + ( + + {value} + + )} + /> + + +
+ + {/* Expanded "Other" section */} + {expandedOther && otherEntry?.otherItems && ( +
+
Other Suppliers
+
+
+ {otherEntry.otherItems.slice(0, 20).map((item) => ( +
+ {item.name} + + {formatSimple(item.value)} ({formatPercentDisplay(item.percentage)}) + +
+ ))} + {otherEntry.otherItems.length > 20 && ( +

And {otherEntry.otherItems.length - 20} more...

+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index 31ff4d15..f0eee1b8 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -28,6 +28,9 @@ import { useMarketWarnings } from '@/hooks/useMarketWarnings'; import { MarketHeader } from './components/market-header'; import RateChart from './components/charts/rate-chart'; import VolumeChart from './components/charts/volume-chart'; +import { SuppliersPieChart } from './components/charts/suppliers-pie-chart'; +import { BorrowersPieChart } from './components/charts/borrowers-pie-chart'; +import { CollateralAtRiskChart } from './components/charts/collateral-at-risk-chart'; function MarketContent() { // 1. Get URL params first @@ -262,7 +265,8 @@ function MarketContent() { Statistics Activities - Positions + Supply Details + Borrow Details @@ -304,13 +308,34 @@ function MarketContent() { - - setShowSupplierFiltersModal(true)} - /> + +
+ + setShowSupplierFiltersModal(true)} + /> +
+
+ + +
+ + +
{ + const { data, isLoading, error } = useQuery({ + queryKey: ['allMarketBorrowers', marketId, network], + queryFn: async () => { + if (!marketId || !network) return null; + + // Try Morpho API first + if (supportsMorphoApi(network)) { + try { + const result = await fetchMorphoMarketBorrowers(marketId, Number(network), '0', TOP_POSITIONS_LIMIT, 0); + return result; + } catch (morphoError) { + console.error('Failed to fetch all borrowers via Morpho API:', morphoError); + } + } + + // Fallback to Subgraph + const result = await fetchSubgraphMarketBorrowers(marketId, network, '0', TOP_POSITIONS_LIMIT, 0); + return result; + }, + enabled: !!marketId && !!network, + staleTime: 1000 * 60 * 2, // 2 minutes + }); + + return { + data: data?.items ?? null, + totalCount: data?.totalCount ?? 0, + isLoading, + error: error as Error | null, + }; +}; + +/** + * Fetches top suppliers for chart aggregation (non-paginated). + * Retrieves up to 100 positions sorted by supply shares descending. + */ +export const useAllMarketSuppliers = ( + marketId: string | undefined, + network: SupportedNetworks | undefined, +): UseAllSuppliersResult => { + const { data, isLoading, error } = useQuery({ + queryKey: ['allMarketSuppliers', marketId, network], + queryFn: async () => { + if (!marketId || !network) return null; + + // Try Morpho API first + if (supportsMorphoApi(network)) { + try { + const result = await fetchMorphoMarketSuppliers(marketId, Number(network), '0', TOP_POSITIONS_LIMIT, 0); + return result; + } catch (morphoError) { + console.error('Failed to fetch all suppliers via Morpho API:', morphoError); + } + } + + // Fallback to Subgraph + const result = await fetchSubgraphMarketSuppliers(marketId, network, '0', TOP_POSITIONS_LIMIT, 0); + return result; + }, + enabled: !!marketId && !!network, + staleTime: 1000 * 60 * 2, // 2 minutes + }); + + return { + data: data?.items ?? null, + totalCount: data?.totalCount ?? 0, + isLoading, + error: error as Error | null, + }; +}; From 7ba5d5ea344f468daf284b20a1981c2cb614022d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 15 Jan 2026 16:15:21 +0800 Subject: [PATCH 2/7] feat: pie chart and concentration chart --- .../components/charts/borrowers-pie-chart.tsx | 40 ++- .../charts/collateral-at-risk-chart.tsx | 248 +++++++--------- .../components/charts/concentration-chart.tsx | 278 ++++++++++++++++++ .../components/charts/suppliers-pie-chart.tsx | 37 ++- .../filters/concentration-filters-modal.tsx | 76 +++++ src/features/market-detail/market-view.tsx | 102 +++++-- src/hooks/useAllMarketPositions.ts | 24 +- src/hooks/useMarketBorrowers.ts | 4 +- src/hooks/useMarketSuppliers.ts | 4 +- 9 files changed, 598 insertions(+), 215 deletions(-) create mode 100644 src/features/market-detail/components/charts/concentration-chart.tsx create mode 100644 src/features/market-detail/components/filters/concentration-filters-modal.tsx diff --git a/src/features/market-detail/components/charts/borrowers-pie-chart.tsx b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx index 97edbc13..6d3f174f 100644 --- a/src/features/market-detail/components/charts/borrowers-pie-chart.tsx +++ b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx @@ -32,8 +32,8 @@ type PieDataItem = { otherItems?: { name: string; value: number; address: string; percentage: number; collateral: number; ltv: number }[]; }; -const TOP_POSITIONS_COUNT = 10; -const MIN_PERCENTAGE_THRESHOLD = 1; // 1% +const TOP_POSITIONS_TO_SHOW = 8; +const OTHER_COLOR = '#64748B'; // Grey for "Other" category export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPieChartProps) { const { data: borrowers, isLoading, totalCount } = useAllMarketBorrowers(market.uniqueKey, chainId); @@ -87,7 +87,7 @@ export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPie }) .sort((a, b) => b.value - a.value); - // Split into top positions and "Other" + // Split into top positions and "Other" - always show top 8 regardless of percentage const topPositions: PieDataItem[] = []; const otherPositions: { name: string; @@ -98,29 +98,37 @@ export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPie ltv: number; }[] = []; - for (const position of borrowersWithData) { - if (topPositions.length < TOP_POSITIONS_COUNT && position.percentage >= MIN_PERCENTAGE_THRESHOLD) { - topPositions.push(position); + for (let i = 0; i < borrowersWithData.length; i++) { + if (i < TOP_POSITIONS_TO_SHOW) { + topPositions.push(borrowersWithData[i]); } else { - otherPositions.push(position); + otherPositions.push(borrowersWithData[i]); } } - // Add "Other" category if there are remaining positions - if (otherPositions.length > 0) { - const otherTotal = otherPositions.reduce((sum, p) => sum + p.value, 0); - const otherPercentage = otherPositions.reduce((sum, p) => sum + p.percentage, 0); + // Calculate "Other" as everything NOT in topPositions (including positions beyond top 100) + // This correctly accounts for all positions, not just the ones we fetched + const top8TotalPercentage = topPositions.reduce((sum, p) => sum + p.percentage, 0); + const otherPercentage = 100 - top8TotalPercentage; + + // For absolute value, use market total minus top 8 + const totalBorrowValue = Number(formatUnits(totalBorrowAssets, market.loanAsset.decimals)); + const top8TotalValue = topPositions.reduce((sum, p) => sum + p.value, 0); + const otherValue = totalBorrowValue - top8TotalValue; + + // Only add "Other" if there's meaningful remainder + if (otherPercentage > 0.01) { const otherCollateral = otherPositions.reduce((sum, p) => sum + p.collateral, 0); topPositions.push({ - name: `Other (${otherPositions.length})`, + name: 'Other', address: 'other', - value: otherTotal, + value: otherValue, percentage: otherPercentage, collateral: otherCollateral, - ltv: 0, // Aggregated LTV doesn't make sense + ltv: 0, isOther: true, - otherItems: otherPositions, + otherItems: otherPositions, // Only contains positions 9-100, but percentage/value are correct }); } @@ -238,7 +246,7 @@ export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPie {pieData.map((entry, index) => ( diff --git a/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx index 80b3d55f..3fd16791 100644 --- a/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx +++ b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx @@ -9,7 +9,7 @@ import { RISK_COLORS } from '@/constants/chartColors'; import { useAllMarketBorrowers } from '@/hooks/useAllMarketPositions'; import { formatReadable } from '@/utils/balance'; import type { SupportedNetworks } from '@/utils/networks'; -import type { Market, MarketBorrower } from '@/utils/types'; +import type { Market } from '@/utils/types'; import { ChartGradients, chartTooltipCursor } from './chart-utils'; type CollateralAtRiskChartProps = { @@ -18,157 +18,112 @@ type CollateralAtRiskChartProps = { oraclePrice: bigint; }; -type RiskBucket = { - priceDropPercent: number; - cumulativeDebt: number; - debtInBucket: number; +type RiskDataPoint = { + priceDrop: number; // 0 to -100 + cumulativeDebt: number; // Absolute debt amount }; // Gradient config for the risk chart const RISK_GRADIENTS = [{ id: 'riskGradient', color: RISK_COLORS.stroke }]; -// Price drop buckets from 0% to -100% -const BUCKET_INTERVALS = [0, -5, -10, -15, -20, -25, -30, -35, -40, -45, -50, -55, -60, -65, -70, -75, -80, -85, -90, -95, -100]; - -/** - * Calculate the price drop percentage at which a borrower would be liquidated. - * - * Liquidation happens when LTV >= LLTV. - * currentLTV = borrowAssets / (collateral * oraclePrice / 10^36) - * At liquidation: LLTV = borrowAssets / (collateral * liquidationPrice / 10^36) - * - * The price drop percentage is: (currentLTV / LLTV - 1) * 100 - * If currentLTV > LLTV, the position is already liquidatable (return 0). - */ -function calculateLiquidationPriceDropPercent( - borrowAssets: bigint, - collateral: bigint, - oraclePrice: bigint, - lltv: bigint, -): number { - if (collateral === 0n || oraclePrice === 0n) return 0; // Already liquidatable - - // Calculate collateral value in loan asset terms - const collateralValueScaled = collateral * oraclePrice; // Still has 10^36 scale - if (collateralValueScaled === 0n) return 0; - - // Current LTV = borrowAssets / collateralValue - // To avoid precision issues, we calculate: currentLTV / LLTV = (borrowAssets * 10^18) / (collateralValue * lltv / 10^36) - // Simplify: (borrowAssets * 10^18 * 10^36) / (collateral * oraclePrice * lltv) - - const numerator = borrowAssets * BigInt(10 ** 18) * BigInt(10 ** 36); - const denominator = collateralValueScaled * lltv; - - if (denominator === 0n) return 0; - - // ratio = currentLTV / LLTV - const ratioScaled = (numerator * BigInt(10000)) / denominator; // Scaled by 10000 for precision - const ratio = Number(ratioScaled) / 10000; - - // Price drop % = (ratio - 1) * 100 - // If ratio >= 1, position is at or above LLTV (already risky) - const priceDropPercent = (ratio - 1) * 100; - - // Clamp to [-100, 0] range - // Negative values mean the price needs to drop that much to liquidate - // Values > 0 mean already liquidatable - return Math.max(Math.min(priceDropPercent, 0), -100); -} - -/** - * Aggregate borrowers into risk buckets showing cumulative debt at each price drop level. - */ -function calculateRiskBuckets( - borrowers: MarketBorrower[], - oraclePrice: bigint, - lltv: bigint, - loanDecimals: number, -): RiskBucket[] { - // Calculate liquidation price drop for each borrower - const borrowersWithRisk = borrowers.map((borrower) => { - const borrowAssets = BigInt(borrower.borrowAssets); - const collateral = BigInt(borrower.collateral); - - const priceDropPercent = calculateLiquidationPriceDropPercent(borrowAssets, collateral, oraclePrice, lltv); - - const debtValue = Number(formatUnits(borrowAssets, loanDecimals)); - - return { - priceDropPercent, - debtValue, - }; - }); - - // Sort by price drop (least negative first = closest to liquidation) - borrowersWithRisk.sort((a, b) => b.priceDropPercent - a.priceDropPercent); - - // Create buckets - const buckets: RiskBucket[] = []; - let cumulativeDebt = 0; - - for (const bucket of BUCKET_INTERVALS) { - // Sum all debt that would be liquidated at this price level or higher - const debtAtThisLevel = borrowersWithRisk - .filter((b) => b.priceDropPercent >= bucket) - .reduce((sum, b) => sum + b.debtValue, 0); - - buckets.push({ - priceDropPercent: bucket, - cumulativeDebt: debtAtThisLevel, - debtInBucket: debtAtThisLevel - cumulativeDebt, - }); - - cumulativeDebt = debtAtThisLevel; - } - - return buckets; -} - export function CollateralAtRiskChart({ chainId, market, oraclePrice }: CollateralAtRiskChartProps) { const { data: borrowers, isLoading } = useAllMarketBorrowers(market.uniqueKey, chainId); - const riskBuckets = useMemo(() => { - if (!borrowers || borrowers.length === 0 || !oraclePrice || oraclePrice === 0n) return []; - - const lltv = BigInt(market.lltv); - if (lltv === 0n) return []; - - return calculateRiskBuckets(borrowers, oraclePrice, lltv, market.loanAsset.decimals); - }, [borrowers, oraclePrice, market]); - - // Calculate key risk metrics - const riskMetrics = useMemo(() => { - if (riskBuckets.length === 0) return null; - - const totalDebt = riskBuckets.at(-1)?.cumulativeDebt ?? 0; - const debtAt10 = riskBuckets.find((b) => b.priceDropPercent === -10)?.cumulativeDebt ?? 0; - const debtAt25 = riskBuckets.find((b) => b.priceDropPercent === -25)?.cumulativeDebt ?? 0; - const debtAt50 = riskBuckets.find((b) => b.priceDropPercent === -50)?.cumulativeDebt ?? 0; + const lltv = useMemo(() => { + const lltvBigInt = BigInt(market.lltv); + return Number(lltvBigInt) / 1e18; // LLTV as decimal (e.g., 0.8 for 80%) + }, [market.lltv]); + + const { chartData, totalDebt, riskMetrics } = useMemo(() => { + if (!borrowers || borrowers.length === 0 || !oraclePrice || oraclePrice === 0n || lltv === 0) { + return { chartData: [], totalDebt: 0, riskMetrics: null }; + } + + // Calculate price drop threshold for each borrower + const borrowersWithRisk = borrowers + .map((borrower) => { + const borrowAssets = BigInt(borrower.borrowAssets); + const collateral = BigInt(borrower.collateral); + + if (collateral === 0n || borrowAssets === 0n) return null; + + // Calculate collateral value in loan asset terms + const collateralValueInLoan = (collateral * oraclePrice) / BigInt(10 ** 36); + if (collateralValueInLoan === 0n) return null; + + // Calculate current LTV as decimal + const currentLTV = Number(borrowAssets) / Number(collateralValueInLoan); + const debtValue = Number(formatUnits(borrowAssets, market.loanAsset.decimals)); + + // Calculate price drop needed for liquidation + // Formula: priceDropPercent = 100 * (1 - currentLTV / LLTV) + // Negative value means price needs to DROP by that percentage + const priceDropForLiquidation = -100 * (1 - currentLTV / lltv); + + // If currentLTV >= LLTV, priceDropForLiquidation >= 0 (already at/past threshold) + // Clamp to reasonable range + const clampedPriceDrop = Math.max(-100, Math.min(0, priceDropForLiquidation)); + + return { priceDrop: clampedPriceDrop, debt: debtValue }; + }) + .filter((b): b is { priceDrop: number; debt: number } => b !== null) + .sort((a, b) => b.priceDrop - a.priceDrop); // Sort by price drop descending (closest to 0 first = most at risk) + + if (borrowersWithRisk.length === 0) { + return { chartData: [], totalDebt: 0, riskMetrics: null }; + } + + // Calculate total debt + const total = borrowersWithRisk.reduce((sum, b) => sum + b.debt, 0); + + // Build cumulative data for each 1% price drop (continuous scale) + // "At X% price drop, how much cumulative debt is at risk?" + const dataPoints: RiskDataPoint[] = []; + for (let drop = 0; drop >= -100; drop -= 1) { + // Sum debt for all borrowers who would be liquidated at this price drop or less severe + const cumulativeDebt = borrowersWithRisk + .filter((b) => b.priceDrop >= drop) // Borrowers liquidated at this drop or earlier (less negative) + .reduce((sum, b) => sum + b.debt, 0); + + dataPoints.push({ priceDrop: drop, cumulativeDebt }); + } + + // Calculate risk metrics for header + const debtAt10 = dataPoints.find((d) => d.priceDrop === -10)?.cumulativeDebt ?? 0; + const debtAt25 = dataPoints.find((d) => d.priceDrop === -25)?.cumulativeDebt ?? 0; + const debtAt50 = dataPoints.find((d) => d.priceDrop === -50)?.cumulativeDebt ?? 0; return { - totalDebt, - debtAt10, - debtAt25, - debtAt50, - percentAt10: totalDebt > 0 ? (debtAt10 / totalDebt) * 100 : 0, - percentAt25: totalDebt > 0 ? (debtAt25 / totalDebt) * 100 : 0, - percentAt50: totalDebt > 0 ? (debtAt50 / totalDebt) * 100 : 0, + chartData: dataPoints, + totalDebt: total, + riskMetrics: { + debtAt10, + debtAt25, + debtAt50, + }, }; - }, [riskBuckets]); + }, [borrowers, oraclePrice, market.loanAsset.decimals, lltv]); - const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: number }) => { + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: { value: number; payload: RiskDataPoint }[] }) => { if (!active || !payload || !payload[0]) return null; + const data = payload[0].payload; return (
-

At {label}% price drop

+

At {data.priceDrop}% price drop

- Cumulative Debt at Risk - - {formatReadable(payload[0].value)} {market.loanAsset.symbol} + Debt at Risk + + {formatReadable(data.cumulativeDebt)} {market.loanAsset.symbol}
+ {totalDebt > 0 && ( +
+ % of Total + {((data.cumulativeDebt / totalDebt) * 100).toFixed(1)}% +
+ )}
); @@ -182,7 +137,7 @@ export function CollateralAtRiskChart({ chainId, market, oraclePrice }: Collater ); } - if (riskBuckets.length === 0) { + if (chartData.length === 0) { return (

No borrower data available

@@ -199,21 +154,20 @@ export function CollateralAtRiskChart({ chainId, market, oraclePrice }: Collater
@-10%: - 10 ? RISK_COLORS.stroke : 'inherit' }}> - {formatReadable(riskMetrics.debtAt10)} ({riskMetrics.percentAt10.toFixed(1)}%) + 0 ? RISK_COLORS.stroke : 'inherit' }} + > + {formatReadable(riskMetrics.debtAt10)}
@-25%: - - {formatReadable(riskMetrics.debtAt25)} ({riskMetrics.percentAt25.toFixed(1)}%) - + {formatReadable(riskMetrics.debtAt25)}
@-50%: - - {formatReadable(riskMetrics.debtAt50)} ({riskMetrics.percentAt50.toFixed(1)}%) - + {formatReadable(riskMetrics.debtAt50)}
)} @@ -226,7 +180,7 @@ export function CollateralAtRiskChart({ chainId, market, oraclePrice }: Collater height={280} > `${value}%`} tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} - ticks={[0, -25, -50, -75, -100]} + domain={[0, -100]} + reversed={false} /> formatReadable(value)} tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} width={60} + domain={[0, 'dataMax']} />

- Shows cumulative debt that would become liquidatable at each collateral price drop level. Current LLTV:{' '} - {(Number(BigInt(market.lltv)) / 1e16).toFixed(0)}% + Shows cumulative debt at risk if collateral price drops. Total debt: {formatReadable(totalDebt)} {market.loanAsset.symbol}. LLTV:{' '} + {(lltv * 100).toFixed(0)}%

diff --git a/src/features/market-detail/components/charts/concentration-chart.tsx b/src/features/market-detail/components/charts/concentration-chart.tsx new file mode 100644 index 00000000..28fb82b1 --- /dev/null +++ b/src/features/market-detail/components/charts/concentration-chart.tsx @@ -0,0 +1,278 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { GoFilter } from 'react-icons/go'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { ChartGradients, chartTooltipCursor } from './chart-utils'; +import ConcentrationFiltersModal from '../filters/concentration-filters-modal'; + +type ConcentrationDataPoint = { + position: number; + cumulativePercent: number; + idealPercent: number; +}; + +type ConcentrationChartProps = { + positions: { percentage: number }[] | null; + totalCount: number; + isLoading: boolean; + title: string; + color: string; +}; + +const DEFAULT_MIN_PERCENT = 0.01; + +export function ConcentrationChart({ positions, totalCount, isLoading, title, color }: ConcentrationChartProps) { + const [minPercentInput, setMinPercentInput] = useState(DEFAULT_MIN_PERCENT.toString()); + const [showFiltersModal, setShowFiltersModal] = useState(false); + + const minPercent = useMemo(() => { + const parsed = Number.parseFloat(minPercentInput); + // Always filter out 0% positions even if user enters 0 + return Number.isNaN(parsed) || parsed <= 0 ? 0.000001 : parsed; + }, [minPercentInput]); + + const { chartData, meaningfulCount, totalPercentShown } = useMemo(() => { + const emptyResult = { chartData: [], meaningfulCount: 0, totalPercentShown: 0 }; + + if (!positions || positions.length === 0 || totalCount === 0) { + return emptyResult; + } + + const sorted = [...positions].sort((a, b) => b.percentage - a.percentage); + const meaningful = sorted.filter((p) => p.percentage > 0 && p.percentage >= minPercent); + + if (meaningful.length === 0) { + return emptyResult; + } + + let runningSum = 0; + const dataPoints: ConcentrationDataPoint[] = meaningful.map((pos, index) => { + runningSum += pos.percentage; + return { + position: index + 1, + cumulativePercent: runningSum, + idealPercent: 0, // Calculated below once we know totalPercentShown + }; + }); + + const totalPct = runningSum; + for (const point of dataPoints) { + point.idealPercent = (point.position / meaningful.length) * totalPct; + } + + dataPoints.unshift({ position: 0, cumulativePercent: 0, idealPercent: 0 }); + + return { + chartData: dataPoints, + meaningfulCount: meaningful.length, + totalPercentShown: totalPct, + }; + }, [positions, totalCount, minPercent]); + + const metrics = useMemo(() => { + if (chartData.length === 0) return null; + + const findPercent = (pos: number) => chartData.find((d) => d.position === pos)?.cumulativePercent ?? null; + + return { + top1: findPercent(1), + top10: findPercent(10), + top100: findPercent(100), + }; + }, [chartData]); + + function CustomTooltip({ active, payload }: { active?: boolean; payload?: { payload: ConcentrationDataPoint }[] }) { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + + if (data.position === 0) return null; + + return ( +
+

Top {data.position.toLocaleString()}

+
+
+ Actual + {data.cumulativePercent.toFixed(1)}% +
+
+ If equal + {data.idealPercent.toFixed(1)}% +
+
+
+ ); + } + + if (isLoading) { + return ( + + + + ); + } + + if (!positions || positions.length === 0) { + return ( + +

No data available

+
+ ); + } + + const yAxisMax = Math.ceil(totalPercentShown / 10) * 10 || 100; + + if (chartData.length === 0) { + return ( + +
+
+

{title}

+ +
+
+
+

All positions below {minPercentInput}% threshold

+
+ +
+ ); + } + + return ( + +
+
+
+

{title}

+ +
+ {metrics && ( +
+ {metrics.top1 !== null && ( +
+ Top 1: + {metrics.top1.toFixed(1)}% +
+ )} + {metrics.top10 !== null && ( +
+ Top 10: + {metrics.top10.toFixed(1)}% +
+ )} + {metrics.top100 !== null && ( +
+ Top 100: + {metrics.top100.toFixed(1)}% +
+ )} +
+ )} +
+
+ +
+ + + + + value.toLocaleString()} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + `${value}%`} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + width={45} + domain={[0, yAxisMax]} + /> + } + /> + + + + +
+ +
+

+ {meaningfulCount < totalCount + ? `${meaningfulCount.toLocaleString()} of ${totalCount.toLocaleString()} positions above ${minPercentInput}% (${totalPercentShown.toFixed(1)}% of total)` + : `${totalCount.toLocaleString()} total positions`} +

+
+ + +
+ ); +} diff --git a/src/features/market-detail/components/charts/suppliers-pie-chart.tsx b/src/features/market-detail/components/charts/suppliers-pie-chart.tsx index 45f56dbf..8dc3e65c 100644 --- a/src/features/market-detail/components/charts/suppliers-pie-chart.tsx +++ b/src/features/market-detail/components/charts/suppliers-pie-chart.tsx @@ -29,8 +29,8 @@ type PieDataItem = { otherItems?: { name: string; value: number; address: string; percentage: number }[]; }; -const TOP_POSITIONS_COUNT = 10; -const MIN_PERCENTAGE_THRESHOLD = 1; // 1% +const TOP_POSITIONS_TO_SHOW = 8; +const OTHER_COLOR = '#64748B'; // Grey for "Other" category export function SuppliersPieChart({ chainId, market }: SuppliersPieChartProps) { const { data: suppliers, isLoading, totalCount } = useAllMarketSuppliers(market.uniqueKey, chainId); @@ -71,30 +71,37 @@ export function SuppliersPieChart({ chainId, market }: SuppliersPieChartProps) { }) .sort((a, b) => b.value - a.value); - // Split into top positions and "Other" + // Split into top positions and "Other" - always show top 8 regardless of percentage const topPositions: PieDataItem[] = []; const otherPositions: { name: string; value: number; address: string; percentage: number }[] = []; - for (const position of suppliersWithAssets) { - if (topPositions.length < TOP_POSITIONS_COUNT && position.percentage >= MIN_PERCENTAGE_THRESHOLD) { - topPositions.push(position); + for (let i = 0; i < suppliersWithAssets.length; i++) { + if (i < TOP_POSITIONS_TO_SHOW) { + topPositions.push(suppliersWithAssets[i]); } else { - otherPositions.push(position); + otherPositions.push(suppliersWithAssets[i]); } } - // Add "Other" category if there are remaining positions - if (otherPositions.length > 0) { - const otherTotal = otherPositions.reduce((sum, p) => sum + p.value, 0); - const otherPercentage = otherPositions.reduce((sum, p) => sum + p.percentage, 0); + // Calculate "Other" as everything NOT in topPositions (including positions beyond top 100) + // This correctly accounts for all positions, not just the ones we fetched + const top8TotalPercentage = topPositions.reduce((sum, p) => sum + p.percentage, 0); + const otherPercentage = 100 - top8TotalPercentage; + // For absolute value, use market total minus top 8 + const totalSupplyValue = Number(formatUnits(totalSupplyAssets, market.loanAsset.decimals)); + const top8TotalValue = topPositions.reduce((sum, p) => sum + p.value, 0); + const otherValue = totalSupplyValue - top8TotalValue; + + // Only add "Other" if there's meaningful remainder + if (otherPercentage > 0.01) { topPositions.push({ - name: `Other (${otherPositions.length})`, + name: 'Other', address: 'other', - value: otherTotal, + value: otherValue, percentage: otherPercentage, isOther: true, - otherItems: otherPositions, + otherItems: otherPositions, // Only contains positions 9-100, but percentage/value are correct }); } @@ -193,7 +200,7 @@ export function SuppliersPieChart({ chainId, market }: SuppliersPieChartProps) { {pieData.map((entry, index) => ( diff --git a/src/features/market-detail/components/filters/concentration-filters-modal.tsx b/src/features/market-detail/components/filters/concentration-filters-modal.tsx new file mode 100644 index 00000000..f094f323 --- /dev/null +++ b/src/features/market-detail/components/filters/concentration-filters-modal.tsx @@ -0,0 +1,76 @@ +import { Input } from '@/components/ui/input'; +import { GoFilter } from 'react-icons/go'; +import { Button } from '@/components/ui/button'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; +import { SettingItem, createNumericInputHandler } from './shared-filter-utils'; + +type ConcentrationFiltersModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + minPercent: string; + onMinPercentChange: (value: string) => void; + title: string; +}; + +export default function ConcentrationFiltersModal({ + isOpen, + onOpenChange, + minPercent, + onMinPercentChange, + title, +}: ConcentrationFiltersModalProps) { + const handlePercentChange = createNumericInputHandler(onMinPercentChange); + + return ( + + {(onClose) => ( + <> + } + onClose={onClose} + /> + +
+

Dust Threshold

+

+ Filter out small positions below the specified percentage of total market. +

+ + + +
+
+ + + + + )} +
+ ); +} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index f0eee1b8..e55a14fd 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -25,12 +25,15 @@ import { SuppliersTable } from '@/features/market-detail/components/suppliers-ta import SupplierFiltersModal from '@/features/market-detail/components/filters/supplier-filters-modal'; import TransactionFiltersModal from '@/features/market-detail/components/filters/transaction-filters-modal'; import { useMarketWarnings } from '@/hooks/useMarketWarnings'; +import { useAllMarketBorrowers, useAllMarketSuppliers } from '@/hooks/useAllMarketPositions'; import { MarketHeader } from './components/market-header'; import RateChart from './components/charts/rate-chart'; import VolumeChart from './components/charts/volume-chart'; import { SuppliersPieChart } from './components/charts/suppliers-pie-chart'; import { BorrowersPieChart } from './components/charts/borrowers-pie-chart'; import { CollateralAtRiskChart } from './components/charts/collateral-at-risk-chart'; +import { ConcentrationChart } from './components/charts/concentration-chart'; +import { CHART_COLORS } from '@/constants/chartColors'; function MarketContent() { // 1. Get URL params first @@ -45,9 +48,9 @@ function MarketContent() { const [isRefreshing, setIsRefreshing] = useState(false); const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false); const [showSupplierFiltersModal, setShowSupplierFiltersModal] = useState(false); - const [minSupplierShares, setMinSupplierShares] = useState('0'); + const [minSupplierShares, setMinSupplierShares] = useState('1'); const [showBorrowerFiltersModal, setShowBorrowerFiltersModal] = useState(false); - const [minBorrowerShares, setMinBorrowerShares] = useState('0'); + const [minBorrowerShares, setMinBorrowerShares] = useState('1'); // 4. Data fetching hooks - use unified time range const { @@ -75,6 +78,18 @@ function MarketContent() { // Get all warnings for this market (hook handles undefined market) const allWarnings = useMarketWarnings(market); + // Fetch position data for concentration charts + const { + data: borrowersData, + isLoading: borrowersLoading, + totalCount: borrowersTotalCount, + } = useAllMarketBorrowers(market?.uniqueKey, network); + const { + data: suppliersData, + isLoading: suppliersLoading, + totalCount: suppliersTotalCount, + } = useAllMarketSuppliers(market?.uniqueKey, network); + // 6. All memoized values and callbacks // Helper to scale user input to token amount @@ -127,10 +142,12 @@ function MarketContent() { BigInt(market.state.supplyShares), market.loanAsset.decimals, ) - : '0', + : '1', [minSupplierShares, market], ); + console.log('scaledMinSupplierShares', scaledMinSupplierShares); + const scaledMinBorrowerShares = useMemo( () => market @@ -140,10 +157,36 @@ function MarketContent() { BigInt(market.state.borrowShares), market.loanAsset.decimals, ) - : '0', + : '1', [minBorrowerShares, market], ); + // Prepare concentration data for borrowers + const borrowerConcentrationData = useMemo(() => { + if (!borrowersData || !market) return null; + const totalBorrowAssets = BigInt(market.state.borrowAssets); + if (totalBorrowAssets === 0n) return null; + + return borrowersData.map((b) => { + const borrowAssets = BigInt(b.borrowAssets); + const percentageScaled = (borrowAssets * 10000n) / totalBorrowAssets; + return { percentage: Number(percentageScaled) / 100 }; + }); + }, [borrowersData, market]); + + // Prepare concentration data for suppliers + const supplierConcentrationData = useMemo(() => { + if (!suppliersData || !market) return null; + const totalSupplyShares = BigInt(market.state.supplyShares); + if (totalSupplyShares === 0n) return null; + + return suppliersData.map((s) => { + const shares = BigInt(s.supplyShares); + const percentageScaled = (shares * 10000n) / totalSupplyShares; + return { percentage: Number(percentageScaled) / 100 }; + }); + }, [suppliersData, market]); + // Unified refetch function for both market and user position const handleRefreshAll = useCallback(async () => { setIsRefreshing(true); @@ -259,17 +302,16 @@ function MarketContent() { {/* Tabs Section */} - Statistics + Trend Activities - Supply Details - Borrow Details + Positions - + - -
+ + {/* Suppliers row: Pie + Concentration */} +
- setShowSupplierFiltersModal(true)} +
-
- -
+ {/* Borrowers row: Pie + Concentration */} +
+ +
+ + {/* Collateral at Risk chart */} +
+ + {/* Tables */} +
+ setShowSupplierFiltersModal(true)} + /> +
{ +export const useAllMarketBorrowers = (marketId: string | undefined, network: SupportedNetworks | undefined): UseAllBorrowersResult => { const { data, isLoading, error } = useQuery({ queryKey: ['allMarketBorrowers', marketId, network], queryFn: async () => { @@ -39,7 +36,7 @@ export const useAllMarketBorrowers = ( // Try Morpho API first if (supportsMorphoApi(network)) { try { - const result = await fetchMorphoMarketBorrowers(marketId, Number(network), '0', TOP_POSITIONS_LIMIT, 0); + const result = await fetchMorphoMarketBorrowers(marketId, Number(network), '1', TOP_POSITIONS_LIMIT, 0); return result; } catch (morphoError) { console.error('Failed to fetch all borrowers via Morpho API:', morphoError); @@ -47,7 +44,7 @@ export const useAllMarketBorrowers = ( } // Fallback to Subgraph - const result = await fetchSubgraphMarketBorrowers(marketId, network, '0', TOP_POSITIONS_LIMIT, 0); + const result = await fetchSubgraphMarketBorrowers(marketId, network, '1', TOP_POSITIONS_LIMIT, 0); return result; }, enabled: !!marketId && !!network, @@ -64,12 +61,9 @@ export const useAllMarketBorrowers = ( /** * Fetches top suppliers for chart aggregation (non-paginated). - * Retrieves up to 100 positions sorted by supply shares descending. + * Retrieves up to 1000 positions sorted by supply shares descending. */ -export const useAllMarketSuppliers = ( - marketId: string | undefined, - network: SupportedNetworks | undefined, -): UseAllSuppliersResult => { +export const useAllMarketSuppliers = (marketId: string | undefined, network: SupportedNetworks | undefined): UseAllSuppliersResult => { const { data, isLoading, error } = useQuery({ queryKey: ['allMarketSuppliers', marketId, network], queryFn: async () => { @@ -78,7 +72,7 @@ export const useAllMarketSuppliers = ( // Try Morpho API first if (supportsMorphoApi(network)) { try { - const result = await fetchMorphoMarketSuppliers(marketId, Number(network), '0', TOP_POSITIONS_LIMIT, 0); + const result = await fetchMorphoMarketSuppliers(marketId, Number(network), '1', TOP_POSITIONS_LIMIT, 0); return result; } catch (morphoError) { console.error('Failed to fetch all suppliers via Morpho API:', morphoError); @@ -86,7 +80,7 @@ export const useAllMarketSuppliers = ( } // Fallback to Subgraph - const result = await fetchSubgraphMarketSuppliers(marketId, network, '0', TOP_POSITIONS_LIMIT, 0); + const result = await fetchSubgraphMarketSuppliers(marketId, network, '1', TOP_POSITIONS_LIMIT, 0); return result; }, enabled: !!marketId && !!network, diff --git a/src/hooks/useMarketBorrowers.ts b/src/hooks/useMarketBorrowers.ts index 674fe790..74c14344 100644 --- a/src/hooks/useMarketBorrowers.ts +++ b/src/hooks/useMarketBorrowers.ts @@ -14,7 +14,7 @@ import type { PaginatedMarketBorrowers } from '@/utils/types'; * * @param marketId The ID of the market (e.g., 0x...). * @param network The blockchain network. - * @param minShares Minimum borrow share amount to filter borrowers (optional, defaults to '0'). + * @param minShares Minimum borrow share amount to filter borrowers (optional, defaults to '1' to exclude zero positions). * @param page Current page number (1-indexed, defaults to 1). * @param pageSize Number of items per page (defaults to 10). * @returns Paginated borrowers for the market. @@ -22,7 +22,7 @@ import type { PaginatedMarketBorrowers } from '@/utils/types'; export const useMarketBorrowers = ( marketId: string | undefined, network: SupportedNetworks | undefined, - minShares = '0', + minShares = '1', page = 1, pageSize = 10, ) => { diff --git a/src/hooks/useMarketSuppliers.ts b/src/hooks/useMarketSuppliers.ts index 1115aefd..e9c2795e 100644 --- a/src/hooks/useMarketSuppliers.ts +++ b/src/hooks/useMarketSuppliers.ts @@ -14,7 +14,7 @@ import type { PaginatedMarketSuppliers } from '@/utils/types'; * * @param marketId The ID of the market (e.g., 0x...). * @param network The blockchain network. - * @param minShares Minimum share amount to filter suppliers (optional, defaults to '0'). + * @param minShares Minimum share amount to filter suppliers (optional, defaults to '1' to exclude zero positions). * @param page Current page number (1-indexed, defaults to 1). * @param pageSize Number of items per page (defaults to 8). * @returns Paginated suppliers for the market. @@ -22,7 +22,7 @@ import type { PaginatedMarketSuppliers } from '@/utils/types'; export const useMarketSuppliers = ( marketId: string | undefined, network: SupportedNetworks | undefined, - minShares = '0', + minShares = '1', page = 1, pageSize = 8, ) => { From 4144fee6309ea89003aa1deeb75ee212d343876e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 15 Jan 2026 16:36:51 +0800 Subject: [PATCH 3/7] chore: lint --- .../components/charts/concentration-chart.tsx | 64 +++------------- .../filters/concentration-filters-modal.tsx | 76 ------------------- src/features/market-detail/market-view.tsx | 12 +-- src/hooks/useMarketBorrowers.ts | 17 +++-- src/hooks/useMarketSuppliers.ts | 17 +++-- src/stores/useMarketDetailPreferences.ts | 30 ++++++++ 6 files changed, 66 insertions(+), 150 deletions(-) delete mode 100644 src/features/market-detail/components/filters/concentration-filters-modal.tsx create mode 100644 src/stores/useMarketDetailPreferences.ts diff --git a/src/features/market-detail/components/charts/concentration-chart.tsx b/src/features/market-detail/components/charts/concentration-chart.tsx index 28fb82b1..74df7997 100644 --- a/src/features/market-detail/components/charts/concentration-chart.tsx +++ b/src/features/market-detail/components/charts/concentration-chart.tsx @@ -1,12 +1,10 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import { GoFilter } from 'react-icons/go'; import { Card } from '@/components/ui/card'; import { Spinner } from '@/components/ui/spinner'; import { ChartGradients, chartTooltipCursor } from './chart-utils'; -import ConcentrationFiltersModal from '../filters/concentration-filters-modal'; type ConcentrationDataPoint = { position: number; @@ -22,18 +20,9 @@ type ConcentrationChartProps = { color: string; }; -const DEFAULT_MIN_PERCENT = 0.01; +const MIN_PERCENT_THRESHOLD = 0.01; export function ConcentrationChart({ positions, totalCount, isLoading, title, color }: ConcentrationChartProps) { - const [minPercentInput, setMinPercentInput] = useState(DEFAULT_MIN_PERCENT.toString()); - const [showFiltersModal, setShowFiltersModal] = useState(false); - - const minPercent = useMemo(() => { - const parsed = Number.parseFloat(minPercentInput); - // Always filter out 0% positions even if user enters 0 - return Number.isNaN(parsed) || parsed <= 0 ? 0.000001 : parsed; - }, [minPercentInput]); - const { chartData, meaningfulCount, totalPercentShown } = useMemo(() => { const emptyResult = { chartData: [], meaningfulCount: 0, totalPercentShown: 0 }; @@ -42,7 +31,7 @@ export function ConcentrationChart({ positions, totalCount, isLoading, title, co } const sorted = [...positions].sort((a, b) => b.percentage - a.percentage); - const meaningful = sorted.filter((p) => p.percentage > 0 && p.percentage >= minPercent); + const meaningful = sorted.filter((p) => p.percentage > 0 && p.percentage >= MIN_PERCENT_THRESHOLD); if (meaningful.length === 0) { return emptyResult; @@ -54,7 +43,7 @@ export function ConcentrationChart({ positions, totalCount, isLoading, title, co return { position: index + 1, cumulativePercent: runningSum, - idealPercent: 0, // Calculated below once we know totalPercentShown + idealPercent: 0, }; }); @@ -70,7 +59,7 @@ export function ConcentrationChart({ positions, totalCount, isLoading, title, co meaningfulCount: meaningful.length, totalPercentShown: totalPct, }; - }, [positions, totalCount, minPercent]); + }, [positions, totalCount]); const metrics = useMemo(() => { if (chartData.length === 0) return null; @@ -129,28 +118,11 @@ export function ConcentrationChart({ positions, totalCount, isLoading, title, co return (
-
-

{title}

- -
+

{title}

-

All positions below {minPercentInput}% threshold

+

All positions below {MIN_PERCENT_THRESHOLD}% threshold

-
); } @@ -159,17 +131,7 @@ export function ConcentrationChart({ positions, totalCount, isLoading, title, co
-
-

{title}

- -
+

{title}

{metrics && (
{metrics.top1 !== null && ( @@ -261,18 +223,10 @@ export function ConcentrationChart({ positions, totalCount, isLoading, title, co

{meaningfulCount < totalCount - ? `${meaningfulCount.toLocaleString()} of ${totalCount.toLocaleString()} positions above ${minPercentInput}% (${totalPercentShown.toFixed(1)}% of total)` + ? `${meaningfulCount.toLocaleString()} of ${totalCount.toLocaleString()} positions above ${MIN_PERCENT_THRESHOLD}%` : `${totalCount.toLocaleString()} total positions`}

- - ); } diff --git a/src/features/market-detail/components/filters/concentration-filters-modal.tsx b/src/features/market-detail/components/filters/concentration-filters-modal.tsx deleted file mode 100644 index f094f323..00000000 --- a/src/features/market-detail/components/filters/concentration-filters-modal.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Input } from '@/components/ui/input'; -import { GoFilter } from 'react-icons/go'; -import { Button } from '@/components/ui/button'; -import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; -import { SettingItem, createNumericInputHandler } from './shared-filter-utils'; - -type ConcentrationFiltersModalProps = { - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - minPercent: string; - onMinPercentChange: (value: string) => void; - title: string; -}; - -export default function ConcentrationFiltersModal({ - isOpen, - onOpenChange, - minPercent, - onMinPercentChange, - title, -}: ConcentrationFiltersModalProps) { - const handlePercentChange = createNumericInputHandler(onMinPercentChange); - - return ( - - {(onClose) => ( - <> - } - onClose={onClose} - /> - -
-

Dust Threshold

-

- Filter out small positions below the specified percentage of total market. -

- - - -
-
- - - - - )} -
- ); -} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index e55a14fd..d679113c 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -14,6 +14,7 @@ import { useModal } from '@/hooks/useModal'; import { useMarketData } from '@/hooks/useMarketData'; import { useOraclePrice } from '@/hooks/useOraclePrice'; import { useTransactionFilters } from '@/stores/useTransactionFilters'; +import { useMarketDetailPreferences, type MarketDetailTab } from '@/stores/useMarketDetailPreferences'; import useUserPosition from '@/hooks/useUserPosition'; import type { SupportedNetworks } from '@/utils/networks'; import { BorrowersTable } from '@/features/market-detail/components/borrowers-table'; @@ -44,13 +45,15 @@ function MarketContent() { // 3. Consolidated state const { open: openModal } = useModal(); + const selectedTab = useMarketDetailPreferences((s) => s.selectedTab); + const setSelectedTab = useMarketDetailPreferences((s) => s.setSelectedTab); const [showBorrowModal, setShowBorrowModal] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false); const [showSupplierFiltersModal, setShowSupplierFiltersModal] = useState(false); - const [minSupplierShares, setMinSupplierShares] = useState('1'); + const [minSupplierShares, setMinSupplierShares] = useState(''); const [showBorrowerFiltersModal, setShowBorrowerFiltersModal] = useState(false); - const [minBorrowerShares, setMinBorrowerShares] = useState('1'); + const [minBorrowerShares, setMinBorrowerShares] = useState(''); // 4. Data fetching hooks - use unified time range const { @@ -146,8 +149,6 @@ function MarketContent() { [minSupplierShares, market], ); - console.log('scaledMinSupplierShares', scaledMinSupplierShares); - const scaledMinBorrowerShares = useMemo( () => market @@ -302,7 +303,8 @@ function MarketContent() { {/* Tabs Section */} setSelectedTab(value as MarketDetailTab)} className="mt-8 w-full" > diff --git a/src/hooks/useMarketBorrowers.ts b/src/hooks/useMarketBorrowers.ts index 74c14344..171006a7 100644 --- a/src/hooks/useMarketBorrowers.ts +++ b/src/hooks/useMarketBorrowers.ts @@ -28,7 +28,10 @@ export const useMarketBorrowers = ( ) => { const queryClient = useQueryClient(); - const queryKey = ['marketBorrowers', marketId, network, minShares, page, pageSize]; + // Always filter out zero positions by ensuring minShares >= 1 + const effectiveMinShares = !minShares || minShares === '0' || minShares === '' ? '1' : minShares; + + const queryKey = ['marketBorrowers', marketId, network, effectiveMinShares, page, pageSize]; const queryFn = useCallback( async (targetPage: number): Promise => { @@ -43,7 +46,7 @@ export const useMarketBorrowers = ( if (supportsMorphoApi(network)) { try { console.log(`Attempting to fetch borrowers via Morpho API for ${marketId} (page ${targetPage})`); - result = await fetchMorphoMarketBorrowers(marketId, Number(network), minShares, pageSize, targetSkip); + result = await fetchMorphoMarketBorrowers(marketId, Number(network), effectiveMinShares, pageSize, targetSkip); } catch (morphoError) { console.error('Failed to fetch borrowers via Morpho API:', morphoError); } @@ -53,7 +56,7 @@ export const useMarketBorrowers = ( if (!result) { try { console.log(`Attempting to fetch borrowers via Subgraph for ${marketId} (page ${targetPage})`); - result = await fetchSubgraphMarketBorrowers(marketId, network, minShares, pageSize, targetSkip); + result = await fetchSubgraphMarketBorrowers(marketId, network, effectiveMinShares, pageSize, targetSkip); } catch (subgraphError) { console.error('Failed to fetch borrowers via Subgraph:', subgraphError); throw subgraphError; @@ -62,7 +65,7 @@ export const useMarketBorrowers = ( return result; }, - [marketId, network, minShares, pageSize], + [marketId, network, effectiveMinShares, pageSize], ); const { data, isLoading, isFetching, error, refetch } = useQuery({ @@ -81,7 +84,7 @@ export const useMarketBorrowers = ( const totalPages = data.totalCount > 0 ? Math.ceil(data.totalCount / pageSize) : 0; if (page > 1) { - const prevPageKey = ['marketBorrowers', marketId, network, minShares, page - 1, pageSize]; + const prevPageKey = ['marketBorrowers', marketId, network, effectiveMinShares, page - 1, pageSize]; void queryClient.prefetchQuery({ queryKey: prevPageKey, queryFn: async () => queryFn(page - 1), @@ -90,14 +93,14 @@ export const useMarketBorrowers = ( } if (page < totalPages) { - const nextPageKey = ['marketBorrowers', marketId, network, minShares, page + 1, pageSize]; + const nextPageKey = ['marketBorrowers', marketId, network, effectiveMinShares, page + 1, pageSize]; void queryClient.prefetchQuery({ queryKey: nextPageKey, queryFn: async () => queryFn(page + 1), staleTime: 1000 * 60 * 2, }); } - }, [page, data, queryClient, queryFn, marketId, network, minShares, pageSize]); + }, [page, data, queryClient, queryFn, marketId, network, effectiveMinShares, pageSize]); return { data: data, diff --git a/src/hooks/useMarketSuppliers.ts b/src/hooks/useMarketSuppliers.ts index e9c2795e..ccaa1700 100644 --- a/src/hooks/useMarketSuppliers.ts +++ b/src/hooks/useMarketSuppliers.ts @@ -28,7 +28,10 @@ export const useMarketSuppliers = ( ) => { const queryClient = useQueryClient(); - const queryKey = ['marketSuppliers', marketId, network, minShares, page, pageSize]; + // Always filter out zero positions by ensuring minShares >= 1 + const effectiveMinShares = !minShares || minShares === '0' || minShares === '' ? '1' : minShares; + + const queryKey = ['marketSuppliers', marketId, network, effectiveMinShares, page, pageSize]; const queryFn = useCallback( async (targetPage: number): Promise => { @@ -43,7 +46,7 @@ export const useMarketSuppliers = ( if (supportsMorphoApi(network)) { try { console.log(`Attempting to fetch suppliers via Morpho API for ${marketId} (page ${targetPage})`); - result = await fetchMorphoMarketSuppliers(marketId, Number(network), minShares, pageSize, targetSkip); + result = await fetchMorphoMarketSuppliers(marketId, Number(network), effectiveMinShares, pageSize, targetSkip); } catch (morphoError) { console.error('Failed to fetch suppliers via Morpho API:', morphoError); } @@ -53,7 +56,7 @@ export const useMarketSuppliers = ( if (!result) { try { console.log(`Attempting to fetch suppliers via Subgraph for ${marketId} (page ${targetPage})`); - result = await fetchSubgraphMarketSuppliers(marketId, network, minShares, pageSize, targetSkip); + result = await fetchSubgraphMarketSuppliers(marketId, network, effectiveMinShares, pageSize, targetSkip); } catch (subgraphError) { console.error('Failed to fetch suppliers via Subgraph:', subgraphError); throw subgraphError; @@ -62,7 +65,7 @@ export const useMarketSuppliers = ( return result; }, - [marketId, network, minShares, pageSize], + [marketId, network, effectiveMinShares, pageSize], ); const { data, isLoading, isFetching, error, refetch } = useQuery({ @@ -81,7 +84,7 @@ export const useMarketSuppliers = ( const totalPages = data.totalCount > 0 ? Math.ceil(data.totalCount / pageSize) : 0; if (page > 1) { - const prevPageKey = ['marketSuppliers', marketId, network, minShares, page - 1, pageSize]; + const prevPageKey = ['marketSuppliers', marketId, network, effectiveMinShares, page - 1, pageSize]; void queryClient.prefetchQuery({ queryKey: prevPageKey, queryFn: async () => queryFn(page - 1), @@ -90,14 +93,14 @@ export const useMarketSuppliers = ( } if (page < totalPages) { - const nextPageKey = ['marketSuppliers', marketId, network, minShares, page + 1, pageSize]; + const nextPageKey = ['marketSuppliers', marketId, network, effectiveMinShares, page + 1, pageSize]; void queryClient.prefetchQuery({ queryKey: nextPageKey, queryFn: async () => queryFn(page + 1), staleTime: 1000 * 60 * 2, }); } - }, [page, data, queryClient, queryFn, marketId, network, minShares, pageSize]); + }, [page, data, queryClient, queryFn, marketId, network, effectiveMinShares, pageSize]); return { data: data, diff --git a/src/stores/useMarketDetailPreferences.ts b/src/stores/useMarketDetailPreferences.ts new file mode 100644 index 00000000..14f72047 --- /dev/null +++ b/src/stores/useMarketDetailPreferences.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +type MarketDetailTab = 'trend' | 'activities' | 'positions'; + +type MarketDetailPreferencesState = { + selectedTab: MarketDetailTab; +}; + +type MarketDetailPreferencesActions = { + setSelectedTab: (tab: MarketDetailTab) => void; + setAll: (state: Partial) => void; +}; + +type MarketDetailPreferencesStore = MarketDetailPreferencesState & MarketDetailPreferencesActions; + +export const useMarketDetailPreferences = create()( + persist( + (set) => ({ + selectedTab: 'trend', + setSelectedTab: (tab) => set({ selectedTab: tab }), + setAll: (state) => set(state), + }), + { + name: 'monarch_store_marketDetailPreferences', + }, + ), +); + +export type { MarketDetailTab }; From 3145e7235d02dbcaacdcb56ffdcd9f54d6841542 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 15 Jan 2026 16:43:26 +0800 Subject: [PATCH 4/7] chore: better cutup --- .../charts/collateral-at-risk-chart.tsx | 73 ++++++++++--------- .../components/charts/concentration-chart.tsx | 2 +- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx index 3fd16791..0c67f4c0 100644 --- a/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx +++ b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx @@ -21,6 +21,7 @@ type CollateralAtRiskChartProps = { type RiskDataPoint = { priceDrop: number; // 0 to -100 cumulativeDebt: number; // Absolute debt amount + cumulativeDebtPercent: number; // Percentage of total debt }; // Gradient config for the risk chart @@ -76,30 +77,33 @@ export function CollateralAtRiskChart({ chainId, market, oraclePrice }: Collater // Calculate total debt const total = borrowersWithRisk.reduce((sum, b) => sum + b.debt, 0); - // Build cumulative data for each 1% price drop (continuous scale) - // "At X% price drop, how much cumulative debt is at risk?" - const dataPoints: RiskDataPoint[] = []; - for (let drop = 0; drop >= -100; drop -= 1) { - // Sum debt for all borrowers who would be liquidated at this price drop or less severe - const cumulativeDebt = borrowersWithRisk - .filter((b) => b.priceDrop >= drop) // Borrowers liquidated at this drop or earlier (less negative) - .reduce((sum, b) => sum + b.debt, 0); - - dataPoints.push({ priceDrop: drop, cumulativeDebt }); - } - - // Calculate risk metrics for header - const debtAt10 = dataPoints.find((d) => d.priceDrop === -10)?.cumulativeDebt ?? 0; - const debtAt25 = dataPoints.find((d) => d.priceDrop === -25)?.cumulativeDebt ?? 0; - const debtAt50 = dataPoints.find((d) => d.priceDrop === -50)?.cumulativeDebt ?? 0; + // Build data points only at actual liquidation thresholds (where debt changes) + // Get unique price drop thresholds, plus start (0) and end (-100) + const thresholds = new Set([0, -100, ...borrowersWithRisk.map((b) => Math.round(b.priceDrop))]); + const sortedThresholds = [...thresholds].sort((a, b) => b - a); // Descending (0 to -100) + + const dataPoints: RiskDataPoint[] = sortedThresholds.map((drop) => { + const cumulativeDebt = borrowersWithRisk.filter((b) => b.priceDrop >= drop).reduce((sum, b) => sum + b.debt, 0); + const cumulativeDebtPercent = total > 0 ? (cumulativeDebt / total) * 100 : 0; + return { priceDrop: drop, cumulativeDebt, cumulativeDebtPercent }; + }); + + // Calculate risk metrics for header (percentages) - interpolate from nearest threshold + const getPercentAtDrop = (targetDrop: number) => { + const point = dataPoints.find((d) => d.priceDrop <= targetDrop); + return point?.cumulativeDebtPercent ?? 0; + }; + const percentAt10 = getPercentAtDrop(-10); + const percentAt25 = getPercentAtDrop(-25); + const percentAt50 = getPercentAtDrop(-50); return { chartData: dataPoints, totalDebt: total, riskMetrics: { - debtAt10, - debtAt25, - debtAt50, + percentAt10, + percentAt25, + percentAt50, }, }; }, [borrowers, oraclePrice, market.loanAsset.decimals, lltv]); @@ -112,18 +116,16 @@ export function CollateralAtRiskChart({ chainId, market, oraclePrice }: Collater

At {data.priceDrop}% price drop

+
+ % of Total Debt + {data.cumulativeDebtPercent.toFixed(1)}% +
Debt at Risk - + {formatReadable(data.cumulativeDebt)} {market.loanAsset.symbol}
- {totalDebt > 0 && ( -
- % of Total - {((data.cumulativeDebt / totalDebt) * 100).toFixed(1)}% -
- )}
); @@ -156,18 +158,18 @@ export function CollateralAtRiskChart({ chainId, market, oraclePrice }: Collater @-10%: 0 ? RISK_COLORS.stroke : 'inherit' }} + style={{ color: riskMetrics.percentAt10 > 0 ? RISK_COLORS.stroke : 'inherit' }} > - {formatReadable(riskMetrics.debtAt10)} + {riskMetrics.percentAt10.toFixed(1)}%
@-25%: - {formatReadable(riskMetrics.debtAt25)} + {riskMetrics.percentAt25.toFixed(1)}%
@-50%: - {formatReadable(riskMetrics.debtAt50)} + {riskMetrics.percentAt50.toFixed(1)}%
)} @@ -194,21 +196,22 @@ export function CollateralAtRiskChart({ chainId, market, oraclePrice }: Collater /> `${value}%`} tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} domain={[0, -100]} - reversed={false} + ticks={[0, -20, -40, -60, -80, -100]} /> formatReadable(value)} + tickFormatter={(value) => `${value}%`} tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} - width={60} - domain={[0, 'dataMax']} + width={50} + domain={[0, 100]} /> { From 5418cfb17b91731e2885491cc157a63970b70280 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 15 Jan 2026 16:48:30 +0800 Subject: [PATCH 5/7] chore: charts --- .../market-detail/components/charts/concentration-chart.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/market-detail/components/charts/concentration-chart.tsx b/src/features/market-detail/components/charts/concentration-chart.tsx index 1814c6ff..8fedd541 100644 --- a/src/features/market-detail/components/charts/concentration-chart.tsx +++ b/src/features/market-detail/components/charts/concentration-chart.tsx @@ -52,7 +52,9 @@ export function ConcentrationChart({ positions, totalCount, isLoading, title, co point.idealPercent = (point.position / meaningful.length) * totalPct; } - dataPoints.unshift({ position: 0, cumulativePercent: 0, idealPercent: 0 }); + if (meaningful.length > 1) { + dataPoints.unshift({ position: 0, cumulativePercent: 0, idealPercent: 0 }); + } return { chartData: dataPoints, @@ -215,6 +217,7 @@ export function ConcentrationChart({ positions, totalCount, isLoading, title, co strokeWidth={2} fill={'url(#concentrationChart-gradient)'} fillOpacity={0.7} + dot={chartData.length === 1 ? { r: 4, fill: color } : false} /> From e32c417d6fec2acc743dfc175e98684bb6622dcb Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 15 Jan 2026 16:51:35 +0800 Subject: [PATCH 6/7] fix: collateral at risk chart --- .../components/charts/collateral-at-risk-chart.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx index 0c67f4c0..b70e6b3d 100644 --- a/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx +++ b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx @@ -202,8 +202,9 @@ export function CollateralAtRiskChart({ chainId, market, oraclePrice }: Collater tickMargin={12} tickFormatter={(value) => `${value}%`} tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} - domain={[0, -100]} + domain={[-100, 0]} ticks={[0, -20, -40, -60, -80, -100]} + reversed /> Date: Thu, 15 Jan 2026 16:55:08 +0800 Subject: [PATCH 7/7] fix: more than 100% issue --- src/features/market-detail/market-view.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index d679113c..8963e230 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -164,8 +164,9 @@ function MarketContent() { // Prepare concentration data for borrowers const borrowerConcentrationData = useMemo(() => { - if (!borrowersData || !market) return null; - const totalBorrowAssets = BigInt(market.state.borrowAssets); + if (!borrowersData || borrowersData.length === 0) return null; + // Calculate total from actual data to ensure percentages sum to 100% + const totalBorrowAssets = borrowersData.reduce((sum, b) => sum + BigInt(b.borrowAssets), 0n); if (totalBorrowAssets === 0n) return null; return borrowersData.map((b) => { @@ -173,12 +174,13 @@ function MarketContent() { const percentageScaled = (borrowAssets * 10000n) / totalBorrowAssets; return { percentage: Number(percentageScaled) / 100 }; }); - }, [borrowersData, market]); + }, [borrowersData]); // Prepare concentration data for suppliers const supplierConcentrationData = useMemo(() => { - if (!suppliersData || !market) return null; - const totalSupplyShares = BigInt(market.state.supplyShares); + if (!suppliersData || suppliersData.length === 0) return null; + // Calculate total from actual data to ensure percentages sum to 100% + const totalSupplyShares = suppliersData.reduce((sum, s) => sum + BigInt(s.supplyShares), 0n); if (totalSupplyShares === 0n) return null; return suppliersData.map((s) => { @@ -186,7 +188,7 @@ function MarketContent() { const percentageScaled = (shares * 10000n) / totalSupplyShares; return { percentage: Number(percentageScaled) / 100 }; }); - }, [suppliersData, market]); + }, [suppliersData]); // Unified refetch function for both market and user position const handleRefreshAll = useCallback(async () => {