diff --git a/src/exchanges/aster/client.ts b/src/exchanges/aster/client.ts index b86dc5f..71d3204 100644 --- a/src/exchanges/aster/client.ts +++ b/src/exchanges/aster/client.ts @@ -827,6 +827,36 @@ export class AsterRestClient { } } + async getPremiumIndex(symbol: string): Promise<{ + symbol: string; + markPrice?: string; + indexPrice?: string; + lastFundingRate?: string; + fundingRate?: string; + nextFundingTime?: number; + time?: number; + }> { + const upper = symbol.toUpperCase(); + const url = `${FUTURES_REST_BASE}/fapi/v1/premiumIndex?symbol=${encodeURIComponent(upper)}`; + let response: Response; + try { + response = await fetch(url); + } catch (error) { + throw new Error(`[AsterRestClient] 获取资金费率失败 ${String(error)}`); + } + const text = await response.text(); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${text}`); + } + try { + const payload = JSON.parse(text) as any; + // The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time } + return payload; + } catch (error) { + throw new Error(`[AsterRestClient] 无法解析资金费率响应: ${text.slice(0, 200)}`); + } + } + async getListenKey(): Promise { const response = await this.signedRequest({ path: "/fapi/v1/listenKey", method: "POST", params: {} }); return response.listenKey; diff --git a/src/strategy/basis-arb-engine.ts b/src/strategy/basis-arb-engine.ts index 2fb9743..e523db0 100644 --- a/src/strategy/basis-arb-engine.ts +++ b/src/strategy/basis-arb-engine.ts @@ -1,7 +1,7 @@ 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 { AsterSpotRestClient, AsterRestClient } 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"; @@ -16,6 +16,9 @@ export interface BasisArbSnapshot { spotAsk: number | null; futuresLastUpdate: number | null; spotLastUpdate: number | null; + fundingRate: number | null; + nextFundingTime: number | null; + fundingLastUpdate: number | null; spread: number | null; spreadBps: number | null; netSpread: number | null; @@ -25,6 +28,7 @@ export interface BasisArbSnapshot { feedStatus: { futures: boolean; spot: boolean; + funding: boolean; }; opportunity: boolean; } @@ -34,6 +38,7 @@ type BasisArbListener = (snapshot: BasisArbSnapshot) => void; interface BasisArbDependencies { spotClient?: Pick; + futuresClient?: Pick; now?: () => number; } @@ -49,27 +54,37 @@ interface SpotState { updatedAt: number | null; } +interface FundingState { + rate: number | null; + nextFundingTime: number | null; + updatedAt: number | null; +} + export class BasisArbEngine { private readonly events = new StrategyEventEmitter(); private readonly tradeLog: ReturnType; private readonly spotClient: Pick; + private readonly futuresClient: Pick; 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 funding: FundingState = { rate: null, nextFundingTime: null, updatedAt: null }; - private readonly feedReady = { futures: false, spot: false }; + private readonly feedReady = { futures: false, spot: false, funding: false }; private timer: ReturnType | null = null; private spotInFlight = false; + private fundingInFlight = false; private stopped = false; constructor(config: BasisArbConfig, exchange: ExchangeAdapter, deps: BasisArbDependencies = {}) { this.config = config; this.exchange = exchange; this.spotClient = deps.spotClient ?? new AsterSpotRestClient(); + this.futuresClient = deps.futuresClient ?? new AsterRestClient(); this.now = deps.now ?? (() => Date.now()); this.tradeLog = createTradeLog(this.config.maxLogEntries); this.bootstrap(); @@ -79,8 +94,10 @@ export class BasisArbEngine { if (this.timer) return; this.timer = setInterval(() => { void this.pollSpot(); + void this.pollFunding(); }, Math.max(this.config.refreshIntervalMs, 200)); void this.pollSpot(); + void this.pollFunding(); } stop(): void { @@ -154,6 +171,32 @@ export class BasisArbEngine { } } + private async pollFunding(): Promise { + if (this.fundingInFlight || this.stopped) return; + this.fundingInFlight = true; + try { + const data = await this.futuresClient.getPremiumIndex(this.config.futuresSymbol); + const rateRaw = (data.lastFundingRate ?? data.fundingRate) as string | undefined; + const rate = rateRaw !== undefined ? Number(rateRaw) : NaN; + const ts = (data.time ?? data.nextFundingTime ?? this.now()) as number | undefined; + if (Number.isFinite(rate)) { + this.funding.rate = Number(rateRaw); + this.funding.nextFundingTime = typeof data.nextFundingTime === "number" ? data.nextFundingTime : null; + this.funding.updatedAt = typeof ts === "number" ? ts : this.now(); + if (!this.feedReady.funding) { + this.feedReady.funding = true; + this.tradeLog.push("info", `资金费率已就绪 (${this.config.futuresSymbol})`); + } + this.emitUpdate(); + } + } catch (error) { + this.feedReady.funding = false; + this.tradeLog.push("error", `获取资金费率失败: ${String(error instanceof Error ? error.message : error)}`); + } finally { + this.fundingInFlight = false; + } + } + private applySpotTicker(ticker: AsterSpotBookTicker): void { const bid = Number(ticker.bidPrice); const ask = Number(ticker.askPrice); @@ -181,6 +224,8 @@ export class BasisArbEngine { const futuresAsk = this.futures.ask; const spotBid = this.spot.bid; const spotAsk = this.spot.ask; + const fundingRate = this.funding.rate; + const nextFundingTime = this.funding.nextFundingTime; const spread = this.computeSpread(futuresBid, spotAsk); const spreadBps = this.computeSpreadBps(spread, spotAsk); const netSpread = this.computeNetSpread(futuresBid, spotAsk); @@ -188,7 +233,8 @@ export class BasisArbEngine { 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 + spotBid != null && this.spot.updatedAt ? this.spot.updatedAt : 0, + fundingRate != null && this.funding.updatedAt ? this.funding.updatedAt : 0 ); return { @@ -201,6 +247,9 @@ export class BasisArbEngine { spotAsk, futuresLastUpdate: this.futures.updatedAt, spotLastUpdate: this.spot.updatedAt, + fundingRate, + nextFundingTime, + fundingLastUpdate: this.funding.updatedAt, spread, spreadBps, netSpread, diff --git a/src/ui/BasisApp.tsx b/src/ui/BasisApp.tsx index 069ba3c..c5bd41e 100644 --- a/src/ui/BasisApp.tsx +++ b/src/ui/BasisApp.tsx @@ -82,6 +82,9 @@ export function BasisApp({ onExit }: BasisAppProps) { 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 fundingRatePct = snapshot.fundingRate != null ? `${(snapshot.fundingRate * 100).toFixed(4)}%` : "-"; + const fundingUpdated = snapshot.fundingLastUpdate ? new Date(snapshot.fundingLastUpdate).toLocaleTimeString() : "-"; + const nextFundingTime = snapshot.nextFundingTime ? new Date(snapshot.nextFundingTime).toLocaleTimeString() : "-"; const feedStatus = snapshot.feedStatus; const lastLogs = snapshot.tradeLog.slice(-5); @@ -92,7 +95,7 @@ export function BasisApp({ onExit }: BasisAppProps) { 交易所: {exchangeName} | 期货合约: {snapshot.futuresSymbol} | 现货交易对: {snapshot.spotSymbol} - 按 Esc 返回策略选择 | 数据状态: 期货({feedStatus.futures ? "OK" : "--"}) 现货({feedStatus.spot ? "OK" : "--"}) + 按 Esc 返回策略选择 | 数据状态: 期货({feedStatus.futures ? "OK" : "--"}) 现货({feedStatus.spot ? "OK" : "--"}) 资金费率({feedStatus.funding ? "OK" : "--"}) 最近更新时间: {lastUpdated} @@ -109,6 +112,12 @@ export function BasisApp({ onExit }: BasisAppProps) { + + 资金费率 + 当前资金费率: {fundingRatePct} + 资金费率更新时间: {fundingUpdated} | 下次结算时间: {nextFundingTime} + + 套利差价(卖期货 / 买现货) 毛价差: {spread} USDT | {spreadBps} bp