From c81a852a59e92c6b449250c20051d2e218b78bf0 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 7 Jan 2026 17:31:29 +0800 Subject: [PATCH 01/12] feat: monarch API --- .env.local.example | 5 + app/api/monarch/metrics/route.ts | 46 ++++ src/data-sources/subgraph/market.ts | 2 - src/features/markets/components/constants.ts | 1 + .../table/markets-table-actions.tsx | 23 ++ src/graphql/morpho-api-queries.ts | 2 - src/hooks/queries/useMarketMetricsQuery.ts | 199 ++++++++++++++++++ src/hooks/useFilteredMarkets.ts | 24 ++- src/stores/useMarketsFilters.ts | 8 + src/utils/markets.ts | 1 + src/utils/subgraph-types.ts | 1 - src/utils/types.ts | 1 - 12 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 app/api/monarch/metrics/route.ts create mode 100644 src/hooks/queries/useMarketMetricsQuery.ts diff --git a/.env.local.example b/.env.local.example index 691db78d..d800dedd 100644 --- a/.env.local.example +++ b/.env.local.example @@ -34,3 +34,8 @@ ALCHEMY_API_KEY= # used for getting block ETHERSCAN_API_KEY= + +# ==================== Monarch API ==================== +# Monarch monitoring API for trending markets +MONARCH_API_ENDPOINT=http://localhost:3000 +MONARCH_API_KEY= diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts new file mode 100644 index 00000000..410f30c3 --- /dev/null +++ b/app/api/monarch/metrics/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +const MONARCH_API_ENDPOINT = process.env.MONARCH_API_ENDPOINT ?? 'http://localhost:3000'; +const MONARCH_API_KEY = process.env.MONARCH_API_KEY ?? ''; + +export async function GET(req: NextRequest) { + const searchParams = req.nextUrl.searchParams; + + // Pass through all query params + const params = new URLSearchParams(); + const chainId = searchParams.get('chain_id'); + const sortBy = searchParams.get('sort_by'); + const sortOrder = searchParams.get('sort_order'); + const limit = searchParams.get('limit'); + const offset = searchParams.get('offset'); + + if (chainId) params.set('chain_id', chainId); + if (sortBy) params.set('sort_by', sortBy); + if (sortOrder) params.set('sort_order', sortOrder); + if (limit) params.set('limit', limit); + if (offset) params.set('offset', offset); + + const url = `${MONARCH_API_ENDPOINT}/v1/markets/metrics?${params.toString()}`; + + try { + const response = await fetch(url, { + headers: { + 'X-API-Key': MONARCH_API_KEY, + }, + // Cache for 15 minutes on the edge + next: { revalidate: 900 }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Monarch Metrics API] Error:', response.status, errorText); + return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('[Monarch Metrics API] Failed to fetch:', error); + return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: 500 }); + } +} diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index acd0d678..a45ffbf8 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -84,7 +84,6 @@ const transformSubgraphMarketToMarket = ( const marketId = subgraphMarket.id ?? ''; const lltv = subgraphMarket.lltv ?? '0'; const irmAddress = subgraphMarket.irm ?? '0x'; - const inputTokenPriceUSD = subgraphMarket.inputTokenPriceUSD ?? '0'; const oracleAddress = (subgraphMarket.oracle?.oracleAddress ?? '0x') as Address; const totalSupplyShares = subgraphMarket.totalSupplyShares ?? '0'; @@ -175,7 +174,6 @@ const transformSubgraphMarketToMarket = ( uniqueKey: marketId, lltv: lltv, irmAddress: irmAddress as Address, - collateralPrice: inputTokenPriceUSD, whitelisted: true, // All subgraph markets are considered whitelisted loanAsset: loanAsset, collateralAsset: collateralAsset, diff --git a/src/features/markets/components/constants.ts b/src/features/markets/components/constants.ts index 9ed581bf..9b613c99 100644 --- a/src/features/markets/components/constants.ts +++ b/src/features/markets/components/constants.ts @@ -11,6 +11,7 @@ export enum SortColumn { RateAtTarget = 10, TrustedBy = 11, UtilizationRate = 12, + Trend = 13, } // Gas cost to simplify tx flow: do not need to estimate gas for transactions diff --git a/src/features/markets/components/table/markets-table-actions.tsx b/src/features/markets/components/table/markets-table-actions.tsx index 7eff4c86..99a503da 100644 --- a/src/features/markets/components/table/markets-table-actions.tsx +++ b/src/features/markets/components/table/markets-table-actions.tsx @@ -3,6 +3,7 @@ import { ReloadIcon } from '@radix-ui/react-icons'; import { CgDisplayFullwidth } from 'react-icons/cg'; import { FiSettings } from 'react-icons/fi'; +import { HiOutlineFire, HiFire } from 'react-icons/hi2'; import { TbArrowAutofitWidth } from 'react-icons/tb'; import { Button } from '@/components/ui/button'; import { Tooltip } from '@/components/ui/tooltip'; @@ -10,6 +11,7 @@ import { TooltipContent } from '@/components/shared/tooltip-content'; import { MarketFilter } from '@/features/positions/components/markets-filter-compact'; import { useModal } from '@/hooks/useModal'; import { useMarketPreferences } from '@/stores/useMarketPreferences'; +import { useMarketsFilters } from '@/stores/useMarketsFilters'; type MarketsTableActionsProps = { onRefresh: () => void; @@ -20,10 +22,31 @@ type MarketsTableActionsProps = { export function MarketsTableActions({ onRefresh, isRefetching, isMobile }: MarketsTableActionsProps) { const { open: openModal } = useModal(); const { tableViewMode, setTableViewMode } = useMarketPreferences(); + const { trendingMode, toggleTrendingMode } = useMarketsFilters(); const effectiveTableViewMode = isMobile ? 'compact' : tableViewMode; return ( <> + } + title="Trending Markets" + detail={trendingMode ? 'Click to show all markets' : 'Click to show only hot markets with supply inflows'} + /> + } + > + + + openModal('marketSettings', {})} /> ; + blockNumber: number; + updatedAt: string; +}; + +export type MarketMetricsResponse = { + total: number; + limit: number; + offset: number; + markets: MarketMetrics[]; +}; + +// Composite key for market lookup +export const getMetricsKey = (chainId: number, uniqueKey: string): string => `${chainId}-${uniqueKey.toLowerCase()}`; + +type MarketMetricsParams = { + chainId?: number | number[]; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + limit?: number; + offset?: number; + enabled?: boolean; +}; + +const fetchMarketMetrics = async (params: MarketMetricsParams): Promise => { + const searchParams = new URLSearchParams(); + + if (params.chainId !== undefined) { + const chainIds = Array.isArray(params.chainId) ? params.chainId.join(',') : String(params.chainId); + searchParams.set('chain_id', chainIds); + } + if (params.sortBy) searchParams.set('sort_by', params.sortBy); + if (params.sortOrder) searchParams.set('sort_order', params.sortOrder); + if (params.limit) searchParams.set('limit', String(params.limit)); + if (params.offset) searchParams.set('offset', String(params.offset)); + + const response = await fetch(`/api/monarch/metrics?${searchParams.toString()}`); + + if (!response.ok) { + throw new Error('Failed to fetch market metrics'); + } + + return response.json(); +}; + +/** + * Fetches enhanced market metrics from the Monarch monitoring API. + * Pre-fetched and cached for 15 minutes. + * + * Returns rich metadata including: + * - Flow data (1h, 24h, 7d, 30d) for supply/borrow + * - Individual vs vault supply breakdown + * - Liquidation history flag + * - Market scores (future) + * + * @example + * ```tsx + * const { data, isLoading } = useMarketMetricsQuery(); + * ``` + */ +export const useMarketMetricsQuery = (params: MarketMetricsParams = {}) => { + const { chainId, sortBy, sortOrder, limit = 500, offset = 0, enabled = true } = params; + + return useQuery({ + queryKey: ['market-metrics', { chainId, sortBy, sortOrder, limit, offset }], + queryFn: () => fetchMarketMetrics({ chainId, sortBy, sortOrder, limit, offset }), + staleTime: 15 * 60 * 1000, // 15 minutes - matches API update frequency + refetchInterval: 15 * 60 * 1000, + refetchOnWindowFocus: false, // Don't refetch on focus since data is slow-changing + enabled, + }); +}; + +/** + * Returns a Map for O(1) lookup of market metrics by key. + * Key format: `${chainId}-${uniqueKey.toLowerCase()}` + * + * @example + * ```tsx + * const { metricsMap, isLoading } = useMarketMetricsMap(); + * const metrics = metricsMap.get(getMetricsKey(chainId, uniqueKey)); + * if (metrics?.everLiquidated) { ... } + * ``` + */ +export const useMarketMetricsMap = (params: MarketMetricsParams = {}) => { + const { data, isLoading, ...rest } = useMarketMetricsQuery(params); + + const metricsMap = useMemo(() => { + const map = new Map(); + if (!data?.markets) return map; + + for (const market of data.markets) { + const key = getMetricsKey(market.chainId, market.marketUniqueKey); + map.set(key, market); + } + return map; + }, [data?.markets]); + + return { metricsMap, isLoading, data, ...rest }; +}; + +/** + * Returns a Set of market keys that have ever been liquidated. + * Can be used to replace the existing useLiquidationsQuery. + */ +export const useLiquidatedMarketsSet = (params: MarketMetricsParams = {}) => { + const { metricsMap, isLoading, ...rest } = useMarketMetricsMap(params); + + const liquidatedKeys = useMemo(() => { + const keys = new Set(); + for (const [key, metrics] of metricsMap) { + if (metrics.everLiquidated) { + keys.add(key); + } + } + return keys; + }, [metricsMap]); + + return { liquidatedKeys, isLoading, ...rest }; +}; + +/** + * Convert flow assets (BigInt string) to human-readable number. + * @param flowAssets - The flow assets as BigInt string + * @param decimals - Token decimals + */ +export const parseFlowAssets = (flowAssets: string, decimals: number): number => { + return Number(flowAssets) / 10 ** decimals; +}; + +/** + * Returns top N trending markets by supply flow for a given time window. + * Filters to markets with positive inflow. + */ +export const useTrendingMarketKeys = (params: MarketMetricsParams & { timeWindow?: FlowTimeWindow; topN?: number } = {}) => { + const { timeWindow = '24h', topN = 10, ...queryParams } = params; + const { data, isLoading, ...rest } = useMarketMetricsQuery(queryParams); + + const trendingKeys = useMemo(() => { + const keys = new Set(); + if (!data?.markets) return keys; + + // Sort by supply flow for the time window, filter positive, take top N + const sorted = [...data.markets] + .filter((m) => m.flows[timeWindow]?.supplyFlowUsd > 0) + .sort((a, b) => (b.flows[timeWindow]?.supplyFlowUsd ?? 0) - (a.flows[timeWindow]?.supplyFlowUsd ?? 0)) + .slice(0, topN); + + for (const market of sorted) { + keys.add(getMetricsKey(market.chainId, market.marketUniqueKey)); + } + return keys; + }, [data?.markets, timeWindow, topN]); + + return { trendingKeys, isLoading, data, ...rest }; +}; diff --git a/src/hooks/useFilteredMarkets.ts b/src/hooks/useFilteredMarkets.ts index 738a069d..647ad4dc 100644 --- a/src/hooks/useFilteredMarkets.ts +++ b/src/hooks/useFilteredMarkets.ts @@ -5,6 +5,7 @@ import { useMarketPreferences } from '@/stores/useMarketPreferences'; import { useAppSettings } from '@/stores/useAppSettings'; import { useTrustedVaults } from '@/stores/useTrustedVaults'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; +import { useTrendingMarketKeys, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; import { filterMarkets, sortMarkets, createPropertySort, createStarredSort } from '@/utils/marketFilters'; import { SortColumn } from '@/features/markets/components/constants'; import { getVaultKey } from '@/constants/vaults/known_vaults'; @@ -40,6 +41,11 @@ export const useFilteredMarkets = (): Market[] => { const { showUnwhitelistedMarkets } = useAppSettings(); const { vaults: trustedVaults } = useTrustedVaults(); const { findToken } = useTokensQuery(); + // Fetch trending market keys from market metrics (pre-cached, 15-min stale time) + const { trendingKeys } = useTrendingMarketKeys({ + timeWindow: '24h', + topN: 10, + }); return useMemo(() => { // 1. Start with allMarkets or whitelistedMarkets based on setting @@ -86,7 +92,20 @@ export const useFilteredMarkets = (): Market[] => { }); } - // 4. Apply sorting + // 4. Filter by trending mode - show only hot markets + if (filters.trendingMode) { + // If still loading or no trending data, return empty to indicate "loading trending" + if (trendingKeys.size === 0) { + return []; + } + + markets = markets.filter((market) => { + const key = getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey); + return trendingKeys.has(key); + }); + } + + // 5. Apply sorting if (preferences.sortColumn === SortColumn.Starred) { return sortMarkets(markets, createStarredSort(preferences.starredMarkets), 1); } @@ -121,6 +140,7 @@ export const useFilteredMarkets = (): Market[] => { [SortColumn.RateAtTarget]: 'state.apyAtTarget', [SortColumn.TrustedBy]: '', [SortColumn.UtilizationRate]: 'state.utilization', + [SortColumn.Trend]: '', // Trend is a filter mode, not a sort }; const propertyPath = sortPropertyMap[preferences.sortColumn]; @@ -129,5 +149,5 @@ export const useFilteredMarkets = (): Market[] => { } return markets; - }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken]); + }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken, trendingKeys]); }; diff --git a/src/stores/useMarketsFilters.ts b/src/stores/useMarketsFilters.ts index de85fcd0..dec1daf0 100644 --- a/src/stores/useMarketsFilters.ts +++ b/src/stores/useMarketsFilters.ts @@ -15,6 +15,7 @@ type MarketsFiltersState = { selectedNetwork: SupportedNetworks | null; selectedOracles: PriceFeedVendors[]; searchQuery: string; + trendingMode: boolean; }; type MarketsFiltersActions = { @@ -23,6 +24,7 @@ type MarketsFiltersActions = { setSelectedNetwork: (network: SupportedNetworks | null) => void; setSelectedOracles: (oracles: PriceFeedVendors[]) => void; setSearchQuery: (query: string) => void; + toggleTrendingMode: () => void; resetFilters: () => void; }; @@ -34,6 +36,7 @@ const DEFAULT_STATE: MarketsFiltersState = { selectedNetwork: null, selectedOracles: [], searchQuery: '', + trendingMode: false, }; /** @@ -75,5 +78,10 @@ export const useMarketsFilters = create()((set) => ({ searchQuery: query, }), + toggleTrendingMode: () => + set((state) => ({ + trendingMode: !state.trendingMode, + })), + resetFilters: () => set(DEFAULT_STATE), })); diff --git a/src/utils/markets.ts b/src/utils/markets.ts index ca3d484c..2a48e891 100644 --- a/src/utils/markets.ts +++ b/src/utils/markets.ts @@ -21,6 +21,7 @@ export const parseNumericThreshold = (rawValue: string | undefined | null): numb export const blacklistedMarkets = [ '0x8eaf7b29f02ba8d8c1d7aeb587403dcb16e2e943e4e2f5f94b0963c2386406c9', // PAXG / USDC market with wrong oracle '0x7e79c25831c97175922df132d09b02f93103a2306b1d71e57a7714ddd4c15d13', // Relend USDC / USDC: Should be considered unrecoverable + '0x1dca6989b0d2b0a546530b3a739e91402eee2e1536a2d3ded4f5ce589a9cd1c2', // ]; // Market specially whitelisted by Monarch, lowercase diff --git a/src/utils/subgraph-types.ts b/src/utils/subgraph-types.ts index f8cbf763..5bed22e3 100644 --- a/src/utils/subgraph-types.ts +++ b/src/utils/subgraph-types.ts @@ -48,7 +48,6 @@ export type SubgraphMarket = { lltv: string; irm: Address; inputToken: SubgraphToken; - inputTokenPriceUSD: string; // BigDecimal (collateralPrice) borrowedToken: SubgraphToken; // loanAsset // note: these 2 are weird diff --git a/src/utils/types.ts b/src/utils/types.ts index 4b053b3a..504cde71 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -296,7 +296,6 @@ export type Market = { uniqueKey: string; irmAddress: string; oracleAddress: string; - collateralPrice: string; whitelisted: boolean; morphoBlue: { id: string; From fcd2fa857f364dd3cb0c765ec3d3eaa8a4937190 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 8 Jan 2026 11:23:04 +0800 Subject: [PATCH 02/12] feat: api metrics data --- app/api/monarch/metrics/route.ts | 4 + .../components/table/market-table-body.tsx | 9 +- src/hooks/queries/useMarketMetricsQuery.ts | 112 +++++++++--------- src/hooks/useFilteredMarkets.ts | 23 +--- 4 files changed, 68 insertions(+), 80 deletions(-) diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts index 410f30c3..8a858636 100644 --- a/app/api/monarch/metrics/route.ts +++ b/app/api/monarch/metrics/route.ts @@ -14,6 +14,8 @@ export async function GET(req: NextRequest) { const limit = searchParams.get('limit'); const offset = searchParams.get('offset'); + console.log('getting limit', limit, 'offset', offset); + if (chainId) params.set('chain_id', chainId); if (sortBy) params.set('sort_by', sortBy); if (sortOrder) params.set('sort_order', sortOrder); @@ -37,7 +39,9 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: response.status }); } + const data = await response.json(); + console.log('data', data.markets?.length); return NextResponse.json(data); } catch (error) { console.error('[Monarch Metrics API] Failed to fetch:', error); diff --git a/src/features/markets/components/table/market-table-body.tsx b/src/features/markets/components/table/market-table-body.tsx index 5ce174b2..97a76068 100644 --- a/src/features/markets/components/table/market-table-body.tsx +++ b/src/features/markets/components/table/market-table-body.tsx @@ -9,6 +9,7 @@ import { MarketRiskIndicators } from '@/features/markets/components/market-risk- import OracleVendorBadge from '@/features/markets/components/oracle-vendor-badge'; import { TrustedByCell } from '@/features/autovault/components/trusted-vault-badges'; import { getVaultKey, type TrustedVault } from '@/constants/vaults/known_vaults'; +import { useMarketMetricsMap, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useMarketPreferences } from '@/stores/useMarketPreferences'; @@ -28,6 +29,7 @@ type MarketTableBodyProps = { export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowId, trustedVaultMap }: MarketTableBodyProps) { const { columnVisibility, starredMarkets, starMarket, unstarMarket } = useMarketPreferences(); const { success: toastSuccess } = useStyledToast(); + const { metricsMap } = useMarketMetricsMap(); const { label: supplyRateLabel } = useRateLabel({ prefix: 'Supply' }); const { label: borrowRateLabel } = useRateLabel({ prefix: 'Borrow' }); @@ -84,7 +86,12 @@ export function MarketTableBody({ currentEntries, expandedRowId, setExpandedRowI setExpandedRowId(item.uniqueKey === expandedRowId ? null : item.uniqueKey)} + onClick={() => { + const key = getMetricsKey(item.morphoBlue.chain.id, item.uniqueKey); + const metrics = metricsMap.get(key); + console.log('[Metrics]', key, metrics ?? 'NOT FOUND'); + setExpandedRowId(item.uniqueKey === expandedRowId ? null : item.uniqueKey); + }} className={`hover:cursor-pointer ${item.uniqueKey === expandedRowId ? 'table-body-focused ' : ''}`} > => { +const PAGE_SIZE = 1000; + +const fetchMarketMetricsPage = async ( + params: MarketMetricsParams, + limit: number, + offset: number, +): Promise => { const searchParams = new URLSearchParams(); if (params.chainId !== undefined) { @@ -74,8 +78,8 @@ const fetchMarketMetrics = async (params: MarketMetricsParams): Promise => { + // First request to get total count + const firstPage = await fetchMarketMetricsPage(params, PAGE_SIZE, 0); + const allMarkets = [...firstPage.markets]; + const total = firstPage.total; + + // If we got all markets in first request, return early + if (allMarkets.length >= total) { + return { ...firstPage, markets: allMarkets }; + } + + // Fetch remaining pages in parallel + const remainingPages = Math.ceil((total - PAGE_SIZE) / PAGE_SIZE); + const pagePromises: Promise[] = []; + + for (let i = 1; i <= remainingPages; i++) { + pagePromises.push(fetchMarketMetricsPage(params, PAGE_SIZE, i * PAGE_SIZE)); + } + + const pages = await Promise.all(pagePromises); + for (const page of pages) { + allMarkets.push(...page.markets); + } + + console.log(`[Metrics] Fetched ${allMarkets.length} markets in ${remainingPages + 1} requests`); + + return { + total, + limit: total, + offset: 0, + markets: allMarkets, + }; +}; + /** * Fetches enhanced market metrics from the Monarch monitoring API. * Pre-fetched and cached for 15 minutes. @@ -102,13 +144,13 @@ const fetchMarketMetrics = async (params: MarketMetricsParams): Promise { - const { chainId, sortBy, sortOrder, limit = 500, offset = 0, enabled = true } = params; + const { chainId, sortBy, sortOrder, enabled = true } = params; return useQuery({ - queryKey: ['market-metrics', { chainId, sortBy, sortOrder, limit, offset }], - queryFn: () => fetchMarketMetrics({ chainId, sortBy, sortOrder, limit, offset }), - staleTime: 15 * 60 * 1000, // 15 minutes - matches API update frequency - refetchInterval: 15 * 60 * 1000, + queryKey: ['market-metrics', { chainId, sortBy, sortOrder }], + queryFn: () => fetchAllMarketMetrics({ chainId, sortBy, sortOrder }), + staleTime: 1 * 60 * 1000, // 15 minutes - matches API update frequency + refetchInterval: 1 * 60 * 1000, refetchOnWindowFocus: false, // Don't refetch on focus since data is slow-changing enabled, }); @@ -136,32 +178,13 @@ export const useMarketMetricsMap = (params: MarketMetricsParams = {}) => { const key = getMetricsKey(market.chainId, market.marketUniqueKey); map.set(key, market); } + console.log('[Metrics] Loaded', map.size, 'of', data.total, 'markets'); return map; - }, [data?.markets]); + }, [data?.markets, data?.total]); return { metricsMap, isLoading, data, ...rest }; }; -/** - * Returns a Set of market keys that have ever been liquidated. - * Can be used to replace the existing useLiquidationsQuery. - */ -export const useLiquidatedMarketsSet = (params: MarketMetricsParams = {}) => { - const { metricsMap, isLoading, ...rest } = useMarketMetricsMap(params); - - const liquidatedKeys = useMemo(() => { - const keys = new Set(); - for (const [key, metrics] of metricsMap) { - if (metrics.everLiquidated) { - keys.add(key); - } - } - return keys; - }, [metricsMap]); - - return { liquidatedKeys, isLoading, ...rest }; -}; - /** * Convert flow assets (BigInt string) to human-readable number. * @param flowAssets - The flow assets as BigInt string @@ -170,30 +193,3 @@ export const useLiquidatedMarketsSet = (params: MarketMetricsParams = {}) => { export const parseFlowAssets = (flowAssets: string, decimals: number): number => { return Number(flowAssets) / 10 ** decimals; }; - -/** - * Returns top N trending markets by supply flow for a given time window. - * Filters to markets with positive inflow. - */ -export const useTrendingMarketKeys = (params: MarketMetricsParams & { timeWindow?: FlowTimeWindow; topN?: number } = {}) => { - const { timeWindow = '24h', topN = 10, ...queryParams } = params; - const { data, isLoading, ...rest } = useMarketMetricsQuery(queryParams); - - const trendingKeys = useMemo(() => { - const keys = new Set(); - if (!data?.markets) return keys; - - // Sort by supply flow for the time window, filter positive, take top N - const sorted = [...data.markets] - .filter((m) => m.flows[timeWindow]?.supplyFlowUsd > 0) - .sort((a, b) => (b.flows[timeWindow]?.supplyFlowUsd ?? 0) - (a.flows[timeWindow]?.supplyFlowUsd ?? 0)) - .slice(0, topN); - - for (const market of sorted) { - keys.add(getMetricsKey(market.chainId, market.marketUniqueKey)); - } - return keys; - }, [data?.markets, timeWindow, topN]); - - return { trendingKeys, isLoading, data, ...rest }; -}; diff --git a/src/hooks/useFilteredMarkets.ts b/src/hooks/useFilteredMarkets.ts index 647ad4dc..1308704f 100644 --- a/src/hooks/useFilteredMarkets.ts +++ b/src/hooks/useFilteredMarkets.ts @@ -5,7 +5,6 @@ import { useMarketPreferences } from '@/stores/useMarketPreferences'; import { useAppSettings } from '@/stores/useAppSettings'; import { useTrustedVaults } from '@/stores/useTrustedVaults'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; -import { useTrendingMarketKeys, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; import { filterMarkets, sortMarkets, createPropertySort, createStarredSort } from '@/utils/marketFilters'; import { SortColumn } from '@/features/markets/components/constants'; import { getVaultKey } from '@/constants/vaults/known_vaults'; @@ -41,11 +40,6 @@ export const useFilteredMarkets = (): Market[] => { const { showUnwhitelistedMarkets } = useAppSettings(); const { vaults: trustedVaults } = useTrustedVaults(); const { findToken } = useTokensQuery(); - // Fetch trending market keys from market metrics (pre-cached, 15-min stale time) - const { trendingKeys } = useTrendingMarketKeys({ - timeWindow: '24h', - topN: 10, - }); return useMemo(() => { // 1. Start with allMarkets or whitelistedMarkets based on setting @@ -92,20 +86,7 @@ export const useFilteredMarkets = (): Market[] => { }); } - // 4. Filter by trending mode - show only hot markets - if (filters.trendingMode) { - // If still loading or no trending data, return empty to indicate "loading trending" - if (trendingKeys.size === 0) { - return []; - } - - markets = markets.filter((market) => { - const key = getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey); - return trendingKeys.has(key); - }); - } - - // 5. Apply sorting + // 4. Apply sorting if (preferences.sortColumn === SortColumn.Starred) { return sortMarkets(markets, createStarredSort(preferences.starredMarkets), 1); } @@ -149,5 +130,5 @@ export const useFilteredMarkets = (): Market[] => { } return markets; - }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken, trendingKeys]); + }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken]); }; From e6d49021588bcbc0d8ff9f4cd39b0c846c75c1ed Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 8 Jan 2026 15:24:26 +0800 Subject: [PATCH 03/12] feat: trending data --- .../table/markets-table-actions.tsx | 21 +++++++++- src/hooks/queries/useMarketMetricsQuery.ts | 42 +++++++++++++++++++ src/hooks/useFilteredMarkets.ts | 14 ++++++- src/stores/useMarketsFilters.ts | 21 ++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/features/markets/components/table/markets-table-actions.tsx b/src/features/markets/components/table/markets-table-actions.tsx index 99a503da..12c2ec28 100644 --- a/src/features/markets/components/table/markets-table-actions.tsx +++ b/src/features/markets/components/table/markets-table-actions.tsx @@ -6,12 +6,14 @@ import { FiSettings } from 'react-icons/fi'; import { HiOutlineFire, HiFire } from 'react-icons/hi2'; import { TbArrowAutofitWidth } from 'react-icons/tb'; import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tooltip } from '@/components/ui/tooltip'; import { TooltipContent } from '@/components/shared/tooltip-content'; import { MarketFilter } from '@/features/positions/components/markets-filter-compact'; import { useModal } from '@/hooks/useModal'; import { useMarketPreferences } from '@/stores/useMarketPreferences'; import { useMarketsFilters } from '@/stores/useMarketsFilters'; +import type { FlowTimeWindow } from '@/hooks/queries/useMarketMetricsQuery'; type MarketsTableActionsProps = { onRefresh: () => void; @@ -22,7 +24,7 @@ type MarketsTableActionsProps = { export function MarketsTableActions({ onRefresh, isRefetching, isMobile }: MarketsTableActionsProps) { const { open: openModal } = useModal(); const { tableViewMode, setTableViewMode } = useMarketPreferences(); - const { trendingMode, toggleTrendingMode } = useMarketsFilters(); + const { trendingMode, toggleTrendingMode, trendingTimeWindow, setTrendingTimeWindow } = useMarketsFilters(); const effectiveTableViewMode = isMobile ? 'compact' : tableViewMode; return ( @@ -47,6 +49,23 @@ export function MarketsTableActions({ onRefresh, isRefetching, isMobile }: Marke + {trendingMode && ( + + )} + openModal('marketSettings', {})} /> { export const parseFlowAssets = (flowAssets: string, decimals: number): number => { return Number(flowAssets) / 10 ** decimals; }; + +/** + * Determines if a market is trending based on flow thresholds. + * All criteria must be met (AND logic). + */ +export const isMarketTrending = ( + metrics: MarketMetrics, + timeWindow: FlowTimeWindow, + thresholds: TrendingThresholds, +): boolean => { + const flow = metrics.flows[timeWindow]; + + if (!flow) return false; + + return flow.supplyFlowUsd >= thresholds.minSupplyFlowUsd && + flow.borrowFlowUsd >= thresholds.minBorrowFlowUsd && + flow.individualSupplyFlowUsd >= thresholds.minIndividualSupplyFlowUsd; +}; + +/** + * Returns a Set of market keys that are currently trending. + * Uses metricsMap for O(1) lookup and filters based on trending thresholds. + */ +export const useTrendingMarketKeys = () => { + const { metricsMap } = useMarketMetricsMap(); + const { trendingTimeWindow, trendingThresholds } = useMarketsFilters(); + + console.log('trendingThresholds', trendingThresholds) + + return useMemo(() => { + const keys = new Set(); + for (const [key, metrics] of metricsMap) { + if (isMarketTrending(metrics, trendingTimeWindow, trendingThresholds)) { + console.log('market trending', key, metrics.flows[trendingTimeWindow]); + keys.add(key); + } + } + console.log(`[Trending] Found ${keys.size} trending markets (${trendingTimeWindow})`); + return keys; + }, [metricsMap, trendingTimeWindow, trendingThresholds]); +}; diff --git a/src/hooks/useFilteredMarkets.ts b/src/hooks/useFilteredMarkets.ts index 1308704f..76e0c0ae 100644 --- a/src/hooks/useFilteredMarkets.ts +++ b/src/hooks/useFilteredMarkets.ts @@ -5,6 +5,7 @@ import { useMarketPreferences } from '@/stores/useMarketPreferences'; import { useAppSettings } from '@/stores/useAppSettings'; import { useTrustedVaults } from '@/stores/useTrustedVaults'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; +import { useTrendingMarketKeys, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; import { filterMarkets, sortMarkets, createPropertySort, createStarredSort } from '@/utils/marketFilters'; import { SortColumn } from '@/features/markets/components/constants'; import { getVaultKey } from '@/constants/vaults/known_vaults'; @@ -40,6 +41,7 @@ export const useFilteredMarkets = (): Market[] => { const { showUnwhitelistedMarkets } = useAppSettings(); const { vaults: trustedVaults } = useTrustedVaults(); const { findToken } = useTokensQuery(); + const trendingKeys = useTrendingMarketKeys(); return useMemo(() => { // 1. Start with allMarkets or whitelistedMarkets based on setting @@ -86,7 +88,15 @@ export const useFilteredMarkets = (): Market[] => { }); } - // 4. Apply sorting + // 4. Filter by trending if enabled + if (filters.trendingMode && trendingKeys.size > 0) { + markets = markets.filter((market) => { + const key = getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey); + return trendingKeys.has(key); + }); + } + + // 5. Apply sorting if (preferences.sortColumn === SortColumn.Starred) { return sortMarkets(markets, createStarredSort(preferences.starredMarkets), 1); } @@ -130,5 +140,5 @@ export const useFilteredMarkets = (): Market[] => { } return markets; - }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken]); + }, [allMarkets, whitelistedMarkets, showUnwhitelistedMarkets, filters, preferences, trustedVaults, findToken, trendingKeys]); }; diff --git a/src/stores/useMarketsFilters.ts b/src/stores/useMarketsFilters.ts index dec1daf0..e32036cb 100644 --- a/src/stores/useMarketsFilters.ts +++ b/src/stores/useMarketsFilters.ts @@ -1,6 +1,13 @@ import { create } from 'zustand'; import type { SupportedNetworks } from '@/utils/networks'; import type { PriceFeedVendors } from '@/utils/oracle'; +import type { FlowTimeWindow } from '@/hooks/queries/useMarketMetricsQuery'; + +export type TrendingThresholds = { + minSupplyFlowUsd: number; + minBorrowFlowUsd: number; + minIndividualSupplyFlowUsd: number; +}; /** * Temporary filter state for markets page (resets on refresh for lightning-fast UX). @@ -16,6 +23,8 @@ type MarketsFiltersState = { selectedOracles: PriceFeedVendors[]; searchQuery: string; trendingMode: boolean; + trendingTimeWindow: FlowTimeWindow; + trendingThresholds: TrendingThresholds; }; type MarketsFiltersActions = { @@ -25,6 +34,7 @@ type MarketsFiltersActions = { setSelectedOracles: (oracles: PriceFeedVendors[]) => void; setSearchQuery: (query: string) => void; toggleTrendingMode: () => void; + setTrendingTimeWindow: (window: FlowTimeWindow) => void; resetFilters: () => void; }; @@ -37,6 +47,12 @@ const DEFAULT_STATE: MarketsFiltersState = { selectedOracles: [], searchQuery: '', trendingMode: false, + trendingTimeWindow: '24h', + trendingThresholds: { + minSupplyFlowUsd: 30000, + minBorrowFlowUsd: 20000, + minIndividualSupplyFlowUsd: 10000, + }, }; /** @@ -83,5 +99,10 @@ export const useMarketsFilters = create()((set) => ({ trendingMode: !state.trendingMode, })), + setTrendingTimeWindow: (window) => + set({ + trendingTimeWindow: window, + }), + resetFilters: () => set(DEFAULT_STATE), })); From 21234240985ad415af93deb1884d0e2cb6c14adb Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 8 Jan 2026 17:27:11 +0800 Subject: [PATCH 04/12] feat: trending --- app/settings/page.tsx | 27 ++ .../markets/components/market-indicators.tsx | 25 ++ .../table/markets-table-actions.tsx | 42 --- .../components/markets-filter-compact.tsx | 18 +- src/hooks/queries/useMarketMetricsQuery.ts | 95 ++++-- src/modals/registry.tsx | 3 + .../settings/trending-settings-modal.tsx | 282 ++++++++++++++++++ src/stores/useMarketPreferences.ts | 49 +++ src/stores/useMarketsFilters.ts | 23 +- src/stores/useModalStore.ts | 2 + 10 files changed, 472 insertions(+), 94 deletions(-) create mode 100644 src/modals/settings/trending-settings-modal.tsx diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 4230d805..ad5c030c 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -171,6 +171,33 @@ export default function SettingsPage() { + {/* Trending Markets (Beta) Section */} +
+
+

