diff --git a/src/config.ts b/src/config.ts index d4b5ece..08ea03d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -113,6 +113,7 @@ export interface BasisArbConfig { refreshIntervalMs: number; maxLogEntries: number; takerFeeRate: number; + arbAmount: number; // base asset amount to arb (e.g., ASTER amount when ASTERUSDT) } export type GridDirection = "both" | "long" | "short"; @@ -158,6 +159,7 @@ export const basisConfig: BasisArbConfig = { 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), + arbAmount: parseNumber(process.env.ARB_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0)), }; const resolveGridDirection = (raw: string | undefined, fallback: GridDirection): GridDirection => { diff --git a/src/strategy/basis-arb-engine.ts b/src/strategy/basis-arb-engine.ts index 32be520..782dd23 100644 --- a/src/strategy/basis-arb-engine.ts +++ b/src/strategy/basis-arb-engine.ts @@ -19,6 +19,10 @@ export interface BasisArbSnapshot { fundingRate: number | null; nextFundingTime: number | null; fundingLastUpdate: number | null; + fundingIncomePerFunding: number | null; // USDT per funding event + fundingIncomePerDay: number | null; // USDT per day (assuming 3 fundings/day) + takerFeesPerRoundTrip: number | null; // USDT cost to open both legs + fundingCountToBreakeven: number | null; // number of fundings to cover fees spread: number | null; spreadBps: number | null; netSpread: number | null; @@ -97,6 +101,8 @@ export class BasisArbEngine { private spotAccountInFlight = false; private futuresAccountInFlight = false; private stopped = false; + private lastEntrySignalAt = 0; + private lastExitSignalAt = 0; constructor(config: BasisArbConfig, exchange: ExchangeAdapter, deps: BasisArbDependencies = {}) { this.config = config; @@ -291,6 +297,8 @@ export class BasisArbEngine { } private emitUpdate(): void { + // Evaluate entry/exit signals before emitting so the snapshot includes new log lines + this.evaluateSignals(); this.events.emit("update", this.buildSnapshot(), (error) => { this.tradeLog.push("error", `推送订阅失败: ${String(error)}`); }); @@ -307,6 +315,12 @@ export class BasisArbEngine { const spreadBps = this.computeSpreadBps(spread, spotAsk); const netSpread = this.computeNetSpread(futuresBid, spotAsk); const netSpreadBps = this.computeSpreadBps(netSpread, spotAsk); + const perFundingIncome = this.computeFundingIncomeUSDT(fundingRate, spotAsk); + const perDayIncome = perFundingIncome != null ? perFundingIncome * 3 : null; // 3 times/day typical + const takerFeesPerRoundTrip = this.computeRoundTripFeesUSDT(spotAsk); + const fundingCountToBreakeven = perFundingIncome && perFundingIncome > 0 && takerFeesPerRoundTrip != null + ? takerFeesPerRoundTrip / perFundingIncome + : null; const opportunity = netSpread != null && netSpread >= 0; const lastUpdated = Math.max( futuresBid != null && this.futures.updatedAt ? this.futures.updatedAt : 0, @@ -327,6 +341,10 @@ export class BasisArbEngine { fundingRate, nextFundingTime, fundingLastUpdate: this.funding.updatedAt, + fundingIncomePerFunding: perFundingIncome, + fundingIncomePerDay: perDayIncome, + takerFeesPerRoundTrip, + fundingCountToBreakeven, spread, spreadBps, netSpread, @@ -361,4 +379,58 @@ export class BasisArbEngine { const buySpotNet = Number(spotAsk) * (1 + effectiveFee); return sellFuturesNet - buySpotNet; } + + private evaluateSignals(): void { + // Require both futures and spot feeds before computing signals + if (!this.feedReady.futures || !this.feedReady.spot) return; + const now = this.now(); + const futuresBid = this.futures.bid; + const spotAsk = this.spot.ask; + const spread = this.computeSpread(futuresBid, spotAsk); + const spreadBps = this.computeSpreadBps(spread, spotAsk); + const fundingRate = this.funding.rate; + const nextFundingTime = this.funding.nextFundingTime; + const msUntilFunding = typeof nextFundingTime === "number" ? nextFundingTime - now : null; + + // Entry signal: positive bp and next funding >= 10 minutes away + if (Number.isFinite(spreadBps ?? NaN) && (spreadBps as number) > 0 && Number.isFinite(msUntilFunding ?? NaN) && (msUntilFunding as number) >= 10 * 60 * 1000) { + if (now - this.lastEntrySignalAt >= 60 * 1000) { // debounce 60s + this.lastEntrySignalAt = now; + const bpTxt = (spreadBps as number).toFixed(2); + const minutes = Math.floor(((msUntilFunding as number) / 60000)); + this.tradeLog.push("entry", `入场机会: 价差 ${bpTxt} bp | 距下次资金费约 ${minutes} 分钟`); + } + } + + // Exit signal: funding rate negative and within 10 minutes before collection + if (Number.isFinite(fundingRate ?? NaN) && (fundingRate as number) < 0 && Number.isFinite(msUntilFunding ?? NaN) && (msUntilFunding as number) > 0 && (msUntilFunding as number) <= 10 * 60 * 1000) { + if (now - this.lastExitSignalAt >= 60 * 1000) { // debounce 60s + this.lastExitSignalAt = now; + const minutes = Math.max(0, Math.floor(((msUntilFunding as number) / 60000))); + this.tradeLog.push("exit", `出场机会: 资金费率为负 | 距收取约 ${minutes} 分钟`); + } + } + } + + private computeFundingIncomeUSDT(fundingRate: number | null, spotAsk: number | null): number | null { + if (!Number.isFinite(fundingRate ?? NaN)) return null; + const price = Number.isFinite(spotAsk ?? NaN) ? Number(spotAsk) : null; + const amount = Number.isFinite(this.config.arbAmount ?? NaN) ? Number(this.config.arbAmount) : null; + if (price == null || amount == null) return null; + // Funding income per event for a delta-neutral hedge ~ rate * notional + // Notional in USDT = amount * price + const notional = amount * price; + const rate = Number(fundingRate); + return notional * rate; + } + + private computeRoundTripFeesUSDT(spotAsk: number | null): number | null { + const price = Number.isFinite(spotAsk ?? NaN) ? Number(spotAsk) : null; + const amount = Number.isFinite(this.config.arbAmount ?? NaN) ? Number(this.config.arbAmount) : null; + if (price == null || amount == null) return null; + const notional = amount * price; + // Two taker trades (sell futures, buy spot) → fees on both legs + const perSide = (this.config.takerFeeRate ?? 0) * notional; + return perSide * 2; + } } diff --git a/src/ui/BasisApp.tsx b/src/ui/BasisApp.tsx index bf23425..d1aad6b 100644 --- a/src/ui/BasisApp.tsx +++ b/src/ui/BasisApp.tsx @@ -85,6 +85,10 @@ export function BasisApp({ onExit }: BasisAppProps) { 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 fundingIncomePerFunding = snapshot.fundingIncomePerFunding != null ? `${formatNumber(snapshot.fundingIncomePerFunding, 4)} USDT` : "-"; + const fundingIncomePerDay = snapshot.fundingIncomePerDay != null ? `${formatNumber(snapshot.fundingIncomePerDay, 4)} USDT` : "-"; + const takerFeesPerRoundTrip = snapshot.takerFeesPerRoundTrip != null ? `${formatNumber(snapshot.takerFeesPerRoundTrip, 4)} USDT` : "-"; + const fundingCountToBreakeven = snapshot.fundingCountToBreakeven != null ? `${formatNumber(snapshot.fundingCountToBreakeven, 2)} 次` : "-"; const feedStatus = snapshot.feedStatus; const lastLogs = snapshot.tradeLog.slice(-5); const spotBalances = (snapshot.spotBalances ?? []).filter((b) => Math.abs(b.free) > 0 || Math.abs(b.locked) > 0); @@ -118,6 +122,8 @@ export function BasisApp({ onExit }: BasisAppProps) { 资金费率 当前资金费率: {fundingRatePct} 资金费率更新时间: {fundingUpdated} | 下次结算时间: {nextFundingTime} + 单次资金费率收益(估): {fundingIncomePerFunding} | 日收益(估): {fundingIncomePerDay} + 双边吃单手续费(估): {takerFeesPerRoundTrip} | 回本所需资金费率次数: {fundingCountToBreakeven} @@ -158,11 +164,14 @@ export function BasisApp({ onExit }: BasisAppProps) { 最近事件 {lastLogs.length ? ( - lastLogs.map((entry, index) => ( - - [{entry.time}] [{entry.type}] {entry.detail} - - )) + lastLogs.map((entry, index) => { + const color = entry.type === "entry" ? "green" : entry.type === "exit" ? "red" : undefined; + return ( + + [{entry.time}] [{entry.type}] {entry.detail} + + ); + }) ) : ( 暂无日志 )}