import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { swingConfig } from "../config"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine"; import { formatNumber } from "../utils/format"; import { DataTable, type TableColumn } from "./components/DataTable"; import { t } from "../i18n"; const READY_MESSAGE = t("swing.readyMessage"); interface SwingAppProps { onExit: () => void; } const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); export function SwingApp({ onExit }: SwingAppProps) { 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: swingConfig.symbol }); const engine = new SwingEngine(swingConfig, adapter); engineRef.current = engine; setSnapshot(engine.getSnapshot()); const handler = (next: SwingEngineSnapshot) => { setSnapshot({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] }); }; 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("common.initializing", { target: t("swing.name") })} ); } const zoneLabel = snapshot.rsiZone === "overbought" ? t("swing.zone.overbought") : snapshot.rsiZone === "oversold" ? t("swing.zone.oversold") : snapshot.rsiZone === "neutral" ? t("swing.zone.neutral") : t("swing.zone.unknown"); const phaseLabel = snapshot.phase === "disabled" ? t("swing.phase.disabled") : snapshot.phase === "initializing" ? t("swing.phase.initializing") : snapshot.phase === "waiting_open_short" ? t("swing.phase.waitingOpenShort") : snapshot.phase === "waiting_close_short" ? t("swing.phase.waitingCloseShort") : snapshot.phase === "waiting_open_long" ? t("swing.phase.waitingOpenLong") : snapshot.phase === "waiting_close_long" ? t("swing.phase.waitingCloseLong") : t("swing.phase.observing"); const lastLogs = snapshot.tradeLog.slice(-5); const sortedOrders = [...snapshot.openOrders].sort( (a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId) ); const orderRows = sortedOrders.slice(0, 8).map((order) => ({ id: order.orderId, side: order.side, type: order.type, price: order.price, qty: order.origQty, filled: order.executedQty, status: order.status, })); const orderColumns: TableColumn[] = [ { key: "id", header: "ID", align: "right", minWidth: 6 }, { key: "side", header: "Side", minWidth: 4 }, { key: "type", header: "Type", minWidth: 10 }, { 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: "status", header: "Status", minWidth: 10 }, ]; const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5; return ( {t("swing.title")} {t("swing.headerLine", { exchange: exchangeName, symbol: snapshot.symbol, direction: snapshot.direction, lastPrice: formatNumber(snapshot.lastPrice, 6), phase: phaseLabel, })} {t("swing.signalLine", { binanceSymbol: "ETHBTC", binancePrice: formatNumber(snapshot.binancePrice, 8), rsi: formatNumber(snapshot.rsi, 2), zone: zoneLabel, connection: snapshot.binanceConnection, })} {t("swing.statusLine", { status: snapshot.disabled ? t("status.paused") : snapshot.ready ? t("status.live") : READY_MESSAGE, })} {snapshot.error ? {snapshot.error} : null} {t("common.section.position")} {hasPosition ? ( <> {t("swing.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, 6), })} {t("swing.pnlLine", { pnl: formatNumber(snapshot.pnl, 4), unrealized: formatNumber(snapshot.unrealized, 4), })} {t("swing.stopLine", { stop: formatNumber(snapshot.stopLossTarget, 6) })} ) : ( {t("common.noPosition")} )} {t("swing.stateTitle")} {t("swing.armedLine", { se: snapshot.armed.armedShortEntry ? "Y" : "N", sx: snapshot.armed.armedShortExit ? "Y" : "N", le: snapshot.armed.armedLongEntry ? "Y" : "N", lx: snapshot.armed.armedLongExit ? "Y" : "N", })} {t("swing.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })} {t("common.section.orders")} {orderRows.length > 0 ? : {t("common.noOrders")}} {t("common.section.recentTrades")} {lastLogs.length > 0 ? ( lastLogs.map((item, index) => ( [{item.time}] [{item.type}] {item.detail} )) ) : ( {t("common.noLogs")} )} ); }