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"; import { t } from "../i18n"; interface BasisAppProps { onExit: () => void; } const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); export function BasisApp({ onExit }: BasisAppProps) { const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); const engineRef = useRef(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" && exchangeId !== "nado" && exchangeId !== "standx") { setError(new Error(t("basis.onlyAster"))); 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 ( {t("basis.startFailed", { message: error.message })} {t("common.backHint")} ); } if (!snapshot) { return ( {t("basis.initializing")} ); } 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 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 feePct = (basisConfig.takerFeeRate * 100).toFixed(4); 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); const futuresBalances = (snapshot.futuresBalances ?? []).filter((b) => Math.abs(b.wallet) > 0); return ( {t("basis.title")} {t("basis.headerLine", { exchange: exchangeName, futures: snapshot.futuresSymbol, spot: snapshot.spotSymbol, })} {t("basis.statusLine", { futuresStatus: feedStatus.futures ? "OK" : "--", spotStatus: feedStatus.spot ? "OK" : "--", fundingStatus: feedStatus.funding ? "OK" : "--", })} {t("basis.lastUpdated", { time: lastUpdated })} {t("basis.section.futures")} {t("basis.bookLine", { bid: futuresBid, ask: futuresAsk })} {t("basis.updatedAt", { time: futuresUpdated })} {t("basis.section.spot")} {t("basis.bookLine", { bid: spotBid, ask: spotAsk })} {t("basis.updatedAt", { time: spotUpdated })} {t("basis.section.funding")} {t("basis.fundingRate", { rate: fundingRatePct })} {t("basis.fundingTimes", { updated: fundingUpdated, next: nextFundingTime })} {t("basis.fundingIncome", { per: fundingIncomePerFunding, perDay: fundingIncomePerDay })} {t("basis.takerFees", { fees: takerFeesPerRoundTrip, count: fundingCountToBreakeven })} {t("basis.spotBalanceTitle")} {spotBalances.length ? ( spotBalances.map((b) => ( {t("basis.balanceLine", { asset: b.asset, free: formatNumber(b.free, 8), locked: formatNumber(b.locked, 8), })} )) ) : ( {t("basis.none")} )} {t("basis.futuresBalanceTitle")} {futuresBalances.length ? ( futuresBalances.map((b) => ( {t("basis.futuresBalanceLine", { asset: b.asset, wallet: formatNumber(b.wallet, 8), available: formatNumber(b.available, 8), })} )) ) : ( {t("basis.none")} )} {t("basis.spreadTitle")} {t("basis.spreadLine", { spread, bps: spreadBps })} {t("basis.netSpreadLine", { feePct, net: netSpread, netBps: netSpreadBps })} {t("common.section.recent")} {lastLogs.length ? ( lastLogs.map((entry, index) => { const color = entry.type === "entry" ? "green" : entry.type === "exit" ? "red" : undefined; return ( [{entry.time}] [{entry.type}] {entry.detail} ); }) ) : ( {t("common.noLogs")} )} ); }