Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/lib/top-vaults/TopVaultsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
defaultTvlKey?: string;
/** Default age filter index (used to initialise the dropdown when showFilters is true) */
defaultAgeIndex?: number;
/** Default risk filter index (used to initialise the dropdown when showFilters is true) */
defaultRiskIndex?: number;
/** Default value for the "Hide unknown" filter (1 = hide, 0 = show) */
defaultHideUnknown?: number;
/** Default monthly return filter key */
Expand All @@ -59,6 +61,8 @@
beforeTable?: Snippet;
/** Whole-database vault count for the "out of {total}" listing meta on pre-filtered listings */
totalVaultCount?: number;
/** Include blacklisted vaults in the listing summary stats. */
includeBlacklistedInStats?: boolean;
}

let {
Expand All @@ -77,6 +81,7 @@
showFilters,
defaultTvlKey,
defaultAgeIndex,
defaultRiskIndex,
defaultHideUnknown,
defaultMonthlyReturnKey,
defaultSort,
Expand All @@ -85,7 +90,8 @@
detailAside,
detailDescription,
beforeTable,
totalVaultCount
totalVaultCount,
includeBlacklistedInStats
}: Props = $props();

let renderDetailAsideInHero = $derived(
Expand Down Expand Up @@ -207,10 +213,12 @@
{chain}
loading
{showFilters}
{defaultRiskIndex}
{defaultHideUnknown}
{defaultMonthlyReturnKey}
{defaultSort}
{defaultDirection}
{includeBlacklistedInStats}
/>
{:else if !topVaults?.vaults.length}
{#if stablecoinMetadata}
Expand All @@ -231,10 +239,12 @@
{showFilters}
{defaultTvlKey}
{defaultAgeIndex}
{defaultRiskIndex}
{defaultHideUnknown}
{defaultMonthlyReturnKey}
{defaultSort}
{defaultDirection}
{includeBlacklistedInStats}
/>
{/if}
</div>
Expand Down
107 changes: 38 additions & 69 deletions src/lib/top-vaults/TopVaultsTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@
defaultTvlKey?: string;
/** Default age filter index (used to initialise the dropdown when showFilters is true) */
defaultAgeIndex?: number;
/** Default risk filter index (used to initialise the dropdown when showFilters is true) */
defaultRiskIndex?: number;
/** Default value for the "Hide unknown" filter (1 = hide, 0 = show) */
defaultHideUnknown?: number;
/** Default monthly return filter key */
Expand All @@ -114,6 +116,8 @@
loading?: boolean;
/** Override for the "out of {total}" listing meta; defaults to the vaults passed in (use to show the whole-database count on pre-filtered listings) */
totalVaultCount?: number;
/** Include blacklisted vaults in the listing summary stats. */
includeBlacklistedInStats?: boolean;
}

const emptyTopVaults: TopVaults = {
Expand All @@ -123,6 +127,7 @@
curators: {}
};
const SKELETON_ROW_COUNT = 10;
const skeletonRows = Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => index);

let {
topVaults = emptyTopVaults,
Expand All @@ -135,12 +140,14 @@
showFilters = false,
defaultTvlKey = DEFAULT_TVL_KEY,
defaultAgeIndex = 0,
defaultRiskIndex = 1,
defaultHideUnknown = 1,
defaultMonthlyReturnKey = 'any',
defaultSort,
defaultDirection,
loading = false,
totalVaultCount: totalVaultCountProp
totalVaultCount: totalVaultCountProp,
includeBlacklistedInStats = false
}: Props = $props();

// --- Sort column registry (key → compareFn + default direction) ---
Expand Down Expand Up @@ -196,7 +203,7 @@
const searchParamsSchema = {
tvl: { type: 'string', defaultValue: defaultTvlKey, options: tvlFilterOptions.map((o) => o.key) },
age: { type: 'number', defaultValue: defaultAgeIndex },
risk: { type: 'number', defaultValue: 1 },
risk: { type: 'number', defaultValue: defaultRiskIndex },
sort: {
type: 'string',
defaultValue: defaultSort ?? DEFAULT_RETURN_COLUMN_IDS[0],
Expand All @@ -222,7 +229,7 @@
const current = deserialiseSearchParams(page.url.searchParams, searchParamsSchema);
const updated = { ...current, ...overrides };
const queryString = serialiseSearchParams(updated, searchParamsSchema);
goto('?' + queryString, { replaceState: true, noScroll: true, keepFocus: true });
goto(resolve(`${page.url.pathname}?${queryString}`), { replaceState: true, noScroll: true, keepFocus: true });
}

// --- Filter state (derived from URL) ---
Expand Down Expand Up @@ -478,16 +485,21 @@
});
});

