mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-10 08:48:07 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d829e451cd |
@@ -1,6 +1,7 @@
|
|||||||
import ccxt, { type Balances, type Order as CcxtOrder, type OrderBook as CcxtOrderBook, type Ticker as CcxtTicker } from "ccxt";
|
import ccxt, { type Balances, type Order as CcxtOrder, type OrderBook as CcxtOrderBook, type Ticker as CcxtTicker } from "ccxt";
|
||||||
import type {
|
import type {
|
||||||
AsterAccountSnapshot,
|
AsterAccountSnapshot,
|
||||||
|
AsterAccountPosition,
|
||||||
AsterOrder,
|
AsterOrder,
|
||||||
AsterDepth,
|
AsterDepth,
|
||||||
AsterTicker,
|
AsterTicker,
|
||||||
@@ -30,6 +31,8 @@ export class BackpackGateway {
|
|||||||
private readonly exchange: any;
|
private readonly exchange: any;
|
||||||
private readonly symbol: string;
|
private readonly symbol: string;
|
||||||
private marketSymbol: string;
|
private marketSymbol: string;
|
||||||
|
private market: any | null = null;
|
||||||
|
private isContractMarket = false;
|
||||||
private readonly logger: (context: string, error: unknown) => void;
|
private readonly logger: (context: string, error: unknown) => void;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
private initPromise: Promise<void> | null = null;
|
private initPromise: Promise<void> | null = null;
|
||||||
@@ -92,6 +95,8 @@ export class BackpackGateway {
|
|||||||
throw new Error(`Symbol ${requested} not found in Backpack markets`);
|
throw new Error(`Symbol ${requested} not found in Backpack markets`);
|
||||||
}
|
}
|
||||||
this.marketSymbol = resolved;
|
this.marketSymbol = resolved;
|
||||||
|
this.market = this.exchange.market(this.marketSymbol);
|
||||||
|
this.isContractMarket = Boolean(this.market?.contract);
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
this.logger("initialize", `Backpack gateway initialized for ${this.marketSymbol}`);
|
this.logger("initialize", `Backpack gateway initialized for ${this.marketSymbol}`);
|
||||||
@@ -189,8 +194,7 @@ export class BackpackGateway {
|
|||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const balance = await this.exchange.fetchBalance();
|
const accountSnapshot = await this.fetchAccountSnapshot();
|
||||||
const accountSnapshot = this.mapBalanceToAccountSnapshot(balance);
|
|
||||||
|
|
||||||
for (const listener of this.accountListeners) {
|
for (const listener of this.accountListeners) {
|
||||||
listener(accountSnapshot);
|
listener(accountSnapshot);
|
||||||
@@ -318,6 +322,9 @@ export class BackpackGateway {
|
|||||||
if (params.reduceOnly !== undefined) {
|
if (params.reduceOnly !== undefined) {
|
||||||
extraParams.reduceOnly = params.reduceOnly === "true";
|
extraParams.reduceOnly = params.reduceOnly === "true";
|
||||||
}
|
}
|
||||||
|
if (params.closePosition !== undefined) {
|
||||||
|
extraParams.closePosition = params.closePosition === "true";
|
||||||
|
}
|
||||||
|
|
||||||
const order = await this.exchange.createOrder(
|
const order = await this.exchange.createOrder(
|
||||||
symbol,
|
symbol,
|
||||||
@@ -360,30 +367,172 @@ export class BackpackGateway {
|
|||||||
|
|
||||||
// Mapping functions
|
// Mapping functions
|
||||||
private mapBalanceToAccountSnapshot(balance: Balances): AsterAccountSnapshot {
|
private mapBalanceToAccountSnapshot(balance: Balances): AsterAccountSnapshot {
|
||||||
const positions: any[] = []; // Backpack is spot-only, no positions
|
return this.mapBalanceToAccountSnapshotWithPositions(balance, []);
|
||||||
const assets: any[] = [];
|
}
|
||||||
|
|
||||||
for (const [currency, amount] of Object.entries(balance)) {
|
private async fetchAccountSnapshot(): Promise<AsterAccountSnapshot> {
|
||||||
if (typeof amount === 'object' && amount !== null) {
|
await this.ensureInitialized();
|
||||||
assets.push({
|
const balancePromise = this.exchange.fetchBalance();
|
||||||
asset: currency,
|
const positionsPromise = this.isContractMarket
|
||||||
walletBalance: amount.total?.toString() || "0",
|
? this.exchange.fetchPositions([this.marketSymbol]).catch((error: unknown) => {
|
||||||
availableBalance: amount.free?.toString() || "0",
|
this.logger("fetchPositions", error);
|
||||||
updateTime: Date.now(),
|
return [];
|
||||||
});
|
})
|
||||||
}
|
: Promise.resolve([]);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
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,
|
canTrade: true,
|
||||||
canDeposit: true,
|
canDeposit: true,
|
||||||
canWithdraw: true,
|
canWithdraw: true,
|
||||||
updateTime: Date.now(),
|
updateTime: now,
|
||||||
totalWalletBalance: balance.total?.toString() || "0",
|
totalWalletBalance,
|
||||||
totalUnrealizedProfit: "0",
|
totalUnrealizedProfit,
|
||||||
positions,
|
positions,
|
||||||
assets,
|
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 {
|
private mapOrderToAsterOrder(order: CcxtOrder): AsterOrder {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user