mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
新增 OffsetMakerEngine 和 OffsetMakerApp,支持根据盘口深度自动偏移挂单并在极端不平衡时撤退;更新 MakerEngine,优化撤单逻辑以清理已成交的挂单。
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<number>();
|
||||
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly listeners = new Map<MakerEvent, Set<MakerListener>>();
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | 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<MakerListener>();
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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);
|
||||
|
||||
@@ -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<OffsetMakerEngineSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<OffsetMakerEngine | null>(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 (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">启动失败: {error.message}</Text>
|
||||
<Text color="gray">请检查环境变量和网络连通性。</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>正在初始化偏移做市策略…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Offset Maker Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, 2)} | 卖一价: {formatNumber(topAsk, 2)} | 点差: {spreadDisplay}
|
||||
</Text>
|
||||
<Text>
|
||||
买10档累计: {formatNumber(snapshot.buyDepthSum10, 4)} | 卖10档累计: {formatNumber(snapshot.sellDepthSum10, 4)} | 状态: {imbalanceLabel}
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
当前挂单策略: BUY {snapshot.skipBuySide ? "暂停" : "启用"} | SELL {snapshot.skipSellSide ? "暂停" : "启用"} | 按 Esc 返回策略选择
|
||||
</Text>
|
||||
<Text color="gray">状态: {snapshot.ready ? "实时运行" : "等待市场数据"}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="greenBright">持仓</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
方向: {snapshot.position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} | 开仓价: {formatNumber(snapshot.position.entryPrice, 2)}
|
||||
</Text>
|
||||
<Text>
|
||||
浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT | 账户未实现盈亏: {formatNumber(snapshot.accountUnrealized, 4)} USDT
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">当前无持仓</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="greenBright">目标挂单</Text>
|
||||
{desiredRows.length > 0 ? (
|
||||
<DataTable columns={desiredColumns} rows={desiredRows} />
|
||||
) : (
|
||||
<Text color="gray">暂无目标挂单</Text>
|
||||
)}
|
||||
<Text>
|
||||
累计成交量: {formatNumber(snapshot.sessionVolume, 2)} USDT
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">当前挂单</Text>
|
||||
{openOrderRows.length > 0 ? (
|
||||
<DataTable columns={openOrderColumns} rows={openOrderRows} />
|
||||
) : (
|
||||
<Text color="gray">暂无挂单</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">最近事件</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">暂无日志</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user