mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
feat: 添加期现套利策略支持,更新相关配置和界面,增强 Aster 现货 API 客户端功能
This commit is contained in:
+3
-3
@@ -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` +
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { 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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user