diff --git a/package.json b/package.json index 92e4385..14e1c83 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "start:offset:silent": "bun run index.ts --strategy offset-maker --silent", "pm2:start:trend": "pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent", "pm2:start:maker": "pm2 start bun --name ritmex-maker --cwd . --restart-delay 5000 -- run index.ts --strategy maker --silent", - "pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent" + "pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent", + "pm2:start:maker-points": "pm2 start bun --name ritmex-maker-points --cwd . --restart-delay 5000 -- run index.ts --strategy maker-points --exchange standx --silent" }, "devDependencies": { "@types/bun": "latest", diff --git a/src/cli/args.ts b/src/cli/args.ts index ae90d27..5a1c5d2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,4 +1,4 @@ -export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid"; +export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid"; export interface CliOptions { strategy?: StrategyId; @@ -11,6 +11,7 @@ const STRATEGY_VALUES = new Set([ "trend", "guardian", "maker", + "maker-points", "offset-maker", "basis", "grid", @@ -69,6 +70,8 @@ function assignStrategy(options: CliOptions, raw: string): void { options.strategy = normalized as StrategyId; } else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") { options.strategy = "offset-maker"; + } else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") { + options.strategy = "maker-points"; } } @@ -92,7 +95,7 @@ function assignExchange(options: CliOptions, raw: string): void { export function printCliHelp(): void { // eslint-disable-next-line no-console - console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + + console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + `Options:\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` + diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index dced004..6a81d4e 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -1,9 +1,10 @@ -import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config"; +import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, makerPointsConfig, tradingConfig } from "../config"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import type { ExchangeAdapter } from "../exchanges/adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine"; import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; +import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine"; import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine"; import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; @@ -21,6 +22,7 @@ export const STRATEGY_LABELS: Record = { trend: "Trend Following", guardian: "Guardian", maker: "Maker", + "maker-points": "Maker Points", "offset-maker": "Offset Maker", basis: "Basis Arbitrage", grid: "Grid", @@ -74,6 +76,23 @@ const STRATEGY_FACTORIES: Record = { offUpdate: (emitter) => engine.off("update", emitter), }); }, + "maker-points": async (opts) => { + const exchangeId = resolveExchangeId(); + if (exchangeId !== "standx") { + throw new Error("Maker Points strategy only supports the StandX exchange."); + } + const config = makerPointsConfig; + const adapter = createAdapterOrThrow(config.symbol); + const engine = new MakerPointsEngine(config, adapter); + await runEngine({ + engine, + strategy: "maker-points", + silent: opts.silent, + getSnapshot: () => engine.getSnapshot(), + onUpdate: (emitter) => engine.on("update", emitter), + offUpdate: (emitter) => engine.off("update", emitter), + }); + }, "offset-maker": async (opts) => { const config = makerConfig; const adapter = createAdapterOrThrow(config.symbol); @@ -135,6 +154,7 @@ async function runEngine< | TrendEngineSnapshot | GuardianEngineSnapshot | MakerEngineSnapshot + | MakerPointsSnapshot | OffsetMakerEngineSnapshot | BasisArbSnapshot | GridEngineSnapshot diff --git a/src/config.ts b/src/config.ts index a7400a7..db5ec6d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,6 +110,41 @@ export const makerConfig: MakerConfig = { priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), }; +export interface MakerPointsConfig { + symbol: string; + perOrderAmount: number; + closeThreshold: number; + stopLossUsd: number; + refreshIntervalMs: number; + maxLogEntries: number; + maxCloseSlippagePct: number; + priceTick: number; + qtyStep: number; + enableBand0To10: boolean; + enableBand10To30: boolean; + enableBand30To100: boolean; + minRepriceBps: number; +} + +export const makerPointsConfig: MakerPointsConfig = { + symbol: resolveSymbolFromEnv("standx"), + perOrderAmount: parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001)), + closeThreshold: parseNumber(process.env.MAKER_POINTS_CLOSE_THRESHOLD, 0), + stopLossUsd: parseNumber(process.env.MAKER_POINTS_STOP_LOSS_USD, 0), + refreshIntervalMs: parseNumber(process.env.MAKER_POINTS_REFRESH_INTERVAL_MS, 500), + maxLogEntries: parseNumber(process.env.MAKER_POINTS_MAX_LOG_ENTRIES, 200), + maxCloseSlippagePct: parseNumber( + process.env.MAKER_POINTS_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT, + 0.05 + ), + priceTick: parseNumber(process.env.MAKER_POINTS_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), + qtyStep: parseNumber(process.env.MAKER_POINTS_QTY_STEP ?? process.env.QTY_STEP, 0.001), + enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true), + enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true), + enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true), + minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3), +}; + export interface BasisArbConfig { futuresSymbol: string; spotSymbol: string; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 0e6b433..d665369 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -35,6 +35,11 @@ const translations: Record = { zh: "双边挂单提供流动性,自动追价与风控止损", en: "Places two-sided quotes, auto-chases and risk-manages stops.", }, + "app.strategy.makerPoints.label": { zh: "StandX 积分做市策略", en: "StandX Maker Points" }, + "app.strategy.makerPoints.desc": { + zh: "基于标记价/盘口挂单赚取 StandX Maker Points", + en: "Quotes by mark-price bands to farm StandX maker points.", + }, "app.strategy.grid.label": { zh: "基础网格策略", en: "Grid Strategy" }, "app.strategy.grid.desc": { zh: "在上下边界之间布设等比网格,自动加仓与减仓", @@ -174,6 +179,23 @@ const translations: Record = { }, "maker.targetOrders": { zh: "目标挂单", en: "Target Orders" }, "maker.noTargetOrders": { zh: "暂无目标挂单", en: "No target orders" }, + "makerPoints.title": { zh: "Maker Points 策略仪表盘", en: "Maker Points Dashboard" }, + "makerPoints.initializing": { zh: "正在初始化 Maker Points 策略…", en: "Initializing Maker Points strategy..." }, + "makerPoints.headerLine": { + zh: "交易所: {exchange} | 交易对: {symbol} | 买一价: {bid} | 卖一价: {ask} | 点差: {spread}", + en: "Exchange: {exchange} | Symbol: {symbol} | Best Bid: {bid} | Best Ask: {ask} | Spread: {spread}", + }, + "makerPoints.quoteLine": { + zh: "挂单模式: {mode} | BUY {buy} | SELL {sell}", + en: "Quote mode: {mode} | BUY {buy} | SELL {sell}", + }, + "makerPoints.binanceLine": { + zh: "Binance 深度: 买10 {buy} | 卖10 {sell} | 状态: {status}", + en: "Binance depth: bid10 {buy} | ask10 {sell} | Status: {status}", + }, + "makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" }, + "makerPoints.mode.normal": { zh: "正常", en: "Normal" }, + "makerPoints.feed.binance": { zh: "Binance", en: "Binance" }, "offset.name": { zh: "偏移做市策略", en: "offset maker strategy" }, "offset.title": { zh: "偏移做市策略仪表盘", en: "Offset Maker Strategy Dashboard" }, "offset.initializing": { zh: "正在初始化偏移做市策略…", en: "Initializing offset maker strategy..." }, diff --git a/src/strategy/common/binance-depth.ts b/src/strategy/common/binance-depth.ts new file mode 100644 index 0000000..bafbb89 --- /dev/null +++ b/src/strategy/common/binance-depth.ts @@ -0,0 +1,177 @@ +import NodeWebSocket from "ws"; +import { computeDepthStats, type DepthImbalance } from "../../utils/depth"; + +const WebSocketCtor: typeof globalThis.WebSocket = + typeof globalThis.WebSocket !== "undefined" + ? globalThis.WebSocket + : ((NodeWebSocket as unknown) as typeof globalThis.WebSocket); + +const DEFAULT_BASE_URL = "wss://fstream.binance.com/ws"; + +export interface BinanceDepthSnapshot { + symbol: string; + buySum: number; + sellSum: number; + skipBuySide: boolean; + skipSellSide: boolean; + imbalance: DepthImbalance; + updatedAt: number; +} + +export class BinanceDepthTracker { + private ws: WebSocket | null = null; + private reconnectTimer: ReturnType | null = null; + private reconnectDelayMs = 3000; + private stopped = false; + private snapshot: BinanceDepthSnapshot | null = null; + private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>(); + + constructor( + private readonly symbol: string, + private readonly options?: { + baseUrl?: string; + levels?: number; + ratio?: number; + logger?: (context: string, error: unknown) => void; + } + ) {} + + start(): void { + this.stopped = false; + this.connect(); + } + + stop(): void { + this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + try { + this.ws.close(); + } catch { + // Ignore close errors + } + this.ws = null; + } + } + + onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void { + this.listeners.add(handler); + } + + offUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void { + this.listeners.delete(handler); + } + + getSnapshot(): BinanceDepthSnapshot | null { + return this.snapshot ? { ...this.snapshot } : null; + } + + private connect(): void { + if (this.ws || this.stopped) return; + const url = this.buildUrl(); + this.ws = new WebSocketCtor(url); + + const handleOpen = () => { + this.reconnectDelayMs = 3000; + }; + + const handleClose = () => { + this.ws = null; + if (!this.stopped) { + this.scheduleReconnect(); + } + }; + + const handleError = (error: unknown) => { + this.options?.logger?.("binanceDepth", error); + }; + + const handleMessage = (event: { data: unknown }) => { + this.handlePayload(event.data); + }; + + const handlePing = (data: unknown) => { + if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") { + this.ws.pong(data as any); + } + }; + + if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") { + this.ws.addEventListener("open", handleOpen); + this.ws.addEventListener("message", handleMessage as any); + this.ws.addEventListener("close", handleClose); + this.ws.addEventListener("error", handleError as any); + this.ws.addEventListener("ping", handlePing as any); + } else if ("on" in this.ws && typeof (this.ws as any).on === "function") { + const nodeSocket = this.ws as any; + nodeSocket.on("open", handleOpen); + nodeSocket.on("message", (data: unknown) => handleMessage({ data })); + nodeSocket.on("close", handleClose); + nodeSocket.on("error", handleError); + nodeSocket.on("ping", handlePing); + } else { + (this.ws as any).onopen = handleOpen; + (this.ws as any).onmessage = handleMessage; + (this.ws as any).onclose = handleClose; + (this.ws as any).onerror = handleError; + } + } + + private buildUrl(): string { + const base = this.options?.baseUrl ?? DEFAULT_BASE_URL; + const stream = `${this.symbol.toLowerCase()}@depth10@100ms`; + return `${base}/${stream}`; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer || this.stopped) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 60_000); + this.connect(); + }, this.reconnectDelayMs); + } + + private handlePayload(data: unknown): void { + const payload = this.parsePayload(data); + if (!payload) return; + const bids = Array.isArray(payload.b) ? payload.b : []; + const asks = Array.isArray(payload.a) ? payload.a : []; + const depth = { + lastUpdateId: Number(payload.u ?? Date.now()), + bids, + asks, + }; + const levels = this.options?.levels ?? 10; + const ratio = this.options?.ratio ?? 3; + const stats = computeDepthStats(depth, levels, ratio); + this.snapshot = { + symbol: this.symbol, + buySum: stats.buySum, + sellSum: stats.sellSum, + skipBuySide: stats.skipBuySide, + skipSellSide: stats.skipSellSide, + imbalance: stats.imbalance, + updatedAt: Date.now(), + }; + for (const listener of this.listeners) { + listener({ ...this.snapshot }); + } + } + + private parsePayload(data: unknown): { b?: [string, string][]; a?: [string, string][]; u?: number } | null { + try { + const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null; + if (!text) return null; + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object") return null; + return parsed as { b?: [string, string][]; a?: [string, string][]; u?: number }; + } catch { + return null; + } + } +} + diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts new file mode 100644 index 0000000..70d8ab1 --- /dev/null +++ b/src/strategy/maker-points-engine.ts @@ -0,0 +1,837 @@ +import type { MakerPointsConfig } from "../config"; +import type { ExchangeAdapter } from "../exchanges/adapter"; +import type { + AsterAccountSnapshot, + AsterDepth, + AsterOrder, + AsterTicker, +} from "../exchanges/types"; +import { formatPriceToString } from "../utils/math"; +import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; +import { extractMessage, isInsufficientBalanceError, isRateLimitError, isUnknownOrderError } from "../utils/errors"; +import { isOrderActiveStatus } from "../utils/order-status"; +import { getPosition, parseSymbolParts } from "../utils/strategy"; +import type { PositionSnapshot } from "../utils/strategy"; +import { computePositionPnl } from "../utils/pnl"; +import { getMidOrLast, getTopPrices } from "../utils/price"; +import { + marketClose, + placeOrder, + unlockOperating, +} from "../core/order-coordinator"; +import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; +import { makeOrderPlan } from "../core/lib/order-plan"; +import { safeCancelOrder } from "../core/lib/orders"; +import { RateLimitController } from "../core/lib/rate-limit"; +import { StrategyEventEmitter } from "./common/event-emitter"; +import { safeSubscribe, type LogHandler } from "./common/subscriptions"; +import { SessionVolumeTracker } from "./common/session-volume"; +import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth"; +import { buildBpsTargets } from "./maker-points-logic"; +import { t } from "../i18n"; + +interface DesiredOrder { + side: "BUY" | "SELL"; + price: string; + amount: number; + reduceOnly: boolean; +} + +export interface MakerPointsSnapshot { + ready: boolean; + symbol: string; + topBid: number | null; + topAsk: number | null; + spread: number | null; + priceDecimals: number; + position: PositionSnapshot; + pnl: number; + accountUnrealized: number; + sessionVolume: number; + openOrders: AsterOrder[]; + desiredOrders: DesiredOrder[]; + tradeLog: TradeLogEntry[]; + lastUpdated: number | null; + feedStatus: { + account: boolean; + orders: boolean; + depth: boolean; + ticker: boolean; + binance: boolean; + }; + binanceDepth: BinanceDepthSnapshot | null; + quoteStatus: { + closeOnly: boolean; + skipBuy: boolean; + skipSell: boolean; + }; +} + +type MakerPointsEvent = "update"; +type MakerPointsListener = (snapshot: MakerPointsSnapshot) => void; + +const EPS = 1e-5; +const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000; +const STOP_LOSS_COOLDOWN_MS = 10_000; + +export class MakerPointsEngine { + private accountSnapshot: AsterAccountSnapshot | null = null; + private depthSnapshot: AsterDepth | null = null; + private tickerSnapshot: AsterTicker | null = null; + private openOrders: AsterOrder[] = []; + + private readonly locks: OrderLockMap = {}; + private readonly timers: OrderTimerMap = {}; + private readonly pending: OrderPendingMap = {}; + private readonly pendingCancelOrders = new Set(); + + private readonly tradeLog: ReturnType; + private readonly events = new StrategyEventEmitter(); + private readonly sessionVolume = new SessionVolumeTracker(); + private readonly rateLimit: RateLimitController; + private readonly binanceDepth: BinanceDepthTracker; + + private priceTick: number = 0.1; + private qtyStep: number = 0.001; + private precisionSync: Promise | null = null; + + private timer: ReturnType | null = null; + private stopLossTimer: ReturnType | null = null; + private processing = false; + private stopLossProcessing = false; + private stopLossCooldownUntil = 0; + private desiredOrders: DesiredOrder[] = []; + private accountUnrealized = 0; + private initialOrderSnapshotReady = false; + private initialOrderResetDone = false; + private lastDesiredSummary: string | null = null; + private lastCloseOnly = false; + private lastSkipBuy = false; + private lastSkipSell = false; + private lastQuoteBid1: number | null = null; + private lastQuoteAsk1: number | null = null; + + private readinessLogged = { + account: false, + depth: false, + ticker: false, + orders: false, + }; + private feedStatus = { + account: false, + depth: false, + ticker: false, + orders: false, + binance: false, + }; + private insufficientBalanceCooldownUntil = 0; + private insufficientBalanceNotified = false; + private lastInsufficientMessage: string | null = null; + + constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) { + this.tradeLog = createTradeLog(this.config.maxLogEntries); + this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => + this.tradeLog.push(type, detail) + ); + this.priceTick = Math.max(1e-9, this.config.priceTick); + this.qtyStep = Math.max(1e-9, this.config.qtyStep); + this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), { + baseUrl: process.env.BINANCE_WS_URL, + levels: 10, + ratio: 3, + logger: (context, error) => { + this.tradeLog.push("warn", `Binance ${context} 异常: ${extractMessage(error)}`); + }, + }); + this.binanceDepth.onUpdate(() => { + this.feedStatus.binance = true; + this.emitUpdate(); + }); + this.syncPrecision(); + this.bootstrap(); + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => { + void this.tick(); + }, this.config.refreshIntervalMs); + if (!this.stopLossTimer) { + this.stopLossTimer = setInterval(() => { + void this.checkStopLoss(); + }, Math.max(500, this.config.refreshIntervalMs)); + } + this.binanceDepth.start(); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + if (this.stopLossTimer) { + clearInterval(this.stopLossTimer); + this.stopLossTimer = null; + } + this.binanceDepth.stop(); + } + + on(event: MakerPointsEvent, handler: MakerPointsListener): void { + this.events.on(event, handler); + } + + off(event: MakerPointsEvent, handler: MakerPointsListener): void { + this.events.off(event, handler); + } + + getSnapshot(): MakerPointsSnapshot { + return this.buildSnapshot(); + } + + private bootstrap(): void { + const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); + + safeSubscribe( + this.exchange.watchAccount.bind(this.exchange), + (snapshot) => { + this.accountSnapshot = snapshot; + const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0"); + if (Number.isFinite(totalUnrealized)) { + this.accountUnrealized = totalUnrealized; + } + const position = getPosition(snapshot, this.config.symbol); + this.sessionVolume.update(position, this.getReferencePrice()); + this.feedStatus.account = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }), + processFail: (error) => t("log.process.accountError", { error: String(error) }), + } + ); + + safeSubscribe( + this.exchange.watchOrders.bind(this.exchange), + (orders) => { + this.syncLocksWithOrders(orders); + this.openOrders = Array.isArray(orders) + ? orders.filter( + (order) => + order.type !== "MARKET" && + order.symbol === this.config.symbol && + isOrderActiveStatus(order.status) + ) + : []; + const currentIds = new Set(this.openOrders.map((order) => String(order.orderId))); + for (const id of Array.from(this.pendingCancelOrders)) { + if (!currentIds.has(id)) { + this.pendingCancelOrders.delete(id); + } + } + this.initialOrderSnapshotReady = true; + this.feedStatus.orders = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }), + processFail: (error) => t("log.process.orderError", { error: String(error) }), + } + ); + + safeSubscribe( + this.exchange.watchDepth.bind(this.exchange, this.config.symbol), + (depth) => { + this.depthSnapshot = depth; + this.feedStatus.depth = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }), + processFail: (error) => t("log.process.depthError", { error: String(error) }), + } + ); + + safeSubscribe( + this.exchange.watchTicker.bind(this.exchange, this.config.symbol), + (ticker) => { + this.tickerSnapshot = ticker; + this.feedStatus.ticker = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }), + processFail: (error) => t("log.process.tickerError", { error: String(error) }), + } + ); + } + + private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void { + const list = Array.isArray(orders) ? orders : []; + Object.keys(this.pending).forEach((type) => { + const pendingId = this.pending[type]; + if (!pendingId) return; + const match = list.find((order) => String(order.orderId) === pendingId); + if (!match || (match.status && match.status !== "NEW" && match.status !== "PARTIALLY_FILLED")) { + unlockOperating(this.locks, this.timers, this.pending, type); + } + }); + } + + private isReady(): boolean { + return Boolean( + this.feedStatus.account && + this.feedStatus.depth && + this.feedStatus.ticker && + this.feedStatus.orders + ); + } + + private async tick(): Promise { + if (this.processing) return; + this.processing = true; + let hadRateLimit = false; + try { + const decision = this.rateLimit.beforeCycle(); + if (decision === "paused") { + this.emitUpdate(); + return; + } + if (decision === "skip") { + return; + } + if (!this.isReady()) { + this.logReadinessBlockers(); + this.emitUpdate(); + return; + } + this.resetReadinessFlags(); + if (!(await this.ensureStartupOrderReset())) { + this.emitUpdate(); + return; + } + + const depth = this.depthSnapshot!; + const { topBid, topAsk } = getTopPrices(depth); + if (topBid == null || topAsk == null) { + this.emitUpdate(); + return; + } + + const position = getPosition(this.accountSnapshot, this.config.symbol); + const absPosition = Math.abs(position.positionAmt); + const closeThreshold = Number(this.config.closeThreshold); + const closeOnly = + Number.isFinite(closeThreshold) && + closeThreshold > 0 && + absPosition >= closeThreshold - EPS; + const prevCloseOnly = this.lastCloseOnly; + if (closeOnly !== prevCloseOnly) { + this.tradeLog.push("info", closeOnly ? "进入平仓模式,仅挂 reduce-only" : "退出平仓模式"); + this.lastCloseOnly = closeOnly; + } + + + const binanceSnapshot = this.binanceDepth.getSnapshot(); + const rawSkipBuy = Boolean(binanceSnapshot?.skipBuySide); + const rawSkipSell = Boolean(binanceSnapshot?.skipSellSide); + const skipBuy = closeOnly ? false : rawSkipBuy; + const skipSell = closeOnly ? false : rawSkipSell; + const prevSkipBuy = this.lastSkipBuy; + const prevSkipSell = this.lastSkipSell; + if (skipBuy !== prevSkipBuy || skipSell !== prevSkipSell) { + if (skipBuy || skipSell) { + const summary = `${skipBuy ? "BUY" : ""}${skipBuy && skipSell ? "/" : ""}${skipSell ? "SELL" : ""}`; + this.tradeLog.push("info", `Binance 深度失衡,暂停 ${summary} 挂单`); + } else { + this.tradeLog.push("info", "Binance 深度恢复,继续挂单"); + } + this.lastSkipBuy = skipBuy; + this.lastSkipSell = skipSell; + } + + const closeOnlyChanged = closeOnly !== prevCloseOnly; + const skipChanged = skipBuy !== prevSkipBuy || skipSell !== prevSkipSell; + const repriceNeeded = closeOnly ? true : this.shouldReprice(topBid, topAsk); + const shouldRecompute = + closeOnly || + repriceNeeded || + closeOnlyChanged || + skipChanged || + this.desiredOrders.length === 0; + + const desired = shouldRecompute + ? closeOnly + ? this.buildCloseOnlyOrders(position, topBid, topAsk) + : this.buildDesiredOrders({ + bid1: topBid, + ask1: topAsk, + skipBuy, + skipSell, + }) + : this.desiredOrders; + + if (shouldRecompute) { + if (closeOnly) { + this.lastQuoteBid1 = null; + this.lastQuoteAsk1 = null; + } else { + this.lastQuoteBid1 = topBid; + this.lastQuoteAsk1 = topAsk; + } + } + + this.desiredOrders = desired; + this.logDesiredOrders(desired); + this.sessionVolume.update(position, this.getReferencePrice()); + await this.syncOrders(desired, closeOnly); + this.emitUpdate(); + } catch (error) { + if (isRateLimitError(error)) { + hadRateLimit = true; + this.rateLimit.registerRateLimit("maker-points"); + this.tradeLog.push("warn", `限频触发,暂停挂单: ${extractMessage(error)}`); + } else { + this.tradeLog.push("error", `MakerPoints 主循环异常: ${extractMessage(error)}`); + } + this.emitUpdate(); + } finally { + this.rateLimit.onCycleComplete(hadRateLimit); + this.processing = false; + } + } + + private buildDesiredOrders(params: { + bid1: number; + ask1: number; + skipBuy: boolean; + skipSell: boolean; + }): DesiredOrder[] { + const { bid1, ask1, skipBuy, skipSell } = params; + const amount = Number(this.config.perOrderAmount); + if (!Number.isFinite(amount) || amount <= 0) return []; + + const targets = buildBpsTargets({ + band0To10: this.config.enableBand0To10, + band10To30: this.config.enableBand10To30, + band30To100: this.config.enableBand30To100, + }).sort((a, b) => b - a); + + if (!targets.length) return []; + + const priceDecimals = this.getPriceDecimals(); + const desired: DesiredOrder[] = []; + + for (const bps of targets) { + if (!skipBuy) { + const price = bid1 * (1 - bps / 10000); + if (Number.isFinite(price) && price > 0) { + desired.push({ + side: "BUY", + price: formatPriceToString(price, priceDecimals), + amount, + reduceOnly: false, + }); + } + } + if (!skipSell) { + const price = ask1 * (1 + bps / 10000); + if (Number.isFinite(price) && price > 0) { + desired.push({ + side: "SELL", + price: formatPriceToString(price, priceDecimals), + amount, + reduceOnly: false, + }); + } + } + } + + return desired; + } + + private buildCloseOnlyOrders( + position: PositionSnapshot, + bid1: number, + ask1: number + ): DesiredOrder[] { + const absPosition = Math.abs(position.positionAmt); + if (absPosition < EPS) return []; + const priceDecimals = this.getPriceDecimals(); + if (position.positionAmt > 0) { + return [ + { + side: "SELL", + price: formatPriceToString(bid1, priceDecimals), + amount: absPosition, + reduceOnly: true, + }, + ]; + } + return [ + { + side: "BUY", + price: formatPriceToString(ask1, priceDecimals), + amount: absPosition, + reduceOnly: true, + }, + ]; + } + + private shouldReprice(bid1: number, ask1: number): boolean { + const threshold = Number(this.config.minRepriceBps); + if (!Number.isFinite(threshold) || threshold <= 0) return true; + if (!Number.isFinite(bid1) || !Number.isFinite(ask1)) return false; + if (!Number.isFinite(this.lastQuoteBid1 ?? NaN) || !Number.isFinite(this.lastQuoteAsk1 ?? NaN)) { + return true; + } + if ((this.lastQuoteBid1 ?? 0) <= 0 || (this.lastQuoteAsk1 ?? 0) <= 0) return true; + const bidMove = Math.abs(bid1 - (this.lastQuoteBid1 ?? bid1)) / (this.lastQuoteBid1 ?? bid1) * 10000; + const askMove = Math.abs(ask1 - (this.lastQuoteAsk1 ?? ask1)) / (this.lastQuoteAsk1 ?? ask1) * 10000; + return bidMove >= threshold || askMove >= threshold; + } + + private async ensureStartupOrderReset(): Promise { + if (this.initialOrderResetDone) return true; + if (!this.initialOrderSnapshotReady) return false; + if (!this.openOrders.length) { + this.initialOrderResetDone = true; + return true; + } + try { + await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); + this.pendingCancelOrders.clear(); + unlockOperating(this.locks, this.timers, this.pending, "LIMIT"); + this.openOrders = []; + this.emitUpdate(); + this.tradeLog.push("order", "启动时清理历史挂单"); + this.initialOrderResetDone = true; + return true; + } catch (error) { + if (isUnknownOrderError(error)) { + this.tradeLog.push("order", "历史挂单已消失,跳过启动清理"); + this.initialOrderResetDone = true; + this.openOrders = []; + this.emitUpdate(); + return true; + } + this.tradeLog.push("error", `启动撤单失败: ${String(error)}`); + return false; + } + } + + private async syncOrders(targets: DesiredOrder[], closeOnly: boolean): Promise { + const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId))); + const openOrders = availableOrders.filter((order) => isOrderActiveStatus(order.status)); + const { toCancel, toPlace } = makeOrderPlan(openOrders, targets); + + for (const order of toCancel) { + if (this.pendingCancelOrders.has(String(order.orderId))) continue; + this.pendingCancelOrders.add(String(order.orderId)); + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + () => { + this.tradeLog.push( + "order", + `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}` + ); + }, + () => { + this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); + this.pendingCancelOrders.delete(String(order.orderId)); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + }, + (error) => { + this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); + this.pendingCancelOrders.delete(String(order.orderId)); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + } + ); + } + + const insufficientActive = this.applyInsufficientBalanceState(Date.now()); + if (this.rateLimit.shouldBlockEntries() || insufficientActive) { + return; + } + + for (const target of toPlace) { + if (!target) continue; + if (target.amount < EPS) continue; + try { + await placeOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + target.side, + target.price, + target.amount, + (type, detail) => this.tradeLog.push(type, detail), + target.reduceOnly, + undefined, + { + priceTick: this.priceTick, + qtyStep: this.qtyStep, + skipDedupe: true, + } + ); + } catch (error) { + if (isInsufficientBalanceError(error)) { + this.registerInsufficientBalance(error); + break; + } + this.tradeLog.push( + "error", + `挂单失败 ${target.side} @ ${target.price}: ${extractMessage(error)}` + ); + } + } + } + + private async checkStopLoss(): Promise { + if (this.stopLossProcessing) return; + const lossLimit = Number(this.config.stopLossUsd); + if (!Number.isFinite(lossLimit) || lossLimit <= 0) return; + if (!this.accountSnapshot) return; + const position = getPosition(this.accountSnapshot, this.config.symbol); + const absPosition = Math.abs(position.positionAmt); + if (absPosition < EPS) return; + if (!Number.isFinite(position.unrealizedProfit)) return; + + const now = Date.now(); + if (now < this.stopLossCooldownUntil) return; + if (position.unrealizedProfit > -lossLimit) return; + + this.stopLossProcessing = true; + this.stopLossCooldownUntil = now + STOP_LOSS_COOLDOWN_MS; + this.tradeLog.push( + "stop", + `触发止损: 未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT` + ); + try { + await this.flushOrders(); + await marketClose( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + position.positionAmt > 0 ? "SELL" : "BUY", + absPosition, + (type, detail) => this.tradeLog.push(type, detail), + undefined, + { qtyStep: this.qtyStep } + ); + } catch (error) { + if (isUnknownOrderError(error)) { + this.tradeLog.push("order", "止损平仓时订单已不存在"); + } else { + this.tradeLog.push("error", `止损平仓失败: ${extractMessage(error)}`); + } + } finally { + this.stopLossProcessing = false; + this.emitUpdate(); + } + } + + private async flushOrders(): Promise { + if (!this.openOrders.length) return; + for (const order of this.openOrders) { + if (this.pendingCancelOrders.has(String(order.orderId))) continue; + this.pendingCancelOrders.add(String(order.orderId)); + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + () => { + // No log on successful cancel + }, + () => { + this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); + this.pendingCancelOrders.delete(String(order.orderId)); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + }, + (error) => { + this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); + this.pendingCancelOrders.delete(String(order.orderId)); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + } + ); + } + } + + private syncPrecision(): void { + if (this.precisionSync) return; + const getPrecision = this.exchange.getPrecision?.bind(this.exchange); + if (!getPrecision) return; + this.precisionSync = getPrecision() + .then((precision) => { + if (!precision) return; + let updated = false; + if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) { + if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) { + this.priceTick = precision.priceTick; + this.config.priceTick = precision.priceTick; + updated = true; + } + } + if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { + if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) { + this.qtyStep = precision.qtyStep; + updated = true; + } + } + if (updated) { + this.tradeLog.push( + "info", + t("log.common.precisionSynced", { + priceTick: precision.priceTick, + qtyStep: precision.qtyStep, + }) + ); + } + }) + .catch((error) => { + this.tradeLog.push("error", t("log.common.precisionFailed", { error: extractMessage(error) })); + this.precisionSync = null; + setTimeout(() => this.syncPrecision(), 2000); + }); + } + + private getPriceDecimals(): number { + const tick = Math.max(1e-9, this.priceTick); + const raw = Math.log10(1 / tick); + if (!Number.isFinite(raw)) return 0; + return Math.max(0, Math.floor(raw + 1e-9)); + } + + private emitUpdate(): void { + try { + const snapshot = this.buildSnapshot(); + this.events.emit("update", snapshot, (error) => { + this.tradeLog.push("error", `更新监听异常: ${String(error)}`); + }); + } catch (err) { + this.tradeLog.push("error", `快照生成异常: ${String(err)}`); + } + } + + private buildSnapshot(): MakerPointsSnapshot { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const { topBid, topAsk } = getTopPrices(this.depthSnapshot); + const spread = topBid != null && topAsk != null ? topAsk - topBid : null; + const pnl = computePositionPnl(position, topBid, topAsk); + + return { + ready: this.isReady(), + symbol: this.config.symbol, + topBid, + topAsk, + spread, + priceDecimals: this.getPriceDecimals(), + position, + pnl, + accountUnrealized: this.accountUnrealized, + sessionVolume: this.sessionVolume.value, + openOrders: this.openOrders, + desiredOrders: this.desiredOrders, + tradeLog: this.tradeLog.all(), + lastUpdated: Date.now(), + feedStatus: { ...this.feedStatus }, + binanceDepth: this.binanceDepth.getSnapshot(), + quoteStatus: { + closeOnly: this.lastCloseOnly, + skipBuy: this.lastSkipBuy, + skipSell: this.lastSkipSell, + }, + }; + } + + private getReferencePrice(): number | null { + return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); + } + + private logReadinessBlockers(): void { + if (!this.feedStatus.account && !this.readinessLogged.account) { + this.tradeLog.push("info", t("log.maker.waitAccount")); + this.readinessLogged.account = true; + } + if (!this.feedStatus.depth && !this.readinessLogged.depth) { + this.tradeLog.push("info", t("log.maker.waitDepth")); + this.readinessLogged.depth = true; + } + if (!this.feedStatus.ticker && !this.readinessLogged.ticker) { + this.tradeLog.push("info", t("log.maker.waitTicker")); + this.readinessLogged.ticker = true; + } + if (!this.feedStatus.orders && !this.readinessLogged.orders) { + this.tradeLog.push("info", t("log.maker.waitOrders")); + this.readinessLogged.orders = true; + } + } + + private resetReadinessFlags(): void { + this.readinessLogged = { + account: false, + depth: false, + ticker: false, + orders: false, + }; + } + + private logDesiredOrders(desired: DesiredOrder[]): void { + if (!desired.length) { + if (this.lastDesiredSummary !== "none") { + this.tradeLog.push("info", "暂无目标挂单"); + this.lastDesiredSummary = "none"; + } + return; + } + const summary = desired + .map((order) => `${order.side}@${order.price}${order.reduceOnly ? "(RO)" : ""}`) + .join(" | "); + if (summary !== this.lastDesiredSummary) { + this.tradeLog.push("info", `目标挂单: ${summary}`); + this.lastDesiredSummary = summary; + } + } + + private registerInsufficientBalance(error: unknown): void { + const now = Date.now(); + const detail = extractMessage(error); + const alreadyActive = now < this.insufficientBalanceCooldownUntil; + if (alreadyActive && detail === this.lastInsufficientMessage) { + this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS; + return; + } + this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS; + this.lastInsufficientMessage = detail; + const seconds = Math.ceil(INSUFFICIENT_BALANCE_COOLDOWN_MS / 1000); + this.tradeLog.push("warn", `余额不足,暂停挂单 ${seconds}s: ${detail}`); + this.insufficientBalanceNotified = true; + } + + private applyInsufficientBalanceState(now: number): boolean { + const active = now < this.insufficientBalanceCooldownUntil; + if (!active && this.insufficientBalanceNotified) { + this.tradeLog.push("info", "余额恢复,继续挂单"); + this.insufficientBalanceNotified = false; + this.lastInsufficientMessage = null; + } + return active; + } +} + +function resolveBinanceSymbol(symbol: string): string { + const parts = parseSymbolParts(symbol); + const base = (parts.base ?? symbol).replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); + return base ? `${base}USDT` : "BTCUSDT"; +} diff --git a/src/strategy/maker-points-logic.test.ts b/src/strategy/maker-points-logic.test.ts new file mode 100644 index 0000000..9dc776e --- /dev/null +++ b/src/strategy/maker-points-logic.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { buildBpsTargets } from "./maker-points-logic"; + +describe("maker points target builder", () => { + it("builds fixed bps targets per enabled band", () => { + const targets = buildBpsTargets({ + band0To10: true, + band10To30: true, + band30To100: true, + }); + expect(targets).toEqual([9, 29, 99]); + }); + + it("skips disabled bands", () => { + const targets = buildBpsTargets({ + band0To10: true, + band10To30: false, + band30To100: true, + }); + expect(targets).toEqual([9, 99]); + }); +}); diff --git a/src/strategy/maker-points-logic.ts b/src/strategy/maker-points-logic.ts new file mode 100644 index 0000000..5136eca --- /dev/null +++ b/src/strategy/maker-points-logic.ts @@ -0,0 +1,13 @@ +export interface MakerPointsBandConfig { + band0To10: boolean; + band10To30: boolean; + band30To100: boolean; +} + +export function buildBpsTargets(config: MakerPointsBandConfig): number[] { + const targets: number[] = []; + if (config.band0To10) targets.push(9); + if (config.band10To30) targets.push(29); + if (config.band30To100) targets.push(99); + return targets.sort((a, b) => a - b); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 489c660..9ae784d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput } from "ink"; import { TrendApp } from "./TrendApp"; import { GuardianApp } from "./GuardianApp"; import { MakerApp } from "./MakerApp"; +import { MakerPointsApp } from "./MakerPointsApp"; import { OffsetMakerApp } from "./OffsetMakerApp"; import { GridApp } from "./GridApp"; import { BasisApp } from "./BasisApp"; @@ -12,7 +13,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter"; import { t } from "../i18n"; interface StrategyOption { - id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid"; + id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid"; label: string; description: string; component: React.ComponentType<{ onExit: () => void }>; @@ -60,19 +61,25 @@ export function App() { const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []); const exchangeId = useMemo(() => resolveExchangeId(), []); const strategies = useMemo(() => { - if (!isBasisStrategyEnabled()) { - return BASE_STRATEGIES; + const next: StrategyOption[] = [...BASE_STRATEGIES]; + if (exchangeId === "standx") { + next.splice(3, 0, { + id: "maker-points" as const, + label: t("app.strategy.makerPoints.label"), + description: t("app.strategy.makerPoints.desc"), + component: MakerPointsApp, + }); } - return [ - ...BASE_STRATEGIES, - { + if (isBasisStrategyEnabled()) { + next.push({ id: "basis" as const, label: t("app.strategy.basis.label"), description: t("app.strategy.basis.desc"), component: BasisApp, - }, - ]; - }, []); + }); + } + return next; + }, [exchangeId]); useInput( (input, key) => { diff --git a/src/ui/MakerPointsApp.tsx b/src/ui/MakerPointsApp.tsx new file mode 100644 index 0000000..c90dc06 --- /dev/null +++ b/src/ui/MakerPointsApp.tsx @@ -0,0 +1,238 @@ +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"); + + 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), + status: imbalanceLabel, + })} + + + {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")} + )} + + + ); +}