mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
Compare commits
7
Commits
feat/standx
...
feat/mm
| 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. 一键脚本会自动写入这些变量,手动部署时需自行维护。
|
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
|
||||||
|
|
||||||
### StandX
|
### StandX
|
||||||
|
|
||||||
|
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
|
||||||
|
|
||||||
|
策略需要 StandX 的登录 token 才能下单
|
||||||
|
|
||||||
|
获取方式:
|
||||||
|
1. 打开 https://standx.ritmex.one/
|
||||||
|
2. 连接钱包
|
||||||
|
3. 点击“登录”
|
||||||
|
4. 导出登录信息里面会包含 token和代理钱包私钥,代理钱包仅用于交易签名,有效保证资产钱包安全
|
||||||
|
|
||||||
|
请妥善保存,不要分享给他人
|
||||||
|
|
||||||
1. 设置 `EXCHANGE=standx`。
|
1. 设置 `EXCHANGE=standx`。
|
||||||
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
||||||
3. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(代理钱包私钥)。
|
||||||
4. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
||||||
5. 可选:如需请求签名,补充 `STANDX_REQUEST_PRIVATE_KEY`。
|
5. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
||||||
|
|
||||||
|
|
||||||
### GRVT
|
### GRVT
|
||||||
1. 在 `.env` 中设置 `EXCHANGE=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",
|
"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: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: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": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@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 {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
@@ -11,6 +11,7 @@ const STRATEGY_VALUES = new Set<StrategyId>([
|
|||||||
"trend",
|
"trend",
|
||||||
"guardian",
|
"guardian",
|
||||||
"maker",
|
"maker",
|
||||||
|
"maker-points",
|
||||||
"offset-maker",
|
"offset-maker",
|
||||||
"basis",
|
"basis",
|
||||||
"grid",
|
"grid",
|
||||||
@@ -69,6 +70,8 @@ function assignStrategy(options: CliOptions, raw: string): void {
|
|||||||
options.strategy = normalized as StrategyId;
|
options.strategy = normalized as StrategyId;
|
||||||
} else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") {
|
} else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") {
|
||||||
options.strategy = "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 {
|
export function printCliHelp(): void {
|
||||||
// eslint-disable-next-line no-console
|
// 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` +
|
`Options:\n` +
|
||||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||||
` Aliases: offset, offset-maker for the offset maker engine.\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 { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
||||||
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-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 { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||||
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
||||||
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||||
@@ -21,6 +22,7 @@ export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
|||||||
trend: "Trend Following",
|
trend: "Trend Following",
|
||||||
guardian: "Guardian",
|
guardian: "Guardian",
|
||||||
maker: "Maker",
|
maker: "Maker",
|
||||||
|
"maker-points": "Maker Points",
|
||||||
"offset-maker": "Offset Maker",
|
"offset-maker": "Offset Maker",
|
||||||
basis: "Basis Arbitrage",
|
basis: "Basis Arbitrage",
|
||||||
grid: "Grid",
|
grid: "Grid",
|
||||||
@@ -74,6 +76,23 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
|||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
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) => {
|
"offset-maker": async (opts) => {
|
||||||
const config = makerConfig;
|
const config = makerConfig;
|
||||||
const adapter = createAdapterOrThrow(config.symbol);
|
const adapter = createAdapterOrThrow(config.symbol);
|
||||||
@@ -135,6 +154,7 @@ async function runEngine<
|
|||||||
| TrendEngineSnapshot
|
| TrendEngineSnapshot
|
||||||
| GuardianEngineSnapshot
|
| GuardianEngineSnapshot
|
||||||
| MakerEngineSnapshot
|
| MakerEngineSnapshot
|
||||||
|
| MakerPointsSnapshot
|
||||||
| OffsetMakerEngineSnapshot
|
| OffsetMakerEngineSnapshot
|
||||||
| BasisArbSnapshot
|
| BasisArbSnapshot
|
||||||
| GridEngineSnapshot
|
| GridEngineSnapshot
|
||||||
|
|||||||
@@ -110,6 +110,41 @@ export const makerConfig: MakerConfig = {
|
|||||||
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
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 {
|
export interface BasisArbConfig {
|
||||||
futuresSymbol: string;
|
futuresSymbol: string;
|
||||||
spotSymbol: string;
|
spotSymbol: string;
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "双边挂单提供流动性,自动追价与风控止损",
|
zh: "双边挂单提供流动性,自动追价与风控止损",
|
||||||
en: "Places two-sided quotes, auto-chases and risk-manages stops.",
|
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.label": { zh: "基础网格策略", en: "Grid Strategy" },
|
||||||
"app.strategy.grid.desc": {
|
"app.strategy.grid.desc": {
|
||||||
zh: "在上下边界之间布设等比网格,自动加仓与减仓",
|
zh: "在上下边界之间布设等比网格,自动加仓与减仓",
|
||||||
@@ -174,6 +179,23 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
},
|
},
|
||||||
"maker.targetOrders": { zh: "目标挂单", en: "Target Orders" },
|
"maker.targetOrders": { zh: "目标挂单", en: "Target Orders" },
|
||||||
"maker.noTargetOrders": { zh: "暂无目标挂单", en: "No 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.name": { zh: "偏移做市策略", en: "offset maker strategy" },
|
||||||
"offset.title": { zh: "偏移做市策略仪表盘", en: "Offset Maker Strategy Dashboard" },
|
"offset.title": { zh: "偏移做市策略仪表盘", en: "Offset Maker Strategy Dashboard" },
|
||||||
"offset.initializing": { zh: "正在初始化偏移做市策略…", en: "Initializing offset maker strategy..." },
|
"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 { TrendApp } from "./TrendApp";
|
||||||
import { GuardianApp } from "./GuardianApp";
|
import { GuardianApp } from "./GuardianApp";
|
||||||
import { MakerApp } from "./MakerApp";
|
import { MakerApp } from "./MakerApp";
|
||||||
|
import { MakerPointsApp } from "./MakerPointsApp";
|
||||||
import { OffsetMakerApp } from "./OffsetMakerApp";
|
import { OffsetMakerApp } from "./OffsetMakerApp";
|
||||||
import { GridApp } from "./GridApp";
|
import { GridApp } from "./GridApp";
|
||||||
import { BasisApp } from "./BasisApp";
|
import { BasisApp } from "./BasisApp";
|
||||||
@@ -12,7 +13,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface StrategyOption {
|
interface StrategyOption {
|
||||||
id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
|
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
component: React.ComponentType<{ onExit: () => void }>;
|
component: React.ComponentType<{ onExit: () => void }>;
|
||||||
@@ -60,19 +61,25 @@ export function App() {
|
|||||||
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
const strategies = useMemo(() => {
|
const strategies = useMemo(() => {
|
||||||
if (!isBasisStrategyEnabled()) {
|
const next: StrategyOption[] = [...BASE_STRATEGIES];
|
||||||
return 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 [
|
if (isBasisStrategyEnabled()) {
|
||||||
...BASE_STRATEGIES,
|
next.push({
|
||||||
{
|
|
||||||
id: "basis" as const,
|
id: "basis" as const,
|
||||||
label: t("app.strategy.basis.label"),
|
label: t("app.strategy.basis.label"),
|
||||||
description: t("app.strategy.basis.desc"),
|
description: t("app.strategy.basis.desc"),
|
||||||
component: BasisApp,
|
component: BasisApp,
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
}, []);
|
return next;
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(input, key) => {
|
(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