Trending Markets

+ Beta +
+ +
+
+
+

Configure Trending Criteria

+

+ Define thresholds for market flow metrics to identify trending markets. Markets meeting all criteria will show a + fire indicator. +

+
+ +
+
+
+ {/* Trusted Vaults Section */}

Trusted Vaults

diff --git a/src/features/markets/components/market-indicators.tsx b/src/features/markets/components/market-indicators.tsx index 013fbb7d..408020b9 100644 --- a/src/features/markets/components/market-indicators.tsx +++ b/src/features/markets/components/market-indicators.tsx @@ -1,9 +1,12 @@ import { Tooltip } from '@/components/ui/tooltip'; import { FaShieldAlt, FaStar, FaUser } from 'react-icons/fa'; import { FiAlertCircle } from 'react-icons/fi'; +import { AiOutlineFire } from "react-icons/ai"; import { TooltipContent } from '@/components/shared/tooltip-content'; import { useLiquidationsQuery } from '@/hooks/queries/useLiquidationsQuery'; +import { useTrendingMarketKeys, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; +import { useMarketPreferences } from '@/stores/useMarketPreferences'; import type { Market } from '@/utils/types'; import { RewardsIndicator } from '@/features/markets/components/rewards-indicator'; @@ -21,6 +24,12 @@ export function MarketIndicators({ market, showRisk = false, isStared = false, h const { data: liquidatedMarkets } = useLiquidationsQuery(); const hasLiquidationProtection = liquidatedMarkets?.has(market.uniqueKey) ?? false; + // Check trending status + const { trendingConfig } = useMarketPreferences(); + const trendingKeys = useTrendingMarketKeys(); + const isTrending = + trendingConfig.enabled && trendingKeys.has(getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey)); + // Compute risk warnings if needed const warnings = showRisk ? computeMarketWarnings(market, true) : []; const hasWarnings = warnings.length > 0; @@ -114,6 +123,22 @@ export function MarketIndicators({ market, showRisk = false, isStared = false, h whitelisted={market.whitelisted} /> + {/* Trending Indicator */} + {isTrending && ( + } + detail="This market is trending based on flow metrics" + /> + } + > +
+ +
+
+ )} + {/* Risk Warnings */} {showRisk && hasWarnings && ( void; @@ -24,48 +20,10 @@ type MarketsTableActionsProps = { export function MarketsTableActions({ onRefresh, isRefetching, isMobile }: MarketsTableActionsProps) { const { open: openModal } = useModal(); const { tableViewMode, setTableViewMode } = useMarketPreferences(); - const { trendingMode, toggleTrendingMode, trendingTimeWindow, setTrendingTimeWindow } = useMarketsFilters(); const effectiveTableViewMode = isMobile ? 'compact' : tableViewMode; return ( <> - } - title="Trending Markets" - detail={trendingMode ? 'Click to show all markets' : 'Click to show only hot markets with supply inflows'} - /> - } - > - - - - {trendingMode && ( - - )} - openModal('marketSettings', {})} />
+ {trendingConfig.enabled && ( + + + + )} => { +const fetchMarketMetricsPage = async (params: MarketMetricsParams, limit: number, offset: number): Promise => { const searchParams = new URLSearchParams(); if (params.chainId !== undefined) { @@ -197,41 +193,82 @@ export const parseFlowAssets = (flowAssets: string, decimals: number): number => /** * Determines if a market is trending based on flow thresholds. - * All criteria must be met (AND logic). + * All non-empty thresholds must be met (AND logic). + * Only positive flows (inflows) are considered. */ -export const isMarketTrending = ( - metrics: MarketMetrics, - timeWindow: FlowTimeWindow, - thresholds: TrendingThresholds, -): boolean => { - const flow = metrics.flows[timeWindow]; - - if (!flow) return false; - - return flow.supplyFlowUsd >= thresholds.minSupplyFlowUsd && - flow.borrowFlowUsd >= thresholds.minBorrowFlowUsd && - flow.individualSupplyFlowUsd >= thresholds.minIndividualSupplyFlowUsd; +export const isMarketTrending = (metrics: MarketMetrics, trendingConfig: TrendingConfig): boolean => { + if (!trendingConfig.enabled) return false; + + // Check all windows - ALL non-empty thresholds must be met + for (const [window, config] of Object.entries(trendingConfig.windows)) { + // Defensive access for potentially undefined config fields (old stored data) + const supplyPct = config?.minSupplyFlowPct ?? ''; + const supplyUsd = config?.minSupplyFlowUsd ?? ''; + const borrowPct = config?.minBorrowFlowPct ?? ''; + const borrowUsd = config?.minBorrowFlowUsd ?? ''; + + const hasSupplyThreshold = supplyPct || supplyUsd; + const hasBorrowThreshold = borrowPct || borrowUsd; + + // Skip windows with no thresholds + if (!hasSupplyThreshold && !hasBorrowThreshold) continue; + + const flow = metrics.flows[window as FlowTimeWindow]; + + // If threshold is set but flow data is missing, market doesn't qualify + if (!flow) return false; + + // Supply flow checks - use API's supplyFlowPct directly for percentage + if (supplyPct) { + const actualPct = flow.supplyFlowPct ?? 0; + if (actualPct < Number(supplyPct)) return false; + } + if (supplyUsd) { + const actualUsd = flow.supplyFlowUsd ?? 0; + if (actualUsd < Number(supplyUsd)) return false; + } + + // Borrow flow checks - compute percentage since API doesn't provide it + if (borrowPct) { + const borrowBase = metrics.currentState.borrowUsd; + const actualPct = borrowBase > 0 ? ((flow.borrowFlowUsd ?? 0) / borrowBase) * 100 : 0; + if (actualPct < Number(borrowPct)) return false; + } + if (borrowUsd) { + const actualUsd = flow.borrowFlowUsd ?? 0; + if (actualUsd < Number(borrowUsd)) return false; + } + } + + // At least one threshold must be set for the market to be considered trending + const hasAnyThreshold = Object.values(trendingConfig.windows).some((c) => { + const supplyPct = c?.minSupplyFlowPct ?? ''; + const supplyUsd = c?.minSupplyFlowUsd ?? ''; + const borrowPct = c?.minBorrowFlowPct ?? ''; + const borrowUsd = c?.minBorrowFlowUsd ?? ''; + return supplyPct || supplyUsd || borrowPct || borrowUsd; + }); + + return hasAnyThreshold; }; /** * Returns a Set of market keys that are currently trending. - * Uses metricsMap for O(1) lookup and filters based on trending thresholds. + * Uses metricsMap for O(1) lookup and filters based on trending config from preferences. */ export const useTrendingMarketKeys = () => { const { metricsMap } = useMarketMetricsMap(); - const { trendingTimeWindow, trendingThresholds } = useMarketsFilters(); - - console.log('trendingThresholds', trendingThresholds) + const { trendingConfig } = useMarketPreferences(); return useMemo(() => { const keys = new Set(); + if (!trendingConfig.enabled) return keys; + for (const [key, metrics] of metricsMap) { - if (isMarketTrending(metrics, trendingTimeWindow, trendingThresholds)) { - console.log('market trending', key, metrics.flows[trendingTimeWindow]); + if (isMarketTrending(metrics, trendingConfig)) { keys.add(key); } } - console.log(`[Trending] Found ${keys.size} trending markets (${trendingTimeWindow})`); return keys; - }, [metricsMap, trendingTimeWindow, trendingThresholds]); + }, [metricsMap, trendingConfig]); }; diff --git a/src/modals/registry.tsx b/src/modals/registry.tsx index 123e2bc0..c34d251c 100644 --- a/src/modals/registry.tsx +++ b/src/modals/registry.tsx @@ -38,6 +38,8 @@ const BlacklistedMarketsModal = lazy(() => const TrustedVaultsModal = lazy(() => import('@/modals/settings/trusted-vaults-modal')); +const TrendingSettingsModal = lazy(() => import('@/modals/settings/trending-settings-modal')); + const MarketSettingsModal = lazy(() => import('@/features/markets/components/market-settings-modal')); // Vault Operations @@ -60,6 +62,7 @@ export const MODAL_REGISTRY: { rebalanceMarketSelection: RebalanceMarketSelectionModal, marketSettings: MarketSettingsModal, trustedVaults: TrustedVaultsModal, + trendingSettings: TrendingSettingsModal, blacklistedMarkets: BlacklistedMarketsModal, vaultDeposit: VaultDepositModal, vaultWithdraw: VaultWithdrawModal, diff --git a/src/modals/settings/trending-settings-modal.tsx b/src/modals/settings/trending-settings-modal.tsx new file mode 100644 index 00000000..3377c3c6 --- /dev/null +++ b/src/modals/settings/trending-settings-modal.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useMemo } from 'react'; +import { HiFire } from 'react-icons/hi2'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { IconSwitch } from '@/components/ui/icon-switch'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; +import { MarketIdentity, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { useMarketPreferences, type FlowTimeWindow, type TrendingWindowConfig } from '@/stores/useMarketPreferences'; +import { useMarketMetricsMap, isMarketTrending, getMetricsKey } from '@/hooks/queries/useMarketMetricsQuery'; +import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; +import { formatReadable } from '@/utils/balance'; +import type { Market } from '@/utils/types'; + +type TrendingSettingsModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +const TIME_WINDOWS: { value: FlowTimeWindow; label: string }[] = [ + { value: '1h', label: '1h' }, + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, + { value: '30d', label: '30d' }, +]; + +// Generate human-readable summary of the filter +function generateFilterSummary(config: { enabled: boolean; windows: Record }): string { + if (!config.enabled) return 'Trending detection is disabled'; + + const parts: string[] = []; + + for (const { value: window, label } of TIME_WINDOWS) { + const windowConfig = config.windows?.[window]; + if (!windowConfig) continue; + + const windowParts: string[] = []; + + // Supply thresholds - defensive access for old stored data + const supplyPct = windowConfig.minSupplyFlowPct ?? ''; + const supplyUsd = windowConfig.minSupplyFlowUsd ?? ''; + const supplyParts: string[] = []; + if (supplyPct) supplyParts.push(`+${supplyPct}%`); + if (supplyUsd) supplyParts.push(`+$${formatReadable(Number(supplyUsd))}`); + if (supplyParts.length > 0) { + windowParts.push(`supply grew ${supplyParts.join(' and ')}`); + } + + // Borrow thresholds - defensive access for old stored data + const borrowPct = windowConfig.minBorrowFlowPct ?? ''; + const borrowUsd = windowConfig.minBorrowFlowUsd ?? ''; + const borrowParts: string[] = []; + if (borrowPct) borrowParts.push(`+${borrowPct}%`); + if (borrowUsd) borrowParts.push(`+$${formatReadable(Number(borrowUsd))}`); + if (borrowParts.length > 0) { + windowParts.push(`borrow grew ${borrowParts.join(' and ')}`); + } + + if (windowParts.length > 0) { + parts.push(`${windowParts.join(', ')} in ${label}`); + } + } + + if (parts.length === 0) return 'No thresholds configured'; + return `Markets where ${parts.join('; ')}`; +} + +function CompactInput({ + value, + onChange, + disabled, + prefix, + suffix, +}: { + value: string; + onChange: (v: string) => void; + disabled: boolean; + prefix?: string; + suffix?: string; +}) { + return ( +
+ {prefix && {prefix}} + onChange(e.target.value.replace(/[^0-9.]/g, ''))} + placeholder="-" + disabled={disabled} + className="font-inter h-6 w-12 px-1 text-center text-xs" + /> + {suffix && {suffix}} +
+ ); +} + +export default function TrendingSettingsModal({ isOpen, onOpenChange }: TrendingSettingsModalProps) { + const { trendingConfig, setTrendingEnabled, setTrendingWindowConfig } = useMarketPreferences(); + const { metricsMap } = useMarketMetricsMap(); + const { allMarkets } = useProcessedMarkets(); + const isEnabled = trendingConfig.enabled; + + // Compute matching markets for preview + const matchingMarkets = useMemo(() => { + if (!isEnabled || metricsMap.size === 0) return []; + + const matches: Array<{ market: Market; supplyFlowPct1h: number }> = []; + + for (const [key, metrics] of metricsMap) { + if (isMarketTrending(metrics, trendingConfig)) { + const market = allMarkets.find((m) => getMetricsKey(m.morphoBlue.chain.id, m.uniqueKey) === key); + if (market) { + matches.push({ + market, + supplyFlowPct1h: metrics.flows['1h']?.supplyFlowPct ?? 0, + }); + } + } + } + + return matches.sort((a, b) => (b.market.state?.supplyAssetsUsd ?? 0) - (a.market.state?.supplyAssetsUsd ?? 0)).slice(0, 10); + }, [isEnabled, metricsMap, trendingConfig, allMarkets]); + + const totalMatches = useMemo(() => { + if (!isEnabled || metricsMap.size === 0) return 0; + let count = 0; + for (const [, metrics] of metricsMap) { + if (isMarketTrending(metrics, trendingConfig)) count++; + } + return count; + }, [isEnabled, metricsMap, trendingConfig]); + + const handleChange = (window: FlowTimeWindow, field: keyof TrendingWindowConfig, value: string) => { + setTrendingWindowConfig(window, { [field]: value }); + }; + + const filterSummary = generateFilterSummary(trendingConfig); + + return ( + + {(onClose) => ( + <> + + Trending Markets + Beta + + } + mainIcon={} + onClose={onClose} + /> + + {/* Toggle + Summary */} +
+
+

{filterSummary}

+
+ +
+ + {/* Compact threshold table */} +
+ {/* Header */} +
+
+
Supply Flow
+
Borrow Flow
+
+ + {/* Rows */} + {TIME_WINDOWS.map(({ value: window, label }) => { + const config = trendingConfig.windows[window]; + + return ( +
+
{label}
+ + {/* Supply inputs */} +
+ handleChange(window, 'minSupplyFlowPct', v)} + disabled={!isEnabled} + suffix="%" + /> + handleChange(window, 'minSupplyFlowUsd', v.replace(/[^0-9]/g, ''))} + disabled={!isEnabled} + prefix="$" + /> +
+ + {/* Borrow inputs */} +
+ handleChange(window, 'minBorrowFlowPct', v)} + disabled={!isEnabled} + suffix="%" + /> + handleChange(window, 'minBorrowFlowUsd', v.replace(/[^0-9]/g, ''))} + disabled={!isEnabled} + prefix="$" + /> +
+
+ ); + })} +
+ + {/* Preview */} + {isEnabled && ( +
+
+ Preview + + {totalMatches > 0 ? `${totalMatches} market${totalMatches !== 1 ? 's' : ''} match` : 'No matches'} + +
+
+ {matchingMarkets.length > 0 ? ( +
+ {matchingMarkets.slice(0, 2).map((m) => ( +
+ + +{m.supplyFlowPct1h.toFixed(1)}% +
+ ))} + {totalMatches > 2 && +{totalMatches - 2} more} +
+ ) : ( + No markets match current criteria + )} +
+
+ )} + + + + + + )} + + ); +} diff --git a/src/stores/useMarketPreferences.ts b/src/stores/useMarketPreferences.ts index b8e25ca4..c8eaf4a7 100644 --- a/src/stores/useMarketPreferences.ts +++ b/src/stores/useMarketPreferences.ts @@ -4,6 +4,33 @@ import { SortColumn } from '@/features/markets/components/constants'; import { DEFAULT_MIN_SUPPLY_USD } from '@/constants/markets'; import { DEFAULT_COLUMN_VISIBILITY, type ColumnVisibility } from '@/features/markets/components/column-visibility'; +// Trending feature types +export type FlowTimeWindow = '1h' | '24h' | '7d' | '30d'; + +export type TrendingWindowConfig = { + // Supply flow thresholds (both must be met if set - AND logic) + minSupplyFlowPct: string; // e.g. "6" = 6% of current supply + minSupplyFlowUsd: string; // Absolute USD threshold + // Borrow flow thresholds (both must be met if set - AND logic) + minBorrowFlowPct: string; + minBorrowFlowUsd: string; +}; + +export type TrendingConfig = { + enabled: boolean; + windows: Record; +}; + +const DEFAULT_TRENDING_CONFIG: TrendingConfig = { + enabled: false, + windows: { + '1h': { minSupplyFlowPct: '6', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' }, + '24h': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' }, + '7d': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' }, + '30d': { minSupplyFlowPct: '', minSupplyFlowUsd: '', minBorrowFlowPct: '', minBorrowFlowUsd: '' }, + }, +}; + type MarketPreferencesState = { // Sorting sortColumn: SortColumn; @@ -31,6 +58,9 @@ type MarketPreferencesState = { minSupplyEnabled: boolean; minBorrowEnabled: boolean; minLiquidityEnabled: boolean; + + // Trending Config (Beta) + trendingConfig: TrendingConfig; }; type MarketPreferencesActions = { @@ -63,6 +93,10 @@ type MarketPreferencesActions = { setMinBorrowEnabled: (enabled: boolean) => void; setMinLiquidityEnabled: (enabled: boolean) => void; + // Trending Config (Beta) + setTrendingEnabled: (enabled: boolean) => void; + setTrendingWindowConfig: (window: FlowTimeWindow, config: Partial) => void; + // Bulk update for migration setAll: (state: Partial) => void; }; @@ -98,6 +132,7 @@ export const useMarketPreferences = create()( minSupplyEnabled: false, minBorrowEnabled: false, minLiquidityEnabled: false, + trendingConfig: DEFAULT_TRENDING_CONFIG, // Actions setSortColumn: (column) => set({ sortColumn: column }), @@ -129,6 +164,20 @@ export const useMarketPreferences = create()( setMinSupplyEnabled: (enabled) => set({ minSupplyEnabled: enabled }), setMinBorrowEnabled: (enabled) => set({ minBorrowEnabled: enabled }), setMinLiquidityEnabled: (enabled) => set({ minLiquidityEnabled: enabled }), + setTrendingEnabled: (enabled) => + set((state) => ({ + trendingConfig: { ...state.trendingConfig, enabled }, + })), + setTrendingWindowConfig: (window, config) => + set((state) => ({ + trendingConfig: { + ...state.trendingConfig, + windows: { + ...state.trendingConfig.windows, + [window]: { ...state.trendingConfig.windows[window], ...config }, + }, + }, + })), setAll: (state) => set(state), }), { diff --git a/src/stores/useMarketsFilters.ts b/src/stores/useMarketsFilters.ts index e32036cb..0d07b295 100644 --- a/src/stores/useMarketsFilters.ts +++ b/src/stores/useMarketsFilters.ts @@ -1,13 +1,6 @@ import { create } from 'zustand'; import type { SupportedNetworks } from '@/utils/networks'; import type { PriceFeedVendors } from '@/utils/oracle'; -import type { FlowTimeWindow } from '@/hooks/queries/useMarketMetricsQuery'; - -export type TrendingThresholds = { - minSupplyFlowUsd: number; - minBorrowFlowUsd: number; - minIndividualSupplyFlowUsd: number; -}; /** * Temporary filter state for markets page (resets on refresh for lightning-fast UX). @@ -22,9 +15,7 @@ type MarketsFiltersState = { selectedNetwork: SupportedNetworks | null; selectedOracles: PriceFeedVendors[]; searchQuery: string; - trendingMode: boolean; - trendingTimeWindow: FlowTimeWindow; - trendingThresholds: TrendingThresholds; + trendingMode: boolean; // Filter toggle - thresholds are in useMarketPreferences }; type MarketsFiltersActions = { @@ -34,7 +25,6 @@ type MarketsFiltersActions = { setSelectedOracles: (oracles: PriceFeedVendors[]) => void; setSearchQuery: (query: string) => void; toggleTrendingMode: () => void; - setTrendingTimeWindow: (window: FlowTimeWindow) => void; resetFilters: () => void; }; @@ -47,12 +37,6 @@ const DEFAULT_STATE: MarketsFiltersState = { selectedOracles: [], searchQuery: '', trendingMode: false, - trendingTimeWindow: '24h', - trendingThresholds: { - minSupplyFlowUsd: 30000, - minBorrowFlowUsd: 20000, - minIndividualSupplyFlowUsd: 10000, - }, }; /** @@ -99,10 +83,5 @@ export const useMarketsFilters = create()((set) => ({ trendingMode: !state.trendingMode, })), - setTrendingTimeWindow: (window) => - set({ - trendingTimeWindow: window, - }), - resetFilters: () => set(DEFAULT_STATE), })); diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts index 641932ed..66d80a57 100644 --- a/src/stores/useModalStore.ts +++ b/src/stores/useModalStore.ts @@ -51,6 +51,8 @@ export type ModalProps = { trustedVaults: Record; // No props needed - uses useTrustedVaults() store + trendingSettings: Record; // No props needed - uses useMarketPreferences() store + blacklistedMarkets: Record; // No props needed - uses useProcessedMarkets() context // Vault Operations From d3930fa5db743e1f5f25036dcc77613f0fbda470 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Thu, 8 Jan 2026 18:06:38 +0800 Subject: [PATCH 05/12] feat: trend configs --- app/api/monarch/metrics/route.ts | 1 - app/settings/page.tsx | 4 ++-- .../markets/components/market-indicators.tsx | 17 ++++++++++++----- src/modals/settings/trending-settings-modal.tsx | 11 +++-------- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts index 8a858636..09366285 100644 --- a/app/api/monarch/metrics/route.ts +++ b/app/api/monarch/metrics/route.ts @@ -39,7 +39,6 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: response.status }); } - const data = await response.json(); console.log('data', data.markets?.length); return NextResponse.json(data); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ad5c030c..a136f764 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -183,8 +183,8 @@ export default function SettingsPage() {

