Skip to content
Open
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
6 changes: 6 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Centralise market category classification so consumers share one model instead of re-deriving it per client ([#9009](https://github.com/MetaMask/core/pull/9009))
- Export `getMarketTypeFilter` (resolves a market to its UI category filter with singular values aligned to `MarketCategory`) and `isHip3Market`. `getMarketTypeFilter` and `matchesCategory` treat a `marketSource` DEX id as a HIP-3 signal consistently, so partial (route-param) markets classify the same way in both.
- Export the pure `matchesCategory` and `applyMarketFilters` helpers (moved from `MarketDataService`).

## [7.0.0]

### Added
Expand Down
4 changes: 4 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,10 @@ export {
calculateFundingCountdown,
calculate24hHighLow,
filterMarketsByQuery,
matchesCategory,
getMarketTypeFilter,
applyMarketFilters,
isHip3Market,
} from './utils';
export type { MarketPatternMatcher, CompiledMarketPattern } from './utils';
export type {
Expand Down
94 changes: 2 additions & 92 deletions packages/perps-controller/src/services/MarketDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ import type { CandlePeriod } from '../constants/chartConfig';
import { PerpsMeasurementName } from '../constants/performanceMetrics';
import { PERPS_CONSTANTS } from '../constants/perpsConfig';
import { PERPS_ERROR_CODES } from '../perpsErrorCodes';
import {
MarketCategory,
PerpsTraceNames,
PerpsTraceOperations,
} from '../types';
import { PerpsTraceNames, PerpsTraceOperations } from '../types';
import type {
PerpsProvider,
Position,
Expand Down Expand Up @@ -36,12 +32,11 @@ import type {
AssetRoute,
PerpsPlatformDependencies,
PerpsMarketData,
MarketTypeFilter,
} from '../types';
import type { CandleData } from '../types/perps-types';
import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest';
import { ensureError, isAbortError } from '../utils/errorUtils';
import { sortMarkets } from '../utils/sortMarkets';
import { applyMarketFilters } from '../utils/marketUtils';
import type { ServiceContext } from './ServiceContext';

/**
Expand Down Expand Up @@ -1258,88 +1253,3 @@ export class MarketDataService {
return provider.getBlockExplorerUrl(address);
}
}

// ============================================================================
// Market filtering helpers (module-level pure functions)
// These live outside the class because they have no service dependencies —
// they are pure data transformations that can be tested and reused independently.
// ============================================================================

/**
* Returns true when a market matches the given UI filter category.
*
* @param market - The market data to test.
* @param category - The filter category to test against.
* @returns Whether the market matches the category.
*/
export function matchesCategory(
market: PerpsMarketData,
category: MarketTypeFilter,
): boolean {
switch (category) {
case 'all':
return true;
case 'new':
return market.isNewMarket === true;
case 'crypto':
// Includes non-HIP3 markets AND HIP-3 assets explicitly typed as CryptoCurrency.
return (
!market.isHip3 || market.marketType === MarketCategory.CryptoCurrency
);
case 'stocks':
return market.marketType === MarketCategory.Stock;
case 'pre-ipo':
return market.marketType === MarketCategory.PreIpo;
case 'indices':
return market.marketType === MarketCategory.Index;
case 'etfs':
return market.marketType === MarketCategory.Etf;
case 'commodities':
return market.marketType === MarketCategory.Commodity;
case 'forex':
return market.marketType === MarketCategory.Forex;
default:
return true;
}
}

/**
* Applies optional category filtering, sorting, and limit to a list of markets.
*
* @param markets - Source market array.
* @param params - Optional filter/sort/limit params.
* @returns Filtered, sorted, and/or sliced market array.
*/
export function applyMarketFilters(
markets: PerpsMarketData[],
params?: GetMarketDataWithPricesParams,
): PerpsMarketData[] {
let result = markets;

if (params?.categories?.length) {
const { categories } = params;
result = result.filter((market) =>
// A market is included if it matches ANY of the requested categories.
categories.some((category) => matchesCategory(market, category)),
);
}

if (params?.excludeSymbols?.length) {
const excluded = new Set(params.excludeSymbols);
result = result.filter((market) => !excluded.has(market.symbol));
}

if (params?.sortBy) {
result = sortMarkets({
markets: result,
sortBy: params.sortBy,
direction: params.direction,
});
}

if (params?.limit !== undefined) {
result = result.slice(0, params.limit);
}

return result;
}
17 changes: 8 additions & 9 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,14 @@ export enum MarketCategory {
export type MarketType = `${MarketCategory}`;

// Market type filter for UI category badges
// Note: 'stocks' maps to 'stock', 'commodities' maps to 'commodity' in the data model
export type MarketTypeFilter =
| 'all'
| 'crypto'
| 'stocks'
| 'stock'
| 'pre-ipo'
| 'indices'
| 'etfs'
| 'commodities'
| 'index'
| 'etf'
| 'commodity'
| 'forex'
| 'new';

Expand All @@ -107,11 +106,11 @@ export type MarketTypeFilter =
*/
export const MARKET_CATEGORIES = [
'crypto',
'stocks',
'stock',
'pre-ipo',
'indices',
'etfs',
'commodities',
'index',
'etf',
'commodity',
'forex',
] as const satisfies MarketTypeFilter[];

Expand Down
145 changes: 144 additions & 1 deletion packages/perps-controller/src/utils/marketUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,148 @@
import type { PerpsMarketData } from '../types';
import { MarketCategory } from '../types';
import type {
GetMarketDataWithPricesParams,
MarketTypeFilter,
PerpsMarketData,
} from '../types';
import type { CandleData, CandleStick } from '../types/perps-types';
import { sortMarkets } from './sortMarkets';

// ============================================================================
// Market category classification (pure functions)
// No service dependencies — pure data transformations that can be tested and
// reused independently. `matchesCategory` and `getMarketTypeFilter` share the
// same category model.
// ============================================================================

/**
* Maps each data-model {@link MarketCategory} to its UI {@link MarketTypeFilter}
* with matching singular values. Exhaustive: adding a `MarketCategory` is a
* compile error here until it is mapped, so the model can't silently drift.
*/
const MARKET_CATEGORY_TO_FILTER: Record<MarketCategory, MarketTypeFilter> = {
[MarketCategory.CryptoCurrency]: 'crypto',
[MarketCategory.Stock]: 'stock',
[MarketCategory.PreIpo]: 'pre-ipo',
[MarketCategory.Index]: 'index',
[MarketCategory.Etf]: 'etf',
[MarketCategory.Commodity]: 'commodity',
[MarketCategory.Forex]: 'forex',
};
Comment on lines +22 to +30
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should align market category with filter category to be the same all singular. The current approach is a bit confusing

Comment on lines +22 to +30
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are aligning, do we need this anymore?


/**
* Whether a market is a HIP-3 (non-main-DEX) market. A `marketSource` DEX id
* marks a HIP-3 market even when the `isHip3` flag is unset (e.g. partial route
* params), so both signals are checked. Used as the single HIP-3 signal so the
* classifiers stay consistent.
*
* @param market - The market data to test.
* @returns True if the market is HIP-3.
*/
export const isHip3Market = (
market: Pick<PerpsMarketData, 'isHip3' | 'marketSource'>,
): boolean => Boolean(market.isHip3) || Boolean(market.marketSource);

/**
* Returns true when a market matches the given UI filter category.
*
* @param market - The market data to test.
* @param category - The filter category to test against.
* @returns Whether the market matches the category.
*/
export function matchesCategory(
market: PerpsMarketData,
category: MarketTypeFilter,
): boolean {
switch (category) {
case 'all':
return true;
case 'new':
// Explicitly flagged, or an uncategorized HIP-3 market (kept in sync with
// getMarketTypeFilter's 'new' bucket).
return (
market.isNewMarket === true ||
(isHip3Market(market) && market.marketType === undefined)
);
case 'crypto':
// Main-DEX markets, plus HIP-3 assets explicitly typed as CryptoCurrency.
return (
!isHip3Market(market) ||
market.marketType === MarketCategory.CryptoCurrency
);
default:
// Every other filter is a 1:1 data-model category match.
return (
market.marketType !== undefined &&
MARKET_CATEGORY_TO_FILTER[market.marketType] === category
);
Comment on lines +72 to +77
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️ way better, but I believe we can just say

Suggested change
default:
// Every other filter is a 1:1 data-model category match.
return (
market.marketType !== undefined &&
MARKET_CATEGORY_TO_FILTER[market.marketType] === category
);
default:
// Every other filter is a 1:1 data-model category match.
return (
market.marketType !== undefined &&
market.marketType === category
);

}
}

/**
* Resolve the user-facing category bucket for a market — one of `crypto`,
* `stock`, `pre-ipo`, `index`, `etf`, `commodity`, `forex`, or `new`. Data-model
* categories map 1:1. A market with no data-model category is `crypto` when it
* is main-DEX, or `new` when it is an uncategorized HIP-3 market (`isHip3`, or a
* `marketSource` DEX id when `isHip3` is unset, e.g. minimal route params).
* Never returns the `all` sentinel.
*
* Centralised as the single source of truth so consumers (e.g. category
* shortcuts, related markets) share one classification instead of re-deriving
* it per client and drifting as new categories are added.
*
* @param market - The market data to classify.
* @returns The market type filter bucket.
*/
export function getMarketTypeFilter(market: PerpsMarketData): MarketTypeFilter {
const { marketType } = market;
if (marketType) {
return MARKET_CATEGORY_TO_FILTER[marketType];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return MARKET_CATEGORY_TO_FILTER[marketType];
return marketType;

}
// No data-model category: an uncategorized HIP-3 market is the 'new' bucket;
// otherwise it's a main-DEX crypto market.
return isHip3Market(market) ? 'new' : 'crypto';
}

/**
* Applies optional category filtering, sorting, and limit to a list of markets.
*
* @param markets - Source market array.
* @param params - Optional filter/sort/limit params.
* @returns Filtered, sorted, and/or sliced market array.
*/
export function applyMarketFilters(
markets: PerpsMarketData[],
params?: GetMarketDataWithPricesParams,
): PerpsMarketData[] {
let result = markets;

if (params?.categories?.length) {
const { categories } = params;
result = result.filter((market) =>
// A market is included if it matches ANY of the requested categories.
categories.some((category) => matchesCategory(market, category)),
);
}

if (params?.excludeSymbols?.length) {
const excluded = new Set(params.excludeSymbols);
result = result.filter((market) => !excluded.has(market.symbol));
}

if (params?.sortBy) {
result = sortMarkets({
markets: result,
sortBy: params.sortBy,
direction: params.direction,
});
}

if (params?.limit !== undefined) {
result = result.slice(0, params.limit);
}

return result;
}

/**
* Maximum length for market filter patterns (prevents DoS attacks)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,11 @@ describe('PerpsController — market categories & filtering', () => {
it('includes all 7 data categories', () => {
const categories = controller.getMarketCategories();
expect(categories).toContain('crypto');
expect(categories).toContain('stocks');
expect(categories).toContain('stock');
expect(categories).toContain('pre-ipo');
expect(categories).toContain('indices');
expect(categories).toContain('etfs');
expect(categories).toContain('commodities');
expect(categories).toContain('index');
expect(categories).toContain('etf');
expect(categories).toContain('commodity');
expect(categories).toContain('forex');
});
});
Expand Down Expand Up @@ -213,7 +213,7 @@ describe('PerpsController — market categories & filtering', () => {
expect(symbols).not.toContain('xyz:TSLA');
});

it('filters to only stock markets when categories is ["stocks"]', async () => {
it('filters to only stock markets when categories is ["stock"]', async () => {
const markets = [
buildMarket({ symbol: 'BTC', isHip3: false }),
buildMarket({
Expand All @@ -235,7 +235,7 @@ describe('PerpsController — market categories & filtering', () => {
mockProvider.getMarketDataWithPrices.mockResolvedValue(markets);

const result = await controller.getMarketDataWithPrices({
categories: ['stocks'],
categories: ['stock'],
});

expect(result).toHaveLength(2);
Expand Down Expand Up @@ -266,7 +266,7 @@ describe('PerpsController — market categories & filtering', () => {
mockProvider.getMarketDataWithPrices.mockResolvedValue(markets);

const result = await controller.getMarketDataWithPrices({
categories: ['stocks', 'etfs'],
categories: ['stock', 'etf'],
});

expect(result).toHaveLength(2);
Expand Down Expand Up @@ -483,7 +483,7 @@ describe('PerpsController — market categories & filtering', () => {
mockProvider.getMarketDataWithPrices.mockResolvedValue(markets);

const result = await controller.getMarketDataWithPrices({
categories: ['stocks'],
categories: ['stock'],
sortBy: 'openInterest',
direction: 'desc',
limit: 2,
Expand Down
Loading
Loading