diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 77068ed231..dba4641058 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1563,9 +1563,6 @@ } }, "packages/perps-controller/src/PerpsController.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, "no-restricted-syntax": { "count": 3 } diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 986f1b250c..daccb00c91 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `MarketCategory` enum, `MARKET_CATEGORIES` ordered array (7 data-model category pills), and `getMarketCategories` messenger action ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Expand `HIP3_ASSET_MARKET_TYPES` with new stock, ETF, pre-IPO, forex, and commodity markets ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Add `categories`, `sortBy`, `direction`, `limit`, and `excludeSymbols` optional params to `GetMarketDataWithPricesParams` and `getMarketDataWithPrices()` for post-processing filtering, sorting, and pagination of market data ([#8892](https://github.com/MetaMask/core/pull/8892)) +- Export `SortField`, `SortDirection`, and `GetMarketDataWithPricesParams` types from the package root ([#8892](https://github.com/MetaMask/core/pull/8892)) + +### Changed + +- **BREAKING:** Replace `'equity'` with granular `MarketType` values: `'stock'`, `'pre-ipo'`, `'index'`, and `'etf'` ([#8892](https://github.com/MetaMask/core/pull/8892)) + - Update any code matching `marketType === 'equity'` to use the specific sub-type. + ## [6.3.0] ### Added diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index cdc4c733a4..1351266616 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -404,13 +404,18 @@ export type PerpsControllerGetMarketsAction = { }; /** - * Get market data with prices (includes price, volume, 24h change) + * Get market data with prices (includes price, volume, 24h change). + * Optionally filter by category, sort, and limit the results. * * For standalone mode, bypasses getActiveProvider() to allow market data queries * without full perps initialization (e.g., for background preloading on app start) * * @param params - The operation parameters. * @param params.standalone - Whether to use standalone mode. + * @param params.categories - Filter to markets matching any of these categories. + * @param params.sortBy - Sort results by this field. + * @param params.direction - Sort direction (default: desc). + * @param params.limit - Maximum number of results to return. * @returns A promise that resolves to the market data. */ export type PerpsControllerGetMarketDataWithPricesAction = { @@ -574,6 +579,18 @@ export type PerpsControllerGetCurrentNetworkAction = { handler: PerpsController['getCurrentNetwork']; }; +/** + * Get the ordered list of all market categories for HIP-3 markets. + * Returns a stable, explicitly ordered array so the UI can render + * category filter tabs without deriving order from config insertion. + * + * @returns Ordered array of {@link MarketTypeFilter} values. Does not include the 'all' or 'new' sentinels — those are separate UI controls. + */ +export type PerpsControllerGetMarketCategoriesAction = { + type: `PerpsController:getMarketCategories`; + handler: PerpsController['getMarketCategories']; +}; + /** * Get the current WebSocket connection state from the active provider. * Used by the UI to monitor connection health and show notifications. @@ -1050,6 +1067,7 @@ export type PerpsControllerMethodActions = | PerpsControllerToggleTestnetAction | PerpsControllerSwitchProviderAction | PerpsControllerGetCurrentNetworkAction + | PerpsControllerGetMarketCategoriesAction | PerpsControllerGetWebSocketConnectionStateAction | PerpsControllerSubscribeToConnectionStateAction | PerpsControllerReconnectAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index bc3fe86762..8e57403634 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -22,7 +22,6 @@ import { PERPS_CONSTANTS, MARKET_SORTING_CONFIG, PROVIDER_CONFIG, - PERPS_DISK_CACHE_MARKETS, PERPS_DISK_CACHE_USER_DATA, buildProviderCacheKey, MAX_SLIPPAGE_BOUNDS, @@ -47,6 +46,7 @@ import { PerpsTraceNames, PerpsTraceOperations, isVersionGatedFeatureFlag, + MARKET_CATEGORIES, // Platform dependencies interface for core migration (bundles all platform-specific deps) } from './types'; import type { @@ -68,6 +68,7 @@ import type { GetAccountStateParams, GetAvailableDexsParams, GetFundingParams, + GetMarketDataWithPricesParams, GetMarketsParams, GetOrderFillsParams, GetOrdersParams, @@ -110,8 +111,10 @@ import type { PerpsRemoteFeatureFlagState, PerpsTransactionParams, PerpsAddTransactionOptions, + MarketTypeFilter, MYXCredentials, } from './types'; +import type { SortDirection } from './types'; import type { PerpsControllerAllowedActions, PerpsControllerAllowedEvents, @@ -128,7 +131,6 @@ import { persistMarketEntriesToDisk, persistUserEntriesToDisk, } from './utils/perpsDiskPersistence'; -import type { SortDirection } from './utils/sortMarkets'; import { wait } from './utils/wait'; /** Derived type for logger options from PerpsLogger interface */ @@ -723,6 +725,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getCurrentNetwork', 'getFunding', 'getHistoricalPortfolio', + 'getMarketCategories', 'getMarketDataWithPrices', 'getMarketFilterPreferences', 'getMarkets', @@ -2933,28 +2936,41 @@ export class PerpsController extends BaseController< } /** - * Get market data with prices (includes price, volume, 24h change) + * Get market data with prices (includes price, volume, 24h change). + * Optionally filter by category, sort, and limit the results. * * For standalone mode, bypasses getActiveProvider() to allow market data queries * without full perps initialization (e.g., for background preloading on app start) * * @param params - The operation parameters. * @param params.standalone - Whether to use standalone mode. + * @param params.categories - Filter to markets matching any of these categories. + * @param params.sortBy - Sort results by this field. + * @param params.direction - Sort direction (default: desc). + * @param params.limit - Maximum number of results to return. * @returns A promise that resolves to the market data. */ - async getMarketDataWithPrices(params?: { - standalone?: boolean; - }): Promise { + async getMarketDataWithPrices( + params?: GetMarketDataWithPricesParams, + ): Promise { if (params?.standalone) { // Use activeProviderInstance if available (respects provider abstraction) // Fallback to cached standalone provider for pre-initialization discovery const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - return provider.getMarketDataWithPrices(); + return this.#marketDataService.getMarketDataWithPrices({ + provider, + params, + context: this.#createServiceContext('getMarketDataWithPrices'), + }); } const provider = this.getActiveProvider(); - return provider.getMarketDataWithPrices(); + return this.#marketDataService.getMarketDataWithPrices({ + provider, + params, + context: this.#createServiceContext('getMarketDataWithPrices'), + }); } // ============================================================================ @@ -3960,6 +3976,17 @@ export class PerpsController extends BaseController< return this.state.isTestnet ? 'testnet' : 'mainnet'; } + /** + * Get the ordered list of all market categories for HIP-3 markets. + * Returns a stable, explicitly ordered array so the UI can render + * category filter tabs without deriving order from config insertion. + * + * @returns Ordered array of {@link MarketTypeFilter} values. Does not include the 'all' or 'new' sentinels — those are separate UI controls. + */ + getMarketCategories(): MarketTypeFilter[] { + return MARKET_CATEGORIES; + } + /** * Get the current WebSocket connection state from the active provider. * Used by the UI to monitor connection health and show notifications. diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index 6551915bea..ec2354bc97 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -1,5 +1,7 @@ import type { CaipAssetId, CaipChainId, Hex } from '@metamask/utils'; +import { MarketCategory } from '../types'; +import type { MarketType } from '../types'; import type { HyperLiquidNetwork, HyperLiquidEndpoints, @@ -292,79 +294,107 @@ export const SPOT_ASSET_ID_OFFSET = 10000; * Maps asset symbols (e.g., "xyz:TSLA") to their market type for badge display. * * Market type determines the badge shown in the UI: - * - 'equity': STOCK badge (stocks like TSLA, NVDA) - * - 'commodity': COMMODITY badge (commodities like GOLD) - * - 'forex': FOREX badge (forex pairs) - * - undefined: No badge for crypto or unmapped assets + * - 'stock': Individual stocks (TSLA, NVDA, AAPL, etc.) + * - 'pre-ipo': Pre-IPO assets not yet publicly listed + * - 'index': Market indices (SP500, JP225, VIX, etc.) + * - 'etf': Exchange-traded funds (EWY, EWJ, USAR, etc.) + * - 'commodity': Commodities (GOLD, SILVER, CL, etc.) + * - 'forex': Forex pairs (EUR, JPY, DXY) + * - 'crypto': Explicitly categorized crypto assets + * - undefined: No badge for unmapped assets * * Format: 'dex:SYMBOL' → MarketType * This allows flexible per-asset classification. * Assets not listed here will have no market type (undefined). */ -export const HIP3_ASSET_MARKET_TYPES: Record< - string, - 'equity' | 'commodity' | 'forex' | 'crypto' -> = { - // xyz DEX - Equities - 'xyz:TSLA': 'equity', - 'xyz:NVDA': 'equity', - 'xyz:XYZ100': 'equity', - 'xyz:INTC': 'equity', - 'xyz:MU': 'equity', - 'xyz:CRCL': 'equity', - 'xyz:HOOD': 'equity', - 'xyz:SNDK': 'equity', - 'xyz:GOOGL': 'equity', - 'xyz:COIN': 'equity', - 'xyz:ORCL': 'equity', - 'xyz:AMZN': 'equity', - 'xyz:PLTR': 'equity', - 'xyz:AAPL': 'equity', - 'xyz:META': 'equity', - 'xyz:AMD': 'equity', - 'xyz:MSFT': 'equity', - 'xyz:BABA': 'equity', - 'xyz:RIVN': 'equity', - 'xyz:NFLX': 'equity', - 'xyz:COST': 'equity', - 'xyz:LLY': 'equity', - 'xyz:TSM': 'equity', - 'xyz:SKHX': 'equity', - 'xyz:MSTR': 'equity', - 'xyz:CRWV': 'equity', - 'xyz:SMSN': 'equity', - - 'xyz:GME': 'equity', - 'xyz:SOFTBANK': 'equity', - 'xyz:HYUNDAI': 'equity', - 'xyz:KIOXIA': 'equity', - 'xyz:HIMS': 'equity', - 'xyz:EWY': 'equity', - 'xyz:EWJ': 'equity', - 'xyz:SP500': 'equity', - 'xyz:JP225': 'equity', - 'xyz:KR200': 'equity', - 'xyz:VIX': 'equity', - 'xyz:USAR': 'equity', +export const HIP3_ASSET_MARKET_TYPES: Record = { + // xyz DEX - Stocks (US) + 'xyz:TSLA': MarketCategory.Stock, + 'xyz:NVDA': MarketCategory.Stock, + 'xyz:INTC': MarketCategory.Stock, + 'xyz:MU': MarketCategory.Stock, + 'xyz:CRCL': MarketCategory.Stock, + 'xyz:HOOD': MarketCategory.Stock, + 'xyz:SNDK': MarketCategory.Stock, + 'xyz:GOOGL': MarketCategory.Stock, + 'xyz:COIN': MarketCategory.Stock, + 'xyz:ORCL': MarketCategory.Stock, + 'xyz:AMZN': MarketCategory.Stock, + 'xyz:PLTR': MarketCategory.Stock, + 'xyz:AAPL': MarketCategory.Stock, + 'xyz:META': MarketCategory.Stock, + 'xyz:AMD': MarketCategory.Stock, + 'xyz:MSFT': MarketCategory.Stock, + 'xyz:BABA': MarketCategory.Stock, + 'xyz:RIVN': MarketCategory.Stock, + 'xyz:NFLX': MarketCategory.Stock, + 'xyz:COST': MarketCategory.Stock, + 'xyz:LLY': MarketCategory.Stock, + 'xyz:TSM': MarketCategory.Stock, + 'xyz:MSTR': MarketCategory.Stock, + 'xyz:CRWV': MarketCategory.Stock, + 'xyz:GME': MarketCategory.Stock, + 'xyz:HIMS': MarketCategory.Stock, + 'xyz:USAR': MarketCategory.Stock, + 'xyz:DKNG': MarketCategory.Stock, + 'xyz:BIRD': MarketCategory.Stock, + 'xyz:RKLB': MarketCategory.Stock, + 'xyz:MRVL': MarketCategory.Stock, + 'xyz:ZM': MarketCategory.Stock, + 'xyz:EBAY': MarketCategory.Stock, + 'xyz:CBRS': MarketCategory.Stock, + 'xyz:PURRDAT': MarketCategory.Stock, + 'xyz:ARM': MarketCategory.Stock, + 'xyz:BX': MarketCategory.Stock, + 'xyz:LITE': MarketCategory.Stock, + + // xyz DEX - Stocks (Korea) + 'xyz:SKHX': MarketCategory.Stock, + 'xyz:SMSN': MarketCategory.Stock, + 'xyz:HYUNDAI': MarketCategory.Stock, + + // xyz DEX - Stocks (Japan) + 'xyz:SOFTBANK': MarketCategory.Stock, + 'xyz:KIOXIA': MarketCategory.Stock, + + // xyz DEX - Pre-IPO + 'xyz:SPCX': MarketCategory.PreIpo, + + // xyz DEX - Indices + 'xyz:SP500': MarketCategory.Index, + 'xyz:XYZ100': MarketCategory.Index, + 'xyz:JP225': MarketCategory.Index, + 'xyz:KR200': MarketCategory.Index, + 'xyz:VIX': MarketCategory.Index, + + // xyz DEX - ETFs + 'xyz:EWY': MarketCategory.Etf, + 'xyz:EWJ': MarketCategory.Etf, + 'xyz:EWT': MarketCategory.Etf, + 'xyz:EWZ': MarketCategory.Etf, + 'xyz:URNM': MarketCategory.Etf, + 'xyz:DRAM': MarketCategory.Etf, + 'xyz:XLE': MarketCategory.Etf, // xyz DEX - Commodities - 'xyz:GOLD': 'commodity', - 'xyz:SILVER': 'commodity', - 'xyz:CL': 'commodity', - 'xyz:COPPER': 'commodity', - 'xyz:ALUMINIUM': 'commodity', - 'xyz:URANIUM': 'commodity', - 'xyz:URNM': 'commodity', - 'xyz:NATGAS': 'commodity', - 'xyz:PLATINUM': 'commodity', - 'xyz:PALLADIUM': 'commodity', - 'xyz:BRENTOIL': 'commodity', + 'xyz:GOLD': MarketCategory.Commodity, + 'xyz:SILVER': MarketCategory.Commodity, + 'xyz:CL': MarketCategory.Commodity, + 'xyz:WTIOIL': MarketCategory.Commodity, + 'xyz:COPPER': MarketCategory.Commodity, + 'xyz:ALUMINIUM': MarketCategory.Commodity, + 'xyz:URANIUM': MarketCategory.Commodity, + 'xyz:NATGAS': MarketCategory.Commodity, + 'xyz:PLATINUM': MarketCategory.Commodity, + 'xyz:PALLADIUM': MarketCategory.Commodity, + 'xyz:BRENTOIL': MarketCategory.Commodity, // xyz DEX - Forex - 'xyz:EUR': 'forex', - 'xyz:JPY': 'forex', - 'xyz:DXY': 'forex', -} as const; + 'xyz:EUR': MarketCategory.Forex, + 'xyz:JPY': MarketCategory.Forex, + 'xyz:GBP': MarketCategory.Forex, + 'xyz:DXY': MarketCategory.Forex, +}; /** * Testnet-specific HIP-3 DEX configuration diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 64d331b75b..6a4382638f 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -72,6 +72,7 @@ export type { PerpsControllerGetHistoricalPortfolioAction, PerpsControllerGetMarketDataWithPricesAction, PerpsControllerGetMarketFilterPreferencesAction, + PerpsControllerGetMarketCategoriesAction, PerpsControllerGetMarketsAction, PerpsControllerGetMaxLeverageAction, PerpsControllerGetOpenOrdersAction, @@ -132,7 +133,12 @@ export type { export { HyperLiquidProvider } from './providers/HyperLiquidProvider'; // Type definitions (explicit named exports) -export { WebSocketConnectionState, PerpsAnalyticsEvent } from './types'; +export { + WebSocketConnectionState, + PerpsAnalyticsEvent, + MARKET_CATEGORIES, + MarketCategory, +} from './types'; export type { RawLedgerUpdate, UserHistoryItem, @@ -195,6 +201,9 @@ export type { GetSupportedPathsParams, GetAvailableDexsParams, GetMarketsParams, + GetMarketDataWithPricesParams, + SortField, + SortDirection, SubscribePricesParams, SubscribePositionsParams, SubscribeOrderFillsParams, @@ -511,7 +520,7 @@ export { hasExceededSignificantFigures, roundToSignificantFigures, } from './utils'; -export type { SortField, SortDirection, SortMarketsParams } from './utils'; +export type { SortMarketsParams } from './utils'; export { parseVolume, sortMarkets } from './utils'; export type { StandaloneInfoClientOptions } from './utils'; export { diff --git a/packages/perps-controller/src/selectors.ts b/packages/perps-controller/src/selectors.ts index a7e0b1cf4a..f47b186061 100644 --- a/packages/perps-controller/src/selectors.ts +++ b/packages/perps-controller/src/selectors.ts @@ -2,8 +2,7 @@ import { createSelector } from 'reselect'; import { MARKET_SORTING_CONFIG, SortOptionId } from './constants/perpsConfig'; import type { PerpsControllerState } from './PerpsController'; -import type { PerpsSelectedPaymentToken } from './types'; -import type { SortDirection } from './utils/sortMarkets'; +import type { PerpsSelectedPaymentToken, SortDirection } from './types'; /** * Select whether the user is a first-time perps user diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index 102e0506ff..15919a2450 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -4,7 +4,11 @@ import type { CandlePeriod } from '../constants/chartConfig'; import { PerpsMeasurementName } from '../constants/performanceMetrics'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; -import { PerpsTraceNames, PerpsTraceOperations } from '../types'; +import { + MarketCategory, + PerpsTraceNames, + PerpsTraceOperations, +} from '../types'; import type { PerpsProvider, Position, @@ -20,6 +24,7 @@ import type { Order, GetOrdersParams, MarketInfo, + GetMarketDataWithPricesParams, GetMarketsParams, GetAvailableDexsParams, LiquidationPriceParams, @@ -30,10 +35,13 @@ import type { ClosePositionParams, 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 type { ServiceContext } from './ServiceContext'; /** @@ -799,6 +807,83 @@ export class MarketDataService { } } + /** + * Get market data with prices (includes price, volume, 24h change). + * Applies optional category filtering, sorting, and limit after fetching. + * + * @param options - The configuration options. + * @param options.provider - The perps provider instance. + * @param options.params - Optional filter/sort/limit params. + * @param options.context - The service context for dependencies. + * @returns The result of the operation. + */ + async getMarketDataWithPrices(options: { + provider: PerpsProvider; + params?: GetMarketDataWithPricesParams; + context: ServiceContext; + }): Promise { + const { provider, params, context } = options; + const traceId = uuidv4(); + let traceData: { success: boolean; error?: string } | undefined; + + try { + this.#deps.tracer.trace({ + name: PerpsTraceNames.GetMarketDataWithPrices, + id: traceId, + op: PerpsTraceOperations.Operation, + tags: { + provider: context.tracingContext.provider, + isTestnet: String(context.tracingContext.isTestnet), + ...(params?.categories && { + categoryCount: String(params.categories.length), + }), + }, + }); + + const markets = await provider.getMarketDataWithPrices(); + const filtered = applyMarketFilters(markets, params); + + traceData = { success: true }; + return filtered; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : PERPS_ERROR_CODES.MARKETS_FAILED; + + this.#deps.logger.error( + ensureError(error, 'MarketDataService.getMarketDataWithPrices'), + { + tags: { + feature: PERPS_CONSTANTS.FeatureName, + provider: context.tracingContext.provider, + network: context.tracingContext.isTestnet ? 'testnet' : 'mainnet', + }, + context: { + name: context.errorContext.controller, + data: { + method: context.errorContext.method, + params, + }, + }, + }, + ); + + traceData = { + success: false, + error: errorMessage, + }; + + throw error; + } finally { + this.#deps.tracer.endTrace({ + name: PerpsTraceNames.GetMarketDataWithPrices, + id: traceId, + data: traceData, + }); + } + } + /** * Get available DEXs (HIP-3 support required) * @@ -1173,3 +1258,88 @@ 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 967c8d60d7..f9402d5bce 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -74,18 +74,47 @@ export type TradeConfiguration = { }; // Market asset type classification (reusable across components) -export type MarketType = 'crypto' | 'equity' | 'commodity' | 'forex'; +export enum MarketCategory { + CryptoCurrency = 'crypto', + Stock = 'stock', + PreIpo = 'pre-ipo', + Index = 'index', + Etf = 'etf', + Commodity = 'commodity', + Forex = 'forex', +} + +export type MarketType = `${MarketCategory}`; // Market type filter for UI category badges -// Note: 'stocks' maps to 'equity' and 'commodities' maps to 'commodity' in the data model +// Note: 'stocks' maps to 'stock', 'commodities' maps to 'commodity' in the data model export type MarketTypeFilter = | 'all' | 'crypto' | 'stocks' + | 'pre-ipo' + | 'indices' + | 'etfs' | 'commodities' | 'forex' | 'new'; +/** + * Ordered list of the 7 data-model market categories for UI pills. + * Does not include the 'all' or 'new' sentinel values — those are applied + * via dedicated UI controls, not the category pills. + * Kept in sync with {@link MarketTypeFilter} via `satisfies`. + */ +export const MARKET_CATEGORIES = [ + 'crypto', + 'stocks', + 'pre-ipo', + 'indices', + 'etfs', + 'commodities', + 'forex', +] as const satisfies MarketTypeFilter[]; + // Input method for amount entry tracking export type InputMethod = | 'default' @@ -422,7 +451,10 @@ export type PerpsMarketData = { /** * Market asset type classification (optional) * - crypto: Cryptocurrency (default for most markets) - * - equity: Stock/equity markets (HIP-3) + * - stock: Individual stocks (HIP-3) + * - pre-ipo: Pre-IPO assets (HIP-3) + * - index: Market indices (HIP-3) + * - etf: Exchange-traded funds (HIP-3) * - commodity: Commodity markets (HIP-3) * - forex: Foreign exchange pairs (HIP-3) */ @@ -783,6 +815,16 @@ export type GetSupportedPathsParams = { /** Placeholder for future filter/pagination params (e.g., validated, chain). Empty today so the API signature is stable. */ export type GetAvailableDexsParams = Record; +/** Field to sort markets by. */ +export type SortField = + | 'volume' + | 'priceChange' + | 'fundingRate' + | 'openInterest'; + +/** Direction for market sorting. */ +export type SortDirection = 'asc' | 'desc'; + export type GetMarketsParams = { symbols?: string[]; // Optional symbol filter (e.g., ['BTC', 'xyz:XYZ100']) dex?: string; // HyperLiquid HIP-3: DEX name (empty string '' or undefined for main DEX). Other protocols: ignored. @@ -790,6 +832,20 @@ export type GetMarketsParams = { standalone?: boolean; // Lightweight mode: skip full initialization, only fetch market metadata (no wallet/WebSocket needed). Only main DEX markets returned. Use for discovery use cases like checking if a perps market exists. }; +/** + * Parameters for {@link PerpsController.getMarketDataWithPrices}. + * Extends the base market-fetch params with optional category filtering, + * sorting, and pagination that are applied as post-processing. + */ +export type GetMarketDataWithPricesParams = { + standalone?: boolean; // Lightweight mode: see GetMarketsParams.standalone + categories?: MarketTypeFilter[]; // Filter to markets matching any of these categories; omit for all markets + excludeSymbols?: string[]; // Symbols to exclude from results (e.g. the currently viewed market) + sortBy?: SortField; // Sort results by this field + direction?: SortDirection; // Sort direction (default: desc) + limit?: number; // Maximum number of results to return +}; + export type SubscribePricesParams = { symbols: string[]; callback: (prices: PriceUpdate[]) => void; @@ -1303,6 +1359,7 @@ export type PerpsTraceName = | 'Perps Get Account State' | 'Perps Get Historical Portfolio' | 'Perps Get Markets' + | 'Perps Get Market Data With Prices' | 'Perps Fetch Historical Candles' | 'Perps WebSocket Connected' | 'Perps WebSocket Disconnected' @@ -1340,6 +1397,7 @@ export const PerpsTraceNames = { GetPositions: 'Perps Get Positions', GetAccountState: 'Perps Get Account State', GetMarkets: 'Perps Get Markets', + GetMarketDataWithPrices: 'Perps Get Market Data With Prices', OrderFillsFetch: 'Perps Order Fills Fetch', OrdersFetch: 'Perps Orders Fetch', FundingFetch: 'Perps Funding Fetch', diff --git a/packages/perps-controller/src/utils/sortMarkets.ts b/packages/perps-controller/src/utils/sortMarkets.ts index 902e1f29c1..cb643c4a0d 100644 --- a/packages/perps-controller/src/utils/sortMarkets.ts +++ b/packages/perps-controller/src/utils/sortMarkets.ts @@ -2,14 +2,7 @@ import { MARKET_SORTING_CONFIG, PERPS_CONSTANTS, } from '../constants/perpsConfig'; -import type { PerpsMarketData } from '../types'; - -export type SortField = - | 'volume' - | 'priceChange' - | 'fundingRate' - | 'openInterest'; -export type SortDirection = 'asc' | 'desc'; +import type { PerpsMarketData, SortDirection, SortField } from '../types'; export type SortMarketsParams = { markets: PerpsMarketData[]; diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts index bacb51ff5d..944c5e8fd0 100644 --- a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -176,6 +176,15 @@ const mockMarketDataServiceInstance = { getPositions: jest.fn(), getAccountState: jest.fn(), getMarkets: jest.fn(), + getMarketDataWithPrices: jest + .fn() + .mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ), getWithdrawalRoutes: jest.fn().mockReturnValue([]), validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), validateOrder: jest.fn(), @@ -483,6 +492,13 @@ describe('PerpsController', () => { returnOnEquity: '0', }); mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ); mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ isValid: true, diff --git a/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts new file mode 100644 index 0000000000..c75c4c5899 --- /dev/null +++ b/packages/perps-controller/tests/src/PerpsController.market-filtering.test.ts @@ -0,0 +1,498 @@ +/** + * Tests for PerpsController market filtering, sorting, and pagination: + * - getMarketCategories() + * - getMarketDataWithPrices({ categories, sortBy, direction, limit }) + */ + +/* eslint-disable */ + +import { + PerpsController, + getDefaultPerpsControllerState, + InitializationState, +} from '../../src/PerpsController'; +import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider'; +import type { + PerpsProvider, + PerpsProviderType, + PerpsPlatformDependencies, + PerpsMarketData, +} from '../../src/types'; +import { MARKET_CATEGORIES, MarketCategory } from '../../src/types'; +import { createMockHyperLiquidProvider } from '../helpers/providerMocks'; +import { + createMockInfrastructure, + createMockMessenger, +} from '../helpers/serviceMocks'; + +jest.mock('@nktkas/hyperliquid', () => ({})); +jest.mock('@myx-trade/sdk', () => ({ + MyxClient: jest.fn(), + OrderStatusEnum: { Successful: 9 }, +})); + +jest.mock( + '../../../core/Engine', + () => ({ + __esModule: true, + default: { + context: { + RewardsController: { getPerpsDiscountForAccount: jest.fn() }, + NetworkController: { + getNetworkClientById: jest.fn().mockReturnValue({ + configuration: { chainId: '0x1' }, + }), + }, + AccountTreeController: { + getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([]), + }, + TransactionController: { + estimateGasFee: jest.fn(), + estimateGas: jest.fn(), + }, + AccountTrackerController: { + state: { accountsByChainId: {} }, + }, + }, + }, + }), + { virtual: true }, +); + +jest.mock('../../src/providers/HyperLiquidProvider'); +jest.mock('../../src/providers/MYXProvider'); + +jest.mock('../../src/utils/wait', () => ({ + wait: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../src/services/EligibilityService', () => ({ + EligibilityService: jest.fn().mockImplementation(() => ({ + checkAndUpdateEligibility: jest + .fn() + .mockResolvedValue({ isEligible: true }), + isCurrentlyEligible: jest.fn().mockReturnValue(true), + subscribeToEligibilityChanges: jest.fn().mockReturnValue(() => undefined), + })), +})); + +/** Expose protected methods for testing. */ +class TestablePerpsController extends PerpsController { + public testMarkInitialized() { + this.isInitialized = true; + this.update((state) => { + state.initializationState = InitializationState.Initialized; + }); + } + + public testSetProviders(providers: Map) { + this.providers = providers; + const firstProvider = providers.values().next().value; + if (firstProvider) { + this.activeProviderInstance = firstProvider; + } + } +} + +/** Build a minimal PerpsMarketData object. */ +function buildMarket( + overrides: Partial = {}, +): PerpsMarketData { + return { + symbol: 'TEST', + name: 'Test Market', + maxLeverage: '10x', + price: '$100.00', + change24h: '$0.00', + change24hPercent: '0%', + volume: '$1M', + ...overrides, + }; +} + +describe('PerpsController — market categories & filtering', () => { + let controller: TestablePerpsController; + let mockProvider: jest.Mocked; + let mockInfrastructure: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + mockProvider = createMockHyperLiquidProvider(); + mockInfrastructure = createMockInfrastructure(); + + const mockCall = jest.fn().mockReturnValue(undefined); + + controller = new TestablePerpsController({ + messenger: createMockMessenger({ call: mockCall }), + state: getDefaultPerpsControllerState(), + infrastructure: mockInfrastructure, + }); + + controller.testSetProviders( + new Map([['hyperliquid', mockProvider as unknown as PerpsProvider]]), + ); + controller.testMarkInitialized(); + }); + + // ============================================================================ + // getMarketCategories + // ============================================================================ + + describe('getMarketCategories', () => { + it('returns the MARKET_CATEGORIES constant', () => { + expect(controller.getMarketCategories()).toStrictEqual(MARKET_CATEGORIES); + }); + + it('does not include the all or new sentinel values', () => { + const categories = controller.getMarketCategories(); + expect(categories).not.toContain('all'); + expect(categories).not.toContain('new'); + }); + + it('includes all 7 data categories', () => { + const categories = controller.getMarketCategories(); + expect(categories).toContain('crypto'); + expect(categories).toContain('stocks'); + expect(categories).toContain('pre-ipo'); + expect(categories).toContain('indices'); + expect(categories).toContain('etfs'); + expect(categories).toContain('commodities'); + expect(categories).toContain('forex'); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — category filtering + // ============================================================================ + + describe('getMarketDataWithPrices — category filtering', () => { + it('returns unfiltered results when no params are provided', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices(); + + expect(result).toHaveLength(2); + }); + + it('filters to only crypto markets when categories is ["crypto"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ symbol: 'ETH', isHip3: false }), + buildMarket({ + symbol: 'xyz:CRYPTO1', + isHip3: true, + marketType: MarketCategory.CryptoCurrency, + }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['crypto'], + }); + + // Non-HIP3 markets + HIP-3 assets explicitly typed CryptoCurrency; excludes Stock + expect(result).toHaveLength(3); + const symbols = result.map((m) => m.symbol); + expect(symbols).toContain('BTC'); + expect(symbols).toContain('ETH'); + expect(symbols).toContain('xyz:CRYPTO1'); + expect(symbols).not.toContain('xyz:TSLA'); + }); + + it('filters to only stock markets when categories is ["stocks"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:NVDA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:EUR', + isHip3: true, + marketType: MarketCategory.Forex, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stocks'], + }); + + expect(result).toHaveLength(2); + expect(result.every((m) => m.marketType === MarketCategory.Stock)).toBe( + true, + ); + }); + + it('returns the union of matched markets when multiple categories are given', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + buildMarket({ + symbol: 'xyz:SPY', + isHip3: true, + marketType: MarketCategory.Etf, + }), + buildMarket({ + symbol: 'xyz:EUR', + isHip3: true, + marketType: MarketCategory.Forex, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stocks', 'etfs'], + }); + + expect(result).toHaveLength(2); + const symbols = result.map((m) => m.symbol); + expect(symbols).toContain('xyz:TSLA'); + expect(symbols).toContain('xyz:SPY'); + }); + + it('returns all markets when categories contains "all"', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['all'], + }); + + expect(result).toHaveLength(2); + }); + + it('filters to isNewMarket markets when categories is ["new"]', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isNewMarket: false }), + buildMarket({ + symbol: 'xyz:NEWTOKEN', + isHip3: true, + isNewMarket: true, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['new'], + }); + + expect(result).toHaveLength(1); + expect(result[0].symbol).toBe('xyz:NEWTOKEN'); + }); + + it('returns empty array when no markets match the given categories', async () => { + const markets = [buildMarket({ symbol: 'BTC', isHip3: false })]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['forex'], + }); + + expect(result).toHaveLength(0); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — excludeSymbols + // ============================================================================ + + describe('getMarketDataWithPrices — excludeSymbols', () => { + it('excludes a single symbol from results', async () => { + const markets = [ + buildMarket({ symbol: 'BTC' }), + buildMarket({ symbol: 'ETH' }), + buildMarket({ symbol: 'SOL' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + excludeSymbols: ['ETH'], + }); + + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'SOL']); + }); + + it('excludes multiple symbols from results', async () => { + const markets = [ + buildMarket({ symbol: 'BTC' }), + buildMarket({ symbol: 'ETH' }), + buildMarket({ symbol: 'SOL' }), + buildMarket({ symbol: 'AVAX' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + excludeSymbols: ['ETH', 'SOL'], + }); + + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'AVAX']); + }); + + it('applies excludeSymbols after category filter and before limit', async () => { + const markets = [ + buildMarket({ symbol: 'BTC', isHip3: false }), + buildMarket({ symbol: 'ETH', isHip3: false }), + buildMarket({ symbol: 'SOL', isHip3: false }), + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['crypto'], + excludeSymbols: ['ETH'], + limit: 10, + }); + + // Stock excluded by category, ETH excluded by excludeSymbols + expect(result.map((m) => m.symbol)).toStrictEqual(['BTC', 'SOL']); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — sorting + // ============================================================================ + + describe('getMarketDataWithPrices — sorting', () => { + it('sorts by openInterest descending when sortBy is "openInterest"', async () => { + const markets = [ + buildMarket({ symbol: 'LOW', openInterest: '$1M' }), + buildMarket({ symbol: 'HIGH', openInterest: '$5M' }), + buildMarket({ symbol: 'MID', openInterest: '$3M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + sortBy: 'openInterest', + direction: 'desc', + }); + + expect(result[0].symbol).toBe('HIGH'); + expect(result[1].symbol).toBe('MID'); + expect(result[2].symbol).toBe('LOW'); + }); + + it('sorts by openInterest ascending when direction is "asc"', async () => { + const markets = [ + buildMarket({ symbol: 'HIGH', openInterest: '$5M' }), + buildMarket({ symbol: 'LOW', openInterest: '$1M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + sortBy: 'openInterest', + direction: 'asc', + }); + + expect(result[0].symbol).toBe('LOW'); + expect(result[1].symbol).toBe('HIGH'); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — limit + // ============================================================================ + + describe('getMarketDataWithPrices — limit', () => { + it('returns at most `limit` markets', async () => { + const markets = Array.from({ length: 10 }, (_, i) => + buildMarket({ symbol: `MARKET${i}` }), + ); + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ limit: 3 }); + + expect(result).toHaveLength(3); + }); + + it('returns all markets when limit exceeds total count', async () => { + const markets = [ + buildMarket({ symbol: 'A' }), + buildMarket({ symbol: 'B' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ limit: 100 }); + + expect(result).toHaveLength(2); + }); + }); + + // ============================================================================ + // getMarketDataWithPrices — params compose together + // ============================================================================ + + describe('getMarketDataWithPrices — composed params', () => { + it('applies categories filter, then sort, then limit in order', async () => { + const markets = [ + buildMarket({ + symbol: 'xyz:TSLA', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$5M', + }), + buildMarket({ + symbol: 'xyz:NVDA', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$3M', + }), + buildMarket({ + symbol: 'xyz:AAPL', + isHip3: true, + marketType: MarketCategory.Stock, + openInterest: '$8M', + }), + buildMarket({ symbol: 'BTC', isHip3: false, openInterest: '$100M' }), + ]; + mockProvider.getMarketDataWithPrices.mockResolvedValue(markets); + + const result = await controller.getMarketDataWithPrices({ + categories: ['stocks'], + sortBy: 'openInterest', + direction: 'desc', + limit: 2, + }); + + // Only stocks, sorted by OI desc, top 2 + expect(result).toHaveLength(2); + expect(result[0].symbol).toBe('xyz:AAPL'); // $8M + expect(result[1].symbol).toBe('xyz:TSLA'); // $5M + }); + }); +}); diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index 76fc7a3137..637bcfffb9 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -176,6 +176,15 @@ const mockMarketDataServiceInstance = { getPositions: jest.fn(), getAccountState: jest.fn(), getMarkets: jest.fn(), + getMarketDataWithPrices: jest + .fn() + .mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ), getWithdrawalRoutes: jest.fn().mockReturnValue([]), validateClosePosition: jest.fn().mockResolvedValue({ isValid: true }), validateOrder: jest.fn(), @@ -483,6 +492,13 @@ describe('PerpsController', () => { returnOnEquity: '0', }); mockMarketDataServiceInstance.getMarkets.mockResolvedValue([]); + mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( + ({ + provider, + }: { + provider: { getMarketDataWithPrices: () => Promise }; + }) => provider.getMarketDataWithPrices(), + ); mockMarketDataServiceInstance.getWithdrawalRoutes.mockReturnValue([]); mockMarketDataServiceInstance.validateClosePosition.mockResolvedValue({ isValid: true, diff --git a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts index 84ee8e319e..2d5cf519fc 100644 --- a/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts +++ b/packages/perps-controller/tests/src/constants/hyperLiquidConfig.test.ts @@ -1,28 +1,152 @@ import { HIP3_ASSET_MARKET_TYPES } from '../../../src/constants/hyperLiquidConfig'; +import { MarketCategory, MARKET_CATEGORIES } from '../../../src/types'; +import type { MarketType, MarketTypeFilter } from '../../../src/types'; describe('HIP3_ASSET_MARKET_TYPES', () => { - it('classifies URNM as commodity (Sprott Uranium Miners ETF)', () => { - expect(HIP3_ASSET_MARKET_TYPES['xyz:URNM']).toBe('commodity'); + it('classifies known US stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:TSLA']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:NVDA']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:AAPL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:GOOGL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:AMZN']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:META']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:MSFT']).toBe('stock'); }); - it('classifies USAR as equity (US equity fund)', () => { - expect(HIP3_ASSET_MARKET_TYPES['xyz:USAR']).toBe('equity'); + it('classifies newly added US stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:DKNG']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BIRD']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:RKLB']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:MRVL']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:ZM']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EBAY']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:CBRS']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:PURRDAT']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:ARM']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BX']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:LITE']).toBe('stock'); }); - it('classifies known equities correctly', () => { - expect(HIP3_ASSET_MARKET_TYPES['xyz:TSLA']).toBe('equity'); - expect(HIP3_ASSET_MARKET_TYPES['xyz:NVDA']).toBe('equity'); - expect(HIP3_ASSET_MARKET_TYPES['xyz:AAPL']).toBe('equity'); + it('classifies USAR as stock (USA Rare Earth)', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:USAR']).toBe('stock'); + }); + + it('classifies Korean stocks correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:SKHX']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:SMSN']).toBe('stock'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:HYUNDAI']).toBe('stock'); + }); + + it('classifies pre-IPO markets correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:SPCX']).toBe('pre-ipo'); + }); + + it('classifies known indices correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:SP500']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:XYZ100']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:JP225']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:KR200']).toBe('index'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:VIX']).toBe('index'); + }); + + it('classifies known ETFs correctly', () => { + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWY']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWJ']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWT']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:EWZ']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:URNM']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:DRAM']).toBe('etf'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:XLE']).toBe('etf'); }); it('classifies known commodities correctly', () => { expect(HIP3_ASSET_MARKET_TYPES['xyz:GOLD']).toBe('commodity'); expect(HIP3_ASSET_MARKET_TYPES['xyz:SILVER']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:CL']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:WTIOIL']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:COPPER']).toBe('commodity'); expect(HIP3_ASSET_MARKET_TYPES['xyz:URANIUM']).toBe('commodity'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:BRENTOIL']).toBe('commodity'); }); it('classifies known forex pairs correctly', () => { expect(HIP3_ASSET_MARKET_TYPES['xyz:EUR']).toBe('forex'); expect(HIP3_ASSET_MARKET_TYPES['xyz:JPY']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:GBP']).toBe('forex'); + expect(HIP3_ASSET_MARKET_TYPES['xyz:DXY']).toBe('forex'); + }); + + it('derives unique market categories from config', () => { + const categories: MarketType[] = [ + ...new Set(Object.values(HIP3_ASSET_MARKET_TYPES)), + ]; + expect(categories).toContain('stock'); + expect(categories).toContain('pre-ipo'); + expect(categories).toContain('index'); + expect(categories).toContain('etf'); + expect(categories).toContain('commodity'); + expect(categories).toContain('forex'); + expect(categories).not.toContain('equity'); + expect(categories).not.toContain('crypto'); + }); +}); + +describe('MarketCategory', () => { + it('has string values for all 7 data-model categories', () => { + expect(MarketCategory.CryptoCurrency).toBe('crypto'); + expect(MarketCategory.Stock).toBe('stock'); + expect(MarketCategory.PreIpo).toBe('pre-ipo'); + expect(MarketCategory.Index).toBe('index'); + expect(MarketCategory.Etf).toBe('etf'); + expect(MarketCategory.Commodity).toBe('commodity'); + expect(MarketCategory.Forex).toBe('forex'); + }); + + it('has exactly 7 members', () => { + const values = Object.values(MarketCategory); + expect(values).toHaveLength(7); + }); +}); + +describe('MARKET_CATEGORIES', () => { + it('has exactly 7 entries (one per data-model category)', () => { + expect(MARKET_CATEGORIES).toHaveLength(7); + }); + + it('does not include the all or new sentinel values', () => { + expect(MARKET_CATEGORIES).not.toContain('all'); + expect(MARKET_CATEGORIES).not.toContain('new'); + }); + + it('includes all 7 MarketTypeFilter data categories', () => { + const dataCategories: MarketTypeFilter[] = [ + 'crypto', + 'stocks', + 'pre-ipo', + 'indices', + 'etfs', + 'commodities', + 'forex', + ]; + for (const category of dataCategories) { + expect(MARKET_CATEGORIES).toContain(category); + } + }); + + it('satisfies MarketTypeFilter[] so no unknown values exist', () => { + // Compile-time guarantee: MARKET_CATEGORIES is typed as readonly MarketTypeFilter[] + // The runtime check here mirrors that constraint. + const validValues: readonly string[] = [ + 'crypto', + 'stocks', + 'pre-ipo', + 'indices', + 'etfs', + 'commodities', + 'forex', + ]; + for (const entry of MARKET_CATEGORIES) { + expect(validValues).toContain(entry); + } }); });