Configure Trending Criteria

- Define thresholds for market flow metrics to identify trending markets. Markets meeting all criteria will show a - fire indicator. + Define thresholds for market flow metrics to identify trending markets. Markets meeting all criteria will show a fire + indicator.

- {/* Filter Settings Section */}

Filter Settings

- {/* Group related settings with a subtle separator */}

Show Unknown Tokens

@@ -171,7 +164,6 @@ export default function SettingsPage() {
- {/* Trending Markets (Beta) Section */}

Trending Markets

@@ -198,7 +190,6 @@ export default function SettingsPage() {
- {/* Trusted Vaults Section */}

Trusted Vaults

@@ -220,7 +211,6 @@ export default function SettingsPage() {
- {/* Display trusted vault icons */}
{mounted ? ( @@ -257,7 +247,6 @@ export default function SettingsPage() {
- {/* Blacklisted Markets Section */}

Blacklisted Markets

@@ -280,7 +269,6 @@ export default function SettingsPage() {
- {/* Advanced Section */}
diff --git a/src/data-sources/morpho-api/liquidations.ts b/src/data-sources/morpho-api/liquidations.ts index 71487d11..1fa0ef33 100644 --- a/src/data-sources/morpho-api/liquidations.ts +++ b/src/data-sources/morpho-api/liquidations.ts @@ -9,7 +9,6 @@ import type { SupportedNetworks } from '@/utils/networks'; import { URLS } from '@/utils/urls'; -// Re-use the query structure from the original hook const liquidationsQuery = ` query getLiquidations($first: Int, $skip: Int, $chainId: Int!) { transactions( @@ -84,7 +83,6 @@ export const fetchMorphoApiLiquidatedMarketKeys = async (network: SupportedNetwo const result = (await response.json()) as QueryResult; - // Check for GraphQL errors if (result.errors) { console.error('GraphQL errors:', result.errors); throw new Error(`GraphQL error fetching liquidations for network ${network}`); @@ -92,32 +90,27 @@ export const fetchMorphoApiLiquidatedMarketKeys = async (network: SupportedNetwo if (!result.data?.transactions) { console.warn(`No transactions data found for network ${network} at skip ${skip}`); - break; // Exit loop if data structure is unexpected + break; } - const liquidations = result.data.transactions.items; - const pageInfo = result.data.transactions.pageInfo; + const { items, pageInfo } = result.data.transactions; - liquidations.forEach((tx) => { + for (const tx of items) { if (tx.data?.market?.uniqueKey) { liquidatedKeys.add(tx.data.market.uniqueKey); } - }); + } totalCount = pageInfo.countTotal; skip += pageInfo.count; - // Safety break if pageInfo.count is 0 to prevent infinite loop - if (pageInfo.count === 0 && skip < totalCount) { - console.warn('Received 0 items in a page, but not yet at total count. Breaking loop.'); - break; - } + if (pageInfo.count === 0 && skip < totalCount) break; } while (skip < totalCount); } catch (error) { console.error(`Error fetching liquidations via Morpho API for network ${network}:`, error); - throw error; // Re-throw the error to be handled by the calling hook + throw error; } - console.log(`Fetched ${liquidatedKeys.size} liquidated market keys via Morpho API for ${network}.`); + console.log(`[Morpho API] Fetched ${liquidatedKeys.size} liquidated market keys for ${network}`); return liquidatedKeys; }; diff --git a/src/data-sources/subgraph/liquidations.ts b/src/data-sources/subgraph/liquidations.ts index 746bad0f..7060d8bd 100644 --- a/src/data-sources/subgraph/liquidations.ts +++ b/src/data-sources/subgraph/liquidations.ts @@ -33,9 +33,6 @@ export const fetchSubgraphLiquidatedMarketKeys = async (network: SupportedNetwor } const liquidatedKeys = new Set(); - - // Apply the same base filters as fetchSubgraphMarkets - // paginate until the API returns < pageSize items const pageSize = 1000; let skip = 0; while (true) { @@ -68,12 +65,11 @@ export const fetchSubgraphLiquidatedMarketKeys = async (network: SupportedNetwor break; // Exit loop if no markets are returned } - markets.forEach((market) => { - // If the liquidates array has items, this market has had liquidations - if (market.liquidates && market.liquidates.length > 0) { + for (const market of markets) { + if (market.liquidates?.length > 0) { liquidatedKeys.add(market.id); } - }); + } if (markets.length < pageSize) { break; // Exit loop if the number of returned markets is less than the page size diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index a45ffbf8..3bbdb786 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -90,8 +90,6 @@ const transformSubgraphMarketToMarket = ( const totalBorrowShares = subgraphMarket.totalBorrowShares ?? '0'; const fee = subgraphMarket.fee ?? '0'; - // Define the estimation helper *inside* the transform function - // so it has access to majorPrices const getEstimateValue = (token: ERC20Token | UnknownERC20Token): number | undefined => { if (!('peg' in token) || token.peg === undefined) { return undefined; @@ -100,7 +98,6 @@ const transformSubgraphMarketToMarket = ( if (peg === TokenPeg.USD) { return 1; } - // Access majorPrices from the outer function's scope return majorPrices[peg]; }; @@ -117,7 +114,6 @@ const transformSubgraphMarketToMarket = ( const chainId = network; - // @todo: might update due to input token being used here const supplyAssets = subgraphMarket.totalSupply ?? subgraphMarket.inputTokenBalance ?? '0'; const borrowAssets = subgraphMarket.totalBorrow ?? subgraphMarket.variableBorrowedTokenBalance ?? '0'; const collateralAssets = subgraphMarket.totalCollateral ?? '0'; @@ -131,13 +127,10 @@ const transformSubgraphMarketToMarket = ( const supplyApy = Number(subgraphMarket.rates?.find((r) => r.side === 'LENDER')?.rate ?? 0); const borrowApy = Number(subgraphMarket.rates?.find((r) => r.side === 'BORROWER')?.rate ?? 0); - const warnings: MarketWarning[] = []; // Initialize warnings + const warnings: MarketWarning[] = []; - // get the prices let loanAssetPrice = safeParseFloat(subgraphMarket.borrowedToken?.lastPriceUSD ?? '0'); let collateralAssetPrice = safeParseFloat(subgraphMarket.inputToken?.lastPriceUSD ?? '0'); - - // @todo: might update due to input token being used here const hasUSDPrice = loanAssetPrice > 0 && collateralAssetPrice > 0; const knownLoadAsset = findToken(loanAsset.address, network); @@ -172,55 +165,39 @@ const transformSubgraphMarketToMarket = ( const marketDetail = { id: marketId, uniqueKey: marketId, - lltv: lltv, + lltv, irmAddress: irmAddress as Address, - whitelisted: true, // All subgraph markets are considered whitelisted - loanAsset: loanAsset, - collateralAsset: collateralAsset, + whitelisted: true, + loanAsset, + collateralAsset, state: { - // assets - borrowAssets: borrowAssets, - supplyAssets: supplyAssets, - liquidityAssets: liquidityAssets, - collateralAssets: collateralAssets, - // shares + borrowAssets, + supplyAssets, + liquidityAssets, + collateralAssets, borrowShares: totalBorrowShares, supplyShares: totalSupplyShares, - // usd - borrowAssetsUsd: borrowAssetsUsd, - supplyAssetsUsd: supplyAssetsUsd, - liquidityAssetsUsd: liquidityAssetsUsd, - collateralAssetsUsd: collateralAssetsUsd, - - utilization: utilization, - supplyApy: supplyApy, - borrowApy: borrowApy, - fee: safeParseFloat(fee) / 10_000, // Subgraph fee is likely basis points (needs verification) - timestamp: timestamp, - - // AdaptiveCurveIRM APY if utilization was at target - apyAtTarget: 0, // Not available from subgraph - - // AdaptiveCurveIRM rate per second if utilization was at target - rateAtTarget: '0', // Not available from subgraph + borrowAssetsUsd, + supplyAssetsUsd, + liquidityAssetsUsd, + collateralAssetsUsd, + utilization, + supplyApy, + borrowApy, + fee: safeParseFloat(fee) / 10_000, + timestamp, + apyAtTarget: 0, + rateAtTarget: '0', }, - oracleAddress: oracleAddress, + oracleAddress, morphoBlue: { id: subgraphMarket.protocol?.id ?? '0x', address: subgraphMarket.protocol?.id ?? '0x', - chain: { - id: chainId, - }, - }, - warnings: warnings, // Assign the potentially filtered warnings - hasUSDPrice: hasUSDPrice, - - // todo: not able to parse bad debt now - realizedBadDebt: { - underlying: '0', + chain: { id: chainId }, }, - - // todo: no way to parse supplying vaults now + warnings, + hasUSDPrice, + realizedBadDebt: { underlying: '0' }, supplyingVaults: [], }; @@ -258,17 +235,14 @@ export const fetchSubgraphMarket = async (uniqueKey: string, network: SupportedN } }; -// Define type for GraphQL variables type SubgraphMarketsVariables = { first: number; where?: { inputToken_not_in?: string[]; - // Add other potential filter fields here if needed }; - network?: string; // Keep network optional if sometimes omitted + network?: string; }; -// Fetcher for multiple markets from Subgraph export const fetchSubgraphMarkets = async (network: SupportedNetworks): Promise => { const subgraphApiUrl = getSubgraphUrl(network); @@ -277,7 +251,6 @@ export const fetchSubgraphMarkets = async (network: SupportedNetworks): Promise< return []; } - // Construct variables for the query, adding blacklistTokens const variables: SubgraphMarketsVariables = { first: 1000, // Max limit where: { diff --git a/src/features/markets/components/market-indicators.tsx b/src/features/markets/components/market-indicators.tsx index 23e7eafb..2945dc78 100644 --- a/src/features/markets/components/market-indicators.tsx +++ b/src/features/markets/components/market-indicators.tsx @@ -19,15 +19,10 @@ type MarketIndicatorsProps = { }; export function MarketIndicators({ market, showRisk = false, isStared = false, hasUserPosition = false }: MarketIndicatorsProps) { - // Check liquidation protection status (uses Monarch Metrics API with fallback) const hasLiquidationProtection = useEverLiquidated(market.morphoBlue.chain.id, market.uniqueKey); - - // Check trending status const { trendingConfig } = useMarketPreferences(); const trendingKeys = useTrendingMarketKeys(); const isTrending = trendingConfig.enabled && trendingKeys.has(getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey)); - - // Compute risk warnings if needed const warnings = showRisk ? computeMarketWarnings(market, true) : []; const hasWarnings = warnings.length > 0; const alertWarning = warnings.find((w) => w.level === 'alert'); @@ -35,7 +30,6 @@ export function MarketIndicators({ market, showRisk = false, isStared = false, h return (
- {/* Personal Indicators */} {isStared && ( )} - {/* Universal Indicators */} {hasLiquidationProtection && ( )} - {/* {market.isMonarchWhitelisted && ( - } - detail="This market is recognized by Monarch" - /> - } - > -
- Monarch -
-
- )} */} - - {/* Trending Indicator */} {isTrending && ( )} - {/* Risk Warnings */} {showRisk && hasWarnings && ( { const { enabled = true } = options; @@ -51,7 +32,6 @@ export const useLiquidationsQuery = (options: { enabled?: boolean } = {}) => { let networkLiquidatedKeys: Set; let trySubgraph = false; - // Try Morpho API first if supported if (supportsMorphoApi(network)) { try { console.log(`Attempting to fetch liquidated markets via Morpho API for ${network}`); @@ -66,7 +46,6 @@ export const useLiquidationsQuery = (options: { enabled?: boolean } = {}) => { trySubgraph = true; } - // If Morpho API failed or not supported, try Subgraph if (trySubgraph) { try { console.log(`Attempting to fetch liquidated markets via Subgraph for ${network}`); @@ -77,8 +56,9 @@ export const useLiquidationsQuery = (options: { enabled?: boolean } = {}) => { } } - // Add the keys to the combined set - networkLiquidatedKeys.forEach((key) => combinedLiquidatedKeys.add(key)); + for (const key of networkLiquidatedKeys) { + combinedLiquidatedKeys.add(key); + } } catch (networkError) { console.error(`Failed to fetch liquidated markets for network ${network}:`, networkError); fetchErrors.push(networkError); @@ -86,15 +66,14 @@ export const useLiquidationsQuery = (options: { enabled?: boolean } = {}) => { }), ); - // If any network fetch failed, log but still return what we got if (fetchErrors.length > 0) { console.warn(`Failed to fetch liquidations from ${fetchErrors.length} network(s)`, fetchErrors[0]); } return combinedLiquidatedKeys; }, - staleTime: 10 * 60 * 1000, // Data is fresh for 10 minutes - refetchInterval: 10 * 60 * 1000, // Auto-refetch every 10 minutes - refetchOnWindowFocus: true, // Refetch when user returns to tab + staleTime: 10 * 60 * 1000, + refetchInterval: 10 * 60 * 1000, + refetchOnWindowFocus: true, }); }; diff --git a/src/hooks/queries/useMarketMetricsQuery.ts b/src/hooks/queries/useMarketMetricsQuery.ts index 303a336d..6f6ad70f 100644 --- a/src/hooks/queries/useMarketMetricsQuery.ts +++ b/src/hooks/queries/useMarketMetricsQuery.ts @@ -201,9 +201,7 @@ export const parseFlowAssets = (flowAssets: string, decimals: number): number => export const isMarketTrending = (metrics: MarketMetrics, trendingConfig: TrendingConfig): boolean => { if (!trendingConfig.enabled) return false; - // Check all windows - ALL non-empty thresholds must be met for (const [window, config] of Object.entries(trendingConfig.windows)) { - // Defensive access for potentially undefined config fields (old stored data) const supplyPct = config?.minSupplyFlowPct ?? ''; const supplyUsd = config?.minSupplyFlowUsd ?? ''; const borrowPct = config?.minBorrowFlowPct ?? ''; @@ -212,15 +210,11 @@ export const isMarketTrending = (metrics: MarketMetrics, trendingConfig: Trendin const hasSupplyThreshold = supplyPct || supplyUsd; const hasBorrowThreshold = borrowPct || borrowUsd; - // Skip windows with no thresholds if (!hasSupplyThreshold && !hasBorrowThreshold) continue; const flow = metrics.flows[window as FlowTimeWindow]; - - // If threshold is set but flow data is missing, market doesn't qualify if (!flow) return false; - // Supply flow checks - use API's supplyFlowPct directly for percentage if (supplyPct) { const actualPct = flow.supplyFlowPct ?? 0; if (actualPct < Number(supplyPct)) return false; @@ -230,7 +224,6 @@ export const isMarketTrending = (metrics: MarketMetrics, trendingConfig: Trendin if (actualUsd < Number(supplyUsd)) return false; } - // Borrow flow checks - compute percentage since API doesn't provide it if (borrowPct) { const borrowBase = metrics.currentState.borrowUsd; const actualPct = borrowBase > 0 ? ((flow.borrowFlowUsd ?? 0) / borrowBase) * 100 : 0; @@ -242,7 +235,6 @@ export const isMarketTrending = (metrics: MarketMetrics, trendingConfig: Trendin } } - // At least one threshold must be set for the market to be considered trending const hasAnyThreshold = Object.values(trendingConfig.windows).some((c) => { const supplyPct = c?.minSupplyFlowPct ?? ''; const supplyUsd = c?.minSupplyFlowUsd ?? ''; @@ -275,7 +267,6 @@ export const useTrendingMarketKeys = () => { }, [metricsMap, trendingConfig]); }; -// Staleness threshold for liquidations data (2 hour 5 min) const LIQUIDATIONS_STALE_THRESHOLD_MS = (2 * 60 + 5) * 60 * 1000; /** @@ -287,29 +278,17 @@ const LIQUIDATIONS_STALE_THRESHOLD_MS = (2 * 60 + 5) * 60 * 1000; * once Monarch API stability is confirmed. */ export const useEverLiquidated = (chainId: number, uniqueKey: string): boolean => { - // Primary: Monarch API liquidations endpoint const { liquidatedKeys, lastUpdatedAt, isLoading } = useMonarchLiquidatedKeys(); - - // Check if data is stale (>1 hour old) const isStale = lastUpdatedAt * 1000 < Date.now() - LIQUIDATIONS_STALE_THRESHOLD_MS; - - // Only enable fallback AFTER Monarch query completes AND data is stale/missing - // This prevents triggering fallback on first render when data hasn't loaded yet const needsFallback = !isLoading && (isStale || liquidatedKeys.size === 0); - // @deprecated_fallback - Only fetch if Monarch data is stale/empty const { data: fallbackKeys } = useLiquidationsQuery({ enabled: needsFallback }); return useMemo(() => { const key = `${chainId}-${uniqueKey.toLowerCase()}`; - - // Use Monarch data if fresh and available if (!needsFallback) { return liquidatedKeys.has(key); } - - // Fallback to old Morpho API - // @deprecated_fallback - Remove after Monarch API stability confirmed return fallbackKeys?.has(uniqueKey) ?? false; }, [liquidatedKeys, needsFallback, chainId, uniqueKey, fallbackKeys]); }; diff --git a/src/hooks/useFilteredMarkets.ts b/src/hooks/useFilteredMarkets.ts index 76e0c0ae..5c3d2dd6 100644 --- a/src/hooks/useFilteredMarkets.ts +++ b/src/hooks/useFilteredMarkets.ts @@ -11,29 +11,6 @@ import { SortColumn } from '@/features/markets/components/constants'; import { getVaultKey } from '@/constants/vaults/known_vaults'; import type { Market } from '@/utils/types'; -/** - * Combines processed markets with all active filters and sorting preferences. - * - * Data Flow: - * 1. Get processed markets (already blacklist filtered + oracle enriched) - * 2. Apply whitelist setting (show all or whitelisted only) - * 3. Apply user filters (network, assets, USD thresholds, search) - * 4. Filter by trusted vaults if enabled - * 5. Apply sorting (starred, property-based, etc.) - * - * Reactivity: - * - Automatically recomputes when processed data changes (refetch, blacklist) - * - Automatically recomputes when any filter/preference changes - * - No manual synchronization needed! - * - * @returns Filtered and sorted markets ready for display - * - * @example - * ```tsx - * const filteredMarkets = useFilteredMarkets(); - * // Use in table - automatically updates when data or filters change - * ``` - */ export const useFilteredMarkets = (): Market[] => { const { allMarkets, whitelistedMarkets } = useProcessedMarkets(); const filters = useMarketsFilters(); @@ -44,12 +21,9 @@ export const useFilteredMarkets = (): Market[] => { const trendingKeys = useTrendingMarketKeys(); return useMemo(() => { - // 1. Start with allMarkets or whitelistedMarkets based on setting let markets = showUnwhitelistedMarkets ? allMarkets : whitelistedMarkets; - if (markets.length === 0) return []; - // 2. Apply all filters (network, assets, USD thresholds, search, etc.) markets = filterMarkets(markets, { selectedNetwork: filters.selectedNetwork, showUnknownTokens: preferences.includeUnknownTokens, @@ -75,7 +49,6 @@ export const useFilteredMarkets = (): Market[] => { searchQuery: filters.searchQuery, }); - // 3. Filter by trusted vaults if enabled if (preferences.trustedVaultsOnly) { const trustedVaultKeys = new Set(trustedVaults.map((vault) => getVaultKey(vault.address, vault.chainId))); markets = markets.filter((market) => { @@ -88,7 +61,6 @@ export const useFilteredMarkets = (): Market[] => { }); } - // 4. Filter by trending if enabled if (filters.trendingMode && trendingKeys.size > 0) { markets = markets.filter((market) => { const key = getMetricsKey(market.morphoBlue.chain.id, market.uniqueKey); @@ -96,13 +68,11 @@ export const useFilteredMarkets = (): Market[] => { }); } - // 5. Apply sorting if (preferences.sortColumn === SortColumn.Starred) { return sortMarkets(markets, createStarredSort(preferences.starredMarkets), 1); } if (preferences.sortColumn === SortColumn.TrustedBy) { - // Custom sort for trusted vaults const trustedVaultKeys = new Set(trustedVaults.map((vault) => getVaultKey(vault.address, vault.chainId))); return sortMarkets( markets, @@ -117,7 +87,6 @@ export const useFilteredMarkets = (): Market[] => { ); } - // Property-based sorting const sortPropertyMap: Record = { [SortColumn.Starred]: 'uniqueKey', [SortColumn.LoanAsset]: 'loanAsset.name', diff --git a/src/modals/settings/trending-settings-modal.tsx b/src/modals/settings/trending-settings-modal.tsx index 248fec43..54e4334a 100644 --- a/src/modals/settings/trending-settings-modal.tsx +++ b/src/modals/settings/trending-settings-modal.tsx @@ -1,5 +1,4 @@ 'use client'; - import { useMemo } from 'react'; import { HiFire } from 'react-icons/hi2'; import { Button } from '@/components/ui/button'; @@ -25,7 +24,6 @@ const TIME_WINDOWS: { value: FlowTimeWindow; label: string }[] = [ { value: '30d', label: '30d' }, ]; -// Generate human-readable summary of the filter function generateFilterSummary(config: { enabled: boolean; windows: Record }): string { if (!config.enabled) return 'Trending detection is disabled'; @@ -128,9 +126,7 @@ export default function TrendingSettingsModal({ isOpen, onOpenChange }: Trending return matches.sort((a, b) => (b.market.state?.supplyAssetsUsd ?? 0) - (a.market.state?.supplyAssetsUsd ?? 0)); }, [isEnabled, metricsMap, trendingConfig, allMarkets]); - const totalMatches = useMemo(() => { - return matchingMarkets.length; - }, [isEnabled, metricsMap, trendingConfig]); + const totalMatches = matchingMarkets.length; const handleChange = (window: FlowTimeWindow, field: keyof TrendingWindowConfig, value: string) => { setTrendingWindowConfig(window, { [field]: value }); diff --git a/src/stores/useMarketPreferences.ts b/src/stores/useMarketPreferences.ts index c8eaf4a7..76ef356e 100644 --- a/src/stores/useMarketPreferences.ts +++ b/src/stores/useMarketPreferences.ts @@ -116,7 +116,6 @@ type MarketPreferencesStore = MarketPreferencesState & MarketPreferencesActions; export const useMarketPreferences = create()( persist( (set, get) => ({ - // Default state sortColumn: SortColumn.Supply, sortDirection: -1, entriesPerPage: 8, @@ -134,7 +133,6 @@ export const useMarketPreferences = create()( minLiquidityEnabled: false, trendingConfig: DEFAULT_TRENDING_CONFIG, - // Actions setSortColumn: (column) => set({ sortColumn: column }), setSortDirection: (direction) => set({ sortDirection: direction }), setEntriesPerPage: (count) => set({ entriesPerPage: count }), diff --git a/src/stores/useMarketsFilters.ts b/src/stores/useMarketsFilters.ts index 0d07b295..e0797469 100644 --- a/src/stores/useMarketsFilters.ts +++ b/src/stores/useMarketsFilters.ts @@ -49,39 +49,14 @@ const DEFAULT_STATE: MarketsFiltersState = { * ``` */ export const useMarketsFilters = create()((set) => ({ - // Default state ...DEFAULT_STATE, - // Actions - setSelectedCollaterals: (collaterals) => - set({ - selectedCollaterals: [...new Set(collaterals)], // Remove duplicates - }), - - setSelectedLoanAssets: (assets) => - set({ - selectedLoanAssets: [...new Set(assets)], // Remove duplicates - }), - - setSelectedNetwork: (network) => - set({ - selectedNetwork: network, - }), - - setSelectedOracles: (oracles) => - set({ - selectedOracles: oracles, - }), - - setSearchQuery: (query) => - set({ - searchQuery: query, - }), - - toggleTrendingMode: () => - set((state) => ({ - trendingMode: !state.trendingMode, - })), + setSelectedCollaterals: (collaterals) => set({ selectedCollaterals: [...new Set(collaterals)] }), + setSelectedLoanAssets: (assets) => set({ selectedLoanAssets: [...new Set(assets)] }), + setSelectedNetwork: (network) => set({ selectedNetwork: network }), + setSelectedOracles: (oracles) => set({ selectedOracles: oracles }), + setSearchQuery: (query) => set({ searchQuery: query }), + toggleTrendingMode: () => set((state) => ({ trendingMode: !state.trendingMode })), resetFilters: () => set(DEFAULT_STATE), })); diff --git a/src/utils/subgraph-types.ts b/src/utils/subgraph-types.ts index 5bed22e3..4eb182b4 100644 --- a/src/utils/subgraph-types.ts +++ b/src/utils/subgraph-types.ts @@ -1,15 +1,13 @@ import type { Address } from 'viem'; -// Corresponds to tokenFragment export type SubgraphToken = { - id: Address; // address + id: Address; name: string; symbol: string; decimals: number; - lastPriceUSD: string | null; // BigDecimal represented as string + lastPriceUSD: string | null; }; -// Corresponds to oracleFragment export type SubgraphOracle = { id: string; oracleAddress: Address; @@ -18,24 +16,21 @@ export type SubgraphOracle = { isUSD: boolean; }; -// Corresponds to InterestRate type within marketFragment export type SubgraphInterestRate = { id: string; - rate: string; // BigDecimal represented as string (APY percentage) + rate: string; side: 'LENDER' | 'BORROWER'; type: 'STABLE' | 'VARIABLE' | 'FIXED'; }; -// Corresponds to protocol details within marketFragment export type SubgraphProtocolInfo = { id: string; - network: string; // e.g., "MAINNET", "BASE" - protocol: string; // e.g., "Morpho Blue" + network: string; + protocol: string; }; -// Corresponds to the main marketFragment (SubgraphMarketFields) export type SubgraphMarket = { - id: Address; // uniqueKey (market address) + id: Address; name: string; isActive: boolean; canBorrowFrom: boolean; @@ -48,17 +43,15 @@ export type SubgraphMarket = { lltv: string; irm: Address; inputToken: SubgraphToken; - borrowedToken: SubgraphToken; // loanAsset - - // note: these 2 are weird - variableBorrowedTokenBalance: string | null; // updated as total Borrowed - inputTokenBalance: string; // updated as total Supply + borrowedToken: SubgraphToken; + variableBorrowedTokenBalance: string | null; + inputTokenBalance: string; totalValueLockedUSD: string; totalDepositBalanceUSD: string; totalBorrowBalanceUSD: string; totalSupplyShares: string; - totalBorrowShares: string; // BigInt (borrowShares) + totalBorrowShares: string; totalSupply: string; totalBorrow: string; @@ -73,7 +66,6 @@ export type SubgraphMarket = { protocol: SubgraphProtocolInfo; }; -// Type for the GraphQL response structure using marketsQuery export type SubgraphMarketsQueryResponse = { data: { markets: SubgraphMarket[]; @@ -81,7 +73,6 @@ export type SubgraphMarketsQueryResponse = { errors?: { message: string }[]; }; -// Type for a single market response (if we adapt query later) export type SubgraphMarketQueryResponse = { data: { market: SubgraphMarket | null; diff --git a/src/utils/types.ts b/src/utils/types.ts index 504cde71..1aeaede0 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -108,7 +108,6 @@ export type TokenInfo = { decimals: number; }; -// Common types type AssetType = { id: string; address: string; @@ -122,7 +121,6 @@ export type RewardAmount = { claimed: string; }; -// Market Program Type export type MarketRewardType = { // shared type: 'market-reward'; @@ -143,7 +141,6 @@ export type MarketRewardType = { }; }; -// Uniform Reward Type export type UniformRewardType = { // shared type: 'uniform-reward'; @@ -179,7 +176,6 @@ export type VaultProgramType = { id: string; }; -// Combined RewardResponseType export type RewardResponseType = MarketRewardType | UniformRewardType | VaultRewardType; export type AggregatedRewardType = { @@ -247,7 +243,6 @@ export type GroupedPosition = { allWarnings: WarningWithDetail[]; }; -// Add these new types export type OracleFeed = { address: string; chain: { @@ -289,7 +284,6 @@ export type OraclesQueryResponse = { errors?: { message: string }[]; }; -// Update the Market type export type Market = { id: string; lltv: string; @@ -323,10 +317,7 @@ export type Market = { fee: number; timestamp: number; - // AdaptiveCurveIRM APY if utilization was at target apyAtTarget: number; - - // AdaptiveCurveIRM rate per second if utilization was at target rateAtTarget: string; }; realizedBadDebt: { @@ -335,7 +326,6 @@ export type Market = { supplyingVaults?: { address: string; }[]; - // whether we have USD price such has supplyUSD, borrowUSD, collateralUSD, etc. If not, use estimationP hasUSDPrice: boolean; warnings: MarketWarning[]; oracle?: { @@ -354,7 +344,6 @@ export type TimeseriesOptions = { interval: 'HOUR' | 'DAY' | 'WEEK' | 'MONTH'; }; -// Export MarketRates and MarketVolumes export type MarketRates = { supplyApy: TimeseriesDataPoint[]; borrowApy: TimeseriesDataPoint[]; @@ -401,7 +390,6 @@ export type AgentMetadata = { image: string; }; -// Define the comprehensive Market Activity Transaction type export type MarketActivityTransaction = { type: 'MarketSupply' | 'MarketWithdraw' | 'MarketBorrow' | 'MarketRepay'; hash: string; @@ -410,13 +398,11 @@ export type MarketActivityTransaction = { userAddress: string; // Unified field for user address }; -// Paginated result type for market activity transactions export type PaginatedMarketActivityTransactions = { items: MarketActivityTransaction[]; totalCount: number; }; -// Type for Liquidation Transactions (Simplified based on original hook) export type MarketLiquidationTransaction = { type: 'MarketLiquidation'; hash: string; @@ -427,28 +413,22 @@ export type MarketLiquidationTransaction = { badDebtAssets: string; }; -// Type for Market Supplier (current position state, not historical transactions) -// Only stores shares - assets can be calculated from shares using market state export type MarketSupplier = { userAddress: string; supplyShares: string; }; -// Paginated result type for market suppliers export type PaginatedMarketSuppliers = { items: MarketSupplier[]; totalCount: number; }; -// Type for Market Borrower (current position state, not historical transactions) -// Stores borrowAssets and collateral - shares can be calculated if needed export type MarketBorrower = { userAddress: string; borrowAssets: string; collateral: string; }; -// Paginated result type for market borrowers export type PaginatedMarketBorrowers = { items: MarketBorrower[]; totalCount: number; From 7890da271da30519bcabbc090b5c1dd20cf5f10e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 9 Jan 2026 16:09:55 +0800 Subject: [PATCH 10/12] misc: review feedback --- app/api/monarch/liquidations/route.ts | 6 ++---- src/hooks/queries/useMarketMetricsQuery.ts | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/api/monarch/liquidations/route.ts b/app/api/monarch/liquidations/route.ts index 93570f37..26135f65 100644 --- a/app/api/monarch/liquidations/route.ts +++ b/app/api/monarch/liquidations/route.ts @@ -12,10 +12,8 @@ export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; const chainId = searchParams.get('chain_id'); - const params = new URLSearchParams(); - if (chainId) params.set('chain_id', chainId); - - const url = `${MONARCH_API_ENDPOINT}/v1/liquidations?${params.toString()}`; + const url = new URL('/v1/liquidations', MONARCH_API_ENDPOINT.replace(/\/$/, '')); + if (chainId) url.searchParams.set('chain_id', chainId); try { const response = await fetch(url, { diff --git a/src/hooks/queries/useMarketMetricsQuery.ts b/src/hooks/queries/useMarketMetricsQuery.ts index 6f6ad70f..93a7e20e 100644 --- a/src/hooks/queries/useMarketMetricsQuery.ts +++ b/src/hooks/queries/useMarketMetricsQuery.ts @@ -272,7 +272,7 @@ const LIQUIDATIONS_STALE_THRESHOLD_MS = (2 * 60 + 5) * 60 * 1000; /** * Returns whether a market has ever been liquidated. * Primary: Uses Monarch API /v1/liquidations endpoint - * Fallback: Uses old Morpho API/Subgraph if Monarch data is stale (>1 hour) + * Fallback: Uses old Morpho API/Subgraph if Monarch data is stale (>2 hours) * * @deprecated_fallback The fallback to useLiquidationsQuery can be removed * once Monarch API stability is confirmed. @@ -289,6 +289,6 @@ export const useEverLiquidated = (chainId: number, uniqueKey: string): boolean = if (!needsFallback) { return liquidatedKeys.has(key); } - return fallbackKeys?.has(uniqueKey) ?? false; + return fallbackKeys?.has(uniqueKey.toLowerCase()) ?? false; }, [liquidatedKeys, needsFallback, chainId, uniqueKey, fallbackKeys]); }; From bb0b84ee56f7462da3e2876ae84661b44e254a8d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 9 Jan 2026 16:13:57 +0800 Subject: [PATCH 11/12] chore: format --- app/api/monarch/liquidations/route.ts | 19 +++++++--------- app/api/monarch/metrics/route.ts | 31 +++++++++------------------ app/api/monarch/utils.ts | 7 ++++++ 3 files changed, 25 insertions(+), 32 deletions(-) create mode 100644 app/api/monarch/utils.ts diff --git a/app/api/monarch/liquidations/route.ts b/app/api/monarch/liquidations/route.ts index 26135f65..66f039b1 100644 --- a/app/api/monarch/liquidations/route.ts +++ b/app/api/monarch/liquidations/route.ts @@ -1,24 +1,21 @@ import { type NextRequest, NextResponse } from 'next/server'; - -const MONARCH_API_ENDPOINT = process.env.MONARCH_API_ENDPOINT; -const MONARCH_API_KEY = process.env.MONARCH_API_KEY; +import { MONARCH_API_KEY, getMonarchUrl } from '../utils'; export async function GET(req: NextRequest) { - if (!MONARCH_API_ENDPOINT || !MONARCH_API_KEY) { - console.error('[Monarch Liquidations API] Missing required env vars: MONARCH_API_ENDPOINT or MONARCH_API_KEY'); + if (!MONARCH_API_KEY) { + console.error('[Monarch Liquidations API] Missing MONARCH_API_KEY'); return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); } - const searchParams = req.nextUrl.searchParams; - const chainId = searchParams.get('chain_id'); - - const url = new URL('/v1/liquidations', MONARCH_API_ENDPOINT.replace(/\/$/, '')); - if (chainId) url.searchParams.set('chain_id', chainId); + const chainId = req.nextUrl.searchParams.get('chain_id'); try { + const url = getMonarchUrl('/v1/liquidations'); + if (chainId) url.searchParams.set('chain_id', chainId); + const response = await fetch(url, { headers: { 'X-API-Key': MONARCH_API_KEY }, - next: { revalidate: 300 }, // Cache for 5 minutes + next: { revalidate: 300 }, }); if (!response.ok) { diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts index d3c3c3d9..eb748adb 100644 --- a/app/api/monarch/metrics/route.ts +++ b/app/api/monarch/metrics/route.ts @@ -1,31 +1,21 @@ import { type NextRequest, NextResponse } from 'next/server'; - -const MONARCH_API_ENDPOINT = process.env.MONARCH_API_ENDPOINT; -const MONARCH_API_KEY = process.env.MONARCH_API_KEY; +import { MONARCH_API_KEY, getMonarchUrl } from '../utils'; export async function GET(req: NextRequest) { - if (!MONARCH_API_ENDPOINT || !MONARCH_API_KEY) { - console.error('[Monarch Metrics API] Missing required env vars: MONARCH_API_ENDPOINT or MONARCH_API_KEY'); + if (!MONARCH_API_KEY) { + console.error('[Monarch Metrics API] Missing MONARCH_API_KEY'); return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); } const searchParams = req.nextUrl.searchParams; - const params = new URLSearchParams(); - const chainId = searchParams.get('chain_id'); - const sortBy = searchParams.get('sort_by'); - const sortOrder = searchParams.get('sort_order'); - const limit = searchParams.get('limit'); - const offset = searchParams.get('offset'); - - if (chainId) params.set('chain_id', chainId); - if (sortBy) params.set('sort_by', sortBy); - if (sortOrder) params.set('sort_order', sortOrder); - if (limit) params.set('limit', limit); - if (offset) params.set('offset', offset); - - const url = `${MONARCH_API_ENDPOINT}/v1/markets/metrics?${params.toString()}`; try { + const url = getMonarchUrl('/v1/markets/metrics'); + for (const key of ['chain_id', 'sort_by', 'sort_order', 'limit', 'offset']) { + const value = searchParams.get(key); + if (value) url.searchParams.set(key, value); + } + const response = await fetch(url, { headers: { 'X-API-Key': MONARCH_API_KEY }, next: { revalidate: 900 }, @@ -37,8 +27,7 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: response.status }); } - const data = await response.json(); - return NextResponse.json(data); + return NextResponse.json(await response.json()); } catch (error) { console.error('[Monarch Metrics API] Failed to fetch:', error); return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: 500 }); diff --git a/app/api/monarch/utils.ts b/app/api/monarch/utils.ts new file mode 100644 index 00000000..cea2f7d9 --- /dev/null +++ b/app/api/monarch/utils.ts @@ -0,0 +1,7 @@ +export const MONARCH_API_ENDPOINT = process.env.MONARCH_API_ENDPOINT; +export const MONARCH_API_KEY = process.env.MONARCH_API_KEY; + +export const getMonarchUrl = (path: string): URL => { + if (!MONARCH_API_ENDPOINT) throw new Error('MONARCH_API_ENDPOINT not configured'); + return new URL(path, MONARCH_API_ENDPOINT.replace(/\/$/, '')); +}; From 95b63ac80bcccd20570b9b7b7b99fae9bf82bb89 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 9 Jan 2026 16:16:24 +0800 Subject: [PATCH 12/12] chore: clean --- app/api/monarch/metrics/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts index eb748adb..2c022e3f 100644 --- a/app/api/monarch/metrics/route.ts +++ b/app/api/monarch/metrics/route.ts @@ -18,7 +18,7 @@ export async function GET(req: NextRequest) { const response = await fetch(url, { headers: { 'X-API-Key': MONARCH_API_KEY }, - next: { revalidate: 900 }, + cache: 'no-store', }); if (!response.ok) {