// Exclude blacklisted vaults from stats calculations; uses filteredVaults so all
// Uses filteredVaults so all
// active filters (TVL, age, risk, search) are reflected in the stats row.
let statsVaults = $derived(filteredVaults.filter((v) => !isBlacklisted(v)));
let statsVaults = $derived(
includeBlacklistedInStats ? filteredVaults : filteredVaults.filter((v) => !isBlacklisted(v))
);

// Calculate total TVL from non-blacklisted, fully-filtered vaults
// Calculate total TVL from fully-filtered vaults
let totalTvl = $derived(calculateTotalTvl(statsVaults.map((v) => ({ current_nav: getVaultCurrentTvlUsd(v) }))));

// Calculate TVL-weighted average 1M APY from non-blacklisted, fully-filtered vaults
// Calculate TVL-weighted average 1M APY from fully-filtered vaults
let avgTvlWeightedApy1M = $derived(
calculateTvlWeightedApy(statsVaults.map((v) => ({ ...v, current_nav: getVaultCurrentTvlUsd(v) })))
calculateTvlWeightedApy(
statsVaults.map((v) => ({ ...v, current_nav: getVaultCurrentTvlUsd(v) })),
{ includeBlacklisted: includeBlacklistedInStats }
)
);

// sort vaults
Expand All @@ -505,11 +517,7 @@
let visibleVaults = $derived(sortedVaults.slice(0, maxVisibleRows));
let hasMoreRows = $derived(maxVisibleRows < sortedVaults.length);

