Files
ritmex-bot/src/exchanges/lighter/mappers.ts
T

231 lines
7.9 KiB
TypeScript

import type {
AsterAccountAsset,
AsterAccountPosition,
AsterAccountSnapshot,
AsterDepth,
AsterDepthLevel,
AsterKline,
AsterOrder,
AsterTicker,
OrderSide,
OrderType,
} from "../types";
import type {
LighterAccountDetails,
LighterKline,
LighterMarketStats,
LighterOrder,
LighterOrderBookLevel,
LighterOrderBookSnapshot,
LighterPosition,
} from "./types";
export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth {
const toLevels = (levels: LighterOrderBookLevel[]): AsterDepthLevel[] =>
levels.map((level) => [level.price, level.size]);
return {
symbol,
lastUpdateId: snapshot.offset ?? Date.now(),
bids: toLevels(snapshot.bids ?? []),
asks: toLevels(snapshot.asks ?? []),
eventTime: Date.now(),
eventType: "lighterDepth",
};
}
export function toTicker(symbol: string, stats: LighterMarketStats): AsterTicker {
return {
symbol,
eventType: "lighterTicker",
eventTime: Date.now(),
lastPrice: stats.last_trade_price,
openPrice: stats.daily_price_low != null ? String(stats.daily_price_low) : stats.last_trade_price,
highPrice: stats.daily_price_high != null ? String(stats.daily_price_high) : stats.last_trade_price,
lowPrice: stats.daily_price_low != null ? String(stats.daily_price_low) : stats.last_trade_price,
volume: stats.daily_base_token_volume != null ? String(stats.daily_base_token_volume) : "0",
quoteVolume: stats.daily_quote_token_volume != null ? String(stats.daily_quote_token_volume) : "0",
priceChange: stats.daily_price_change != null ? String(stats.daily_price_change) : undefined,
markPrice: undefined,
weightedAvgPrice: undefined,
} as AsterTicker;
}
export function toKlines(symbol: string, interval: string, klines: LighterKline[]): AsterKline[] {
return klines.map((entry) => ({
symbol,
eventType: "lighterKline",
eventTime: Date.now(),
interval,
openTime: entry.start_timestamp,
closeTime: entry.end_timestamp,
open: entry.open,
high: entry.high,
low: entry.low,
close: entry.close,
volume: entry.base_token_volume,
quoteAssetVolume: entry.quote_token_volume,
numberOfTrades: entry.trades ?? 0,
isClosed: true,
}));
}
export function toOrders(symbol: string, orders: LighterOrder[]): AsterOrder[] {
return orders.map((order) => lighterOrderToAster(symbol, order));
}
export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterOrder {
const side: OrderSide = order.is_ask || order.side?.toLowerCase() === "sell" || order.side?.toLowerCase() === "ask"
? "SELL"
: "BUY";
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 ?? ""),
symbol,
side,
type: mapOrderType(order.type),
status: order.status ?? order.trigger_status ?? "UNKNOWN",
price: order.price ?? "0",
origQty: order.initial_base_amount ?? "0",
executedQty: computeExecutedQty(order),
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),
workingType: "MARK_PRICE",
activationPrice: order.trigger_price,
};
}
function computeExecutedQty(order: LighterOrder): string {
if (order.filled_base_amount) return order.filled_base_amount;
if (order.initial_base_amount && order.remaining_base_amount) {
try {
const initial = Number(order.initial_base_amount);
const remaining = Number(order.remaining_base_amount);
if (Number.isFinite(initial) && Number.isFinite(remaining)) {
return (initial - remaining).toString();
}
} catch (_) {
// fall through
}
}
return "0";
}
function mapOrderType(value?: string): OrderType {
if (!value) return "LIMIT";
const normalized = value.toLowerCase();
switch (normalized) {
case "limit":
return "LIMIT";
case "market":
return "MARKET";
case "stop_loss":
case "stop_loss_market":
return "STOP_MARKET";
case "stop_loss_limit":
return "LIMIT";
case "take_profit":
case "take_profit_market":
return "STOP_MARKET";
case "take_profit_limit":
return "LIMIT";
default:
return "LIMIT";
}
}
export function toAccountSnapshot(
symbol: string,
details: LighterAccountDetails,
positions: LighterPosition[] = [],
assets: AsterAccountAsset[] = [],
options?: { marketSymbol?: string | null; marketId?: number | null }
): AsterAccountSnapshot {
const targetSymbol = options?.marketSymbol ?? null;
const targetMarketId =
options?.marketId != null && Number.isFinite(Number(options.marketId))
? Number(options.marketId)
: null;
const filteredPositions = positions.filter((position) => {
if (targetMarketId != null) {
const positionMarketId = Number(position.market_id);
if (Number.isFinite(positionMarketId)) {
return positionMarketId === targetMarketId;
}
return targetSymbol ? symbolsMatch(position.symbol, targetSymbol) : false;
}
if (targetSymbol) {
return symbolsMatch(position.symbol, targetSymbol);
}
return true;
});
const transformedPositions = filteredPositions.map((position) => lighterPositionToAster(symbol, position));
const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0);
const assetList = assets.length ? assets : defaultAsset(details);
return {
canTrade: details.status !== 0,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: details.collateral ?? "0",
totalUnrealizedProfit: aggregateUnrealized.toFixed(8),
positions: transformedPositions,
assets: assetList,
};
}
function defaultAsset(details: LighterAccountDetails): AsterAccountAsset[] {
return [
{
asset: "USDC",
walletBalance: details.collateral ?? "0",
availableBalance: details.available_balance ?? details.collateral ?? "0",
updateTime: Date.now(),
},
];
}
function lighterPositionToAster(symbol: string, position: LighterPosition): AsterAccountPosition {
const sign = position.sign ?? 0;
const positionSide = sign > 0 ? "LONG" : sign < 0 ? "SHORT" : "BOTH";
const magnitude = Number(position.position ?? 0);
// Normalize: when sign is 0, treat as flat regardless of reported magnitude
const signed = sign === 0 ? 0 : sign < 0 ? -Math.abs(magnitude) : Math.abs(magnitude);
return {
symbol,
positionAmt: Number.isFinite(signed) ? signed.toString() : position.position ?? "0",
entryPrice: position.avg_entry_price ?? "0",
unrealizedProfit: position.unrealized_pnl ?? "0",
positionSide,
updateTime: Date.now(),
liquidationPrice: position.liquidation_price,
maintMargin: undefined,
initialMargin: position.allocated_margin,
marginType: position.margin_mode === 1 ? "ISOLATED" : "CROSS",
markPrice: undefined,
};
}
function symbolsMatch(source: string | null | undefined, target: string | null | undefined): boolean {
if (!source || !target) return false;
const sourceForms = normalizeSymbolForms(source);
const targetForms = normalizeSymbolForms(target);
if (!sourceForms.length || !targetForms.length) return false;
return sourceForms.some((value) => targetForms.includes(value));
}
function normalizeSymbolForms(value: string): string[] {
const upper = value.toUpperCase();
const sanitized = upper.replace(/[^A-Z0-9]/g, "");
const parts = upper.split(/[-:/]/).filter(Boolean);
const base = parts.length ? parts[0] : "";
const forms = new Set<string>();
if (upper) forms.add(upper);
if (sanitized) forms.add(sanitized);
if (base) forms.add(base);
return Array.from(forms);
}