feat: 添加期现套利策略支持,更新相关配置和界面,增强 Aster 现货 API 客户端功能

This commit is contained in:
discountry
2025-10-07 00:23:24 +08:00
parent 1bd3e7edc6
commit d7a95ceb36
11 changed files with 3971 additions and 39 deletions
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,4 +1,4 @@
export type StrategyId = "trend" | "maker" | "offset-maker";
export type StrategyId = "trend" | "maker" | "offset-maker" | "basis";
export interface CliOptions {
strategy?: StrategyId;
@@ -7,7 +7,7 @@ export interface CliOptions {
exchange?: "aster" | "grvt" | "lighter" | "backpack";
}
const STRATEGY_VALUES = new Set<StrategyId>(["trend", "maker", "offset-maker"]);
const STRATEGY_VALUES = new Set<StrategyId>(["trend", "maker", "offset-maker", "basis"]);
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
const options: CliOptions = { silent: false, help: false };
@@ -77,7 +77,7 @@ function assignExchange(options: CliOptions, raw: string): void {
export function printCliHelp(): void {
// eslint-disable-next-line no-console
console.log(`Usage: bun run index.ts [--strategy <trend|maker|offset-maker>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
console.log(`Usage: bun run index.ts [--strategy <trend|maker|offset-maker|basis>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
`Options:\n` +
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
` Aliases: offset, offset-maker for the offset maker engine.\n` +
+23 -2
View File
@@ -1,4 +1,4 @@
import { makerConfig, tradingConfig } from "../config";
import { basisConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import type { ExchangeAdapter } from "../exchanges/adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
@@ -8,6 +8,7 @@ import {
} from "../strategy/maker-engine";
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
import { extractMessage } from "../utils/errors";
import type { StrategyId } from "./args";
@@ -21,6 +22,7 @@ export const STRATEGY_LABELS: Record<StrategyId, string> = {
trend: "Trend Following",
maker: "Maker",
"offset-maker": "Offset Maker",
basis: "Basis Arbitrage",
};
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
@@ -71,6 +73,25 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
offUpdate: (emitter) => engine.off("update", emitter),
});
},
basis: async (opts) => {
if (!isBasisStrategyEnabled()) {
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
}
const exchangeId = resolveExchangeId();
if (exchangeId !== "aster") {
throw new Error("Basis arbitrage strategy currently only supports the Aster exchange");
}
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol);
const engine = new BasisArbEngine(basisConfig, adapter);
await runEngine({
engine,
strategy: "basis",
silent: opts.silent,
getSnapshot: () => engine.getSnapshot(),
onUpdate: (emitter) => engine.on("update", emitter),
offUpdate: (emitter) => engine.off("update", emitter),
});
},
};
interface EngineHarness<TSnapshot> {
@@ -82,7 +103,7 @@ interface EngineHarness<TSnapshot> {
offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
}
async function runEngine<TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot>(
async function runEngine<TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot | BasisArbSnapshot>(
harness: EngineHarness<TSnapshot>
): Promise<void> {
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
+39 -20
View File
@@ -1,26 +1,6 @@
/**
* Trading Configuration
*
* Environment Variables for Backpack Exchange:
* - BACKPACK_API_KEY: Required API key for Backpack
* - BACKPACK_API_SECRET: Required API secret for Backpack
* - BACKPACK_PASSWORD: Optional password for Backpack (if required)
* - BACKPACK_SUBACCOUNT: Optional subaccount name
* - BACKPACK_SYMBOL: Override symbol (defaults to TRADE_SYMBOL)
* - BACKPACK_SANDBOX: Set to "true" for sandbox mode
* - BACKPACK_DEBUG: Set to "true" for debug logging
*
* Environment Variables for Paradex Exchange:
* - PARADEX_PRIVATE_KEY: Required EVM private key for REST/WS authentication
* - PARADEX_WALLET_ADDRESS: Required wallet address matching the private key
* - PARADEX_SYMBOL: Override symbol (defaults to TRADE_SYMBOL)
* - PARADEX_SANDBOX: Set to "true" to use testnet endpoints
* - PARADEX_USE_PRO: Set to "false" to disable ccxt.pro websocket feeds
* - PARADEX_RECONNECT_DELAY_MS: Optional websocket reconnect delay in ms (default 2000)
* - PARADEX_DEBUG: Set to "true" for verbose Paradex adapter logging
*
* Usage: Set EXCHANGE=backpack to use Backpack exchange
* Set EXCHANGE=paradex to use Paradex exchange
*/
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
@@ -117,3 +97,42 @@ export const makerConfig: MakerConfig = {
),
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
};
export interface BasisArbConfig {
futuresSymbol: string;
spotSymbol: string;
refreshIntervalMs: number;
maxLogEntries: number;
takerFeeRate: number;
}
const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
for (const key of envKeys) {
const value = process.env[key];
if (value && value.trim()) {
return value.trim().toUpperCase();
}
}
return fallback.toUpperCase();
};
export const basisConfig: BasisArbConfig = {
futuresSymbol: resolveBasisSymbol(
["BASIS_FUTURES_SYMBOL", "ASTER_FUTURES_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
"ASTERUSDT"
),
spotSymbol: resolveBasisSymbol(
["BASIS_SPOT_SYMBOL", "ASTER_SPOT_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
"ASTERUSDT"
),
refreshIntervalMs: parseNumber(process.env.BASIS_REFRESH_INTERVAL_MS, 1000),
maxLogEntries: parseNumber(process.env.BASIS_MAX_LOG_ENTRIES, 200),
takerFeeRate: parseNumber(process.env.BASIS_TAKER_FEE_RATE, 0.0004),
};
export function isBasisStrategyEnabled(): boolean {
const raw = process.env.ENABLE_BASIS_STRATEGY;
if (!raw) return false;
const normalized = raw.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
+516 -11
View File
@@ -6,14 +6,33 @@ import type {
AsterDepth,
AsterKline,
AsterOrder,
AsterSpotAccount,
AsterSpotAggTrade,
AsterSpotBookTicker,
AsterSpotCommissionRate,
AsterSpotDepth,
AsterSpotExchangeInfo,
AsterSpotHistoricalTrade,
AsterSpotKline,
AsterSpotPriceTicker,
AsterSpotTicker24h,
AsterSpotTrade,
AsterSpotUserTrade,
AsterTicker,
CancelSpotOrderParams,
CreateOrderParams,
CreateSpotOrderParams,
PositionSide,
QuerySpotOrderParams,
SpotAllOrdersParams,
SpotOpenOrdersParams,
SpotUserTradesParams,
} from "../types";
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
const REST_BASE = "https://fapi.asterdex.com";
const FUTURES_REST_BASE = "https://fapi.asterdex.com";
const SPOT_REST_BASE = "https://sapi.asterdex.com";
const WS_PUBLIC_URL = "wss://fstream.asterdex.com/ws";
const WS_LISTEN_KEY_URL = "wss://fstream.asterdex.com/ws/";
@@ -33,6 +52,499 @@ function requireEnv(value: string | undefined, key: string): string {
return value;
}
function serialize(params: Record<string, unknown>): string {
return Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null)
.sort()
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
.join("&");
}
export class AsterSpotRestClient {
private readonly apiKey?: string;
private readonly apiSecret?: string;
constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
this.apiKey = options.apiKey ?? process.env.ASTER_API_KEY;
this.apiSecret = options.apiSecret ?? process.env.ASTER_API_SECRET;
}
async ping(): Promise<void> {
await this.request<void>({ path: "/api/v1/ping", method: "GET" });
}
async getServerTime(): Promise<{ serverTime: number }> {
return this.request<{ serverTime: number }>({ path: "/api/v1/time", method: "GET" });
}
async getExchangeInfo(): Promise<AsterSpotExchangeInfo> {
return this.request<AsterSpotExchangeInfo>({ path: "/api/v1/exchangeInfo", method: "GET" });
}
async getDepth(symbol: string, limit?: number): Promise<AsterSpotDepth> {
const payload = await this.request<AsterSpotDepth>({
path: "/api/v1/depth",
method: "GET",
params: { symbol: symbol.toUpperCase(), limit },
});
return {
lastUpdateId: Number(payload.lastUpdateId),
E: payload.E,
T: payload.T,
bids: (payload.bids ?? []).map(([price, qty]) => [String(price), String(qty)]) as AsterSpotDepth["bids"],
asks: (payload.asks ?? []).map(([price, qty]) => [String(price), String(qty)]) as AsterSpotDepth["asks"],
};
}
async getTrades(symbol: string, limit?: number): Promise<AsterSpotTrade[]> {
const payload = await this.request<any[]>({
path: "/api/v1/trades",
method: "GET",
params: { symbol: symbol.toUpperCase(), limit },
});
return payload.map((item) => ({
id: Number(item.id),
price: String(item.price),
qty: String(item.qty),
baseQty: item.baseQty !== undefined ? String(item.baseQty) : undefined,
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
time: Number(item.time ?? Date.now()),
isBuyerMaker: Boolean(item.isBuyerMaker),
}));
}
async getHistoricalTrades(params: { symbol: string; limit?: number; fromId?: number }): Promise<AsterSpotHistoricalTrade[]> {
const payload = await this.request<any[]>({
path: "/api/v1/historicalTrades",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
limit: params.limit,
fromId: params.fromId,
},
requiresApiKey: true,
});
return payload.map((item) => ({
id: Number(item.id),
price: String(item.price),
qty: String(item.qty),
baseQty: item.baseQty !== undefined ? String(item.baseQty) : undefined,
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
time: Number(item.time ?? Date.now()),
isBuyerMaker: Boolean(item.isBuyerMaker),
isBestMatch: item.isBestMatch !== undefined ? Boolean(item.isBestMatch) : undefined,
}));
}
async getAggTrades(params: {
symbol: string;
fromId?: number;
startTime?: number;
endTime?: number;
limit?: number;
}): Promise<AsterSpotAggTrade[]> {
const payload = await this.request<any[]>({
path: "/api/v1/aggTrades",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
fromId: params.fromId,
startTime: params.startTime,
endTime: params.endTime,
limit: params.limit,
},
});
return payload.map((item) => ({
a: Number(item.a),
p: String(item.p),
q: String(item.q),
f: Number(item.f),
l: Number(item.l),
T: Number(item.T),
m: Boolean(item.m),
M: item.M !== undefined ? Boolean(item.M) : undefined,
}));
}
async getKlines(params: {
symbol: string;
interval: string;
startTime?: number;
endTime?: number;
limit?: number;
}): Promise<AsterSpotKline[]> {
const payload = await this.request<any[]>({
path: "/api/v1/klines",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
interval: params.interval,
startTime: params.startTime,
endTime: params.endTime,
limit: params.limit,
},
});
return payload.map((entry) => ({
openTime: Number(entry[0]),
open: String(entry[1]),
high: String(entry[2]),
low: String(entry[3]),
close: String(entry[4]),
volume: String(entry[5]),
closeTime: Number(entry[6]),
quoteAssetVolume: String(entry[7]),
numberOfTrades: Number(entry[8] ?? 0),
takerBuyBaseAssetVolume: String(entry[9] ?? "0"),
takerBuyQuoteAssetVolume: String(entry[10] ?? "0"),
}));
}
async getTicker24h(symbol?: string): Promise<AsterSpotTicker24h | AsterSpotTicker24h[]> {
const payload = await this.request<any>({
path: "/api/v1/ticker/24hr",
method: "GET",
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
});
return this.normalizeTicker24h(payload);
}
async getTickerPrice(symbol?: string): Promise<AsterSpotPriceTicker | AsterSpotPriceTicker[]> {
const payload = await this.request<any>({
path: "/api/v1/ticker/price",
method: "GET",
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
});
return Array.isArray(payload) ? payload.map((item) => this.normalizePriceTicker(item)) : this.normalizePriceTicker(payload);
}
async getBookTicker(symbol?: string): Promise<AsterSpotBookTicker | AsterSpotBookTicker[]> {
const payload = await this.request<any>({
path: "/api/v1/ticker/bookTicker",
method: "GET",
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
});
return Array.isArray(payload) ? payload.map((item) => this.normalizeBookTicker(item)) : this.normalizeBookTicker(payload);
}
async getCommissionRate(symbol: string, params: { recvWindow?: number } = {}): Promise<AsterSpotCommissionRate> {
const payload = await this.request<AsterSpotCommissionRate>({
path: "/api/v1/commissionRate",
method: "GET",
params: { symbol: symbol.toUpperCase(), recvWindow: params.recvWindow },
signed: true,
});
return {
symbol: payload.symbol,
makerCommissionRate: String(payload.makerCommissionRate),
takerCommissionRate: String(payload.takerCommissionRate),
};
}
async createOrder(params: CreateSpotOrderParams): Promise<AsterOrder> {
const response = await this.request<any>({
path: "/api/v1/order",
method: "POST",
params: this.normalizeSpotOrderParams(params),
signed: true,
sendInBody: true,
});
return toOrderFromRest(response);
}
async cancelOrder(params: CancelSpotOrderParams): Promise<AsterOrder> {
const response = await this.request<any>({
path: "/api/v1/order",
method: "DELETE",
params: {
symbol: params.symbol.toUpperCase(),
orderId: params.orderId,
origClientOrderId: params.origClientOrderId,
recvWindow: params.recvWindow,
},
signed: true,
});
return toOrderFromRest(response);
}
async getOrder(params: QuerySpotOrderParams): Promise<AsterOrder> {
const response = await this.request<any>({
path: "/api/v1/order",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
orderId: params.orderId,
origClientOrderId: params.origClientOrderId,
recvWindow: params.recvWindow,
},
signed: true,
});
return toOrderFromRest(response);
}
async getOpenOrders(params: SpotOpenOrdersParams = {}): Promise<AsterOrder[]> {
const response = await this.request<any[]>({
path: "/api/v1/openOrders",
method: "GET",
params: {
symbol: params.symbol ? params.symbol.toUpperCase() : undefined,
recvWindow: params.recvWindow,
},
signed: true,
});
return response.map(toOrderFromRest);
}
async cancelAllOpenOrders(params: SpotOpenOrdersParams & { symbol: string }): Promise<{ code: number; msg: string }> {
const payload: Record<string, unknown> = {
symbol: params.symbol.toUpperCase(),
recvWindow: params.recvWindow,
};
if (params.orderIdList && params.orderIdList.length) {
payload.orderIdList = `[${params.orderIdList
.map((id) => (typeof id === "string" ? id.trim() : String(id)))
.join(",")}]`;
}
if (params.origClientOrderIdList && params.origClientOrderIdList.length) {
payload.origClientOrderIdList = JSON.stringify(params.origClientOrderIdList);
}
return this.request<{ code: number; msg: string }>({
path: "/api/v1/allOpenOrders",
method: "DELETE",
params: payload,
signed: true,
});
}
async getAllOrders(params: SpotAllOrdersParams): Promise<AsterOrder[]> {
const response = await this.request<any[]>({
path: "/api/v1/allOrders",
method: "GET",
params: {
symbol: params.symbol.toUpperCase(),
orderId: params.orderId,
startTime: params.startTime,
endTime: params.endTime,
limit: params.limit,
recvWindow: params.recvWindow,
},
signed: true,
});
return response.map(toOrderFromRest);
}
async getAccount(params: { recvWindow?: number } = {}): Promise<AsterSpotAccount> {
const payload = await this.request<AsterSpotAccount>({
path: "/api/v1/account",
method: "GET",
params: { recvWindow: params.recvWindow },
signed: true,
});
return {
...payload,
balances: (payload.balances ?? []).map((balance) => ({
asset: balance.asset,
free: String(balance.free ?? "0"),
locked: String(balance.locked ?? "0"),
})),
};
}
async getUserTrades(params: SpotUserTradesParams = {}): Promise<AsterSpotUserTrade[]> {
const response = await this.request<any[]>({
path: "/api/v1/userTrades",
method: "GET",
params: {
symbol: params.symbol ? params.symbol.toUpperCase() : undefined,
orderId: params.orderId,
startTime: params.startTime,
endTime: params.endTime,
fromId: params.fromId,
limit: params.limit,
recvWindow: params.recvWindow,
},
signed: true,
});
return response.map((item) => ({
symbol: item.symbol,
id: Number(item.id),
orderId: Number(item.orderId),
side: item.side,
price: String(item.price),
qty: String(item.qty),
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
commission: String(item.commission ?? "0"),
commissionAsset: String(item.commissionAsset ?? ""),
time: Number(item.time ?? Date.now()),
counterpartyId: item.counterpartyId !== undefined ? Number(item.counterpartyId) : undefined,
maker: Boolean(item.maker),
buyer: Boolean(item.buyer),
}));
}
private normalizeTicker24h(payload: any): AsterSpotTicker24h | AsterSpotTicker24h[] {
const mapOne = (entry: any): AsterSpotTicker24h => ({
symbol: entry.symbol,
priceChange: String(entry.priceChange),
priceChangePercent: String(entry.priceChangePercent),
weightedAvgPrice: String(entry.weightedAvgPrice),
prevClosePrice: String(entry.prevClosePrice),
lastPrice: String(entry.lastPrice),
lastQty: String(entry.lastQty),
bidPrice: String(entry.bidPrice),
bidQty: String(entry.bidQty),
askPrice: String(entry.askPrice),
askQty: String(entry.askQty),
openPrice: String(entry.openPrice),
highPrice: String(entry.highPrice),
lowPrice: String(entry.lowPrice),
volume: String(entry.volume),
quoteVolume: String(entry.quoteVolume),
openTime: Number(entry.openTime ?? 0),
closeTime: Number(entry.closeTime ?? 0),
firstId: Number(entry.firstId ?? 0),
lastId: Number(entry.lastId ?? 0),
count: Number(entry.count ?? 0),
baseAsset: entry.baseAsset,
quoteAsset: entry.quoteAsset,
});
return Array.isArray(payload) ? payload.map((entry) => mapOne(entry)) : mapOne(payload);
}
private normalizePriceTicker(entry: any): AsterSpotPriceTicker {
return {
symbol: entry.symbol,
price: String(entry.price),
time: entry.time !== undefined ? Number(entry.time) : undefined,
};
}
private normalizeBookTicker(entry: any): AsterSpotBookTicker {
return {
symbol: entry.symbol,
bidPrice: String(entry.bidPrice),
bidQty: String(entry.bidQty),
askPrice: String(entry.askPrice),
askQty: String(entry.askQty),
time: entry.time !== undefined ? Number(entry.time) : undefined,
};
}
private normalizeSpotOrderParams(params: CreateSpotOrderParams): Record<string, unknown> {
const payload: Record<string, unknown> = {
symbol: params.symbol.toUpperCase(),
side: params.side,
type: params.type,
timeInForce: params.timeInForce,
quantity: params.quantity !== undefined ? params.quantity : undefined,
quoteOrderQty: params.quoteOrderQty !== undefined ? params.quoteOrderQty : undefined,
price: params.price !== undefined ? params.price : undefined,
newClientOrderId: params.newClientOrderId,
stopPrice: params.stopPrice !== undefined ? params.stopPrice : undefined,
recvWindow: params.recvWindow,
};
return payload;
}
private ensureApiKey(): string {
if (!this.apiKey) {
throw new Error("[AsterSpotRestClient] Missing API key");
}
return this.apiKey;
}
private ensureCredentials(): { apiKey: string; apiSecret: string } {
const apiKey = this.ensureApiKey();
const apiSecret = this.apiSecret;
if (!apiSecret) {
throw new Error("[AsterSpotRestClient] Missing API secret");
}
return { apiKey, apiSecret };
}
private cleanParams(params: Record<string, unknown> | undefined): Record<string, unknown> {
const source = params ?? {};
const cleaned: Record<string, unknown> = {};
for (const key of Object.keys(source)) {
const value = (source as Record<string, unknown>)[key];
if (value === undefined || value === null) continue;
cleaned[key] = value;
}
return cleaned;
}
private async request<T>({
path,
method,
params,
signed = false,
sendInBody,
requiresApiKey = false,
}: {
path: string;
method: "GET" | "POST" | "DELETE" | "PUT";
params?: Record<string, unknown>;
signed?: boolean;
sendInBody?: boolean;
requiresApiKey?: boolean;
}): Promise<T> {
const cleaned = this.cleanParams(params);
const headers: Record<string, string> = {};
let url = `${SPOT_REST_BASE}${path}`;
const useBody = sendInBody ?? (method !== "GET" && method !== "DELETE");
let body: string | undefined;
if (requiresApiKey || signed) {
headers["X-MBX-APIKEY"] = this.ensureApiKey();
}
if (signed) {
if (cleaned.timestamp === undefined) cleaned.timestamp = Date.now();
if (cleaned.recvWindow === undefined) cleaned.recvWindow = 5000;
const { apiSecret } = this.ensureCredentials();
const serialized = serialize(cleaned);
const signature = crypto.createHmac("sha256", apiSecret).update(serialized).digest("hex");
if (useBody) {
body = serialized ? `${serialized}&signature=${signature}` : `signature=${signature}`;
} else {
const query = serialized ? `${serialized}&signature=${signature}` : `signature=${signature}`;
url += url.includes("?") ? `&${query}` : `?${query}`;
}
} else {
const query = serialize(cleaned);
if (query) {
if (useBody) {
body = query;
} else {
url += url.includes("?") ? `&${query}` : `?${query}`;
}
}
}
const init: RequestInit = { method, headers };
if (useBody) {
init.body = body ?? "";
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
let response: Response;
try {
response = await fetch(url, init);
} catch (error) {
throw new Error(`[AsterSpotRestClient] 请求失败 ${String(error)}`);
}
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${text}`);
}
if (!text) {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch (error) {
throw new Error(`[AsterSpotRestClient] 无法解析响应: ${text.slice(0, 200)}`);
}
}
}
function toDepth(streamSymbol: string, data: any): AsterDepth {
return {
eventType: data.e,
@@ -296,7 +808,7 @@ export class AsterRestClient {
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
const upper = symbol.toUpperCase();
const url = `${REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
const url = `${FUTURES_REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
let response: Response;
try {
response = await fetch(url);
@@ -331,9 +843,9 @@ export class AsterRestClient {
private async signedRequest<T>({ path, method, params }: { path: string; method: string; params: Record<string, unknown> }): Promise<T> {
const timestamp = Date.now();
const payload = { ...params, timestamp, recvWindow: 5000 };
const query = this.serialize(payload);
const query = serialize(payload);
const signature = crypto.createHmac("sha256", this.apiSecret).update(query).digest("hex");
const url = `${REST_BASE}${path}?${query}&signature=${signature}`;
const url = `${FUTURES_REST_BASE}${path}?${query}&signature=${signature}`;
const init: RequestInit = {
method,
headers: {
@@ -358,13 +870,6 @@ export class AsterRestClient {
}
}
private serialize(params: Record<string, unknown>): string {
return Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null)
.sort()
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
.join("&");
}
}
type DepthHandler = (depth: AsterDepth) => void;
+221
View File
@@ -4,7 +4,10 @@ export type OrderSide = "BUY" | "SELL";
export type OrderType =
| "LIMIT"
| "MARKET"
| "STOP"
| "STOP_MARKET"
| "TAKE_PROFIT"
| "TAKE_PROFIT_MARKET"
| "TRAILING_STOP_MARKET";
export type PositionSide = "BOTH" | "LONG" | "SHORT";
export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX";
@@ -321,6 +324,224 @@ export interface AsterTicker {
count?: number;
}
export interface AsterSpotRateLimit {
rateLimitType: string;
interval: string;
intervalNum: number;
limit: number;
}
export interface AsterSpotExchangeFilter {
filterType: string;
[key: string]: string | number | boolean | undefined;
}
export interface AsterSpotAssetInfo {
asset: string;
}
export interface AsterSpotSymbolInfo {
symbol: string;
status: string;
baseAsset: string;
quoteAsset: string;
baseAssetPrecision?: number;
quotePrecision?: number;
pricePrecision?: number;
quantityPrecision?: number;
orderTypes: string[];
timeInForce: string[];
ocoAllowed: boolean;
filters: AsterSpotExchangeFilter[];
}
export interface AsterSpotExchangeInfo {
timezone: string;
serverTime: number;
rateLimits: AsterSpotRateLimit[];
exchangeFilters: AsterSpotExchangeFilter[];
assets?: AsterSpotAssetInfo[];
symbols: AsterSpotSymbolInfo[];
}
export interface AsterSpotDepth {
lastUpdateId: number;
E?: number;
T?: number;
bids: AsterDepthLevel[];
asks: AsterDepthLevel[];
}
export interface AsterSpotTrade {
id: number;
price: string;
qty: string;
baseQty?: string;
quoteQty?: string;
time: number;
isBuyerMaker: boolean;
}
export interface AsterSpotHistoricalTrade extends AsterSpotTrade {
isBestMatch?: boolean;
}
export interface AsterSpotAggTrade {
a: number;
p: string;
q: string;
f: number;
l: number;
T: number;
m: boolean;
M?: boolean;
}
export interface AsterSpotKline {
openTime: number;
open: string;
high: string;
low: string;
close: string;
volume: string;
closeTime: number;
quoteAssetVolume: string;
numberOfTrades: number;
takerBuyBaseAssetVolume: string;
takerBuyQuoteAssetVolume: string;
}
export interface AsterSpotTicker24h {
symbol: string;
priceChange: string;
priceChangePercent: string;
weightedAvgPrice: string;
prevClosePrice: string;
lastPrice: string;
lastQty: string;
bidPrice: string;
bidQty: string;
askPrice: string;
askQty: string;
openPrice: string;
highPrice: string;
lowPrice: string;
volume: string;
quoteVolume: string;
openTime: number;
closeTime: number;
firstId: number;
lastId: number;
count: number;
baseAsset?: string;
quoteAsset?: string;
}
export interface AsterSpotPriceTicker {
symbol: string;
price: string;
time?: number;
}
export interface AsterSpotBookTicker {
symbol: string;
bidPrice: string;
bidQty: string;
askPrice: string;
askQty: string;
time?: number;
}
export interface AsterSpotCommissionRate {
symbol: string;
makerCommissionRate: string;
takerCommissionRate: string;
}
export interface CreateSpotOrderParams {
symbol: string;
side: OrderSide;
type: OrderType;
timeInForce?: TimeInForce;
quantity?: number | string;
quoteOrderQty?: number | string;
price?: number | string;
newClientOrderId?: string;
stopPrice?: number | string;
recvWindow?: number;
}
export interface CancelSpotOrderParams {
symbol: string;
orderId?: number | string;
origClientOrderId?: string;
recvWindow?: number;
}
export interface QuerySpotOrderParams extends CancelSpotOrderParams {}
export interface SpotOpenOrdersParams {
symbol?: string;
recvWindow?: number;
orderIdList?: Array<number | string>;
origClientOrderIdList?: string[];
}
export interface SpotAllOrdersParams {
symbol: string;
orderId?: number;
startTime?: number;
endTime?: number;
limit?: number;
recvWindow?: number;
}
export interface AsterSpotAccountBalance {
asset: string;
free: string;
locked: string;
}
export interface AsterSpotAccount {
feeTier: number;
canTrade: boolean;
canDeposit: boolean;
canWithdraw: boolean;
canBurnAsset?: boolean;
updateTime: number;
makerCommission?: string;
takerCommission?: string;
buyerCommission?: string;
sellerCommission?: string;
balances: AsterSpotAccountBalance[];
}
export interface SpotUserTradesParams {
symbol?: string;
orderId?: number;
startTime?: number;
endTime?: number;
fromId?: number;
limit?: number;
recvWindow?: number;
}
export interface AsterSpotUserTrade {
symbol: string;
id: number;
orderId: number;
side: OrderSide;
price: string;
qty: string;
quoteQty?: string;
commission: string;
commissionAsset: string;
time: number;
counterpartyId?: number;
maker: boolean;
buyer: boolean;
}
export interface AsterKline {
eventType?: string;
eventTime?: number;
+236
View File
@@ -0,0 +1,236 @@
import type { BasisArbConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type { AsterDepth, AsterSpotBookTicker } from "../exchanges/types";
import { AsterSpotRestClient } from "../exchanges/aster/client";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
export interface BasisArbSnapshot {
ready: boolean;
futuresSymbol: string;
spotSymbol: string;
futuresBid: number | null;
futuresAsk: number | null;
spotBid: number | null;
spotAsk: number | null;
futuresLastUpdate: number | null;
spotLastUpdate: number | null;
spread: number | null;
spreadBps: number | null;
netSpread: number | null;
netSpreadBps: number | null;
lastUpdated: number | null;
tradeLog: TradeLogEntry[];
feedStatus: {
futures: boolean;
spot: boolean;
};
opportunity: boolean;
}
type BasisArbEvent = "update";
type BasisArbListener = (snapshot: BasisArbSnapshot) => void;
interface BasisArbDependencies {
spotClient?: Pick<AsterSpotRestClient, "getBookTicker">;
now?: () => number;
}
interface DepthState {
bid: number | null;
ask: number | null;
updatedAt: number | null;
}
interface SpotState {
bid: number | null;
ask: number | null;
updatedAt: number | null;
}
export class BasisArbEngine {
private readonly events = new StrategyEventEmitter<BasisArbEvent, BasisArbSnapshot>();
private readonly tradeLog: ReturnType<typeof createTradeLog>;
private readonly spotClient: Pick<AsterSpotRestClient, "getBookTicker">;
private readonly now: () => number;
private readonly config: BasisArbConfig;
private readonly exchange: ExchangeAdapter;
private readonly futures: DepthState = { bid: null, ask: null, updatedAt: null };
private readonly spot: SpotState = { bid: null, ask: null, updatedAt: null };
private readonly feedReady = { futures: false, spot: false };
private timer: ReturnType<typeof setInterval> | null = null;
private spotInFlight = false;
private stopped = false;
constructor(config: BasisArbConfig, exchange: ExchangeAdapter, deps: BasisArbDependencies = {}) {
this.config = config;
this.exchange = exchange;
this.spotClient = deps.spotClient ?? new AsterSpotRestClient();
this.now = deps.now ?? (() => Date.now());
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.bootstrap();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.pollSpot();
}, Math.max(this.config.refreshIntervalMs, 200));
void this.pollSpot();
}
stop(): void {
this.stopped = true;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
on(event: BasisArbEvent, handler: BasisArbListener): void {
this.events.on(event, handler);
}
off(event: BasisArbEvent, handler: BasisArbListener): void {
this.events.off(event, handler);
}
getSnapshot(): BasisArbSnapshot {
return this.buildSnapshot();
}
private bootstrap(): void {
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
safeSubscribe<AsterDepth>(
this.exchange.watchDepth.bind(this.exchange, this.config.futuresSymbol),
(depth) => {
this.applyFuturesDepth(depth);
},
log,
{
subscribeFail: (error) => `订阅期货深度失败: ${String(error)}`,
processFail: (error) => `处理期货深度异常: ${String(error)}`,
}
);
}
private applyFuturesDepth(depth: AsterDepth): void {
if (!depth?.bids?.length || !depth?.asks?.length) {
return;
}
const topBid = Number(depth.bids[0]?.[0]);
const topAsk = Number(depth.asks[0]?.[0]);
if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) {
return;
}
this.futures.bid = topBid;
this.futures.ask = topAsk;
this.futures.updatedAt = depth.eventTime ?? depth.tradeTime ?? this.now();
if (!this.feedReady.futures) {
this.feedReady.futures = true;
this.tradeLog.push("info", `期货深度已就绪 (${this.config.futuresSymbol})`);
}
this.emitUpdate();
}
private async pollSpot(): Promise<void> {
if (this.spotInFlight || this.stopped) return;
this.spotInFlight = true;
try {
const result = await this.spotClient.getBookTicker(this.config.spotSymbol);
const ticker = Array.isArray(result) ? result[0] : result;
if (!ticker) return;
this.applySpotTicker(ticker);
} catch (error) {
this.feedReady.spot = false;
this.tradeLog.push("error", `获取现货盘口失败: ${String(error instanceof Error ? error.message : error)}`);
} finally {
this.spotInFlight = false;
}
}
private applySpotTicker(ticker: AsterSpotBookTicker): void {
const bid = Number(ticker.bidPrice);
const ask = Number(ticker.askPrice);
if (!Number.isFinite(bid) || !Number.isFinite(ask)) {
return;
}
this.spot.bid = bid;
this.spot.ask = ask;
this.spot.updatedAt = ticker.time ?? this.now();
if (!this.feedReady.spot) {
this.feedReady.spot = true;
this.tradeLog.push("info", `现货盘口已就绪 (${this.config.spotSymbol})`);
}
this.emitUpdate();
}
private emitUpdate(): void {
this.events.emit("update", this.buildSnapshot(), (error) => {
this.tradeLog.push("error", `推送订阅失败: ${String(error)}`);
});
}
private buildSnapshot(): BasisArbSnapshot {
const futuresBid = this.futures.bid;
const futuresAsk = this.futures.ask;
const spotBid = this.spot.bid;
const spotAsk = this.spot.ask;
const spread = this.computeSpread(futuresBid, spotAsk);
const spreadBps = this.computeSpreadBps(spread, spotAsk);
const netSpread = this.computeNetSpread(futuresBid, spotAsk);
const netSpreadBps = this.computeSpreadBps(netSpread, spotAsk);
const opportunity = netSpread != null && netSpread >= 0;
const lastUpdated = Math.max(
futuresBid != null && this.futures.updatedAt ? this.futures.updatedAt : 0,
spotBid != null && this.spot.updatedAt ? this.spot.updatedAt : 0
);
return {
ready: this.feedReady.futures && this.feedReady.spot,
futuresSymbol: this.config.futuresSymbol,
spotSymbol: this.config.spotSymbol,
futuresBid,
futuresAsk,
spotBid,
spotAsk,
futuresLastUpdate: this.futures.updatedAt,
spotLastUpdate: this.spot.updatedAt,
spread,
spreadBps,
netSpread,
netSpreadBps,
lastUpdated: lastUpdated > 0 ? lastUpdated : null,
tradeLog: this.tradeLog.all(),
feedStatus: { ...this.feedReady },
opportunity,
};
}
private computeSpread(futuresPrice: number | null, spotPrice: number | null): number | null {
if (!Number.isFinite(futuresPrice ?? NaN) || !Number.isFinite(spotPrice ?? NaN)) return null;
return Number(futuresPrice) - Number(spotPrice);
}
private computeSpreadBps(spread: number | null, spotAsk: number | null): number | null {
if (!Number.isFinite(spread ?? NaN) || !Number.isFinite(spotAsk ?? NaN)) return null;
if (!spotAsk) return null;
return (Number(spread) / Number(spotAsk)) * 10_000;
}
private computeNetSpread(futuresBid: number | null, spotAsk: number | null): number | null {
if (!Number.isFinite(futuresBid ?? NaN) || !Number.isFinite(spotAsk ?? NaN)) {
return null;
}
const perSideFee = this.config.takerFeeRate ?? 0;
const effectiveFee = perSideFee * 2;
const sellFuturesNet = Number(futuresBid) * (1 - effectiveFee);
const buySpotNet = Number(spotAsk) * (1 + effectiveFee);
return sellFuturesNet - buySpotNet;
}
}
+18 -3
View File
@@ -3,17 +3,19 @@ import { Box, Text, useInput } from "ink";
import { TrendApp } from "./TrendApp";
import { MakerApp } from "./MakerApp";
import { OffsetMakerApp } from "./OffsetMakerApp";
import { BasisApp } from "./BasisApp";
import { isBasisStrategyEnabled } from "../config";
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
import { resolveExchangeId } from "../exchanges/create-adapter";
interface StrategyOption {
id: "trend" | "maker" | "offset-maker";
id: "trend" | "maker" | "offset-maker" | "basis";
label: string;
description: string;
component: React.ComponentType<{ onExit: () => void }>;
}
const STRATEGIES: StrategyOption[] = [
const BASE_STRATEGIES: StrategyOption[] = [
{
id: "trend",
label: "趋势跟随策略 (SMA30)",
@@ -42,7 +44,20 @@ export function App() {
const copyright = useMemo(() => loadCopyrightFragments(), []);
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const strategies = useMemo(() => STRATEGIES, []);
const strategies = useMemo(() => {
if (!isBasisStrategyEnabled()) {
return BASE_STRATEGIES;
}
return [
...BASE_STRATEGIES,
{
id: "basis" as const,
label: "期现套利策略",
description: "监控期货与现货盘口差价,辅助发现套利机会",
component: BasisApp,
},
];
}, []);
useInput(
(input, key) => {
+134
View File
@@ -0,0 +1,134 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { basisConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
import { formatNumber } from "../utils/format";
interface BasisAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function BasisApp({ onExit }: BasisAppProps) {
const [snapshot, setSnapshot] = useState<BasisArbSnapshot | null>(null);
const [error, setError] = useState<Error | null>(null);
const engineRef = useRef<BasisArbEngine | null>(null);
const exchangeId = useMemo(() => resolveExchangeId(), []);
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
useInput(
(input, key) => {
if (key.escape) {
engineRef.current?.stop();
onExit();
}
},
{ isActive: inputSupported }
);
useEffect(() => {
if (exchangeId !== "aster") {
setError(new Error("期现套利策略目前仅支持 Aster 交易所。请设置 EXCHANGE=aster 后重试。"));
return;
}
try {
const adapter = buildAdapterFromEnv({ exchangeId, symbol: basisConfig.futuresSymbol });
const engine = new BasisArbEngine(basisConfig, adapter);
engineRef.current = engine;
setSnapshot(engine.getSnapshot());
const handler = (next: BasisArbSnapshot) => {
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
};
engine.on("update", handler);
engine.start();
return () => {
engine.off("update", handler);
engine.stop();
};
} catch (err) {
console.error(err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, [exchangeId]);
if (error) {
return (
<Box flexDirection="column" padding={1}>
<Text color="red">: {error.message}</Text>
<Text color="gray"> Esc </Text>
</Box>
);
}
if (!snapshot) {
return (
<Box padding={1}>
<Text></Text>
</Box>
);
}
const futuresBid = formatNumber(snapshot.futuresBid, 4);
const futuresAsk = formatNumber(snapshot.futuresAsk, 4);
const spotBid = formatNumber(snapshot.spotBid, 4);
const spotAsk = formatNumber(snapshot.spotAsk, 4);
const spread = formatNumber(snapshot.spread, 4);
const spreadBps = formatNumber(snapshot.spreadBps, 2);
const netSpread = formatNumber(snapshot.netSpread, 4);
const netSpreadBps = formatNumber(snapshot.netSpreadBps, 2);
const lastUpdated = snapshot.lastUpdated ? new Date(snapshot.lastUpdated).toLocaleTimeString() : "-";
const futuresUpdated = snapshot.futuresLastUpdate ? new Date(snapshot.futuresLastUpdate).toLocaleTimeString() : "-";
const spotUpdated = snapshot.spotLastUpdate ? new Date(snapshot.spotLastUpdate).toLocaleTimeString() : "-";
const feedStatus = snapshot.feedStatus;
const lastLogs = snapshot.tradeLog.slice(-5);
return (
<Box flexDirection="column" paddingX={1}>
<Box flexDirection="column" marginBottom={1}>
<Text color="cyanBright">Basis Arbitrage Dashboard</Text>
<Text>
: {exchangeName} : {snapshot.futuresSymbol} : {snapshot.spotSymbol}
</Text>
<Text color="gray"> Esc 数据状态: 期货({feedStatus.futures ? "OK" : "--"}) ({feedStatus.spot ? "OK" : "--"})</Text>
<Text color="gray">: {lastUpdated}</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box flexDirection="column" marginRight={4}>
<Text color="greenBright"></Text>
<Text>: {futuresBid} : {futuresAsk}</Text>
<Text color="gray">: {futuresUpdated}</Text>
</Box>
<Box flexDirection="column">
<Text color="greenBright"></Text>
<Text>: {spotBid} : {spotAsk}</Text>
<Text color="gray">: {spotUpdated}</Text>
</Box>
</Box>
<Box flexDirection="column" marginBottom={1}>
<Text color={snapshot.opportunity ? "greenBright" : "redBright"}> / </Text>
<Text color={snapshot.opportunity ? "green" : undefined}>: {spread} USDT {spreadBps} bp</Text>
<Text color={snapshot.opportunity ? "green" : "red"}>
taker ({(basisConfig.takerFeeRate * 100).toFixed(4)}% × ): {netSpread} USDT {netSpreadBps} bp
</Text>
</Box>
<Box flexDirection="column">
<Text color="yellow"></Text>
{lastLogs.length ? (
lastLogs.map((entry, index) => (
<Text key={`${entry.time}-${index}`}>
[{entry.time}] [{entry.type}] {entry.detail}
</Text>
))
) : (
<Text color="gray"></Text>
)}
</Box>
</Box>
);
}
+90
View File
@@ -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&timestamp=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" });
});
});
+119
View File
@@ -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();
});
});