mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 16:28:06 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
499ee692da | ||
|
|
de13142950 | ||
|
|
597e41f053 | ||
|
|
b0a33a58d1 | ||
|
|
65b9f21981 | ||
|
|
33b5407245 | ||
|
|
7aafc3b69d |
@@ -111,11 +111,25 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
|
||||
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
|
||||
|
||||
### StandX
|
||||
|
||||
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
|
||||
|
||||
策略需要 StandX 的登录 token 才能下单
|
||||
|
||||
获取方式:
|
||||
1. 打开 https://standx.ritmex.one/
|
||||
2. 连接钱包
|
||||
3. 点击“登录”
|
||||
4. 导出登录信息里面会包含 token和代理钱包私钥,代理钱包仅用于交易签名,有效保证资产钱包安全
|
||||
|
||||
请妥善保存,不要分享给他人
|
||||
|
||||
1. 设置 `EXCHANGE=standx`。
|
||||
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
||||
3. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
||||
4. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
||||
5. 可选:如需请求签名,补充 `STANDX_REQUEST_PRIVATE_KEY`。
|
||||
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(代理钱包私钥)。
|
||||
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
||||
5. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
||||
|
||||
|
||||
### GRVT
|
||||
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# StandX 做市积分策略使用教程(新手版)
|
||||
|
||||
本教程用于帮助你快速上手 StandX 做市积分策略(Maker Points)。无需交易经验,照着步骤做即可。
|
||||
|
||||
---
|
||||
|
||||
## 1. 准备工作
|
||||
|
||||
### 1.1 安装 Bun
|
||||
本项目使用 Bun 运行。
|
||||
|
||||
如果你还没有安装 Bun,请先参考官方文档完成安装:
|
||||
- https://bun.sh/
|
||||
|
||||
安装完成后,进入项目目录执行:
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
### 1.2 获取 StandX 登录凭证(Token)
|
||||
策略需要 StandX 的登录 token 才能下单。
|
||||
|
||||
获取方式:
|
||||
1. 打开 https://standx.ritmex.one/
|
||||
2. 连接钱包
|
||||
3. 点击“登录”
|
||||
4. 导出登录信息(里面会包含 token)
|
||||
|
||||
请妥善保存,不要分享给他人。
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置环境变量
|
||||
|
||||
在项目根目录新建 `.env` 文件(或修改已有 `.env`),填入以下配置:
|
||||
|
||||
```bash
|
||||
EXCHANGE=standx
|
||||
STANDX_REQUEST_PRIVATE_KEY=你的代理钱包私钥
|
||||
STANDX_TOKEN=你的token
|
||||
STANDX_SYMBOL=BTC-USD
|
||||
|
||||
MAKER_POINTS_ORDER_AMOUNT=0.01
|
||||
MAKER_POINTS_CLOSE_THRESHOLD=0.1
|
||||
MAKER_POINTS_STOP_LOSS_USD=0
|
||||
MAKER_POINTS_MIN_REPRICE_BPS=3
|
||||
|
||||
MAKER_POINTS_BAND_0_10=true
|
||||
MAKER_POINTS_BAND_10_30=true
|
||||
MAKER_POINTS_BAND_30_100=true
|
||||
```
|
||||
|
||||
### 配置说明(新手可直接照抄)
|
||||
- `STANDX_TOKEN`: 登录后导出的 token(必须)
|
||||
- `STANDX_SYMBOL`: 交易对(默认 `BTC-USD`)
|
||||
- `MAKER_POINTS_ORDER_AMOUNT`: 每一笔挂单数量
|
||||
- `MAKER_POINTS_CLOSE_THRESHOLD`: 持仓达到该数值进入平仓模式(0 表示不自动平仓)
|
||||
- `MAKER_POINTS_STOP_LOSS_USD`: 亏损超过该值时市价平仓(0 表示关闭)
|
||||
- `MAKER_POINTS_MIN_REPRICE_BPS`: 盘口变动达到多少 bps 才会重算并撤单(默认 3)
|
||||
- `MAKER_POINTS_BAND_0_10` / `10_30` / `30_100`: 三个档位开关
|
||||
|
||||
---
|
||||
|
||||
## 3. 启动策略
|
||||
|
||||
### 3.1 普通启动
|
||||
```bash
|
||||
bun run index.ts --strategy maker-points --exchange standx
|
||||
```
|
||||
|
||||
### 3.2 使用 PM2 后台运行(推荐)
|
||||
```bash
|
||||
bun run pm2:start:maker-points
|
||||
```
|
||||
|
||||
PM2 会自动重启策略,适合长期运行。
|
||||
|
||||
---
|
||||
|
||||
## 4. 运行后你会看到什么
|
||||
|
||||
启动后会显示仪表盘,包含:
|
||||
- 当前交易对与盘口
|
||||
- 当前仓位与浮动盈亏
|
||||
- 目标挂单列表
|
||||
- 已挂单列表
|
||||
- Binance 深度失衡状态
|
||||
|
||||
---
|
||||
|
||||
## 5. 新手常见问题
|
||||
|
||||
### Q1:为什么会撤单重挂?
|
||||
策略只有在盘口变化超过 `MAKER_POINTS_MIN_REPRICE_BPS` 或 Binance 深度状态变化时才会重算挂单。
|
||||
|
||||
### Q2:担心平掉手动持仓怎么办?
|
||||
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 0 或者设成大于你的持仓即可。
|
||||
|
||||
### Q3:我只想挂某个档位?
|
||||
把不需要的档位开关设为 `false` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 6. 注意事项
|
||||
|
||||
1. 请确保钱包里有足够的保证金/资金。
|
||||
2. 不要泄露 `STANDX_TOKEN`。
|
||||
3. 初次使用请先用小仓位测试。
|
||||
|
||||
---
|
||||
|
||||
如果你需要我帮你定制参数或排查问题,直接把日志贴给我即可。
|
||||
+2
-1
@@ -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
@@ -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,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..." },
|
||||
|
||||
@@ -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,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";
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user