Skip to content
Draft
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
15 changes: 15 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New `PERPS_EVENT_PROPERTY` keys: `CHART_LIBRARY`, `ASSET_TYPE`
- New `PERPS_EVENT_VALUE.CHART_LIBRARY` group: `lightweight`, `advanced`
- New `PERPS_EVENT_VALUE.ASSET_TYPE` group: `spot`, `perp`
- Add `fast?: boolean` to `SubscribeOrderBookParams` (TAT-3333): when set to `true`, the order book subscription uses Hyperliquid's fast l2Book mode (5 levels @ ~0.5 s cadence) instead of the default (20 levels @ ~2 s)
- No change to `#processOrderBookData` or cumulative-total math; callers opting into `fast: true` receive up to 5 levels per side instead of 20.

### Changed

- On `subscribeToPrices` calls with `includeMarketData: true` (focused detail/ticket screens), the `price` field in each `PriceUpdate` is now driven by the per-symbol `activeAssetCtx` WebSocket stream (`midPx`, falling back to `markPx`) rather than the main-DEX `allMids` snapshot, which Hyperliquid throttles to a ~2 s push cadence
- Price source selection is **per-subscriber**: focused (`includeMarketData: true`) callbacks receive the fast-stream price; list/overview (`includeMarketData: false`) callbacks always receive the raw `allMids` baseline, even when both subscriber types share the same symbol.
- The fast-stream price is preferred only while it is fresh (within a 10 s staleness window); `allMids` takes back over automatically once the `activeAssetCtx` stream goes quiet.
- A startup guard prevents any `'0'` price from being emitted: if `activeAssetCtx` fires before `allMids` with no `midPx`/`markPx`, no notification is sent until a usable price arrives from either source.
- No new WebSocket subscriptions are created; `activeAssetCtx` was already established for `includeMarketData: true` subscriptions.
- Bump `@nktkas/hyperliquid` from `^0.32.2` to `^0.33.1` (TAT-3333): adds support for the `fast` field on `l2Book` subscriptions

## [9.0.0]

Expand Down Expand Up @@ -70,6 +81,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))
- On `subscribeToPrices` calls with `includeMarketData: true` (i.e. focused single-symbol screens), the `price` field in each `PriceUpdate` is now driven by the per-symbol `activeAssetCtx` WebSocket stream (`midPx`, falling back to `markPx`) rather than the main-DEX `allMids` snapshot, which Hyperliquid throttles to a ~5 s push cadence ([#TODO](https://github.com/MetaMask/core/pull/TODO))
- The fast-stream price is preferred only while it is fresh (within a 10 s staleness window); `allMids` remains the fallback if the `activeAssetCtx` stream goes silent.
- Subscriptions with `includeMarketData: false` (list/overview screens) are unaffected and continue to use `allMids` exclusively.
- No new WebSocket subscriptions are created; `activeAssetCtx` was already established for `includeMarketData: true` subscriptions.

## [8.1.0]

Expand Down
2 changes: 1 addition & 1 deletion packages/perps-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"@metamask/messenger": "^1.2.0",
"@metamask/superstruct": "^3.1.0",
"@metamask/utils": "^11.11.0",
"@nktkas/hyperliquid": "^0.32.2",
"@nktkas/hyperliquid": "^0.33.1",
"bignumber.js": "^9.1.2",
"reselect": "^5.1.1",
"uuid": "^8.3.2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,9 +310,23 @@ export class HyperLiquidSubscriptionService {
volume24h?: number;
oraclePrice?: number;
lastUpdated: number;
// Fast-stream price from activeAssetCtx (midPx preferred, markPx fallback).
// Populated only for symbols with includeMarketData: true subscriptions.
// #notifyAllPriceSubscribers projects this onto the allMids baseline for
// focused (includeMarketData: true) subscribers only; list subscribers
// always receive the raw allMids price.
activeAssetCtxPrice?: number;
// Timestamp of the last activeAssetCtx price update.
// Used by #notifyAllPriceSubscribers and #projectPriceUpdate for staleness checks.
priceLastUpdated?: number;
}
>();

// Stale threshold for the fast-stream price preference. If the last
// activeAssetCtx price update is older than this, the allMids baseline is
// used for focused subscribers.
static readonly #activeAssetCtxPriceTtlMs = 10_000;

// Flag to suppress error logging during intentional disconnect
// Set in clearAll() and never reset (service instance is discarded after disconnect)
#isClearing = false;
Expand Down Expand Up @@ -1609,11 +1623,32 @@ export class HyperLiquidSubscriptionService {
}
});

