import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { gridConfig } from "../config"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; import { t } from "../i18n"; interface GridAppProps { onExit: () => void; } const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); export function GridApp({ onExit }: GridAppProps) { 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: gridConfig.symbol }); const engine = new GridEngine(gridConfig, adapter); engineRef.current = engine; setSnapshot(engine.getSnapshot()); const handler = (next: GridEngineSnapshot) => { setSnapshot({ ...next, desiredOrders: [...next.desiredOrders], gridLines: [...next.gridLines], 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("grid.initializing")} ); } const feedStatus = snapshot.feedStatus; const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [ { key: "account", label: t("maker.feed.account") }, { key: "orders", label: t("maker.feed.orders") }, { key: "depth", label: t("maker.feed.depth") }, { key: "ticker", label: t("maker.feed.ticker") }, ]; const stopReason = snapshot.running ? null : snapshot.stopReason; const lastLogs = snapshot.tradeLog.slice(-5); const position = snapshot.position; const hasPosition = Math.abs(position.positionAmt) > 1e-5; const gridColumns: TableColumn[] = [ { key: "level", header: "#", align: "right", minWidth: 3 }, { key: "price", header: "Price", align: "right", minWidth: 10 }, { key: "side", header: "Side", minWidth: 4 }, { key: "active", header: "Active", minWidth: 6 }, { key: "hasOrder", header: "Order", minWidth: 5 }, ]; const gridRows = snapshot.gridLines.map((line) => ({ level: line.level, price: formatNumber(line.price, 4), side: line.side, active: line.active ? "yes" : "no", hasOrder: line.hasOrder ? "yes" : "no", })); const desiredColumns: TableColumn[] = [ { key: "level", header: "#", align: "right", minWidth: 3 }, { key: "side", header: "Side", minWidth: 4 }, { key: "price", header: "Price", align: "right", minWidth: 10 }, { key: "amount", header: "Qty", align: "right", minWidth: 8 }, ]; const desiredRows = snapshot.desiredOrders.map((order) => ({ level: order.level, side: order.side, price: order.price, amount: formatNumber(order.amount, 4), })); const statusLabel = snapshot.running ? t("status.running") : t("status.paused"); const directionLabel = snapshot.direction === "both" ? t("grid.direction.both") : snapshot.direction === "long" ? t("grid.direction.long") : t("grid.direction.short"); return ( {t("grid.title")} {t("grid.headerLine", { exchange: exchangeName, symbol: snapshot.symbol, status: statusLabel, direction: directionLabel, })} {t("grid.priceLine", { lastPrice: formatNumber(snapshot.lastPrice, 4), lower: formatNumber(snapshot.lowerPrice, 4), upper: formatNumber(snapshot.upperPrice, 4), count: snapshot.gridLines.length, })} {t("grid.dataStatus")} {feedEntries.map((entry, index) => ( {index === 0 ? " " : " "} {entry.label} ))} {" | "} {t("common.backHint")} {stopReason ? {t("grid.stopReason", { reason: stopReason })} : null} {t("grid.configTitle")} {t("grid.configSize", { orderSize: formatNumber(gridConfig.orderSize, 6), maxPosition: formatNumber(gridConfig.maxPositionSize, 6), })} {t("grid.configRisk", { stopLoss: (gridConfig.stopLossPct * 100).toFixed(2), restart: (gridConfig.restartTriggerPct * 100).toFixed(2), autoRestart: gridConfig.autoRestart ? t("common.enabled") : t("common.disabled"), })} {t("grid.refreshInterval", { interval: gridConfig.refreshIntervalMs })} {t("common.section.position")} {hasPosition ? ( <> {t("grid.positionLine", { direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"), qty: formatNumber(Math.abs(position.positionAmt), 6), avgPrice: formatNumber(position.entryPrice, 4), })} {t("grid.unrealizedLine", { pnl: formatNumber(position.unrealizedProfit, 4), mark: formatNumber(position.markPrice, 4), })} ) : ( {t("common.noPosition")} )} {t("grid.linesTitle")} {gridRows.length > 0 ? ( ) : ( {t("grid.noLines")} )} {t("maker.targetOrders")} {desiredRows.length > 0 ? ( ) : ( {t("maker.noTargetOrders")} )} {t("common.section.recent")} {lastLogs.length > 0 ? ( lastLogs.map((item, index) => ( [{item.time}] [{item.type}] {item.detail} )) ) : ( {t("common.noLogs")} )} ); }