mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
Add Maker Points strategy support in StandX. Introduce new configuration for Maker Points, including point bands and order management logic. Implement MakerPointsEngine for handling order placement and tracking. Update CLI and UI components to integrate Maker Points functionality, enhancing user experience and strategy options.
This commit is contained in:
+5
-2
@@ -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<StrategyId>([
|
||||
"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 <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--silent]\n\n` +
|
||||
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|maker-points|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--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` +
|
||||
|
||||
@@ -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<StrategyId, string> = {
|
||||
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<StrategyId, StrategyRunner> = {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,11 @@ const translations: Record<string, TranslationEntry> = {
|
||||
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,27 @@ const translations: Record<string, TranslationEntry> = {
|
||||
},
|
||||
"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.markLine": {
|
||||
zh: "标记价: {mark} | 偏离: {bps} bps | 阻断: {block} bps",
|
||||
en: "Mark: {mark} | Dislocation: {bps} bps | Block: {block} bps",
|
||||
},
|
||||
"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..." },
|
||||
|
||||
@@ -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<typeof setTimeout> | 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
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 { 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, computeDislocationBps } 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;
|
||||
markPrice: number | null;
|
||||
dislocationBps: number | null;
|
||||
blockedBps: number;
|
||||
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 DISLOCATION_THRESHOLD_BPS = 1;
|
||||
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<string>();
|
||||
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly events = new StrategyEventEmitter<MakerPointsEvent, MakerPointsSnapshot>();
|
||||
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<void> | null = null;
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private stopLossTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
private stopLossProcessing = false;
|
||||
private stopLossCooldownUntil = 0;
|
||||
private desiredOrders: DesiredOrder[] = [];
|
||||
private accountUnrealized = 0;
|
||||
private initialOrderSnapshotReady = false;
|
||||
private initialOrderResetDone = false;
|
||||
private entryPricePendingLogged = false;
|
||||
private lastDesiredSummary: string | null = null;
|
||||
private lastDislocationBlock = 0;
|
||||
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<AsterAccountSnapshot>(
|
||||
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<AsterOrder[]>(
|
||||
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<AsterDepth>(
|
||||
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<AsterTicker>(
|
||||
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<void> {
|
||||
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 markPrice = this.getMarkPrice();
|
||||
const hasMarkPrice = Number.isFinite(markPrice) && (markPrice ?? 0) > 0;
|
||||
if (!hasMarkPrice && !closeOnly) {
|
||||
if (!this.entryPricePendingLogged) {
|
||||
this.tradeLog.push("info", "等待标记价格推送…");
|
||||
this.entryPricePendingLogged = true;
|
||||
}
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
if (hasMarkPrice) {
|
||||
this.entryPricePendingLogged = false;
|
||||
}
|
||||
|
||||
const resolvedMarkPrice = hasMarkPrice ? Number(markPrice) : 0;
|
||||
const dislocationBps = hasMarkPrice ? computeDislocationBps(resolvedMarkPrice, topBid, topAsk) : null;
|
||||
const blockBps =
|
||||
dislocationBps != null && dislocationBps > DISLOCATION_THRESHOLD_BPS
|
||||
? Math.floor(dislocationBps + 1e-9)
|
||||
: 0;
|
||||
const prevBlockBps = this.lastDislocationBlock;
|
||||
if (blockBps !== prevBlockBps) {
|
||||
if (blockBps > 0) {
|
||||
this.tradeLog.push("warn", `标记价偏离盘口 ${blockBps} bps,撤销该范围挂单`);
|
||||
} else if (prevBlockBps > 0) {
|
||||
this.tradeLog.push("info", "标记价偏离已恢复,恢复挂单");
|
||||
}
|
||||
this.lastDislocationBlock = blockBps;
|
||||
}
|
||||
|
||||
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 blockChanged = blockBps !== prevBlockBps;
|
||||
const closeOnlyChanged = closeOnly !== prevCloseOnly;
|
||||
const skipChanged = skipBuy !== prevSkipBuy || skipSell !== prevSkipSell;
|
||||
const repriceNeeded = closeOnly ? true : this.shouldReprice(topBid, topAsk);
|
||||
const shouldRecompute =
|
||||
closeOnly ||
|
||||
repriceNeeded ||
|
||||
blockChanged ||
|
||||
closeOnlyChanged ||
|
||||
skipChanged ||
|
||||
this.desiredOrders.length === 0;
|
||||
|
||||
const desired = shouldRecompute
|
||||
? closeOnly
|
||||
? this.buildCloseOnlyOrders(position, topBid, topAsk)
|
||||
: this.buildDesiredOrders({
|
||||
bid1: topBid,
|
||||
ask1: topAsk,
|
||||
markPrice: resolvedMarkPrice,
|
||||
blockBps,
|
||||
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, resolvedMarkPrice, 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;
|
||||
markPrice: number;
|
||||
blockBps: number;
|
||||
skipBuy: boolean;
|
||||
skipSell: boolean;
|
||||
}): DesiredOrder[] {
|
||||
const { bid1, ask1, markPrice, blockBps, 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);
|
||||
const distanceBps = (markPrice - price) / markPrice * 10000;
|
||||
if (
|
||||
Number.isFinite(price) &&
|
||||
price > 0 &&
|
||||
(!Number.isFinite(distanceBps) || distanceBps > blockBps)
|
||||
) {
|
||||
desired.push({
|
||||
side: "BUY",
|
||||
price: formatPriceToString(price, priceDecimals),
|
||||
amount,
|
||||
reduceOnly: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!skipSell) {
|
||||
const price = ask1 * (1 + bps / 10000);
|
||||
const distanceBps = (price - markPrice) / markPrice * 10000;
|
||||
if (
|
||||
Number.isFinite(price) &&
|
||||
price > 0 &&
|
||||
(!Number.isFinite(distanceBps) || distanceBps > blockBps)
|
||||
) {
|
||||
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<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 async syncOrders(targets: DesiredOrder[], markPrice: number, closeOnly: boolean): Promise<void> {
|
||||
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,
|
||||
closeOnly
|
||||
? undefined
|
||||
: {
|
||||
markPrice,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{
|
||||
priceTick: this.priceTick,
|
||||
qtyStep: this.qtyStep,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (isInsufficientBalanceError(error)) {
|
||||
this.registerInsufficientBalance(error);
|
||||
break;
|
||||
}
|
||||
this.tradeLog.push(
|
||||
"error",
|
||||
`挂单失败 ${target.side} @ ${target.price}: ${extractMessage(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkStopLoss(): Promise<void> {
|
||||
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<void> {
|
||||
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 markPrice = this.getMarkPrice();
|
||||
const dislocationBps = computeDislocationBps(markPrice, topBid, topAsk);
|
||||
const blockBps =
|
||||
dislocationBps != null && dislocationBps > DISLOCATION_THRESHOLD_BPS
|
||||
? Math.floor(dislocationBps + 1e-9)
|
||||
: 0;
|
||||
const pnl = computePositionPnl(position, topBid, topAsk);
|
||||
|
||||
return {
|
||||
ready: this.isReady(),
|
||||
symbol: this.config.symbol,
|
||||
topBid,
|
||||
topAsk,
|
||||
spread,
|
||||
markPrice,
|
||||
dislocationBps,
|
||||
blockedBps: blockBps,
|
||||
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 {
|
||||
const mark = Number(this.tickerSnapshot?.markPrice);
|
||||
if (Number.isFinite(mark) && mark > 0) return mark;
|
||||
const last = Number(this.tickerSnapshot?.lastPrice);
|
||||
return Number.isFinite(last) && last > 0 ? last : null;
|
||||
}
|
||||
|
||||
private getMarkPrice(): number | null {
|
||||
const mark = Number(this.tickerSnapshot?.markPrice);
|
||||
if (Number.isFinite(mark) && mark > 0) return mark;
|
||||
const positionMark = Number(getPosition(this.accountSnapshot, this.config.symbol).markPrice);
|
||||
if (Number.isFinite(positionMark) && positionMark > 0) return positionMark;
|
||||
const last = Number(this.tickerSnapshot?.lastPrice);
|
||||
return Number.isFinite(last) && last > 0 ? last : null;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBpsTargets, computeDislocationBps } 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]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maker points dislocation", () => {
|
||||
it("computes max bps dislocation from mark vs bid/ask", () => {
|
||||
const bps = computeDislocationBps(100, 99.97, 100.02);
|
||||
expect(bps).not.toBeNull();
|
||||
expect(Number(bps?.toFixed(2))).toBe(3.0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
}
|
||||
|
||||
export function computeDislocationBps(
|
||||
markPrice: number | null | undefined,
|
||||
bestBid: number | null | undefined,
|
||||
bestAsk: number | null | undefined
|
||||
): number | null {
|
||||
const mark = Number(markPrice);
|
||||
if (!Number.isFinite(mark) || mark <= 0) return null;
|
||||
const distances: number[] = [];
|
||||
const bid = Number(bestBid);
|
||||
const ask = Number(bestAsk);
|
||||
if (Number.isFinite(bid)) {
|
||||
distances.push(Math.abs(mark - bid) / mark * 10000);
|
||||
}
|
||||
if (Number.isFinite(ask)) {
|
||||
distances.push(Math.abs(ask - mark) / mark * 10000);
|
||||
}
|
||||
if (!distances.length) return null;
|
||||
const max = Math.max(...distances);
|
||||
return Number.isFinite(max) ? max : null;
|
||||
}
|
||||
+16
-9
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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<MakerPointsSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<MakerPointsEngine | null>(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 (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">{t("common.startFailed", { message: error.message })}</Text>
|
||||
<Text color="gray">{t("common.checkEnv")}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>{t("makerPoints.initializing")}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 markDisplay = Number.isFinite(snapshot.markPrice) ? formatNumber(snapshot.markPrice, priceDigits) : "-";
|
||||
const dislocationDisplay =
|
||||
snapshot.dislocationBps != null ? formatNumber(snapshot.dislocationBps, 2) : "-";
|
||||
const blockDisplay = snapshot.blockedBps > 0 ? String(snapshot.blockedBps) : "-";
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">{t("makerPoints.title")}</Text>
|
||||
<Text>
|
||||
{t("makerPoints.headerLine", {
|
||||
exchange: exchangeName,
|
||||
symbol: snapshot.symbol,
|
||||
bid: formatNumber(topBid, priceDigits),
|
||||
ask: formatNumber(topAsk, priceDigits),
|
||||
spread: spreadDisplay,
|
||||
})}
|
||||
</Text>
|
||||
<Text>
|
||||
{t("makerPoints.markLine", {
|
||||
mark: markDisplay,
|
||||
bps: dislocationDisplay,
|
||||
block: blockDisplay,
|
||||
})}
|
||||
</Text>
|
||||
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
|
||||
<Text>
|
||||
{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"),
|
||||
})}
|
||||
</Text>
|
||||
<Text>
|
||||
{t("makerPoints.binanceLine", {
|
||||
buy: formatNumber(snapshot.binanceDepth?.buySum ?? 0, 4),
|
||||
sell: formatNumber(snapshot.binanceDepth?.sellSum ?? 0, 4),
|
||||
status: imbalanceLabel,
|
||||
})}
|
||||
</Text>
|
||||
<Text>
|
||||
{t("maker.dataStatus")}
|
||||
{feedEntries.map((entry, index) => (
|
||||
<Text key={entry.key} color={feedStatus[entry.key] ? "green" : "red"}>
|
||||
{index === 0 ? " " : " "}
|
||||
{entry.label}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="greenBright">{t("common.section.position")}</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
{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),
|
||||
})}
|
||||
</Text>
|
||||
<Text>
|
||||
{t("maker.pnlLine", {
|
||||
pnl: formatNumber(snapshot.pnl, 4),
|
||||
accountPnl: formatNumber(snapshot.accountUnrealized, 4),
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">{t("common.noPosition")}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="greenBright">{t("maker.targetOrders")}</Text>
|
||||
{desiredRows.length > 0 ? (
|
||||
<DataTable columns={desiredColumns} rows={desiredRows} />
|
||||
) : (
|
||||
<Text color="gray">{t("maker.noTargetOrders")}</Text>
|
||||
)}
|
||||
<Text>
|
||||
{t("trend.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">{t("common.section.orders")}</Text>
|
||||
{openOrderRows.length > 0 ? (
|
||||
<DataTable columns={openOrderColumns} rows={openOrderRows} />
|
||||
) : (
|
||||
<Text color="gray">{t("common.noOrders")}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">{t("common.section.recent")}</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">{t("common.noLogs")}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user