function sortBy(
key: SortOptions['key'],
defaultDirection: SortOptions['direction'],
_compareFn: SortOptions['compareFn']
) {
function sortBy(key: SortOptions['key'], defaultDirection: SortOptions['direction']) {
let direction = defaultDirection;
if (sortOptions.key === key) {
direction = sortOptions.direction === 'asc' ? 'desc' : 'asc';
Expand Down Expand Up @@ -553,14 +561,9 @@
}
</script>

{#snippet sortColHeader(
label: string,
key: string,
direction: SortOptions['direction'],
compareFn: SortOptions['compareFn']
)}
{#snippet sortColHeader(label: string, key: string, direction: SortOptions['direction'])}
<th class={isReturnSortKey(key) ? `return-column return-column-${canonicaliseReturnSortKey(key)}` : key}>
<button onclick={() => sortBy(key, direction, compareFn)}>
<button onclick={() => sortBy(key, direction)}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html label}
</button>
Expand Down Expand Up @@ -677,7 +680,8 @@
<div class="table-stats" class:hidden={loading} data-testid="top-vaults-meta">
<Tooltip>
<svelte:fragment slot="trigger"
>{filteredVaults.length} vaults{#if totalVaultCount > filteredVaults.length}{' '}out of {totalVaultCount}{/if}</svelte:fragment
>{filteredVaults.length} vaults{#if totalVaultCount > filteredVaults.length}
<span>&nbsp;out of {totalVaultCount}</span>{/if}</svelte:fragment
>
<svelte:fragment slot="popup"
>{#if hiddenByTvl > 0}
Expand Down Expand Up @@ -983,62 +987,27 @@
<tr>
<th class="index"></th>
{#if showChainCol}
{@render sortColHeader(
'',
'chain',
'asc',
stringCompare((v) => getChain(v.chain_id)?.name ?? `Chain ${v.chain_id}`)
)}
{@render sortColHeader('', 'chain', 'asc')}
{/if}
{@render sortColHeader(
'Vault',
'vault',
'asc',
stringCompare((v) => `${v.name.trim()} ${v.protocol}`)
)}
{@render sortColHeader('Vault', 'vault', 'asc')}
{#each selectedReturnColumns as column (column.id)}
{@render sortColHeader(
column.headerLabel,
column.id,
column.sortDirection,
sortColumnMap[column.id].compareFn
)}
{@render sortColHeader(column.headerLabel, column.id, column.sortDirection)}
{/each}
{@render sortColHeader('3m Sharpe', 'three_months_sharpe', 'desc', rankVaultsBy(['three_months_sharpe']))}
{@render sortColHeader(
'3M Vola&shy;tility',
'three_months_volatility',
'asc',
rankVaultsBy(['three_months_volatility'])
)}
{@render sortColHeader('Max DD', 'max_dd', 'desc', sortColumnMap['max_dd'].compareFn)}
{@render sortColHeader(
'Denom&shy;ination',
'denomination',
'asc',
stringCompare((v) => v.denomination)
)}
{@render sortColHeader(
'TVL USD<br/>(current/&ZeroWidthSpace;peak)',
'tvl',
'desc',
sortColumnMap['tvl'].compareFn
)}
{@render sortColHeader('Age (y)', 'age', 'desc', rankVaultsBy(['years']))}
{@render sortColHeader(
'Fees<br />(mgmt/&ZeroWidthSpace;perf)',
'fees',
'asc',
rankVaultsBy(['mgmt_fee', 'perf_fee'], Infinity)
)}
{@render sortColHeader('Deposit and delays', 'lockup', 'asc', rankVaultsBy(['lockup'], Infinity))}
{@render sortColHeader('Protocol Technical Risk', 'risk', 'asc', rankVaultsBy(['risk_numeric'], Infinity))}
{@render sortColHeader('3m Sharpe', 'three_months_sharpe', 'desc')}
{@render sortColHeader('3M Vola&shy;tility', 'three_months_volatility', 'asc')}
{@render sortColHeader('Max DD', 'max_dd', 'desc')}
{@render sortColHeader('Denom&shy;ination', 'denomination', 'asc')}
{@render sortColHeader('TVL USD<br/>(current/&ZeroWidthSpace;peak)', 'tvl', 'desc')}
{@render sortColHeader('Age (y)', 'age', 'desc')}
{@render sortColHeader('Fees<br />(mgmt/&ZeroWidthSpace;perf)', 'fees', 'asc')}
{@render sortColHeader('Deposit and delays', 'lockup', 'asc')}
{@render sortColHeader('Protocol Technical Risk', 'risk', 'asc')}
<th class="sparkline">3M price</th>
</tr>
</thead>
<tbody>
{#if loading}
{#each { length: SKELETON_ROW_COUNT } as _}
{#each skeletonRows as index (index)}
<tr>
<td class="index"></td>
{#if showChainCol}<td class="chain"></td>{/if}
Expand Down
1 change: 1 addition & 0 deletions src/lib/top-vaults/VaultListingsSelector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
{ href: '/trading-view/vaults/high-tvl', label: 'Top with $2M TVL' },
{ href: '/trading-view/vaults/new-vaults', label: 'New' },
{ href: '/trading-view/vaults/negative', label: 'Negative' },
{ href: '/trading-view/vaults/blacklisted', label: 'Blacklisted' },
{ href: '/trading-view/vaults/all', label: 'All' }
] as const;

Expand Down
45 changes: 45 additions & 0 deletions src/lib/top-vaults/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest';
import {
CORE3_METHODOLOGY_URL,
isBlacklisted,
isEligibleFrontpageVault,
hasSupportedProtocol,
getProtocolDisplayName,
isUnsupportedProtocolSlug,
Expand Down Expand Up @@ -119,6 +120,20 @@ describe('calculateTvlWeightedApy', () => {

expect(calculateTvlWeightedApy([included, blacklisted])).toBe(0.1);
});

test('includes blacklisted vaults when requested', () => {
const included = createTestVault('Included vault', {
current_nav: 100_000,
one_month_cagr: 0.1
});
const blacklisted = createTestVault('Blacklisted vault', {
current_nav: 300_000,
one_month_cagr: 0.5,
risk: 'Blacklisted'
});

expect(calculateTvlWeightedApy([included, blacklisted], { includeBlacklisted: true })).toBe(0.4);
});
});

describe('hasSupportedProtocol', () => {
Expand All @@ -132,6 +147,11 @@ describe('hasSupportedProtocol', () => {
expect(hasSupportedProtocol(vault)).toBe(false);
});

test('returns false for empty protocols', () => {
const vault = createTestVault('Test vault', { protocol: ' ' });
expect(hasSupportedProtocol(vault)).toBe(false);
});

test('returns false for unsupported protocol slugs generated from placeholders', () => {
const vault = {
protocol: 'Yearn',
Expand All @@ -141,6 +161,31 @@ describe('hasSupportedProtocol', () => {
});
});

describe('isEligibleFrontpageVault', () => {
test('returns true for known protocol vaults with severe risk or safer', () => {
const vault = createTestVault('Test vault', { protocol: 'Yearn', risk: 'Severe' });
expect(isEligibleFrontpageVault(vault)).toBe(true);
});

test('returns false for dangerous risk vaults', () => {
const vault = createTestVault('Test vault', { protocol: 'Yearn', risk: 'Dangerous' });
expect(isEligibleFrontpageVault(vault)).toBe(false);
});

test('returns false for unknown risk vaults', () => {
const vault = createTestVault('Test vault', { protocol: 'Yearn' });
expect(isEligibleFrontpageVault(vault)).toBe(false);
});

test('returns false for unknown protocol vaults', () => {
const vault = createTestVault('Test vault', {
protocol: '<protocol not yet identified>',
risk: 'Low'
});
expect(isEligibleFrontpageVault(vault)).toBe(false);
});
});

describe('getProtocolDisplayName', () => {
test('aliases unidentified protocol placeholder to Unknown', () => {
expect(getProtocolDisplayName('<protocol not yet identified>')).toBe('Unknown');
Expand Down
Loading
Loading