From fa3c912d63e33d7cd61f9de9f43502eb5247e820 Mon Sep 17 00:00:00 2001 From: discountry Date: Wed, 24 Sep 2025 02:41:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20OffsetMakerEngine=20?= =?UTF-8?q?=E5=92=8C=20OffsetMakerApp=EF=BC=8C=E6=94=AF=E6=8C=81=E6=A0=B9?= =?UTF-8?q?=E6=8D=AE=E7=9B=98=E5=8F=A3=E6=B7=B1=E5=BA=A6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=81=8F=E7=A7=BB=E6=8C=82=E5=8D=95=E5=B9=B6=E5=9C=A8=E6=9E=81?= =?UTF-8?q?=E7=AB=AF=E4=B8=8D=E5=B9=B3=E8=A1=A1=E6=97=B6=E6=92=A4=E9=80=80?= =?UTF-8?q?=EF=BC=9B=E6=9B=B4=E6=96=B0=20MakerEngine=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=92=A4=E5=8D=95=E9=80=BB=E8=BE=91=E4=BB=A5=E6=B8=85?= =?UTF-8?q?=E7=90=86=E5=B7=B2=E6=88=90=E4=BA=A4=E7=9A=84=E6=8C=82=E5=8D=95?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/maker-engine.ts | 2 + src/core/offset-maker-engine.ts | 552 ++++++++++++++++++++++++++++++++ src/ui/App.tsx | 9 +- src/ui/OffsetMakerApp.tsx | 195 +++++++++++ 4 files changed, 757 insertions(+), 1 deletion(-) create mode 100644 src/core/offset-maker-engine.ts create mode 100644 src/ui/OffsetMakerApp.tsx diff --git a/src/core/maker-engine.ts b/src/core/maker-engine.ts index ddba44b..f2ce301 100644 --- a/src/core/maker-engine.ts +++ b/src/core/maker-engine.ts @@ -284,6 +284,7 @@ export class MakerEngine { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); this.pendingCancelOrders.delete(order.orderId); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } else { this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); this.pendingCancelOrders.delete(order.orderId); @@ -381,6 +382,7 @@ export class MakerEngine { if (isUnknownOrderError(error)) { this.tradeLog.push("order", "订单已不存在,撤销跳过"); this.pendingCancelOrders.delete(order.orderId); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); } else { this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); this.pendingCancelOrders.delete(order.orderId); diff --git a/src/core/offset-maker-engine.ts b/src/core/offset-maker-engine.ts new file mode 100644 index 0000000..797f64d --- /dev/null +++ b/src/core/offset-maker-engine.ts @@ -0,0 +1,552 @@ +import type { MakerConfig } from "../config"; +import type { ExchangeAdapter } from "../exchanges/adapter"; +import type { + AsterAccountSnapshot, + AsterDepth, + AsterOrder, + AsterTicker, +} from "../exchanges/types"; +import { toPrice1Decimal } from "../utils/math"; +import { createTradeLog } from "../state/trade-log"; +import { isUnknownOrderError } from "../utils/errors"; +import { getPosition, type PositionSnapshot } from "../utils/strategy"; +import { + marketClose, + placeOrder, + unlockOperating, +} from "./order-coordinator"; +import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "./order-coordinator"; +import type { MakerEngineSnapshot } from "./maker-engine"; + +interface DesiredOrder { + side: "BUY" | "SELL"; + price: number; + amount: number; + reduceOnly: boolean; +} + +export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot { + buyDepthSum10: number; + sellDepthSum10: number; + depthImbalance: "balanced" | "buy_dominant" | "sell_dominant"; + skipBuySide: boolean; + skipSellSide: boolean; +} + +type MakerEvent = "update"; +type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void; + +const EPS = 1e-5; + +export class OffsetMakerEngine { + 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 listeners = new Map>(); + + private timer: ReturnType | null = null; + private processing = false; + private desiredOrders: DesiredOrder[] = []; + private accountUnrealized = 0; + private sessionQuoteVolume = 0; + private prevPositionAmt = 0; + private initializedPosition = false; + private initialOrderSnapshotReady = false; + private initialOrderResetDone = false; + private entryPricePendingLogged = false; + + private lastBuyDepthSum10 = 0; + private lastSellDepthSum10 = 0; + private lastSkipBuy = false; + private lastSkipSell = false; + private lastImbalance: "balanced" | "buy_dominant" | "sell_dominant" = "balanced"; + + constructor(private readonly config: MakerConfig, private readonly exchange: ExchangeAdapter) { + this.tradeLog = createTradeLog(this.config.maxLogEntries); + this.bootstrap(); + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => { + void this.tick(); + }, this.config.refreshIntervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + on(event: MakerEvent, handler: MakerListener): void { + const handlers = this.listeners.get(event) ?? new Set(); + handlers.add(handler); + this.listeners.set(event, handlers); + } + + off(event: MakerEvent, handler: MakerListener): void { + const handlers = this.listeners.get(event); + if (!handlers) return; + handlers.delete(handler); + if (handlers.size === 0) { + this.listeners.delete(event); + } + } + + getSnapshot(): OffsetMakerEngineSnapshot { + return this.buildSnapshot(); + } + + private bootstrap(): void { + this.exchange.watchAccount((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.updateSessionVolume(position); + this.emitUpdate(); + }); + + this.exchange.watchOrders((orders) => { + this.syncLocksWithOrders(orders); + this.openOrders = Array.isArray(orders) + ? orders.filter((order) => order.type !== "MARKET" && order.symbol === this.config.symbol) + : []; + const currentIds = new Set(this.openOrders.map((order) => order.orderId)); + for (const id of Array.from(this.pendingCancelOrders)) { + if (!currentIds.has(id)) { + this.pendingCancelOrders.delete(id); + } + } + this.initialOrderSnapshotReady = true; + this.emitUpdate(); + }); + + this.exchange.watchDepth(this.config.symbol, (depth) => { + this.depthSnapshot = depth; + this.emitUpdate(); + }); + + this.exchange.watchTicker(this.config.symbol, (ticker) => { + this.tickerSnapshot = ticker; + this.emitUpdate(); + }); + + this.exchange.watchKlines(this.config.symbol, "1m", () => { + /* no-op */ + }); + } + + private syncLocksWithOrders(orders: AsterOrder[]): void { + Object.keys(this.pending).forEach((type) => { + const pendingId = this.pending[type]; + if (!pendingId) return; + const match = orders.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.accountSnapshot && this.depthSnapshot); + } + + private async tick(): Promise { + if (this.processing) return; + this.processing = true; + try { + if (!this.isReady()) { + this.emitUpdate(); + return; + } + if (!(await this.ensureStartupOrderReset())) { + this.emitUpdate(); + return; + } + + const depth = this.depthSnapshot!; + const bidLevel = depth.bids?.[0]; + const askLevel = depth.asks?.[0]; + const topBid = bidLevel ? Number(bidLevel[0]) : undefined; + const topAsk = askLevel ? Number(askLevel[0]) : undefined; + if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) { + this.emitUpdate(); + return; + } + + const { buySum, sellSum, skipBuySide, skipSellSide, imbalance } = this.evaluateDepth(depth); + this.lastBuyDepthSum10 = buySum; + this.lastSellDepthSum10 = sellSum; + this.lastSkipBuy = skipBuySide; + this.lastSkipSell = skipSellSide; + this.lastImbalance = imbalance; + + const position = getPosition(this.accountSnapshot, this.config.symbol); + const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum); + if (handledImbalance) { + this.emitUpdate(); + return; + } + + const bidPrice = toPrice1Decimal(topBid! - this.config.bidOffset); + const askPrice = toPrice1Decimal(topAsk! + this.config.askOffset); + const absPosition = Math.abs(position.positionAmt); + const desired: DesiredOrder[] = []; + + if (absPosition < EPS) { + this.entryPricePendingLogged = false; + if (!skipBuySide) { + desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false }); + } + if (!skipSellSide) { + desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false }); + } + } else { + const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; + const closePrice = closeSide === "SELL" ? askPrice : bidPrice; + desired.push({ side: closeSide, price: closePrice, amount: absPosition, reduceOnly: true }); + } + + this.desiredOrders = desired; + this.updateSessionVolume(position); + await this.syncOrders(desired); + await this.checkRisk(position, bidPrice, askPrice); + this.emitUpdate(); + } catch (error) { + this.tradeLog.push("error", `偏移做市循环异常: ${String(error)}`); + this.emitUpdate(); + } finally { + this.processing = false; + } + } + + 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 evaluateDepth(depth: AsterDepth): { + buySum: number; + sellSum: number; + skipBuySide: boolean; + skipSellSide: boolean; + imbalance: "balanced" | "buy_dominant" | "sell_dominant"; + } { + const topBids = (depth.bids ?? []).slice(0, 10); + const topAsks = (depth.asks ?? []).slice(0, 10); + const buySum = topBids.reduce((total, [price, qty]) => { + const size = Number(qty); + return Number.isFinite(size) ? total + size : total; + }, 0); + const sellSum = topAsks.reduce((total, [price, qty]) => { + const size = Number(qty); + return Number.isFinite(size) ? total + size : total; + }, 0); + + const skipSellSide = sellSum === 0 || sellSum * 3 < buySum; + const skipBuySide = buySum === 0 || buySum * 3 < sellSum; + let imbalance: "balanced" | "buy_dominant" | "sell_dominant" = "balanced"; + if (buySum > sellSum * 3) { + imbalance = "buy_dominant"; + } else if (sellSum > buySum * 3) { + imbalance = "sell_dominant"; + } + + return { buySum, sellSum, skipBuySide, skipSellSide, imbalance }; + } + + private async handleImbalanceExit( + position: PositionSnapshot, + buySum: number, + sellSum: number + ): Promise { + const absPosition = Math.abs(position.positionAmt); + if (absPosition < EPS) return false; + + const longExitRequired = position.positionAmt > 0 && (buySum === 0 || buySum * 5 < sellSum); + const shortExitRequired = position.positionAmt < 0 && (sellSum === 0 || sellSum * 5 < buySum); + + if (!longExitRequired && !shortExitRequired) return false; + + const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; + this.tradeLog.push( + "stop", + `深度极端不平衡(${buySum.toFixed(4)} vs ${sellSum.toFixed(4)}), 市价平仓 ${side}` + ); + try { + await this.flushOrders(); + await marketClose( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + side, + absPosition, + (type, detail) => this.tradeLog.push(type, detail) + ); + } catch (error) { + if (isUnknownOrderError(error)) { + this.tradeLog.push("order", "深度不平衡平仓时订单已不存在"); + } else { + this.tradeLog.push("error", `深度不平衡平仓失败: ${String(error)}`); + } + } + return true; + } + + private async syncOrders(targets: DesiredOrder[]): Promise { + const tolerance = this.config.priceChaseThreshold; + const unmatched = new Set(targets.map((_, idx) => idx)); + const toCancel: AsterOrder[] = []; + + for (const order of this.openOrders) { + const price = Number(order.price); + if (!Number.isFinite(price)) { + toCancel.push(order); + continue; + } + const reduceOnly = order.reduceOnly === true; + if (this.pendingCancelOrders.has(order.orderId)) { + continue; + } + const matchedIndex = targets.findIndex((target, index) => { + if (!unmatched.has(index)) return false; + if (target.side !== order.side) return false; + if (target.reduceOnly !== reduceOnly) return false; + return Math.abs(price - target.price) <= tolerance; + }); + if (matchedIndex >= 0) { + unmatched.delete(matchedIndex); + continue; + } + toCancel.push(order); + } + + for (const order of toCancel) { + this.pendingCancelOrders.add(order.orderId); + try { + await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId }); + this.tradeLog.push("order", `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`); + } catch (error) { + if (isUnknownOrderError(error)) { + this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); + this.pendingCancelOrders.delete(order.orderId); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + } else { + this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); + this.pendingCancelOrders.delete(order.orderId); + } + } + } + + for (const index of unmatched) { + const target = targets[index]; + 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 + ); + } catch (error) { + this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`); + } + } + } + + private async checkRisk(position: PositionSnapshot, bidPrice: number, askPrice: number): Promise { + const absPosition = Math.abs(position.positionAmt); + if (absPosition < EPS) return; + + const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8; + if (!hasEntryPrice) { + if (!this.entryPricePendingLogged) { + this.tradeLog.push("info", "做市持仓均价未同步,等待账户快照刷新后再执行止损判断"); + this.entryPricePendingLogged = true; + } + return; + } + this.entryPricePendingLogged = false; + + const pnl = position.positionAmt > 0 + ? (bidPrice - position.entryPrice) * absPosition + : (position.entryPrice - askPrice) * absPosition; + const unrealized = Number.isFinite(position.unrealizedProfit) + ? position.unrealizedProfit + : null; + const derivedLoss = pnl < -this.config.lossLimit; + const snapshotLoss = Boolean( + unrealized != null && + unrealized < -this.config.lossLimit && + pnl <= 0 + ); + + if (derivedLoss || snapshotLoss) { + this.tradeLog.push( + "stop", + `触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.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) + ); + } catch (error) { + if (isUnknownOrderError(error)) { + this.tradeLog.push("order", "止损平仓时订单已不存在"); + } else { + this.tradeLog.push("error", `止损平仓失败: ${String(error)}`); + } + } + } + } + + private async flushOrders(): Promise { + if (!this.openOrders.length) return; + for (const order of this.openOrders) { + if (this.pendingCancelOrders.has(order.orderId)) continue; + this.pendingCancelOrders.add(order.orderId); + try { + await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: order.orderId }); + } catch (error) { + if (isUnknownOrderError(error)) { + this.tradeLog.push("order", "订单已不存在,撤销跳过"); + this.pendingCancelOrders.delete(order.orderId); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + } else { + this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); + this.pendingCancelOrders.delete(order.orderId); + } + } + } + } + + private emitUpdate(): void { + const snapshot = this.buildSnapshot(); + const handlers = this.listeners.get("update"); + if (handlers) { + handlers.forEach((handler) => handler(snapshot)); + } + } + + private buildSnapshot(): OffsetMakerEngineSnapshot { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const bid = this.depthSnapshot?.bids?.[0]?.[0]; + const ask = this.depthSnapshot?.asks?.[0]?.[0]; + const bidNum = Number(bid); + const askNum = Number(ask); + const spread = Number.isFinite(bidNum) && Number.isFinite(askNum) ? askNum - bidNum : null; + const priceForPnl = position.positionAmt > 0 ? bidNum : askNum; + const pnl = Number.isFinite(priceForPnl) + ? (position.positionAmt > 0 + ? (priceForPnl! - position.entryPrice) * Math.abs(position.positionAmt) + : (position.entryPrice - priceForPnl!) * Math.abs(position.positionAmt)) + : 0; + + return { + ready: this.isReady(), + symbol: this.config.symbol, + topBid: Number.isFinite(bidNum) ? bidNum : null, + topAsk: Number.isFinite(askNum) ? askNum : null, + spread, + position, + pnl, + accountUnrealized: this.accountUnrealized, + sessionVolume: this.sessionQuoteVolume, + openOrders: this.openOrders, + desiredOrders: this.desiredOrders, + tradeLog: this.tradeLog.all(), + lastUpdated: Date.now(), + buyDepthSum10: this.lastBuyDepthSum10, + sellDepthSum10: this.lastSellDepthSum10, + depthImbalance: this.lastImbalance, + skipBuySide: this.lastSkipBuy, + skipSellSide: this.lastSkipSell, + }; + } + + private updateSessionVolume(position: PositionSnapshot): void { + const price = this.getReferencePrice(); + if (!this.initializedPosition) { + this.prevPositionAmt = position.positionAmt; + this.initializedPosition = true; + return; + } + if (price == null) { + this.prevPositionAmt = position.positionAmt; + return; + } + const delta = Math.abs(position.positionAmt - this.prevPositionAmt); + if (delta > 0) { + this.sessionQuoteVolume += delta * price; + } + this.prevPositionAmt = position.positionAmt; + } + + private getReferencePrice(): number | null { + const bid = Number(this.depthSnapshot?.bids?.[0]?.[0]); + const ask = Number(this.depthSnapshot?.asks?.[0]?.[0]); + if (Number.isFinite(bid) && Number.isFinite(ask)) return (bid + ask) / 2; + if (this.tickerSnapshot) { + const last = Number(this.tickerSnapshot.lastPrice); + if (Number.isFinite(last)) return last; + } + return null; + } +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index fc5eff1..b0cd784 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2,9 +2,10 @@ import React, { useState } from "react"; import { Box, Text, useInput } from "ink"; import { TrendApp } from "./TrendApp"; import { MakerApp } from "./MakerApp"; +import { OffsetMakerApp } from "./OffsetMakerApp"; interface StrategyOption { - id: "trend" | "maker"; + id: "trend" | "maker" | "offset-maker"; label: string; description: string; component: React.ComponentType<{ onExit: () => void }>; @@ -23,6 +24,12 @@ const STRATEGIES: StrategyOption[] = [ description: "双边挂单提供流动性,自动追价与风控止损", component: MakerApp, }, + { + id: "offset-maker", + label: "偏移做市策略", + description: "根据盘口深度自动偏移挂单并在极端不平衡时撤退", + component: OffsetMakerApp, + }, ]; const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); diff --git a/src/ui/OffsetMakerApp.tsx b/src/ui/OffsetMakerApp.tsx new file mode 100644 index 0000000..bd9a1d9 --- /dev/null +++ b/src/ui/OffsetMakerApp.tsx @@ -0,0 +1,195 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { makerConfig } from "../config"; +import { AsterExchangeAdapter } from "../exchanges/aster-adapter"; +import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../core/offset-maker-engine"; +import { DataTable, type TableColumn } from "./components/DataTable"; +import { formatNumber } from "../utils/format"; + +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); + + useInput( + (input, key) => { + if (key.escape) { + engineRef.current?.stop(); + onExit(); + } + }, + { isActive: inputSupported } + ); + + useEffect(() => { + const apiKey = process.env.ASTER_API_KEY; + const apiSecret = process.env.ASTER_API_SECRET; + if (!apiKey || !apiSecret) { + setError(new Error("缺少 ASTER_API_KEY 或 ASTER_API_SECRET 环境变量")); + return; + } + try { + const adapter = new AsterExchangeAdapter({ + apiKey, + apiSecret, + 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))); + } + }, []); + + if (error) { + return ( + + 启动失败: {error.message} + 请检查环境变量和网络连通性。 + + ); + } + + if (!snapshot) { + return ( + + 正在初始化偏移做市策略… + + ); + } + + const topBid = snapshot.topBid; + const topAsk = snapshot.topAsk; + const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} 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" + ? "均衡" + : snapshot.depthImbalance === "buy_dominant" + ? "买盘占优" + : "卖盘占优"; + + return ( + + + Offset Maker Strategy Dashboard + + 交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, 2)} | 卖一价: {formatNumber(topAsk, 2)} | 点差: {spreadDisplay} + + + 买10档累计: {formatNumber(snapshot.buyDepthSum10, 4)} | 卖10档累计: {formatNumber(snapshot.sellDepthSum10, 4)} | 状态: {imbalanceLabel} + + + 当前挂单策略: BUY {snapshot.skipBuySide ? "暂停" : "启用"} | SELL {snapshot.skipSellSide ? "暂停" : "启用"} | 按 Esc 返回策略选择 + + 状态: {snapshot.ready ? "实时运行" : "等待市场数据"} + + + + + 持仓 + {hasPosition ? ( + <> + + 方向: {snapshot.position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} | 开仓价: {formatNumber(snapshot.position.entryPrice, 2)} + + + 浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT | 账户未实现盈亏: {formatNumber(snapshot.accountUnrealized, 4)} USDT + + + ) : ( + 当前无持仓 + )} + + + 目标挂单 + {desiredRows.length > 0 ? ( + + ) : ( + 暂无目标挂单 + )} + + 累计成交量: {formatNumber(snapshot.sessionVolume, 2)} USDT + + + + + + 当前挂单 + {openOrderRows.length > 0 ? ( + + ) : ( + 暂无挂单 + )} + + + + 最近事件 + {lastLogs.length > 0 ? ( + lastLogs.map((item, index) => ( + + [{item.time}] [{item.type}] {item.detail} + + )) + ) : ( + 暂无日志 + )} + + + ); +} +