mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 17:28:08 +00:00
feat: 添加期现套利策略支持,更新相关配置和界面,增强 Aster 现货 API 客户端功能
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import crypto from "crypto";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AsterSpotRestClient } from "../src/exchanges/aster/client";
|
||||
|
||||
describe("AsterSpotRestClient", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock = vi.fn();
|
||||
// @ts-expect-error override for tests
|
||||
globalThis.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls ping without credentials", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
|
||||
|
||||
await client.ping();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://sapi.asterdex.com/api/v1/ping");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers).toEqual({});
|
||||
});
|
||||
|
||||
it("signs market order requests", async () => {
|
||||
const orderResponse = {
|
||||
orderId: 1,
|
||||
clientOrderId: "abc",
|
||||
symbol: "BTCUSDT",
|
||||
side: "BUY",
|
||||
type: "MARKET",
|
||||
status: "FILLED",
|
||||
price: "0",
|
||||
origQty: "1",
|
||||
executedQty: "1",
|
||||
stopPrice: "0",
|
||||
time: 1000,
|
||||
updateTime: 1000,
|
||||
reduceOnly: false,
|
||||
closePosition: false,
|
||||
};
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(orderResponse), { status: 200 }));
|
||||
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
|
||||
vi.spyOn(Date, "now").mockReturnValue(1000);
|
||||
|
||||
await client.createOrder({ symbol: "BTCUSDT", side: "BUY", type: "MARKET", quoteOrderQty: "100" });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://sapi.asterdex.com/api/v1/order");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toEqual({
|
||||
"X-MBX-APIKEY": "key",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
});
|
||||
const payload = "quoteOrderQty=100&recvWindow=5000&side=BUY&symbol=BTCUSDT×tamp=1000&type=MARKET";
|
||||
const expectedSignature = crypto.createHmac("sha256", "secret").update(payload).digest("hex");
|
||||
expect(init.body).toBe(`${payload}&signature=${expectedSignature}`);
|
||||
});
|
||||
|
||||
it("attaches api key for historical trades without signing", async () => {
|
||||
const trades = [
|
||||
{ id: 1, price: "1", qty: "1", time: 1000, isBuyerMaker: false },
|
||||
];
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(trades), { status: 200 }));
|
||||
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
|
||||
|
||||
await client.getHistoricalTrades({ symbol: "BTCUSDT" });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://sapi.asterdex.com/api/v1/historicalTrades?symbol=BTCUSDT");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers).toEqual({ "X-MBX-APIKEY": "key" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterDepth,
|
||||
AsterKline,
|
||||
AsterOrder,
|
||||
AsterTicker,
|
||||
} from "../src/exchanges/types";
|
||||
import { BasisArbEngine } from "../src/strategy/basis-arb-engine";
|
||||
|
||||
class StubAdapter implements ExchangeAdapter {
|
||||
id = "aster";
|
||||
private depthHandler: ((depth: AsterDepth) => void) | null = null;
|
||||
|
||||
supportsTrailingStops(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {
|
||||
// not required for this test
|
||||
}
|
||||
|
||||
watchOrders(_cb: (orders: AsterOrder[]) => void): void {
|
||||
// not required for this test
|
||||
}
|
||||
|
||||
watchDepth(_symbol: string, cb: (depth: AsterDepth) => void): void {
|
||||
this.depthHandler = cb;
|
||||
}
|
||||
|
||||
emitDepth(depth: AsterDepth): void {
|
||||
this.depthHandler?.(depth);
|
||||
}
|
||||
|
||||
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {
|
||||
// not required for this test
|
||||
}
|
||||
|
||||
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {
|
||||
// not required for this test
|
||||
}
|
||||
|
||||
createOrder(): Promise<AsterOrder> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
cancelOrder(_params: { symbol: string; orderId: number | string }): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
cancelOrders(_params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
cancelAllOrders(_params: { symbol: string }): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("BasisArbEngine", () => {
|
||||
it("computes spreads after receiving futures depth and spot quotes", async () => {
|
||||
const adapter = new StubAdapter();
|
||||
const spotClient = {
|
||||
getBookTicker: vi.fn().mockResolvedValue({
|
||||
symbol: "ASTERUSDT",
|
||||
bidPrice: "1.0000",
|
||||
bidQty: "1",
|
||||
askPrice: "1.0500",
|
||||
askQty: "1",
|
||||
time: 2_000,
|
||||
}),
|
||||
};
|
||||
|
||||
const engine = new BasisArbEngine(
|
||||
{
|
||||
futuresSymbol: "ASTERUSDT",
|
||||
spotSymbol: "ASTERUSDT",
|
||||
refreshIntervalMs: 1_000,
|
||||
maxLogEntries: 10,
|
||||
takerFeeRate: 0.0004,
|
||||
},
|
||||
adapter,
|
||||
{
|
||||
spotClient,
|
||||
now: () => 1_000,
|
||||
}
|
||||
);
|
||||
|
||||
engine.start();
|
||||
|
||||
adapter.emitDepth({
|
||||
lastUpdateId: 1,
|
||||
bids: [["1.0400", "1"]],
|
||||
asks: [["1.0600", "1"]],
|
||||
eventTime: 1_500,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spotClient.getBookTicker).toHaveBeenCalled();
|
||||
const snap = engine.getSnapshot();
|
||||
expect(snap.spotBid).not.toBeNull();
|
||||
expect(snap.futuresBid).not.toBeNull();
|
||||
});
|
||||
|
||||
const snapshot = engine.getSnapshot();
|
||||
expect(snapshot.spread).toBeCloseTo(1.04 - 1.05, 6);
|
||||
expect(snapshot.spreadBps).toBeCloseTo(((1.04 - 1.05) / 1.05) * 10_000, 6);
|
||||
const fee = 0.0004;
|
||||
const effectiveFee = fee * 2;
|
||||
const expectedNet = 1.04 * (1 - effectiveFee) - 1.05 * (1 + effectiveFee);
|
||||
expect(snapshot.netSpread).toBeCloseTo(expectedNet, 6);
|
||||
expect(snapshot.netSpreadBps).toBeCloseTo((expectedNet / 1.05) * 10_000, 6);
|
||||
expect(snapshot.feedStatus).toEqual({ futures: true, spot: true });
|
||||
expect(snapshot.opportunity).toBe(expectedNet >= 0);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user