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}
+
+ );
+ })
) : (
暂无日志
)}