diff --git a/src/features/market-detail/components/charts/position-flow-chart.tsx b/src/features/market-detail/components/charts/position-flow-chart.tsx new file mode 100644 index 00000000..c6c7608e --- /dev/null +++ b/src/features/market-detail/components/charts/position-flow-chart.tsx @@ -0,0 +1,257 @@ +import { useState, useMemo } from 'react'; +import { Card } from '@/components/ui/card'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ReferenceLine } from 'recharts'; +import { Spinner } from '@/components/ui/spinner'; +import { useChartColors } from '@/constants/chartColors'; +import { formatReadable } from '@/utils/balance'; +import { formatChartTime } from '@/utils/chart'; +import { useMarketPositionFlow, type PositionFlowTimeframe } from '@/hooks/useMarketPositionFlow'; +import { ChartGradients, chartTooltipCursor } from './chart-utils'; +import type { Market } from '@/utils/types'; +import type { SupportedNetworks } from '@/utils/networks'; + +type PositionFlowChartProps = { + marketId: string; + chainId: SupportedNetworks; + market: Market; +}; + +const TIMEFRAME_LABELS: Record = { + '7d': '7D', + '30d': '30D', +}; + +function PositionFlowChart({ marketId, chainId, market }: PositionFlowChartProps) { + const [selectedTimeframe, setSelectedTimeframe] = useState('7d'); + const chartColors = useChartColors(); + + const { + data: flowData, + stats, + isLoading, + } = useMarketPositionFlow(marketId, market.loanAsset.id, chainId, selectedTimeframe, market.loanAsset.decimals); + + // Calculate duration for time formatting + const durationSeconds = useMemo(() => { + if (selectedTimeframe === '7d') return 7 * 24 * 60 * 60; + return 30 * 24 * 60 * 60; + }, [selectedTimeframe]); + + const formatValue = (value: number) => { + const formattedValue = formatReadable(Math.abs(value)); + const prefix = value >= 0 ? '+' : '-'; + return `${prefix}${formattedValue} ${market.loanAsset.symbol}`; + }; + + const formatYAxis = (value: number) => { + const prefix = value >= 0 ? '+' : ''; + return `${prefix}${formatReadable(value)}`; + }; + + // Create gradient configs for positive and negative bars + const flowGradients = useMemo( + () => [ + { id: 'positiveFlowGradient', color: chartColors.supply.stroke }, + { id: 'negativeFlowGradient', color: chartColors.withdraw.stroke }, + ], + [chartColors], + ); + + // Custom tooltip component + const CustomTooltip = ({ active, payload, label }: any) => { + if (!active || !payload || payload.length === 0) return null; + const data = payload[0].payload; + return ( +
+

+ {new Date(label * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} +

+
+
+
+ + Supply +
+ + +{formatReadable(data.supplyVolume)} {market.loanAsset.symbol} + +
+
+
+ + Withdraw +
+ + -{formatReadable(data.withdrawVolume)} {market.loanAsset.symbol} + +
+
+
+ Net Flow + = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> + {formatValue(data.netFlow)} + +
+
+
+
+ ); + }; + + return ( + + {/* Header: Stats + Controls */} +
+ {/* Stats */} +
+
+

Net Flow

+
+ = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> + {formatValue(stats.totalNetFlow)} + +
+
+
+

Total Supply

+ + +{formatReadable(stats.totalSupply)} {market.loanAsset.symbol} + +
+
+

Total Withdraw

+ + -{formatReadable(stats.totalWithdraw)} {market.loanAsset.symbol} + +
+
+ + {/* Controls */} +
+ +
+
+ + {/* Chart Body */} +
+ {isLoading ? ( +
+ +
+ ) : flowData.length === 0 ? ( +
No position flow data available for this period
+ ) : ( + + + + + formatChartTime(time, durationSeconds)} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + + } + /> + + + {flowData.map((entry, index) => ( + = 0 ? chartColors.supply.stroke : chartColors.withdraw.stroke} + fillOpacity={0.85} + /> + ))} + + + + )} +
+ + {/* Footer: Average info */} +
+
+
+ Avg. Daily Flow + = 0 ? 'text-emerald-500' : 'text-rose-500'}`}> + {formatValue(stats.avgDailyFlow)} + +
+
+ + Inflow (Supply) +
+
+ + Outflow (Withdraw) +
+
+
+
+ ); +} + +export default PositionFlowChart; diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index aae5a4e8..236e0663 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -30,6 +30,7 @@ import { useAllMarketBorrowers, useAllMarketSuppliers } from '@/hooks/useAllMark import { MarketHeader } from './components/market-header'; import RateChart from './components/charts/rate-chart'; import VolumeChart from './components/charts/volume-chart'; +import PositionFlowChart from './components/charts/position-flow-chart'; import { SuppliersPieChart } from './components/charts/suppliers-pie-chart'; import { BorrowersPieChart } from './components/charts/borrowers-pie-chart'; import { DebtAtRiskChart } from './components/charts/debt-at-risk-chart'; @@ -367,6 +368,14 @@ function MarketContent() { market={market} /> +
+ +
+
{ + // Calculate timestamp filter based on timeframe + const startTimestamp = useMemo(() => { + const now = moment(); + if (timeframe === '7d') { + return now.subtract(7, 'days').unix(); + } + return now.subtract(30, 'days').unix(); + }, [timeframe]); + + // Fetch all supply/withdraw transactions for the timeframe + // We fetch a larger batch to get all transactions in the period + const { data, isLoading, error } = useQuery({ + queryKey: ['marketPositionFlow', marketId, chainId, timeframe], + queryFn: async () => { + if (!marketId || !loanAssetId || !chainId) { + return null; + } + + // Fetch up to 500 transactions to cover the timeframe + // Using pagination to get all data + const allTransactions: MarketActivityTransaction[] = []; + let skip = 0; + const pageSize = 100; + let hasMore = true; + + while (hasMore) { + let result; + + // Try Morpho API first if supported + if (supportsMorphoApi(chainId)) { + try { + result = await fetchMorphoMarketSupplies(marketId, '0', pageSize, skip); + } catch { + console.error('Morpho API failed, falling back to subgraph'); + } + } + + // Fallback to Subgraph + if (!result) { + result = await fetchSubgraphMarketSupplies(marketId, loanAssetId, chainId, '0', pageSize, skip); + } + + if (!result || result.items.length === 0) { + hasMore = false; + break; + } + + // Filter to only include transactions within our timeframe + const filteredItems = result.items.filter((tx) => tx.timestamp >= startTimestamp); + allTransactions.push(...filteredItems); + + // If the oldest transaction in this batch is before our start time, we have all we need + const oldestTimestamp = Math.min(...result.items.map((tx) => tx.timestamp)); + if (oldestTimestamp < startTimestamp || result.items.length < pageSize) { + hasMore = false; + } else { + skip += pageSize; + } + + // Safety limit to prevent infinite loops + if (skip >= 500) { + hasMore = false; + } + } + + return allTransactions; + }, + enabled: !!marketId && !!loanAssetId && !!chainId, + staleTime: 1000 * 60 * 5, // 5 minutes + }); + + // Aggregate transactions by day + const aggregatedData = useMemo(() => { + if (!data || data.length === 0) { + return []; + } + + // Group by day + const dailyData = new Map(); + + for (const tx of data) { + const dayKey = moment.unix(tx.timestamp).format('YYYY-MM-DD'); + + if (!dailyData.has(dayKey)) { + dailyData.set(dayKey, { supply: 0n, withdraw: 0n }); + } + + const dayEntry = dailyData.get(dayKey)!; + const amount = BigInt(tx.amount); + + if (tx.type === 'MarketSupply') { + dayEntry.supply += amount; + } else if (tx.type === 'MarketWithdraw') { + dayEntry.withdraw += amount; + } + } + + // Convert to array and sort by date + const result: PositionFlowDataPoint[] = []; + const sortedDays = Array.from(dailyData.keys()).sort(); + + for (const dayKey of sortedDays) { + const entry = dailyData.get(dayKey)!; + const supplyVolume = Number(entry.supply) / 10 ** decimals; + const withdrawVolume = Number(entry.withdraw) / 10 ** decimals; + const netFlow = supplyVolume - withdrawVolume; + + result.push({ + date: moment(dayKey, 'YYYY-MM-DD').unix(), + netFlow, + supplyVolume, + withdrawVolume, + }); + } + + return result; + }, [data, decimals]); + + // Calculate stats + const stats = useMemo(() => { + if (aggregatedData.length === 0) { + return { + totalNetFlow: 0, + totalSupply: 0, + totalWithdraw: 0, + avgDailyFlow: 0, + }; + } + + const totalSupply = aggregatedData.reduce((sum, d) => sum + d.supplyVolume, 0); + const totalWithdraw = aggregatedData.reduce((sum, d) => sum + d.withdrawVolume, 0); + const totalNetFlow = totalSupply - totalWithdraw; + const avgDailyFlow = totalNetFlow / aggregatedData.length; + + return { + totalNetFlow, + totalSupply, + totalWithdraw, + avgDailyFlow, + }; + }, [aggregatedData]); + + return { + data: aggregatedData, + stats, + isLoading, + error, + }; +}; + +export default useMarketPositionFlow;