import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useInput } from "ink"; import { makerPointsConfig } from "../config"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine"; import { DataTable, type TableColumn } from "./components/DataTable"; import { formatNumber } from "../utils/format"; import { t } from "../i18n"; interface MakerPointsAppProps { onExit: () => void; } const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); export function MakerPointsApp({ onExit }: MakerPointsAppProps) { 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 { if (exchangeId !== "standx") { throw new Error("Maker Points strategy only supports the StandX exchange."); } const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerPointsConfig.symbol }); const engine = new MakerPointsEngine(makerPointsConfig, adapter); engineRef.current = engine; setSnapshot(engine.getSnapshot()); const handler = (next: MakerPointsSnapshot) => { 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("makerPoints.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 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") }, { key: "binance", label: t("makerPoints.feed.binance") }, ]; const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData"); const imbalanceStatus = snapshot.binanceDepth?.imbalance ?? "balanced"; const imbalanceLabel = imbalanceStatus === "buy_dominant" ? t("offset.imbalance.buy") : imbalanceStatus === "sell_dominant" ? t("offset.imbalance.sell") : t("offset.imbalance.balanced"); const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal"); const formatDepth = (value: number | null) => (value == null ? "-" : formatNumber(value, 4)); return ( {t("makerPoints.title")} {t("makerPoints.headerLine", { exchange: exchangeName, symbol: snapshot.symbol, bid: formatNumber(topBid, priceDigits), ask: formatNumber(topAsk, priceDigits), spread: spreadDisplay, })} {t("trend.statusLine", { status: readyStatus })} {t("makerPoints.quoteLine", { mode: quoteMode, buy: snapshot.quoteStatus.skipBuy ? t("common.disabled") : t("common.enabled"), sell: snapshot.quoteStatus.skipSell ? t("common.disabled") : t("common.enabled"), })} {t("makerPoints.binanceLine", { buy: formatNumber(snapshot.binanceDepth?.buySum ?? 0, 4), sell: formatNumber(snapshot.binanceDepth?.sellSum ?? 0, 4), windowBps: snapshot.binanceDepth?.windowBps ?? 5, status: imbalanceLabel, })} {snapshot.bandDepths.map((band) => ( {t("makerPoints.bandDepthLine", { band: band.band, buy: formatDepth(band.buyDepth), sell: formatDepth(band.sellDepth), })} ))} {t("maker.dataStatus")} {feedEntries.map((entry, index) => ( {index === 0 ? " " : " "} {entry.label} ))} {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")} )} ); }