mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
Compare commits
3
Commits
0ea71503e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a757985b8 | ||
|
|
85954461f3 | ||
|
|
f3a96886ac |
+44
-11
@@ -27,7 +27,7 @@ STANDX_SYMBOL=BTC-USD
|
|||||||
# STANDX_SESSION_ID=
|
# STANDX_SESSION_ID=
|
||||||
# Optional: request signing key (ed25519 private key, supports hex or base58 format)
|
# Optional: request signing key (ed25519 private key, supports hex or base58 format)
|
||||||
# STANDX_REQUEST_PRIVATE_KEY=
|
# 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
|
# 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_CREATE_DATE=2026-01-15 # Token creation date (YYYY-MM-DD format)
|
||||||
# STANDX_TOKEN_VALIDITY_DAYS=30 # Token validity period in days
|
# 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%
|
SWING_STOP_LOSS_PCT=0.05 # 0.05 = 5%
|
||||||
|
|
||||||
# Risk management (USD amounts unless noted)
|
# 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_PROFIT=0.2 # Trailing stop activation profit (USDT)
|
||||||
TRAILING_CALLBACK_RATE=0.2 # Trailing callback percent (e.g. 0.2 => 0.2%)
|
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)
|
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_LENGTH=20 # SMA window (minutes) used for Bollinger bandwidth
|
||||||
BOLLINGER_STD_MULTIPLIER=2 # Standard deviation multiplier for Bollinger bands
|
BOLLINGER_STD_MULTIPLIER=2 # Standard deviation multiplier for Bollinger bands
|
||||||
MIN_BOLLINGER_BANDWIDTH=0.001 # Require bandwidth >= this ratio before new entries
|
MIN_BOLLINGER_BANDWIDTH=0.001 # Require bandwidth >= this ratio before new entries
|
||||||
|
|
||||||
# Precision (per-symbol exchange filters)
|
# 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)
|
QTY_STEP=0.001 # Quantity step size (e.g. BTC min step 0.001)
|
||||||
|
|
||||||
# Engine cadence and UI
|
# 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_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK)
|
||||||
|
|
||||||
# Maker-points Binance depth imbalance monitor
|
# 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_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 strategy defaults
|
||||||
GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency)
|
GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency)
|
||||||
GRID_UPPER_PRICE=35000 # Grid upper bound price
|
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_MAX_LOG_ENTRIES=200 # Grid trade log length (defaults to MAX_LOG_ENTRIES when unset)
|
||||||
GRID_DIRECTION=both # Order direction: both | long | short
|
GRID_DIRECTION=both # Order direction: both | long | short
|
||||||
GRID_STOP_LOSS_PCT=0.01 # Stop loss trigger percentage beyond bounds (0.01 => 1%)
|
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_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_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_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_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_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_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_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_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)
|
# 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_ACCOUNT_INDEX=
|
||||||
LIGHTER_API_PRIVATE_KEY= # 40-byte hex private key (e.g., 0x...)
|
LIGHTER_API_PRIVATE_KEY= # 40-byte hex private key (e.g., 0x...)
|
||||||
LIGHTER_API_KEY_INDEX=0 # API key slot (default 0)
|
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_ENV=testnet # mainnet | testnet | staging | dev
|
||||||
# LIGHTER_BASE_URL=https://testnet.zklighter.elliot.ai
|
# 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_MARKET_ID=1 # Prefer explicit market id when symbols differ
|
||||||
# LIGHTER_PRICE_DECIMALS=3 # Manual override for price decimals (optional)
|
# LIGHTER_PRICE_DECIMALS=3 # Manual override for price decimals (optional)
|
||||||
# LIGHTER_SIZE_DECIMALS=3 # Manual override for size decimals (optional)
|
# LIGHTER_SIZE_DECIMALS=3 # Manual override for size decimals (optional)
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
docs/
|
docs/
|
||||||
.claude/
|
.claude/
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|
||||||
|
# 密钥文件:package.json 的 files 白名单之外的第二道防线。
|
||||||
|
# 注意 .npmignore 一旦存在就会完全接管 .gitignore,.gitignore 里的规则不再生效。
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|||||||
@@ -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)
|
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=RITMEX)
|
||||||
* [Hyperliquid 邀请注册链接](https://app.hyperliquid.xyz/join/RITMEX)
|
* [Hyperliquid 邀请注册链接](https://app.hyperliquid.xyz/join/RITMEX)
|
||||||
* [Ondo Perps 邀请注册链接](https://app.ondoperps.xyz/?ref=4A3ACQ)
|
* [Ondo Perps 邀请注册链接](https://app.ondoperps.xyz/?ref=4A3ACQ)
|
||||||
|
|||||||
@@ -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:
|
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)
|
* [Lighter referral link](https://app.lighter.xyz/?referral=RITMEX)
|
||||||
* [Hyperliquid referral link](https://app.hyperliquid.xyz/join/RITMEX)
|
* [Hyperliquid referral link](https://app.hyperliquid.xyz/join/RITMEX)
|
||||||
* [Ondo Perps referral link](https://app.ondoperps.xyz/?ref=4A3ACQ)
|
* [Ondo Perps referral link](https://app.ondoperps.xyz/?ref=4A3ACQ)
|
||||||
|
|||||||
@@ -8,18 +8,30 @@ This guide configures Lighter perpetuals and the integrated Spot markets. Lighte
|
|||||||
|
|
||||||
## 1. Select a network
|
## 1. Select a network
|
||||||
|
|
||||||
| `LIGHTER_ENV` | REST URL | Chain ID |
|
| `LIGHTER_ENV` | REST URL | WebSocket | Signing chain ID | Quote asset |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
|
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
|
||||||
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
|
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
|
||||||
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
|
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
|
||||||
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
|
| `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
|
## 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.
|
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.
|
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`.
|
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.
|
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 |
|
| 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_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_MARKET_TYPE` | `perp` or `spot` |
|
||||||
| `LIGHTER_PRICE_DECIMALS` | Forces price decimals |
|
| `LIGHTER_PRICE_DECIMALS` | Forces price decimals |
|
||||||
| `LIGHTER_SIZE_DECIMALS` | Forces size 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 |
|
| `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
|
```bash
|
||||||
bun run index.ts doctor --exchange lighter --symbol BTC --json
|
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.
|
- `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.
|
- `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.
|
- `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.
|
- 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
|
## Security
|
||||||
|
|||||||
+53
-15
@@ -8,18 +8,30 @@ English version: [Lighter Configuration Guide](lighter.en.md)
|
|||||||
|
|
||||||
## 1. 选择网络
|
## 1. 选择网络
|
||||||
|
|
||||||
| `LIGHTER_ENV` | REST 地址 | Chain ID |
|
| `LIGHTER_ENV` | REST 地址 | WebSocket | 签名 Chain ID | 计价资产 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
|
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
|
||||||
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
|
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
|
||||||
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
|
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
|
||||||
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
|
| `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
|
## 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`。
|
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。
|
3. 按[官方 API Keys 指南](https://apidocs.lighter.xyz/docs/api-keys) 创建 API Key。
|
||||||
4. 保存创建流程返回的 API 私钥,并记录对应的 `api_key_index`。
|
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_L1_ADDRESS` | 账户关联的 L1 地址 |
|
||||||
| `LIGHTER_MARKET_ID` | 强制 market ID;仅在自动解析失败时设置 |
|
| `LIGHTER_MARKET_ID` | 强制 market ID;仅在自动解析失败时设置,且不可跨平台复用 |
|
||||||
| `LIGHTER_MARKET_TYPE` | `perp` 或 `spot` |
|
| `LIGHTER_MARKET_TYPE` | `perp` 或 `spot` |
|
||||||
| `LIGHTER_PRICE_DECIMALS` | 强制价格小数位 |
|
| `LIGHTER_PRICE_DECIMALS` | 强制价格小数位 |
|
||||||
| `LIGHTER_SIZE_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` 输出调试日志 |
|
| `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
|
```bash
|
||||||
bun run index.ts doctor --exchange lighter --symbol BTC --json
|
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`:填写账户接口返回的数字索引。
|
- `LIGHTER_ACCOUNT_INDEX must be an integer`:填写账户接口返回的数字索引。
|
||||||
- `Invalid LIGHTER_API_KEY_INDEX`:使用创建 Key 时记录的非负整数索引。
|
- `Invalid LIGHTER_API_KEY_INDEX`:使用创建 Key 时记录的非负整数索引。
|
||||||
- `private key does not match the one on Lighter`:账户索引、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 环境。
|
- signer 加载失败:仓库预置 macOS arm64 与 Linux amd64 签名库,其他平台需要构建兼容签名库或使用受支持的 WSL/Linux 环境。
|
||||||
|
|
||||||
## 安全要求
|
## 安全要求
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ MAKER_POINTS_BAND_0_10=true
|
|||||||
MAKER_POINTS_BAND_10_30=true
|
MAKER_POINTS_BAND_10_30=true
|
||||||
MAKER_POINTS_BAND_30_100=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 过期时间配置(推荐配置) =====
|
# ===== Token 过期时间配置(推荐配置) =====
|
||||||
# 填写你创建 API Token 时显示的创建日期和有效期天数
|
# 填写你创建 API Token 时显示的创建日期和有效期天数
|
||||||
# 创建日期格式:YYYY-MM-DD(例如:2026-01-15)
|
# 创建日期格式: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_WINDOW_BPS` | Binance 失衡检测窗口(bps) | 默认 `3` |
|
||||||
| `MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO` | Binance 失衡比例阈值 | 默认 `9` |
|
| `MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO` | Binance 失衡比例阈值 | 默认 `9` |
|
||||||
| `MAKER_POINTS_BAND_*` | 三个挂单档位的开关 | 全部 `true` 即可 |
|
| `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_CREATE_DATE` | Token 创建日期 | 推荐配置,格式 YYYY-MM-DD |
|
||||||
| `STANDX_TOKEN_VALIDITY_DAYS` | Token 有效期天数 | 推荐配置,与创建日期配合使用 |
|
| `STANDX_TOKEN_VALIDITY_DAYS` | Token 有效期天数 | 推荐配置,与创建日期配合使用 |
|
||||||
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
|
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
|
||||||
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
|
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
|
||||||
| `TELEGRAM_ACCOUNT_LABEL` | Telegram 通知账户标签 | 可选,用于区分多个账户 |
|
| `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 过期时间配置详解
|
### Token 过期时间配置详解
|
||||||
|
|
||||||
`STANDX_TOKEN_CREATE_DATE` 和 `STANDX_TOKEN_VALIDITY_DAYS` 用于设置 Token 的过期时间。配置后,策略会:
|
`STANDX_TOKEN_CREATE_DATE` 和 `STANDX_TOKEN_VALIDITY_DAYS` 用于设置 Token 的过期时间。配置后,策略会:
|
||||||
@@ -329,6 +439,22 @@ STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
|
|||||||
2. 检查 TOKEN 和私钥是否正确填写
|
2. 检查 TOKEN 和私钥是否正确填写
|
||||||
3. 检查 .env 文件是否保存成功
|
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:担心平掉我手动开的仓位?
|
### Q:担心平掉我手动开的仓位?
|
||||||
|
|
||||||
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
|
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
|
||||||
|
|||||||
@@ -11,6 +11,19 @@
|
|||||||
"bin": {
|
"bin": {
|
||||||
"ritmex-bot": "./bin/ritmex-bot"
|
"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": {
|
"scripts": {
|
||||||
"dev": "bun run index.ts",
|
"dev": "bun run index.ts",
|
||||||
"start": "bun run index.ts",
|
"start": "bun run index.ts",
|
||||||
@@ -20,6 +33,8 @@
|
|||||||
"test": "bun x vitest run",
|
"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: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",
|
"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:trend:silent": "bun run index.ts --strategy trend --silent",
|
||||||
"start:maker:silent": "bun run index.ts --strategy maker --silent",
|
"start:maker:silent": "bun run index.ts --strategy maker --silent",
|
||||||
"start:offset:silent": "bun run index.ts --strategy offset-maker --silent",
|
"start:offset:silent": "bun run index.ts --strategy offset-maker --silent",
|
||||||
|
|||||||
@@ -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
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
||||||
import { language, type Language } from "./i18n";
|
import { language, type Language } from "./i18n";
|
||||||
|
import { DEFAULT_BAND_BPS, MAKER_POINTS_ZERO_BPS } from "./strategy/maker-points-logic";
|
||||||
|
|
||||||
export interface StandxTokenConfig {
|
export interface StandxTokenConfig {
|
||||||
expiryTimestamp: number | null;
|
expiryTimestamp: number | null;
|
||||||
@@ -226,7 +227,20 @@ export interface MakerPointsConfig {
|
|||||||
band10To30Amount: number;
|
band10To30Amount: number;
|
||||||
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
band30To100Amount: number;
|
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;
|
minRepriceBps: number;
|
||||||
|
/** 远档重挂阈值 = max(minRepriceBps, 目标距离 × 该比例),默认 0.15 */
|
||||||
|
bandRepriceRatio: number;
|
||||||
|
/** 成交后立即止损的触发价偏移(bps),默认 2;随标的价格自动缩放 */
|
||||||
|
slOffsetBps: number;
|
||||||
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
|
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
|
||||||
enableBinanceDepthCancel: boolean;
|
enableBinanceDepthCancel: boolean;
|
||||||
/** Binance 深度监控窗口(bps),默认 3 */
|
/** 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 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 = {
|
export const makerPointsConfig: MakerPointsConfig = {
|
||||||
symbol: resolveSymbolFromEnv("standx"),
|
symbol: resolveSymbolFromEnv("standx"),
|
||||||
perOrderAmount: defaultMakerPointsAmount,
|
perOrderAmount: defaultMakerPointsAmount,
|
||||||
@@ -252,13 +293,19 @@ export const makerPointsConfig: MakerPointsConfig = {
|
|||||||
),
|
),
|
||||||
priceTick: parseNumber(process.env.MAKER_POINTS_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
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),
|
qtyStep: parseNumber(process.env.MAKER_POINTS_QTY_STEP ?? process.env.QTY_STEP, 0.001),
|
||||||
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
|
enableBand0To10: makerPointsBands.band0To10.enabled,
|
||||||
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
|
enableBand10To30: makerPointsBands.band10To30.enabled,
|
||||||
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
|
enableBand30To100: makerPointsBands.band30To100.enabled,
|
||||||
band0To10Amount: parseNumber(process.env.MAKER_POINTS_BAND_0_10_AMOUNT, defaultMakerPointsAmount),
|
band0To10Amount: parseNumber(process.env.MAKER_POINTS_BAND_0_10_AMOUNT, defaultMakerPointsAmount),
|
||||||
band10To30Amount: parseNumber(process.env.MAKER_POINTS_BAND_10_30_AMOUNT, defaultMakerPointsAmount),
|
band10To30Amount: parseNumber(process.env.MAKER_POINTS_BAND_10_30_AMOUNT, defaultMakerPointsAmount),
|
||||||
band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_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),
|
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),
|
enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true),
|
||||||
binanceDepthWindowBps: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS, 3),
|
binanceDepthWindowBps: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS, 3),
|
||||||
binanceDepthImbalanceRatio: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO, 9),
|
binanceDepthImbalanceRatio: parseNumber(process.env.MAKER_POINTS_BINANCE_DEPTH_IMBALANCE_RATIO, 9),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface LighterCredentials {
|
|||||||
apiKeyIndex?: number;
|
apiKeyIndex?: number;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
wsUrl?: string;
|
||||||
marketId?: number;
|
marketId?: number;
|
||||||
priceDecimals?: number;
|
priceDecimals?: number;
|
||||||
sizeDecimals?: number;
|
sizeDecimals?: number;
|
||||||
@@ -60,7 +61,8 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
|
|||||||
accountIndex,
|
accountIndex,
|
||||||
apiKeys,
|
apiKeys,
|
||||||
baseUrl: credentials.baseUrl ?? process.env.LIGHTER_BASE_URL,
|
baseUrl: credentials.baseUrl ?? process.env.LIGHTER_BASE_URL,
|
||||||
environment: environment as LighterGatewayOptions["environment"],
|
environment,
|
||||||
|
wsUrl: credentials.wsUrl ?? process.env.LIGHTER_WS_URL,
|
||||||
marketId,
|
marketId,
|
||||||
priceDecimals,
|
priceDecimals,
|
||||||
sizeDecimals,
|
sizeDecimals,
|
||||||
|
|||||||
@@ -1,36 +1,114 @@
|
|||||||
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev";
|
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev" | "rh" | "rh-testnet";
|
||||||
|
|
||||||
export interface LighterHostConfig {
|
export interface LighterHostConfig {
|
||||||
rest: string;
|
rest: string;
|
||||||
ws: 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: {
|
mainnet: {
|
||||||
rest: "https://mainnet.zklighter.elliot.ai",
|
rest: "https://mainnet.zklighter.elliot.ai",
|
||||||
ws: "wss://mainnet.zklighter.elliot.ai/stream",
|
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: {
|
testnet: {
|
||||||
rest: "https://testnet.zklighter.elliot.ai",
|
rest: "https://testnet.zklighter.elliot.ai",
|
||||||
ws: "wss://testnet.zklighter.elliot.ai/stream",
|
ws: "wss://testnet.zklighter.elliot.ai/stream",
|
||||||
|
chainId: 300,
|
||||||
|
l1ChainId: 123456,
|
||||||
|
zkLighterContract: "0xe034801BC49cCDC79FB683022dA0591C86077261",
|
||||||
|
defaultQuoteAsset: "USDC",
|
||||||
},
|
},
|
||||||
staging: {
|
staging: {
|
||||||
rest: "https://staging.zklighter.elliot.ai",
|
rest: "https://staging.zklighter.elliot.ai",
|
||||||
ws: "wss://staging.zklighter.elliot.ai/stream",
|
ws: "wss://staging.zklighter.elliot.ai/stream",
|
||||||
|
chainId: 300,
|
||||||
|
l1ChainId: null,
|
||||||
|
zkLighterContract: null,
|
||||||
|
defaultQuoteAsset: "USDC",
|
||||||
},
|
},
|
||||||
dev: {
|
dev: {
|
||||||
rest: "https://dev.zklighter.elliot.ai",
|
rest: "https://dev.zklighter.elliot.ai",
|
||||||
ws: "wss://dev.zklighter.elliot.ai/stream",
|
ws: "wss://dev.zklighter.elliot.ai/stream",
|
||||||
|
chainId: 300,
|
||||||
|
l1ChainId: null,
|
||||||
|
zkLighterContract: null,
|
||||||
|
defaultQuoteAsset: "USDC",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = {
|
/** Spellings users actually type, mapped onto canonical environment names. */
|
||||||
mainnet: 304,
|
export const LIGHTER_ENVIRONMENT_ALIASES: Record<string, LighterEnvironment> = {
|
||||||
testnet: 300,
|
robinhood: "rh",
|
||||||
staging: 300,
|
robinhoodchain: "rh",
|
||||||
dev: 300,
|
"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_LIGHTER_ENVIRONMENT: LighterEnvironment = "testnet";
|
||||||
|
|
||||||
export const DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS = 10 * 60 * 1000 - 1000; // 10 min minus 1s
|
export const DEFAULT_TRANSACTION_EXPIRY_BUFFER_MS = 10 * 60 * 1000 - 1000; // 10 min minus 1s
|
||||||
|
|||||||
@@ -34,13 +34,12 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AUTH_TOKEN_BUFFER_MS,
|
DEFAULT_AUTH_TOKEN_BUFFER_MS,
|
||||||
DEFAULT_LIGHTER_ENVIRONMENT,
|
|
||||||
LIGHTER_HOSTS,
|
|
||||||
LIGHTER_ORDER_TYPE,
|
LIGHTER_ORDER_TYPE,
|
||||||
LIGHTER_TIME_IN_FORCE,
|
LIGHTER_TIME_IN_FORCE,
|
||||||
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
|
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
|
||||||
type LighterEnvironment,
|
type LighterEnvironment,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
import { resolveLighterNetwork, type LighterNetworkResolution } from "./network";
|
||||||
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
|
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
|
||||||
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
|
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
|
||||||
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
|
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 {
|
interface Pollers {
|
||||||
ticker?: ReturnType<typeof setInterval>;
|
ticker?: ReturnType<typeof setInterval>;
|
||||||
klines: Map<string, ReturnType<typeof setInterval>>;
|
klines: Map<string, ReturnType<typeof setInterval>>;
|
||||||
@@ -181,8 +139,34 @@ const TERMINAL_ORDER_STATUSES = new Set([
|
|||||||
"canceled-reduce-only",
|
"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 },
|
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 {
|
export interface LighterGatewayOptions {
|
||||||
@@ -191,7 +175,9 @@ export interface LighterGatewayOptions {
|
|||||||
accountIndex: number;
|
accountIndex: number;
|
||||||
apiKeys: Record<number, string>;
|
apiKeys: Record<number, string>;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
environment?: keyof typeof LIGHTER_HOSTS;
|
/** Canonical name or alias; see LIGHTER_ENVIRONMENT_ALIASES. */
|
||||||
|
environment?: string;
|
||||||
|
wsUrl?: string;
|
||||||
marketId?: number;
|
marketId?: number;
|
||||||
priceDecimals?: number;
|
priceDecimals?: number;
|
||||||
sizeDecimals?: number;
|
sizeDecimals?: number;
|
||||||
@@ -211,7 +197,9 @@ export class LighterGateway {
|
|||||||
private readonly nonceManager: HttpNonceManager;
|
private readonly nonceManager: HttpNonceManager;
|
||||||
private readonly logger: (context: string, error: unknown) => void;
|
private readonly logger: (context: string, error: unknown) => void;
|
||||||
private readonly apiKeyIndices: number[];
|
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 readonly pollers: Pollers = { ticker: undefined, klines: new Map() };
|
||||||
private accountPoller: ReturnType<typeof setInterval> | null = null;
|
private accountPoller: ReturnType<typeof setInterval> | null = null;
|
||||||
private accountPollInFlight = false;
|
private accountPollInFlight = false;
|
||||||
@@ -236,6 +224,8 @@ export class LighterGateway {
|
|||||||
private forcedSpotPreset = false;
|
private forcedSpotPreset = false;
|
||||||
|
|
||||||
private marketId: number | null = null;
|
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 marketType: "perp" | "spot" | null = null;
|
||||||
private priceDecimals: number | null = null;
|
private priceDecimals: number | null = null;
|
||||||
private sizeDecimals: number | null = null;
|
private sizeDecimals: number | null = null;
|
||||||
@@ -295,40 +285,43 @@ export class LighterGateway {
|
|||||||
const parsedSymbols = parseBaseQuote(this.marketSymbol);
|
const parsedSymbols = parseBaseQuote(this.marketSymbol);
|
||||||
this.baseAssetSymbol = parsedSymbols.base ?? null;
|
this.baseAssetSymbol = parsedSymbols.base ?? null;
|
||||||
this.quoteAssetSymbol = parsedSymbols.quote ?? null;
|
this.quoteAssetSymbol = parsedSymbols.quote ?? null;
|
||||||
this.applyPresetMarket();
|
|
||||||
if (process.env.LIGHTER_MARKET_ID) {
|
// Explicit overrides are applied before presets so a preset can only fill a gap, never
|
||||||
this.marketId = Number(process.env.LIGHTER_MARKET_ID);
|
// 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) {
|
if (process.env.LIGHTER_MARKET_TYPE) {
|
||||||
this.marketType = normalizeMarketType(process.env.LIGHTER_MARKET_TYPE) ?? this.marketType;
|
this.marketType = normalizeMarketType(process.env.LIGHTER_MARKET_TYPE) ?? this.marketType;
|
||||||
}
|
}
|
||||||
const envPreference =
|
|
||||||
options.environment ??
|
const baseUrl = options.baseUrl ?? process.env.LIGHTER_BASE_URL ?? undefined;
|
||||||
process.env.LIGHTER_ENV ??
|
// A spot-only symbol implies its venue, but only when nothing more explicit was given —
|
||||||
(this.forcedSpotPreset && !options.baseUrl ? "mainnet" : undefined);
|
// otherwise the default (testnet) would be picked for a market that does not exist there.
|
||||||
this.environment = inferEnvironment(envPreference, options.baseUrl);
|
const presetEnvHint = SPOT_PRESET_ENVIRONMENTS[normalizeSymbolKey(this.marketSymbol)];
|
||||||
const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest;
|
this.network = resolveLighterNetwork({
|
||||||
if (!host) {
|
environment: options.environment ?? process.env.LIGHTER_ENV ?? (baseUrl ? undefined : presetEnvHint),
|
||||||
throw new Error(`Unknown Lighter environment ${this.environment}`);
|
baseUrl,
|
||||||
}
|
wsUrl: options.wsUrl ?? process.env.LIGHTER_WS_URL,
|
||||||
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
|
chainId: options.chainId,
|
||||||
// eslint-disable-next-line no-console
|
});
|
||||||
console.error(
|
this.environment = this.network.environment;
|
||||||
"[LighterGateway] init",
|
|
||||||
JSON.stringify({ env: this.environment, host, marketId: this.marketId, marketType: this.marketType })
|
// Market ids are per-deployment, so presets can only be applied once the venue is known.
|
||||||
);
|
this.applyPresetMarket();
|
||||||
}
|
|
||||||
const wsHost = LIGHTER_HOSTS[this.environment]?.ws;
|
this.wsUrl = this.network.wsUrl;
|
||||||
if (!wsHost) {
|
this.http = new LighterHttpClient({ baseUrl: this.network.restUrl });
|
||||||
throw new Error(`WebSocket endpoint not configured for env ${this.environment}`);
|
|
||||||
}
|
|
||||||
this.wsUrl = wsHost;
|
|
||||||
this.http = new LighterHttpClient({ baseUrl: host });
|
|
||||||
this.signer = new LighterSigner({
|
this.signer = new LighterSigner({
|
||||||
accountIndex: options.accountIndex,
|
accountIndex: options.accountIndex,
|
||||||
chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300),
|
chainId: this.network.chainId,
|
||||||
apiKeys: options.apiKeys,
|
apiKeys: options.apiKeys,
|
||||||
baseUrl: host,
|
baseUrl: this.network.restUrl,
|
||||||
});
|
});
|
||||||
this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number);
|
this.apiKeyIndices = options.apiKeyIndices ?? Object.keys(options.apiKeys).map(Number);
|
||||||
if (this.forcedSpotPreset && this.apiKeyIndices.length > 1) {
|
if (this.forcedSpotPreset && this.apiKeyIndices.length > 1) {
|
||||||
@@ -347,9 +340,6 @@ export class LighterGateway {
|
|||||||
console.error(`[LighterGateway] ${context}`, error);
|
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.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS;
|
||||||
this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS;
|
this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS;
|
||||||
this.l1Address = options.l1Address ?? null;
|
this.l1Address = options.l1Address ?? null;
|
||||||
@@ -359,6 +349,16 @@ export class LighterGateway {
|
|||||||
this.lastOrdersUpdateAt = now;
|
this.lastOrdersUpdateAt = now;
|
||||||
this.lastAccountUpdateAt = now;
|
this.lastAccountUpdateAt = now;
|
||||||
this.lastTickerUpdateAt = 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> {
|
async ensureInitialized(): Promise<void> {
|
||||||
@@ -520,16 +520,64 @@ export class LighterGateway {
|
|||||||
this.startStaleMonitor();
|
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> {
|
private async loadMetadata(): Promise<void> {
|
||||||
|
await this.verifyNetworkIdentity();
|
||||||
const books = await this.http.getOrderBooks();
|
const books = await this.http.getOrderBooks();
|
||||||
const desiredSymbol = this.marketSymbol;
|
const desiredSymbol = this.marketSymbol;
|
||||||
const wantsSpot = guessMarketType(desiredSymbol) === "spot" || this.marketType === "spot";
|
const wantsSpot = guessMarketType(desiredSymbol) === "spot" || this.marketType === "spot";
|
||||||
this.logger("loadMetadata", { desiredSymbol, wantsSpot, presetMarketId: this.marketId, bookCount: books.length });
|
this.logger("loadMetadata", { desiredSymbol, wantsSpot, presetMarketId: this.marketId, bookCount: books.length });
|
||||||
let target: LighterOrderBookMetadata | null = null;
|
let target: LighterOrderBookMetadata | null = null;
|
||||||
|
|
||||||
if (!this.marketId && wantsSpot) {
|
if (!this.marketId && wantsSpot && this.environment) {
|
||||||
const normalized = desiredSymbol.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalizeSymbolKey(desiredSymbol)];
|
||||||
const preset = KNOWN_SPOT_MARKETS[normalized];
|
|
||||||
if (preset) {
|
if (preset) {
|
||||||
this.marketId = preset.marketId;
|
this.marketId = preset.marketId;
|
||||||
this.baseAssetSymbol = this.baseAssetSymbol ?? preset.base;
|
this.baseAssetSymbol = this.baseAssetSymbol ?? preset.base;
|
||||||
@@ -550,8 +598,11 @@ export class LighterGateway {
|
|||||||
target = spotById ?? null;
|
target = spotById ?? null;
|
||||||
}
|
}
|
||||||
if (!target) {
|
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(
|
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"}`
|
`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.marketId = Number(target.market_id);
|
||||||
|
this.resolvedMarketSymbol = target.symbol ?? null;
|
||||||
this.marketType = normalizeMarketType(target.market_type) ?? this.marketType ?? guessMarketType(target.symbol);
|
this.marketType = normalizeMarketType(target.market_type) ?? this.marketType ?? guessMarketType(target.symbol);
|
||||||
this.baseAssetId = target.base_asset_id ?? this.baseAssetId;
|
this.baseAssetId = target.base_asset_id ?? this.baseAssetId;
|
||||||
this.quoteAssetId = target.quote_asset_id ?? this.quoteAssetId;
|
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> {
|
private async refreshAccountSnapshot(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const auth = await this.ensureAuthToken();
|
const auth = await this.ensureAuthToken();
|
||||||
@@ -927,8 +1005,7 @@ export class LighterGateway {
|
|||||||
desiredSymbol.includes("/") ||
|
desiredSymbol.includes("/") ||
|
||||||
desiredSymbol.includes("-") ||
|
desiredSymbol.includes("-") ||
|
||||||
desiredSymbol.includes(":") ||
|
desiredSymbol.includes(":") ||
|
||||||
normalizedDesired.includes("USDC") ||
|
SPOT_QUOTE_SUFFIXES.some((suffix) => normalizedDesired.includes(suffix));
|
||||||
normalizedDesired.endsWith("USD");
|
|
||||||
const preferred = candidates.filter((book) =>
|
const preferred = candidates.filter((book) =>
|
||||||
wantsSpot ? normalizeMarketType(book.market_type) === "spot" : true
|
wantsSpot ? normalizeMarketType(book.market_type) === "spot" : true
|
||||||
);
|
);
|
||||||
@@ -1471,7 +1548,8 @@ export class LighterGateway {
|
|||||||
|
|
||||||
private extractMarketIdFromChannel(channel: unknown): number | null {
|
private extractMarketIdFromChannel(channel: unknown): number | null {
|
||||||
if (typeof channel !== "string") return 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]) {
|
if (match && match[1]) {
|
||||||
const value = Number(match[1]);
|
const value = Number(match[1]);
|
||||||
return Number.isFinite(value) ? value : null;
|
return Number.isFinite(value) ? value : null;
|
||||||
@@ -1513,7 +1591,9 @@ export class LighterGateway {
|
|||||||
marketId: this.marketId,
|
marketId: this.marketId,
|
||||||
marketType: this.marketType ?? guessMarketType(this.marketSymbol),
|
marketType: this.marketType ?? guessMarketType(this.marketSymbol),
|
||||||
baseAssetSymbol: this.baseAssetSymbol,
|
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,
|
baseAssetId: this.baseAssetId,
|
||||||
quoteAssetId: this.quoteAssetId,
|
quoteAssetId: this.quoteAssetId,
|
||||||
}
|
}
|
||||||
@@ -1661,10 +1741,17 @@ export class LighterGateway {
|
|||||||
const stats = await this.http.getExchangeStats();
|
const stats = await this.http.getExchangeStats();
|
||||||
const marketId = this.marketId;
|
const marketId = this.marketId;
|
||||||
if (marketId == null) return;
|
if (marketId == null) return;
|
||||||
const match = stats.find(
|
// Neither mainnet nor rh returns market_id in this payload today, so matching falls back
|
||||||
(entry) => Number(entry.market_id) === marketId || (entry.symbol ? entry.symbol.toUpperCase() : "") === this.marketSymbol
|
// 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;
|
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);
|
const ticker = toTicker(this.displaySymbol, match);
|
||||||
this.tickerEvent.emit(ticker);
|
this.tickerEvent.emit(ticker);
|
||||||
this.loggedCreateOrderPayload = false;
|
this.loggedCreateOrderPayload = false;
|
||||||
@@ -1783,8 +1870,9 @@ export class LighterGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyPresetMarket(): void {
|
private applyPresetMarket(): void {
|
||||||
const normalized = (this.marketSymbol ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
|
if (!this.environment) return;
|
||||||
const preset = KNOWN_SPOT_MARKETS[normalized];
|
const normalized = normalizeSymbolKey(this.marketSymbol);
|
||||||
|
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalized];
|
||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
if (this.marketId == null) this.marketId = preset.marketId;
|
if (this.marketId == null) this.marketId = preset.marketId;
|
||||||
if (!this.baseAssetSymbol) this.baseAssetSymbol = preset.base;
|
if (!this.baseAssetSymbol) this.baseAssetSymbol = preset.base;
|
||||||
@@ -2192,10 +2280,19 @@ function normalizeMarketType(value: string | null | undefined): "perp" | "spot"
|
|||||||
return undefined;
|
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 {
|
function guessMarketType(symbol: string | null | undefined): "perp" | "spot" | null {
|
||||||
if (!symbol) return null;
|
if (!symbol) return null;
|
||||||
const upper = symbol.toUpperCase();
|
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 "spot";
|
||||||
}
|
}
|
||||||
return null;
|
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[] {
|
function normalizeSymbolForms(value: string | null | undefined): string[] {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
const upper = value.toUpperCase();
|
const upper = value.toUpperCase();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
LighterMarketStats,
|
LighterMarketStats,
|
||||||
LighterOrderBookMetadata,
|
LighterOrderBookMetadata,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS } from "./constants";
|
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS, LIGHTER_NETWORKS } from "./constants";
|
||||||
|
|
||||||
interface ApiResponseBase {
|
interface ApiResponseBase {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -38,6 +38,11 @@ interface NextNonceResponse extends ApiResponseBase {
|
|||||||
nonce: number;
|
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 {
|
export interface SendTxResponse extends ApiResponseBase {
|
||||||
tx_hash: string;
|
tx_hash: string;
|
||||||
predicted_execution_time_ms?: number;
|
predicted_execution_time_ms?: number;
|
||||||
@@ -78,7 +83,7 @@ export class LighterHttpClient {
|
|||||||
|
|
||||||
constructor(options: LighterHttpClientOptions = {}) {
|
constructor(options: LighterHttpClientOptions = {}) {
|
||||||
const env = options.environment ?? DEFAULT_LIGHTER_ENVIRONMENT;
|
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) {
|
if (!host) {
|
||||||
throw new Error(`Unknown Lighter environment: ${env}`);
|
throw new Error(`Unknown Lighter environment: ${env}`);
|
||||||
}
|
}
|
||||||
@@ -95,25 +100,31 @@ export class LighterHttpClient {
|
|||||||
return response.order_books ?? [];
|
return response.order_books ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getLayer1BasicInfo(): Promise<Layer1BasicInfo> {
|
||||||
|
return this.get<Layer1BasicInfo>("/api/v1/layer1BasicInfo");
|
||||||
|
}
|
||||||
|
|
||||||
async getExchangeStats(): Promise<LighterMarketStats[]> {
|
async getExchangeStats(): Promise<LighterMarketStats[]> {
|
||||||
const response = await this.get<ExchangeStatsResponse>("/api/v1/exchangeStats");
|
const response = await this.get<ExchangeStatsResponse>("/api/v1/exchangeStats");
|
||||||
const stats = response.order_book_stats ?? [];
|
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) => ({
|
return stats.map((entry) => ({
|
||||||
market_id: entry.market_id,
|
market_id: entry.market_id,
|
||||||
symbol: entry.symbol,
|
symbol: entry.symbol,
|
||||||
market_type: (entry as any).market_type,
|
market_type: (entry as any).market_type,
|
||||||
index_price: (entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price,
|
index_price: toPriceString((entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price) ?? "0",
|
||||||
mid_price: (entry as any).mid_price,
|
mid_price: toPriceString((entry as any).mid_price),
|
||||||
mark_price: entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price,
|
mark_price: toPriceString(entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price),
|
||||||
last_trade_price: entry.last_trade_price,
|
last_trade_price: toPriceString(entry.last_trade_price) ?? "0",
|
||||||
open_interest: (entry as any).open_interest ?? "0",
|
open_interest: toPriceString((entry as any).open_interest) ?? "0",
|
||||||
daily_base_token_volume: entry.daily_base_token_volume,
|
daily_base_token_volume: entry.daily_base_token_volume,
|
||||||
daily_quote_token_volume: entry.daily_quote_token_volume,
|
daily_quote_token_volume: entry.daily_quote_token_volume,
|
||||||
daily_price_low: entry.daily_price_low,
|
daily_price_low: entry.daily_price_low,
|
||||||
daily_price_high: entry.daily_price_high,
|
daily_price_high: entry.daily_price_high,
|
||||||
daily_price_change: entry.daily_price_change,
|
daily_price_change: entry.daily_price_change,
|
||||||
current_funding_rate: entry.current_funding_rate,
|
current_funding_rate: toPriceString(entry.current_funding_rate),
|
||||||
funding_rate: entry.funding_rate,
|
funding_rate: toPriceString(entry.funding_rate),
|
||||||
funding_timestamp: entry.funding_timestamp,
|
funding_timestamp: entry.funding_timestamp,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -293,3 +304,10 @@ export class LighterHttpClient {
|
|||||||
function truncateBody(body: string, limit = 200): string {
|
function truncateBody(body: string, limit = 200): string {
|
||||||
return body.length > limit ? `${body.slice(0, limit)}…` : body;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -144,6 +144,8 @@ export interface LighterOrderBookMetadata {
|
|||||||
supported_price_decimals: number;
|
supported_price_decimals: number;
|
||||||
supported_quote_decimals: number;
|
supported_quote_decimals: number;
|
||||||
status: "inactive" | "frozen" | "active" | string;
|
status: "inactive" | "frozen" | "active" | string;
|
||||||
|
/** Contract multiplier; "1.0" everywhere except a few tokenized-equity markets on rh. */
|
||||||
|
multiplier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LighterAccountMarketUpdate {
|
export interface LighterAccountMarketUpdate {
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ function resolveLighterCredentials(symbol: string): LighterCredentials {
|
|||||||
apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0,
|
apiKeyIndex: process.env.LIGHTER_API_KEY_INDEX ? Number(process.env.LIGHTER_API_KEY_INDEX) : 0,
|
||||||
environment: process.env.LIGHTER_ENV,
|
environment: process.env.LIGHTER_ENV,
|
||||||
baseUrl: process.env.LIGHTER_BASE_URL,
|
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,
|
l1Address: process.env.LIGHTER_L1_ADDRESS,
|
||||||
marketSymbol: process.env.LIGHTER_SYMBOL,
|
marketSymbol: process.env.LIGHTER_SYMBOL,
|
||||||
marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined,
|
marketId: process.env.LIGHTER_MARKET_ID ? Number(process.env.LIGHTER_MARKET_ID) : undefined,
|
||||||
|
|||||||
+7
-2
@@ -243,6 +243,10 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "交易所: {exchange} | 交易对: {symbol} | 买一价: {bid} | 卖一价: {ask} | 点差: {spread}",
|
zh: "交易所: {exchange} | 交易对: {symbol} | 买一价: {bid} | 卖一价: {ask} | 点差: {spread}",
|
||||||
en: "Exchange: {exchange} | Symbol: {symbol} | Best Bid: {bid} | Best Ask: {ask} | Spread: {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": {
|
"makerPoints.quoteLine": {
|
||||||
zh: "挂单模式: {mode} | BUY {buy} | SELL {sell}",
|
zh: "挂单模式: {mode} | BUY {buy} | SELL {sell}",
|
||||||
en: "Quote mode: {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}",
|
en: "Binance depth (±{windowBps}bps): bid {buy} | ask {sell} | Status: {status}",
|
||||||
},
|
},
|
||||||
"makerPoints.bandDepthLine": {
|
"makerPoints.bandDepthLine": {
|
||||||
zh: "StandX 档位 {band}bps 深度: 买 {buy} | 卖 {sell}",
|
zh: "档位 {band} 目标 {target}bps | 买 {buyDist} ×{buyMult} 深度 {buy} | 卖 {sellDist} ×{sellMult} 深度 {sell}",
|
||||||
en: "StandX band {band}bps depth: buy {buy} | sell {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.closeOnly": { zh: "平仓", en: "Close only" },
|
||||||
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
|
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
|
||||||
"makerPoints.feed.binance": { zh: "Binance", en: "Binance" },
|
"makerPoints.feed.binance": { zh: "Binance", en: "Binance" },
|
||||||
|
|||||||
+310
-211
@@ -35,7 +35,16 @@ import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-
|
|||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
|
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 { t } from "../i18n";
|
||||||
import { IsolatedMarginGuard } from "./common/isolated-margin-guard";
|
import { IsolatedMarginGuard } from "./common/isolated-margin-guard";
|
||||||
import { TokenExpiryGuard } from "./common/token-expiry-guard";
|
import { TokenExpiryGuard } from "./common/token-expiry-guard";
|
||||||
@@ -52,11 +61,28 @@ interface DesiredOrder {
|
|||||||
reduceOnly: boolean;
|
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 {
|
export interface MakerPointsSnapshot {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
topBid: number | null;
|
topBid: number | null;
|
||||||
topAsk: number | null;
|
topAsk: number | null;
|
||||||
|
markPrice: number | null;
|
||||||
spread: number | null;
|
spread: number | null;
|
||||||
priceDecimals: number;
|
priceDecimals: number;
|
||||||
position: PositionSnapshot;
|
position: PositionSnapshot;
|
||||||
@@ -75,13 +101,11 @@ export interface MakerPointsSnapshot {
|
|||||||
binance: boolean;
|
binance: boolean;
|
||||||
};
|
};
|
||||||
binanceDepth: BinanceDepthSnapshot | null;
|
binanceDepth: BinanceDepthSnapshot | null;
|
||||||
bandDepths: Array<{
|
/** 配置的最大挂单距离(bps),用于仪表盘提示与 100bps 悬崖的安全边际。 */
|
||||||
band: "0-10" | "10-30" | "30-100";
|
maxDistanceBps: number;
|
||||||
bps: number;
|
bandDepths: BandStatus[];
|
||||||
buyDepth: number | null;
|
/** 每个在场挂单已在盘口停留的毫秒数;Maker Points 要求超过 3 秒才计分。 */
|
||||||
sellDepth: number | null;
|
orderRestingMs: Record<string, number>;
|
||||||
enabled: boolean;
|
|
||||||
}>;
|
|
||||||
quoteStatus: {
|
quoteStatus: {
|
||||||
closeOnly: boolean;
|
closeOnly: boolean;
|
||||||
skipBuy: boolean;
|
skipBuy: boolean;
|
||||||
@@ -134,10 +158,10 @@ export class MakerPointsEngine {
|
|||||||
private lastCloseOnly = false;
|
private lastCloseOnly = false;
|
||||||
private lastSkipBuy = false;
|
private lastSkipBuy = false;
|
||||||
private lastSkipSell = false;
|
private lastSkipSell = false;
|
||||||
private lastQuoteBid1: number | null = null;
|
|
||||||
private lastQuoteAsk1: number | null = null;
|
|
||||||
// 跟踪各档位深度是否足够的状态 (按 bps 值索引)
|
// 跟踪各档位深度是否足够的状态 (按 bps 值索引)
|
||||||
private lastDepthOkStatus: Record<number, { buy: boolean; sell: boolean }> = {};
|
private lastDepthOkStatus: Record<number, { buy: boolean; sell: boolean }> = {};
|
||||||
|
/** 最近一轮实际下发的报价距 mark 的距离,用于仪表盘展示倍率。 */
|
||||||
|
private lastQuoteDistanceBps: Partial<Record<MakerPointsBand, { buy: number | null; sell: number | null }>> = {};
|
||||||
|
|
||||||
private readinessLogged = {
|
private readinessLogged = {
|
||||||
account: false,
|
account: false,
|
||||||
@@ -549,8 +573,7 @@ export class MakerPointsEngine {
|
|||||||
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
||||||
|
|
||||||
// 重置 reprice 基准,强制下一次重新计算
|
// 重置 reprice 基准,强制下一次重新计算
|
||||||
this.lastQuoteBid1 = null;
|
this.lastQuoteDistanceBps = {};
|
||||||
this.lastQuoteAsk1 = null;
|
|
||||||
this.desiredOrders = [];
|
this.desiredOrders = [];
|
||||||
this.lastDesiredSummary = null;
|
this.lastDesiredSummary = null;
|
||||||
|
|
||||||
@@ -705,39 +728,18 @@ export class MakerPointsEngine {
|
|||||||
this.lastSkipSell = skipSell;
|
this.lastSkipSell = skipSell;
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeOnlyChanged = closeOnly !== prevCloseOnly;
|
// 每轮都重算:报价是否真的变动由各档位的 sticky 判定决定,
|
||||||
const skipChanged = skipBuy !== prevSkipBuy || skipSell !== prevSkipSell;
|
// 价格没漂出档位容差时会复用现有挂单价,makeOrderPlan 也就不会撤单。
|
||||||
const repriceNeeded = closeOnly ? true : this.shouldReprice(topBid, topAsk);
|
const desired = closeOnly
|
||||||
const depthStatusChanged = this.checkDepthStatusChanged(depth, topBid, topAsk);
|
|
||||||
const shouldRecompute =
|
|
||||||
closeOnly ||
|
|
||||||
repriceNeeded ||
|
|
||||||
closeOnlyChanged ||
|
|
||||||
skipChanged ||
|
|
||||||
depthStatusChanged ||
|
|
||||||
this.desiredOrders.length === 0;
|
|
||||||
|
|
||||||
const desired = shouldRecompute
|
|
||||||
? closeOnly
|
|
||||||
? this.buildCloseOnlyOrders(position, topBid, topAsk)
|
? this.buildCloseOnlyOrders(position, topBid, topAsk)
|
||||||
: this.buildDesiredOrders({
|
: this.buildDesiredOrders({
|
||||||
bid1: topBid,
|
bid1: topBid,
|
||||||
ask1: topAsk,
|
ask1: topAsk,
|
||||||
|
anchor: this.getQuoteAnchor(depth),
|
||||||
skipBuy,
|
skipBuy,
|
||||||
skipSell,
|
skipSell,
|
||||||
depth,
|
depth,
|
||||||
})
|
});
|
||||||
: this.desiredOrders;
|
|
||||||
|
|
||||||
if (shouldRecompute) {
|
|
||||||
if (closeOnly) {
|
|
||||||
this.lastQuoteBid1 = null;
|
|
||||||
this.lastQuoteAsk1 = null;
|
|
||||||
} else {
|
|
||||||
this.lastQuoteBid1 = topBid;
|
|
||||||
this.lastQuoteAsk1 = topAsk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.desiredOrders = desired;
|
this.desiredOrders = desired;
|
||||||
this.logDesiredOrders(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: {
|
private buildDesiredOrders(params: {
|
||||||
bid1: number;
|
bid1: number;
|
||||||
ask1: number;
|
ask1: number;
|
||||||
|
anchor: number | null;
|
||||||
skipBuy: boolean;
|
skipBuy: boolean;
|
||||||
skipSell: boolean;
|
skipSell: boolean;
|
||||||
depth: Depth | null;
|
depth: Depth | null;
|
||||||
}): DesiredOrder[] {
|
}): DesiredOrder[] {
|
||||||
const { bid1, ask1, skipBuy, skipSell, depth } = params;
|
const { bid1, ask1, anchor, 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 targets = this.bandTargets().sort((a, b) => b.bps - a.bps);
|
||||||
if (!targets.length) return [];
|
if (!targets.length) return [];
|
||||||
|
|
||||||
const priceDecimals = this.getPriceDecimals();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
const desired: DesiredOrder[] = [];
|
|
||||||
const minDepth = this.config.filterMinDepth;
|
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 => {
|
for (const target of targets) {
|
||||||
if (bps <= 10) return Number(this.config.band0To10Amount);
|
const amount = this.amountForBand(target.band);
|
||||||
if (bps <= 30) return Number(this.config.band10To30Amount);
|
const record: { buy: number | null; sell: number | null } = { buy: null, sell: null };
|
||||||
return Number(this.config.band30To100Amount);
|
distances[target.band] = record;
|
||||||
};
|
|
||||||
|
|
||||||
for (const bps of targets) {
|
|
||||||
const amount = getAmountForBps(bps);
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0) continue;
|
if (!Number.isFinite(amount) || amount <= 0) continue;
|
||||||
|
|
||||||
// 所有档位都检查深度
|
for (const side of ["BUY", "SELL"] as const) {
|
||||||
const shouldCheckDepth = minDepth > 0;
|
if (side === "BUY" ? skipBuy : skipSell) continue;
|
||||||
|
|
||||||
if (!skipBuy) {
|
const raw = resolveSafeQuotePrice({
|
||||||
const targetPrice = this.normalizeDepthTargetPrice(bid1 * (1 - bps / 10000), priceDecimals);
|
side,
|
||||||
if (targetPrice != null) {
|
targetBps: target.bps,
|
||||||
if (shouldCheckDepth) {
|
markPrice: anchor,
|
||||||
const depthQty = getDepthBetweenPrices(depth, "BUY", targetPrice);
|
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) {
|
if (depthQty < minDepth) {
|
||||||
this.logThinDepthSkip("BUY", bps, depthQty, minDepth);
|
this.logThinDepthSkip(side, target.bps, depthQty, minDepth);
|
||||||
} else {
|
continue;
|
||||||
this.resetThinDepthSkip("BUY", bps);
|
}
|
||||||
|
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({
|
desired.push({
|
||||||
side: "BUY",
|
side,
|
||||||
price: formatPriceToString(targetPrice, priceDecimals),
|
price: formatPriceToString(price, priceDecimals),
|
||||||
amount,
|
amount,
|
||||||
reduceOnly: false,
|
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;
|
return desired;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查各档位的深度状态是否发生变化
|
* 深度从“满足阈值”切换到“不满足阈值”时立即触发一次主循环,抢在被吃穿前撤单。
|
||||||
* 当深度从足够变为不足,或从不足变为足够时,需要触发重新计算
|
* 同时维护 lastDepthOkStatus,供下一次比较使用。
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 当深度从“满足阈值”切换到“不满足阈值”时,立即触发一次主循环,优先撤销不再安全的挂单。
|
|
||||||
*/
|
*/
|
||||||
private shouldTriggerImmediateDepthProtection(depth: Depth | null): boolean {
|
private shouldTriggerImmediateDepthProtection(depth: Depth | null): boolean {
|
||||||
if (!depth) return false;
|
if (!depth) return false;
|
||||||
@@ -907,47 +939,78 @@ export class MakerPointsEngine {
|
|||||||
const { topBid, topAsk } = getTopPrices(depth);
|
const { topBid, topAsk } = getTopPrices(depth);
|
||||||
if (topBid == null || topAsk == null) return false;
|
if (topBid == null || topAsk == null) return false;
|
||||||
|
|
||||||
const targets = buildBpsTargets({
|
const anchor = this.getQuoteAnchor(depth);
|
||||||
band0To10: this.config.enableBand0To10,
|
|
||||||
band10To30: this.config.enableBand10To30,
|
|
||||||
band30To100: this.config.enableBand30To100,
|
|
||||||
});
|
|
||||||
const priceDecimals = this.getPriceDecimals();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
|
let degraded = false;
|
||||||
|
|
||||||
for (const bps of targets) {
|
for (const target of this.bandTargets()) {
|
||||||
const lastStatus = this.lastDepthOkStatus[bps];
|
const buyPrice = this.normalizeSafeQuote("BUY", target.bps, anchor, topBid, priceDecimals);
|
||||||
if (!lastStatus) continue;
|
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 lastStatus = this.lastDepthOkStatus[target.bps];
|
||||||
const sellTargetPrice = this.normalizeDepthTargetPrice(topAsk * (1 + bps / 10000), priceDecimals);
|
if (lastStatus && ((lastStatus.buy && !currentBuyOk) || (lastStatus.sell && !currentSellOk))) {
|
||||||
const buyDepthQty = getDepthBetweenPrices(depth, "BUY", buyTargetPrice ?? 0);
|
degraded = true;
|
||||||
const sellDepthQty = getDepthBetweenPrices(depth, "SELL", sellTargetPrice ?? 0);
|
}
|
||||||
const currentBuyOk = buyDepthQty >= minDepth;
|
this.lastDepthOkStatus[target.bps] = { buy: currentBuyOk, sell: currentSellOk };
|
||||||
const currentSellOk = sellDepthQty >= minDepth;
|
|
||||||
|
|
||||||
if (lastStatus.buy && !currentBuyOk) return true;
|
|
||||||
if (lastStatus.sell && !currentSellOk) return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private shouldTriggerImmediateReprice(depth: Depth | null): boolean {
|
||||||
if (!depth) return false;
|
if (!depth) return false;
|
||||||
if (this.defenseMode || this.reconnectResetPending || this.stopLossProcessing) return false;
|
if (this.defenseMode || this.reconnectResetPending || this.stopLossProcessing) return false;
|
||||||
|
|
||||||
const hasActiveEntryOrders = this.openOrders.some(
|
const pool = this.activeEntryOrders();
|
||||||
(order) => order.symbol === this.config.symbol && !order.reduceOnly && isOrderActiveStatus(order.status)
|
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);
|
return false;
|
||||||
if (topBid == null || topAsk == null) return false;
|
|
||||||
|
|
||||||
return this.shouldReprice(topBid, topAsk);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCloseOnlyOrders(
|
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> {
|
private async ensureStartupOrderReset(): Promise<boolean> {
|
||||||
if (this.initialOrderResetDone) return true;
|
if (this.initialOrderResetDone) return true;
|
||||||
if (!this.initialOrderSnapshotReady) return false;
|
if (!this.initialOrderSnapshotReady) return false;
|
||||||
@@ -1077,12 +1127,7 @@ export class MakerPointsEngine {
|
|||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
try {
|
try {
|
||||||
// reduce-only 订单不能设置 tp/sl,仅开仓单设置止损
|
// reduce-only 订单不能设置 tp/sl,仅开仓单设置止损
|
||||||
const priceNum = Number(target.price);
|
const slPrice = target.reduceOnly ? undefined : this.computeStopLossTrigger(target.side, Number(target.price));
|
||||||
const slPrice = target.reduceOnly
|
|
||||||
? undefined
|
|
||||||
: target.side === "BUY"
|
|
||||||
? priceNum - 1
|
|
||||||
: priceNum + 1;
|
|
||||||
await placeOrder(this.orderContext, {
|
await placeOrder(this.orderContext, {
|
||||||
openOrders: this.openOrders,
|
openOrders: this.openOrders,
|
||||||
side: target.side,
|
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 {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.precision.priceTick);
|
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
@@ -1359,13 +1419,24 @@ export class MakerPointsEngine {
|
|||||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||||
const spread = topBid != null && topAsk != null ? topAsk - topBid : null;
|
const spread = topBid != null && topAsk != null ? topAsk - topBid : null;
|
||||||
const pnl = computePositionPnl(position, topBid, topAsk);
|
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 {
|
return {
|
||||||
ready: this.isReady(),
|
ready: this.isReady(),
|
||||||
symbol: this.config.symbol,
|
symbol: this.config.symbol,
|
||||||
topBid,
|
topBid,
|
||||||
topAsk,
|
topAsk,
|
||||||
|
markPrice: Number.isFinite(markRaw) && markRaw > 0 ? markRaw : null,
|
||||||
spread,
|
spread,
|
||||||
priceDecimals: this.getPriceDecimals(),
|
priceDecimals: this.getPriceDecimals(),
|
||||||
position,
|
position,
|
||||||
@@ -1378,7 +1449,9 @@ export class MakerPointsEngine {
|
|||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
feedStatus: { ...this.feedStatus },
|
feedStatus: { ...this.feedStatus },
|
||||||
binanceDepth: this.binanceDepth.getSnapshot(),
|
binanceDepth: this.binanceDepth.getSnapshot(),
|
||||||
|
maxDistanceBps: this.config.maxDistanceBps,
|
||||||
bandDepths,
|
bandDepths,
|
||||||
|
orderRestingMs,
|
||||||
quoteStatus: {
|
quoteStatus: {
|
||||||
closeOnly: this.lastCloseOnly,
|
closeOnly: this.lastCloseOnly,
|
||||||
skipBuy: this.lastSkipBuy,
|
skipBuy: this.lastSkipBuy,
|
||||||
@@ -1387,24 +1460,51 @@ export class MakerPointsEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeBandDepths(topBid: number | null, topAsk: number | null): MakerPointsSnapshot["bandDepths"] {
|
private computeBandDepths(
|
||||||
const bands: MakerPointsSnapshot["bandDepths"] = [
|
topBid: number | null,
|
||||||
{ band: "0-10", bps: 9, buyDepth: null, sellDepth: null, enabled: this.config.enableBand0To10 },
|
topAsk: number | null,
|
||||||
{ band: "10-30", bps: 29, buyDepth: null, sellDepth: null, enabled: this.config.enableBand10To30 },
|
anchor: number | null
|
||||||
{ band: "30-100", bps: 99, buyDepth: null, sellDepth: null, enabled: this.config.enableBand30To100 },
|
): BandStatus[] {
|
||||||
];
|
const enabled: Record<MakerPointsBand, boolean> = {
|
||||||
|
"0-10": this.config.enableBand0To10,
|
||||||
if (!this.depthSnapshot || topBid == null || topAsk == null) {
|
"10-30": this.config.enableBand10To30,
|
||||||
return bands;
|
"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();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
|
|
||||||
return bands.map((band) => {
|
return all.map(({ band, bps }) => {
|
||||||
const buyTargetPrice = this.normalizeDepthTargetPrice(topBid * (1 - band.bps / 10000), priceDecimals);
|
const quoted = this.lastQuoteDistanceBps[band];
|
||||||
const sellTargetPrice = this.normalizeDepthTargetPrice(topAsk * (1 + band.bps / 10000), priceDecimals);
|
const buyDistanceBps = quoted?.buy ?? null;
|
||||||
const buyDepth = getDepthBetweenPrices(this.depthSnapshot, "BUY", buyTargetPrice ?? 0);
|
const sellDistanceBps = quoted?.sell ?? null;
|
||||||
const sellDepth = getDepthBetweenPrices(this.depthSnapshot, "SELL", sellTargetPrice ?? 0);
|
const base: BandStatus = {
|
||||||
return { ...band, buyDepth, sellDepth };
|
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.desiredOrders = [];
|
||||||
this.lastDesiredSummary = null;
|
this.lastDesiredSummary = null;
|
||||||
this.lastQuoteBid1 = null;
|
this.lastQuoteDistanceBps = {};
|
||||||
this.lastQuoteAsk1 = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("maker points target builder", () => {
|
||||||
it("builds fixed bps targets per enabled band", () => {
|
it("uses the default bps per enabled band", () => {
|
||||||
const targets = buildBpsTargets({
|
const targets = buildBpsTargets({
|
||||||
band0To10: true,
|
band0To10: true,
|
||||||
band10To30: true,
|
band10To30: true,
|
||||||
band30To100: true,
|
band30To100: true,
|
||||||
});
|
});
|
||||||
expect(targets).toEqual([9, 29, 99]);
|
expect(targets).toEqual([9, 29, 40]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips disabled bands", () => {
|
it("skips disabled bands", () => {
|
||||||
@@ -17,6 +25,115 @@ describe("maker points target builder", () => {
|
|||||||
band10To30: false,
|
band10To30: false,
|
||||||
band30To100: true,
|
band30To100: true,
|
||||||
});
|
});
|
||||||
expect(targets).toEqual([9, 99]);
|
expect(targets).toEqual([9, 40]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an explicit bps override the band default", () => {
|
||||||
|
const targets = buildBandTargets({
|
||||||
|
band0To10: true,
|
||||||
|
band10To30: true,
|
||||||
|
band30To100: true,
|
||||||
|
band0To10Bps: 5,
|
||||||
|
band30To100Bps: 60,
|
||||||
|
});
|
||||||
|
expect(targets).toEqual([
|
||||||
|
{ band: "0-10", bps: 5 },
|
||||||
|
{ band: "10-30", bps: 29 },
|
||||||
|
{ band: "30-100", bps: 60 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a configured bps at the zero-points cliff", () => {
|
||||||
|
const targets = buildBpsTargets({
|
||||||
|
band0To10: false,
|
||||||
|
band10To30: false,
|
||||||
|
band30To100: true,
|
||||||
|
band30To100Bps: 250,
|
||||||
|
});
|
||||||
|
expect(targets).toEqual([100]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("maker points multiplier curve", () => {
|
||||||
|
// 活动公布的样例点,用来锁住三段折线的系数
|
||||||
|
it.each([
|
||||||
|
[2, 0.88],
|
||||||
|
[5, 0.7],
|
||||||
|
[10, 0.4],
|
||||||
|
[20, 0.2625],
|
||||||
|
[50, 0.0893],
|
||||||
|
])("matches the published example at %i bps", (distance, expected) => {
|
||||||
|
expect(makerPointsMultiplier(distance)).toBeCloseTo(expected, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zero at and beyond the 100 bps cliff", () => {
|
||||||
|
expect(makerPointsMultiplier(100)).toBe(0);
|
||||||
|
expect(makerPointsMultiplier(101)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks 40 bps far above the old 99 bps edge quote", () => {
|
||||||
|
expect(makerPointsMultiplier(40)).toBeCloseTo(0.1071, 4);
|
||||||
|
expect(makerPointsMultiplier(99)).toBeCloseTo(0.0018, 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safe quote price", () => {
|
||||||
|
const base = { targetBps: 40, maxDistanceBps: 95 };
|
||||||
|
|
||||||
|
it("picks the lower of mark/book for a buy", () => {
|
||||||
|
// mark 低于 bid1 时以 mark 为基准更远离盘口
|
||||||
|
const price = resolveSafeQuotePrice({ ...base, side: "BUY", markPrice: 90_000, bookPrice: 90_020 });
|
||||||
|
expect(price).toBeCloseTo(90_000 * (1 - 0.004), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the higher of mark/book for a sell", () => {
|
||||||
|
const price = resolveSafeQuotePrice({ ...base, side: "SELL", markPrice: 90_050, bookPrice: 90_020 });
|
||||||
|
expect(price).toBeCloseTo(90_050 * (1 + 0.004), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the book when mark is unavailable", () => {
|
||||||
|
const price = resolveSafeQuotePrice({ ...base, side: "BUY", markPrice: null, bookPrice: 90_000 });
|
||||||
|
expect(price).toBeCloseTo(90_000 * (1 - 0.004), 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps a safer-but-worthless price back inside the cliff", () => {
|
||||||
|
// bid1 已经砸到 mark 下方,照盘口算出的买价会被推过 100 bps 变成零积分
|
||||||
|
const price = resolveSafeQuotePrice({
|
||||||
|
side: "BUY",
|
||||||
|
targetBps: 90,
|
||||||
|
maxDistanceBps: 95,
|
||||||
|
markPrice: 90_500,
|
||||||
|
bookPrice: 90_000,
|
||||||
|
});
|
||||||
|
expect(signedDistanceBps("BUY", price!, 90_500)).toBeCloseTo(95, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("band reprice tolerance", () => {
|
||||||
|
it("keeps the floor for near bands and scales up for far bands", () => {
|
||||||
|
expect(bandRepriceToleranceBps(9, 3, 0.15)).toBeCloseTo(3, 6);
|
||||||
|
expect(bandRepriceToleranceBps(40, 3, 0.15)).toBeCloseTo(6, 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sticky quote decision", () => {
|
||||||
|
const base = { side: "BUY" as const, anchor: 90_000, targetBps: 40, toleranceBps: 6, maxDistanceBps: 95 };
|
||||||
|
|
||||||
|
it("keeps a quote that drifted inside the tolerance", () => {
|
||||||
|
// 89_650 距 mark 38.9 bps,仍在 40±6 内
|
||||||
|
expect(shouldKeepQuote({ ...base, existingPrice: 89_650 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a quote that drifted outside the tolerance", () => {
|
||||||
|
// 89_500 距 mark 55.6 bps
|
||||||
|
expect(shouldKeepQuote({ ...base, existingPrice: 89_500 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a quote that crossed to the wrong side of mark", () => {
|
||||||
|
expect(shouldKeepQuote({ ...base, existingPrice: 90_100 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a quote that fell out of the scoring range", () => {
|
||||||
|
expect(shouldKeepQuote({ ...base, targetBps: 90, toleranceBps: 20, existingPrice: 89_100 })).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,168 @@
|
|||||||
|
export type MakerPointsBand = "0-10" | "10-30" | "30-100";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StandX 的 Maker Points 在距 mark price 100 bps 处倍率归零。
|
||||||
|
* 越过这条线的挂单不产生任何积分,只消耗保证金和下单配额。
|
||||||
|
*/
|
||||||
|
export const MAKER_POINTS_ZERO_BPS = 100;
|
||||||
|
|
||||||
|
/** 布尔开关全开时各档位的默认目标距离(bps)。 */
|
||||||
|
export const DEFAULT_BAND_BPS: Record<MakerPointsBand, number> = {
|
||||||
|
"0-10": 9,
|
||||||
|
"10-30": 29,
|
||||||
|
// 活动改为线性梯度后贴边(99 bps)倍率仅 0.18%,40 bps 仍有 10.7%
|
||||||
|
"30-100": 40,
|
||||||
|
};
|
||||||
|
|
||||||
export interface MakerPointsBandConfig {
|
export interface MakerPointsBandConfig {
|
||||||
band0To10: boolean;
|
band0To10: boolean;
|
||||||
band10To30: boolean;
|
band10To30: boolean;
|
||||||
band30To100: 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[] {
|
export function buildBpsTargets(config: MakerPointsBandConfig): number[] {
|
||||||
const targets: number[] = [];
|
return buildBandTargets(config).map((target) => target.bps);
|
||||||
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);
|
* Maker Points 的线性梯度倍率,三段折线:
|
||||||
|
* 0–10 bps: 100% → 40%
|
||||||
|
* 10–30 bps: 40% → 12.5%
|
||||||
|
* 30–100 bps: 12.5% → 0%
|
||||||
|
* 系数由活动公布的样例点(2/5/10/20/50 bps)反解得到。
|
||||||
|
*/
|
||||||
|
export function makerPointsMultiplier(distanceBps: number): number {
|
||||||
|
if (!Number.isFinite(distanceBps) || distanceBps < 0) return 0;
|
||||||
|
if (distanceBps >= MAKER_POINTS_ZERO_BPS) return 0;
|
||||||
|
if (distanceBps <= 10) return 1 - 0.06 * distanceBps;
|
||||||
|
if (distanceBps <= 30) return 0.4 - 0.01375 * (distanceBps - 10);
|
||||||
|
return (0.125 * (MAKER_POINTS_ZERO_BPS - distanceBps)) / 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挂单价相对参考价的带符号距离(bps)。
|
||||||
|
* 正数表示朝“更不容易成交”的方向偏离:BUY 在参考价下方,SELL 在参考价上方。
|
||||||
|
* 负数说明挂单已经穿过参考价,随时可能被吃。
|
||||||
|
*/
|
||||||
|
export function signedDistanceBps(side: "BUY" | "SELL", price: number, anchor: number): number {
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(anchor) || anchor <= 0) return Number.NaN;
|
||||||
|
const raw = side === "BUY" ? anchor - price : price - anchor;
|
||||||
|
return (raw / anchor) * 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeQuoteInput {
|
||||||
|
side: "BUY" | "SELL";
|
||||||
|
/** 目标距离(bps)。 */
|
||||||
|
targetBps: number;
|
||||||
|
/** 交易所 mark price;不可用时传 null。 */
|
||||||
|
markPrice: number | null;
|
||||||
|
/** 盘口一档:BUY 用 bid1,SELL 用 ask1。 */
|
||||||
|
bookPrice: number;
|
||||||
|
/** 距 mark 的最大允许距离(bps),超出即失去积分资格。 */
|
||||||
|
maxDistanceBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同时以 mark price 和盘口一档为基准算价,取对“不成交”更安全的一侧:
|
||||||
|
* BUY 取更低价、SELL 取更高价。
|
||||||
|
*
|
||||||
|
* 随后按 maxDistanceBps 夹回 —— 否则在 mark 远离盘口时,为了安全选出的价格
|
||||||
|
* 可能被推过 100 bps 悬崖,挂单虽然更安全却一分不得。
|
||||||
|
*/
|
||||||
|
export function resolveSafeQuotePrice(input: SafeQuoteInput): number | null {
|
||||||
|
const { side, targetBps, markPrice, bookPrice, maxDistanceBps } = input;
|
||||||
|
if (!Number.isFinite(bookPrice) || bookPrice <= 0) return null;
|
||||||
|
if (!Number.isFinite(targetBps) || targetBps < 0) return null;
|
||||||
|
|
||||||
|
const mark = Number.isFinite(markPrice ?? Number.NaN) && (markPrice ?? 0) > 0 ? (markPrice as number) : null;
|
||||||
|
const factor = side === "BUY" ? 1 - targetBps / 10000 : 1 + targetBps / 10000;
|
||||||
|
const fromBook = bookPrice * factor;
|
||||||
|
const candidate =
|
||||||
|
mark == null
|
||||||
|
? fromBook
|
||||||
|
: side === "BUY"
|
||||||
|
? Math.min(fromBook, mark * factor)
|
||||||
|
: Math.max(fromBook, mark * factor);
|
||||||
|
|
||||||
|
// 悬崖以 mark 为准;拿不到 mark 时只能用盘口近似
|
||||||
|
const anchor = mark ?? bookPrice;
|
||||||
|
const cap = Math.max(0, Math.min(maxDistanceBps, MAKER_POINTS_ZERO_BPS));
|
||||||
|
const limit = side === "BUY" ? anchor * (1 - cap / 10000) : anchor * (1 + cap / 10000);
|
||||||
|
const clamped = side === "BUY" ? Math.max(candidate, limit) : Math.min(candidate, limit);
|
||||||
|
|
||||||
|
return Number.isFinite(clamped) && clamped > 0 ? clamped : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 该档位允许的距离漂移(bps)。远档天然容忍更大的漂移,因为同样的盘口移动
|
||||||
|
* 对远档的倍率影响小得多,没必要跟着近档一起撤挂。
|
||||||
|
*/
|
||||||
|
export function bandRepriceToleranceBps(targetBps: number, minRepriceBps: number, ratio: number): number {
|
||||||
|
const floor = Number.isFinite(minRepriceBps) && minRepriceBps > 0 ? minRepriceBps : 0;
|
||||||
|
const scaled = Number.isFinite(ratio) && ratio > 0 ? targetBps * ratio : 0;
|
||||||
|
return Math.max(floor, scaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeepQuoteInput {
|
||||||
|
side: "BUY" | "SELL";
|
||||||
|
/** 当前已挂在盘口上的价格。 */
|
||||||
|
existingPrice: number;
|
||||||
|
/** 参考价:优先 mark price。 */
|
||||||
|
anchor: number;
|
||||||
|
targetBps: number;
|
||||||
|
toleranceBps: number;
|
||||||
|
maxDistanceBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断现有挂单是否还能原地不动。保持不动意味着这一轮不撤不挂,
|
||||||
|
* 订单得以在盘口连续停留,跨过 Maker Points 的 3 秒计分门槛。
|
||||||
|
*/
|
||||||
|
export function shouldKeepQuote(input: KeepQuoteInput): boolean {
|
||||||
|
const { side, existingPrice, anchor, targetBps, toleranceBps, maxDistanceBps } = input;
|
||||||
|
const distance = signedDistanceBps(side, existingPrice, anchor);
|
||||||
|
if (!Number.isFinite(distance)) return false;
|
||||||
|
// 已经穿到参考价另一侧,随时可能成交,必须立即重挂
|
||||||
|
if (distance <= 0) return false;
|
||||||
|
// 已经掉出积分范围,留着也不得分
|
||||||
|
if (distance >= Math.min(maxDistanceBps, MAKER_POINTS_ZERO_BPS)) return false;
|
||||||
|
return Math.abs(distance - targetBps) <= toleranceBps;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,20 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
|
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
|
||||||
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
|
(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) => ({
|
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||||
id: order.orderId,
|
id: order.orderId,
|
||||||
side: order.side,
|
side: order.side,
|
||||||
price: order.price,
|
price: order.price,
|
||||||
qty: order.origQty,
|
qty: order.origQty,
|
||||||
filled: order.executedQty,
|
filled: order.executedQty,
|
||||||
|
resting: formatResting(order.orderId),
|
||||||
reduceOnly: order.reduceOnly ? "yes" : "no",
|
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||||
status: order.status,
|
status: order.status,
|
||||||
}));
|
}));
|
||||||
@@ -58,6 +66,7 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
|
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
|
||||||
{ key: "filled", header: "Filled", 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: "reduceOnly", header: "RO", minWidth: 4 },
|
||||||
{ key: "status", header: "Status", minWidth: 10 },
|
{ key: "status", header: "Status", minWidth: 10 },
|
||||||
];
|
];
|
||||||
@@ -96,6 +105,9 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
: t("offset.imbalance.balanced");
|
: t("offset.imbalance.balanced");
|
||||||
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
|
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
|
||||||
const formatDepth = (value: number | null) => (value == null ? "-" : formatNumber(value, 4));
|
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 (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
@@ -110,6 +122,12 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
spread: spreadDisplay,
|
spread: spreadDisplay,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</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 color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{t("makerPoints.quoteLine", {
|
{t("makerPoints.quoteLine", {
|
||||||
@@ -130,9 +148,15 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
|
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
|
||||||
{t("makerPoints.bandDepthLine", {
|
{t("makerPoints.bandDepthLine", {
|
||||||
band: band.band,
|
band: band.band,
|
||||||
|
target: formatNumber(band.bps, 1),
|
||||||
|
buyDist: formatDistance(band.buyDistanceBps),
|
||||||
|
buyMult: formatMultiplier(band.buyMultiplier),
|
||||||
buy: formatDepth(band.buyDepth),
|
buy: formatDepth(band.buyDepth),
|
||||||
|
sellDist: formatDistance(band.sellDistanceBps),
|
||||||
|
sellMult: formatMultiplier(band.sellMultiplier),
|
||||||
sell: formatDepth(band.sellDepth),
|
sell: formatDepth(band.sellDepth),
|
||||||
})}
|
})}
|
||||||
|
{band.enabled ? "" : t("makerPoints.bandDisabled")}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
<Text>
|
<Text>
|
||||||
|
|||||||
@@ -72,7 +72,13 @@ describe("MakerPointsEngine Binance depth health defense", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: true,
|
enableBinanceDepthCancel: true,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
@@ -136,7 +142,13 @@ describe("MakerPointsEngine Binance depth health defense", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: true,
|
enableBinanceDepthCancel: true,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
+68
-1
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resolveSymbolFromEnv } from "../src/config";
|
import { resolveSymbolFromEnv } from "../src/config";
|
||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env };
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
@@ -72,3 +72,70 @@ describe("resolveSymbolFromEnv", () => {
|
|||||||
expect(resolveSymbolFromEnv("ondoperp")).toBe("ETH-USD.P");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: true,
|
enableBinanceDepthCancel: true,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
@@ -79,7 +85,13 @@ describe("MakerPointsEngine Binance depth monitor config", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: true,
|
enableBinanceDepthCancel: true,
|
||||||
binanceDepthWindowBps: 7,
|
binanceDepthWindowBps: 7,
|
||||||
binanceDepthImbalanceRatio: 11,
|
binanceDepthImbalanceRatio: 11,
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ describe("MakerPointsEngine defense-mode account staleness", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: false,
|
enableBinanceDepthCancel: false,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
@@ -107,7 +113,13 @@ describe("MakerPointsEngine defense-mode account staleness", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: false,
|
enableBinanceDepthCancel: false,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,7 +85,13 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: false,
|
enableBinanceDepthCancel: false,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
@@ -139,7 +145,13 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: false,
|
enableBinanceDepthCancel: false,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ describe("MakerPointsEngine immediate depth protection", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: false,
|
enableBinanceDepthCancel: false,
|
||||||
filterMinDepth: 10,
|
filterMinDepth: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("MakerPointsEngine immediate reprice", () => {
|
function buildEngine(adapter: StubAdapter, restingBuyPrice: string): MakerPointsEngine {
|
||||||
it("triggers an immediate tick when min reprice bps threshold is reached", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const adapter = new StubAdapter();
|
|
||||||
|
|
||||||
const engine = new MakerPointsEngine(
|
const engine = new MakerPointsEngine(
|
||||||
{
|
{
|
||||||
symbol: "BTC-USD",
|
symbol: "BTC-USD",
|
||||||
@@ -67,7 +63,13 @@ describe("MakerPointsEngine immediate reprice", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: false,
|
enableBinanceDepthCancel: false,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
@@ -79,8 +81,6 @@ describe("MakerPointsEngine immediate reprice", () => {
|
|||||||
(engine as any).defenseMode = false;
|
(engine as any).defenseMode = false;
|
||||||
(engine as any).reconnectResetPending = false;
|
(engine as any).reconnectResetPending = false;
|
||||||
(engine as any).stopLossProcessing = false;
|
(engine as any).stopLossProcessing = false;
|
||||||
(engine as any).lastQuoteBid1 = 100;
|
|
||||||
(engine as any).lastQuoteAsk1 = 101;
|
|
||||||
(engine as any).openOrders = [
|
(engine as any).openOrders = [
|
||||||
{
|
{
|
||||||
orderId: 1,
|
orderId: 1,
|
||||||
@@ -89,7 +89,7 @@ describe("MakerPointsEngine immediate reprice", () => {
|
|||||||
side: "BUY",
|
side: "BUY",
|
||||||
type: "LIMIT",
|
type: "LIMIT",
|
||||||
status: "NEW",
|
status: "NEW",
|
||||||
price: "99.0",
|
price: restingBuyPrice,
|
||||||
origQty: "0.01",
|
origQty: "0.01",
|
||||||
executedQty: "0",
|
executedQty: "0",
|
||||||
stopPrice: "0",
|
stopPrice: "0",
|
||||||
@@ -99,17 +99,57 @@ describe("MakerPointsEngine immediate reprice", () => {
|
|||||||
closePosition: false,
|
closePosition: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
|
// bid1 99.9 / ask1 100.9 → 中值 100.4;0-10 档目标 9 bps,容差 max(3, 9×0.15)=3
|
||||||
|
// 所以保留窗口是距中值 6–12 bps,即 100.28–100.34
|
||||||
adapter.emitDepth({
|
const DEPTH = {
|
||||||
lastUpdateId: 1,
|
lastUpdateId: 1,
|
||||||
bids: [["99.9", "1"]],
|
bids: [["99.9", "1"]] as Array<[string, string]>,
|
||||||
asks: [["100.9", "1"]],
|
asks: [["100.9", "1"]] as Array<[string, string]>,
|
||||||
eventTime: Date.now(),
|
eventTime: Date.now(),
|
||||||
symbol: "BTC-USD",
|
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);
|
expect(tickSpy).toHaveBeenCalledTimes(1);
|
||||||
engine.stop();
|
engine.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,13 @@ describe("MakerPointsEngine StandX isolated margin guard", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: false,
|
enableBinanceDepthCancel: false,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
@@ -146,7 +152,13 @@ describe("MakerPointsEngine StandX isolated margin guard", () => {
|
|||||||
band0To10Amount: 0.01,
|
band0To10Amount: 0.01,
|
||||||
band10To30Amount: 0.01,
|
band10To30Amount: 0.01,
|
||||||
band30To100Amount: 0.01,
|
band30To100Amount: 0.01,
|
||||||
|
band0To10Bps: 9,
|
||||||
|
band10To30Bps: 29,
|
||||||
|
band30To100Bps: 40,
|
||||||
|
maxDistanceBps: 95,
|
||||||
minRepriceBps: 3,
|
minRepriceBps: 3,
|
||||||
|
bandRepriceRatio: 0.15,
|
||||||
|
slOffsetBps: 2,
|
||||||
enableBinanceDepthCancel: false,
|
enableBinanceDepthCancel: false,
|
||||||
filterMinDepth: 0,
|
filterMinDepth: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user