Files
ritmex-bot/src/exchanges/lighter/mappers.ts
T
2025-12-08 19:57:57 +08:00

304 lines
11 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";
import { coerceBooleanFlag, normalizeBooleanFlag } from "./flags";
import { normalizeOrderIdentity } from "./order-identity";
import { normalizeOrderStatus } from "./status";
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: stats.mid_price ?? stats.mark_price ?? stats.index_price,
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 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: orderIndex,
clientOrderId: clientIndex || orderIndex,
symbol,
side,
type: mapOrderType(order.type),
status: normalizeOrderStatus(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,
closePosition: reduceOnly,
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;
marketType?: string | null;
baseAssetSymbol?: string | null;
quoteAssetSymbol?: string | null;
baseAssetId?: number | null;
quoteAssetId?: 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));
if (!transformedPositions.length && normalizeMarketType(options?.marketType) === "spot") {
const baseSymbol = options?.baseAssetSymbol ?? options?.marketSymbol ?? symbol;
const baseAsset = findAsset(assets, baseSymbol);
const baseSize = Number(baseAsset?.walletBalance ?? 0);
if (Number.isFinite(baseSize) && baseSize > 0) {
transformedPositions.push({
symbol,
positionAmt: baseSize.toString(),
entryPrice: "0",
unrealizedProfit: "0",
positionSide: "BOTH",
updateTime: Date.now(),
});
}
}
const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0);
const assetList = assets.length ? assets : defaultAsset(details, options?.quoteAssetSymbol ?? "USDC");
const totalWallet = computeTotalWalletBalance(assetList, details);
return {
canTrade: details.status !== 0,
canDeposit: true,
canWithdraw: true,
updateTime: Date.now(),
totalWalletBalance: totalWallet,
availableBalance: details.available_balance ?? undefined,
totalUnrealizedProfit: aggregateUnrealized.toFixed(8),
positions: transformedPositions,
assets: assetList,
marketType: normalizeMarketType(options?.marketType),
baseAsset: options?.baseAssetSymbol ?? undefined,
quoteAsset: options?.quoteAssetSymbol ?? undefined,
baseAssetId: options?.baseAssetId ?? undefined,
quoteAssetId: options?.quoteAssetId ?? undefined,
};
}
function defaultAsset(details: LighterAccountDetails, quoteAsset: string): AsterAccountAsset[] {
return [
{
asset: quoteAsset || "USDC",
walletBalance: details.collateral ?? "0",
availableBalance: details.available_balance ?? details.collateral ?? "0",
updateTime: Date.now(),
},
];
}
function computeTotalWalletBalance(assets: AsterAccountAsset[], details: LighterAccountDetails): string {
const sum = assets.reduce((acc, asset) => acc + Number(asset.walletBalance ?? 0), 0);
if (Number.isFinite(sum) && sum > 0) {
return sum.toString();
}
return details.total_asset_value ?? details.collateral ?? "0";
}
function findAsset(assets: AsterAccountAsset[], target: string | undefined | null): AsterAccountAsset | undefined {
if (!target) return undefined;
const normalizedTarget = target.toUpperCase().split(/[-:/]/)[0];
return assets.find((asset) => asset.asset.toUpperCase().split(/[-:/]/)[0] === normalizedTarget);
}
function normalizeMarketType(value: string | null | undefined): "perp" | "spot" | undefined {
if (!value) return undefined;
const normalized = value.toLowerCase();
if (normalized === "spot") return "spot";
if (normalized === "perp" || normalized === "perpetual" || normalized === "futures") return "perp";
return undefined;
}
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);
}