diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index 8a457c6..16fa766 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -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}`); diff --git a/src/exchanges/lighter/adapter.ts b/src/exchanges/lighter/adapter.ts index c521790..7bef7fd 100644 --- a/src/exchanges/lighter/adapter.ts +++ b/src/exchanges/lighter/adapter.ts @@ -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 { if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) { return credentials.apiKeys; diff --git a/src/exchanges/lighter/flags.ts b/src/exchanges/lighter/flags.ts new file mode 100644 index 0000000..a525d94 --- /dev/null +++ b/src/exchanges/lighter/flags.ts @@ -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; +} diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index 174fd3c..4b8189c 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -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 { 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(); private ws: WebSocket | null = null; private reconnectTimer: ReturnType | 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(() => { diff --git a/src/exchanges/lighter/mappers.ts b/src/exchanges/lighter/mappers.ts index acf572f..6c71460 100644 --- a/src/exchanges/lighter/mappers.ts +++ b/src/exchanges/lighter/mappers.ts @@ -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, }; diff --git a/src/exchanges/lighter/order-feed.ts b/src/exchanges/lighter/order-feed.ts new file mode 100644 index 0000000..310c90d --- /dev/null +++ b/src/exchanges/lighter/order-feed.ts @@ -0,0 +1,19 @@ +function isPlainObject(value: unknown): value is Record { + 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; +} diff --git a/src/exchanges/lighter/order-identity.ts b/src/exchanges/lighter/order-identity.ts new file mode 100644 index 0000000..816f8a3 --- /dev/null +++ b/src/exchanges/lighter/order-identity.ts @@ -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; +} diff --git a/src/exchanges/lighter/types.ts b/src/exchanges/lighter/types.ts index b51de33..7a57197 100644 --- a/src/exchanges/lighter/types.ts +++ b/src/exchanges/lighter/types.ts @@ -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; diff --git a/tests/lighter/order-feed.test.ts b/tests/lighter/order-feed.test.ts new file mode 100644 index 0000000..9fbc24e --- /dev/null +++ b/tests/lighter/order-feed.test.ts @@ -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); + }); +}); diff --git a/tests/lighter/order-identity.test.ts b/tests/lighter/order-identity.test.ts new file mode 100644 index 0000000..84962c6 --- /dev/null +++ b/tests/lighter/order-identity.test.ts @@ -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"); + }); +}); diff --git a/tests/lighter/orders.test.ts b/tests/lighter/orders.test.ts new file mode 100644 index 0000000..2a7609e --- /dev/null +++ b/tests/lighter/orders.test.ts @@ -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 { + 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"); + }); +});