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:
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
export type StrategyId = "trend" | "maker" | "offset-maker";
|
export type StrategyId = "trend" | "maker" | "offset-maker" | "basis";
|
||||||
|
|
||||||
export interface CliOptions {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
@@ -7,7 +7,7 @@ export interface CliOptions {
|
|||||||
exchange?: "aster" | "grvt" | "lighter" | "backpack";
|
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 {
|
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
|
||||||
const options: CliOptions = { silent: false, help: false };
|
const options: CliOptions = { silent: false, help: false };
|
||||||
@@ -77,7 +77,7 @@ function assignExchange(options: CliOptions, raw: string): void {
|
|||||||
|
|
||||||
export function printCliHelp(): void {
|
export function printCliHelp(): void {
|
||||||
// eslint-disable-next-line no-console
|
// 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` +
|
`Options:\n` +
|
||||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||||
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { makerConfig, tradingConfig } from "../config";
|
import { basisConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../strategy/maker-engine";
|
} from "../strategy/maker-engine";
|
||||||
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
||||||
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||||
|
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||||
import { extractMessage } from "../utils/errors";
|
import { extractMessage } from "../utils/errors";
|
||||||
import type { StrategyId } from "./args";
|
import type { StrategyId } from "./args";
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
|||||||
trend: "Trend Following",
|
trend: "Trend Following",
|
||||||
maker: "Maker",
|
maker: "Maker",
|
||||||
"offset-maker": "Offset Maker",
|
"offset-maker": "Offset Maker",
|
||||||
|
basis: "Basis Arbitrage",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
|
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),
|
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> {
|
interface EngineHarness<TSnapshot> {
|
||||||
@@ -82,7 +103,7 @@ interface EngineHarness<TSnapshot> {
|
|||||||
offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
|
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>
|
harness: EngineHarness<TSnapshot>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
|
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
|
||||||
|
|||||||
+39
-20
@@ -1,26 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Trading Configuration
|
* 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";
|
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),
|
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
@@ -6,14 +6,33 @@ import type {
|
|||||||
AsterDepth,
|
AsterDepth,
|
||||||
AsterKline,
|
AsterKline,
|
||||||
AsterOrder,
|
AsterOrder,
|
||||||
|
AsterSpotAccount,
|
||||||
|
AsterSpotAggTrade,
|
||||||
|
AsterSpotBookTicker,
|
||||||
|
AsterSpotCommissionRate,
|
||||||
|
AsterSpotDepth,
|
||||||
|
AsterSpotExchangeInfo,
|
||||||
|
AsterSpotHistoricalTrade,
|
||||||
|
AsterSpotKline,
|
||||||
|
AsterSpotPriceTicker,
|
||||||
|
AsterSpotTicker24h,
|
||||||
|
AsterSpotTrade,
|
||||||
|
AsterSpotUserTrade,
|
||||||
AsterTicker,
|
AsterTicker,
|
||||||
|
CancelSpotOrderParams,
|
||||||
CreateOrderParams,
|
CreateOrderParams,
|
||||||
|
CreateSpotOrderParams,
|
||||||
PositionSide,
|
PositionSide,
|
||||||
|
QuerySpotOrderParams,
|
||||||
|
SpotAllOrdersParams,
|
||||||
|
SpotOpenOrdersParams,
|
||||||
|
SpotUserTradesParams,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
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_PUBLIC_URL = "wss://fstream.asterdex.com/ws";
|
||||||
const WS_LISTEN_KEY_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;
|
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 {
|
function toDepth(streamSymbol: string, data: any): AsterDepth {
|
||||||
return {
|
return {
|
||||||
eventType: data.e,
|
eventType: data.e,
|
||||||
@@ -296,7 +808,7 @@ export class AsterRestClient {
|
|||||||
|
|
||||||
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
|
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
|
||||||
const upper = symbol.toUpperCase();
|
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;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url);
|
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> {
|
private async signedRequest<T>({ path, method, params }: { path: string; method: string; params: Record<string, unknown> }): Promise<T> {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const payload = { ...params, timestamp, recvWindow: 5000 };
|
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 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 = {
|
const init: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
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;
|
type DepthHandler = (depth: AsterDepth) => void;
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ export type OrderSide = "BUY" | "SELL";
|
|||||||
export type OrderType =
|
export type OrderType =
|
||||||
| "LIMIT"
|
| "LIMIT"
|
||||||
| "MARKET"
|
| "MARKET"
|
||||||
|
| "STOP"
|
||||||
| "STOP_MARKET"
|
| "STOP_MARKET"
|
||||||
|
| "TAKE_PROFIT"
|
||||||
|
| "TAKE_PROFIT_MARKET"
|
||||||
| "TRAILING_STOP_MARKET";
|
| "TRAILING_STOP_MARKET";
|
||||||
export type PositionSide = "BOTH" | "LONG" | "SHORT";
|
export type PositionSide = "BOTH" | "LONG" | "SHORT";
|
||||||
export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX";
|
export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX";
|
||||||
@@ -321,6 +324,224 @@ export interface AsterTicker {
|
|||||||
count?: number;
|
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 {
|
export interface AsterKline {
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
eventTime?: number;
|
eventTime?: number;
|
||||||
|
|||||||
@@ -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
@@ -3,17 +3,19 @@ import { Box, Text, useInput } from "ink";
|
|||||||
import { TrendApp } from "./TrendApp";
|
import { TrendApp } from "./TrendApp";
|
||||||
import { MakerApp } from "./MakerApp";
|
import { MakerApp } from "./MakerApp";
|
||||||
import { OffsetMakerApp } from "./OffsetMakerApp";
|
import { OffsetMakerApp } from "./OffsetMakerApp";
|
||||||
|
import { BasisApp } from "./BasisApp";
|
||||||
|
import { isBasisStrategyEnabled } from "../config";
|
||||||
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
|
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
|
||||||
import { resolveExchangeId } from "../exchanges/create-adapter";
|
import { resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
|
||||||
interface StrategyOption {
|
interface StrategyOption {
|
||||||
id: "trend" | "maker" | "offset-maker";
|
id: "trend" | "maker" | "offset-maker" | "basis";
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
component: React.ComponentType<{ onExit: () => void }>;
|
component: React.ComponentType<{ onExit: () => void }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STRATEGIES: StrategyOption[] = [
|
const BASE_STRATEGIES: StrategyOption[] = [
|
||||||
{
|
{
|
||||||
id: "trend",
|
id: "trend",
|
||||||
label: "趋势跟随策略 (SMA30)",
|
label: "趋势跟随策略 (SMA30)",
|
||||||
@@ -42,7 +44,20 @@ export function App() {
|
|||||||
const copyright = useMemo(() => loadCopyrightFragments(), []);
|
const copyright = useMemo(() => loadCopyrightFragments(), []);
|
||||||
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
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(
|
useInput(
|
||||||
(input, key) => {
|
(input, key) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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