// Send cached data immediately if available
// Send cached data immediately if available, projecting the fast-stream
// price for focused subscribers and falling back to the allMids baseline
// for list subscribers.
symbols.forEach((symbol) => {
const cachedPrice = this.#cachedPriceData?.get(symbol);
if (cachedPrice) {
callback([cachedPrice]);
const projected = includeMarketData
? this.#projectPriceUpdate(symbol, cachedPrice)
: cachedPrice;
callback([projected]);
} else if (includeMarketData) {
// No allMids baseline yet; if a fresh fast-stream price is cached,
// send it immediately so focused screens are not blank on first render.
const marketData = this.#marketDataCache.get(symbol);
const now = Date.now();
const isFastPriceFresh =
marketData?.activeAssetCtxPrice !== undefined &&
marketData.priceLastUpdated !== undefined &&
now - marketData.priceLastUpdated <=
HyperLiquidSubscriptionService.#activeAssetCtxPriceTtlMs;
if (isFastPriceFresh) {
const fastPrice = (
marketData!.activeAssetCtxPrice as number
).toString();
callback([this.#createPriceUpdate(symbol, fastPrice)]);
}
}
});

Expand Down Expand Up @@ -2857,7 +2892,7 @@ export class HyperLiquidSubscriptionService {

const priceUpdate = {
symbol,
price, // This is the mid price from allMids
price,
timestamp: Date.now(),
percentChange24h,
// Add mark price from activeAssetCtx
Expand Down Expand Up @@ -2889,8 +2924,56 @@ export class HyperLiquidSubscriptionService {
return priceUpdate;
}

/**
* Project a base PriceUpdate (allMids baseline) onto the per-symbol fast-stream
* price for focused (`includeMarketData: true`) subscribers.
*
* Returns `base` unchanged when:
* - No `activeAssetCtxPrice` is cached for the symbol, OR
* - The cached price is older than `#activeAssetCtxPriceTtlMs` (10 s).
*
* Otherwise returns a shallow clone of `base` with `price` and `timestamp`
* overridden by the fresh fast-stream value. All other fields (funding,
* openInterest, isTradable, etc.) are inherited from the allMids baseline so
* cumulative metrics stay consistent.
*/
#projectPriceUpdate(symbol: string, base: PriceUpdate): PriceUpdate {
const marketData = this.#marketDataCache.get(symbol);
if (
marketData?.activeAssetCtxPrice === undefined ||
marketData.priceLastUpdated === undefined
) {
return base;
}
const now = Date.now();
if (
now - marketData.priceLastUpdated >
HyperLiquidSubscriptionService.#activeAssetCtxPriceTtlMs
) {
return base;
}
return {
...base,
price: marketData.activeAssetCtxPrice.toString(),
timestamp: now,
};
}

/**
* Ensure global allMids subscription is active (singleton pattern)
*
* NOTE ON PUSH CADENCE: Hyperliquid throttles the main-DEX allMids stream to
* push every ~5 seconds. This cadence is acceptable for list/overview screens
* that show many symbols simultaneously, but would make a focused single-symbol
* view (trade detail, order ticket) feel noticeably stale.
*
* Mitigation: when a subscription is created with `includeMarketData: true`,
* #ensureActiveAssetSubscription establishes a per-symbol activeAssetCtx
* WebSocket that ticks at a faster cadence. #notifyAllPriceSubscribers
* projects the fast-stream price (with a 10 s staleness gate via
* #activeAssetCtxPriceTtlMs) for focused (includeMarketData: true) callbacks
* only; list/overview callbacks always receive the raw allMids baseline so
* the two subscriber types are guaranteed separate price sources.
*/
#ensureGlobalAllMidsSubscription(): void {
// Check both the subscription AND the promise to prevent race conditions
Expand Down Expand Up @@ -3039,6 +3122,7 @@ export class HyperLiquidSubscriptionService {

// Cache market data for consolidation with price updates
const ctxPrice = ctx.midPx ?? ctx.markPx;
const now = Date.now();
const openInterestUSD =
isPerpsContext(data) && ctxPrice
? calculateOpenInterestUSD(data.ctx.openInterest, ctxPrice)
Expand All @@ -3061,23 +3145,23 @@ export class HyperLiquidSubscriptionService {
oraclePrice: isPerpsContext(data)
? parseFloat(data.ctx.oraclePx.toString())
: undefined,
lastUpdated: Date.now(),
lastUpdated: now,
// Store fast-stream price for per-subscriber projection in
// #notifyAllPriceSubscribers. Used only for focused subscribers.
activeAssetCtxPrice: ctxPrice
? parseFloat(ctxPrice.toString())
: undefined,
priceLastUpdated: ctxPrice ? now : undefined,
};

this.#marketDataCache.set(symbol, marketData);

// Update cached price data with new 24h change if we have current price
const currentCachedPrice = this.#cachedPriceData?.get(symbol);
if (currentCachedPrice) {
const updatedPrice = this.#createPriceUpdate(
symbol,
currentCachedPrice.price,
);

this.#cachedPriceData ??= new Map<string, PriceUpdate>();
this.#cachedPriceData.set(symbol, updatedPrice);
this.#notifyAllPriceSubscribers();
}
// Notify subscribers. #notifyAllPriceSubscribers projects the
// fast-stream price (now stored in #marketDataCache) for focused
// (includeMarketData: true) subscribers, while list subscribers
// continue to receive only the allMids baseline from #cachedPriceData.
// List subscribers are skipped until an allMids tick has arrived.
this.#notifyAllPriceSubscribers();
}
},
)
Expand Down Expand Up @@ -3647,6 +3731,7 @@ export class HyperLiquidSubscriptionService {
levels = 10,
nSigFigs = 5,
mantissa,
fast,
callback,
onError,
} = params;
Expand All @@ -3673,14 +3758,17 @@ export class HyperLiquidSubscriptionService {
let cancelled = false;

subscriptionClient
.l2Book({ coin: symbol, nSigFigs, mantissa }, (data: L2BookResponse) => {
if (cancelled || data?.coin !== symbol || !data?.levels) {
return;
}
.l2Book(
{ coin: symbol, nSigFigs, mantissa, fast },
(data: L2BookResponse) => {
if (cancelled || data?.coin !== symbol || !data?.levels) {
return;
}

const orderBookData = this.#processOrderBookData(data, levels);
callback(orderBookData);
})
const orderBookData = this.#processOrderBookData(data, levels);
callback(orderBookData);
},
)
.then(async (sub) => {
if (cancelled) {
try {
Expand Down Expand Up @@ -3812,36 +3900,66 @@ export class HyperLiquidSubscriptionService {
}

/**
* Notify all price subscribers with their requested symbols from cache
* Optimized to batch updates per subscriber
* Notify all price subscribers with per-subscriber price projection.
*
* Price source selection per (symbol, callback):
* - **Focused** (`includeMarketData: true`) callbacks are identified by their
* presence in `#marketDataSubscribers[symbol]`. When a fresh
* `activeAssetCtxPrice` is cached (within the 10 s TTL), those callbacks
* receive a clone of the allMids baseline with `price` and `timestamp`
* overridden by the fast-stream value. If no fresh fast price exists they
* fall back to the allMids baseline.
* - **List** (`includeMarketData: false`) callbacks always receive the raw
* allMids baseline. They are skipped entirely until at least one allMids
* tick has been cached for the symbol.
* - When no allMids baseline exists yet but a fresh `activeAssetCtxPrice` is
* available, focused callbacks still receive an update so detail screens
* stay responsive on first render.
*/
#notifyAllPriceSubscribers(): void {
// If no price data exists yet, don't notify
if (!this.#cachedPriceData) {
return;
}

const priceData = this.#cachedPriceData;

// Group updates by subscriber to batch notifications
const subscriberUpdates = new Map<
(prices: PriceUpdate[]) => void,
PriceUpdate[]
>();

this.#priceSubscribers.forEach((subscriberSet, symbol) => {
const priceUpdate = priceData.get(symbol);
if (priceUpdate) {
subscriberSet.forEach((callback) => {
const allMidsBase = this.#cachedPriceData?.get(symbol);
const marketData = this.#marketDataCache.get(symbol);

const now = Date.now();
const isFastPriceFresh =
marketData?.activeAssetCtxPrice !== undefined &&
marketData.priceLastUpdated !== undefined &&
now - marketData.priceLastUpdated <=
HyperLiquidSubscriptionService.#activeAssetCtxPriceTtlMs;

subscriberSet.forEach((callback) => {
const isFocused =
this.#marketDataSubscribers.get(symbol)?.has(callback) ?? false;

let priceUpdate: PriceUpdate | undefined;

if (isFocused && isFastPriceFresh) {
const fastPrice = (
marketData!.activeAssetCtxPrice as number
).toString();
// Use allMids baseline as the structural base when available;
// fall back to a freshly computed PriceUpdate if allMids hasn't
// arrived yet so focused screens stay responsive on first render.
const base =
allMidsBase ?? this.#createPriceUpdate(symbol, fastPrice);
priceUpdate = { ...base, price: fastPrice, timestamp: now };
} else if (allMidsBase !== undefined) {
priceUpdate = allMidsBase;
}

if (priceUpdate !== undefined) {
if (!subscriberUpdates.has(callback)) {
subscriberUpdates.set(callback, []);
}
const updates = subscriberUpdates.get(callback);
if (updates) {
updates.push(priceUpdate);
}
});
}
subscriberUpdates.get(callback)!.push(priceUpdate);
}
});
});

// Send batched updates to each subscriber
Expand Down
7 changes: 7 additions & 0 deletions packages/perps-controller/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,13 @@ export type SubscribeOrderBookParams = {
nSigFigs?: 2 | 3 | 4 | 5;
/** Mantissa for aggregation when nSigFigs is 5 (2 or 5). Controls finest price increments */
mantissa?: 2 | 5;
/**
* Enable fast order book updates (5 levels @ ~0.5 s cadence).
* When omitted, Hyperliquid uses the default cadence (20 levels @ ~2 s).
* Note: with `fast: true` the widget receives at most 5 levels per side
* regardless of the `levels` setting.
*/
fast?: boolean;
/** Callback function receiving order book updates */
callback: (orderBook: OrderBookData) => void;
/** Callback for errors */
Expand Down
Loading
Loading