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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Weblog of stuff

- Rank strategy vaults by displayed annual return within green and red chart groups, and clarify the strategy listing description (2026-07-28)
- Add ApeX perpetual futures DEX support — chain logo and entry, perp DEX detection, "Any" default TVL filter on the protocol page, and perp DEX wording in chain descriptions (2026-07-25)
- Add tokenised fund listings, NAV market-share chart, and fund-specific vault detail messaging (2026-07-17)
- Add live TVL and listing-count summaries to chain, protocol, curator and stablecoin vault pages (2026-07-11)
Expand Down
120 changes: 106 additions & 14 deletions src/lib/strategies/sort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ import { compareStrategiesForFrontend, compareStrategiesForFrontpage } from './s
function createStrategy({
id,
name,
sort_priority
sort_priority,
tileChartDirection = 'absolute',
chartValues,
annualReturn,
rawAnnualReturn
}: {
id: string;
name: string;
sort_priority: number;
tileChartDirection?: 'absolute' | 'relative';
chartValues?: [number, number];
annualReturn?: number;
rawAnnualReturn?: number;
}): StrategyInfo {
return {
id,
Expand All @@ -22,38 +30,122 @@ function createStrategy({
microsite: false,
depositExternal: false,
useSharePrice: false,
tileChartDirection: 'absolute',
tileChartDirection,
summary_statistics: chartValues
? ({
compounding_unrealised_trading_profitability: [
[1, chartValues[0]],
[2, chartValues[1]]
],
return_annualised: rawAnnualReturn,
key_metrics: annualReturn === undefined ? {} : { cagr: { value: annualReturn } }
} as StrategyInfo['summary_statistics'])
: annualReturn === undefined && rawAnnualReturn === undefined
? undefined
: ({
return_annualised: rawAnnualReturn,
key_metrics: annualReturn === undefined ? {} : { cagr: { value: annualReturn } }
} as StrategyInfo['summary_statistics']),
connected: false,
icon_url: '',
error: 'test error'
};
}

describe('compareStrategiesForFrontend', () => {
it('keeps pinned strategies in the requested order', () => {
it('lists green charts first and ranks each chart-colour group by annual return', () => {
const strategies = [
createStrategy({ id: 'gmx-ai', name: 'GMX AI', sort_priority: 999 }),
createStrategy({ id: 'master-vault', name: 'Master Vault', sort_priority: 999 }),
createStrategy({ id: 'opencz', name: 'OpenCZ', sort_priority: 0 }),
createStrategy({ id: 'hyper-ai', name: 'HyperAI', sort_priority: 1 }),
createStrategy({ id: 'ichi-hyperliquid', name: 'Ichi', sort_priority: 500 })
createStrategy({ id: 'red-high', name: 'Red high', sort_priority: 999, chartValues: [0, -0.1], annualReturn: 2 }),
createStrategy({ id: 'red-low', name: 'Red low', sort_priority: 999, chartValues: [0, -0.2], annualReturn: 0.1 }),
createStrategy({
id: 'green-low',
name: 'Green low',
sort_priority: 999,
chartValues: [0, 0.1],
annualReturn: 0.2
}),
createStrategy({
id: 'green-high',
name: 'Green high',
sort_priority: 0,
chartValues: [0, 0.2],
annualReturn: 0.5
}),
createStrategy({ id: 'neutral', name: 'Neutral', sort_priority: 999, chartValues: [0, 0], annualReturn: 5 }),
createStrategy({ id: 'no-data', name: 'No data', sort_priority: 500, annualReturn: 6 })
];

const sortedIds = strategies.sort(compareStrategiesForFrontend).map((strategy) => strategy.id);

expect(sortedIds).toEqual(['green-high', 'green-low', 'red-high', 'red-low', 'neutral', 'no-data']);
});

it('uses the annual return displayed on the strategy tile', () => {
const strategies = [
createStrategy({
id: 'gmx',
name: 'GMX',
sort_priority: 0,
chartValues: [0, 0.2],
annualReturn: -0.64,
rawAnnualReturn: 1.38
}),
createStrategy({
id: 'hyper-ai',
name: 'HyperAI',
sort_priority: 0,
chartValues: [0, 0.1],
annualReturn: 0.66,
rawAnnualReturn: 0.56
})
];

const sortedIds = strategies.sort(compareStrategiesForFrontend).map((strategy) => strategy.id);

expect(sortedIds).toEqual(['hyper-ai', 'gmx']);
});

it('uses the relative chart change when that controls the tile colour', () => {
const strategies = [
createStrategy({
id: 'relative-profit',
name: 'Relative profit',
sort_priority: 0,
tileChartDirection: 'relative',
chartValues: [-0.5, -0.4],
annualReturn: 0.1
}),
createStrategy({
id: 'absolute-loss',
name: 'Absolute loss',
sort_priority: 0,
chartValues: [0, -0.1],
annualReturn: 2
})
];

const sortedIds = strategies.sort(compareStrategiesForFrontend).map((strategy) => strategy.id);

expect(sortedIds).toEqual(['opencz', 'hyper-ai', 'master-vault', 'ichi-hyperliquid', 'gmx-ai']);
expect(sortedIds).toEqual(['relative-profit', 'absolute-loss']);
});

it('places all other strategies afterwards by sort priority', () => {
it('uses sort priority when annual returns are tied or unavailable', () => {
const strategies = [
createStrategy({ id: 'other-low', name: 'Other low', sort_priority: 1 }),
createStrategy({ id: 'other-high', name: 'Other high', sort_priority: 5 }),
createStrategy({ id: 'opencz', name: 'OpenCZ', sort_priority: 0 })
createStrategy({ id: 'low', name: 'Low', sort_priority: 1 }),
createStrategy({ id: 'high', name: 'High', sort_priority: 5 }),
createStrategy({ id: 'tied', name: 'Tied', sort_priority: 10, chartValues: [0, 0.2], annualReturn: 0.2 }),
createStrategy({
id: 'tied-lower-priority',
name: 'Tied lower priority',
sort_priority: 1,
chartValues: [0, 0.2],
annualReturn: 0.2
})
];

const sortedIds = strategies.sort(compareStrategiesForFrontend).map((strategy) => strategy.id);

expect(sortedIds).toEqual(['opencz', 'other-high', 'other-low']);
expect(sortedIds).toEqual(['tied', 'tied-lower-priority', 'high', 'low']);
});

it('keeps frontpage strategies in the requested order', () => {
Expand Down
78 changes: 70 additions & 8 deletions src/lib/strategies/sort.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { StrategyInfo } from 'trade-executor/models/strategy-info';

const listingPinnedStrategyOrder = ['opencz', 'hyper-ai', 'master-vault', 'ichi-hyperliquid', 'gmx-ai'] as const;
import { getMetricsWithAltCAGR } from 'trade-executor/helpers/metrics';
import { relativeReturn } from '$lib/helpers/financial';

const frontpagePinnedStrategyOrder = ['master-vault', 'hyper-ai', 'vega'] as const;

Expand Down Expand Up @@ -31,17 +31,79 @@ function createStrategyComparator(pinnedStrategyOrder: readonly string[]) {
}

/**
* Sort strategies for frontend listings.
* Get the return used to colour a strategy tile chart.
*
* Pinned strategies always appear first in a fixed order.
* Remaining strategies fall back to existing sort priority rules.
* The main listing uses this same value to group vaults, so green charts are
* shown before red charts.
*/
function getTileChartReturn(strategy: StrategyInfo): number | undefined {
const data = strategy.useSharePrice
? strategy.summary_statistics?.share_price_returns_90_days
: strategy.summary_statistics?.compounding_unrealised_trading_profitability;

const start = data?.[0]?.[1];
const end = data?.at(-1)?.[1];
const value = strategy.tileChartDirection === 'relative' ? relativeReturn(start, end) : end;

return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}

/**
* Get a strategy's displayed annual return for ordering within its chart-colour group.
*/
export const compareStrategiesForFrontend = createStrategyComparator(listingPinnedStrategyOrder);
function getAnnualReturn(strategy: StrategyInfo): number | undefined {
const value = getMetricsWithAltCAGR(strategy).cagr?.value;
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}

function getTileChartColourRank(strategy: StrategyInfo) {
const chartReturn = getTileChartReturn(strategy);

if (chartReturn === undefined) return 3;
if (chartReturn > 0) return 0;
if (chartReturn < 0) return 1;
return 2;
}

/**
* Sort strategies for the main listing by chart colour and annual return.
*
* Green tile charts appear first, then red charts. Neutral and unavailable
* charts follow. Within each group, the highest annual return appears first.
* Sort priority and name provide a deterministic order for ties or missing data.
*/
export function compareStrategiesForFrontend(a: StrategyInfo, b: StrategyInfo) {
const chartColourRankA = getTileChartColourRank(a);
const chartColourRankB = getTileChartColourRank(b);

if (chartColourRankA !== chartColourRankB) {
return chartColourRankA - chartColourRankB;
}

const annualReturnA = getAnnualReturn(a);
const annualReturnB = getAnnualReturn(b);

if (annualReturnA !== undefined && annualReturnB !== undefined && annualReturnA !== annualReturnB) {
return annualReturnB - annualReturnA;
}

if (annualReturnA !== undefined && annualReturnB === undefined) return -1;
if (annualReturnA === undefined && annualReturnB !== undefined) return 1;

const sortPriorityA = a.sort_priority ?? 0;
const sortPriorityB = b.sort_priority ?? 0;

if (sortPriorityA !== sortPriorityB) {
return sortPriorityB - sortPriorityA;
}

return a.name.localeCompare(b.name);
}

/**
* Sort strategies for the frontpage featured section.
*
* Master Vault appears first, then HyperAI, then Premium Harvest Vault (vega),
* with all other strategies following the default sort-priority behaviour.
* Its curated order is intentionally independent of the performance-ranked
* strategy listing.
*/
export const compareStrategiesForFrontpage = createStrategyComparator(frontpagePinnedStrategyOrder);
56 changes: 47 additions & 9 deletions src/lib/top-vaults/VaultGroupTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@
}

:global(table.datatable.responsive tbody tr) {
grid-template-columns: max-content max-content minmax(0, 1fr);
grid-template-columns: calc(2.5rem + 0.5rem + 2.5rem + 1rem) max-content max-content minmax(0, 1fr);
gap: 0 0.5rem;
padding: 0.75rem 0.875rem;
border-radius: 0.75rem;
Expand All @@ -285,18 +285,19 @@

:global(table.datatable.responsive tbody tr[data-row-index]::before) {
top: 0.75rem;
left: calc(0.875rem + 2.5rem + 0.5rem);
left: calc(0.875rem + 1.25rem);
transform: translateX(-50%);
font: var(--f-ui-md-bold);
line-height: 2.5rem;
color: var(--c-text-light);
color: var(--c-text);
pointer-events: none;
}

:global(table.datatable.responsive tbody tr td.name) {
grid-column: 1 / -1;
align-items: center;
min-height: 2.5rem;
padding-inline: calc(2.5rem + 0.5rem + 2.75rem) 1.5rem;
padding-inline: calc(2.5rem + 0.5rem + 2.5rem + 1.5rem) 2.25rem;
font: var(--f-ui-lg-bold);
line-height: 1.1;
}
Expand All @@ -307,18 +308,23 @@
gap: var(--space-xxs);
min-width: 0;
padding: 0;
font: var(--f-ui-sm-roman);
font: var(--f-ui-xs-roman);
line-height: 1.2;
color: var(--c-text-light);
color: var(--c-text);
white-space: nowrap;
word-break: normal;
}

:global(table.datatable.responsive tbody tr td:not(.name)::before),
:global(table.datatable.responsive tbody tr td:not(.name)::after) {
color: var(--c-text-light);
}

:global(table.datatable.responsive tbody tr td.name .entity-symbol .logo),
:global(table.datatable.responsive tbody tr td.name .entity-symbol .placeholder-logo) {
position: absolute;
top: 0.75rem;
left: 0.875rem;
top: calc(0.75rem + 0.625rem);
left: calc(0.875rem + 2.5rem + 0.5rem);
width: 2.5rem;
height: 2.5rem;
object-fit: contain;
Expand All @@ -328,9 +334,15 @@
:global(table.datatable.responsive tbody tr td.name .entity-symbol .label),
:global(table.datatable.responsive tbody tr td.name .group-name) {
min-width: 0;
max-width: 100%;
}

:global(table.datatable.responsive tbody tr td.name .entity-symbol) {
width: 100%;
}

:global(table.datatable.responsive tbody tr td.name .group-name > span:last-child) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
Expand All @@ -351,14 +363,22 @@
}

:global(table.datatable.responsive tbody tr td.vault_count) {
margin-inline-start: calc(2.5rem + 0.5rem + 2.75rem);
grid-column: 2;
}

:global(table.datatable.responsive tbody tr td.vault_count::before) {
content: 'vaults';
order: 2;
}

:global(table.datatable.responsive tbody tr td.avg_apy) {
grid-column: 3;
}

:global(table.datatable.responsive tbody tr td.tvl) {
grid-column: 4;
}

:global(table.datatable.responsive tbody tr td.avg_apy::before),
:global(table.datatable.responsive tbody tr td.tvl::before) {
content: '·';
Expand All @@ -379,6 +399,24 @@
:global(table.datatable.responsive tbody tr td .row-link) {
display: none;
}

@media (width < 480px) {
:global(table.datatable.responsive tbody tr) {
grid-template-columns: calc(2.5rem + 0.5rem + 2.5rem + 1rem) max-content minmax(0, 1fr);
}

:global(table.datatable.responsive tbody tr td.avg_apy) {
grid-column: 3;
}

:global(table.datatable.responsive tbody tr td.tvl) {
grid-column: 2 / -1;
}

:global(table.datatable.responsive tbody tr td.tvl::before) {
display: none;
}
}
}
}

Expand Down
Loading
Loading