2 changed files with 282 additions and 26 deletions
+175 -26
View File
@@ -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<void> | null = null;
@@ -84,7 +87,7 @@ export class BackpackGateway {
private async doInitialize(symbol?: string): Promise<void> {
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<AsterAccountSnapshot> {
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<string, unknown>): "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 {
+107
View File
@@ -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");
});
});