mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
feat: enhance order handling in LighterGateway and introduce order identity normalization for improved precision and consistency
This commit is contained in:
@@ -166,14 +166,16 @@ export async function placeOrder(
|
|||||||
}
|
}
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
|
const closePosition = reduceOnly ? true : undefined;
|
||||||
const order = await routeLimitOrder({
|
const order = await routeLimitOrder({
|
||||||
adapter,
|
adapter,
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
quantity,
|
quantity,
|
||||||
price: priceNum,
|
price: priceNum,
|
||||||
timeInForce: "GTX",
|
timeInForce: reduceOnly ? "GTC" : "GTX",
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
|
closePosition,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
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);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
|
const closePosition = reduceOnly ? true : undefined;
|
||||||
const order = await routeMarketOrder({
|
const order = await routeMarketOrder({
|
||||||
adapter,
|
adapter,
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
quantity,
|
quantity,
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
|
closePosition,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
||||||
|
|||||||
@@ -158,12 +158,23 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private logError(context: string, error: unknown): void {
|
private logError(context: string, error: unknown): void {
|
||||||
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
|
if (process.env.LIGHTER_DEBUG !== "1" && process.env.LIGHTER_DEBUG !== "true") {
|
||||||
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`);
|
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> {
|
function resolveApiKeys(credentials: LighterCredentials): Record<number, string> {
|
||||||
if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) {
|
if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) {
|
||||||
return credentials.apiKeys;
|
return credentials.apiKeys;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
|
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
|
||||||
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
|
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
|
||||||
|
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
|
||||||
|
import { shouldResetMarketOrders } from "./order-feed";
|
||||||
|
|
||||||
interface SimpleEvent<T> {
|
interface SimpleEvent<T> {
|
||||||
add(handler: (value: T) => void): void;
|
add(handler: (value: T) => void): void;
|
||||||
@@ -178,6 +180,7 @@ export class LighterGateway {
|
|||||||
private marketId: number | null = null;
|
private marketId: number | null = null;
|
||||||
private priceDecimals: number | null = null;
|
private priceDecimals: number | null = null;
|
||||||
private sizeDecimals: number | null = null;
|
private sizeDecimals: number | null = null;
|
||||||
|
private readonly orderIndexByClientId = new Map<string, string>();
|
||||||
|
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | 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") {
|
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
|
||||||
this.logger("createOrder.sendTx.response", response);
|
this.logger("createOrder.sendTx.response", response);
|
||||||
}
|
}
|
||||||
|
const clientOrderIndexStr = signParams.clientOrderIndex.toString();
|
||||||
return lighterOrderToAster(this.displaySymbol, {
|
return lighterOrderToAster(this.displaySymbol, {
|
||||||
order_index: Number(signParams.clientOrderIndex % 1_000_000_000n),
|
order_index: clientOrderIndexStr,
|
||||||
client_order_index: Number(signParams.clientOrderIndex),
|
client_order_index: clientOrderIndexStr,
|
||||||
|
order_id: clientOrderIndexStr,
|
||||||
|
client_order_id: clientOrderIndexStr,
|
||||||
market_index: signParams.marketIndex,
|
market_index: signParams.marketIndex,
|
||||||
initial_base_amount: baseAmountScaledString,
|
initial_base_amount: baseAmountScaledString,
|
||||||
remaining_base_amount: baseAmountScaledString,
|
remaining_base_amount: baseAmountScaledString,
|
||||||
@@ -325,14 +331,8 @@ export class LighterGateway {
|
|||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
const marketIndex = params.marketIndex ?? this.marketId;
|
const marketIndex = params.marketIndex ?? this.marketId;
|
||||||
if (marketIndex == null) throw new Error("Market index unknown");
|
if (marketIndex == null) throw new Error("Market index unknown");
|
||||||
// Parse order id to BigInt without precision loss; prefer string input
|
const resolvedOrderId = this.resolveOrderIndex(String(params.orderId));
|
||||||
let indexValue: bigint;
|
const indexValue = BigInt(resolvedOrderId);
|
||||||
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 { apiKeyIndex, nonce } = this.nonceManager.next();
|
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
||||||
try {
|
try {
|
||||||
const signed = await this.signer.signCancelOrder({
|
const signed = await this.signer.signCancelOrder({
|
||||||
@@ -344,10 +344,7 @@ export class LighterGateway {
|
|||||||
const auth = await this.ensureAuthToken();
|
const auth = await this.ensureAuthToken();
|
||||||
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
|
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
|
||||||
// Optimistically remove the order locally to avoid stale duplicates until WS confirms
|
// Optimistically remove the order locally to avoid stale duplicates until WS confirms
|
||||||
const key = String(params.orderId);
|
this.removeOrderLocally(String(params.orderId));
|
||||||
this.orderMap.delete(key);
|
|
||||||
this.orders = Array.from(this.orderMap.values());
|
|
||||||
this.emitOrders();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.nonceManager.acknowledgeFailure(apiKeyIndex);
|
this.nonceManager.acknowledgeFailure(apiKeyIndex);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -843,19 +840,22 @@ export class LighterGateway {
|
|||||||
const marketKeys = Object.keys(ordersObject);
|
const marketKeys = Object.keys(ordersObject);
|
||||||
if (snapshot && marketKeys.length === 0) {
|
if (snapshot && marketKeys.length === 0) {
|
||||||
this.orderMap.clear();
|
this.orderMap.clear();
|
||||||
|
this.orderIndexByClientId.clear();
|
||||||
this.orders = [];
|
this.orders = [];
|
||||||
this.emitOrders();
|
this.emitOrders();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
this.orderMap.clear();
|
this.orderMap.clear();
|
||||||
|
this.orderIndexByClientId.clear();
|
||||||
}
|
}
|
||||||
for (const [market, bucket] of Object.entries(ordersObject)) {
|
for (const [market, bucket] of Object.entries(ordersObject)) {
|
||||||
const marketId = Number(market);
|
const marketId = Number(market);
|
||||||
const normalized = this.normalizeOrders(bucket);
|
const shouldReset = shouldResetMarketOrders(bucket, snapshot);
|
||||||
if (Number.isFinite(marketId)) {
|
if (shouldReset && Number.isFinite(marketId)) {
|
||||||
this.clearOrdersForMarket(marketId);
|
this.clearOrdersForMarket(marketId);
|
||||||
}
|
}
|
||||||
|
const normalized = this.normalizeOrders(bucket);
|
||||||
if (!normalized.length) continue;
|
if (!normalized.length) continue;
|
||||||
for (const order of normalized) {
|
for (const order of normalized) {
|
||||||
this.applyOrderUpdate(order);
|
this.applyOrderUpdate(order);
|
||||||
@@ -887,6 +887,7 @@ export class LighterGateway {
|
|||||||
this.clearOrdersForMarket(marketId);
|
this.clearOrdersForMarket(marketId);
|
||||||
} else {
|
} else {
|
||||||
this.orderMap.clear();
|
this.orderMap.clear();
|
||||||
|
this.orderIndexByClientId.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const order of orders) {
|
for (const order of orders) {
|
||||||
@@ -897,26 +898,45 @@ export class LighterGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyOrderUpdate(order: LighterOrder): void {
|
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;
|
if (!key) return;
|
||||||
const status = (order.status ?? "").toLowerCase();
|
const status = (order.status ?? "").toLowerCase();
|
||||||
if (TERMINAL_ORDER_STATUSES.has(status)) {
|
if (TERMINAL_ORDER_STATUSES.has(status)) {
|
||||||
|
const existing = this.orderMap.get(key);
|
||||||
this.orderMap.delete(key);
|
this.orderMap.delete(key);
|
||||||
|
if (existing) {
|
||||||
|
this.forgetOrderIdentity(existing);
|
||||||
|
}
|
||||||
return;
|
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())) {
|
for (const [existingKey, existingOrder] of Array.from(this.orderMap.entries())) {
|
||||||
if (existingKey === key) continue;
|
if (existingKey === key) continue;
|
||||||
const sameOrderIndex =
|
const sameOrderIndex =
|
||||||
order.order_index != null &&
|
orderIdentityEquals(order.order_index, existingOrder.order_index) ||
|
||||||
existingOrder.order_index != null &&
|
orderIdentityEquals(order.order_id, existingOrder.order_id);
|
||||||
Number(existingOrder.order_index) === Number(order.order_index);
|
|
||||||
const sameClientIndex =
|
const sameClientIndex =
|
||||||
order.client_order_index != null &&
|
orderIdentityEquals(order.client_order_index, existingOrder.client_order_index) ||
|
||||||
existingOrder.client_order_index != null &&
|
orderIdentityEquals(order.client_order_id, existingOrder.client_order_id);
|
||||||
Number(existingOrder.client_order_index) === Number(order.client_order_index);
|
|
||||||
if (sameOrderIndex || sameClientIndex) {
|
if (sameOrderIndex || sameClientIndex) {
|
||||||
|
const removed = this.orderMap.get(existingKey);
|
||||||
this.orderMap.delete(existingKey);
|
this.orderMap.delete(existingKey);
|
||||||
|
if (removed) {
|
||||||
|
this.forgetOrderIdentity(removed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -927,8 +947,12 @@ export class LighterGateway {
|
|||||||
const normalized = Number(marketId);
|
const normalized = Number(marketId);
|
||||||
if (!Number.isFinite(normalized)) return;
|
if (!Number.isFinite(normalized)) return;
|
||||||
for (const [key, existing] of Array.from(this.orderMap.entries())) {
|
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.orderMap.delete(key);
|
||||||
|
this.forgetOrderIdentity(existing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -974,6 +998,54 @@ export class LighterGateway {
|
|||||||
this.ordersEvent.emit(mapped);
|
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 {
|
private startPolling(): void {
|
||||||
if (!this.pollers.ticker) {
|
if (!this.pollers.ticker) {
|
||||||
this.pollers.ticker = setInterval(() => {
|
this.pollers.ticker = setInterval(() => {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
LighterOrderBookSnapshot,
|
LighterOrderBookSnapshot,
|
||||||
LighterPosition,
|
LighterPosition,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { coerceBooleanFlag, normalizeBooleanFlag } from "./flags";
|
||||||
|
import { normalizeOrderIdentity } from "./order-identity";
|
||||||
|
|
||||||
export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth {
|
export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth {
|
||||||
const toLevels = (levels: LighterOrderBookLevel[]): AsterDepthLevel[] =>
|
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 {
|
export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterOrder {
|
||||||
const side: OrderSide = order.is_ask || order.side?.toLowerCase() === "sell" || order.side?.toLowerCase() === "ask"
|
const booleanIsAsk = normalizeBooleanFlag(order.is_ask);
|
||||||
? "SELL"
|
const normalizedSide = order.side?.toLowerCase();
|
||||||
: "BUY";
|
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 {
|
return {
|
||||||
// Use string order id to avoid precision loss; prefer on-chain order_index for cancellation
|
// Use string order id to avoid precision loss; prefer on-chain order_index for cancellation
|
||||||
orderId: String(order.order_index ?? order.client_order_index ?? ""),
|
orderId: orderIndex,
|
||||||
clientOrderId: String(order.client_order_index ?? order.order_index ?? ""),
|
clientOrderId: clientIndex || orderIndex,
|
||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
type: mapOrderType(order.type),
|
type: mapOrderType(order.type),
|
||||||
@@ -91,8 +111,8 @@ export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterO
|
|||||||
stopPrice: order.trigger_price ?? "0",
|
stopPrice: order.trigger_price ?? "0",
|
||||||
time: order.created_at ?? Date.now(),
|
time: order.created_at ?? Date.now(),
|
||||||
updateTime: order.updated_at ?? Date.now(),
|
updateTime: order.updated_at ?? Date.now(),
|
||||||
reduceOnly: Boolean(order.reduce_only),
|
reduceOnly,
|
||||||
closePosition: Boolean(order.reduce_only ?? order.owner_account_index === undefined ? false : order.is_ask),
|
closePosition: reduceOnly,
|
||||||
workingType: "MARK_PRICE",
|
workingType: "MARK_PRICE",
|
||||||
activationPrice: order.trigger_price,
|
activationPrice: order.trigger_price,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@ export type LighterOrderType =
|
|||||||
| "take_profit_limit"
|
| "take_profit_limit"
|
||||||
| string;
|
| string;
|
||||||
|
|
||||||
|
type StrOrNum = string | number;
|
||||||
|
|
||||||
export interface LighterOrder {
|
export interface LighterOrder {
|
||||||
order_index: number;
|
order_index: StrOrNum;
|
||||||
client_order_index: number;
|
client_order_index: StrOrNum;
|
||||||
order_id?: string;
|
order_id?: string | null;
|
||||||
client_order_id?: string;
|
client_order_id?: string | null;
|
||||||
market_index: number;
|
market_index: StrOrNum;
|
||||||
owner_account_index?: number;
|
owner_account_index?: number;
|
||||||
initial_base_amount: string;
|
initial_base_amount: string;
|
||||||
remaining_base_amount: string;
|
remaining_base_amount: string;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user