feat: 更新基础套利引擎,添加资金收益和手续费计算逻辑,优化信号评估功能并更新UI以显示相关信息

This commit is contained in:
discountry
2025-10-07 16:23:53 +08:00
parent d778e20c23
commit 24f93fa8a7
3 changed files with 88 additions and 5 deletions
+2
View File
@@ -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 => {
+72
View File
@@ -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;
}
}
+14 -5
View File
@@ -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) {
<Text color="yellow"></Text>
<Text>: {fundingRatePct}</Text>
<Text color="gray">: {fundingUpdated} : {nextFundingTime}</Text>
<Text>(): {fundingIncomePerFunding} (): {fundingIncomePerDay}</Text>
<Text>(): {takerFeesPerRoundTrip} : {fundingCountToBreakeven}</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
@@ -158,11 +164,14 @@ export function BasisApp({ onExit }: BasisAppProps) {
<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>
))
lastLogs.map((entry, index) => {
const color = entry.type === "entry" ? "green" : entry.type === "exit" ? "red" : undefined;
return (
<Text key={`${entry.time}-${index}`} color={color}>
[{entry.time}] [{entry.type}] {entry.detail}
</Text>
);
})
) : (
<Text color="gray"></Text>
)}