mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
feat(maker-points): enhance configuration for maker points bands
Added new configuration options for maker points bands, including target distances and maximum distance limits. Updated the logic to handle these configurations, ensuring backward compatibility with existing defaults. Enhanced documentation and tests to cover the new features and ensure correct functionality across the system.
This commit is contained in:
+45
-12
@@ -27,7 +27,7 @@ STANDX_SYMBOL=BTC-USD
|
||||
# STANDX_SESSION_ID=
|
||||
# Optional: request signing key (ed25519 private key, supports hex or base58 format)
|
||||
# STANDX_REQUEST_PRIVATE_KEY=
|
||||
# Token expiry configuration (recommended method: creation date + validity days)
|
||||
# Token expiry configuration (recommended method: creation date + validity days
|
||||
# Get these values when generating API token at https://standx.com/user/session
|
||||
# STANDX_TOKEN_CREATE_DATE=2026-01-15 # Token creation date (YYYY-MM-DD format)
|
||||
# STANDX_TOKEN_VALIDITY_DAYS=30 # Token validity period in days
|
||||
@@ -43,17 +43,17 @@ SWING_DIRECTION=short # short | long | both
|
||||
SWING_STOP_LOSS_PCT=0.05 # 0.05 = 5%
|
||||
|
||||
# Risk management (USD amounts unless noted)
|
||||
LOSS_LIMIT=0.04 # Max loss per trade in USDT before forced close
|
||||
LOSS_LIMIT=0.04 # Max loss per trade in USDT before for
|
||||
TRAILING_PROFIT=0.2 # Trailing stop activation profit (USDT)
|
||||
TRAILING_CALLBACK_RATE=0.2 # Trailing callback percent (e.g. 0.2 => 0.2%)
|
||||
PROFIT_LOCK_TRIGGER_USD=0.08 # Start moving base stop once unrealized PnL > this (USDT)
|
||||
PROFIT_LOCK_TRIGGER_USD=0.08 # Start moving base stop once unrealiz
|
||||
PROFIT_LOCK_OFFSET_USD=0.04 # Base stop offset from entry after trigger (USDT)
|
||||
BOLLINGER_LENGTH=20 # SMA window (minutes) used for Bollinger bandwidth
|
||||
BOLLINGER_STD_MULTIPLIER=2 # Standard deviation multiplier for Bollinger bands
|
||||
MIN_BOLLINGER_BANDWIDTH=0.001 # Require bandwidth >= this ratio before new entries
|
||||
|
||||
# Precision (per-symbol exchange filters)
|
||||
PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.1)
|
||||
PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.
|
||||
QTY_STEP=0.001 # Quantity step size (e.g. BTC min step 0.001)
|
||||
|
||||
# Engine cadence and UI
|
||||
@@ -71,9 +71,42 @@ MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks
|
||||
MAKER_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK)
|
||||
|
||||
# Maker-points Binance depth imbalance monitor
|
||||
MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS=3 # Binance depth monitor window around best bid/ask (bps)
|
||||
MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS=3 # Binance depth monitor window ar
|
||||
MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO=9 # Imbalance threshold ratio (e.g. 9 => one side >= 9x)
|
||||
|
||||
# Maker-points quote distance (all optional — omit a line to use the default shown)
|
||||
# StandX scores by distance from MARK PRICE on a linear gradient: 100% at 0 bps,
|
||||
# 40% at 10 bps, 12.5% at 30 bps, and exactly 0 at 100 bps and beyond.
|
||||
#
|
||||
# Target distance from mark price per band (bps). Defaults: 9 / 29 / 40.
|
||||
# 40 bps is used for the far band because the old 99 bps edge quote only earns
|
||||
# a 0.18% multiplier — 1/60th of what 40 bps earns — while tying up the same margin.
|
||||
# MAKER_POINTS_BAND_0_10_BPS=9
|
||||
# MAKER_POINTS_BAND_10_30_BPS=29
|
||||
# MAKER_POINTS_BAND_30_100_BPS=40
|
||||
#
|
||||
# Hard cap on quote distance (bps). 95 leaves a safety margin before the 100 bp
|
||||
# Automatically raised to the widest ENABLED band, so a quote is never pulled back
|
||||
# toward the book (that would be the direction most likely to get filled). Capped at 100.
|
||||
# MAKER_POINTS_MAX_DISTANCE_BPS=95
|
||||
#
|
||||
# Per-band reprice tolerance = max(MAKER_POINTS_MIN_REPRICE_BPS, band bps x this ratio).
|
||||
# StandX only scores quotes that rest on the book for more than 3 seconds, and
|
||||
# short-cycle cancels, so far bands are deliberately slower to move than near ones.
|
||||
# Higher => orders move less often and rest longer. Not recommended below 0.1.
|
||||
# MAKER_POINTS_BAND_REPRICE_RATIO=0.15
|
||||
# MAKER_POINTS_MIN_REPRICE_BPS=3
|
||||
#
|
||||
# Stop-loss trigger offset attached to entry quotes (bps), so a filled quote is closed
|
||||
# immediately instead of leaving inventory. Scales with the symbol price. Set 0 to disable.
|
||||
# MAKER_POINTS_SL_OFFSET_BPS=2
|
||||
#
|
||||
# Band on/off switches (all default true). Disabling the 0-10 band is the simplest way
|
||||
# to cut fill risk, at the cost of the highest-multiplier quotes.
|
||||
# MAKER_POINTS_BAND_0_10=true
|
||||
# MAKER_POINTS_BAND_10_30=true
|
||||
# MAKER_POINTS_BAND_30_100=true
|
||||
|
||||
# Grid strategy defaults
|
||||
GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency)
|
||||
GRID_UPPER_PRICE=35000 # Grid upper bound price
|
||||
@@ -84,16 +117,16 @@ GRID_REFRESH_INTERVAL_MS=1000 # Grid evaluation cadence (ms)
|
||||
GRID_MAX_LOG_ENTRIES=200 # Grid trade log length (defaults to MAX_LOG_ENTRIES when unset)
|
||||
GRID_DIRECTION=both # Order direction: both | long | short
|
||||
GRID_STOP_LOSS_PCT=0.01 # Stop loss trigger percentage beyond bounds (0.01 => 1%)
|
||||
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside bounds
|
||||
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside boun
|
||||
GRID_AUTO_RESTART_ENABLED=true # Automatically resume grid when price re-enters range
|
||||
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Close-order slippage guard relative to mark price
|
||||
GRID_SHIFT_ENABLED=false # Smart-follow grid: shift the whole grid when price drifts from anchor
|
||||
GRID_SHIFT_TRIGGER_PCT=0.05 # Shift trigger: |price/anchor - 1| threshold (0.05 => 5%)
|
||||
GRID_SHIFT_TRIGGER_PCT=0.05 # Shift trigger: |price/anchor - 1| thr
|
||||
GRID_SHIFT_RANGE_PCT=0.05 # New grid half-range around the new anchor after a shift
|
||||
GRID_SHIFT_CONFIRM_MS=3000 # Deviation must persist this long before shifting (anti-wick)
|
||||
GRID_SHIFT_CONFIRM_MS=3000 # Deviation must persist this long befo
|
||||
GRID_USE_REDUCE_ONLY=false # Attach reduceOnly to EXIT orders (some venues reject it alongside entries)
|
||||
GRID_EXCHANGE_STOP_ENABLED=true # Keep an exchange-side STOP_MARKET backstop (aster/binance/grvt/ondoperps)
|
||||
GRID_RECONCILE_INTERVAL_MS=30000 # Periodic REST reconcile cadence when the venue supports order queries
|
||||
GRID_RECONCILE_INTERVAL_MS=30000 # Periodic REST reconcile cadence when ueries
|
||||
GRID_UNCOVERED_GRACE_MS=5000 # Grace before the coverage audit acts on uncovered position
|
||||
# GRID_PRICE_TICK=0.1 # Optional override for grid price tick (falls back to PRICE_TICK)
|
||||
# GRID_QTY_STEP=0.001 # Optional override for grid quantity step (falls back to QTY_STEP)
|
||||
@@ -115,10 +148,10 @@ GRVT_ENV=prod
|
||||
LIGHTER_ACCOUNT_INDEX=
|
||||
LIGHTER_API_PRIVATE_KEY= # 40-byte hex private key (e.g., 0x...)
|
||||
LIGHTER_API_KEY_INDEX=0 # API key slot (default 0)
|
||||
LIGHTER_SYMBOL=BTCUSDT # Trading pair (defaults to TRADE_SYMBOL when omitted)
|
||||
LIGHTER_SYMBOL=BTCUSDT # Trading pair (defaults to TRADE_SYMBO
|
||||
LIGHTER_ENV=testnet # mainnet | testnet | staging | dev
|
||||
# LIGHTER_BASE_URL=https://testnet.zklighter.elliot.ai
|
||||
# LIGHTER_CHAIN_ID=300 # Override inferred chain id when needed
|
||||
# LIGHTER_CHAIN_ID=300 # Override inferred chain id when neede
|
||||
# LIGHTER_MARKET_ID=1 # Prefer explicit market id when symbols differ
|
||||
# LIGHTER_PRICE_DECIMALS=3 # Manual override for price decimals (optional)
|
||||
# LIGHTER_SIZE_DECIMALS=3 # Manual override for size decimals (optional)
|
||||
@@ -191,4 +224,4 @@ NADO_MIN_SIZE_POLICY=adjust
|
||||
# Telegram notification configuration
|
||||
# TELEGRAM_BOT_TOKEN= # Telegram bot token from @BotFather
|
||||
# TELEGRAM_CHAT_ID= # Chat ID to receive notifications
|
||||
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
|
||||
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
|
||||
@@ -140,6 +140,18 @@ MAKER_POINTS_BAND_0_10=true
|
||||
MAKER_POINTS_BAND_10_30=true
|
||||
MAKER_POINTS_BAND_30_100=true
|
||||
|
||||
# ===== 挂单距离(可选,不填就用下面这些默认值) =====
|
||||
# 每个档位挂在距 mark price 多远的地方(单位 bps,1 bps = 万分之一)
|
||||
# MAKER_POINTS_BAND_0_10_BPS=9
|
||||
# MAKER_POINTS_BAND_10_30_BPS=29
|
||||
# MAKER_POINTS_BAND_30_100_BPS=40
|
||||
# 最远不超过这个距离(超过 100 bps 就完全没有积分了)
|
||||
# MAKER_POINTS_MAX_DISTANCE_BPS=95
|
||||
# 远档位挪动订单的门槛倍数(越大越懒得动,订单活得越久)
|
||||
# MAKER_POINTS_BAND_REPRICE_RATIO=0.15
|
||||
# 万一挂单被吃掉,多远触发自动止损(单位 bps)
|
||||
# MAKER_POINTS_SL_OFFSET_BPS=2
|
||||
|
||||
# ===== Token 过期时间配置(推荐配置) =====
|
||||
# 填写你创建 API Token 时显示的创建日期和有效期天数
|
||||
# 创建日期格式:YYYY-MM-DD(例如:2026-01-15)
|
||||
@@ -221,12 +233,110 @@ bun run pm2:start:maker-points
|
||||
| `MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS` | Binance 失衡检测窗口(bps) | 默认 `3` |
|
||||
| `MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO` | Binance 失衡比例阈值 | 默认 `9` |
|
||||
| `MAKER_POINTS_BAND_*` | 三个挂单档位的开关 | 全部 `true` 即可 |
|
||||
| `MAKER_POINTS_BAND_*_BPS` | 各档位挂多远(bps) | 不填,默认 `9` / `29` / `40` |
|
||||
| `MAKER_POINTS_MAX_DISTANCE_BPS` | 挂单距离上限(bps) | 不填,默认 `95` |
|
||||
| `MAKER_POINTS_BAND_REPRICE_RATIO` | 远档挪单门槛倍数 | 不填,默认 `0.15` |
|
||||
| `MAKER_POINTS_SL_OFFSET_BPS` | 被吃后止损触发距离(bps) | 不填,默认 `2` |
|
||||
| `STANDX_TOKEN_CREATE_DATE` | Token 创建日期 | 推荐配置,格式 YYYY-MM-DD |
|
||||
| `STANDX_TOKEN_VALIDITY_DAYS` | Token 有效期天数 | 推荐配置,与创建日期配合使用 |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
|
||||
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
|
||||
| `TELEGRAM_ACCOUNT_LABEL` | Telegram 通知账户标签 | 可选,用于区分多个账户 |
|
||||
|
||||
### 挂单距离配置详解
|
||||
|
||||
> 💡 **这一整节都可以跳过。** 上面 4 个 `# 注释掉` 的参数不填就是默认值,策略照常运行,
|
||||
> 默认值就是按 StandX 当前活动规则调好的。想微调再往下看。
|
||||
|
||||
#### 先搞懂积分是怎么算的
|
||||
|
||||
StandX 按你的挂单**距离 mark price 有多远**给积分倍率,越近给得越多:
|
||||
|
||||
| 距离 | 倍率 |
|
||||
|------|------|
|
||||
| 2 bps | 88% |
|
||||
| 5 bps | 70% |
|
||||
| 10 bps | 40% |
|
||||
| 20 bps | 26.25% |
|
||||
| 29 bps | 13.9% |
|
||||
| 40 bps | 10.7% |
|
||||
| 50 bps | 8.9% |
|
||||
| 99 bps | 0.18% |
|
||||
| **100 bps 以上** | **0(一分没有)** |
|
||||
|
||||
注意两件事:
|
||||
|
||||
1. **100 bps 是断崖**,超过一点就完全不得分。所以有了 `MAKER_POINTS_MAX_DISTANCE_BPS=95`,
|
||||
留 5 bps 安全边际,防止 mark price 跳动时你的单被甩出去白挂。
|
||||
2. **挂得越近积分越多,但也越容易被真的成交。** 本策略的目标是只赚挂单积分、不产生真实成交,
|
||||
所以默认值是偏保守的一组,不是积分最大化的一组。
|
||||
|
||||
#### 三个档位默认挂多远
|
||||
|
||||
| 档位 | 默认距离 | 倍率 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| `BAND_0_10` | 9 bps | 46% | 最近,积分最高,也最容易被吃 |
|
||||
| `BAND_10_30` | 29 bps | 13.9% | 中距离 |
|
||||
| `BAND_30_100` | 40 bps | 10.7% | 最远,最安全 |
|
||||
|
||||
**为什么第三档是 40 而不是贴着 99?** 因为 StandX 改成线性倍率之后,99 bps 只有 0.18% 倍率,
|
||||
是 40 bps 的六十分之一——挂了等于没挂,还白占保证金。40 bps 既远离盘口又能保住 10.7%。
|
||||
|
||||
**想更保守**(更不容易被成交,但积分少):把三档都往大调,例如
|
||||
|
||||
```bash
|
||||
MAKER_POINTS_BAND_0_10_BPS=10
|
||||
MAKER_POINTS_BAND_10_30_BPS=35
|
||||
MAKER_POINTS_BAND_30_100_BPS=55
|
||||
```
|
||||
|
||||
或者干脆关掉最近的一档:`MAKER_POINTS_BAND_0_10=false`。
|
||||
|
||||
**想更激进**(积分多,但被成交的风险明显上升):
|
||||
|
||||
```bash
|
||||
MAKER_POINTS_BAND_0_10_BPS=5
|
||||
MAKER_POINTS_BAND_10_30_BPS=20
|
||||
MAKER_POINTS_BAND_30_100_BPS=32
|
||||
```
|
||||
|
||||
> ⚠️ 如果你把某档距离调得比 `MAKER_POINTS_MAX_DISTANCE_BPS` 还大,策略会**自动把上限提到该档位**,
|
||||
> 不会把你的挂单硬拽回盘口附近。上限最高锁在 100 bps。
|
||||
|
||||
#### `MAKER_POINTS_BAND_REPRICE_RATIO` 是干什么的
|
||||
|
||||
StandX 规定**挂单要在盘口停留超过 3 秒才计分**,而且频繁撤挂会被判定刷量、剔除出奖励。
|
||||
所以策略不会价格一动就重挂,而是给每个档位一个"容忍范围",漂出去了才动:
|
||||
|
||||
```
|
||||
容忍范围 = max(MAKER_POINTS_MIN_REPRICE_BPS, 该档距离 × MAKER_POINTS_BAND_REPRICE_RATIO)
|
||||
```
|
||||
|
||||
按默认值(`MIN_REPRICE_BPS=3`、`RATIO=0.15`)算出来是:
|
||||
|
||||
| 档位 | 距离 | 容忍范围 | 实测平均存活 |
|
||||
|------|------|----------|--------------|
|
||||
| 0-10 | 9 bps | ±3 bps | 约 8 秒 |
|
||||
| 10-30 | 29 bps | ±4.35 bps | 约 16 秒 |
|
||||
| 30-100 | 40 bps | ±6 bps | 约 28 秒 |
|
||||
|
||||
远的档位挪得更少,因为价格小幅波动对它影响本来就小。三档平均存活都远超 3 秒门槛。
|
||||
|
||||
**调大 ratio**(例如 `0.25`)→ 订单更少被挪动、更容易跨过 3 秒门槛,但挂单距离会偏离目标更多。
|
||||
**调小 ratio**(例如 `0.08`)→ 距离更精准,但撤挂更频繁,有跌破 3 秒门槛的风险。**不建议低于 0.1。**
|
||||
|
||||
> 无论容忍范围设多大,出现这三种情况都会**立刻撤单**,不受影响:挂单穿到了 mark price 另一侧、
|
||||
> 挂单掉出积分范围、目标价前方的盘口深度不够。插针行情下撤单永远畅通。
|
||||
|
||||
#### `MAKER_POINTS_SL_OFFSET_BPS` 是干什么的
|
||||
|
||||
万一挂单还是被成交了,策略会给它附带一个止损单立刻平掉,避免留下仓位。
|
||||
这个参数控制止损触发价离成交价多远,默认 `2` bps。
|
||||
|
||||
设成 `0` 表示不附带止损(不推荐,除非你自己有别的风控)。
|
||||
|
||||
---
|
||||
|
||||
### Token 过期时间配置详解
|
||||
|
||||
`STANDX_TOKEN_CREATE_DATE` 和 `STANDX_TOKEN_VALIDITY_DAYS` 用于设置 Token 的过期时间。配置后,策略会:
|
||||
@@ -329,6 +439,22 @@ STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
|
||||
2. 检查 TOKEN 和私钥是否正确填写
|
||||
3. 检查 .env 文件是否保存成功
|
||||
|
||||
### Q:我升级了代码,需要改 .env 吗?
|
||||
|
||||
**不需要。** 新增的 `MAKER_POINTS_BAND_*_BPS`、`MAKER_POINTS_MAX_DISTANCE_BPS`、
|
||||
`MAKER_POINTS_BAND_REPRICE_RATIO`、`MAKER_POINTS_SL_OFFSET_BPS` 全部有默认值,
|
||||
不填就按默认值跑。老的 `.env` 直接用就行。
|
||||
|
||||
### Q:仪表盘上挂单的 `Rest` 那一列是什么?
|
||||
|
||||
是这张挂单已经在盘口停留了多少秒。StandX 只对**停留超过 3 秒**的挂单计分,
|
||||
所以数字前面带 `!` 的(不足 3 秒)暂时还不产生积分。正常运行时大部分单会稳定在十几秒以上。
|
||||
|
||||
### Q:档位那几行显示的 `×10.71%` 是什么意思?
|
||||
|
||||
是这个档位当前挂单对应的**积分倍率**。旁边的 `38.9bps` 是实际距离 mark price 多远。
|
||||
如果倍率显示 `0.00%`,说明挂单已经跑到 100 bps 之外了,这时候是白挂——检查一下你的档位距离配置。
|
||||
|
||||
### Q:担心平掉我手动开的仓位?
|
||||
|
||||
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
|
||||
|
||||
+50
-3
@@ -5,6 +5,7 @@
|
||||
|
||||
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
||||
import { language, type Language } from "./i18n";
|
||||
import { DEFAULT_BAND_BPS, MAKER_POINTS_ZERO_BPS } from "./strategy/maker-points-logic";
|
||||
|
||||
export interface StandxTokenConfig {
|
||||
expiryTimestamp: number | null;
|
||||
@@ -226,7 +227,20 @@ export interface MakerPointsConfig {
|
||||
band10To30Amount: number;
|
||||
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||
band30To100Amount: number;
|
||||
/** 0-10 bps 档位目标距离(距 mark price 的 bps),默认 9 */
|
||||
band0To10Bps: number;
|
||||
/** 10-30 bps 档位目标距离(距 mark price 的 bps),默认 29 */
|
||||
band10To30Bps: number;
|
||||
/** 30-100 bps 档位目标距离(距 mark price 的 bps),默认 40 */
|
||||
band30To100Bps: number;
|
||||
/** 距 mark price 的最大允许距离(bps)。100 bps 处倍率归零,默认 95 留安全边际 */
|
||||
maxDistanceBps: number;
|
||||
/** 近档最小重挂阈值(bps),默认 3 */
|
||||
minRepriceBps: number;
|
||||
/** 远档重挂阈值 = max(minRepriceBps, 目标距离 × 该比例),默认 0.15 */
|
||||
bandRepriceRatio: number;
|
||||
/** 成交后立即止损的触发价偏移(bps),默认 2;随标的价格自动缩放 */
|
||||
slOffsetBps: number;
|
||||
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
|
||||
enableBinanceDepthCancel: boolean;
|
||||
/** Binance 深度监控窗口(bps),默认 3 */
|
||||
@@ -239,6 +253,33 @@ export interface MakerPointsConfig {
|
||||
|
||||
const defaultMakerPointsAmount = parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001));
|
||||
|
||||
const makerPointsBands = {
|
||||
band0To10: {
|
||||
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
|
||||
bps: parseNumber(process.env.MAKER_POINTS_BAND_0_10_BPS, DEFAULT_BAND_BPS["0-10"]),
|
||||
},
|
||||
band10To30: {
|
||||
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
|
||||
bps: parseNumber(process.env.MAKER_POINTS_BAND_10_30_BPS, DEFAULT_BAND_BPS["10-30"]),
|
||||
},
|
||||
band30To100: {
|
||||
enabled: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
|
||||
bps: parseNumber(process.env.MAKER_POINTS_BAND_30_100_BPS, DEFAULT_BAND_BPS["30-100"]),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 最大挂单距离不能小于任何启用档位的目标距离 —— 否则夹回会把挂单推向盘口,
|
||||
* 正好是最容易被吃的方向。上限锁在 100 bps,那里倍率归零。
|
||||
*/
|
||||
function resolveMaxDistanceBps(): number {
|
||||
const configured = parseNumber(process.env.MAKER_POINTS_MAX_DISTANCE_BPS, 95);
|
||||
const widest = Object.values(makerPointsBands)
|
||||
.filter((band) => band.enabled)
|
||||
.reduce((max, band) => Math.max(max, band.bps), 1);
|
||||
return Math.min(MAKER_POINTS_ZERO_BPS, Math.max(configured, widest));
|
||||
}
|
||||
|
||||
export const makerPointsConfig: MakerPointsConfig = {
|
||||
symbol: resolveSymbolFromEnv("standx"),
|
||||
perOrderAmount: defaultMakerPointsAmount,
|
||||
@@ -252,13 +293,19 @@ export const makerPointsConfig: MakerPointsConfig = {
|
||||
),
|
||||
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),
|
||||
enableBand0To10: makerPointsBands.band0To10.enabled,
|
||||
enableBand10To30: makerPointsBands.band10To30.enabled,
|
||||
enableBand30To100: makerPointsBands.band30To100.enabled,
|
||||
band0To10Amount: parseNumber(process.env.MAKER_POINTS_BAND_0_10_AMOUNT, defaultMakerPointsAmount),
|
||||
band10To30Amount: parseNumber(process.env.MAKER_POINTS_BAND_10_30_AMOUNT, defaultMakerPointsAmount),
|
||||
band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_AMOUNT, defaultMakerPointsAmount),
|
||||
band0To10Bps: makerPointsBands.band0To10.bps,
|
||||
band10To30Bps: makerPointsBands.band10To30.bps,
|
||||
band30To100Bps: makerPointsBands.band30To100.bps,
|
||||
maxDistanceBps: resolveMaxDistanceBps(),
|
||||
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
|
||||
bandRepriceRatio: parseNumber(process.env.MAKER_POINTS_BAND_REPRICE_RATIO, 0.15),
|
||||
slOffsetBps: parseNumber(process.env.MAKER_POINTS_SL_OFFSET_BPS, 2),
|
||||
enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true),
|
||||
binanceDepthWindowBps: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS, 3),
|
||||
binanceDepthImbalanceRatio: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO, 9),
|
||||
|
||||
+7
-2
@@ -243,6 +243,10 @@ const translations: Record<string, TranslationEntry> = {
|
||||
zh: "交易所: {exchange} | 交易对: {symbol} | 买一价: {bid} | 卖一价: {ask} | 点差: {spread}",
|
||||
en: "Exchange: {exchange} | Symbol: {symbol} | Best Bid: {bid} | Best Ask: {ask} | Spread: {spread}",
|
||||
},
|
||||
"makerPoints.markLine": {
|
||||
zh: "计分基准 Mark: {mark} | 100bps 外倍率归零,超过 {maxDistance}bps 不再挂单",
|
||||
en: "Scoring anchor (mark): {mark} | zero multiplier beyond 100bps; quotes capped at {maxDistance}bps",
|
||||
},
|
||||
"makerPoints.quoteLine": {
|
||||
zh: "挂单模式: {mode} | BUY {buy} | SELL {sell}",
|
||||
en: "Quote mode: {mode} | BUY {buy} | SELL {sell}",
|
||||
@@ -252,9 +256,10 @@ const translations: Record<string, TranslationEntry> = {
|
||||
en: "Binance depth (±{windowBps}bps): bid {buy} | ask {sell} | Status: {status}",
|
||||
},
|
||||
"makerPoints.bandDepthLine": {
|
||||
zh: "StandX 档位 {band}bps 深度: 买 {buy} | 卖 {sell}",
|
||||
en: "StandX band {band}bps depth: buy {buy} | sell {sell}",
|
||||
zh: "档位 {band} 目标 {target}bps | 买 {buyDist} ×{buyMult} 深度 {buy} | 卖 {sellDist} ×{sellMult} 深度 {sell}",
|
||||
en: "Band {band} target {target}bps | buy {buyDist} ×{buyMult} depth {buy} | sell {sellDist} ×{sellMult} depth {sell}",
|
||||
},
|
||||
"makerPoints.bandDisabled": { zh: "(已关闭)", en: " (off)" },
|
||||
"makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" },
|
||||
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
|
||||
"makerPoints.feed.binance": { zh: "Binance", en: "Binance" },
|
||||
|
||||
+319
-220
@@ -35,7 +35,16 @@ import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-
|
||||
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 {
|
||||
bandRepriceToleranceBps,
|
||||
buildBandTargets,
|
||||
makerPointsMultiplier,
|
||||
resolveSafeQuotePrice,
|
||||
shouldKeepQuote,
|
||||
signedDistanceBps,
|
||||
type BandTarget,
|
||||
type MakerPointsBand,
|
||||
} from "./maker-points-logic";
|
||||
import { t } from "../i18n";
|
||||
import { IsolatedMarginGuard } from "./common/isolated-margin-guard";
|
||||
import { TokenExpiryGuard } from "./common/token-expiry-guard";
|
||||
@@ -52,11 +61,28 @@ interface DesiredOrder {
|
||||
reduceOnly: boolean;
|
||||
}
|
||||
|
||||
export interface BandStatus {
|
||||
band: MakerPointsBand;
|
||||
/** 该档位配置的目标距离(bps,距 mark price)。 */
|
||||
bps: number;
|
||||
enabled: boolean;
|
||||
/** 盘口一档到目标价之间的挂单量,用于判断被吃穿的风险。 */
|
||||
buyDepth: number | null;
|
||||
sellDepth: number | null;
|
||||
/** 实际在场挂单距 mark 的距离;无挂单时为 null。 */
|
||||
buyDistanceBps: number | null;
|
||||
sellDistanceBps: number | null;
|
||||
/** 上述实际距离对应的 Maker Points 倍率。 */
|
||||
buyMultiplier: number | null;
|
||||
sellMultiplier: number | null;
|
||||
}
|
||||
|
||||
export interface MakerPointsSnapshot {
|
||||
ready: boolean;
|
||||
symbol: string;
|
||||
topBid: number | null;
|
||||
topAsk: number | null;
|
||||
markPrice: number | null;
|
||||
spread: number | null;
|
||||
priceDecimals: number;
|
||||
position: PositionSnapshot;
|
||||
@@ -75,13 +101,11 @@ export interface MakerPointsSnapshot {
|
||||
binance: boolean;
|
||||
};
|
||||
binanceDepth: BinanceDepthSnapshot | null;
|
||||
bandDepths: Array<{
|
||||
band: "0-10" | "10-30" | "30-100";
|
||||
bps: number;
|
||||
buyDepth: number | null;
|
||||
sellDepth: number | null;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
/** 配置的最大挂单距离(bps),用于仪表盘提示与 100bps 悬崖的安全边际。 */
|
||||
maxDistanceBps: number;
|
||||
bandDepths: BandStatus[];
|
||||
/** 每个在场挂单已在盘口停留的毫秒数;Maker Points 要求超过 3 秒才计分。 */
|
||||
orderRestingMs: Record<string, number>;
|
||||
quoteStatus: {
|
||||
closeOnly: boolean;
|
||||
skipBuy: boolean;
|
||||
@@ -134,10 +158,10 @@ export class MakerPointsEngine {
|
||||
private lastCloseOnly = false;
|
||||
private lastSkipBuy = false;
|
||||
private lastSkipSell = false;
|
||||
private lastQuoteBid1: number | null = null;
|
||||
private lastQuoteAsk1: number | null = null;
|
||||
// 跟踪各档位深度是否足够的状态 (按 bps 值索引)
|
||||
private lastDepthOkStatus: Record<number, { buy: boolean; sell: boolean }> = {};
|
||||
/** 最近一轮实际下发的报价距 mark 的距离,用于仪表盘展示倍率。 */
|
||||
private lastQuoteDistanceBps: Partial<Record<MakerPointsBand, { buy: number | null; sell: number | null }>> = {};
|
||||
|
||||
private readinessLogged = {
|
||||
account: false,
|
||||
@@ -549,8 +573,7 @@ export class MakerPointsEngine {
|
||||
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
||||
|
||||
// 重置 reprice 基准,强制下一次重新计算
|
||||
this.lastQuoteBid1 = null;
|
||||
this.lastQuoteAsk1 = null;
|
||||
this.lastQuoteDistanceBps = {};
|
||||
this.desiredOrders = [];
|
||||
this.lastDesiredSummary = null;
|
||||
|
||||
@@ -705,39 +728,18 @@ export class MakerPointsEngine {
|
||||
this.lastSkipSell = skipSell;
|
||||
}
|
||||
|
||||
const closeOnlyChanged = closeOnly !== prevCloseOnly;
|
||||
const skipChanged = skipBuy !== prevSkipBuy || skipSell !== prevSkipSell;
|
||||
const repriceNeeded = closeOnly ? true : this.shouldReprice(topBid, topAsk);
|
||||
const depthStatusChanged = this.checkDepthStatusChanged(depth, topBid, topAsk);
|
||||
const shouldRecompute =
|
||||
closeOnly ||
|
||||
repriceNeeded ||
|
||||
closeOnlyChanged ||
|
||||
skipChanged ||
|
||||
depthStatusChanged ||
|
||||
this.desiredOrders.length === 0;
|
||||
|
||||
const desired = shouldRecompute
|
||||
? closeOnly
|
||||
? this.buildCloseOnlyOrders(position, topBid, topAsk)
|
||||
: this.buildDesiredOrders({
|
||||
bid1: topBid,
|
||||
ask1: topAsk,
|
||||
skipBuy,
|
||||
skipSell,
|
||||
depth,
|
||||
})
|
||||
: this.desiredOrders;
|
||||
|
||||
if (shouldRecompute) {
|
||||
if (closeOnly) {
|
||||
this.lastQuoteBid1 = null;
|
||||
this.lastQuoteAsk1 = null;
|
||||
} else {
|
||||
this.lastQuoteBid1 = topBid;
|
||||
this.lastQuoteAsk1 = topAsk;
|
||||
}
|
||||
}
|
||||
// 每轮都重算:报价是否真的变动由各档位的 sticky 判定决定,
|
||||
// 价格没漂出档位容差时会复用现有挂单价,makeOrderPlan 也就不会撤单。
|
||||
const desired = closeOnly
|
||||
? this.buildCloseOnlyOrders(position, topBid, topAsk)
|
||||
: this.buildDesiredOrders({
|
||||
bid1: topBid,
|
||||
ask1: topAsk,
|
||||
anchor: this.getQuoteAnchor(depth),
|
||||
skipBuy,
|
||||
skipSell,
|
||||
depth,
|
||||
});
|
||||
|
||||
this.desiredOrders = desired;
|
||||
this.logDesiredOrders(desired);
|
||||
@@ -759,143 +761,173 @@ export class MakerPointsEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前启用的档位及其目标距离,按距离升序。 */
|
||||
private bandTargets(): BandTarget[] {
|
||||
return buildBandTargets({
|
||||
band0To10: this.config.enableBand0To10,
|
||||
band10To30: this.config.enableBand10To30,
|
||||
band30To100: this.config.enableBand30To100,
|
||||
band0To10Bps: this.config.band0To10Bps,
|
||||
band10To30Bps: this.config.band10To30Bps,
|
||||
band30To100Bps: this.config.band30To100Bps,
|
||||
});
|
||||
}
|
||||
|
||||
private amountForBand(band: MakerPointsBand): number {
|
||||
if (band === "0-10") return Number(this.config.band0To10Amount);
|
||||
if (band === "10-30") return Number(this.config.band10To30Amount);
|
||||
return Number(this.config.band30To100Amount);
|
||||
}
|
||||
|
||||
private toleranceFor(targetBps: number): number {
|
||||
return bandRepriceToleranceBps(targetBps, this.config.minRepriceBps, this.config.bandRepriceRatio);
|
||||
}
|
||||
|
||||
/**
|
||||
* 距离计算的基准价:活动按 mark price 计分,所以优先用交易所 mark price,
|
||||
* 拿不到时退回盘口中值。
|
||||
*/
|
||||
private getQuoteAnchor(depth: Depth | null): number | null {
|
||||
const mark = Number(this.tickerSnapshot?.markPrice);
|
||||
if (Number.isFinite(mark) && mark > 0) return mark;
|
||||
const { topBid, topAsk } = getTopPrices(depth ?? this.depthSnapshot);
|
||||
if (topBid == null || topAsk == null) return null;
|
||||
return (topBid + topAsk) / 2;
|
||||
}
|
||||
|
||||
/** 可以被 sticky 复用的在场开仓挂单。 */
|
||||
private activeEntryOrders(): Order[] {
|
||||
return this.openOrders.filter(
|
||||
(order) =>
|
||||
order.symbol === this.config.symbol &&
|
||||
!order.reduceOnly &&
|
||||
isOrderActiveStatus(order.status) &&
|
||||
!this.pendingCancelOrders.has(String(order.orderId))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在现有挂单中找出还能留在原地的那一张:距离仍在本档容差内、数量一致、
|
||||
* 且没有被其它档位认领。找到就复用它的价格,这一轮该档位不撤不挂。
|
||||
*/
|
||||
private pickStickyPrice(params: {
|
||||
side: "BUY" | "SELL";
|
||||
targetBps: number;
|
||||
anchor: number;
|
||||
amount: number;
|
||||
pool: Order[];
|
||||
claimed: Set<string>;
|
||||
}): number | null {
|
||||
const { side, targetBps, anchor, amount, pool, claimed } = params;
|
||||
const tolerance = this.toleranceFor(targetBps);
|
||||
const qtyTolerance = Math.max(this.precision.qtyStep, EPS);
|
||||
let best: { id: string; price: number; delta: number } | null = null;
|
||||
|
||||
for (const order of pool) {
|
||||
if (order.side !== side) continue;
|
||||
const id = String(order.orderId);
|
||||
if (claimed.has(id)) continue;
|
||||
const price = Number(order.price);
|
||||
if (!Number.isFinite(price) || price <= 0) continue;
|
||||
const origQty = Number(order.origQty);
|
||||
if (Number.isFinite(origQty) && Math.abs(origQty - amount) > qtyTolerance) continue;
|
||||
const keep = shouldKeepQuote({
|
||||
side,
|
||||
existingPrice: price,
|
||||
anchor,
|
||||
targetBps,
|
||||
toleranceBps: tolerance,
|
||||
maxDistanceBps: this.config.maxDistanceBps,
|
||||
});
|
||||
if (!keep) continue;
|
||||
const delta = Math.abs(signedDistanceBps(side, price, anchor) - targetBps);
|
||||
if (!best || delta < best.delta) {
|
||||
best = { id, price, delta };
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) return null;
|
||||
claimed.add(best.id);
|
||||
return best.price;
|
||||
}
|
||||
|
||||
private buildDesiredOrders(params: {
|
||||
bid1: number;
|
||||
ask1: number;
|
||||
anchor: number | null;
|
||||
skipBuy: boolean;
|
||||
skipSell: boolean;
|
||||
depth: Depth | null;
|
||||
}): DesiredOrder[] {
|
||||
const { bid1, ask1, skipBuy, skipSell, depth } = params;
|
||||
|
||||
const targets = buildBpsTargets({
|
||||
band0To10: this.config.enableBand0To10,
|
||||
band10To30: this.config.enableBand10To30,
|
||||
band30To100: this.config.enableBand30To100,
|
||||
}).sort((a, b) => b - a);
|
||||
const { bid1, ask1, anchor, skipBuy, skipSell, depth } = params;
|
||||
|
||||
// 远档先算,让它优先认领距离最匹配的在场挂单
|
||||
const targets = this.bandTargets().sort((a, b) => b.bps - a.bps);
|
||||
if (!targets.length) return [];
|
||||
|
||||
const priceDecimals = this.getPriceDecimals();
|
||||
const desired: DesiredOrder[] = [];
|
||||
const minDepth = this.config.filterMinDepth;
|
||||
const desired: DesiredOrder[] = [];
|
||||
const pool = this.activeEntryOrders();
|
||||
const claimed = new Set<string>();
|
||||
const distances: Partial<Record<MakerPointsBand, { buy: number | null; sell: number | null }>> = {};
|
||||
|
||||
const getAmountForBps = (bps: number): number => {
|
||||
if (bps <= 10) return Number(this.config.band0To10Amount);
|
||||
if (bps <= 30) return Number(this.config.band10To30Amount);
|
||||
return Number(this.config.band30To100Amount);
|
||||
};
|
||||
|
||||
for (const bps of targets) {
|
||||
const amount = getAmountForBps(bps);
|
||||
for (const target of targets) {
|
||||
const amount = this.amountForBand(target.band);
|
||||
const record: { buy: number | null; sell: number | null } = { buy: null, sell: null };
|
||||
distances[target.band] = record;
|
||||
if (!Number.isFinite(amount) || amount <= 0) continue;
|
||||
|
||||
// 所有档位都检查深度
|
||||
const shouldCheckDepth = minDepth > 0;
|
||||
for (const side of ["BUY", "SELL"] as const) {
|
||||
if (side === "BUY" ? skipBuy : skipSell) continue;
|
||||
|
||||
if (!skipBuy) {
|
||||
const targetPrice = this.normalizeDepthTargetPrice(bid1 * (1 - bps / 10000), priceDecimals);
|
||||
if (targetPrice != null) {
|
||||
if (shouldCheckDepth) {
|
||||
const depthQty = getDepthBetweenPrices(depth, "BUY", targetPrice);
|
||||
if (depthQty < minDepth) {
|
||||
this.logThinDepthSkip("BUY", bps, depthQty, minDepth);
|
||||
} else {
|
||||
this.resetThinDepthSkip("BUY", bps);
|
||||
desired.push({
|
||||
side: "BUY",
|
||||
price: formatPriceToString(targetPrice, priceDecimals),
|
||||
amount,
|
||||
reduceOnly: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
desired.push({
|
||||
side: "BUY",
|
||||
price: formatPriceToString(targetPrice, priceDecimals),
|
||||
amount,
|
||||
reduceOnly: false,
|
||||
});
|
||||
const raw = resolveSafeQuotePrice({
|
||||
side,
|
||||
targetBps: target.bps,
|
||||
markPrice: anchor,
|
||||
bookPrice: side === "BUY" ? bid1 : ask1,
|
||||
maxDistanceBps: this.config.maxDistanceBps,
|
||||
});
|
||||
const ideal = raw == null ? null : this.normalizeDepthTargetPrice(raw, priceDecimals);
|
||||
if (ideal == null) continue;
|
||||
|
||||
// 深度保护先于价格复用:目标价前方挂单太薄就整档不挂
|
||||
if (minDepth > 0) {
|
||||
const depthQty = getDepthBetweenPrices(depth, side, ideal);
|
||||
if (depthQty < minDepth) {
|
||||
this.logThinDepthSkip(side, target.bps, depthQty, minDepth);
|
||||
continue;
|
||||
}
|
||||
this.resetThinDepthSkip(side, target.bps);
|
||||
}
|
||||
}
|
||||
if (!skipSell) {
|
||||
const targetPrice = this.normalizeDepthTargetPrice(ask1 * (1 + bps / 10000), priceDecimals);
|
||||
if (targetPrice != null) {
|
||||
if (shouldCheckDepth) {
|
||||
const depthQty = getDepthBetweenPrices(depth, "SELL", targetPrice);
|
||||
if (depthQty < minDepth) {
|
||||
this.logThinDepthSkip("SELL", bps, depthQty, minDepth);
|
||||
} else {
|
||||
this.resetThinDepthSkip("SELL", bps);
|
||||
desired.push({
|
||||
side: "SELL",
|
||||
price: formatPriceToString(targetPrice, priceDecimals),
|
||||
amount,
|
||||
reduceOnly: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
desired.push({
|
||||
side: "SELL",
|
||||
price: formatPriceToString(targetPrice, priceDecimals),
|
||||
amount,
|
||||
reduceOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
const sticky =
|
||||
anchor == null
|
||||
? null
|
||||
: this.pickStickyPrice({ side, targetBps: target.bps, anchor, amount, pool, claimed });
|
||||
const price = sticky ?? ideal;
|
||||
|
||||
if (anchor != null) {
|
||||
const distance = signedDistanceBps(side, price, anchor);
|
||||
record[side === "BUY" ? "buy" : "sell"] = Number.isFinite(distance) ? distance : null;
|
||||
}
|
||||
|
||||
desired.push({
|
||||
side,
|
||||
price: formatPriceToString(price, priceDecimals),
|
||||
amount,
|
||||
reduceOnly: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.lastQuoteDistanceBps = distances;
|
||||
return desired;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查各档位的深度状态是否发生变化
|
||||
* 当深度从足够变为不足,或从不足变为足够时,需要触发重新计算
|
||||
*/
|
||||
private checkDepthStatusChanged(
|
||||
depth: Depth | null,
|
||||
bid1: number,
|
||||
ask1: number
|
||||
): boolean {
|
||||
const minDepth = this.config.filterMinDepth;
|
||||
if (minDepth <= 0) return false;
|
||||
const priceDecimals = this.getPriceDecimals();
|
||||
|
||||
// 获取启用的所有档位
|
||||
const targets = buildBpsTargets({
|
||||
band0To10: this.config.enableBand0To10,
|
||||
band10To30: this.config.enableBand10To30,
|
||||
band30To100: this.config.enableBand30To100,
|
||||
});
|
||||
|
||||
let changed = false;
|
||||
|
||||
for (const bps of targets) {
|
||||
const buyTargetPrice = this.normalizeDepthTargetPrice(bid1 * (1 - bps / 10000), priceDecimals);
|
||||
const sellTargetPrice = this.normalizeDepthTargetPrice(ask1 * (1 + bps / 10000), priceDecimals);
|
||||
|
||||
const buyDepthQty = getDepthBetweenPrices(depth, "BUY", buyTargetPrice ?? 0);
|
||||
const sellDepthQty = getDepthBetweenPrices(depth, "SELL", sellTargetPrice ?? 0);
|
||||
const currentBuyOk = buyDepthQty >= minDepth;
|
||||
const currentSellOk = sellDepthQty >= minDepth;
|
||||
|
||||
const lastStatus = this.lastDepthOkStatus[bps];
|
||||
if (lastStatus) {
|
||||
if (lastStatus.buy !== currentBuyOk || lastStatus.sell !== currentSellOk) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.lastDepthOkStatus[bps] = { buy: currentBuyOk, sell: currentSellOk };
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当深度从“满足阈值”切换到“不满足阈值”时,立即触发一次主循环,优先撤销不再安全的挂单。
|
||||
* 深度从“满足阈值”切换到“不满足阈值”时立即触发一次主循环,抢在被吃穿前撤单。
|
||||
* 同时维护 lastDepthOkStatus,供下一次比较使用。
|
||||
*/
|
||||
private shouldTriggerImmediateDepthProtection(depth: Depth | null): boolean {
|
||||
if (!depth) return false;
|
||||
@@ -907,47 +939,78 @@ export class MakerPointsEngine {
|
||||
const { topBid, topAsk } = getTopPrices(depth);
|
||||
if (topBid == null || topAsk == null) return false;
|
||||
|
||||
const targets = buildBpsTargets({
|
||||
band0To10: this.config.enableBand0To10,
|
||||
band10To30: this.config.enableBand10To30,
|
||||
band30To100: this.config.enableBand30To100,
|
||||
});
|
||||
const anchor = this.getQuoteAnchor(depth);
|
||||
const priceDecimals = this.getPriceDecimals();
|
||||
let degraded = false;
|
||||
|
||||
for (const bps of targets) {
|
||||
const lastStatus = this.lastDepthOkStatus[bps];
|
||||
if (!lastStatus) continue;
|
||||
for (const target of this.bandTargets()) {
|
||||
const buyPrice = this.normalizeSafeQuote("BUY", target.bps, anchor, topBid, priceDecimals);
|
||||
const sellPrice = this.normalizeSafeQuote("SELL", target.bps, anchor, topAsk, priceDecimals);
|
||||
const currentBuyOk = getDepthBetweenPrices(depth, "BUY", buyPrice ?? 0) >= minDepth;
|
||||
const currentSellOk = getDepthBetweenPrices(depth, "SELL", sellPrice ?? 0) >= minDepth;
|
||||
|
||||
const buyTargetPrice = this.normalizeDepthTargetPrice(topBid * (1 - bps / 10000), priceDecimals);
|
||||
const sellTargetPrice = this.normalizeDepthTargetPrice(topAsk * (1 + bps / 10000), priceDecimals);
|
||||
const buyDepthQty = getDepthBetweenPrices(depth, "BUY", buyTargetPrice ?? 0);
|
||||
const sellDepthQty = getDepthBetweenPrices(depth, "SELL", sellTargetPrice ?? 0);
|
||||
const currentBuyOk = buyDepthQty >= minDepth;
|
||||
const currentSellOk = sellDepthQty >= minDepth;
|
||||
|
||||
if (lastStatus.buy && !currentBuyOk) return true;
|
||||
if (lastStatus.sell && !currentSellOk) return true;
|
||||
const lastStatus = this.lastDepthOkStatus[target.bps];
|
||||
if (lastStatus && ((lastStatus.buy && !currentBuyOk) || (lastStatus.sell && !currentSellOk))) {
|
||||
degraded = true;
|
||||
}
|
||||
this.lastDepthOkStatus[target.bps] = { buy: currentBuyOk, sell: currentSellOk };
|
||||
}
|
||||
|
||||
return false;
|
||||
return degraded;
|
||||
}
|
||||
|
||||
private normalizeSafeQuote(
|
||||
side: "BUY" | "SELL",
|
||||
targetBps: number,
|
||||
anchor: number | null,
|
||||
bookPrice: number,
|
||||
priceDecimals: number
|
||||
): number | null {
|
||||
const raw = resolveSafeQuotePrice({
|
||||
side,
|
||||
targetBps,
|
||||
markPrice: anchor,
|
||||
bookPrice,
|
||||
maxDistanceBps: this.config.maxDistanceBps,
|
||||
});
|
||||
return raw == null ? null : this.normalizeDepthTargetPrice(raw, priceDecimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当盘口相对上次报价偏移超过 minRepriceBps 时,立即触发一次主循环,优先撤销旧报价。
|
||||
* 任一在场挂单已经漂出所有启用档位的容差(或穿过 mark、掉出积分范围)时,
|
||||
* 立即触发一次主循环,不等 500ms 定时器。
|
||||
*/
|
||||
private shouldTriggerImmediateReprice(depth: Depth | null): boolean {
|
||||
if (!depth) return false;
|
||||
if (this.defenseMode || this.reconnectResetPending || this.stopLossProcessing) return false;
|
||||
|
||||
const hasActiveEntryOrders = this.openOrders.some(
|
||||
(order) => order.symbol === this.config.symbol && !order.reduceOnly && isOrderActiveStatus(order.status)
|
||||
);
|
||||
if (!hasActiveEntryOrders) return false;
|
||||
const pool = this.activeEntryOrders();
|
||||
if (!pool.length) return false;
|
||||
|
||||
const { topBid, topAsk } = getTopPrices(depth);
|
||||
if (topBid == null || topAsk == null) return false;
|
||||
const anchor = this.getQuoteAnchor(depth);
|
||||
if (anchor == null) return false;
|
||||
|
||||
return this.shouldReprice(topBid, topAsk);
|
||||
const targets = this.bandTargets();
|
||||
if (!targets.length) return true;
|
||||
|
||||
for (const order of pool) {
|
||||
const price = Number(order.price);
|
||||
if (!Number.isFinite(price) || price <= 0) return true;
|
||||
const side = order.side === "BUY" ? "BUY" : "SELL";
|
||||
const keepable = targets.some((target) =>
|
||||
shouldKeepQuote({
|
||||
side,
|
||||
existingPrice: price,
|
||||
anchor,
|
||||
targetBps: target.bps,
|
||||
toleranceBps: this.toleranceFor(target.bps),
|
||||
maxDistanceBps: this.config.maxDistanceBps,
|
||||
})
|
||||
);
|
||||
if (!keepable) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private buildCloseOnlyOrders(
|
||||
@@ -978,19 +1041,6 @@ export class MakerPointsEngine {
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1077,12 +1127,7 @@ export class MakerPointsEngine {
|
||||
if (target.amount < EPS) continue;
|
||||
try {
|
||||
// reduce-only 订单不能设置 tp/sl,仅开仓单设置止损
|
||||
const priceNum = Number(target.price);
|
||||
const slPrice = target.reduceOnly
|
||||
? undefined
|
||||
: target.side === "BUY"
|
||||
? priceNum - 1
|
||||
: priceNum + 1;
|
||||
const slPrice = target.reduceOnly ? undefined : this.computeStopLossTrigger(target.side, Number(target.price));
|
||||
await placeOrder(this.orderContext, {
|
||||
openOrders: this.openOrders,
|
||||
side: target.side,
|
||||
@@ -1329,6 +1374,21 @@ export class MakerPointsEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开仓单附带的止损触发价:一旦挂单被吃就立刻市价止血。
|
||||
* 按 bps 计算而非固定金额,换标的时不会退化成几百 bps 或落到 tick 之内被交易所拒单。
|
||||
*/
|
||||
private computeStopLossTrigger(side: "BUY" | "SELL", price: number): number | undefined {
|
||||
if (!Number.isFinite(price) || price <= 0) return undefined;
|
||||
const bps = Number(this.config.slOffsetBps);
|
||||
if (!Number.isFinite(bps) || bps <= 0) return undefined;
|
||||
const tick = Math.max(this.precision.priceTick, 1e-9);
|
||||
const offset = Math.max((price * bps) / 10000, tick * 2);
|
||||
const trigger = side === "BUY" ? price - offset : price + offset;
|
||||
if (!Number.isFinite(trigger) || trigger <= 0) return undefined;
|
||||
return Number(formatPriceToString(trigger, this.getPriceDecimals()));
|
||||
}
|
||||
|
||||
private getPriceDecimals(): number {
|
||||
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||
const raw = Math.log10(1 / tick);
|
||||
@@ -1359,13 +1419,24 @@ export class MakerPointsEngine {
|
||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||
const spread = topBid != null && topAsk != null ? topAsk - topBid : null;
|
||||
const pnl = computePositionPnl(position, topBid, topAsk);
|
||||
const bandDepths = this.computeBandDepths(topBid, topAsk);
|
||||
const anchor = this.getQuoteAnchor(this.depthSnapshot);
|
||||
const bandDepths = this.computeBandDepths(topBid, topAsk, anchor);
|
||||
const markRaw = Number(this.tickerSnapshot?.markPrice);
|
||||
const now = Date.now();
|
||||
const orderRestingMs: Record<string, number> = {};
|
||||
for (const order of this.openOrders) {
|
||||
const placed = Number(order.time);
|
||||
if (Number.isFinite(placed) && placed > 0) {
|
||||
orderRestingMs[String(order.orderId)] = Math.max(0, now - placed);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: this.isReady(),
|
||||
symbol: this.config.symbol,
|
||||
topBid,
|
||||
topAsk,
|
||||
markPrice: Number.isFinite(markRaw) && markRaw > 0 ? markRaw : null,
|
||||
spread,
|
||||
priceDecimals: this.getPriceDecimals(),
|
||||
position,
|
||||
@@ -1378,7 +1449,9 @@ export class MakerPointsEngine {
|
||||
lastUpdated: Date.now(),
|
||||
feedStatus: { ...this.feedStatus },
|
||||
binanceDepth: this.binanceDepth.getSnapshot(),
|
||||
maxDistanceBps: this.config.maxDistanceBps,
|
||||
bandDepths,
|
||||
orderRestingMs,
|
||||
quoteStatus: {
|
||||
closeOnly: this.lastCloseOnly,
|
||||
skipBuy: this.lastSkipBuy,
|
||||
@@ -1387,24 +1460,51 @@ export class MakerPointsEngine {
|
||||
};
|
||||
}
|
||||
|
||||
private computeBandDepths(topBid: number | null, topAsk: number | null): MakerPointsSnapshot["bandDepths"] {
|
||||
const bands: MakerPointsSnapshot["bandDepths"] = [
|
||||
{ band: "0-10", bps: 9, buyDepth: null, sellDepth: null, enabled: this.config.enableBand0To10 },
|
||||
{ band: "10-30", bps: 29, buyDepth: null, sellDepth: null, enabled: this.config.enableBand10To30 },
|
||||
{ band: "30-100", bps: 99, buyDepth: null, sellDepth: null, enabled: this.config.enableBand30To100 },
|
||||
];
|
||||
|
||||
if (!this.depthSnapshot || topBid == null || topAsk == null) {
|
||||
return bands;
|
||||
}
|
||||
private computeBandDepths(
|
||||
topBid: number | null,
|
||||
topAsk: number | null,
|
||||
anchor: number | null
|
||||
): BandStatus[] {
|
||||
const enabled: Record<MakerPointsBand, boolean> = {
|
||||
"0-10": this.config.enableBand0To10,
|
||||
"10-30": this.config.enableBand10To30,
|
||||
"30-100": this.config.enableBand30To100,
|
||||
};
|
||||
// 展开全部三档(含未启用的),仪表盘要能看到被关掉的档位
|
||||
const all = buildBandTargets({
|
||||
band0To10: true,
|
||||
band10To30: true,
|
||||
band30To100: true,
|
||||
band0To10Bps: this.config.band0To10Bps,
|
||||
band10To30Bps: this.config.band10To30Bps,
|
||||
band30To100Bps: this.config.band30To100Bps,
|
||||
});
|
||||
const priceDecimals = this.getPriceDecimals();
|
||||
|
||||
return bands.map((band) => {
|
||||
const buyTargetPrice = this.normalizeDepthTargetPrice(topBid * (1 - band.bps / 10000), priceDecimals);
|
||||
const sellTargetPrice = this.normalizeDepthTargetPrice(topAsk * (1 + band.bps / 10000), priceDecimals);
|
||||
const buyDepth = getDepthBetweenPrices(this.depthSnapshot, "BUY", buyTargetPrice ?? 0);
|
||||
const sellDepth = getDepthBetweenPrices(this.depthSnapshot, "SELL", sellTargetPrice ?? 0);
|
||||
return { ...band, buyDepth, sellDepth };
|
||||
return all.map(({ band, bps }) => {
|
||||
const quoted = this.lastQuoteDistanceBps[band];
|
||||
const buyDistanceBps = quoted?.buy ?? null;
|
||||
const sellDistanceBps = quoted?.sell ?? null;
|
||||
const base: BandStatus = {
|
||||
band,
|
||||
bps,
|
||||
enabled: enabled[band],
|
||||
buyDepth: null,
|
||||
sellDepth: null,
|
||||
buyDistanceBps,
|
||||
sellDistanceBps,
|
||||
buyMultiplier: buyDistanceBps == null ? null : makerPointsMultiplier(buyDistanceBps),
|
||||
sellMultiplier: sellDistanceBps == null ? null : makerPointsMultiplier(sellDistanceBps),
|
||||
};
|
||||
if (!this.depthSnapshot || topBid == null || topAsk == null) return base;
|
||||
|
||||
const buyPrice = this.normalizeSafeQuote("BUY", bps, anchor, topBid, priceDecimals);
|
||||
const sellPrice = this.normalizeSafeQuote("SELL", bps, anchor, topAsk, priceDecimals);
|
||||
return {
|
||||
...base,
|
||||
buyDepth: getDepthBetweenPrices(this.depthSnapshot, "BUY", buyPrice ?? 0),
|
||||
sellDepth: getDepthBetweenPrices(this.depthSnapshot, "SELL", sellPrice ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1718,8 +1818,7 @@ export class MakerPointsEngine {
|
||||
// 重置本地状态,强制下一轮重新计算挂单
|
||||
this.desiredOrders = [];
|
||||
this.lastDesiredSummary = null;
|
||||
this.lastQuoteBid1 = null;
|
||||
this.lastQuoteAsk1 = null;
|
||||
this.lastQuoteDistanceBps = {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBpsTargets } from "./maker-points-logic";
|
||||
import {
|
||||
bandRepriceToleranceBps,
|
||||
buildBandTargets,
|
||||
buildBpsTargets,
|
||||
makerPointsMultiplier,
|
||||
resolveSafeQuotePrice,
|
||||
shouldKeepQuote,
|
||||
signedDistanceBps,
|
||||
} from "./maker-points-logic";
|
||||
|
||||
describe("maker points target builder", () => {
|
||||
it("builds fixed bps targets per enabled band", () => {
|
||||
it("uses the default bps per enabled band", () => {
|
||||
const targets = buildBpsTargets({
|
||||
band0To10: true,
|
||||
band10To30: true,
|
||||
band30To100: true,
|
||||
});
|
||||
expect(targets).toEqual([9, 29, 99]);
|
||||
expect(targets).toEqual([9, 29, 40]);
|
||||
});
|
||||
|
||||
it("skips disabled bands", () => {
|
||||
@@ -17,6 +25,115 @@ describe("maker points target builder", () => {
|
||||
band10To30: false,
|
||||
band30To100: true,
|
||||
});
|
||||
expect(targets).toEqual([9, 99]);
|
||||
expect(targets).toEqual([9, 40]);
|
||||
});
|
||||
|
||||
it("lets an explicit bps override the band default", () => {
|
||||
const targets = buildBandTargets({
|
||||
band0To10: true,
|
||||
band10To30: true,
|
||||
band30To100: true,
|
||||
band0To10Bps: 5,
|
||||
band30To100Bps: 60,
|
||||
});
|
||||
expect(targets).toEqual([
|
||||
{ band: "0-10", bps: 5 },
|
||||
{ band: "10-30", bps: 29 },
|
||||
{ band: "30-100", bps: 60 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps a configured bps at the zero-points cliff", () => {
|
||||
const targets = buildBpsTargets({
|
||||
band0To10: false,
|
||||
band10To30: false,
|
||||
band30To100: true,
|
||||
band30To100Bps: 250,
|
||||
});
|
||||
expect(targets).toEqual([100]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maker points multiplier curve", () => {
|
||||
// 活动公布的样例点,用来锁住三段折线的系数
|
||||
it.each([
|
||||
[2, 0.88],
|
||||
[5, 0.7],
|
||||
[10, 0.4],
|
||||
[20, 0.2625],
|
||||
[50, 0.0893],
|
||||
])("matches the published example at %i bps", (distance, expected) => {
|
||||
expect(makerPointsMultiplier(distance)).toBeCloseTo(expected, 4);
|
||||
});
|
||||
|
||||
it("returns zero at and beyond the 100 bps cliff", () => {
|
||||
expect(makerPointsMultiplier(100)).toBe(0);
|
||||
expect(makerPointsMultiplier(101)).toBe(0);
|
||||
});
|
||||
|
||||
it("ranks 40 bps far above the old 99 bps edge quote", () => {
|
||||
expect(makerPointsMultiplier(40)).toBeCloseTo(0.1071, 4);
|
||||
expect(makerPointsMultiplier(99)).toBeCloseTo(0.0018, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safe quote price", () => {
|
||||
const base = { targetBps: 40, maxDistanceBps: 95 };
|
||||
|
||||
it("picks the lower of mark/book for a buy", () => {
|
||||
// mark 低于 bid1 时以 mark 为基准更远离盘口
|
||||
const price = resolveSafeQuotePrice({ ...base, side: "BUY", markPrice: 90_000, bookPrice: 90_020 });
|
||||
expect(price).toBeCloseTo(90_000 * (1 - 0.004), 6);
|
||||
});
|
||||
|
||||
it("picks the higher of mark/book for a sell", () => {
|
||||
const price = resolveSafeQuotePrice({ ...base, side: "SELL", markPrice: 90_050, bookPrice: 90_020 });
|
||||
expect(price).toBeCloseTo(90_050 * (1 + 0.004), 6);
|
||||
});
|
||||
|
||||
it("falls back to the book when mark is unavailable", () => {
|
||||
const price = resolveSafeQuotePrice({ ...base, side: "BUY", markPrice: null, bookPrice: 90_000 });
|
||||
expect(price).toBeCloseTo(90_000 * (1 - 0.004), 6);
|
||||
});
|
||||
|
||||
it("clamps a safer-but-worthless price back inside the cliff", () => {
|
||||
// bid1 已经砸到 mark 下方,照盘口算出的买价会被推过 100 bps 变成零积分
|
||||
const price = resolveSafeQuotePrice({
|
||||
side: "BUY",
|
||||
targetBps: 90,
|
||||
maxDistanceBps: 95,
|
||||
markPrice: 90_500,
|
||||
bookPrice: 90_000,
|
||||
});
|
||||
expect(signedDistanceBps("BUY", price!, 90_500)).toBeCloseTo(95, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("band reprice tolerance", () => {
|
||||
it("keeps the floor for near bands and scales up for far bands", () => {
|
||||
expect(bandRepriceToleranceBps(9, 3, 0.15)).toBeCloseTo(3, 6);
|
||||
expect(bandRepriceToleranceBps(40, 3, 0.15)).toBeCloseTo(6, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sticky quote decision", () => {
|
||||
const base = { side: "BUY" as const, anchor: 90_000, targetBps: 40, toleranceBps: 6, maxDistanceBps: 95 };
|
||||
|
||||
it("keeps a quote that drifted inside the tolerance", () => {
|
||||
// 89_650 距 mark 38.9 bps,仍在 40±6 内
|
||||
expect(shouldKeepQuote({ ...base, existingPrice: 89_650 })).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a quote that drifted outside the tolerance", () => {
|
||||
// 89_500 距 mark 55.6 bps
|
||||
expect(shouldKeepQuote({ ...base, existingPrice: 89_500 })).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a quote that crossed to the wrong side of mark", () => {
|
||||
expect(shouldKeepQuote({ ...base, existingPrice: 90_100 })).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a quote that fell out of the scoring range", () => {
|
||||
expect(shouldKeepQuote({ ...base, targetBps: 90, toleranceBps: 20, existingPrice: 89_100 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,168 @@
|
||||
export type MakerPointsBand = "0-10" | "10-30" | "30-100";
|
||||
|
||||
/**
|
||||
* StandX 的 Maker Points 在距 mark price 100 bps 处倍率归零。
|
||||
* 越过这条线的挂单不产生任何积分,只消耗保证金和下单配额。
|
||||
*/
|
||||
export const MAKER_POINTS_ZERO_BPS = 100;
|
||||
|
||||
/** 布尔开关全开时各档位的默认目标距离(bps)。 */
|
||||
export const DEFAULT_BAND_BPS: Record<MakerPointsBand, number> = {
|
||||
"0-10": 9,
|
||||
"10-30": 29,
|
||||
// 活动改为线性梯度后贴边(99 bps)倍率仅 0.18%,40 bps 仍有 10.7%
|
||||
"30-100": 40,
|
||||
};
|
||||
|
||||
export interface MakerPointsBandConfig {
|
||||
band0To10: boolean;
|
||||
band10To30: boolean;
|
||||
band30To100: boolean;
|
||||
/** 各档位目标距离(bps);省略时回落到 DEFAULT_BAND_BPS。 */
|
||||
band0To10Bps?: number;
|
||||
band10To30Bps?: number;
|
||||
band30To100Bps?: number;
|
||||
}
|
||||
|
||||
export interface BandTarget {
|
||||
band: MakerPointsBand;
|
||||
bps: number;
|
||||
}
|
||||
|
||||
const BAND_ORDER: MakerPointsBand[] = ["0-10", "10-30", "30-100"];
|
||||
|
||||
function resolveBandBps(band: MakerPointsBand, configured: number | undefined): number {
|
||||
if (Number.isFinite(configured) && (configured as number) > 0) {
|
||||
return Math.min(configured as number, MAKER_POINTS_ZERO_BPS);
|
||||
}
|
||||
return DEFAULT_BAND_BPS[band];
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开启用的档位及其目标距离,按距离升序返回。
|
||||
* 布尔开关继续决定档位是否启用,bps 数值可单独覆盖默认值。
|
||||
*/
|
||||
export function buildBandTargets(config: MakerPointsBandConfig): BandTarget[] {
|
||||
const enabled: Record<MakerPointsBand, boolean> = {
|
||||
"0-10": config.band0To10,
|
||||
"10-30": config.band10To30,
|
||||
"30-100": config.band30To100,
|
||||
};
|
||||
const configured: Record<MakerPointsBand, number | undefined> = {
|
||||
"0-10": config.band0To10Bps,
|
||||
"10-30": config.band10To30Bps,
|
||||
"30-100": config.band30To100Bps,
|
||||
};
|
||||
return BAND_ORDER.filter((band) => enabled[band])
|
||||
.map((band) => ({ band, bps: resolveBandBps(band, configured[band]) }))
|
||||
.sort((a, b) => a.bps - b.bps);
|
||||
}
|
||||
|
||||
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);
|
||||
return buildBandTargets(config).map((target) => target.bps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker Points 的线性梯度倍率,三段折线:
|
||||
* 0–10 bps: 100% → 40%
|
||||
* 10–30 bps: 40% → 12.5%
|
||||
* 30–100 bps: 12.5% → 0%
|
||||
* 系数由活动公布的样例点(2/5/10/20/50 bps)反解得到。
|
||||
*/
|
||||
export function makerPointsMultiplier(distanceBps: number): number {
|
||||
if (!Number.isFinite(distanceBps) || distanceBps < 0) return 0;
|
||||
if (distanceBps >= MAKER_POINTS_ZERO_BPS) return 0;
|
||||
if (distanceBps <= 10) return 1 - 0.06 * distanceBps;
|
||||
if (distanceBps <= 30) return 0.4 - 0.01375 * (distanceBps - 10);
|
||||
return (0.125 * (MAKER_POINTS_ZERO_BPS - distanceBps)) / 70;
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂单价相对参考价的带符号距离(bps)。
|
||||
* 正数表示朝“更不容易成交”的方向偏离:BUY 在参考价下方,SELL 在参考价上方。
|
||||
* 负数说明挂单已经穿过参考价,随时可能被吃。
|
||||
*/
|
||||
export function signedDistanceBps(side: "BUY" | "SELL", price: number, anchor: number): number {
|
||||
if (!Number.isFinite(price) || !Number.isFinite(anchor) || anchor <= 0) return Number.NaN;
|
||||
const raw = side === "BUY" ? anchor - price : price - anchor;
|
||||
return (raw / anchor) * 10000;
|
||||
}
|
||||
|
||||
export interface SafeQuoteInput {
|
||||
side: "BUY" | "SELL";
|
||||
/** 目标距离(bps)。 */
|
||||
targetBps: number;
|
||||
/** 交易所 mark price;不可用时传 null。 */
|
||||
markPrice: number | null;
|
||||
/** 盘口一档:BUY 用 bid1,SELL 用 ask1。 */
|
||||
bookPrice: number;
|
||||
/** 距 mark 的最大允许距离(bps),超出即失去积分资格。 */
|
||||
maxDistanceBps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同时以 mark price 和盘口一档为基准算价,取对“不成交”更安全的一侧:
|
||||
* BUY 取更低价、SELL 取更高价。
|
||||
*
|
||||
* 随后按 maxDistanceBps 夹回 —— 否则在 mark 远离盘口时,为了安全选出的价格
|
||||
* 可能被推过 100 bps 悬崖,挂单虽然更安全却一分不得。
|
||||
*/
|
||||
export function resolveSafeQuotePrice(input: SafeQuoteInput): number | null {
|
||||
const { side, targetBps, markPrice, bookPrice, maxDistanceBps } = input;
|
||||
if (!Number.isFinite(bookPrice) || bookPrice <= 0) return null;
|
||||
if (!Number.isFinite(targetBps) || targetBps < 0) return null;
|
||||
|
||||
const mark = Number.isFinite(markPrice ?? Number.NaN) && (markPrice ?? 0) > 0 ? (markPrice as number) : null;
|
||||
const factor = side === "BUY" ? 1 - targetBps / 10000 : 1 + targetBps / 10000;
|
||||
const fromBook = bookPrice * factor;
|
||||
const candidate =
|
||||
mark == null
|
||||
? fromBook
|
||||
: side === "BUY"
|
||||
? Math.min(fromBook, mark * factor)
|
||||
: Math.max(fromBook, mark * factor);
|
||||
|
||||
// 悬崖以 mark 为准;拿不到 mark 时只能用盘口近似
|
||||
const anchor = mark ?? bookPrice;
|
||||
const cap = Math.max(0, Math.min(maxDistanceBps, MAKER_POINTS_ZERO_BPS));
|
||||
const limit = side === "BUY" ? anchor * (1 - cap / 10000) : anchor * (1 + cap / 10000);
|
||||
const clamped = side === "BUY" ? Math.max(candidate, limit) : Math.min(candidate, limit);
|
||||
|
||||
return Number.isFinite(clamped) && clamped > 0 ? clamped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该档位允许的距离漂移(bps)。远档天然容忍更大的漂移,因为同样的盘口移动
|
||||
* 对远档的倍率影响小得多,没必要跟着近档一起撤挂。
|
||||
*/
|
||||
export function bandRepriceToleranceBps(targetBps: number, minRepriceBps: number, ratio: number): number {
|
||||
const floor = Number.isFinite(minRepriceBps) && minRepriceBps > 0 ? minRepriceBps : 0;
|
||||
const scaled = Number.isFinite(ratio) && ratio > 0 ? targetBps * ratio : 0;
|
||||
return Math.max(floor, scaled);
|
||||
}
|
||||
|
||||
export interface KeepQuoteInput {
|
||||
side: "BUY" | "SELL";
|
||||
/** 当前已挂在盘口上的价格。 */
|
||||
existingPrice: number;
|
||||
/** 参考价:优先 mark price。 */
|
||||
anchor: number;
|
||||
targetBps: number;
|
||||
toleranceBps: number;
|
||||
maxDistanceBps: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断现有挂单是否还能原地不动。保持不动意味着这一轮不撤不挂,
|
||||
* 订单得以在盘口连续停留,跨过 Maker Points 的 3 秒计分门槛。
|
||||
*/
|
||||
export function shouldKeepQuote(input: KeepQuoteInput): boolean {
|
||||
const { side, existingPrice, anchor, targetBps, toleranceBps, maxDistanceBps } = input;
|
||||
const distance = signedDistanceBps(side, existingPrice, anchor);
|
||||
if (!Number.isFinite(distance)) return false;
|
||||
// 已经穿到参考价另一侧,随时可能成交,必须立即重挂
|
||||
if (distance <= 0) return false;
|
||||
// 已经掉出积分范围,留着也不得分
|
||||
if (distance >= Math.min(maxDistanceBps, MAKER_POINTS_ZERO_BPS)) return false;
|
||||
return Math.abs(distance - targetBps) <= toleranceBps;
|
||||
}
|
||||
|
||||
@@ -43,12 +43,20 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
||||
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
|
||||
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
|
||||
);
|
||||
// Maker Points 只对停留超过 3 秒的挂单计分,所以存活时长要直接可见
|
||||
const formatResting = (orderId: string | number) => {
|
||||
const ms = snapshot.orderRestingMs[String(orderId)];
|
||||
if (ms == null) return "-";
|
||||
const seconds = ms / 1000;
|
||||
return `${seconds < 3 ? "!" : ""}${formatNumber(seconds, 1)}s`;
|
||||
};
|
||||
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||
id: order.orderId,
|
||||
side: order.side,
|
||||
price: order.price,
|
||||
qty: order.origQty,
|
||||
filled: order.executedQty,
|
||||
resting: formatResting(order.orderId),
|
||||
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||
status: order.status,
|
||||
}));
|
||||
@@ -58,6 +66,7 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
||||
{ 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: "resting", header: "Rest", align: "right", minWidth: 6 },
|
||||
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||
{ key: "status", header: "Status", minWidth: 10 },
|
||||
];
|
||||
@@ -96,6 +105,9 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
||||
: t("offset.imbalance.balanced");
|
||||
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
|
||||
const formatDepth = (value: number | null) => (value == null ? "-" : formatNumber(value, 4));
|
||||
const formatDistance = (value: number | null) => (value == null ? "-" : `${formatNumber(value, 1)}bps`);
|
||||
const formatMultiplier = (value: number | null) =>
|
||||
value == null ? "-" : `${formatNumber(value * 100, 2)}%`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
@@ -110,6 +122,12 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
||||
spread: spreadDisplay,
|
||||
})}
|
||||
</Text>
|
||||
<Text color={snapshot.markPrice == null ? "yellow" : undefined}>
|
||||
{t("makerPoints.markLine", {
|
||||
mark: snapshot.markPrice == null ? "-" : formatNumber(snapshot.markPrice, priceDigits),
|
||||
maxDistance: snapshot.maxDistanceBps,
|
||||
})}
|
||||
</Text>
|
||||
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
|
||||
<Text>
|
||||
{t("makerPoints.quoteLine", {
|
||||
@@ -130,9 +148,15 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
||||
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
|
||||
{t("makerPoints.bandDepthLine", {
|
||||
band: band.band,
|
||||
target: formatNumber(band.bps, 1),
|
||||
buyDist: formatDistance(band.buyDistanceBps),
|
||||
buyMult: formatMultiplier(band.buyMultiplier),
|
||||
buy: formatDepth(band.buyDepth),
|
||||
sellDist: formatDistance(band.sellDistanceBps),
|
||||
sellMult: formatMultiplier(band.sellMultiplier),
|
||||
sell: formatDepth(band.sellDepth),
|
||||
})}
|
||||
{band.enabled ? "" : t("makerPoints.bandDisabled")}
|
||||
</Text>
|
||||
))}
|
||||
<Text>
|
||||
|
||||
@@ -72,7 +72,13 @@ describe("MakerPointsEngine Binance depth health defense", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: true,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
@@ -136,7 +142,13 @@ describe("MakerPointsEngine Binance depth health defense", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: true,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
|
||||
+68
-1
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSymbolFromEnv } from "../src/config";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
@@ -72,3 +72,70 @@ describe("resolveSymbolFromEnv", () => {
|
||||
expect(resolveSymbolFromEnv("ondoperp")).toBe("ETH-USD.P");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makerPointsConfig defaults", () => {
|
||||
async function loadConfig(env: Record<string, string> = {}) {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (key.startsWith("MAKER_POINTS_")) delete process.env[key];
|
||||
}
|
||||
process.env.EXCHANGE = "standx";
|
||||
Object.assign(process.env, env);
|
||||
vi.resetModules();
|
||||
return (await import("../src/config")).makerPointsConfig;
|
||||
}
|
||||
|
||||
it("runs on sane defaults when none of the new vars are set", async () => {
|
||||
const config = await loadConfig();
|
||||
|
||||
expect(config.band0To10Bps).toBe(9);
|
||||
expect(config.band10To30Bps).toBe(29);
|
||||
expect(config.band30To100Bps).toBe(40);
|
||||
expect(config.maxDistanceBps).toBe(95);
|
||||
expect(config.minRepriceBps).toBe(3);
|
||||
expect(config.bandRepriceRatio).toBe(0.15);
|
||||
expect(config.slOffsetBps).toBe(2);
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (typeof value === "number") {
|
||||
expect(Number.isFinite(value), `${key} must be finite`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to defaults for unparseable values", async () => {
|
||||
const config = await loadConfig({
|
||||
MAKER_POINTS_BAND_0_10_BPS: "abc",
|
||||
MAKER_POINTS_BAND_REPRICE_RATIO: "",
|
||||
MAKER_POINTS_SL_OFFSET_BPS: "not-a-number",
|
||||
});
|
||||
|
||||
expect(config.band0To10Bps).toBe(9);
|
||||
expect(config.bandRepriceRatio).toBe(0.15);
|
||||
expect(config.slOffsetBps).toBe(2);
|
||||
});
|
||||
|
||||
it("never lets the distance cap sit inside an enabled band", async () => {
|
||||
// 否则夹回会把挂单推向盘口,正好是最容易成交的方向
|
||||
const config = await loadConfig({
|
||||
MAKER_POINTS_MAX_DISTANCE_BPS: "20",
|
||||
MAKER_POINTS_BAND_30_100_BPS: "60",
|
||||
});
|
||||
|
||||
expect(config.maxDistanceBps).toBe(60);
|
||||
});
|
||||
|
||||
it("ignores a disabled band when widening the cap", async () => {
|
||||
const config = await loadConfig({
|
||||
MAKER_POINTS_MAX_DISTANCE_BPS: "20",
|
||||
MAKER_POINTS_BAND_30_100: "false",
|
||||
MAKER_POINTS_BAND_30_100_BPS: "60",
|
||||
});
|
||||
|
||||
expect(config.maxDistanceBps).toBe(29);
|
||||
});
|
||||
|
||||
it("caps the distance at the zero-points cliff", async () => {
|
||||
const config = await loadConfig({ MAKER_POINTS_MAX_DISTANCE_BPS: "500" });
|
||||
|
||||
expect(config.maxDistanceBps).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,13 @@ describe("MakerPointsEngine Binance depth monitor config", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: true,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
@@ -79,7 +85,13 @@ describe("MakerPointsEngine Binance depth monitor config", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: true,
|
||||
binanceDepthWindowBps: 7,
|
||||
binanceDepthImbalanceRatio: 11,
|
||||
|
||||
@@ -67,7 +67,13 @@ describe("MakerPointsEngine defense-mode account staleness", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
@@ -107,7 +113,13 @@ describe("MakerPointsEngine defense-mode account staleness", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
|
||||
@@ -85,7 +85,13 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
@@ -139,7 +145,13 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
|
||||
@@ -67,7 +67,13 @@ describe("MakerPointsEngine immediate depth protection", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 10,
|
||||
},
|
||||
|
||||
@@ -45,70 +45,110 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function buildEngine(adapter: StubAdapter, restingBuyPrice: string): MakerPointsEngine {
|
||||
const engine = new MakerPointsEngine(
|
||||
{
|
||||
symbol: "BTC-USD",
|
||||
perOrderAmount: 0.01,
|
||||
closeThreshold: 0,
|
||||
stopLossUsd: 1,
|
||||
refreshIntervalMs: 10_000,
|
||||
maxLogEntries: 20,
|
||||
maxCloseSlippagePct: 0.05,
|
||||
priceTick: 0.1,
|
||||
qtyStep: 0.001,
|
||||
enableBand0To10: true,
|
||||
enableBand10To30: false,
|
||||
enableBand30To100: false,
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
adapter
|
||||
);
|
||||
|
||||
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
|
||||
(engine as any).initialOrderSnapshotReady = true;
|
||||
(engine as any).defenseMode = false;
|
||||
(engine as any).reconnectResetPending = false;
|
||||
(engine as any).stopLossProcessing = false;
|
||||
(engine as any).openOrders = [
|
||||
{
|
||||
orderId: 1,
|
||||
clientOrderId: "entry-order",
|
||||
symbol: "BTC-USD",
|
||||
side: "BUY",
|
||||
type: "LIMIT",
|
||||
status: "NEW",
|
||||
price: restingBuyPrice,
|
||||
origQty: "0.01",
|
||||
executedQty: "0",
|
||||
stopPrice: "0",
|
||||
time: Date.now(),
|
||||
updateTime: Date.now(),
|
||||
reduceOnly: false,
|
||||
closePosition: false,
|
||||
},
|
||||
];
|
||||
return engine;
|
||||
}
|
||||
|
||||
// bid1 99.9 / ask1 100.9 → 中值 100.4;0-10 档目标 9 bps,容差 max(3, 9×0.15)=3
|
||||
// 所以保留窗口是距中值 6–12 bps,即 100.28–100.34
|
||||
const DEPTH = {
|
||||
lastUpdateId: 1,
|
||||
bids: [["99.9", "1"]] as Array<[string, string]>,
|
||||
asks: [["100.9", "1"]] as Array<[string, string]>,
|
||||
eventTime: Date.now(),
|
||||
symbol: "BTC-USD",
|
||||
};
|
||||
|
||||
describe("MakerPointsEngine immediate reprice", () => {
|
||||
it("triggers an immediate tick when min reprice bps threshold is reached", () => {
|
||||
it("leaves a quote alone while it is still inside its band tolerance", () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = new StubAdapter();
|
||||
|
||||
const engine = new MakerPointsEngine(
|
||||
{
|
||||
symbol: "BTC-USD",
|
||||
perOrderAmount: 0.01,
|
||||
closeThreshold: 0,
|
||||
stopLossUsd: 1,
|
||||
refreshIntervalMs: 10_000,
|
||||
maxLogEntries: 20,
|
||||
maxCloseSlippagePct: 0.05,
|
||||
priceTick: 0.1,
|
||||
qtyStep: 0.001,
|
||||
enableBand0To10: true,
|
||||
enableBand10To30: false,
|
||||
enableBand30To100: false,
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
minRepriceBps: 3,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
adapter
|
||||
);
|
||||
|
||||
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
|
||||
(engine as any).initialOrderSnapshotReady = true;
|
||||
(engine as any).defenseMode = false;
|
||||
(engine as any).reconnectResetPending = false;
|
||||
(engine as any).stopLossProcessing = false;
|
||||
(engine as any).lastQuoteBid1 = 100;
|
||||
(engine as any).lastQuoteAsk1 = 101;
|
||||
(engine as any).openOrders = [
|
||||
{
|
||||
orderId: 1,
|
||||
clientOrderId: "entry-order",
|
||||
symbol: "BTC-USD",
|
||||
side: "BUY",
|
||||
type: "LIMIT",
|
||||
status: "NEW",
|
||||
price: "99.0",
|
||||
origQty: "0.01",
|
||||
executedQty: "0",
|
||||
stopPrice: "0",
|
||||
time: Date.now(),
|
||||
updateTime: Date.now(),
|
||||
reduceOnly: false,
|
||||
closePosition: false,
|
||||
},
|
||||
];
|
||||
|
||||
// 100.31 距中值 8.96 bps,仍在 9±3 内 —— 不该撤挂,订单得以跨过 3 秒计分门槛
|
||||
const engine = buildEngine(adapter, "100.31");
|
||||
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
|
||||
|
||||
adapter.emitDepth({
|
||||
lastUpdateId: 1,
|
||||
bids: [["99.9", "1"]],
|
||||
asks: [["100.9", "1"]],
|
||||
eventTime: Date.now(),
|
||||
symbol: "BTC-USD",
|
||||
});
|
||||
adapter.emitDepth(DEPTH);
|
||||
|
||||
expect(tickSpy).not.toHaveBeenCalled();
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
it("triggers an immediate tick once the quote drifts out of every band", () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = new StubAdapter();
|
||||
// 100.25 距中值 14.94 bps,已经掉出 9±3
|
||||
const engine = buildEngine(adapter, "100.25");
|
||||
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
|
||||
|
||||
adapter.emitDepth(DEPTH);
|
||||
|
||||
expect(tickSpy).toHaveBeenCalledTimes(1);
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
it("measures drift against mark price rather than the book mid", () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = new StubAdapter();
|
||||
// 同一张单:按中值 100.4 算是安全的,但 mark 已经跌到 100.0,
|
||||
// 买单实际挂在 mark 上方 31 bps,随时会被吃 —— 必须立即重挂
|
||||
const engine = buildEngine(adapter, "100.31");
|
||||
(engine as any).tickerSnapshot = { symbol: "BTC-USD", markPrice: "100.0" };
|
||||
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
|
||||
|
||||
adapter.emitDepth(DEPTH);
|
||||
|
||||
expect(tickSpy).toHaveBeenCalledTimes(1);
|
||||
engine.stop();
|
||||
|
||||
@@ -81,7 +81,13 @@ describe("MakerPointsEngine StandX isolated margin guard", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
@@ -146,7 +152,13 @@ describe("MakerPointsEngine StandX isolated margin guard", () => {
|
||||
band0To10Amount: 0.01,
|
||||
band10To30Amount: 0.01,
|
||||
band30To100Amount: 0.01,
|
||||
band0To10Bps: 9,
|
||||
band10To30Bps: 29,
|
||||
band30To100Bps: 40,
|
||||
maxDistanceBps: 95,
|
||||
minRepriceBps: 3,
|
||||
bandRepriceRatio: 0.15,
|
||||
slOffsetBps: 2,
|
||||
enableBinanceDepthCancel: false,
|
||||
filterMinDepth: 0,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user