diff --git a/.env.example b/.env.example index e7c9889..92b5e80 100644 --- a/.env.example +++ b/.env.example @@ -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") \ No newline at end of file diff --git a/docs/standx/maker-points-guide.md b/docs/standx/maker-points-guide.md index 5b2b0d0..6c1c004 100644 --- a/docs/standx/maker-points-guide.md +++ b/docs/standx/maker-points-guide.md @@ -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` 或者设置成一个比你持仓大的数字。 diff --git a/src/config.ts b/src/config.ts index 4f73e6a..2e1133a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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), diff --git a/src/i18n/index.ts b/src/i18n/index.ts index cc5be82..7358a32 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -243,6 +243,10 @@ const translations: Record = { 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 = { 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" }, diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index e8b9ad8..da322c2 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -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; 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 = {}; + /** 最近一轮实际下发的报价距 mark 的距离,用于仪表盘展示倍率。 */ + private lastQuoteDistanceBps: Partial> = {}; 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; + }): 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(); + const distances: Partial> = {}; - 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 { 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 = {}; + 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 = { + "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 = {}; } /** diff --git a/src/strategy/maker-points-logic.test.ts b/src/strategy/maker-points-logic.test.ts index 9dc776e..18f8cd6 100644 --- a/src/strategy/maker-points-logic.test.ts +++ b/src/strategy/maker-points-logic.test.ts @@ -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); }); }); diff --git a/src/strategy/maker-points-logic.ts b/src/strategy/maker-points-logic.ts index 5136eca..58e6714 100644 --- a/src/strategy/maker-points-logic.ts +++ b/src/strategy/maker-points-logic.ts @@ -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 = { + "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 = { + "0-10": config.band0To10, + "10-30": config.band10To30, + "30-100": config.band30To100, + }; + const configured: Record = { + "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; } diff --git a/src/ui/MakerPointsApp.tsx b/src/ui/MakerPointsApp.tsx index a5df49e..b482c0a 100644 --- a/src/ui/MakerPointsApp.tsx +++ b/src/ui/MakerPointsApp.tsx @@ -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 ( @@ -110,6 +122,12 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) { spread: spreadDisplay, })} + + {t("makerPoints.markLine", { + mark: snapshot.markPrice == null ? "-" : formatNumber(snapshot.markPrice, priceDigits), + maxDistance: snapshot.maxDistanceBps, + })} + {t("trend.statusLine", { status: readyStatus })} {t("makerPoints.quoteLine", { @@ -130,9 +148,15 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) { {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")} ))} diff --git a/tests/binance-depth-health-integration.test.ts b/tests/binance-depth-health-integration.test.ts index f83beb7..4b9f8c0 100644 --- a/tests/binance-depth-health-integration.test.ts +++ b/tests/binance-depth-health-integration.test.ts @@ -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, }, diff --git a/tests/config.test.ts b/tests/config.test.ts index 67f59c0..42dcad7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/tests/maker-points-binance-depth-config.test.ts b/tests/maker-points-binance-depth-config.test.ts index 0d28114..e8651d6 100644 --- a/tests/maker-points-binance-depth-config.test.ts +++ b/tests/maker-points-binance-depth-config.test.ts @@ -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, diff --git a/tests/maker-points-defense-account-stale.test.ts b/tests/maker-points-defense-account-stale.test.ts index 8ac7f92..e84cc3e 100644 --- a/tests/maker-points-defense-account-stale.test.ts +++ b/tests/maker-points-defense-account-stale.test.ts @@ -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, }, diff --git a/tests/maker-points-defense-rest.test.ts b/tests/maker-points-defense-rest.test.ts index 36b37c7..fc39476 100644 --- a/tests/maker-points-defense-rest.test.ts +++ b/tests/maker-points-defense-rest.test.ts @@ -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, }, diff --git a/tests/maker-points-immediate-depth-protection.test.ts b/tests/maker-points-immediate-depth-protection.test.ts index a2bc20e..6e47497 100644 --- a/tests/maker-points-immediate-depth-protection.test.ts +++ b/tests/maker-points-immediate-depth-protection.test.ts @@ -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, }, diff --git a/tests/maker-points-immediate-reprice.test.ts b/tests/maker-points-immediate-reprice.test.ts index 7e7ef27..6ddf823 100644 --- a/tests/maker-points-immediate-reprice.test.ts +++ b/tests/maker-points-immediate-reprice.test.ts @@ -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(); diff --git a/tests/maker-points-margin-mode.test.ts b/tests/maker-points-margin-mode.test.ts index 99de902..0d1e84d 100644 --- a/tests/maker-points-margin-mode.test.ts +++ b/tests/maker-points-margin-mode.test.ts @@ -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, },