diff --git a/src/components/HistoricalAPYRow.tsx b/src/components/HistoricalAPYRow.tsx new file mode 100644 index 0000000000..81f74032af --- /dev/null +++ b/src/components/HistoricalAPYRow.tsx @@ -0,0 +1,100 @@ +import { SxProps, Theme, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'; + +const supportedHistoricalTimeRangeOptions = ['Now', '30D', '60D', '90D'] as const; + +export enum ESupportedAPYTimeRanges { + Now = 'Now', + ThirtyDays = '30D', + SixtyDays = '60D', + NinetyDays = '90D', +} + +export const reserveHistoricalRateTimeRangeOptions = [ + ESupportedAPYTimeRanges.Now, + ESupportedAPYTimeRanges.ThirtyDays, + ESupportedAPYTimeRanges.SixtyDays, + ESupportedAPYTimeRanges.NinetyDays, +]; + +export type ReserveHistoricalRateTimeRange = typeof reserveHistoricalRateTimeRangeOptions[number]; + +export interface TimeRangeSelectorProps { + disabled?: boolean; + selectedTimeRange: ESupportedAPYTimeRanges; + onTimeRangeChanged: (value: ESupportedAPYTimeRanges) => void; + sx?: { + buttonGroup: SxProps; + button: SxProps; + }; +} + +export const HistoricalAPYRow = ({ + disabled = false, + selectedTimeRange, + onTimeRangeChanged, + ...props +}: TimeRangeSelectorProps) => { + const handleChange = ( + _event: React.MouseEvent, + newInterval: ESupportedAPYTimeRanges + ) => { + if (newInterval !== null) { + onTimeRangeChanged(newInterval); + } + }; + + return ( +
+ APY + + {supportedHistoricalTimeRangeOptions.map((interval) => { + return ( + | undefined => ({ + '&.MuiToggleButtonGroup-grouped:not(.Mui-selected), &.MuiToggleButtonGroup-grouped&.Mui-disabled': + { + border: '0.5px solid transparent', + backgroundColor: 'background.surface', + color: 'action.disabled', + }, + '&.MuiToggleButtonGroup-grouped&.Mui-selected': { + borderRadius: '4px', + border: `0.5px solid ${theme.palette.divider}`, + boxShadow: '0px 2px 1px rgba(0, 0, 0, 0.05), 0px 0px 1px rgba(0, 0, 0, 0.25)', + backgroundColor: 'background.paper', + }, + ...props.sx?.button, + })} + > + {interval} + + ); + })} + +
+ ); +}; diff --git a/src/components/TitleWithSearchBar.tsx b/src/components/TitleWithSearchBar.tsx index 8529a3ec5a..eb4ba1056e 100644 --- a/src/components/TitleWithSearchBar.tsx +++ b/src/components/TitleWithSearchBar.tsx @@ -28,13 +28,10 @@ export const TitleWithSearchBar = ({ title, }: TitleWithSearchBarProps) => { const [showSearchBar, setShowSearchBar] = useState(false); - const { breakpoints } = useTheme(); const sm = useMediaQuery(breakpoints.down('sm')); - const showSearchIcon = sm && !showSearchBar; - const showMarketTitle = !sm || !showSearchBar; - + const showMarketTitle = (!sm || !showSearchBar) && !!title; const handleCancelClick = () => { setShowSearchBar(false); onSearchTermChange(''); @@ -46,7 +43,7 @@ export const TitleWithSearchBar = ({ width: '100%', display: 'flex', alignItems: 'center', - justifyContent: 'space-between', + justifyContent: showMarketTitle && title ? 'space-between' : 'center', }} > {showMarketTitle && ( diff --git a/src/hooks/useHistoricalAPYData.ts b/src/hooks/useHistoricalAPYData.ts new file mode 100644 index 0000000000..96b4161523 --- /dev/null +++ b/src/hooks/useHistoricalAPYData.ts @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react'; +import { INDEX_CURRENT } from 'src/modules/markets/index-current-query'; +import { INDEX_HISTORY } from 'src/modules/markets/index-history-query'; + +export interface HistoricalAPYData { + underlyingAsset: string; + liquidityIndex: string; + variableBorrowIndex: string; + timestamp: string; + liquidityRate: string; + variableBorrowRate: string; +} + +interface Rates { + supplyAPY: string; + variableBorrowAPY: string; +} + +function calculateImpliedAPY( + currentLiquidityIndex: number, + previousLiquidityIndex: number, + daysBetweenIndexes: number, +): string { + if (previousLiquidityIndex <= 0 || currentLiquidityIndex <= 0) { + throw new Error("Liquidity indexes must be positive values."); + } + + const growthFactor = currentLiquidityIndex / previousLiquidityIndex; + + const annualizedGrowthFactor = Math.pow(growthFactor, 365 / daysBetweenIndexes); + + const impliedAPY = (annualizedGrowthFactor - 1); + + return impliedAPY.toString(); +} + +export const useHistoricalAPYData = ( + subgraphUrl: string, + selectedTimeRange: string +) => { + const [historicalAPYData, setHistoricalAPYData] = useState>({}); + + useEffect(() => { + const fetchHistoricalAPYData = async () => { + if (selectedTimeRange === 'Now') { + setHistoricalAPYData({}); + return; + } + + const timeRangeSecondsMap: Record = { + '30D': 30 * 24 * 60 * 60, + '60D': 60 * 24 * 60 * 60, + '90D': 90 * 24 * 60 * 60, + }; + + const timeRangeDaysMap: Record = { + '30D': 30, + '60D': 60, + '90D': 90, + }; + + const timeRangeInSeconds = timeRangeSecondsMap[selectedTimeRange]; + + if (timeRangeInSeconds === undefined) { + console.error(`Invalid time range: ${selectedTimeRange}`); + setHistoricalAPYData({}); + return; + } + + const timestamp = Math.floor(Date.now() / 1000) - timeRangeInSeconds; + + try { + const requestBody = { + query: INDEX_HISTORY, + variables: { timestamp }, + }; + const response = await fetch(subgraphUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const requestBodyCurrent = { + query: INDEX_CURRENT, + }; + const responseCurrent = await fetch(subgraphUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBodyCurrent), + }); + + if (!response.ok || !responseCurrent.ok) { + throw new Error(`Network error: ${response.status} - ${response.statusText}`); + } + + const data = await response.json(); + const dataCurrent = await responseCurrent.json(); + + const historyByAsset: Record = {}; + const currentByAsset: Record = {}; + + data.data.reserveParamsHistoryItems.forEach((entry: any) => { + const assetKey = entry.reserve.underlyingAsset.toLowerCase(); + if (!historyByAsset[assetKey]) { + historyByAsset[assetKey] = { + underlyingAsset: assetKey, + liquidityIndex: entry.liquidityIndex, + variableBorrowIndex: entry.variableBorrowIndex, + liquidityRate: entry.liquidityRate, + variableBorrowRate: entry.variableBorrowRate, + timestamp: entry.timestamp, + }; + } + }); + + dataCurrent.data.reserveParamsHistoryItems.forEach((entry: any) => { + const assetKey = entry.reserve.underlyingAsset.toLowerCase(); + if (!currentByAsset[assetKey]) { + currentByAsset[assetKey] = { + underlyingAsset: assetKey, + liquidityIndex: entry.liquidityIndex, + variableBorrowIndex: entry.variableBorrowIndex, + liquidityRate: entry.liquidityRate, + variableBorrowRate: entry.variableBorrowRate, + timestamp: entry.timestamp, + }; + } + }); + + const allAssets = new Set([ + ...Object.keys(historyByAsset), + ...Object.keys(currentByAsset), + ]); + + const results: Record = {}; + allAssets.forEach((asset) => { + const historical = historyByAsset[asset]; + const current = currentByAsset[asset]; + + if (historical && current) { + results[asset] = { + supplyAPY: calculateImpliedAPY(Number(current.liquidityIndex), Number(historical.liquidityIndex), timeRangeDaysMap[selectedTimeRange] || 0), + variableBorrowAPY: calculateImpliedAPY(Number(current.variableBorrowIndex), Number(historical.variableBorrowIndex), timeRangeDaysMap[selectedTimeRange] || 0), + }; + } + }); + setHistoricalAPYData(results); + } catch (error) { + console.error('Error fetching historical APY data:', error); + setHistoricalAPYData({}); + } + }; + + fetchHistoricalAPYData(); + }, [selectedTimeRange]); + + return historicalAPYData; +}; diff --git a/src/modules/markets/MarketAssetsListContainer.tsx b/src/modules/markets/MarketAssetsListContainer.tsx index 2660231fdc..9065fcdd51 100644 --- a/src/modules/markets/MarketAssetsListContainer.tsx +++ b/src/modules/markets/MarketAssetsListContainer.tsx @@ -10,12 +10,19 @@ import { TitleWithSearchBar } from 'src/components/TitleWithSearchBar'; import { useAppDataContext } from 'src/hooks/app-data-provider/useAppDataProvider'; import { useProtocolDataContext } from 'src/hooks/useProtocolDataContext'; import MarketAssetsList from 'src/modules/markets/MarketAssetsList'; +import { useHistoricalAPYData } from 'src/hooks/useHistoricalAPYData'; + import { useRootStore } from 'src/store/root'; import { fetchIconSymbolAndName } from 'src/ui-config/reservePatches'; import { getGhoReserve, GHO_MINTING_MARKETS, GHO_SYMBOL } from 'src/utils/ghoUtilities'; import { GENERAL } from '../../utils/mixPanelEvents'; import { GhoBanner } from './Gho/GhoBanner'; +import { + ESupportedAPYTimeRanges, + HistoricalAPYRow, + ReserveHistoricalRateTimeRange, +} from 'src/components/HistoricalAPYRow'; function shouldDisplayGhoBanner(marketTitle: string, searchTerm: string): boolean { // GHO banner is only displayed on markets where new GHO is mintable (i.e. Ethereum) @@ -45,12 +52,21 @@ export const MarketAssetsListContainer = () => { const ghoReserve = getGhoReserve(reserves); const displayGhoBanner = shouldDisplayGhoBanner(currentMarket, searchTerm); + const [selectedTimeRange, setSelectedTimeRange] = useState( + ESupportedAPYTimeRanges.Now + ); + + const historicalAPYData = useHistoricalAPYData( + currentMarketData.subgraphUrl ?? '', + selectedTimeRange + ); + const filteredData = reserves // Filter out any non-active reserves .filter((res) => res.isActive) // Filter out GHO if the banner is being displayed .filter((res) => (displayGhoBanner ? res !== ghoReserve : true)) - // filter out any that don't meet search term criteria + // Filter out any reserves that don't meet the search term criteria .filter((res) => { if (!searchTerm) return true; const term = searchTerm.toLowerCase().trim(); @@ -61,15 +77,32 @@ export const MarketAssetsListContainer = () => { ); }) // Transform the object for list to consume it - .map((reserve) => ({ - ...reserve, - ...(reserve.isWrappedBaseAsset - ? fetchIconSymbolAndName({ - symbol: currentNetworkConfig.baseAssetSymbol, - underlyingAsset: API_ETH_MOCK_ADDRESS.toLowerCase(), - }) - : {}), - })); + .map((reserve) => { + const historicalData = historicalAPYData[reserve.underlyingAsset.toLowerCase()]; + + return { + ...reserve, + ...(reserve.isWrappedBaseAsset + ? fetchIconSymbolAndName({ + symbol: currentNetworkConfig.baseAssetSymbol, + underlyingAsset: API_ETH_MOCK_ADDRESS.toLowerCase(), + }) + : {}), + supplyAPY: + selectedTimeRange === ESupportedAPYTimeRanges.Now + ? reserve.supplyAPY + : !!historicalData + ? historicalData.supplyAPY + : 'N/A', + variableBorrowAPY: + selectedTimeRange === ESupportedAPYTimeRanges.Now + ? reserve.variableBorrowAPY + : !!historicalData + ? historicalData.variableBorrowAPY + : 'N/A', + }; + }); + // const marketFrozen = !reserves.some((reserve) => !reserve.isFrozen); // const showFrozenMarketWarning = // marketFrozen && ['Fantom', 'Ethereum AMM'].includes(currentMarketData.marketTitle); @@ -85,15 +118,38 @@ export const MarketAssetsListContainer = () => { return ( +
+ {/* Left: Title */} +
+ {currentMarketData.marketTitle} assets - - } - searchPlaceholder={sm ? 'Search asset' : 'Search asset name, symbol, or address'} - /> + +
+ + {/* Center: Search Bar */} +
+ +
+ + {/* Right: Historical APY */} +
+ +
+
} > {displayGhoBanner && ( diff --git a/src/modules/markets/index-current-query.ts b/src/modules/markets/index-current-query.ts new file mode 100644 index 0000000000..c96e1d00fa --- /dev/null +++ b/src/modules/markets/index-current-query.ts @@ -0,0 +1,14 @@ +export const INDEX_CURRENT = ` +query IndexCurrent { + reserveParamsHistoryItems(orderBy: timestamp, orderDirection: desc){ + liquidityIndex + liquidityRate + variableBorrowRate + variableBorrowIndex + timestamp + reserve{ + underlyingAsset + } + } +} +`; diff --git a/src/modules/markets/index-history-query.ts b/src/modules/markets/index-history-query.ts new file mode 100644 index 0000000000..250a958705 --- /dev/null +++ b/src/modules/markets/index-history-query.ts @@ -0,0 +1,14 @@ +export const INDEX_HISTORY = ` +query IndexHistory($timestamp: Int!) { + reserveParamsHistoryItems(where:{timestamp_lt: $timestamp}, orderBy: timestamp, orderDirection: desc){ + liquidityIndex + liquidityRate + variableBorrowRate + variableBorrowIndex + timestamp + reserve{ + underlyingAsset + } + } +} +`;