feat: enhance order handling in LighterGateway and introduce order identity normalization for improved precision and consistency

This commit is contained in:
discountry
2025-11-12 19:17:04 +08:00
parent 1baee3a207
commit e7f6341961
11 changed files with 318 additions and 40 deletions
+5 -1
View File
@@ -166,14 +166,16 @@ export async function placeOrder(
}
lockOperating(locks, timers, pendings, type, log);
try {
const closePosition = reduceOnly ? true : undefined;
const order = await routeLimitOrder({
adapter,
symbol,
side,
quantity,
price: priceNum,
timeInForce: "GTX",
timeInForce: reduceOnly ? "GTC" : "GTX",
reduceOnly: reduceOnly ? true : undefined,
closePosition,
});
pendings[type] = String(order.orderId);
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
@@ -216,12 +218,14 @@ export async function placeMarketOrder(
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
lockOperating(locks, timers, pendings, type, log);
try {
const closePosition = reduceOnly ? true : undefined;
const order = await routeMarketOrder({
adapter,
symbol,
side,
quantity,
reduceOnly: reduceOnly ? true : undefined,
closePosition,
});
pendings[type] = String(order.orderId);
log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
+13 -2
View File
@@ -158,12 +158,23 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
}
private logError(context: string, error: unknown): void {
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`);
if (process.env.LIGHTER_DEBUG !== "1" && process.env.LIGHTER_DEBUG !== "true") {
return;
}
if (isSuccessfulResponse(error)) {
console.info(`[LighterExchangeAdapter] ${context}: ${JSON.stringify(error)}`);
return;
}
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`);
}
}
function isSuccessfulResponse(value: unknown): value is { code?: number } {
if (typeof value !== "object" || value == null) return false;
const code = (value as { code?: unknown }).code;
return typeof code === "number" && code === 200;
}
function resolveApiKeys(credentials: LighterCredentials): Record<number, string> {
if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) {
return credentials.apiKeys;
+30
View File
@@ -0,0 +1,30 @@
const TRUE_VALUES = new Set(["1", "true", "yes", "y", "on"]);
const FALSE_VALUES = new Set(["0", "false", "no", "n", "off"]);
export function normalizeBooleanFlag(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
return null;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (!normalized) return null;
if (TRUE_VALUES.has(normalized)) return true;
if (FALSE_VALUES.has(normalized)) return false;
return null;
}
if (typeof value === "bigint") {
if (value === 1n) return true;
if (value === 0n) return false;
return null;
}
return null;
}
export function coerceBooleanFlag(value: unknown, fallback = false): boolean {
const normalized = normalizeBooleanFlag(value);
if (normalized == null) return fallback;
return normalized;
}
+97 -25
View File
@@ -35,6 +35,8 @@ import {
} from "./constants";
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
import { shouldResetMarketOrders } from "./order-feed";
interface SimpleEvent<T> {
add(handler: (value: T) => void): void;
@@ -178,6 +180,7 @@ export class LighterGateway {
private marketId: number | null = null;
private priceDecimals: number | null = null;
private sizeDecimals: number | null = null;
private readonly orderIndexByClientId = new Map<string, string>();
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -299,9 +302,12 @@ export class LighterGateway {
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
this.logger("createOrder.sendTx.response", response);
}
const clientOrderIndexStr = signParams.clientOrderIndex.toString();
return lighterOrderToAster(this.displaySymbol, {
order_index: Number(signParams.clientOrderIndex % 1_000_000_000n),
client_order_index: Number(signParams.clientOrderIndex),
order_index: clientOrderIndexStr,
client_order_index: clientOrderIndexStr,
order_id: clientOrderIndexStr,
client_order_id: clientOrderIndexStr,
market_index: signParams.marketIndex,
initial_base_amount: baseAmountScaledString,
remaining_base_amount: baseAmountScaledString,
@@ -325,14 +331,8 @@ export class LighterGateway {
await this.ensureInitialized();
const marketIndex = params.marketIndex ?? this.marketId;
if (marketIndex == null) throw new Error("Market index unknown");
// Parse order id to BigInt without precision loss; prefer string input
let indexValue: bigint;
if (typeof params.orderId === "string") {
indexValue = BigInt(params.orderId);
} else {
// Fallback for numeric ids (may be unsafe if beyond 2^53-1)
indexValue = BigInt(Math.trunc(params.orderId));
}
const resolvedOrderId = this.resolveOrderIndex(String(params.orderId));
const indexValue = BigInt(resolvedOrderId);
const { apiKeyIndex, nonce } = this.nonceManager.next();
try {
const signed = await this.signer.signCancelOrder({
@@ -344,10 +344,7 @@ export class LighterGateway {
const auth = await this.ensureAuthToken();
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
// Optimistically remove the order locally to avoid stale duplicates until WS confirms
const key = String(params.orderId);
this.orderMap.delete(key);
this.orders = Array.from(this.orderMap.values());
this.emitOrders();
this.removeOrderLocally(String(params.orderId));
} catch (error) {
this.nonceManager.acknowledgeFailure(apiKeyIndex);
throw error;
@@ -843,19 +840,22 @@ export class LighterGateway {
const marketKeys = Object.keys(ordersObject);
if (snapshot && marketKeys.length === 0) {
this.orderMap.clear();
this.orderIndexByClientId.clear();
this.orders = [];
this.emitOrders();
return;
}
if (snapshot) {
this.orderMap.clear();
this.orderIndexByClientId.clear();
}
for (const [market, bucket] of Object.entries(ordersObject)) {
const marketId = Number(market);
const normalized = this.normalizeOrders(bucket);
if (Number.isFinite(marketId)) {
const shouldReset = shouldResetMarketOrders(bucket, snapshot);
if (shouldReset && Number.isFinite(marketId)) {
this.clearOrdersForMarket(marketId);
}
const normalized = this.normalizeOrders(bucket);
if (!normalized.length) continue;
for (const order of normalized) {
this.applyOrderUpdate(order);
@@ -887,6 +887,7 @@ export class LighterGateway {
this.clearOrdersForMarket(marketId);
} else {
this.orderMap.clear();
this.orderIndexByClientId.clear();
}
}
for (const order of orders) {
@@ -897,26 +898,45 @@ export class LighterGateway {
}
private applyOrderUpdate(order: LighterOrder): void {
const key = String(order.order_index ?? order.order_id ?? order.client_order_index ?? "");
const orderIndex = this.extractOrderIndex(order);
const clientIndex = this.extractClientIndex(order);
if (orderIndex && clientIndex) {
this.orderIndexByClientId.set(clientIndex, orderIndex);
}
if (orderIndex) {
this.orderIndexByClientId.set(orderIndex, orderIndex);
}
const key = orderIndex ?? clientIndex;
if (!key) return;
const status = (order.status ?? "").toLowerCase();
if (TERMINAL_ORDER_STATUSES.has(status)) {
const existing = this.orderMap.get(key);
this.orderMap.delete(key);
if (existing) {
this.forgetOrderIdentity(existing);
}
return;
}
if (order.client_order_index != null || order.order_index != null) {
if (
order.client_order_index != null ||
order.order_index != null ||
order.client_order_id != null ||
order.order_id != null
) {
for (const [existingKey, existingOrder] of Array.from(this.orderMap.entries())) {
if (existingKey === key) continue;
const sameOrderIndex =
order.order_index != null &&
existingOrder.order_index != null &&
Number(existingOrder.order_index) === Number(order.order_index);
orderIdentityEquals(order.order_index, existingOrder.order_index) ||
orderIdentityEquals(order.order_id, existingOrder.order_id);
const sameClientIndex =
order.client_order_index != null &&
existingOrder.client_order_index != null &&
Number(existingOrder.client_order_index) === Number(order.client_order_index);
orderIdentityEquals(order.client_order_index, existingOrder.client_order_index) ||
orderIdentityEquals(order.client_order_id, existingOrder.client_order_id);
if (sameOrderIndex || sameClientIndex) {
const removed = this.orderMap.get(existingKey);
this.orderMap.delete(existingKey);
if (removed) {
this.forgetOrderIdentity(removed);
}
}
}
}
@@ -927,8 +947,12 @@ export class LighterGateway {
const normalized = Number(marketId);
if (!Number.isFinite(normalized)) return;
for (const [key, existing] of Array.from(this.orderMap.entries())) {
if (Number(existing.market_index) === normalized) {
const existingMarket =
(existing as { market_index?: number | string; market_id?: number | string }).market_index ??
(existing as { market_id?: number | string }).market_id;
if (Number(existingMarket) === normalized) {
this.orderMap.delete(key);
this.forgetOrderIdentity(existing);
}
}
}
@@ -974,6 +998,54 @@ export class LighterGateway {
this.ordersEvent.emit(mapped);
}
private resolveOrderIndex(orderId: string): string {
const normalized = normalizeOrderIdentity(orderId);
if (!normalized) {
throw new Error(`Invalid order id: ${orderId}`);
}
return this.orderIndexByClientId.get(normalized) ?? normalized;
}
private removeOrderLocally(orderId: string): void {
const key = normalizeOrderIdentity(orderId);
if (!key) return;
const existing = this.orderMap.get(key);
this.orderMap.delete(key);
this.orderIndexByClientId.delete(key);
if (existing) {
this.forgetOrderIdentity(existing);
}
this.orders = Array.from(this.orderMap.values());
this.emitOrders();
}
private extractOrderIndex(order: LighterOrder): string | null {
return (
normalizeOrderIdentity(order.order_id) ??
normalizeOrderIdentity(order.order_index) ??
null
);
}
private extractClientIndex(order: LighterOrder): string | null {
return (
normalizeOrderIdentity(order.client_order_id) ??
normalizeOrderIdentity(order.client_order_index) ??
null
);
}
private forgetOrderIdentity(order: LighterOrder): void {
const orderIndex = this.extractOrderIndex(order);
const clientIndex = this.extractClientIndex(order);
if (orderIndex) {
this.orderIndexByClientId.delete(orderIndex);
}
if (clientIndex) {
this.orderIndexByClientId.delete(clientIndex);
}
}
private startPolling(): void {
if (!this.pollers.ticker) {
this.pollers.ticker = setInterval(() => {
+27 -7
View File
@@ -19,6 +19,8 @@ import type {
LighterOrderBookSnapshot,
LighterPosition,
} from "./types";
import { coerceBooleanFlag, normalizeBooleanFlag } from "./flags";
import { normalizeOrderIdentity } from "./order-identity";
export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth {
const toLevels = (levels: LighterOrderBookLevel[]): AsterDepthLevel[] =>
@@ -74,13 +76,31 @@ export function toOrders(symbol: string, orders: LighterOrder[]): AsterOrder[] {
}
export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterOrder {
const side: OrderSide = order.is_ask || order.side?.toLowerCase() === "sell" || order.side?.toLowerCase() === "ask"
? "SELL"
: "BUY";
const booleanIsAsk = normalizeBooleanFlag(order.is_ask);
const normalizedSide = order.side?.toLowerCase();
const side: OrderSide =
booleanIsAsk != null
? booleanIsAsk
? "SELL"
: "BUY"
: normalizedSide === "sell" || normalizedSide === "ask"
? "SELL"
: "BUY";
const reduceOnly = coerceBooleanFlag(order.reduce_only, false);
const orderIndex =
normalizeOrderIdentity(order.order_id) ??
normalizeOrderIdentity(order.order_index) ??
normalizeOrderIdentity(order.client_order_index) ??
normalizeOrderIdentity(order.client_order_id) ??
"";
const clientIndex =
normalizeOrderIdentity(order.client_order_id) ??
normalizeOrderIdentity(order.client_order_index) ??
"";
return {
// Use string order id to avoid precision loss; prefer on-chain order_index for cancellation
orderId: String(order.order_index ?? order.client_order_index ?? ""),
clientOrderId: String(order.client_order_index ?? order.order_index ?? ""),
orderId: orderIndex,
clientOrderId: clientIndex || orderIndex,
symbol,
side,
type: mapOrderType(order.type),
@@ -91,8 +111,8 @@ export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterO
stopPrice: order.trigger_price ?? "0",
time: order.created_at ?? Date.now(),
updateTime: order.updated_at ?? Date.now(),
reduceOnly: Boolean(order.reduce_only),
closePosition: Boolean(order.reduce_only ?? order.owner_account_index === undefined ? false : order.is_ask),
reduceOnly,
closePosition: reduceOnly,
workingType: "MARK_PRICE",
activationPrice: order.trigger_price,
};
+19
View File
@@ -0,0 +1,19 @@
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value != null && !Array.isArray(value);
}
/**
* Determines whether a per-market websocket payload should reset the cached
* orders before applying its contents.
*/
export function shouldResetMarketOrders(bucket: unknown, snapshot: boolean): boolean {
if (snapshot) return true;
if (bucket == null) return false;
if (Array.isArray(bucket)) {
return bucket.length === 0;
}
if (isPlainObject(bucket)) {
return Object.keys(bucket).length === 0;
}
return false;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Helpers for working with Lighter order identifiers without losing precision.
*/
/**
* Normalizes any order identifier into a trimmed string representation.
* Accepts string, number, or bigint inputs; returns null when the value
* cannot be represented as a meaningful identifier.
*/
export function normalizeOrderIdentity(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) {
return null;
}
return Math.trunc(value).toString(10);
}
if (typeof value === "bigint") {
return value.toString(10);
}
return null;
}
/**
* Compares two identifier-like values without casting through Number(),
* which would drop precision for large (>2^53) indices.
*/
export function orderIdentityEquals(a: unknown, b: unknown): boolean {
const left = normalizeOrderIdentity(a);
const right = normalizeOrderIdentity(b);
return left != null && right != null && left === right;
}
+7 -5
View File
@@ -8,12 +8,14 @@ export type LighterOrderType =
| "take_profit_limit"
| string;
type StrOrNum = string | number;
export interface LighterOrder {
order_index: number;
client_order_index: number;
order_id?: string;
client_order_id?: string;
market_index: number;
order_index: StrOrNum;
client_order_index: StrOrNum;
order_id?: string | null;
client_order_id?: string | null;
market_index: StrOrNum;
owner_account_index?: number;
initial_base_amount: string;
remaining_base_amount: string;
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { shouldResetMarketOrders } from "../../src/exchanges/lighter/order-feed";
describe("shouldResetMarketOrders", () => {
it("always resets on snapshots", () => {
expect(shouldResetMarketOrders([{ id: 1 }], true)).toBe(true);
expect(shouldResetMarketOrders([], true)).toBe(true);
expect(shouldResetMarketOrders(null, true)).toBe(true);
});
it("resets when array bucket is empty", () => {
expect(shouldResetMarketOrders([], false)).toBe(true);
expect(shouldResetMarketOrders([{}], false)).toBe(false);
});
it("resets when object bucket has no keys", () => {
expect(shouldResetMarketOrders({}, false)).toBe(true);
expect(shouldResetMarketOrders({ a: 1 }, false)).toBe(false);
});
it("does not reset for non-empty updates", () => {
expect(shouldResetMarketOrders([{ order_index: "1" }], false)).toBe(false);
expect(shouldResetMarketOrders(null, false)).toBe(false);
expect(shouldResetMarketOrders(undefined, false)).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { normalizeOrderIdentity, orderIdentityEquals } from "../../src/exchanges/lighter/order-identity";
describe("order identity helpers", () => {
it("treats large numeric strings as distinct values", () => {
const first = "27584547724798440";
const second = "27584547724798442";
expect(orderIdentityEquals(first, second)).toBe(false);
expect(orderIdentityEquals(first, first)).toBe(true);
});
it("considers numeric inputs equal to their string counterparts", () => {
expect(orderIdentityEquals(123456789, "123456789")).toBe(true);
});
it("normalizes whitespace-only identifiers to null", () => {
expect(normalizeOrderIdentity(" ")).toBeNull();
});
it("falls back to truncated integers for floating inputs", () => {
expect(normalizeOrderIdentity(42.9)).toBe("42");
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { lighterOrderToAster } from "../../src/exchanges/lighter/mappers";
import type { LighterOrder } from "../../src/exchanges/lighter/types";
function createOrder(overrides: Partial<LighterOrder> = {}): LighterOrder {
return {
order_index: 1,
client_order_index: 1,
market_index: 99,
initial_base_amount: "0.1",
remaining_base_amount: "0.1",
price: "154.86",
type: "limit",
reduce_only: "No",
side: "buy",
...overrides,
} as LighterOrder;
}
describe("lighterOrderToAster", () => {
it("treats textual reduce_only flags correctly", () => {
const nonReduce = lighterOrderToAster("USDJPY", createOrder({ reduce_only: "No" }));
expect(nonReduce.reduceOnly).toBe(false);
const reduce = lighterOrderToAster("USDJPY", createOrder({ reduce_only: "Yes" }));
expect(reduce.reduceOnly).toBe(true);
});
it("uses numeric is_ask flag for side inference", () => {
const sell = lighterOrderToAster("USDJPY", createOrder({ is_ask: 1 }));
expect(sell.side).toBe("SELL");
const buy = lighterOrderToAster("USDJPY", createOrder({ is_ask: 0 }));
expect(buy.side).toBe("BUY");
});
});