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
3 changes: 3 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Report the take profit (or stop loss) price on a `Position` when its only trigger for that direction is a partial, quantity-scoped one ([#9912](https://github.com/MetaMask/core/pull/9912))
- `takeProfitPrice`/`stopLossPrice` were only ever scanned from position-bound triggers, so a position whose sole take profit closed it partially reported `takeProfitCount: 1` with no price, and clients rendering the scalar showed none. Applies to the REST `getPositions`, `getUserDataSnapshot`, and WebSocket position paths alike.
- Two or more triggers in a direction still report the scanned price, because no single price describes them and clients render the count instead.
- Map Terminal v1 `category` aliases (`pre_ipo` → `pre-ipo`, `stocks` → `stock`) when `marketType` is absent, so Pre-IPO markets such as Unitree appear under that filter without a static symbol list
- Clear `isNewMarket` on the v1 Terminal enrich path once a `marketType` is applied, matching the v2 snapshot rule so categorized HIP-3 markets are not also in the controller `new` bucket
- Reclassify `xyz:CBRS` and `xyz:SPCX` from `pre-ipo` to `stock` in `HIP3_ASSET_MARKET_TYPES` now that they are public (Terminal already sends `stocks`). Leave `xyz:IPOP` as Pre-IPO

## [12.1.0]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ export const HIP3_ASSET_MARKET_TYPES: Record<string, MarketType> = {
'xyz:ARM': MarketCategory.Stock,
'xyz:BX': MarketCategory.Stock,
'xyz:LITE': MarketCategory.Stock,
'xyz:CBRS': MarketCategory.Stock,
'xyz:SPCX': MarketCategory.Stock,

// xyz DEX - Stocks (Korea)
'xyz:SKHX': MarketCategory.Stock,
Expand All @@ -378,8 +380,6 @@ export const HIP3_ASSET_MARKET_TYPES: Record<string, MarketType> = {
'xyz:KIOXIA': MarketCategory.Stock,

// xyz DEX - Pre-IPO
'xyz:CBRS': MarketCategory.PreIpo,
'xyz:SPCX': MarketCategory.PreIpo,
'xyz:IPOP': MarketCategory.PreIpo,

// xyz DEX - Indices
Expand Down
11 changes: 8 additions & 3 deletions packages/perps-controller/src/services/MarketDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1414,8 +1414,10 @@ export class MarketDataService {
* Merge Terminal API metadata into provider-sourced PerpsMarketData.
* For each market, if the terminal metadata map contains an entry for its
* symbol, override name/description/marketType and attach
* keywords/tags/categories. Unmatched markets keep their provider-sourced
* values.
* keywords/tags/categories. Applying a marketType clears a stale
* isNewMarket flag so the v1 enrich path matches the v2 snapshot rule
* (categorized HIP-3 is not the controller "new" bucket). Unmatched markets
* keep their provider-sourced values.
*
* @param markets - Markets from the provider.
* @param metadata - Per-symbol metadata from the Terminal API.
Expand All @@ -1437,7 +1439,10 @@ export class MarketDataService {
...(meta.description !== undefined && {
description: meta.description,
}),
...(meta.marketType !== undefined && { marketType: meta.marketType }),
...(meta.marketType !== undefined && {
marketType: meta.marketType,
...(market.isNewMarket === true && { isNewMarket: false }),
}),
...(meta.keywords !== undefined && { keywords: meta.keywords }),
...(meta.tags !== undefined && { tags: meta.tags }),
...(meta.categories !== undefined && { categories: meta.categories }),
Expand Down
24 changes: 18 additions & 6 deletions packages/perps-controller/src/services/TerminalMarketService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const TerminalPerpetualItemStruct = type({
minimumOrderSize: optional(number()),
keywords: optional(nullable(array(string()))),
tags: optional(nullable(array(string()))),
category: optional(nullable(string())),
categories: optional(nullable(array(string()))),
marketType: optional(nullable(string())),
listedAt: optional(nullable(union([number(), string()]))),
Expand Down Expand Up @@ -633,13 +634,9 @@ export class TerminalMarketService {
};
}

#marketTypeFor(
dex: string,
category: string | null,
#categoryToMarketType(
category: string | null | undefined,
): TerminalAssetMetadata['marketType'] | undefined {
if (dex === 'main') {
return MarketCategory.CryptoCurrency;
}
if (category === 'stocks') {
return MarketCategory.Stock;
}
Expand All @@ -652,6 +649,16 @@ export class TerminalMarketService {
return undefined;
}

#marketTypeFor(
dex: string,
category: string | null,
): TerminalAssetMetadata['marketType'] | undefined {
if (dex === 'main') {
return MarketCategory.CryptoCurrency;
}
return this.#categoryToMarketType(category);
}

#cloneGlobalSnapshotResult(
result: PerpsGlobalSnapshotResult,
): PerpsGlobalSnapshotResult {
Expand Down Expand Up @@ -788,6 +795,11 @@ export class TerminalMarketService {
) {
entry.marketType =
item.marketType as TerminalAssetMetadata['marketType'];
} else {
const fromCategory = this.#categoryToMarketType(item.category);
if (fromCategory) {
entry.marketType = fromCategory;
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (item.listedAt !== null && item.listedAt !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ describe('HIP3_ASSET_MARKET_TYPES', () => {
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');
expect(HIP3_ASSET_MARKET_TYPES['xyz:CBRS']).toBe('stock');
expect(HIP3_ASSET_MARKET_TYPES['xyz:SPCX']).toBe('stock');
});

it('classifies USAR as stock (USA Rare Earth)', () => {
Expand All @@ -37,8 +39,6 @@ describe('HIP3_ASSET_MARKET_TYPES', () => {
});

it('classifies pre-IPO markets correctly', () => {
expect(HIP3_ASSET_MARKET_TYPES['xyz:CBRS']).toBe('pre-ipo');
expect(HIP3_ASSET_MARKET_TYPES['xyz:SPCX']).toBe('pre-ipo');
expect(HIP3_ASSET_MARKET_TYPES['xyz:IPOP']).toBe('pre-ipo');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from '../../../src/types/index.js';
import type { CandleData } from '../../../src/types/perps-types.js';
import { resetPerpsRestCacheForTests } from '../../../src/utils/coalescePerpsRestRequest.js';
import { matchesCategory } from '../../../src/utils/marketUtils.js';
/* eslint-disable */
import {
createMockHyperLiquidProvider,
Expand Down Expand Up @@ -1683,11 +1684,85 @@ describe('MarketDataService', () => {
expect(result[0]?.tags).toEqual(['top-10']);
expect(result[0]?.categories).toEqual(['crypto']);
expect(result[0]?.marketType).toBe('crypto');
expect(result[0]).not.toHaveProperty('isNewMarket');
expect(result[1]?.name).toBe('Ethereum');
expect(result[1]?.keywords).toEqual(['defi']);
expect(result[1]?.description).toBeUndefined();
});

it('clears isNewMarket when Terminal metadata supplies a marketType', async () => {
const unitreeMarket: PerpsMarketData = {
symbol: 'xyz:UNITREE',
name: 'xyz:UNITREE',
maxLeverage: '10x',
price: '$1.00',
change24h: '+$0.10',
change24hPercent: '+10.00%',
volume: '$100000',
isHip3: true,
isNewMarket: true,
};
mockTerminalService.fetchMarkets.mockResolvedValue({
markets: [
{
name: 'xyz:UNITREE',
szDecimals: 0,
maxLeverage: 10,
marginTableId: 0,
},
],
metadata: new Map<string, TerminalAssetMetadata>([
['xyz:UNITREE', { name: 'Unitree', marketType: 'pre-ipo' }],
]),
});
mockProvider.getMarketDataWithPrices.mockResolvedValue([unitreeMarket]);

const result = await serviceWithTerminal.getMarketDataWithPrices({
provider: mockProvider,
params: { useTerminalApi: true },
context: mockContext,
});

expect(result[0]?.marketType).toBe('pre-ipo');
expect(result[0]?.isNewMarket).toBe(false);
expect(matchesCategory(result[0] as PerpsMarketData, 'pre-ipo')).toBe(
true,
);
expect(matchesCategory(result[0] as PerpsMarketData, 'new')).toBe(
false,
);
});

it('preserves isNewMarket when Terminal metadata has no marketType', async () => {
const unitreeMarket: PerpsMarketData = {
symbol: 'xyz:UNITREE',
name: 'xyz:UNITREE',
maxLeverage: '10x',
price: '$1.00',
change24h: '+$0.10',
change24hPercent: '+10.00%',
volume: '$100000',
isHip3: true,
isNewMarket: true,
};
mockTerminalService.fetchMarkets.mockResolvedValue({
markets: terminalMarkets,
metadata: new Map<string, TerminalAssetMetadata>([
['xyz:UNITREE', { name: 'Unitree' }],
]),
});
mockProvider.getMarketDataWithPrices.mockResolvedValue([unitreeMarket]);

const result = await serviceWithTerminal.getMarketDataWithPrices({
provider: mockProvider,
params: { useTerminalApi: true },
context: mockContext,
});

expect(result[0]?.marketType).toBeUndefined();
expect(result[0]?.isNewMarket).toBe(true);
});

it('preserves provider name when terminal metadata omits name', async () => {
const metadataWithoutName = new Map<string, TerminalAssetMetadata>([
['BTC', { keywords: ['crypto'] }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,95 @@ describe('TerminalMarketService', () => {
expect(metadata.get('FOO')?.marketType).toBeUndefined();
});

it('maps Terminal category pre_ipo to marketType pre-ipo when marketType is absent', async () => {
jest.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: () =>
Promise.resolve([
{
symbol: 'xyz:UNITREE',
name: 'Unitree Technology',
category: 'pre_ipo',
},
{
symbol: 'xyz:CXMT',
name: 'ChangXin Technology',
category: 'pre_ipo',
},
{
symbol: 'xyz:SKHY',
name: 'SK Hynix ADR',
category: 'pre_ipo',
},
]),
} as Response);

const { metadata } = await service.fetchMarkets();

expect(metadata.get('xyz:UNITREE')?.marketType).toBe('pre-ipo');
expect(metadata.get('xyz:CXMT')?.marketType).toBe('pre-ipo');
expect(metadata.get('xyz:SKHY')?.marketType).toBe('pre-ipo');
});

it('prefers explicit marketType over category pre_ipo', async () => {
jest.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: () =>
Promise.resolve([
{
symbol: 'xyz:UNITREE',
name: 'Unitree Technology',
category: 'pre_ipo',
marketType: 'stock',
},
]),
} as Response);

const { metadata } = await service.fetchMarkets();

expect(metadata.get('xyz:UNITREE')?.marketType).toBe('stock');
});

it('maps Terminal category stocks to marketType stock when marketType is absent', async () => {
jest.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: () =>
Promise.resolve([
{
symbol: 'xyz:CBRS',
name: 'Cerebras Systems',
category: 'stocks',
},
]),
} as Response);

const { metadata } = await service.fetchMarkets();

expect(metadata.get('xyz:CBRS')?.marketType).toBe('stock');
});

it('does not map an unknown Terminal category to marketType', async () => {
jest.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: () =>
Promise.resolve([
{ symbol: 'xyz:FOO', name: 'Foo', category: 'unknown' },
]),
} as Response);

const { metadata } = await service.fetchMarkets();

expect(metadata.get('xyz:FOO')?.marketType).toBeUndefined();
});

it('uses defaults for missing numeric fields', async () => {
jest.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
Expand Down
Loading