diff --git a/src/exchanges/backpack/gateway.ts b/src/exchanges/backpack/gateway.ts index 4d52bbf..ad88b2a 100644 --- a/src/exchanges/backpack/gateway.ts +++ b/src/exchanges/backpack/gateway.ts @@ -1,6 +1,7 @@ import ccxt, { type Balances, type Order as CcxtOrder, type OrderBook as CcxtOrderBook, type Ticker as CcxtTicker } from "ccxt"; import type { AsterAccountSnapshot, + AsterAccountPosition, AsterOrder, AsterDepth, AsterTicker, @@ -30,6 +31,8 @@ export class BackpackGateway { private readonly exchange: any; private readonly symbol: string; private marketSymbol: string; + private market: any | null = null; + private isContractMarket = false; private readonly logger: (context: string, error: unknown) => void; private initialized = false; private initPromise: Promise | null = null; @@ -84,7 +87,7 @@ export class BackpackGateway { private async doInitialize(symbol?: string): Promise { try { await this.exchange.loadMarkets(); - + // Verify symbol exists const requested = (symbol ?? this.symbol).toUpperCase(); const resolved = this.resolveMarketSymbol(requested); @@ -92,7 +95,9 @@ export class BackpackGateway { throw new Error(`Symbol ${requested} not found in Backpack markets`); } this.marketSymbol = resolved; - + this.market = this.exchange.market(this.marketSymbol); + this.isContractMarket = Boolean(this.market?.contract); + this.initialized = true; this.logger("initialize", `Backpack gateway initialized for ${this.marketSymbol}`); } catch (error) { @@ -186,12 +191,11 @@ export class BackpackGateway { // Polling implementations private startAccountPolling(): void { if (this.accountPollTimer) return; - + const poll = async () => { try { - const balance = await this.exchange.fetchBalance(); - const accountSnapshot = this.mapBalanceToAccountSnapshot(balance); - + const accountSnapshot = await this.fetchAccountSnapshot(); + for (const listener of this.accountListeners) { listener(accountSnapshot); } @@ -206,7 +210,7 @@ export class BackpackGateway { private startOrderPolling(): void { if (this.orderPollTimer) return; - + const poll = async () => { try { const [openOrders, closedOrders] = await Promise.all([ @@ -318,7 +322,10 @@ export class BackpackGateway { if (params.reduceOnly !== undefined) { extraParams.reduceOnly = params.reduceOnly === "true"; } - + if (params.closePosition !== undefined) { + extraParams.closePosition = params.closePosition === "true"; + } + const order = await this.exchange.createOrder( symbol, type, @@ -360,30 +367,172 @@ export class BackpackGateway { // Mapping functions private mapBalanceToAccountSnapshot(balance: Balances): AsterAccountSnapshot { - const positions: any[] = []; // Backpack is spot-only, no positions - const assets: any[] = []; - - for (const [currency, amount] of Object.entries(balance)) { - if (typeof amount === 'object' && amount !== null) { - assets.push({ - asset: currency, - walletBalance: amount.total?.toString() || "0", - availableBalance: amount.free?.toString() || "0", - updateTime: Date.now(), - }); - } - } - - return { + return this.mapBalanceToAccountSnapshotWithPositions(balance, []); + } + + private async fetchAccountSnapshot(): Promise { + await this.ensureInitialized(); + const balancePromise = this.exchange.fetchBalance(); + const positionsPromise = this.isContractMarket + ? this.exchange.fetchPositions([this.marketSymbol]).catch((error: unknown) => { + this.logger("fetchPositions", error); + return []; + }) + : Promise.resolve([]); + + const [balance, positions] = await Promise.all([balancePromise, positionsPromise]); + return this.mapBalanceToAccountSnapshotWithPositions(balance, positions ?? []); + } + + private mapBalanceToAccountSnapshotWithPositions(balance: Balances, rawPositions: any[]): AsterAccountSnapshot { + const now = Date.now(); + const assets = this.normalizeAssets(balance, now); + const positions = this.normalizePositions(rawPositions, now); + + const totalWalletBalance = this.sumStrings(assets.map((asset) => asset.walletBalance)); + const totalUnrealizedProfit = this.sumStrings(positions.map((position) => position.unrealizedProfit ?? "0")); + const availableBalance = this.sumStrings(assets.map((asset) => asset.availableBalance)); + + const snapshot: AsterAccountSnapshot = { canTrade: true, canDeposit: true, canWithdraw: true, - updateTime: Date.now(), - totalWalletBalance: balance.total?.toString() || "0", - totalUnrealizedProfit: "0", + updateTime: now, + totalWalletBalance, + totalUnrealizedProfit, positions, assets, }; + + snapshot.availableBalance = availableBalance; + snapshot.maxWithdrawAmount = availableBalance; + + if (this.isContractMarket) { + const totalMarginBalance = this.addStrings(totalWalletBalance, totalUnrealizedProfit); + snapshot.totalMarginBalance = totalMarginBalance; + snapshot.totalCrossWalletBalance = totalWalletBalance; + snapshot.totalCrossUnPnl = totalUnrealizedProfit; + } + + return snapshot; + } + + private normalizeAssets(balance: Balances, now: number): AsterAccountSnapshot["assets"] { + const metaKeys = new Set(["free", "used", "total", "info", "timestamp", "datetime", "debt"]); + const assets: AsterAccountSnapshot["assets"] = []; + + for (const [currency, value] of Object.entries(balance)) { + if (metaKeys.has(currency)) continue; + if (!value || typeof value !== "object") continue; + + const walletBalance = this.toStringAmount((value as any).total ?? (value as any).free ?? "0"); + const availableBalance = this.toStringAmount((value as any).free ?? "0"); + + assets.push({ + asset: currency, + walletBalance, + availableBalance, + updateTime: now, + }); + } + + return assets; + } + + private normalizePositions(rawPositions: any[], now: number): AsterAccountSnapshot["positions"] { + if (!Array.isArray(rawPositions)) return []; + + const positions: AsterAccountSnapshot["positions"] = []; + + for (const position of rawPositions) { + const info = position?.info ?? position ?? {}; + const rawSymbol = position?.symbol ?? info.symbol ?? this.marketSymbol; + const rawContracts = position?.contracts ?? info.netExposureQuantity; + const derivedSide = (position?.side ?? info.side ?? this.deriveSideFromExposure(info)) ?? "long"; + const rawSide = derivedSide.toString().toLowerCase(); + const quantity = this.toNumber(rawContracts); + if (!quantity) continue; + + const side = rawSide === "short" ? "short" : "long"; + const signedQuantity = side === "short" ? -Math.abs(quantity) : Math.abs(quantity); + + const normalized: AsterAccountPosition = { + symbol: rawSymbol, + positionAmt: signedQuantity.toString(), + entryPrice: this.toStringAmount(position?.entryPrice ?? info.entryPrice ?? "0"), + unrealizedProfit: this.toStringAmount(position?.unrealizedPnl ?? info.pnlUnrealized ?? "0"), + positionSide: side === "short" ? "SHORT" : "LONG", + updateTime: now, + }; + + const markPrice = this.toOptionalString(position?.markPrice ?? info.markPrice); + if (markPrice !== undefined) normalized.markPrice = markPrice; + + const liquidationPrice = this.toOptionalString(position?.liquidationPrice ?? info.estLiquidationPrice); + if (liquidationPrice !== undefined) normalized.liquidationPrice = liquidationPrice; + + const initialMargin = this.toOptionalString(position?.initialMargin ?? info.initialMargin); + if (initialMargin !== undefined) normalized.initialMargin = initialMargin; + + const maintMargin = this.toOptionalString(position?.maintenanceMargin ?? info.maintenanceMargin); + if (maintMargin !== undefined) normalized.maintMargin = maintMargin; + + const leverage = this.toOptionalString(position?.leverage ?? info.leverage); + if (leverage !== undefined) normalized.leverage = leverage; + + normalized.marginType = "CROSSED"; + + positions.push(normalized); + } + + return positions; + } + + private deriveSideFromExposure(info: Record): "long" | "short" | "flat" { + const exposure = this.toNumber(info?.netExposureNotional ?? info?.netCost ?? info?.netQuantity); + if (!exposure) return "flat"; + return exposure < 0 ? "short" : "long"; + } + + private toStringAmount(value: unknown): string { + if (value === undefined || value === null) return "0"; + if (typeof value === "string") { + if (value.trim() === "") return "0"; + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) return "0"; + return value.toString(); + } + return "0"; + } + + private toOptionalString(value: unknown): string | undefined { + const normalized = this.toStringAmount(value); + return normalized === "0" ? undefined : normalized; + } + + private toNumber(value: unknown): number { + const asString = this.toStringAmount(value); + const parsed = Number(asString); + if (!Number.isFinite(parsed)) return 0; + return parsed; + } + + private sumStrings(values: string[]): string { + let total = 0; + for (const value of values) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) continue; + total += parsed; + } + return total.toString(); + } + + private addStrings(a: string, b: string): string { + const sum = Number(a) + Number(b); + if (!Number.isFinite(sum)) return "0"; + return sum.toString(); } private mapOrderToAsterOrder(order: CcxtOrder): AsterOrder { diff --git a/tests/backpack-gateway.test.ts b/tests/backpack-gateway.test.ts new file mode 100644 index 0000000..2ae0fd5 --- /dev/null +++ b/tests/backpack-gateway.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { BackpackGateway } from "../src/exchanges/backpack/gateway"; + +describe("BackpackGateway account snapshots", () => { + const createGateway = () => + new BackpackGateway({ + apiKey: "key", + apiSecret: "secret", + symbol: "BTCUSDC", + logger: () => {}, + }) as any; + + it("maps spot balances without positions", () => { + const gateway = createGateway(); + gateway.isContractMarket = false; + + const balance = { + info: {}, + free: { USDC: "10" }, + used: { USDC: "5" }, + total: { USDC: "15" }, + USDC: { free: "10", used: "5", total: "15" }, + } as any; + + const snapshot = gateway.mapBalanceToAccountSnapshotWithPositions(balance, []); + + expect(snapshot.positions).toEqual([]); + expect(snapshot.totalWalletBalance).toBe("15"); + expect(snapshot.totalUnrealizedProfit).toBe("0"); + expect(snapshot.availableBalance).toBe("10"); + expect(snapshot.maxWithdrawAmount).toBe("10"); + expect(snapshot.totalMarginBalance).toBeUndefined(); + expect(snapshot.assets).toHaveLength(1); + expect(snapshot.assets[0]).toMatchObject({ + asset: "USDC", + walletBalance: "15", + availableBalance: "10", + }); + }); + + it("includes derivative positions when present", () => { + const gateway = createGateway(); + gateway.isContractMarket = true; + gateway.marketSymbol = "BTC/USDC:USDC"; + + const balance = { + info: {}, + free: { USDC: "80" }, + used: { USDC: "20" }, + total: { USDC: "100" }, + USDC: { free: "80", used: "20", total: "100" }, + } as any; + + const positions = [ + { + symbol: "BTC/USDC:USDC", + contracts: "2", + side: "long", + entryPrice: "25000", + markPrice: "25200", + unrealizedPnl: "400", + info: { + estLiquidationPrice: "15000", + }, + }, + { + info: { + symbol: "ETH/USDC:USDC", + netExposureQuantity: "0.5", + netCost: "-100", + entryPrice: "3000", + pnlUnrealized: "-10", + markPrice: "2900", + estLiquidationPrice: "1000", + }, + }, + ]; + + const snapshot = gateway.mapBalanceToAccountSnapshotWithPositions(balance, positions); + + expect(snapshot.positions).toHaveLength(2); + expect(snapshot.positions[0]).toMatchObject({ + symbol: "BTC/USDC:USDC", + positionAmt: "2", + positionSide: "LONG", + entryPrice: "25000", + unrealizedProfit: "400", + markPrice: "25200", + liquidationPrice: "15000", + }); + expect(snapshot.positions[1]).toMatchObject({ + symbol: "ETH/USDC:USDC", + positionAmt: "-0.5", + positionSide: "SHORT", + entryPrice: "3000", + unrealizedProfit: "-10", + markPrice: "2900", + liquidationPrice: "1000", + }); + + expect(snapshot.totalWalletBalance).toBe("100"); + expect(snapshot.totalUnrealizedProfit).toBe("390"); + expect(snapshot.totalMarginBalance).toBe("490"); + expect(snapshot.totalCrossWalletBalance).toBe("100"); + expect(snapshot.totalCrossUnPnl).toBe("390"); + }); +});