3 Commits
Author SHA1 Message Date
discountry 0a757985b8 feat(npm): enhance .npmignore and add prepublish check script
Updated .npmignore to include sensitive files such as .env and key files, ensuring they are excluded from npm packages. Introduced a new script, check-pack.ts, to validate that no sensitive files are included in the npm tarball before publishing. Updated package.json to include the new script in the prepublish process and expanded the files whitelist for packaging.
2026-08-20 21:46:50 +08:00
discountry 85954461f3 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.
2026-08-18 15:16:49 +08:00
discountry f3a96886ac feat(lighter): add Robinhood Chain venue support 2026-08-14 14:05:37 +08:00
33 changed files with 2155 additions and 445 deletions
+44 -11
View File
@@ -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)
+8
View File
@@ -1,3 +1,11 @@
docs/
.claude/
.cursor/
# 密钥文件:package.json 的 files 白名单之外的第二道防线。
# 注意 .npmignore 一旦存在就会完全接管 .gitignore.gitignore 里的规则不再生效。
.env
.env.*
!.env.example
*.pem
*.key
+1
View File
@@ -10,6 +10,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
* [Lighter Robinhood Chain 注册链接](https://robinhoodchain.lighter.xyz/?referral=RITMEX) —— 额外 10% 积分加成
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=RITMEX)
* [Hyperliquid 邀请注册链接](https://app.hyperliquid.xyz/join/RITMEX)
* [Ondo Perps 邀请注册链接](https://app.ondoperps.xyz/?ref=4A3ACQ)
+1
View File
@@ -6,6 +6,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
If you'd like to support this project and get fee discounts, please consider using these referral links:
* [Lighter Robinhood Chain referral link](https://robinhoodchain.lighter.xyz/?referral=RITMEX) — 10% bonus points
* [Lighter referral link](https://app.lighter.xyz/?referral=RITMEX)
* [Hyperliquid referral link](https://app.hyperliquid.xyz/join/RITMEX)
* [Ondo Perps referral link](https://app.ondoperps.xyz/?ref=4A3ACQ)
+53 -15
View File
@@ -8,18 +8,30 @@ This guide configures Lighter perpetuals and the integrated Spot markets. Lighte
## 1. Select a network
| `LIGHTER_ENV` | REST URL | Chain ID |
| --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
| `LIGHTER_ENV` | REST URL | WebSocket | Signing chain ID | Quote asset |
| --- | --- | --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
| `rh-testnet` | `https://api.rh-testnet.lighter.xyz` | `wss://api.rh-testnet.lighter.xyz/stream` | `300` | USDG |
| `staging` | `https://staging.zklighter.elliot.ai` | `wss://staging.zklighter.elliot.ai/stream` | `300` | USDC |
| `dev` | `https://dev.zklighter.elliot.ai` | `wss://dev.zklighter.elliot.ai/stream` | `300` | USDC |
The current default is `testnet`. Set `LIGHTER_ENV=mainnet` explicitly for production trading.
`rh` is the Robinhood Chain deployment (web app at `robinhoodchain.lighter.xyz`). It is a separate chain from the main venue: accounts, API keys, market IDs and funds are not shared, and the signing chain ID differs.
**Switching venues means changing only `LIGHTER_ENV`** — the REST URL, WebSocket URL and signing chain ID are all derived from it together, so they cannot drift apart. The aliases `robinhood`, `robinhoodchain` and `rhc` all mean `rh`.
The current default is `testnet`. Set `LIGHTER_ENV=mainnet` or `LIGHTER_ENV=rh` explicitly for production trading.
At startup the bot prints one confirmation line and calls `/api/v1/layer1BasicInfo` to check the L1 chain ID and ZkLighter contract address against the configured deployment, failing immediately on a mismatch:
```
[Lighter] env=rh rest=https://api.rh.lighter.xyz ws=wss://api.rh.lighter.xyz/stream chainId=466324 account=12345
```
## 2. Obtain the account index and API key
1. Create and fund an account on [Lighter](https://app.lighter.xyz/?referral=111909FA).
1. Create and fund an account on [Robinhood Chain](https://robinhoodchain.lighter.xyz/?referral=RITMEX) (10% bonus points) or the [Lighter main venue](https://app.lighter.xyz/?referral=111909FA). Accounts on the two are independent.
2. Follow the official [Get Started guide](https://apidocs.lighter.xyz/docs/get-started) to query `account_index` from the L1 address.
3. Follow the official [API Keys guide](https://apidocs.lighter.xyz/docs/api-keys) to create an API key.
4. Save the API private key returned by the creation flow and record its `api_key_index`.
@@ -53,22 +65,45 @@ LIGHTER_SYMBOL=BTC
Testnet and mainnet credentials cannot be mixed.
## 5. Optional settings
## 5. Robinhood Chain configuration
```dotenv
EXCHANGE=lighter
LIGHTER_ENV=rh
LIGHTER_ACCOUNT_INDEX=<your_rh_account_index>
LIGHTER_API_KEY_INDEX=<your_rh_api_key_index>
LIGHTER_API_PRIVATE_KEY=<your_rh_api_private_key_hex>
LIGHTER_SYMBOL=BTC
```
What changes when switching venues:
- **Credentials are venue-specific.** Create the account index and API key on Robinhood Chain itself.
- **Market IDs use a different numbering**, so reusing one across venues points at the wrong instrument. Leave `LIGHTER_MARKET_ID` unset unless metadata resolution fails, and clear it when coming from the main venue.
- **Spot is quoted in USDG, not USDC** — spot symbols look like `ETH/USDG`.
- The venue lists equity perpetuals (`TSLA`, `AAPL`, `NVDA`, …) and tokenized equity spot markets.
- `SGOV/USDG`, `ORCL/USDG` and `MU/USDG` have a contract `multiplier` other than 1 while order scaling assumes 1.0, so those markets are refused. Set `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` to trade them anyway.
## 6. Optional settings
| Variable | Purpose |
| --- | --- |
| `LIGHTER_BASE_URL` | Overrides the REST URL; known hostnames also determine the network |
| `LIGHTER_BASE_URL` | Overrides the REST URL; known hostnames determine the network, and a web-app URL (e.g. `robinhoodchain.lighter.xyz`) is remapped to its API host |
| `LIGHTER_WS_URL` | Overrides the WebSocket URL; derived from `LIGHTER_ENV` or `LIGHTER_BASE_URL` otherwise |
| `LIGHTER_L1_ADDRESS` | L1 address associated with the account |
| `LIGHTER_MARKET_ID` | Forces a market ID when metadata resolution fails |
| `LIGHTER_MARKET_ID` | Forces a market ID when metadata resolution fails; never reuse across venues |
| `LIGHTER_MARKET_TYPE` | `perp` or `spot` |
| `LIGHTER_PRICE_DECIMALS` | Forces price decimals |
| `LIGHTER_SIZE_DECIMALS` | Forces size decimals |
| `LIGHTER_CHAIN_ID` | Overrides the signing chain ID |
| `LIGHTER_CHAIN_ID` | Overrides the signing chain ID; required for a self-hosted or proxied host that cannot be recognized |
| `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER` | Allows trading markets whose `multiplier` is not 1 |
| `LIGHTER_DEBUG` | Set to `1` or `true` for debug output |
Spot markets use symbols such as `ETH/USDC`. Explicit market IDs and decimal overrides must match order-book metadata for the selected network.
Spot markets use symbols such as `ETH/USDC` (main venue) or `ETH/USDG` (Robinhood Chain). Explicit market IDs and decimal overrides must match order-book metadata for the selected network.
## 6. Verify the configuration
For a self-hosted node or a proxy whose hostname cannot be recognized, `LIGHTER_CHAIN_ID` is mandatory: no endpoint exposes the signing chain ID, and guessing it wrong makes every transaction fail signature verification, so startup fails loudly instead of assuming a default.
## 7. Verify the configuration
```bash
bun run index.ts doctor --exchange lighter --symbol BTC --json
@@ -82,7 +117,10 @@ The ticker check loads market metadata, validates the account/API-key pair, and
- `LIGHTER_ACCOUNT_INDEX must be an integer`: use the numeric index returned by the account API.
- `Invalid LIGHTER_API_KEY_INDEX`: use the non-negative integer recorded during key creation.
- `private key does not match the one on Lighter`: the account index, key index, private key, or network differs.
- `Configured market id ... not found`: verify `LIGHTER_ENV`, `LIGHTER_SYMBOL`, and any manual market ID.
- `Configured market id ... not found`: verify `LIGHTER_ENV`, `LIGHTER_SYMBOL`, and any manual market ID. After switching venues the usual cause is a `LIGHTER_MARKET_ID` left over from the previous one.
- `Lighter network mismatch`: the REST URL and `LIGHTER_ENV` point at different deployments, caught before any order is signed. Reconcile `LIGHTER_ENV` and `LIGHTER_BASE_URL` against the table above.
- `Unknown Lighter environment`: `LIGHTER_ENV` is misspelled; the error lists every valid value and alias.
- `has contract multiplier ... not 1.0`: the market's contract multiplier is not 1 and sizing could be wrong; set `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` once you have verified the scaling.
- Signer loading failures: the repository ships macOS arm64 and Linux amd64 signer libraries. Other platforms require a compatible signer build or a supported WSL/Linux environment.
## Security
+53 -15
View File
@@ -8,18 +8,30 @@ English version: [Lighter Configuration Guide](lighter.en.md)
## 1. 选择网络
| `LIGHTER_ENV` | REST 地址 | Chain ID |
| --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
| `LIGHTER_ENV` | REST 地址 | WebSocket | 签名 Chain ID | 计价资产 |
| --- | --- | --- | --- | --- |
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
| `rh-testnet` | `https://api.rh-testnet.lighter.xyz` | `wss://api.rh-testnet.lighter.xyz/stream` | `300` | USDG |
| `staging` | `https://staging.zklighter.elliot.ai` | `wss://staging.zklighter.elliot.ai/stream` | `300` | USDC |
| `dev` | `https://dev.zklighter.elliot.ai` | `wss://dev.zklighter.elliot.ai/stream` | `300` | USDC |
当前默认值为 `testnet`。生产交易应显式设置 `LIGHTER_ENV=mainnet`
`rh` 是 Robinhood Chain 部署(网页端 `robinhoodchain.lighter.xyz`)。它与主站是两条独立的链:账户、API Key、market ID 和资金都不互通,签名 Chain ID 也不同
**切换平台只需要改 `LIGHTER_ENV` 这一个变量** —— REST 地址、WebSocket 地址和签名 Chain ID 都由它一起派生,不会出现只改了一半的错配。别名 `robinhood``robinhoodchain``rhc` 等价于 `rh`
当前默认值为 `testnet`。生产交易应显式设置 `LIGHTER_ENV=mainnet``LIGHTER_ENV=rh`
启动时机器人会打印一行确认,并调用 `/api/v1/layer1BasicInfo` 用 L1 Chain ID 与 ZkLighter 合约地址核对连接的确实是配置声明的那条链,不一致直接报错退出:
```
[Lighter] env=rh rest=https://api.rh.lighter.xyz ws=wss://api.rh.lighter.xyz/stream chainId=466324 account=12345
```
## 2. 获取账户索引和 API Key
1. 在 [Lighter](https://app.lighter.xyz/?referral=111909FA) 创建并入金账户
1. 创建并入金账户:[Robinhood Chain](https://robinhoodchain.lighter.xyz/?referral=RITMEX)(额外 10% 积分加成)或 [Lighter 主站](https://app.lighter.xyz/?referral=111909FA)。两个平台的账户互相独立
2. 按[官方 Get Started](https://apidocs.lighter.xyz/docs/get-started) 使用 L1 地址查询 `account_index`
3. 按[官方 API Keys 指南](https://apidocs.lighter.xyz/docs/api-keys) 创建 API Key。
4. 保存创建流程返回的 API 私钥,并记录对应的 `api_key_index`
@@ -53,22 +65,45 @@ LIGHTER_SYMBOL=BTC
测试网和主网凭证不可混用。
## 5. 可选配置
## 5. Robinhood Chain 配置
```dotenv
EXCHANGE=lighter
LIGHTER_ENV=rh
LIGHTER_ACCOUNT_INDEX=<your_rh_account_index>
LIGHTER_API_KEY_INDEX=<your_rh_api_key_index>
LIGHTER_API_PRIVATE_KEY=<your_rh_api_private_key_hex>
LIGHTER_SYMBOL=BTC
```
切换平台时的注意事项:
- **凭证不通用**Robinhood Chain 的账户索引和 API Key 必须在该平台单独创建。
- **market ID 是另一套编号**,跨平台复用必然指向错误的标的。除非自动解析失败,否则不要设置 `LIGHTER_MARKET_ID`;从主站切过来时务必清掉这个变量。
- **现货计价资产是 USDG 而非 USDC**,现货符号写成 `ETH/USDG`
- 该平台提供股票类永续(`TSLA``AAPL``NVDA` 等)和代币化股票现货。
- `SGOV/USDG``ORCL/USDG``MU/USDG` 三个现货市场的合约 `multiplier` 不等于 1,而下单数量/价格换算按 1.0 处理,因此这些市场会被直接拒绝。确认自己清楚换算关系后可用 `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` 放行。
## 6. 可选配置
| 变量 | 说明 |
| --- | --- |
| `LIGHTER_BASE_URL` | 覆盖 REST 地址;网络可从已知主机名推断 |
| `LIGHTER_BASE_URL` | 覆盖 REST 地址;已知主机名会自动推断网络,填入网页端地址(如 `robinhoodchain.lighter.xyz`)会自动换成对应 API 地址 |
| `LIGHTER_WS_URL` | 覆盖 WebSocket 地址;不填时由 `LIGHTER_ENV``LIGHTER_BASE_URL` 派生 |
| `LIGHTER_L1_ADDRESS` | 账户关联的 L1 地址 |
| `LIGHTER_MARKET_ID` | 强制 market ID;仅在自动解析失败时设置 |
| `LIGHTER_MARKET_ID` | 强制 market ID;仅在自动解析失败时设置,且不可跨平台复用 |
| `LIGHTER_MARKET_TYPE` | `perp``spot` |
| `LIGHTER_PRICE_DECIMALS` | 强制价格小数位 |
| `LIGHTER_SIZE_DECIMALS` | 强制数量小数位 |
| `LIGHTER_CHAIN_ID` | 覆盖签名 Chain ID |
| `LIGHTER_CHAIN_ID` | 覆盖签名 Chain ID;自建/代理主机无法识别网络时必填 |
| `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER` | 允许交易 `multiplier ≠ 1` 的市场 |
| `LIGHTER_DEBUG` | 设置为 `1``true` 输出调试日志 |
现货市场使用 `ETH/USDC` 这类符号。显式 market ID、价格小数位和数量小数位必须与目标网络的 order book 元数据一致。
现货市场使用 `ETH/USDC`(主站)或 `ETH/USDG`Robinhood Chain这类符号。显式 market ID、价格小数位和数量小数位必须与目标网络的 order book 元数据一致。
## 6. 验证配置
自建节点或走代理时,若主机名无法识别为已知部署,则必须显式设置 `LIGHTER_CHAIN_ID` —— 签名 Chain ID 没有任何接口可以查询,猜错会导致每一笔交易验签失败,因此这里选择直接报错而不是使用默认值。
## 7. 验证配置
```bash
bun run index.ts doctor --exchange lighter --symbol BTC --json
@@ -82,7 +117,10 @@ bun run index.ts market ticker --exchange lighter --symbol BTC --json
- `LIGHTER_ACCOUNT_INDEX must be an integer`:填写账户接口返回的数字索引。
- `Invalid LIGHTER_API_KEY_INDEX`:使用创建 Key 时记录的非负整数索引。
- `private key does not match the one on Lighter`:账户索引、Key 索引、私钥或网络不匹配。
- `Configured market id ... not found`:检查 `LIGHTER_ENV``LIGHTER_SYMBOL` 和手动 market ID。
- `Configured market id ... not found`:检查 `LIGHTER_ENV``LIGHTER_SYMBOL` 和手动 market ID。跨平台切换后最常见的原因是 `LIGHTER_MARKET_ID` 仍是上一个平台的编号。
- `Lighter network mismatch`REST 地址与 `LIGHTER_ENV` 指向了不同的部署,机器人在下单前拦下了这个错配。按上表核对 `LIGHTER_ENV``LIGHTER_BASE_URL`
- `Unknown Lighter environment``LIGHTER_ENV` 拼写错误,报错信息会列出全部合法取值与别名。
- `has contract multiplier ... not 1.0`:该市场的合约乘数不为 1,换算可能失真;确认无误后用 `LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1` 放行。
- signer 加载失败:仓库预置 macOS arm64 与 Linux amd64 签名库,其他平台需要构建兼容签名库或使用受支持的 WSL/Linux 环境。
## 安全要求
+126
View File
@@ -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` 或者设置成一个比你持仓大的数字。
+15
View File
@@ -11,6 +11,19 @@
"bin": {
"ritmex-bot": "./bin/ritmex-bot"
},
"files": [
"bin",
"src",
"scripts",
"index.ts",
"tsconfig.json",
"setup.sh",
".env.example",
"README_en.md",
"cli-guide.md",
"cli-guide.en.md",
"grid-trading.md"
],
"scripts": {
"dev": "bun run index.ts",
"start": "bun run index.ts",
@@ -20,6 +33,8 @@
"test": "bun x vitest run",
"test:exchange-contract": "bun x vitest run tests/exchange-contract-suite.test.ts tests/exchange-factory.test.ts tests/config.test.ts",
"test:watch": "bun x vitest",
"check:pack": "bun run scripts/check-pack.ts",
"prepublishOnly": "bun run scripts/check-pack.ts",
"start:trend:silent": "bun run index.ts --strategy trend --silent",
"start:maker:silent": "bun run index.ts --strategy maker --silent",
"start:offset:silent": "bun run index.ts --strategy offset-maker --silent",
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bun
// 发布前闸门:阻止密钥文件进入 npm tarball。
// 0.1.0 曾把 .env 发到 registry —— .npmignore 一旦存在就完全接管 .gitignore
// 而当时的 .npmignore 没有列 .env.gitignore 里的规则形同虚设。
import { spawnSync } from "node:child_process";
const DENY = [
/^\.env$/,
/^\.env\.(?!example$)/,
/^\.npmrc$/,
/\.pem$/,
/\.key$/,
/(^|\/)id_(rsa|ed25519)$/,
];
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { encoding: "utf8" });
if (result.status !== 0) {
console.error(result.stderr);
process.exit(1);
}
const [meta] = JSON.parse(result.stdout) as Array<{ files: Array<{ path: string }> }>;
if (!meta) {
console.error("无法解析 npm pack 输出,发布已中止。");
process.exit(1);
}
const leaked = meta.files.map((file) => file.path).filter((path) => DENY.some((re) => re.test(path)));
if (leaked.length > 0) {
console.error("\n发布已中止 —— tarball 中包含密钥文件:");
for (const path of leaked) console.error(` - ${path}`);
console.error("");
process.exit(1);
}
console.log(`pack 检查通过:${meta.files.length} 个文件,未发现密钥文件。`);
+50 -3
View File
@@ -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),
+3 -1
View File
@@ -22,6 +22,7 @@ export interface LighterCredentials {
apiKeyIndex?: number;
environment?: string;
baseUrl?: string;
wsUrl?: string;
marketId?: number;
priceDecimals?: number;
sizeDecimals?: number;
@@ -60,7 +61,8 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
accountIndex,
apiKeys,
baseUrl: credentials.baseUrl ?? process.env.LIGHTER_BASE_URL,
environment: environment as LighterGatewayOptions["environment"],
environment,
wsUrl: credentials.wsUrl ?? process.env.LIGHTER_WS_URL,
marketId,
priceDecimals,
sizeDecimals,
+85 -7
View File
@@ -1,36 +1,114 @@
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev";
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev" | "rh" | "rh-testnet";
export interface LighterHostConfig {
rest: string;
ws: string;
}
export const LIGHTER_HOSTS: Record<LighterEnvironment, LighterHostConfig> = {
export interface LighterNetworkConfig extends LighterHostConfig {
/**
* Chain id folded into every signature. A wrong value is unrecoverable: the signer
* happily produces a payload and the sequencer rejects every transaction.
* No endpoint exposes it, so this table is the only source of truth.
*/
chainId: number;
/**
* `l1_providers[0].chainId` from `/api/v1/layer1BasicInfo`, plus the ZkLighter contract
* address the only deployment fingerprints the server hands out. Used at startup to
* prove REST really points at the venue the config claims. `null` = not verified.
*/
l1ChainId: number | null;
zkLighterContract: string | null;
/** Settlement/quote asset the venue defaults to when metadata does not name one. */
defaultQuoteAsset: string;
}
/**
* Every knob a deployment needs, bound together so no caller can mix a REST host from one
* venue with the chain id or websocket of another.
*/
export const LIGHTER_NETWORKS: Record<LighterEnvironment, LighterNetworkConfig> = {
mainnet: {
rest: "https://mainnet.zklighter.elliot.ai",
ws: "wss://mainnet.zklighter.elliot.ai/stream",
chainId: 304,
l1ChainId: 1,
zkLighterContract: "0x3B4D794a66304F130a4Db8F2551B0070dfCf5ca7",
defaultQuoteAsset: "USDC",
},
rh: {
rest: "https://api.rh.lighter.xyz",
ws: "wss://api.rh.lighter.xyz/stream",
chainId: 466324,
l1ChainId: 4663,
zkLighterContract: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d",
defaultQuoteAsset: "USDG",
},
"rh-testnet": {
rest: "https://api.rh-testnet.lighter.xyz",
ws: "wss://api.rh-testnet.lighter.xyz/stream",
chainId: 300,
// Shares L1 chain id 123456 with zklighter testnet; only the contract tells them apart.
l1ChainId: 123456,
zkLighterContract: "0x8413Cd5B9856B6D156A8A1066D778885FeaE38F8",
defaultQuoteAsset: "USDG",
},
testnet: {
rest: "https://testnet.zklighter.elliot.ai",
ws: "wss://testnet.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: 123456,
zkLighterContract: "0xe034801BC49cCDC79FB683022dA0591C86077261",
defaultQuoteAsset: "USDC",
},
staging: {
rest: "https://staging.zklighter.elliot.ai",
ws: "wss://staging.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: null,
zkLighterContract: null,
defaultQuoteAsset: "USDC",
},
dev: {
rest: "https://dev.zklighter.elliot.ai",
ws: "wss://dev.zklighter.elliot.ai/stream",
chainId: 300,
l1ChainId: null,
zkLighterContract: null,
defaultQuoteAsset: "USDC",
},
};
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = {
mainnet: 304,
testnet: 300,
staging: 300,
dev: 300,
/** Spellings users actually type, mapped onto canonical environment names. */
export const LIGHTER_ENVIRONMENT_ALIASES: Record<string, LighterEnvironment> = {
robinhood: "rh",
robinhoodchain: "rh",
"robinhood-chain": "rh",
"rh-mainnet": "rh",
rhc: "rh",
"robinhood-testnet": "rh-testnet",
rhtestnet: "rh-testnet",
prod: "mainnet",
production: "mainnet",
};
/**
* Web app hostnames. Pasting one of these as a base URL is a common mistake they serve the
* SPA, not the API so they resolve to the matching environment's real REST host instead.
*/
export const LIGHTER_APP_HOSTS: Record<string, LighterEnvironment> = {
"app.lighter.xyz": "mainnet",
"robinhoodchain.lighter.xyz": "rh",
};
export const LIGHTER_HOSTS: Record<LighterEnvironment, LighterHostConfig> = Object.fromEntries(
Object.entries(LIGHTER_NETWORKS).map(([env, config]) => [env, { rest: config.rest, ws: config.ws }])
) as Record<LighterEnvironment, LighterHostConfig>;
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = Object.fromEntries(
Object.entries(LIGHTER_NETWORKS).map(([env, config]) => [env, config.chainId])
) as Record<LighterEnvironment, number>;
export const DEFAULT_LIGHTER_ENVIRONMENT: LighterEnvironment = "testnet";
export const DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS = 10 * 60 * 1000 - 1000; // 10 min minus 1s
+193 -91
View File
@@ -34,13 +34,12 @@ import type {
} from "./types";
import {
DEFAULT_AUTH_TOKEN_BUFFER_MS,
DEFAULT_LIGHTER_ENVIRONMENT,
LIGHTER_HOSTS,
LIGHTER_ORDER_TYPE,
LIGHTER_TIME_IN_FORCE,
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
type LighterEnvironment,
} from "./constants";
import { resolveLighterNetwork, type LighterNetworkResolution } from "./network";
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
@@ -77,47 +76,6 @@ function createEvent<T>(): SimpleEvent<T> {
};
}
function isLighterEnvironment(value: string | undefined | null): value is LighterEnvironment {
if (!value) return false;
return Object.prototype.hasOwnProperty.call(LIGHTER_HOSTS, value);
}
function detectEnvironmentFromUrl(baseUrl: string | undefined | null): LighterEnvironment | null {
if (!baseUrl) return null;
const matchHost = (host: string): LighterEnvironment | null => {
for (const [env, config] of Object.entries(LIGHTER_HOSTS)) {
try {
const restHost = new URL(config.rest).hostname.toLowerCase();
if (restHost === host) {
return env as LighterEnvironment;
}
} catch {
// ignore invalid config URLs
}
}
if (host.includes("mainnet")) return "mainnet";
if (host.includes("testnet")) return "testnet";
if (host.includes("staging")) return "staging";
if (host.includes("dev")) return "dev";
return null;
};
try {
const parsed = new URL(baseUrl);
return matchHost(parsed.hostname.toLowerCase());
} catch {
return matchHost(baseUrl.toLowerCase());
}
}
function inferEnvironment(envOption: string | undefined, baseUrl?: string | null): LighterEnvironment {
if (isLighterEnvironment(envOption)) {
return envOption;
}
const detected = detectEnvironmentFromUrl(baseUrl ?? undefined);
return detected ?? DEFAULT_LIGHTER_ENVIRONMENT;
}
interface Pollers {
ticker?: ReturnType<typeof setInterval>;
klines: Map<string, ReturnType<typeof setInterval>>;
@@ -181,8 +139,34 @@ const TERMINAL_ORDER_STATUSES = new Set([
"canceled-reduce-only",
]);
const KNOWN_SPOT_MARKETS: Record<string, { marketId: number; base: string; quote: string; priceDecimals?: number; sizeDecimals?: number }> = {
interface SpotMarketPreset {
marketId: number;
base: string;
quote: string;
priceDecimals?: number;
sizeDecimals?: number;
}
/**
* Market ids are per-deployment, so presets are keyed by environment first reusing a mainnet
* id on Robinhood Chain would silently trade a different instrument.
*/
const KNOWN_SPOT_MARKETS: Partial<Record<LighterEnvironment, Record<string, SpotMarketPreset>>> = {
mainnet: {
ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 },
},
rh: {
ETHUSDG: { marketId: 2048, base: "ETH", quote: "USDG", priceDecimals: 2, sizeDecimals: 4 },
},
};
/**
* Which deployment lists a given spot symbol. Used only to pick an environment when the user
* supplied neither LIGHTER_ENV nor LIGHTER_BASE_URL, since the default is testnet.
*/
const SPOT_PRESET_ENVIRONMENTS: Record<string, LighterEnvironment> = {
ETHUSDC: "mainnet",
ETHUSDG: "rh",
};
export interface LighterGatewayOptions {
@@ -191,7 +175,9 @@ export interface LighterGatewayOptions {
accountIndex: number;
apiKeys: Record<number, string>;
baseUrl?: string;
environment?: keyof typeof LIGHTER_HOSTS;
/** Canonical name or alias; see LIGHTER_ENVIRONMENT_ALIASES. */
environment?: string;
wsUrl?: string;
marketId?: number;
priceDecimals?: number;
sizeDecimals?: number;
@@ -211,7 +197,9 @@ export class LighterGateway {
private readonly nonceManager: HttpNonceManager;
private readonly logger: (context: string, error: unknown) => void;
private readonly apiKeyIndices: number[];
private readonly environment: keyof typeof LIGHTER_HOSTS;
private readonly network: LighterNetworkResolution;
private readonly environment: LighterEnvironment | null;
private networkVerified = false;
private readonly pollers: Pollers = { ticker: undefined, klines: new Map() };
private accountPoller: ReturnType<typeof setInterval> | null = null;
private accountPollInFlight = false;
@@ -236,6 +224,8 @@ export class LighterGateway {
private forcedSpotPreset = false;
private marketId: number | null = null;
/** Exact symbol as listed by the venue (e.g. `ETH/USDG`), used to match stats payloads. */
private resolvedMarketSymbol: string | null = null;
private marketType: "perp" | "spot" | null = null;
private priceDecimals: number | null = null;
private sizeDecimals: number | null = null;
@@ -295,40 +285,43 @@ export class LighterGateway {
const parsedSymbols = parseBaseQuote(this.marketSymbol);
this.baseAssetSymbol = parsedSymbols.base ?? null;
this.quoteAssetSymbol = parsedSymbols.quote ?? null;
this.applyPresetMarket();
if (process.env.LIGHTER_MARKET_ID) {
this.marketId = Number(process.env.LIGHTER_MARKET_ID);
}
// Explicit overrides are applied before presets so a preset can only fill a gap, never
// overwrite what the operator asked for.
this.marketId =
options.marketId != null
? Number(options.marketId)
: process.env.LIGHTER_MARKET_ID
? Number(process.env.LIGHTER_MARKET_ID)
: null;
this.priceDecimals = options.priceDecimals ?? null;
this.sizeDecimals = options.sizeDecimals ?? null;
if (process.env.LIGHTER_MARKET_TYPE) {
this.marketType = normalizeMarketType(process.env.LIGHTER_MARKET_TYPE) ?? this.marketType;
}
const envPreference =
options.environment ??
process.env.LIGHTER_ENV ??
(this.forcedSpotPreset && !options.baseUrl ? "mainnet" : undefined);
this.environment = inferEnvironment(envPreference, options.baseUrl);
const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment ${this.environment}`);
}
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
// eslint-disable-next-line no-console
console.error(
"[LighterGateway] init",
JSON.stringify({ env: this.environment, host, marketId: this.marketId, marketType: this.marketType })
);
}
const wsHost = LIGHTER_HOSTS[this.environment]?.ws;
if (!wsHost) {
throw new Error(`WebSocket endpoint not configured for env ${this.environment}`);
}
this.wsUrl = wsHost;
this.http = new LighterHttpClient({ baseUrl: host });
const baseUrl = options.baseUrl ?? process.env.LIGHTER_BASE_URL ?? undefined;
// A spot-only symbol implies its venue, but only when nothing more explicit was given —
// otherwise the default (testnet) would be picked for a market that does not exist there.
const presetEnvHint = SPOT_PRESET_ENVIRONMENTS[normalizeSymbolKey(this.marketSymbol)];
this.network = resolveLighterNetwork({
environment: options.environment ?? process.env.LIGHTER_ENV ?? (baseUrl ? undefined : presetEnvHint),
baseUrl,
wsUrl: options.wsUrl ?? process.env.LIGHTER_WS_URL,
chainId: options.chainId,
});
this.environment = this.network.environment;
// Market ids are per-deployment, so presets can only be applied once the venue is known.
this.applyPresetMarket();
this.wsUrl = this.network.wsUrl;
this.http = new LighterHttpClient({ baseUrl: this.network.restUrl });
this.signer = new LighterSigner({
accountIndex: options.accountIndex,
chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300),
chainId: this.network.chainId,
apiKeys: options.apiKeys,
baseUrl: host,
baseUrl: this.network.restUrl,
});
this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number);
if (this.forcedSpotPreset && this.apiKeyIndices.length > 1) {
@@ -347,9 +340,6 @@ export class LighterGateway {
console.error(`[LighterGateway] ${context}`, error);
}
});
this.marketId = options.marketId != null ? Number(options.marketId) : null;
this.priceDecimals = options.priceDecimals ?? null;
this.sizeDecimals = options.sizeDecimals ?? null;
this.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS;
this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS;
this.l1Address = options.l1Address ?? null;
@@ -359,6 +349,16 @@ export class LighterGateway {
this.lastOrdersUpdateAt = now;
this.lastAccountUpdateAt = now;
this.lastTickerUpdateAt = now;
this.announceNetwork();
}
/** One line so an operator can confirm which venue the bot actually attached to. */
private announceNetwork(): void {
// eslint-disable-next-line no-console
console.error(
`[Lighter] env=${this.environment ?? "custom"} rest=${this.network.restUrl} ws=${this.network.wsUrl} ` +
`chainId=${this.network.chainId} account=${Number(this.signer.accountIndex)}`
);
}
async ensureInitialized(): Promise<void> {
@@ -520,16 +520,64 @@ export class LighterGateway {
this.startStaleMonitor();
}
/**
* Proves the REST host really is the deployment the config claims, before a single order is
* signed. The signing chain id is not exposed by any endpoint, so it can only be validated
* indirectly: `layer1BasicInfo` carries the L1 chain id and the ZkLighter contract address,
* both unique per deployment. A mismatch means REST, websocket and chain id have drifted
* apart every transaction would be signed for the wrong chain so it fails closed.
*/
private async verifyNetworkIdentity(): Promise<void> {
if (this.networkVerified) return;
const { expectedL1ChainId, expectedZkLighterContract } = this.network;
if (expectedL1ChainId == null && expectedZkLighterContract == null) {
this.networkVerified = true;
return;
}
let info: Awaited<ReturnType<LighterHttpClient["getLayer1BasicInfo"]>>;
try {
info = await this.http.getLayer1BasicInfo();
} catch (error) {
// An auxiliary endpoint being unreachable must not block trading; the real calls will
// surface a connectivity problem on their own.
this.logger("verifyNetwork", error);
return;
}
const actualL1ChainId = info.l1_providers?.[0]?.chainId ?? null;
const actualContract =
info.contract_addresses?.find((entry) => entry.name === "ZkLighterContract")?.address ?? null;
const mismatches: string[] = [];
if (expectedL1ChainId != null && actualL1ChainId != null && actualL1ChainId !== expectedL1ChainId) {
mismatches.push(`L1 chainId ${actualL1ChainId} (expected ${expectedL1ChainId})`);
}
if (
expectedZkLighterContract &&
actualContract &&
actualContract.toLowerCase() !== expectedZkLighterContract.toLowerCase()
) {
mismatches.push(`ZkLighter contract ${actualContract} (expected ${expectedZkLighterContract})`);
}
if (mismatches.length) {
throw new Error(
`Lighter network mismatch: ${this.network.restUrl} reports ${mismatches.join(" and ")}. ` +
`Config claims env=${this.environment ?? "custom"} (signing chainId ${this.network.chainId}). ` +
`Fix LIGHTER_ENV / LIGHTER_BASE_URL before trading.`
);
}
this.networkVerified = true;
}
private async loadMetadata(): Promise<void> {
await this.verifyNetworkIdentity();
const books = await this.http.getOrderBooks();
const desiredSymbol = this.marketSymbol;
const wantsSpot = guessMarketType(desiredSymbol) === "spot" || this.marketType === "spot";
this.logger("loadMetadata", { desiredSymbol, wantsSpot, presetMarketId: this.marketId, bookCount: books.length });
let target: LighterOrderBookMetadata | null = null;
if (!this.marketId && wantsSpot) {
const normalized = desiredSymbol.toUpperCase().replace(/[^A-Z0-9]/g, "");
const preset = KNOWN_SPOT_MARKETS[normalized];
if (!this.marketId && wantsSpot && this.environment) {
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalizeSymbolKey(desiredSymbol)];
if (preset) {
this.marketId = preset.marketId;
this.baseAssetSymbol = this.baseAssetSymbol ?? preset.base;
@@ -550,8 +598,11 @@ export class LighterGateway {
target = spotById ?? null;
}
if (!target) {
// Market ids are per-deployment, so a stale id carried over from another venue is the
// most likely cause here.
throw new Error(
`Configured market id ${this.marketId} not found in Lighter order books. Check LIGHTER_ENV/baseUrl matches the venue that lists spot ETH/USDC (e.g., mainnet).`
`Configured market id ${this.marketId} not found on ${this.environment ?? this.network.restUrl}. ` +
`Market ids differ per deployment — clear LIGHTER_MARKET_ID or set one listed by this venue.`
);
}
}
@@ -574,7 +625,9 @@ export class LighterGateway {
`Expected spot market for ${desiredSymbol}, but resolved to market_id=${target.market_id} type=${target.market_type ?? "unknown"}`
);
}
this.assertUnitMultiplier(target);
this.marketId = Number(target.market_id);
this.resolvedMarketSymbol = target.symbol ?? null;
this.marketType = normalizeMarketType(target.market_type) ?? this.marketType ?? guessMarketType(target.symbol);
this.baseAssetId = target.base_asset_id ?? this.baseAssetId;
this.quoteAssetId = target.quote_asset_id ?? this.quoteAssetId;
@@ -596,6 +649,31 @@ export class LighterGateway {
}
}
/**
* Robinhood Chain lists a few tokenized-equity markets whose contract `multiplier` is not 1
* (corporate actions / accrued yield). Size and price scaling here assumes 1.0, so those
* markets are refused rather than traded with quietly wrong quantities. Override only if you
* have verified the scaling yourself.
*/
private assertUnitMultiplier(book: LighterOrderBookMetadata): void {
const raw = book.multiplier;
if (raw == null) return;
const multiplier = Number(raw);
if (!Number.isFinite(multiplier) || Math.abs(multiplier - 1) < 1e-9) return;
if (process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER === "1" || process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER === "true") {
this.logger(
"loadMetadata",
`market ${book.symbol} has multiplier ${raw}; order sizing assumes 1.0 and may be off`
);
return;
}
throw new Error(
`Lighter market ${book.symbol} (id=${book.market_id}) has contract multiplier ${raw}, not 1.0. ` +
`Order size/price scaling assumes 1.0, so trading it could size positions incorrectly. ` +
`Set LIGHTER_ALLOW_NON_UNIT_MULTIPLIER=1 to proceed anyway.`
);
}
private async refreshAccountSnapshot(): Promise<void> {
try {
const auth = await this.ensureAuthToken();
@@ -927,8 +1005,7 @@ export class LighterGateway {
desiredSymbol.includes("/") ||
desiredSymbol.includes("-") ||
desiredSymbol.includes(":") ||
normalizedDesired.includes("USDC") ||
normalizedDesired.endsWith("USD");
SPOT_QUOTE_SUFFIXES.some((suffix) => normalizedDesired.includes(suffix));
const preferred = candidates.filter((book) =>
wantsSpot ? normalizeMarketType(book.market_type) === "spot" : true
);
@@ -1471,7 +1548,8 @@ export class LighterGateway {
private extractMarketIdFromChannel(channel: unknown): number | null {
if (typeof channel !== "string") return null;
const match = channel.match(/account_market:(\d+)/);
// Subscriptions use `account_market/{market}/{account}`; echoes may come back colon-separated.
const match = channel.match(/account_market[:/](\d+)/);
if (match && match[1]) {
const value = Number(match[1]);
return Number.isFinite(value) ? value : null;
@@ -1513,7 +1591,9 @@ export class LighterGateway {
marketId: this.marketId,
marketType: this.marketType ?? guessMarketType(this.marketSymbol),
baseAssetSymbol: this.baseAssetSymbol,
quoteAssetSymbol: this.quoteAssetSymbol,
// Falls back to the venue's settlement asset (USDG on rh, USDC elsewhere) so the
// dashboard never labels a balance with the wrong currency.
quoteAssetSymbol: this.quoteAssetSymbol ?? this.network.defaultQuoteAsset,
baseAssetId: this.baseAssetId,
quoteAssetId: this.quoteAssetId,
}
@@ -1661,10 +1741,17 @@ export class LighterGateway {
const stats = await this.http.getExchangeStats();
const marketId = this.marketId;
if (marketId == null) return;
const match = stats.find(
(entry) => Number(entry.market_id) === marketId || (entry.symbol ? entry.symbol.toUpperCase() : "") === this.marketSymbol
);
// Neither mainnet nor rh returns market_id in this payload today, so matching falls back
// to the exact venue symbol. Compared delimiter-free (`ETH/USDG` vs `ETHUSDG`) but never
// by base alone, which would let the ETH perp masquerade as the ETH/USDG spot market.
const desiredKey = normalizeSymbolKey(this.resolvedMarketSymbol ?? this.marketSymbol);
const match = stats.find((entry) => {
if (entry.market_id != null && Number(entry.market_id) === marketId) return true;
return entry.symbol ? normalizeSymbolKey(entry.symbol) === desiredKey : false;
});
if (!match) return;
// Cached so estimateMarketPrice has a last-trade fallback when the book is empty.
this.ticker = match;
const ticker = toTicker(this.displaySymbol, match);
this.tickerEvent.emit(ticker);
this.loggedCreateOrderPayload = false;
@@ -1783,8 +1870,9 @@ export class LighterGateway {
}
private applyPresetMarket(): void {
const normalized = (this.marketSymbol ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
const preset = KNOWN_SPOT_MARKETS[normalized];
if (!this.environment) return;
const normalized = normalizeSymbolKey(this.marketSymbol);
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalized];
if (!preset) return;
if (this.marketId == null) this.marketId = preset.marketId;
if (!this.baseAssetSymbol) this.baseAssetSymbol = preset.base;
@@ -2192,10 +2280,19 @@ function normalizeMarketType(value: string | null | undefined): "perp" | "spot"
return undefined;
}
/**
* Quote assets that mark a compact symbol as spot. Deliberately excludes bare "USD": mainnet
* lists forex perps such as NZDUSD that would otherwise be mistaken for spot pairs.
*/
const SPOT_QUOTE_SUFFIXES = ["USDC", "USDG"];
function guessMarketType(symbol: string | null | undefined): "perp" | "spot" | null {
if (!symbol) return null;
const upper = symbol.toUpperCase();
if (upper.includes("/") || upper.includes("-") || upper.includes(":") || upper.endsWith("USDC")) {
if (upper.includes("/") || upper.includes("-") || upper.includes(":")) {
return "spot";
}
if (SPOT_QUOTE_SUFFIXES.some((suffix) => upper.endsWith(suffix))) {
return "spot";
}
return null;
@@ -2224,6 +2321,11 @@ function tryParseTxInfo(value: string): unknown {
}
}
/** Delimiter-free upper-case form, e.g. `ETH/USDG` and `eth-usdg` both become `ETHUSDG`. */
function normalizeSymbolKey(value: string | null | undefined): string {
return (value ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
}
function normalizeSymbolForms(value: string | null | undefined): string[] {
if (!value) return [];
const upper = value.toUpperCase();
+27 -9
View File
@@ -4,7 +4,7 @@ import type {
LighterMarketStats,
LighterOrderBookMetadata,
} from "./types";
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS } from "./constants";
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS, LIGHTER_NETWORKS } from "./constants";
interface ApiResponseBase {
code: number;
@@ -38,6 +38,11 @@ interface NextNonceResponse extends ApiResponseBase {
nonce: number;
}
export interface Layer1BasicInfo extends ApiResponseBase {
l1_providers?: Array<{ chainId?: number; networkId?: number }>;
contract_addresses?: Array<{ name?: string; address?: string }>;
}
export interface SendTxResponse extends ApiResponseBase {
tx_hash: string;
predicted_execution_time_ms?: number;
@@ -78,7 +83,7 @@ export class LighterHttpClient {
constructor(options: LighterHttpClientOptions = {}) {
const env = options.environment ?? DEFAULT_LIGHTER_ENVIRONMENT;
const host = options.baseUrl ?? LIGHTER_HOSTS[env]?.rest;
const host = options.baseUrl ?? LIGHTER_NETWORKS[env]?.rest;
if (!host) {
throw new Error(`Unknown Lighter environment: ${env}`);
}
@@ -95,25 +100,31 @@ export class LighterHttpClient {
return response.order_books ?? [];
}
async getLayer1BasicInfo(): Promise<Layer1BasicInfo> {
return this.get<Layer1BasicInfo>("/api/v1/layer1BasicInfo");
}
async getExchangeStats(): Promise<LighterMarketStats[]> {
const response = await this.get<ExchangeStatsResponse>("/api/v1/exchangeStats");
const stats = response.order_book_stats ?? [];
// Both mainnet and rh return prices as JSON numbers here while the shared types (and the
// Ticker contract) declare strings, so normalize instead of leaking numbers downstream.
return stats.map((entry) => ({
market_id: entry.market_id,
symbol: entry.symbol,
market_type: (entry as any).market_type,
index_price: (entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price,
mid_price: (entry as any).mid_price,
mark_price: entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price,
last_trade_price: entry.last_trade_price,
open_interest: (entry as any).open_interest ?? "0",
index_price: toPriceString((entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price) ?? "0",
mid_price: toPriceString((entry as any).mid_price),
mark_price: toPriceString(entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price),
last_trade_price: toPriceString(entry.last_trade_price) ?? "0",
open_interest: toPriceString((entry as any).open_interest) ?? "0",
daily_base_token_volume: entry.daily_base_token_volume,
daily_quote_token_volume: entry.daily_quote_token_volume,
daily_price_low: entry.daily_price_low,
daily_price_high: entry.daily_price_high,
daily_price_change: entry.daily_price_change,
current_funding_rate: entry.current_funding_rate,
funding_rate: entry.funding_rate,
current_funding_rate: toPriceString(entry.current_funding_rate),
funding_rate: toPriceString(entry.funding_rate),
funding_timestamp: entry.funding_timestamp,
}));
}
@@ -293,3 +304,10 @@ export class LighterHttpClient {
function truncateBody(body: string, limit = 200): string {
return body.length > limit ? `${body.slice(0, limit)}` : body;
}
function toPriceString(value: unknown): string | undefined {
if (value == null) return undefined;
if (typeof value === "string") return value;
if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined;
return undefined;
}
+149
View File
@@ -0,0 +1,149 @@
import {
DEFAULT_LIGHTER_ENVIRONMENT,
LIGHTER_APP_HOSTS,
LIGHTER_ENVIRONMENT_ALIASES,
LIGHTER_NETWORKS,
type LighterEnvironment,
} from "./constants";
export interface LighterNetworkResolution {
/** `null` only for a self-hosted/proxied REST host we cannot map to a known deployment. */
environment: LighterEnvironment | null;
restUrl: string;
wsUrl: string;
chainId: number;
expectedL1ChainId: number | null;
expectedZkLighterContract: string | null;
defaultQuoteAsset: string;
}
export interface ResolveLighterNetworkOptions {
environment?: string | null;
baseUrl?: string | null;
wsUrl?: string | null;
chainId?: number | null;
}
const KNOWN_ENVIRONMENTS = Object.keys(LIGHTER_NETWORKS) as LighterEnvironment[];
function isLighterEnvironment(value: string): value is LighterEnvironment {
return Object.prototype.hasOwnProperty.call(LIGHTER_NETWORKS, value);
}
/**
* Canonicalizes a user-supplied environment name. Returns `null` for empty input and throws
* on a non-empty unknown value silently falling back would point a live bot at the wrong
* chain, which is exactly the failure this module exists to prevent.
*/
export function normalizeEnvironmentName(value: string | null | undefined): LighterEnvironment | null {
if (value == null) return null;
const trimmed = String(value).trim().toLowerCase();
if (!trimmed) return null;
if (isLighterEnvironment(trimmed)) return trimmed;
const alias = LIGHTER_ENVIRONMENT_ALIASES[trimmed];
if (alias) return alias;
throw new Error(
`Unknown Lighter environment "${value}". Valid values: ${KNOWN_ENVIRONMENTS.join(", ")} ` +
`(aliases: ${Object.keys(LIGHTER_ENVIRONMENT_ALIASES).join(", ")})`
);
}
function extractHostname(value: string | null | undefined): string | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).hostname.toLowerCase();
} catch {
// Bare hostnames ("api.rh.lighter.xyz") are accepted too.
const withoutPath = trimmed.split("/")[0] ?? "";
return withoutPath.toLowerCase() || null;
}
}
/** Maps a web-app hostname (not an API host) onto the deployment it belongs to. */
export function detectEnvironmentFromAppHost(value: string | null | undefined): LighterEnvironment | null {
const host = extractHostname(value);
if (!host) return null;
return LIGHTER_APP_HOSTS[host] ?? null;
}
/**
* Maps an API hostname onto a known deployment. Order matters: the Robinhood hosts are matched
* before the substring rules, because `api.rh-testnet.lighter.xyz` contains "testnet" and would
* otherwise be mistaken for the zklighter testnet.
*/
export function detectEnvironmentFromUrl(value: string | null | undefined): LighterEnvironment | null {
const host = extractHostname(value);
if (!host) return null;
for (const env of KNOWN_ENVIRONMENTS) {
const configured = extractHostname(LIGHTER_NETWORKS[env].rest);
if (configured && configured === host) return env;
}
if (host.includes("rh-testnet.lighter") || host.includes("robinhood-testnet")) return "rh-testnet";
if (host.includes("rh.lighter") || host.includes("robinhood")) return "rh";
if (host.includes("mainnet")) return "mainnet";
if (host.includes("testnet")) return "testnet";
if (host.includes("staging")) return "staging";
if (host.includes("dev")) return "dev";
return null;
}
/** Turns a REST base URL into the matching stream URL for a self-hosted deployment. */
export function deriveWebSocketUrl(restUrl: string): string {
const trimmed = restUrl.trim().replace(/\/+$/, "");
const withScheme = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const swapped = withScheme.replace(/^http:\/\//i, "ws://").replace(/^https:\/\//i, "wss://");
return swapped.endsWith("/stream") ? swapped : `${swapped}/stream`;
}
function sameHost(a: string, b: string): boolean {
const hostA = extractHostname(a);
const hostB = extractHostname(b);
return hostA != null && hostA === hostB;
}
/**
* Single place where REST host, websocket host and signing chain id are decided together.
* Precedence: explicit environment > web-app hostname > API hostname > default environment.
*/
export function resolveLighterNetwork(options: ResolveLighterNetworkOptions = {}): LighterNetworkResolution {
const explicitEnv = normalizeEnvironmentName(options.environment);
const baseUrl = options.baseUrl?.trim() || null;
const appHostEnv = detectEnvironmentFromAppHost(baseUrl);
const detectedEnv = detectEnvironmentFromUrl(baseUrl);
const environment: LighterEnvironment | null =
explicitEnv ?? appHostEnv ?? detectedEnv ?? (baseUrl ? null : DEFAULT_LIGHTER_ENVIRONMENT);
const config = environment ? LIGHTER_NETWORKS[environment] : null;
// A web-app URL never serves the API, so it selects the deployment and is then discarded.
const restUrl = (appHostEnv ? config?.rest : baseUrl ?? config?.rest) ?? config?.rest ?? null;
if (!restUrl) {
throw new Error("Lighter REST base URL could not be resolved; set LIGHTER_ENV or LIGHTER_BASE_URL");
}
const explicitWs = options.wsUrl?.trim() || null;
const wsUrl =
explicitWs ?? (config && sameHost(restUrl, config.rest) ? config.ws : deriveWebSocketUrl(restUrl));
const chainId = options.chainId ?? config?.chainId ?? null;
if (chainId == null) {
throw new Error(
`Cannot determine the Lighter signing chain id for host ${extractHostname(restUrl) ?? restUrl}. ` +
`Set LIGHTER_ENV to a known deployment (${KNOWN_ENVIRONMENTS.join(", ")}) or set LIGHTER_CHAIN_ID explicitly.`
);
}
return {
environment,
restUrl: restUrl.replace(/\/+$/, ""),
wsUrl,
chainId,
expectedL1ChainId: config?.l1ChainId ?? null,
expectedZkLighterContract: config?.zkLighterContract ?? null,
defaultQuoteAsset: config?.defaultQuoteAsset ?? "USDC",
};
}
+2
View File
@@ -144,6 +144,8 @@ export interface LighterOrderBookMetadata {
supported_price_decimals: number;
supported_quote_decimals: number;
status: "inactive" | "frozen" | "active" | string;
/** Contract multiplier; "1.0" everywhere except a few tokenized-equity markets on rh. */
multiplier?: string;
}
export interface LighterAccountMarketUpdate {
+2
View File
@@ -84,6 +84,8 @@ function resolveLighterCredentials(symbol: string): LighterCredentials {
apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0,
environment: process.env.LIGHTER_ENV,
baseUrl: process.env.LIGHTER_BASE_URL,
wsUrl: process.env.LIGHTER_WS_URL,
chainId: process.env.LIGHTER_CHAIN_ID ? Number(process.env.LIGHTER_CHAIN_ID) : undefined,
l1Address: process.env.LIGHTER_L1_ADDRESS,
marketSymbol: process.env.LIGHTER_SYMBOL,
marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined,
+7 -2
View File
@@ -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" },
+310 -211
View File
@@ -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
// 每轮都重算:报价是否真的变动由各档位的 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;
if (shouldRecompute) {
if (closeOnly) {
this.lastQuoteBid1 = null;
this.lastQuoteAsk1 = null;
} else {
this.lastQuoteBid1 = topBid;
this.lastQuoteAsk1 = topAsk;
}
}
});
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);
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("BUY", bps, depthQty, minDepth);
} else {
this.resetThinDepthSkip("BUY", bps);
this.logThinDepthSkip(side, target.bps, depthQty, minDepth);
continue;
}
this.resetThinDepthSkip(side, target.bps);
}
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: "BUY",
price: formatPriceToString(targetPrice, priceDecimals),
side,
price: formatPriceToString(price, priceDecimals),
amount,
reduceOnly: false,
});
}
} else {
desired.push({
side: "BUY",
price: formatPriceToString(targetPrice, priceDecimals),
amount,
reduceOnly: false,
});
}
}
}
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,
});
}
}
}
}
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)
const pool = this.activeEntryOrders();
if (!pool.length) return false;
const anchor = this.getQuoteAnchor(depth);
if (anchor == null) return false;
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 (!hasActiveEntryOrders) return false;
if (!keepable) return true;
}
const { topBid, topAsk } = getTopPrices(depth);
if (topBid == null || topAsk == null) return false;
return this.shouldReprice(topBid, topAsk);
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 = {};
}
/**
+121 -4
View File
@@ -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);
});
});
+160 -5
View File
@@ -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 线线
* 010 bps: 100% 40%
* 1030 bps: 40% 12.5%
* 30100 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 用 bid1SELL 用 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;
}
+24
View File
@@ -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
View File
@@ -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);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const signerConfigs: Array<{ chainId: number; baseUrl?: string; accountIndex: number | bigint }> = [];
// Keeps the real signer (and its python bridge subprocess) out of these wiring tests.
vi.mock("../../src/exchanges/lighter/signer", () => ({
LighterSigner: class {
readonly accountIndex: bigint;
readonly chainId: number;
readonly defaultKeyIndex = 0;
constructor(config: { chainId: number; baseUrl?: string; accountIndex: number | bigint }) {
signerConfigs.push(config);
this.accountIndex = BigInt(config.accountIndex);
this.chainId = config.chainId;
}
},
}));
const { LighterGateway } = await import("../../src/exchanges/lighter/gateway");
const LIGHTER_ENV_KEYS = [
"LIGHTER_ENV",
"LIGHTER_BASE_URL",
"LIGHTER_WS_URL",
"LIGHTER_MARKET_ID",
"LIGHTER_MARKET_TYPE",
] as const;
let savedEnv: Record<string, string | undefined> = {};
const build = (options: Record<string, unknown> = {}) =>
new LighterGateway({
symbol: "BTCUSDT",
marketSymbol: "BTC",
accountIndex: 7,
apiKeys: { 0: "0xdeadbeef" },
...options,
} as any);
const lastSigner = () => signerConfigs[signerConfigs.length - 1]!;
describe("LighterGateway venue wiring", () => {
beforeEach(() => {
savedEnv = Object.fromEntries(LIGHTER_ENV_KEYS.map((key) => [key, process.env[key]]));
for (const key of LIGHTER_ENV_KEYS) delete process.env[key];
signerConfigs.length = 0;
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
vi.restoreAllMocks();
});
it("wires rest, websocket and chain id from a single environment name", () => {
const gateway = build({ environment: "rh" }) as any;
expect(gateway.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
expect(gateway.network.restUrl).toBe("https://api.rh.lighter.xyz");
expect(lastSigner().chainId).toBe(466324);
expect(lastSigner().baseUrl).toBe("https://api.rh.lighter.xyz");
});
it("accepts an alias from LIGHTER_ENV", () => {
process.env.LIGHTER_ENV = "robinhood";
const gateway = build() as any;
expect(gateway.environment).toBe("rh");
expect(lastSigner().chainId).toBe(466324);
});
it("follows the base url instead of defaulting the websocket to testnet", () => {
const gateway = build({ baseUrl: "https://api.rh.lighter.xyz" }) as any;
expect(gateway.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
expect(lastSigner().chainId).toBe(466324);
});
it("keeps mainnet unaffected", () => {
const gateway = build({ environment: "mainnet" }) as any;
expect(gateway.wsUrl).toBe("wss://mainnet.zklighter.elliot.ai/stream");
expect(lastSigner().chainId).toBe(304);
});
it("honours an explicit websocket override", () => {
process.env.LIGHTER_WS_URL = "wss://custom.example/stream";
const gateway = build({ environment: "rh" }) as any;
expect(gateway.wsUrl).toBe("wss://custom.example/stream");
});
it("does not discard an explicit market id or decimals", () => {
const gateway = build({ environment: "rh", marketId: 16, priceDecimals: 2, sizeDecimals: 4 }) as any;
expect(gateway.marketId).toBe(16);
expect(gateway.priceDecimals).toBe(2);
expect(gateway.sizeDecimals).toBe(4);
});
it("reads a market id from the environment when none is passed", () => {
process.env.LIGHTER_MARKET_ID = "21";
const gateway = build({ environment: "rh" }) as any;
expect(gateway.marketId).toBe(21);
});
it("applies the spot preset of the resolved venue only", () => {
const rh = build({ environment: "rh", marketSymbol: "ETH/USDG" }) as any;
expect(rh.marketId).toBe(2048);
expect(rh.quoteAssetSymbol).toBe("USDG");
expect(rh.marketType).toBe("spot");
// The mainnet preset key must not leak into the rh venue.
const rhWithMainnetSymbol = build({ environment: "rh", marketSymbol: "ETHUSDC" }) as any;
expect(rhWithMainnetSymbol.marketId).toBeNull();
});
it("infers the venue from a spot-only symbol when nothing else is configured", () => {
const mainnet = build({ marketSymbol: "ETHUSDC" }) as any;
expect(mainnet.environment).toBe("mainnet");
expect(mainnet.marketId).toBe(2048);
expect(lastSigner().chainId).toBe(304);
const rh = build({ marketSymbol: "ETHUSDG" }) as any;
expect(rh.environment).toBe("rh");
expect(lastSigner().chainId).toBe(466324);
});
it("announces the resolved venue once", () => {
build({ environment: "rh" });
const banner = (console.error as unknown as { mock: { calls: unknown[][] } }).mock.calls
.map((args) => String(args[0]))
.find((line) => line.startsWith("[Lighter] env="));
expect(banner).toContain("env=rh");
expect(banner).toContain("rest=https://api.rh.lighter.xyz");
expect(banner).toContain("ws=wss://api.rh.lighter.xyz/stream");
expect(banner).toContain("chainId=466324");
});
});
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, describe, expect, it } from "vitest";
import { LighterGateway } from "../../src/exchanges/lighter/gateway";
import type { LighterMarketStats, LighterOrderBookMetadata } from "../../src/exchanges/lighter/types";
/**
* The gateway constructor spawns the signer bridge, so these exercise the individual methods
* against a stub `this` the same approach as order-book-choice.test.ts.
*/
const callOn = <T>(method: string, context: Record<string, unknown>, ...args: unknown[]): T =>
(LighterGateway.prototype as any)[method].apply(context, args);
const book = (overrides: Partial<LighterOrderBookMetadata>): LighterOrderBookMetadata =>
({
symbol: "ETH/USDG",
market_id: 2048,
market_type: "spot",
supported_price_decimals: 2,
supported_size_decimals: 4,
...overrides,
}) as LighterOrderBookMetadata;
describe("assertUnitMultiplier", () => {
const context = () => ({ logger: () => {} });
it("accepts a missing or unit multiplier", () => {
expect(() => callOn("assertUnitMultiplier", context(), book({}))).not.toThrow();
expect(() =>
callOn("assertUnitMultiplier", context(), book({ multiplier: "1.000000000000000000" }))
).not.toThrow();
});
it("refuses a market whose multiplier would skew order sizing", () => {
expect(() =>
callOn("assertUnitMultiplier", context(), book({ symbol: "SGOV/USDG", multiplier: "1.002981519346766532" }))
).toThrow(/multiplier/);
});
afterEach(() => {
delete process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER;
});
it("can be overridden explicitly", () => {
process.env.LIGHTER_ALLOW_NON_UNIT_MULTIPLIER = "1";
const warnings: unknown[] = [];
const ctx = { logger: (_: string, message: unknown) => warnings.push(message) };
expect(() =>
callOn("assertUnitMultiplier", ctx, book({ symbol: "SGOV/USDG", multiplier: "1.0029" }))
).not.toThrow();
expect(warnings).toHaveLength(1);
});
});
describe("refreshTicker symbol matching", () => {
// Robinhood Chain omits market_id from exchangeStats, so matching falls back to the symbol.
const stats: LighterMarketStats[] = [
{ symbol: "ETH", last_trade_price: "3000", index_price: "3000" } as LighterMarketStats,
{ symbol: "ETH/USDG", last_trade_price: "3001", index_price: "3001" } as LighterMarketStats,
];
const makeContext = (overrides: Record<string, unknown>) => {
const emitted: unknown[] = [];
const context = {
http: { getExchangeStats: async () => stats },
tickerEvent: { emit: (value: unknown) => emitted.push(value) },
logger: () => {},
displaySymbol: "ETHUSDG",
marketId: 2048,
ticker: null as LighterMarketStats | null,
staleReason: null,
...overrides,
};
return { context, emitted };
};
it("matches the spot market by its exact venue symbol, not by base asset", async () => {
const { context, emitted } = makeContext({
resolvedMarketSymbol: "ETH/USDG",
marketSymbol: "ETHUSDG",
});
await callOn<Promise<void>>("refreshTicker", context);
expect(emitted).toHaveLength(1);
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("3001");
expect(context.ticker?.symbol).toBe("ETH/USDG");
});
it("matches the perp when that is the resolved market", async () => {
const { context, emitted } = makeContext({
resolvedMarketSymbol: "ETH",
marketSymbol: "ETH",
});
await callOn<Promise<void>>("refreshTicker", context);
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("3000");
});
it("still matches by market_id when the venue provides one", async () => {
const withIds: LighterMarketStats[] = [
{ symbol: "SOMETHING-ELSE", market_id: 2048, last_trade_price: "42", index_price: "42" } as LighterMarketStats,
];
const { context, emitted } = makeContext({
http: { getExchangeStats: async () => withIds },
resolvedMarketSymbol: "ETH/USDG",
marketSymbol: "ETHUSDG",
});
await callOn<Promise<void>>("refreshTicker", context);
expect((emitted[0] as { lastPrice: string }).lastPrice).toBe("42");
});
});
describe("verifyNetworkIdentity", () => {
const rhInfo = {
code: 200,
l1_providers: [{ chainId: 4663 }],
contract_addresses: [{ name: "ZkLighterContract", address: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d" }],
};
const makeContext = (network: Record<string, unknown>, info: unknown = rhInfo) => ({
networkVerified: false,
logger: () => {},
environment: "rh",
http: { getLayer1BasicInfo: async () => info },
network: {
restUrl: "https://api.rh.lighter.xyz",
chainId: 466324,
expectedL1ChainId: 4663,
expectedZkLighterContract: "0x94bAB9693Ba2f6358507eFfcbd372b0660AFfF9d",
...network,
},
});
it("passes when the deployment fingerprint matches", async () => {
const context = makeContext({});
await callOn<Promise<void>>("verifyNetworkIdentity", context);
expect(context.networkVerified).toBe(true);
});
it("fails closed when the host belongs to another deployment", async () => {
const context = makeContext({ expectedL1ChainId: 1, expectedZkLighterContract: null });
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).rejects.toThrow(
/network mismatch/i
);
});
it("catches a contract mismatch even when the L1 chain id collides", async () => {
// rh-testnet and zklighter testnet both report L1 chain id 123456.
const info = {
code: 200,
l1_providers: [{ chainId: 123456 }],
contract_addresses: [{ name: "ZkLighterContract", address: "0xe034801BC49cCDC79FB683022dA0591C86077261" }],
};
const context = makeContext(
{
expectedL1ChainId: 123456,
expectedZkLighterContract: "0x8413Cd5B9856B6D156A8A1066D778885FeaE38F8",
},
info
);
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).rejects.toThrow(
/ZkLighter contract/
);
});
it("tolerates the endpoint being unavailable", async () => {
const context = makeContext({});
context.http = {
getLayer1BasicInfo: async () => {
throw new Error("offline");
},
};
await expect(callOn<Promise<void>>("verifyNetworkIdentity", context)).resolves.toBeUndefined();
expect(context.networkVerified).toBe(false);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
deriveWebSocketUrl,
detectEnvironmentFromUrl,
normalizeEnvironmentName,
resolveLighterNetwork,
} from "../../src/exchanges/lighter/network";
describe("normalizeEnvironmentName", () => {
it("accepts canonical names and aliases regardless of case", () => {
expect(normalizeEnvironmentName("rh")).toBe("rh");
expect(normalizeEnvironmentName("RH")).toBe("rh");
expect(normalizeEnvironmentName(" Robinhood ")).toBe("rh");
expect(normalizeEnvironmentName("robinhoodchain")).toBe("rh");
expect(normalizeEnvironmentName("rh-testnet")).toBe("rh-testnet");
expect(normalizeEnvironmentName("prod")).toBe("mainnet");
});
it("returns null for empty input", () => {
expect(normalizeEnvironmentName(undefined)).toBeNull();
expect(normalizeEnvironmentName("")).toBeNull();
});
it("throws instead of silently falling back on a typo", () => {
expect(() => normalizeEnvironmentName("rhh")).toThrow(/Unknown Lighter environment/);
});
});
describe("detectEnvironmentFromUrl", () => {
it("matches the Robinhood hosts before the testnet substring rule", () => {
expect(detectEnvironmentFromUrl("https://api.rh.lighter.xyz")).toBe("rh");
// Contains "testnet" but must not resolve to the zklighter testnet.
expect(detectEnvironmentFromUrl("https://api.rh-testnet.lighter.xyz")).toBe("rh-testnet");
});
it("matches the zklighter hosts", () => {
expect(detectEnvironmentFromUrl("https://mainnet.zklighter.elliot.ai")).toBe("mainnet");
expect(detectEnvironmentFromUrl("https://testnet.zklighter.elliot.ai")).toBe("testnet");
});
it("returns null for an unrelated host", () => {
expect(detectEnvironmentFromUrl("https://proxy.internal.example")).toBeNull();
});
});
describe("deriveWebSocketUrl", () => {
it("swaps the scheme and appends the stream path", () => {
expect(deriveWebSocketUrl("https://proxy.example")).toBe("wss://proxy.example/stream");
expect(deriveWebSocketUrl("http://localhost:8080/")).toBe("ws://localhost:8080/stream");
expect(deriveWebSocketUrl("https://proxy.example/stream")).toBe("wss://proxy.example/stream");
});
});
describe("resolveLighterNetwork", () => {
it("binds rest, websocket and chain id together for Robinhood Chain", () => {
const resolved = resolveLighterNetwork({ environment: "rh" });
expect(resolved.restUrl).toBe("https://api.rh.lighter.xyz");
expect(resolved.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
expect(resolved.chainId).toBe(466324);
expect(resolved.expectedL1ChainId).toBe(4663);
expect(resolved.defaultQuoteAsset).toBe("USDG");
});
it("keeps mainnet on its own chain id", () => {
const resolved = resolveLighterNetwork({ environment: "mainnet" });
expect(resolved.chainId).toBe(304);
expect(resolved.wsUrl).toBe("wss://mainnet.zklighter.elliot.ai/stream");
expect(resolved.defaultQuoteAsset).toBe("USDC");
});
it("derives the websocket from a base url instead of falling back to the default env", () => {
const resolved = resolveLighterNetwork({ baseUrl: "https://api.rh.lighter.xyz" });
expect(resolved.environment).toBe("rh");
expect(resolved.wsUrl).toBe("wss://api.rh.lighter.xyz/stream");
expect(resolved.chainId).toBe(466324);
});
it("does not mistake the rh testnet host for the zklighter testnet", () => {
const resolved = resolveLighterNetwork({ baseUrl: "https://api.rh-testnet.lighter.xyz" });
expect(resolved.environment).toBe("rh-testnet");
expect(resolved.wsUrl).toBe("wss://api.rh-testnet.lighter.xyz/stream");
});
it("defaults to testnet when nothing is configured", () => {
const resolved = resolveLighterNetwork({});
expect(resolved.environment).toBe("testnet");
expect(resolved.chainId).toBe(300);
});
it("remaps a web app hostname onto the matching API host", () => {
const rh = resolveLighterNetwork({ baseUrl: "https://robinhoodchain.lighter.xyz" });
expect(rh.environment).toBe("rh");
expect(rh.restUrl).toBe("https://api.rh.lighter.xyz");
const main = resolveLighterNetwork({ baseUrl: "https://app.lighter.xyz/" });
expect(main.environment).toBe("mainnet");
expect(main.restUrl).toBe("https://mainnet.zklighter.elliot.ai");
});
it("refuses an unknown host without an explicit chain id", () => {
expect(() => resolveLighterNetwork({ baseUrl: "https://proxy.internal.example" })).toThrow(
/chain id/i
);
});
it("accepts an unknown host once the chain id is supplied", () => {
const resolved = resolveLighterNetwork({ baseUrl: "https://proxy.internal.example", chainId: 466324 });
expect(resolved.environment).toBeNull();
expect(resolved.wsUrl).toBe("wss://proxy.internal.example/stream");
expect(resolved.chainId).toBe(466324);
expect(resolved.expectedL1ChainId).toBeNull();
});
it("keeps the environment chain id when the venue is reached through a proxy", () => {
const resolved = resolveLighterNetwork({ environment: "rh", baseUrl: "https://proxy.internal.example" });
expect(resolved.restUrl).toBe("https://proxy.internal.example");
expect(resolved.wsUrl).toBe("wss://proxy.internal.example/stream");
expect(resolved.chainId).toBe(466324);
});
it("lets an explicit websocket url win", () => {
const resolved = resolveLighterNetwork({ environment: "rh", wsUrl: "wss://custom.example/stream" });
expect(resolved.wsUrl).toBe("wss://custom.example/stream");
expect(resolved.restUrl).toBe("https://api.rh.lighter.xyz");
});
it("lets an explicit chain id override the table", () => {
const resolved = resolveLighterNetwork({ environment: "rh", chainId: 999 });
expect(resolved.chainId).toBe(999);
});
});
@@ -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,
},
+12
View File
@@ -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,
},
+53 -13
View File
@@ -45,11 +45,7 @@ afterEach(() => {
vi.useRealTimers();
});
describe("MakerPointsEngine immediate reprice", () => {
it("triggers an immediate tick when min reprice bps threshold is reached", () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
function buildEngine(adapter: StubAdapter, restingBuyPrice: string): MakerPointsEngine {
const engine = new MakerPointsEngine(
{
symbol: "BTC-USD",
@@ -67,7 +63,13 @@ describe("MakerPointsEngine immediate reprice", () => {
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,
},
@@ -79,8 +81,6 @@ describe("MakerPointsEngine immediate reprice", () => {
(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,
@@ -89,7 +89,7 @@ describe("MakerPointsEngine immediate reprice", () => {
side: "BUY",
type: "LIMIT",
status: "NEW",
price: "99.0",
price: restingBuyPrice,
origQty: "0.01",
executedQty: "0",
stopPrice: "0",
@@ -99,17 +99,57 @@ describe("MakerPointsEngine immediate reprice", () => {
closePosition: false,
},
];
return engine;
}
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
adapter.emitDepth({
// bid1 99.9 / ask1 100.9 → 中值 100.40-10 档目标 9 bps,容差 max(3, 9×0.15)=3
// 所以保留窗口是距中值 612 bps,即 100.28100.34
const DEPTH = {
lastUpdateId: 1,
bids: [["99.9", "1"]],
asks: [["100.9", "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("leaves a quote alone while it is still inside its band tolerance", () => {
vi.useFakeTimers();
const adapter = new StubAdapter();
// 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(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();
});
+12
View File
@@ -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,
},