import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { makerConfig } from "../config"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; import { t } from "../i18n"; interface OffsetMakerAppProps { onExit: () => void; } const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) { 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(() => { try { const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol }); const engine = new OffsetMakerEngine(makerConfig, adapter); engineRef.current = engine; setSnapshot(engine.getSnapshot()); const handler = (next: OffsetMakerEngineSnapshot) => { 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("common.startFailed", { message: error.message })} {t("common.checkEnv")} ); } if (!snapshot) { return ( {t("offset.initializing")} ); } const topBid = snapshot.topBid; const topAsk = snapshot.topAsk; const priceDigits = snapshot.priceDecimals ?? 2; const spreadDigits = Math.max(priceDigits + 1, 4); const spreadDisplay = snapshot.spread != null ? `${formatNumber(snapshot.spread, spreadDigits)} USDT` : "-"; const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5; const sortedOrders = [...snapshot.openOrders].sort((a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId) ); const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({ id: order.orderId, side: order.side, price: order.price, qty: order.origQty, filled: order.executedQty, reduceOnly: order.reduceOnly ? "yes" : "no", status: order.status, })); const openOrderColumns: TableColumn[] = [ { key: "id", header: "ID", align: "right", minWidth: 6 }, { key: "side", header: "Side", minWidth: 4 }, { key: "price", header: "Price", align: "right", minWidth: 10 }, { key: "qty", header: "Qty", align: "right", minWidth: 8 }, { key: "filled", header: "Filled", align: "right", minWidth: 8 }, { key: "reduceOnly", header: "RO", minWidth: 4 }, { key: "status", header: "Status", minWidth: 10 }, ]; const desiredRows = snapshot.desiredOrders.map((order, index) => ({ index: index + 1, side: order.side, price: order.price, amount: order.amount, reduceOnly: order.reduceOnly ? "yes" : "no", })); const desiredColumns: TableColumn[] = [ { key: "index", header: "#", align: "right", minWidth: 2 }, { key: "side", header: "Side", minWidth: 4 }, { key: "price", header: "Price", align: "right", minWidth: 10 }, { key: "amount", header: "Qty", align: "right", minWidth: 8 }, { key: "reduceOnly", header: "RO", minWidth: 4 }, ]; const lastLogs = snapshot.tradeLog.slice(-5); const imbalanceLabel = snapshot.depthImbalance === "balanced" ? t("offset.imbalance.balanced") : snapshot.depthImbalance === "buy_dominant" ? t("offset.imbalance.buy") : t("offset.imbalance.sell"); const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData"); return ( {t("offset.title")} {t("offset.headerLine", { exchange: exchangeName, symbol: snapshot.symbol, bid: formatNumber(topBid, priceDigits), ask: formatNumber(topAsk, priceDigits), spread: spreadDisplay, })} {t("offset.depthLine", { buy: formatNumber(snapshot.buyDepthSum10, 4), sell: formatNumber(snapshot.sellDepthSum10, 4), status: imbalanceLabel, })} {t("offset.strategyStatus", { buyStatus: snapshot.skipBuySide ? t("common.disabled") : t("common.enabled"), sellStatus: snapshot.skipSellSide ? t("common.disabled") : t("common.enabled"), })} {t("trend.statusLine", { status: readyStatus })} {t("common.section.position")} {hasPosition ? ( <> {t("maker.positionLine", { direction: snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"), qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4), entry: formatNumber(snapshot.position.entryPrice, priceDigits), })} {t("maker.pnlLine", { pnl: formatNumber(snapshot.pnl, 4), accountPnl: formatNumber(snapshot.accountUnrealized, 4), })} ) : ( {t("common.noPosition")} )} {t("maker.targetOrders")} {desiredRows.length > 0 ? ( ) : ( {t("maker.noTargetOrders")} )} {t("trend.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })} {t("common.section.orders")} {openOrderRows.length > 0 ? ( ) : ( {t("common.noOrders")} )} {t("common.section.recent")} {lastLogs.length > 0 ? ( lastLogs.map((item, index) => ( [{item.time}] [{item.type}] {item.detail} )) ) : ( {t("common.noLogs")} )} ); }