From 7529de334f79cf692b9bd549579c2cf9b71ecf3b Mon Sep 17 00:00:00 2001 From: discountry Date: Thu, 13 Nov 2025 02:29:36 +0800 Subject: [PATCH] feat: implement position pruning and tracking in LighterGateway to manage stale positions and improve data integrity --- src/exchanges/lighter/gateway.ts | 82 +++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index 47470a2..3447a2a 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -122,6 +122,7 @@ const WS_HEARTBEAT_INTERVAL_MS = 5_000; const WS_STALE_TIMEOUT_MS = 20_000; const FEED_STALE_TIMEOUT_MS = 8_000; const STALE_CHECK_INTERVAL_MS = 2_000; +const POSITION_HTTP_MAX_STALE_MS = 60_000; const ACCOUNT_POLL_INTERVAL_MS = 5_000; const POSITION_EPSILON = 1e-12; @@ -184,6 +185,7 @@ export class LighterGateway { private loggedCreateOrderPayload = false; private readonly logTxInfo: boolean; private lastWsPositionUpdateAt = 0; + private readonly lastWsPositionByMarket = new Map(); private httpPositionsEmptyLogged = false; private marketId: number | null = null; @@ -479,6 +481,7 @@ export class LighterGateway { this.logger("accountPoll", "HTTP positions payload empty, retaining existing positions until WS confirms"); this.httpPositionsEmptyLogged = true; } + this.pruneStalePositionsFromHttp(); } } @@ -487,6 +490,33 @@ export class LighterGateway { this.httpPositionsEmptyLogged = false; } + private markWsPositionForMarket(marketId: number): void { + if (!Number.isFinite(marketId)) return; + this.lastWsPositionByMarket.set(marketId, Date.now()); + } + + private pruneStalePositionsFromHttp(): void { + if (!this.positions.length) return; + const now = Date.now(); + const remaining: LighterPosition[] = []; + let removed = false; + for (const pos of this.positions) { + const marketId = Number(pos.market_id); + const lastWs = this.lastWsPositionByMarket.get(marketId) ?? 0; + if (Number.isFinite(marketId) && lastWs && now - lastWs > POSITION_HTTP_MAX_STALE_MS) { + this.lastWsPositionByMarket.delete(marketId); + removed = true; + continue; + } + remaining.push(pos); + } + if (removed) { + this.logger("accountPoll", "Pruned stale positions based on HTTP inactivity"); + this.positions = remaining; + this.recordWsPositionUpdate(); + } + } + private async openWebSocket(): Promise { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; @@ -749,6 +779,7 @@ export class LighterGateway { const channelMarketId = this.extractMarketIdFromChannel(message.channel); if (position && Number.isFinite(Number(position.market_id))) { this.mergePositions([position]); + this.markWsPositionForMarket(Number(position.market_id)); this.recordWsPositionUpdate(); } if (Array.isArray(message.orders) && message.orders.length) { @@ -762,6 +793,7 @@ export class LighterGateway { const target = Number(position.market_id ?? channelMarketId); if (Number.isFinite(target)) { this.positions = this.positions.filter((entry) => Number(entry.market_id) !== target); + this.lastWsPositionByMarket.delete(target); this.recordWsPositionUpdate(); } } @@ -805,8 +837,10 @@ export class LighterGateway { if (!Number.isFinite(marketId)) continue; if (this.shouldRemovePosition(update)) { byMarket.delete(marketId); + this.lastWsPositionByMarket.delete(marketId); } else { byMarket.set(marketId, update); + this.markWsPositionForMarket(marketId); } } this.positions = Array.from(byMarket.values()); @@ -815,10 +849,19 @@ export class LighterGateway { private replacePositions(positions: LighterPosition[]): void { if (!positions.length) { this.positions = []; + this.lastWsPositionByMarket.clear(); return; } const filtered = this.filterPositions(positions); this.positions = filtered; + const now = Date.now(); + this.lastWsPositionByMarket.clear(); + for (const pos of filtered) { + const marketId = Number(pos.market_id); + if (Number.isFinite(marketId)) { + this.lastWsPositionByMarket.set(marketId, now); + } + } } private filterPositions(positions: LighterPosition[]): LighterPosition[] { @@ -996,6 +1039,7 @@ export class LighterGateway { private markDepthUpdate(): void { this.lastDepthUpdateAt = Date.now(); if (this.staleReason && this.staleReason.startsWith("depth")) { + this.logger("ws:stale:recovered", this.staleReason); this.staleReason = null; } } @@ -1198,6 +1242,7 @@ export class LighterGateway { bidPrice: bestBid != null ? bestBid.toString() : undefined, askPrice: bestAsk != null ? bestAsk.toString() : undefined, priceChange: bestBid != null && bestAsk != null ? (bestAsk - bestBid).toString() : undefined, + markPrice: last.toString(), priceChangePercent: undefined, weightedAvgPrice: undefined, lastQty: undefined, @@ -1314,13 +1359,15 @@ export class LighterGateway { function mergeLevels(existing: LighterOrderBookLevel[], updates: LighterOrderBookLevel[]): LighterOrderBookLevel[] { const map = new Map(); for (const level of existing) { - map.set(level.price, level.size); + const key = normalizePriceKey(level.price); + map.set(key, normalizeSizeValue(level.size)); } for (const update of updates) { + const key = normalizePriceKey(update.price); if (Number(update.size) <= 0) { - map.delete(update.price); + map.delete(key); } else { - map.set(update.price, update.size); + map.set(key, normalizeSizeValue(update.size)); } } return Array.from(map.entries()).map(([price, size]) => ({ price, size } as LighterOrderBookLevel)); @@ -1347,12 +1394,15 @@ function normalizeLevels(raw: Array { if (Array.isArray(entry)) { - const price = String(entry[0]); - const size = String(entry[1]); + const price = normalizePriceKey(entry[0] as string | number); + const size = normalizeSizeValue(entry[1]); return { price, size } as LighterOrderBookLevel; } const obj = entry as LighterOrderBookLevel; - return { price: String(obj.price), size: String(obj.size) } as LighterOrderBookLevel; + return { + price: normalizePriceKey(obj.price), + size: normalizeSizeValue(obj.size), + } as LighterOrderBookLevel; }) .filter((lvl) => lvl.price != null && lvl.size != null); } @@ -1373,6 +1423,26 @@ function sortAndTrimLevels( return list.slice(0, Math.max(1, limit)); } +function normalizePriceKey(value: string | number | undefined): string { + if (value == null) return "0"; + const num = Number(value); + if (!Number.isFinite(num)) { + return String(value).trim(); + } + const fixed = num.toFixed(12); + return fixed.replace(/\.?0+$/, "") || "0"; +} + +function normalizeSizeValue(value: string | number | undefined): string { + if (value == null) return "0"; + const num = Number(value); + if (!Number.isFinite(num)) { + return String(value).trim(); + } + if (Math.abs(num) < 1e-12) return "0"; + return num.toString(); +} + function mapOrderType(type: OrderType): number { switch (type) { case "MARKET":