Merge pull request #16 from discountry/feat/mm

Feat/mm
This commit is contained in:
Disney
2026-01-06 17:50:38 +08:00
committed by GitHub
11 changed files with 1388 additions and 13 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"start:offset:silent": "bun run index.ts --strategy offset-maker --silent",
"pm2:start:trend": "pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent",
"pm2:start:maker": "pm2 start bun --name ritmex-maker --cwd . --restart-delay 5000 -- run index.ts --strategy maker --silent",
"pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent"
"pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent",
"pm2:start:maker-points": "pm2 start bun --name ritmex-maker-points --cwd . --restart-delay 5000 -- run index.ts --strategy maker-points --exchange standx --silent"
},
"devDependencies": {
"@types/bun": "latest",
+5 -2
View File
@@ -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` +
+21 -1
View File
@@ -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
+35
View File
@@ -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;
+22
View File
@@ -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,23 @@ 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.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..." },
+177
View File
@@ -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;
}
}
}
+837
View File
@@ -0,0 +1,837 @@
import type { MakerPointsConfig } from "../config";
import type { ExchangeAdapter } from "../exchanges/adapter";
import type {
AsterAccountSnapshot,
AsterDepth,
AsterOrder,
AsterTicker,
} from "../exchanges/types";
import { formatPriceToString } from "../utils/math";
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
import { extractMessage, isInsufficientBalanceError, isRateLimitError, isUnknownOrderError } from "../utils/errors";
import { isOrderActiveStatus } from "../utils/order-status";
import { getPosition, parseSymbolParts } from "../utils/strategy";
import type { PositionSnapshot } from "../utils/strategy";
import { computePositionPnl } from "../utils/pnl";
import { getMidOrLast, getTopPrices } from "../utils/price";
import {
marketClose,
placeOrder,
unlockOperating,
} from "../core/order-coordinator";
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
import { makeOrderPlan } from "../core/lib/order-plan";
import { safeCancelOrder } from "../core/lib/orders";
import { RateLimitController } from "../core/lib/rate-limit";
import { StrategyEventEmitter } from "./common/event-emitter";
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
import { SessionVolumeTracker } from "./common/session-volume";
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
import { buildBpsTargets } from "./maker-points-logic";
import { t } from "../i18n";
interface DesiredOrder {
side: "BUY" | "SELL";
price: string;
amount: number;
reduceOnly: boolean;
}
export interface MakerPointsSnapshot {
ready: boolean;
symbol: string;
topBid: number | null;
topAsk: number | null;
spread: number | null;
priceDecimals: number;
position: PositionSnapshot;
pnl: number;
accountUnrealized: number;
sessionVolume: number;
openOrders: AsterOrder[];
desiredOrders: DesiredOrder[];
tradeLog: TradeLogEntry[];
lastUpdated: number | null;
feedStatus: {
account: boolean;
orders: boolean;
depth: boolean;
ticker: boolean;
binance: boolean;
};
binanceDepth: BinanceDepthSnapshot | null;
quoteStatus: {
closeOnly: boolean;
skipBuy: boolean;
skipSell: boolean;
};
}
type MakerPointsEvent = "update";
type MakerPointsListener = (snapshot: MakerPointsSnapshot) => void;
const EPS = 1e-5;
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
const STOP_LOSS_COOLDOWN_MS = 10_000;
export class MakerPointsEngine {
private accountSnapshot: AsterAccountSnapshot | null = null;
private depthSnapshot: AsterDepth | null = null;
private tickerSnapshot: AsterTicker | null = null;
private openOrders: AsterOrder[] = [];
private readonly locks: OrderLockMap = {};
private readonly timers: OrderTimerMap = {};
private readonly pending: OrderPendingMap = {};
private readonly pendingCancelOrders = new Set<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 lastDesiredSummary: string | null = null;
private lastCloseOnly = false;
private lastSkipBuy = false;
private lastSkipSell = false;
private lastQuoteBid1: number | null = null;
private lastQuoteAsk1: number | null = null;
private readinessLogged = {
account: false,
depth: false,
ticker: false,
orders: false,
};
private feedStatus = {
account: false,
depth: false,
ticker: false,
orders: false,
binance: false,
};
private insufficientBalanceCooldownUntil = 0;
private insufficientBalanceNotified = false;
private lastInsufficientMessage: string | null = null;
constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) {
this.tradeLog = createTradeLog(this.config.maxLogEntries);
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
this.tradeLog.push(type, detail)
);
this.priceTick = Math.max(1e-9, this.config.priceTick);
this.qtyStep = Math.max(1e-9, this.config.qtyStep);
this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), {
baseUrl: process.env.BINANCE_WS_URL,
levels: 10,
ratio: 3,
logger: (context, error) => {
this.tradeLog.push("warn", `Binance ${context} 异常: ${extractMessage(error)}`);
},
});
this.binanceDepth.onUpdate(() => {
this.feedStatus.binance = true;
this.emitUpdate();
});
this.syncPrecision();
this.bootstrap();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.tick();
}, this.config.refreshIntervalMs);
if (!this.stopLossTimer) {
this.stopLossTimer = setInterval(() => {
void this.checkStopLoss();
}, Math.max(500, this.config.refreshIntervalMs));
}
this.binanceDepth.start();
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.stopLossTimer) {
clearInterval(this.stopLossTimer);
this.stopLossTimer = null;
}
this.binanceDepth.stop();
}
on(event: MakerPointsEvent, handler: MakerPointsListener): void {
this.events.on(event, handler);
}
off(event: MakerPointsEvent, handler: MakerPointsListener): void {
this.events.off(event, handler);
}
getSnapshot(): MakerPointsSnapshot {
return this.buildSnapshot();
}
private bootstrap(): void {
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
safeSubscribe<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 binanceSnapshot = this.binanceDepth.getSnapshot();
const rawSkipBuy = Boolean(binanceSnapshot?.skipBuySide);
const rawSkipSell = Boolean(binanceSnapshot?.skipSellSide);
const skipBuy = closeOnly ? false : rawSkipBuy;
const skipSell = closeOnly ? false : rawSkipSell;
const prevSkipBuy = this.lastSkipBuy;
const prevSkipSell = this.lastSkipSell;
if (skipBuy !== prevSkipBuy || skipSell !== prevSkipSell) {
if (skipBuy || skipSell) {
const summary = `${skipBuy ? "BUY" : ""}${skipBuy && skipSell ? "/" : ""}${skipSell ? "SELL" : ""}`;
this.tradeLog.push("info", `Binance 深度失衡,暂停 ${summary} 挂单`);
} else {
this.tradeLog.push("info", "Binance 深度恢复,继续挂单");
}
this.lastSkipBuy = skipBuy;
this.lastSkipSell = skipSell;
}
const closeOnlyChanged = closeOnly !== prevCloseOnly;
const skipChanged = skipBuy !== prevSkipBuy || skipSell !== prevSkipSell;
const repriceNeeded = closeOnly ? true : this.shouldReprice(topBid, topAsk);
const shouldRecompute =
closeOnly ||
repriceNeeded ||
closeOnlyChanged ||
skipChanged ||
this.desiredOrders.length === 0;
const desired = shouldRecompute
? closeOnly
? this.buildCloseOnlyOrders(position, topBid, topAsk)
: this.buildDesiredOrders({
bid1: topBid,
ask1: topAsk,
skipBuy,
skipSell,
})
: this.desiredOrders;
if (shouldRecompute) {
if (closeOnly) {
this.lastQuoteBid1 = null;
this.lastQuoteAsk1 = null;
} else {
this.lastQuoteBid1 = topBid;
this.lastQuoteAsk1 = topAsk;
}
}
this.desiredOrders = desired;
this.logDesiredOrders(desired);
this.sessionVolume.update(position, this.getReferencePrice());
await this.syncOrders(desired, closeOnly);
this.emitUpdate();
} catch (error) {
if (isRateLimitError(error)) {
hadRateLimit = true;
this.rateLimit.registerRateLimit("maker-points");
this.tradeLog.push("warn", `限频触发,暂停挂单: ${extractMessage(error)}`);
} else {
this.tradeLog.push("error", `MakerPoints 主循环异常: ${extractMessage(error)}`);
}
this.emitUpdate();
} finally {
this.rateLimit.onCycleComplete(hadRateLimit);
this.processing = false;
}
}
private buildDesiredOrders(params: {
bid1: number;
ask1: number;
skipBuy: boolean;
skipSell: boolean;
}): DesiredOrder[] {
const { bid1, ask1, skipBuy, skipSell } = params;
const amount = Number(this.config.perOrderAmount);
if (!Number.isFinite(amount) || amount <= 0) return [];
const targets = buildBpsTargets({
band0To10: this.config.enableBand0To10,
band10To30: this.config.enableBand10To30,
band30To100: this.config.enableBand30To100,
}).sort((a, b) => b - a);
if (!targets.length) return [];
const priceDecimals = this.getPriceDecimals();
const desired: DesiredOrder[] = [];
for (const bps of targets) {
if (!skipBuy) {
const price = bid1 * (1 - bps / 10000);
if (Number.isFinite(price) && price > 0) {
desired.push({
side: "BUY",
price: formatPriceToString(price, priceDecimals),
amount,
reduceOnly: false,
});
}
}
if (!skipSell) {
const price = ask1 * (1 + bps / 10000);
if (Number.isFinite(price) && price > 0) {
desired.push({
side: "SELL",
price: formatPriceToString(price, priceDecimals),
amount,
reduceOnly: false,
});
}
}
}
return desired;
}
private buildCloseOnlyOrders(
position: PositionSnapshot,
bid1: number,
ask1: number
): DesiredOrder[] {
const absPosition = Math.abs(position.positionAmt);
if (absPosition < EPS) return [];
const priceDecimals = this.getPriceDecimals();
if (position.positionAmt > 0) {
return [
{
side: "SELL",
price: formatPriceToString(bid1, priceDecimals),
amount: absPosition,
reduceOnly: true,
},
];
}
return [
{
side: "BUY",
price: formatPriceToString(ask1, priceDecimals),
amount: absPosition,
reduceOnly: true,
},
];
}
private shouldReprice(bid1: number, ask1: number): boolean {
const threshold = Number(this.config.minRepriceBps);
if (!Number.isFinite(threshold) || threshold <= 0) return true;
if (!Number.isFinite(bid1) || !Number.isFinite(ask1)) return false;
if (!Number.isFinite(this.lastQuoteBid1 ?? NaN) || !Number.isFinite(this.lastQuoteAsk1 ?? NaN)) {
return true;
}
if ((this.lastQuoteBid1 ?? 0) <= 0 || (this.lastQuoteAsk1 ?? 0) <= 0) return true;
const bidMove = Math.abs(bid1 - (this.lastQuoteBid1 ?? bid1)) / (this.lastQuoteBid1 ?? bid1) * 10000;
const askMove = Math.abs(ask1 - (this.lastQuoteAsk1 ?? ask1)) / (this.lastQuoteAsk1 ?? ask1) * 10000;
return bidMove >= threshold || askMove >= threshold;
}
private async ensureStartupOrderReset(): Promise<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[], 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,
undefined,
{
priceTick: this.priceTick,
qtyStep: this.qtyStep,
skipDedupe: true,
}
);
} catch (error) {
if (isInsufficientBalanceError(error)) {
this.registerInsufficientBalance(error);
break;
}
this.tradeLog.push(
"error",
`挂单失败 ${target.side} @ ${target.price}: ${extractMessage(error)}`
);
}
}
}
private async checkStopLoss(): Promise<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 pnl = computePositionPnl(position, topBid, topAsk);
return {
ready: this.isReady(),
symbol: this.config.symbol,
topBid,
topAsk,
spread,
priceDecimals: this.getPriceDecimals(),
position,
pnl,
accountUnrealized: this.accountUnrealized,
sessionVolume: this.sessionVolume.value,
openOrders: this.openOrders,
desiredOrders: this.desiredOrders,
tradeLog: this.tradeLog.all(),
lastUpdated: Date.now(),
feedStatus: { ...this.feedStatus },
binanceDepth: this.binanceDepth.getSnapshot(),
quoteStatus: {
closeOnly: this.lastCloseOnly,
skipBuy: this.lastSkipBuy,
skipSell: this.lastSkipSell,
},
};
}
private getReferencePrice(): number | null {
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
}
private logReadinessBlockers(): void {
if (!this.feedStatus.account && !this.readinessLogged.account) {
this.tradeLog.push("info", t("log.maker.waitAccount"));
this.readinessLogged.account = true;
}
if (!this.feedStatus.depth && !this.readinessLogged.depth) {
this.tradeLog.push("info", t("log.maker.waitDepth"));
this.readinessLogged.depth = true;
}
if (!this.feedStatus.ticker && !this.readinessLogged.ticker) {
this.tradeLog.push("info", t("log.maker.waitTicker"));
this.readinessLogged.ticker = true;
}
if (!this.feedStatus.orders && !this.readinessLogged.orders) {
this.tradeLog.push("info", t("log.maker.waitOrders"));
this.readinessLogged.orders = true;
}
}
private resetReadinessFlags(): void {
this.readinessLogged = {
account: false,
depth: false,
ticker: false,
orders: false,
};
}
private logDesiredOrders(desired: DesiredOrder[]): void {
if (!desired.length) {
if (this.lastDesiredSummary !== "none") {
this.tradeLog.push("info", "暂无目标挂单");
this.lastDesiredSummary = "none";
}
return;
}
const summary = desired
.map((order) => `${order.side}@${order.price}${order.reduceOnly ? "(RO)" : ""}`)
.join(" | ");
if (summary !== this.lastDesiredSummary) {
this.tradeLog.push("info", `目标挂单: ${summary}`);
this.lastDesiredSummary = summary;
}
}
private registerInsufficientBalance(error: unknown): void {
const now = Date.now();
const detail = extractMessage(error);
const alreadyActive = now < this.insufficientBalanceCooldownUntil;
if (alreadyActive && detail === this.lastInsufficientMessage) {
this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS;
return;
}
this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS;
this.lastInsufficientMessage = detail;
const seconds = Math.ceil(INSUFFICIENT_BALANCE_COOLDOWN_MS / 1000);
this.tradeLog.push("warn", `余额不足,暂停挂单 ${seconds}s: ${detail}`);
this.insufficientBalanceNotified = true;
}
private applyInsufficientBalanceState(now: number): boolean {
const active = now < this.insufficientBalanceCooldownUntil;
if (!active && this.insufficientBalanceNotified) {
this.tradeLog.push("info", "余额恢复,继续挂单");
this.insufficientBalanceNotified = false;
this.lastInsufficientMessage = null;
}
return active;
}
}
function resolveBinanceSymbol(symbol: string): string {
const parts = parseSymbolParts(symbol);
const base = (parts.base ?? symbol).replace(/[^a-zA-Z0-9]/g, "").toUpperCase();
return base ? `${base}USDT` : "BTCUSDT";
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildBpsTargets } from "./maker-points-logic";
describe("maker points target builder", () => {
it("builds fixed bps targets per enabled band", () => {
const targets = buildBpsTargets({
band0To10: true,
band10To30: true,
band30To100: true,
});
expect(targets).toEqual([9, 29, 99]);
});
it("skips disabled bands", () => {
const targets = buildBpsTargets({
band0To10: true,
band10To30: false,
band30To100: true,
});
expect(targets).toEqual([9, 99]);
});
});
+13
View File
@@ -0,0 +1,13 @@
export interface MakerPointsBandConfig {
band0To10: boolean;
band10To30: boolean;
band30To100: boolean;
}
export function buildBpsTargets(config: MakerPointsBandConfig): number[] {
const targets: number[] = [];
if (config.band0To10) targets.push(9);
if (config.band10To30) targets.push(29);
if (config.band30To100) targets.push(99);
return targets.sort((a, b) => a - b);
}
+16 -9
View File
@@ -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) => {
+238
View File
@@ -0,0 +1,238 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useInput } from "ink";
import { makerPointsConfig } from "../config";
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
import { DataTable, type TableColumn } from "./components/DataTable";
import { formatNumber } from "../utils/format";
import { t } from "../i18n";
interface MakerPointsAppProps {
onExit: () => void;
}
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
const [snapshot, setSnapshot] = useState<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 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 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>
);
}