diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8af2f9c01a..78e54ff882 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,13 @@ 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, collapsing stock-like categories into `stocks`), `isEquityAsset`, `isHip3Market`, and `STOCK_LIKE_MARKET_TYPES`. `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`). + - Add `MARKET_TYPE_FILTER` named constants for the `MarketTypeFilter` values. + ## [7.0.0] ### Added diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 6a4382638f..e5677f13a2 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -137,6 +137,7 @@ export { WebSocketConnectionState, PerpsAnalyticsEvent, MARKET_CATEGORIES, + MARKET_TYPE_FILTER, MarketCategory, } from './types'; export type { @@ -491,6 +492,12 @@ export { calculateFundingCountdown, calculate24hHighLow, filterMarketsByQuery, + matchesCategory, + getMarketTypeFilter, + applyMarketFilters, + isEquityAsset, + isHip3Market, + STOCK_LIKE_MARKET_TYPES, } from './utils'; export type { MarketPatternMatcher, CompiledMarketPattern } from './utils'; export type { diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 15919a2450..6b0e728698 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -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, @@ -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'; /** @@ -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; -} diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index f9402d5bce..01f1841e6e 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -99,6 +99,22 @@ export type MarketTypeFilter = | 'forex' | 'new'; +/** + * Named constants for the {@link MarketTypeFilter} values, so consumers + * reference `MARKET_TYPE_FILTER.Stocks` instead of bare string literals. + */ +export const MARKET_TYPE_FILTER = { + All: 'all', + Crypto: 'crypto', + Stocks: 'stocks', + PreIpo: 'pre-ipo', + Indices: 'indices', + Etfs: 'etfs', + Commodities: 'commodities', + Forex: 'forex', + New: 'new', +} as const satisfies Record; + /** * Ordered list of the 7 data-model market categories for UI pills. * Does not include the 'all' or 'new' sentinel values — those are applied diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 794e268b29..1691d018ec 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -1,5 +1,179 @@ -import type { PerpsMarketData } from '../types'; +import { MARKET_TYPE_FILTER, MarketCategory } from '../types'; +import type { + GetMarketDataWithPricesParams, + MarketType, + 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` is the single source of truth for the +// UI category model; `getMarketTypeFilter` is its inverse. +// ============================================================================ + +/** + * Maps each data-model {@link MarketCategory} to its UI {@link MarketTypeFilter} + * (e.g. `stock` → `stocks`). 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.CryptoCurrency]: MARKET_TYPE_FILTER.Crypto, + [MarketCategory.Stock]: MARKET_TYPE_FILTER.Stocks, + [MarketCategory.PreIpo]: MARKET_TYPE_FILTER.PreIpo, + [MarketCategory.Index]: MARKET_TYPE_FILTER.Indices, + [MarketCategory.Etf]: MARKET_TYPE_FILTER.Etfs, + [MarketCategory.Commodity]: MARKET_TYPE_FILTER.Commodities, + [MarketCategory.Forex]: MARKET_TYPE_FILTER.Forex, +}; + +/** + * 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, +): 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 MARKET_TYPE_FILTER.All: + return true; + case MARKET_TYPE_FILTER.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 MARKET_TYPE_FILTER.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 + ); + } +} + +/** + * Stock-like market categories (stock, pre-ipo, index, etf). Clients collapse + * these into the single 'stocks' filter, and they share traditional market + * hours. Single source of truth for the equity bucket. + */ +export const STOCK_LIKE_MARKET_TYPES: ReadonlySet = new Set([ + MarketCategory.Stock, + MarketCategory.PreIpo, + MarketCategory.Index, + MarketCategory.Etf, +]); + +/** + * Check whether a market type is stock-like (stock, pre-ipo, index, etf). + * + * @param marketType - The market type from {@link PerpsMarketData}. + * @returns True if the asset is stock-like. + */ +export const isEquityAsset = (marketType?: string): boolean => + marketType !== undefined && + STOCK_LIKE_MARKET_TYPES.has(marketType as MarketType); + +/** + * Resolve the user-facing category bucket for a market — one of `crypto`, + * `stocks`, `commodities`, `forex`, or `new`. Stock-like categories (stock, + * pre-ipo, index, etf) collapse into the single `stocks` bucket (see + * {@link isEquityAsset}); the remaining 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; + // Stock-like categories (stock, pre-ipo, index, etf) collapse into one pill. + if (isEquityAsset(marketType)) { + return MARKET_TYPE_FILTER.Stocks; + } + // Every other data-model category maps 1:1 (commodity, forex, crypto). + if (marketType) { + return MARKET_CATEGORY_TO_FILTER[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) + ? MARKET_TYPE_FILTER.New + : MARKET_TYPE_FILTER.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) diff --git a/packages/perps-controller/tests/src/utils/marketUtils.test.ts b/packages/perps-controller/tests/src/utils/marketUtils.test.ts new file mode 100644 index 0000000000..e05a3dcbf4 --- /dev/null +++ b/packages/perps-controller/tests/src/utils/marketUtils.test.ts @@ -0,0 +1,205 @@ +import type { PerpsMarketData } from '../../../src/types'; +import { + STOCK_LIKE_MARKET_TYPES, + getMarketTypeFilter, + isEquityAsset, + isHip3Market, + matchesCategory, +} from '../../../src/utils/marketUtils'; + +const market = (overrides: Partial): PerpsMarketData => + ({ + name: 'BTC', + symbol: 'BTC', + price: '50000', + volume: '$1M', + openInterest: '$1M', + change24hPercent: '+1.00%', + fundingRate: 0, + ...overrides, + }) as PerpsMarketData; + +describe('marketUtils category classification', () => { + describe('STOCK_LIKE_MARKET_TYPES', () => { + it('contains the four stock-like categories', () => { + expect([...STOCK_LIKE_MARKET_TYPES].sort()).toStrictEqual([ + 'etf', + 'index', + 'pre-ipo', + 'stock', + ]); + }); + }); + + describe('isEquityAsset', () => { + it.each(['stock', 'pre-ipo', 'index', 'etf'])( + 'returns true for stock-like %s', + (marketType) => { + expect(isEquityAsset(marketType)).toBe(true); + }, + ); + + it.each(['crypto', 'commodity', 'forex', 'bond', undefined])( + 'returns false for non-equity %s', + (marketType) => { + expect(isEquityAsset(marketType)).toBe(false); + }, + ); + }); + + describe('isHip3Market', () => { + it('is true when isHip3 is set', () => { + expect(isHip3Market(market({ isHip3: true }))).toBe(true); + }); + + it('is true when only marketSource is set', () => { + expect( + isHip3Market(market({ isHip3: undefined, marketSource: 'xyz' })), + ).toBe(true); + }); + + it('is false for a main-DEX market', () => { + expect( + isHip3Market(market({ isHip3: false, marketSource: undefined })), + ).toBe(false); + }); + }); + + describe('matchesCategory', () => { + it("matches every market for 'all'", () => { + expect(matchesCategory(market({ marketType: 'etf' }), 'all')).toBe(true); + }); + + it("matches only new markets for 'new'", () => { + expect(matchesCategory(market({ isNewMarket: true }), 'new')).toBe(true); + expect(matchesCategory(market({ isNewMarket: false }), 'new')).toBe( + false, + ); + }); + + it("matches non-HIP3 markets for 'crypto'", () => { + expect(matchesCategory(market({ isHip3: false }), 'crypto')).toBe(true); + }); + + it("matches HIP-3 markets explicitly typed crypto for 'crypto'", () => { + expect( + matchesCategory( + market({ isHip3: true, marketType: 'crypto' }), + 'crypto', + ), + ).toBe(true); + }); + + it("excludes other HIP-3 markets from 'crypto'", () => { + expect( + matchesCategory(market({ isHip3: true, marketType: 'etf' }), 'crypto'), + ).toBe(false); + }); + + it("treats a marketSource-only partial market as 'new', not 'crypto'", () => { + const partial = market({ + marketType: undefined, + isHip3: undefined, + isNewMarket: undefined, + marketSource: 'xyz', + }); + expect(matchesCategory(partial, 'crypto')).toBe(false); + expect(matchesCategory(partial, 'new')).toBe(true); + }); + + it.each([ + ['stock', 'stocks'], + ['pre-ipo', 'pre-ipo'], + ['index', 'indices'], + ['etf', 'etfs'], + ['commodity', 'commodities'], + ['forex', 'forex'], + ] as const)( + 'matches marketType %s for the granular filter %s', + (marketType, filter) => { + expect(matchesCategory(market({ marketType }), filter)).toBe(true); + }, + ); + + it('keeps stock-like categories distinct in the granular model', () => { + expect(matchesCategory(market({ marketType: 'etf' }), 'stocks')).toBe( + false, + ); + }); + }); + + describe('getMarketTypeFilter', () => { + // HIP-3 markets carry a marketType; main-DEX crypto does not. Stock-like + // categories collapse into the single 'stocks' filter. + it.each([ + ['stock', 'stocks'], + ['pre-ipo', 'stocks'], + ['index', 'stocks'], + ['etf', 'stocks'], + ['commodity', 'commodities'], + ['forex', 'forex'], + ] as const)( + 'collapses HIP-3 marketType %s to the %s filter', + (marketType, expected) => { + expect(getMarketTypeFilter(market({ marketType, isHip3: true }))).toBe( + expected, + ); + }, + ); + + it('resolves an explicit crypto marketType to crypto', () => { + expect(getMarketTypeFilter(market({ marketType: 'crypto' }))).toBe( + 'crypto', + ); + }); + + it('resolves a main-DEX market without a marketType to crypto', () => { + expect( + getMarketTypeFilter(market({ marketType: undefined, isHip3: false })), + ).toBe('crypto'); + }); + + it('resolves uncategorized HIP-3 markets to the new bucket', () => { + expect( + getMarketTypeFilter(market({ marketType: undefined, isHip3: true })), + ).toBe('new'); + }); + + it('treats a marketSource DEX id as HIP-3 (new, not crypto) when isHip3 is unset', () => { + expect( + getMarketTypeFilter( + market({ + marketType: undefined, + isHip3: undefined, + marketSource: 'xyz', + }), + ), + ).toBe('new'); + }); + + it('never returns the all sentinel', () => { + const samples = [ + market({ marketType: 'stock', isHip3: true }), + market({ marketType: 'commodity', isHip3: true }), + market({ marketType: undefined, isHip3: false }), + market({ marketType: undefined, isHip3: true }), + ]; + samples.forEach((sample) => + expect(getMarketTypeFilter(sample)).not.toBe('all'), + ); + }); + + // The resolved bucket must agree with matchesCategory (stock-like is the one + // intentional exception: it collapses to 'stocks' while matchesCategory keeps + // stock/pre-ipo/index/etf granular). + it.each([ + market({ marketType: 'commodity', isHip3: true }), + market({ marketType: 'forex', isHip3: true }), + market({ marketType: undefined, isHip3: false }), + market({ marketType: undefined, isHip3: true }), + market({ marketType: undefined, marketSource: 'xyz' }), + ])('is consistent with matchesCategory for %o', (sample) => { + expect(matchesCategory(sample, getMarketTypeFilter(sample))).toBe(true); + }); + }); +});