mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 16:28:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8c49e1c9d | ||
|
|
ab47c82ebc |
+12
-45
@@ -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 for
|
LOSS_LIMIT=0.04 # Max loss per trade in USDT before forced close
|
||||||
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 unrealiz
|
PROFIT_LOCK_TRIGGER_USD=0.08 # Start moving base stop once unrealized PnL > this (USDT)
|
||||||
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.
|
PRICE_TICK=0.1 # Price tick size (e.g. BTCUSDT uses 0.1)
|
||||||
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,42 +71,9 @@ 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 ar
|
MAKER_POINTS_BINANCE_DEPTH_WINDOW_BPS=3 # Binance depth monitor window around best bid/ask (bps)
|
||||||
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
|
||||||
@@ -117,16 +84,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 boun
|
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside bounds
|
||||||
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| thr
|
GRID_SHIFT_TRIGGER_PCT=0.05 # Shift trigger: |price/anchor - 1| threshold (0.05 => 5%)
|
||||||
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 befo
|
GRID_SHIFT_CONFIRM_MS=3000 # Deviation must persist this long before shifting (anti-wick)
|
||||||
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 ueries
|
GRID_RECONCILE_INTERVAL_MS=30000 # Periodic REST reconcile cadence when the venue supports order queries
|
||||||
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)
|
||||||
@@ -148,10 +115,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_SYMBO
|
LIGHTER_SYMBOL=BTCUSDT # Trading pair (defaults to TRADE_SYMBOL when omitted)
|
||||||
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 neede
|
# LIGHTER_CHAIN_ID=300 # Override inferred chain id when needed
|
||||||
# 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)
|
||||||
@@ -224,4 +191,4 @@ NADO_MIN_SIZE_POLICY=adjust
|
|||||||
# Telegram notification configuration
|
# Telegram notification configuration
|
||||||
# TELEGRAM_BOT_TOKEN= # Telegram bot token from @BotFather
|
# TELEGRAM_BOT_TOKEN= # Telegram bot token from @BotFather
|
||||||
# TELEGRAM_CHAT_ID= # Chat ID to receive notifications
|
# TELEGRAM_CHAT_ID= # Chat ID to receive notifications
|
||||||
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
|
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
docs/
|
docs/
|
||||||
.claude/
|
.claude/
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|
||||||
# 密钥文件:package.json 的 files 白名单之外的第二道防线。
|
|
||||||
# 注意 .npmignore 一旦存在就会完全接管 .gitignore,.gitignore 里的规则不再生效。
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
*.pem
|
|
||||||
*.key
|
|
||||||
|
|||||||
@@ -10,8 +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=111909FA)
|
||||||
* [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)
|
||||||
* [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
|
* [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
|
||||||
|
|||||||
+1
-2
@@ -6,8 +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=111909FA)
|
||||||
* [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)
|
||||||
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
||||||
|
|||||||
@@ -21,8 +21,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.9",
|
"@types/bun": "^1.3.9",
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/ws": "^8.18.1",
|
|
||||||
"oxlint": "^1.54.0",
|
"oxlint": "^1.54.0",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
},
|
},
|
||||||
@@ -210,10 +208,6 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
|
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
|
||||||
|
|
||||||
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||||
|
|
||||||
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
|
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
|
||||||
@@ -266,8 +260,6 @@
|
|||||||
|
|
||||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
||||||
|
|
||||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||||
|
|||||||
@@ -8,30 +8,18 @@ This guide configures Lighter perpetuals and the integrated Spot markets. Lighte
|
|||||||
|
|
||||||
## 1. Select a network
|
## 1. Select a network
|
||||||
|
|
||||||
| `LIGHTER_ENV` | REST URL | WebSocket | Signing chain ID | Quote asset |
|
| `LIGHTER_ENV` | REST URL | Chain ID |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
|
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
|
||||||
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
|
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
|
||||||
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
|
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
|
||||||
| `rh-testnet` | `https://api.rh-testnet.lighter.xyz` | `wss://api.rh-testnet.lighter.xyz/stream` | `300` | USDG |
|
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
`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.
|
The current default is `testnet`. Set `LIGHTER_ENV=mainnet` explicitly for production trading.
|
||||||
|
|
||||||
**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 [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.
|
1. Create and fund an account on [Lighter](https://app.lighter.xyz/?referral=111909FA).
|
||||||
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`.
|
||||||
@@ -65,45 +53,22 @@ LIGHTER_SYMBOL=BTC
|
|||||||
|
|
||||||
Testnet and mainnet credentials cannot be mixed.
|
Testnet and mainnet credentials cannot be mixed.
|
||||||
|
|
||||||
## 5. Robinhood Chain configuration
|
## 5. Optional settings
|
||||||
|
|
||||||
```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 determine the network, and a web-app URL (e.g. `robinhoodchain.lighter.xyz`) is remapped to its API host |
|
| `LIGHTER_BASE_URL` | Overrides the REST URL; known hostnames also determine the network |
|
||||||
| `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; never reuse across venues |
|
| `LIGHTER_MARKET_ID` | Forces a market ID when metadata resolution fails |
|
||||||
| `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; required for a self-hosted or proxied host that cannot be recognized |
|
| `LIGHTER_CHAIN_ID` | Overrides the signing chain ID |
|
||||||
| `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` (main venue) or `ETH/USDG` (Robinhood Chain). Explicit market IDs and decimal overrides must match order-book metadata for the selected network.
|
Spot markets use symbols such as `ETH/USDC`. Explicit market IDs and decimal overrides must match order-book metadata for the selected network.
|
||||||
|
|
||||||
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.
|
## 6. Verify the configuration
|
||||||
|
|
||||||
## 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
|
||||||
@@ -117,10 +82,7 @@ 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. After switching venues the usual cause is a `LIGHTER_MARKET_ID` left over from the previous one.
|
- `Configured market id ... not found`: verify `LIGHTER_ENV`, `LIGHTER_SYMBOL`, and any manual market ID.
|
||||||
- `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
|
||||||
|
|||||||
+15
-53
@@ -8,30 +8,18 @@ English version: [Lighter Configuration Guide](lighter.en.md)
|
|||||||
|
|
||||||
## 1. 选择网络
|
## 1. 选择网络
|
||||||
|
|
||||||
| `LIGHTER_ENV` | REST 地址 | WebSocket | 签名 Chain ID | 计价资产 |
|
| `LIGHTER_ENV` | REST 地址 | Chain ID |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `wss://mainnet.zklighter.elliot.ai/stream` | `304` | USDC |
|
| `mainnet` | `https://mainnet.zklighter.elliot.ai` | `304` |
|
||||||
| `rh` | `https://api.rh.lighter.xyz` | `wss://api.rh.lighter.xyz/stream` | `466324` | USDG |
|
| `testnet` | `https://testnet.zklighter.elliot.ai` | `300` |
|
||||||
| `testnet` | `https://testnet.zklighter.elliot.ai` | `wss://testnet.zklighter.elliot.ai/stream` | `300` | USDC |
|
| `staging` | `https://staging.zklighter.elliot.ai` | `300` |
|
||||||
| `rh-testnet` | `https://api.rh-testnet.lighter.xyz` | `wss://api.rh-testnet.lighter.xyz/stream` | `300` | USDG |
|
| `dev` | `https://dev.zklighter.elliot.ai` | `300` |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
`rh` 是 Robinhood Chain 部署(网页端 `robinhoodchain.lighter.xyz`)。它与主站是两条独立的链:账户、API Key、market ID 和资金都不互通,签名 Chain ID 也不同。
|
当前默认值为 `testnet`。生产交易应显式设置 `LIGHTER_ENV=mainnet`。
|
||||||
|
|
||||||
**切换平台只需要改 `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. 创建并入金账户:[Robinhood Chain](https://robinhoodchain.lighter.xyz/?referral=RITMEX)(额外 10% 积分加成)或 [Lighter 主站](https://app.lighter.xyz/?referral=111909FA)。两个平台的账户互相独立。
|
1. 在 [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`。
|
||||||
@@ -65,45 +53,22 @@ LIGHTER_SYMBOL=BTC
|
|||||||
|
|
||||||
测试网和主网凭证不可混用。
|
测试网和主网凭证不可混用。
|
||||||
|
|
||||||
## 5. Robinhood Chain 配置
|
## 5. 可选配置
|
||||||
|
|
||||||
```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 地址;已知主机名会自动推断网络,填入网页端地址(如 `robinhoodchain.lighter.xyz`)会自动换成对应 API 地址 |
|
| `LIGHTER_BASE_URL` | 覆盖 REST 地址;网络可从已知主机名推断 |
|
||||||
| `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`(主站)或 `ETH/USDG`(Robinhood Chain)这类符号。显式 market ID、价格小数位和数量小数位必须与目标网络的 order book 元数据一致。
|
现货市场使用 `ETH/USDC` 这类符号。显式 market ID、价格小数位和数量小数位必须与目标网络的 order book 元数据一致。
|
||||||
|
|
||||||
自建节点或走代理时,若主机名无法识别为已知部署,则必须显式设置 `LIGHTER_CHAIN_ID` —— 签名 Chain ID 没有任何接口可以查询,猜错会导致每一笔交易验签失败,因此这里选择直接报错而不是使用默认值。
|
## 6. 验证配置
|
||||||
|
|
||||||
## 7. 验证配置
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run index.ts doctor --exchange lighter --symbol BTC --json
|
bun run index.ts doctor --exchange lighter --symbol BTC --json
|
||||||
@@ -117,10 +82,7 @@ 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。跨平台切换后最常见的原因是 `LIGHTER_MARKET_ID` 仍是上一个平台的编号。
|
- `Configured market id ... not found`:检查 `LIGHTER_ENV`、`LIGHTER_SYMBOL` 和手动 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,18 +140,6 @@ 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)
|
||||||
@@ -233,110 +221,12 @@ 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 的过期时间。配置后,策略会:
|
||||||
@@ -439,22 +329,6 @@ 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,30 +11,14 @@
|
|||||||
"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",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"lint:fix": "oxlint --fix",
|
"lint:fix": "oxlint --fix",
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"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",
|
||||||
@@ -45,8 +29,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.9",
|
"@types/bun": "^1.3.9",
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/ws": "^8.18.1",
|
|
||||||
"oxlint": "^1.54.0",
|
"oxlint": "^1.54.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/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} 个文件,未发现密钥文件。`);
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr";
|
||||||
|
import { bytesToHex } from "../src/exchanges/lighter/bytes";
|
||||||
|
|
||||||
|
function derive(keyHex: string): string {
|
||||||
|
const normalized = keyHex.startsWith("0x") ? keyHex.slice(2) : keyHex;
|
||||||
|
const key = LighterPrivateKey.fromHex(normalized);
|
||||||
|
return bytesToHex(key.publicKey().toBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = process.argv[2];
|
||||||
|
if (!input) {
|
||||||
|
console.error("usage: bun run scripts/derive-public.ts <hex>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(derive(input));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr";
|
||||||
|
import { bytesToHex } from "../src/exchanges/lighter/bytes";
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const raw = process.env.LIGHTER_API_PRIVATE_KEY;
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("LIGHTER_API_PRIVATE_KEY env var is required");
|
||||||
|
}
|
||||||
|
const normalized = raw.startsWith("0x") ? raw.slice(2) : raw;
|
||||||
|
const key = LighterPrivateKey.fromHex(normalized);
|
||||||
|
const publicKeyHex = bytesToHex(key.publicKey().toBytes());
|
||||||
|
const apiKeyIndex = process.env.LIGHTER_API_KEY_INDEX ?? "(not set)";
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
apiKeyIndex,
|
||||||
|
publicKeyHex,
|
||||||
|
}, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+24
-7
@@ -1,7 +1,6 @@
|
|||||||
import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "../exchanges/create-adapter";
|
import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "../exchanges/create-adapter";
|
||||||
import { STRATEGY_IDS, parseStrategyId, type StrategyId } from "../strategy/strategy-ids";
|
|
||||||
|
|
||||||
export type { StrategyId };
|
export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
|
|
||||||
export interface CliOptions {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
@@ -10,6 +9,18 @@ export interface CliOptions {
|
|||||||
exchange?: SupportedExchangeId;
|
exchange?: SupportedExchangeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STRATEGY_VALUES = new Set<StrategyId>([
|
||||||
|
"trend",
|
||||||
|
"swing",
|
||||||
|
"guardian",
|
||||||
|
"maker",
|
||||||
|
"maker-points",
|
||||||
|
"offset-maker",
|
||||||
|
"liquidity-maker",
|
||||||
|
"basis",
|
||||||
|
"grid",
|
||||||
|
]);
|
||||||
|
|
||||||
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
|
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
|
||||||
const options: CliOptions = { silent: false, help: false };
|
const options: CliOptions = { silent: false, help: false };
|
||||||
|
|
||||||
@@ -57,9 +68,16 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assignStrategy(options: CliOptions, raw: string): void {
|
function assignStrategy(options: CliOptions, raw: string): void {
|
||||||
const strategy = parseStrategyId(raw);
|
const normalized = raw.trim().toLowerCase();
|
||||||
if (strategy) {
|
if (!normalized) return;
|
||||||
options.strategy = strategy;
|
if (STRATEGY_VALUES.has(normalized as StrategyId)) {
|
||||||
|
options.strategy = normalized as StrategyId;
|
||||||
|
} else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") {
|
||||||
|
options.strategy = "offset-maker";
|
||||||
|
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
|
||||||
|
options.strategy = "maker-points";
|
||||||
|
} else if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity-maker" || normalized === "liquidity_maker") {
|
||||||
|
options.strategy = "liquidity-maker";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +95,8 @@ function assignExchange(options: CliOptions, raw: string): void {
|
|||||||
|
|
||||||
export function printCliHelp(): void {
|
export function printCliHelp(): void {
|
||||||
const exchangeList = SUPPORTED_EXCHANGE_IDS.join("|");
|
const exchangeList = SUPPORTED_EXCHANGE_IDS.join("|");
|
||||||
const strategyList = STRATEGY_IDS.join("|");
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Usage: bun run index.ts [--strategy <${strategyList}>] [--exchange <${exchangeList}>] [--silent]\n\n` +
|
console.log(`Usage: bun run index.ts [--strategy <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <${exchangeList}>] [--silent]\n\n` +
|
||||||
`Options:\n` +
|
`Options:\n` +
|
||||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||||
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
||||||
|
|||||||
+226
-54
@@ -1,58 +1,224 @@
|
|||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
import { DryRunExchangeAdapter } from "../exchanges/dry-run-adapter";
|
import { DryRunExchangeAdapter } from "../exchanges/dry-run-adapter";
|
||||||
import {
|
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
||||||
getStrategyDefinition,
|
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
||||||
strategyUnavailableReason,
|
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||||
STRATEGY_DEFINITIONS,
|
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
||||||
type StrategyEngine,
|
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||||
type StrategySnapshot,
|
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
|
||||||
} from "../strategy/registry";
|
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
||||||
|
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||||
|
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
|
||||||
import { extractMessage } from "../utils/errors";
|
import { extractMessage } from "../utils/errors";
|
||||||
import type { StrategyId } from "../strategy/strategy-ids";
|
import type { StrategyId } from "./args";
|
||||||
|
|
||||||
interface RunnerOptions {
|
interface RunnerOptions {
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STRATEGY_LABELS = Object.fromEntries(
|
type StrategyRunner = (options: RunnerOptions) => Promise<void>;
|
||||||
STRATEGY_DEFINITIONS.map((definition) => [definition.id, definition.consoleLabel])
|
|
||||||
) as Record<StrategyId, string>;
|
export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
||||||
|
trend: "Trend Following",
|
||||||
|
swing: "Swing",
|
||||||
|
guardian: "Guardian",
|
||||||
|
maker: "Maker",
|
||||||
|
"maker-points": "Maker Points",
|
||||||
|
"offset-maker": "Offset Maker",
|
||||||
|
"liquidity-maker": "Liquidity Maker",
|
||||||
|
basis: "Basis Arbitrage",
|
||||||
|
grid: "Grid",
|
||||||
|
};
|
||||||
|
|
||||||
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
|
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
|
||||||
const definition = getStrategyDefinition(strategyId);
|
const runner = STRATEGY_FACTORIES[strategyId];
|
||||||
if (!definition) {
|
if (!runner) {
|
||||||
throw new Error(`Unsupported strategy: ${strategyId}`);
|
throw new Error(`Unsupported strategy: ${strategyId}`);
|
||||||
}
|
}
|
||||||
const exchangeId = resolveExchangeId();
|
await runner(options);
|
||||||
const blocked = strategyUnavailableReason(strategyId, exchangeId);
|
|
||||||
if (blocked) {
|
|
||||||
throw new Error(blocked);
|
|
||||||
}
|
|
||||||
|
|
||||||
const adapter = createAdapterOrThrow(definition.symbol(), options.dryRun);
|
|
||||||
const engine = definition.createEngine(adapter);
|
|
||||||
await runEngine(engine, definition.consoleLabel, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
||||||
* Streams an engine's trade log to the console until SIGINT/SIGTERM, then stops it.
|
trend: async (opts) => {
|
||||||
* Depends only on the StrategyEngine contract, so a new strategy needs no change here.
|
const config = tradingConfig;
|
||||||
*/
|
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
||||||
async function runEngine(
|
const engine = new TrendEngine(config, adapter);
|
||||||
engine: StrategyEngine,
|
await runEngine({
|
||||||
label: string,
|
engine,
|
||||||
options: RunnerOptions
|
strategy: "trend",
|
||||||
): Promise<void> {
|
silent: opts.silent,
|
||||||
const exchangeName = getExchangeDisplayName(resolveExchangeId());
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
swing: async (opts) => {
|
||||||
|
const config = swingConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
||||||
|
const engine = new SwingEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "swing",
|
||||||
|
silent: opts.silent,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
guardian: async (opts) => {
|
||||||
|
const config = tradingConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
||||||
|
const engine = new GuardianEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "guardian",
|
||||||
|
silent: opts.silent,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
maker: async (opts) => {
|
||||||
|
const config = makerConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
||||||
|
const engine = new MakerEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "maker",
|
||||||
|
silent: opts.silent,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"maker-points": async (opts) => {
|
||||||
|
const exchangeId = resolveExchangeId();
|
||||||
|
if (exchangeId !== "standx") {
|
||||||
|
throw new Error("Maker Points strategy only supports the StandX exchange.");
|
||||||
|
}
|
||||||
|
const config = makerPointsConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
||||||
|
const engine = new MakerPointsEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "maker-points",
|
||||||
|
silent: opts.silent,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"offset-maker": async (opts) => {
|
||||||
|
const config = makerConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
||||||
|
const engine = new OffsetMakerEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "offset-maker",
|
||||||
|
silent: opts.silent,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"liquidity-maker": async (opts) => {
|
||||||
|
const config = liquidityMakerConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
||||||
|
const engine = new LiquidityMakerEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "liquidity-maker",
|
||||||
|
silent: opts.silent,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
basis: async (opts) => {
|
||||||
|
if (!isBasisStrategyEnabled()) {
|
||||||
|
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
||||||
|
}
|
||||||
|
const exchangeId = resolveExchangeId();
|
||||||
|
if (!isBasisSupportedExchangeId(exchangeId)) {
|
||||||
|
throw new Error("Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges");
|
||||||
|
}
|
||||||
|
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol, opts.dryRun);
|
||||||
|
const engine = new BasisArbEngine(basisConfig, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "basis",
|
||||||
|
silent: opts.silent,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
grid: async (opts) => {
|
||||||
|
const config = gridConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
||||||
|
const engine = new GridEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "grid",
|
||||||
|
silent: opts.silent,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const initial = engine.getSnapshot();
|
interface EngineHarness<TSnapshot> {
|
||||||
let lastLogKey = lastKeyOf(initial.tradeLog);
|
engine: { start(): void; stop(): void };
|
||||||
|
strategy: StrategyId;
|
||||||
|
silent?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
getSnapshot: () => TSnapshot;
|
||||||
|
onUpdate: (handler: (snapshot: TSnapshot) => void) => void;
|
||||||
|
offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runEngine<
|
||||||
|
TSnapshot extends
|
||||||
|
| TrendEngineSnapshot
|
||||||
|
| SwingEngineSnapshot
|
||||||
|
| GuardianEngineSnapshot
|
||||||
|
| MakerEngineSnapshot
|
||||||
|
| MakerPointsSnapshot
|
||||||
|
| OffsetMakerEngineSnapshot
|
||||||
|
| LiquidityMakerEngineSnapshot
|
||||||
|
| BasisArbSnapshot
|
||||||
|
| GridEngineSnapshot
|
||||||
|
>(
|
||||||
|
harness: EngineHarness<TSnapshot>
|
||||||
|
): Promise<void> {
|
||||||
|
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
|
||||||
|
const exchangeId = resolveExchangeId();
|
||||||
|
const exchangeName = getExchangeDisplayName(exchangeId);
|
||||||
|
const label = STRATEGY_LABELS[strategy];
|
||||||
|
|
||||||
|
const initial = getSnapshot();
|
||||||
|
let lastLogKey: string | undefined;
|
||||||
|
if (Array.isArray(initial.tradeLog) && initial.tradeLog.length > 0) {
|
||||||
|
const lastEntry = initial.tradeLog[initial.tradeLog.length - 1]!;
|
||||||
|
lastLogKey = createLogKey(lastEntry);
|
||||||
|
}
|
||||||
let readyLogged = initial.ready === true;
|
let readyLogged = initial.ready === true;
|
||||||
|
|
||||||
const emitter = (snapshot: StrategySnapshot) => {
|
const emitter = (snapshot: TSnapshot) => {
|
||||||
if (!Array.isArray(snapshot.tradeLog)) return;
|
if (!Array.isArray(snapshot.tradeLog)) return;
|
||||||
if (!readyLogged && snapshot.ready) {
|
if (!readyLogged && snapshot.ready) {
|
||||||
readyLogged = true;
|
readyLogged = true;
|
||||||
@@ -63,24 +229,31 @@ async function runEngine(
|
|||||||
for (const entry of pending) {
|
for (const entry of pending) {
|
||||||
console.info(`[${label}] [${entry.time}] [${entry.type}] ${entry.detail}`);
|
console.info(`[${label}] [${entry.time}] [${entry.type}] ${entry.detail}`);
|
||||||
}
|
}
|
||||||
lastLogKey = lastKeyOf(pending) ?? lastLogKey;
|
const lastEntry = pending[pending.length - 1]!;
|
||||||
|
if (lastEntry) {
|
||||||
|
lastLogKey = createLogKey(lastEntry);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
engine.on("update", emitter);
|
onUpdate(emitter);
|
||||||
engine.start();
|
engine.start();
|
||||||
|
|
||||||
const modeLabel = `${options.silent ? "silent" : "interactive"}${options.dryRun ? "+dry-run" : ""}`;
|
const modeLabel = `${silent ? "silent" : "interactive"}${harness.dryRun ? "+dry-run" : ""}`;
|
||||||
console.info(`[${label}] Starting on ${exchangeName}. Mode: ${modeLabel}. Press Ctrl+C to exit.`);
|
console.info(`[${label}] Starting on ${exchangeName}. Mode: ${modeLabel}. Press Ctrl+C to exit.`);
|
||||||
|
|
||||||
|
const shutdown = (signal: NodeJS.Signals) => {
|
||||||
|
try {
|
||||||
|
console.info(`[${label}] Received ${signal}. Shutting down…`);
|
||||||
|
engine.stop();
|
||||||
|
offUpdate(emitter);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const wrapper = (signal: NodeJS.Signals) => {
|
const wrapper = (signal: NodeJS.Signals) => {
|
||||||
try {
|
shutdown(signal);
|
||||||
console.info(`[${label}] Received ${signal}. Shutting down…`);
|
|
||||||
engine.stop();
|
|
||||||
engine.off("update", emitter);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`);
|
|
||||||
}
|
|
||||||
process.off("SIGINT", wrapper);
|
process.off("SIGINT", wrapper);
|
||||||
process.off("SIGTERM", wrapper);
|
process.off("SIGTERM", wrapper);
|
||||||
resolve();
|
resolve();
|
||||||
@@ -93,7 +266,10 @@ async function runEngine(
|
|||||||
|
|
||||||
function createAdapterOrThrow(symbol: string, dryRun?: boolean): ExchangeAdapter {
|
function createAdapterOrThrow(symbol: string, dryRun?: boolean): ExchangeAdapter {
|
||||||
const adapter = buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
|
const adapter = buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
|
||||||
return dryRun ? new DryRunExchangeAdapter(adapter) : adapter;
|
if (dryRun) {
|
||||||
|
return new DryRunExchangeAdapter(adapter);
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TradeLogEntry = { time: string; type: string; detail: string };
|
type TradeLogEntry = { time: string; type: string; detail: string };
|
||||||
@@ -102,17 +278,13 @@ function diffTradeLog(tradeLog: TradeLogEntry[], lastKey: string | undefined): T
|
|||||||
if (!tradeLog.length) return [];
|
if (!tradeLog.length) return [];
|
||||||
if (!lastKey) return tradeLog;
|
if (!lastKey) return tradeLog;
|
||||||
const lastIndex = tradeLog.findIndex((entry) => createLogKey(entry) === lastKey);
|
const lastIndex = tradeLog.findIndex((entry) => createLogKey(entry) === lastKey);
|
||||||
if (lastIndex === -1) return tradeLog;
|
if (lastIndex === -1) {
|
||||||
|
return tradeLog;
|
||||||
|
}
|
||||||
if (lastIndex === tradeLog.length - 1) return [];
|
if (lastIndex === tradeLog.length - 1) return [];
|
||||||
return tradeLog.slice(lastIndex + 1);
|
return tradeLog.slice(lastIndex + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastKeyOf(entries: TradeLogEntry[] | undefined): string | undefined {
|
|
||||||
if (!Array.isArray(entries) || entries.length === 0) return undefined;
|
|
||||||
const last = entries[entries.length - 1];
|
|
||||||
return last ? createLogKey(last) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLogKey(entry: TradeLogEntry): string {
|
function createLogKey(entry: TradeLogEntry): string {
|
||||||
return `${entry.time}|${entry.type}|${entry.detail}`;
|
return `${entry.time}|${entry.type}|${entry.detail}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-50
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -227,20 +226,7 @@ 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 */
|
||||||
@@ -253,33 +239,6 @@ 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,
|
||||||
@@ -293,19 +252,13 @@ 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: makerPointsBands.band0To10.enabled,
|
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
|
||||||
enableBand10To30: makerPointsBands.band10To30.enabled,
|
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
|
||||||
enableBand30To100: makerPointsBands.band30To100.enabled,
|
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
|
||||||
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),
|
||||||
|
|||||||
+152
-170
@@ -10,7 +10,6 @@ import {
|
|||||||
import { roundDownToTick, roundQtyDownToStep } from "../utils/math";
|
import { roundDownToTick, roundQtyDownToStep } from "../utils/math";
|
||||||
import { isUnknownOrderError } from "../utils/errors";
|
import { isUnknownOrderError } from "../utils/errors";
|
||||||
import { isOrderPriceAllowedByMark } from "../utils/strategy";
|
import { isOrderPriceAllowedByMark } from "../utils/strategy";
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
export type OrderLockMap = Record<string, boolean>;
|
export type OrderLockMap = Record<string, boolean>;
|
||||||
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
||||||
@@ -23,75 +22,12 @@ type OrderGuardOptions = {
|
|||||||
maxPct?: number;
|
maxPct?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything about *where* an order goes, fixed for an engine's lifetime.
|
|
||||||
* These six values always travelled together as the leading positional
|
|
||||||
* parameters of every order function; bundling them keeps call sites readable
|
|
||||||
* and makes an argument-order mistake impossible.
|
|
||||||
*/
|
|
||||||
export interface OrderContext {
|
|
||||||
adapter: ExchangeAdapter;
|
|
||||||
symbol: string;
|
|
||||||
locks: OrderLockMap;
|
|
||||||
timers: OrderTimerMap;
|
|
||||||
pendings: OrderPendingMap;
|
|
||||||
log: LogHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderRequestBase {
|
|
||||||
/** Live orders, used to cancel same-type duplicates before placing. */
|
|
||||||
openOrders: Order[];
|
|
||||||
side: "BUY" | "SELL";
|
|
||||||
/** Rejects the order when its price strays too far from the mark price. */
|
|
||||||
guard?: OrderGuardOptions;
|
|
||||||
qtyStep?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LimitOrderRequest extends OrderRequestBase {
|
|
||||||
/** String to preserve the exact tick the caller computed. */
|
|
||||||
price: string;
|
|
||||||
amount: number;
|
|
||||||
reduceOnly?: boolean;
|
|
||||||
skipDedupe?: boolean;
|
|
||||||
slPrice?: number;
|
|
||||||
tpPrice?: number;
|
|
||||||
clientOrderId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarketOrderRequest extends OrderRequestBase {
|
|
||||||
amount: number;
|
|
||||||
reduceOnly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StopLossOrderRequest extends OrderRequestBase {
|
|
||||||
stopPrice: number;
|
|
||||||
quantity: number;
|
|
||||||
/** Latest traded price; the stop is rejected when it is already through it. */
|
|
||||||
lastPrice: number | null;
|
|
||||||
priceTick?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrailingStopOrderRequest extends OrderRequestBase {
|
|
||||||
activationPrice: number;
|
|
||||||
quantity: number;
|
|
||||||
callbackRate: number;
|
|
||||||
priceTick?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarketCloseRequest extends OrderRequestBase {
|
|
||||||
quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Step assumed when the caller does not know the venue's own. */
|
|
||||||
const DEFAULT_QTY_STEP = 0.001;
|
|
||||||
const DEFAULT_PRICE_TICK = 0.1;
|
|
||||||
|
|
||||||
function enforceMarkPriceGuard(
|
function enforceMarkPriceGuard(
|
||||||
side: "BUY" | "SELL",
|
side: "BUY" | "SELL",
|
||||||
toCheckPrice: number | null | undefined,
|
toCheckPrice: number | null | undefined,
|
||||||
guard: OrderGuardOptions | undefined,
|
guard: OrderGuardOptions | undefined,
|
||||||
log: LogHandler,
|
log: LogHandler,
|
||||||
kind: string
|
context: string
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!guard || guard.maxPct == null) return true;
|
if (!guard || guard.maxPct == null) return true;
|
||||||
const allowed = isOrderPriceAllowedByMark({
|
const allowed = isOrderPriceAllowedByMark({
|
||||||
@@ -105,26 +41,13 @@ function enforceMarkPriceGuard(
|
|||||||
const markStr = Number.isFinite(Number(guard.markPrice)) ? Number(guard.markPrice).toFixed(2) : String(guard.markPrice);
|
const markStr = Number.isFinite(Number(guard.markPrice)) ? Number(guard.markPrice).toFixed(2) : String(guard.markPrice);
|
||||||
log(
|
log(
|
||||||
"info",
|
"info",
|
||||||
t("log.order.markGuardBlocked", {
|
`${context} 保护触发:side=${side} price=${priceStr} mark=${markStr} 超过 ${(guard.maxPct! * 100).toFixed(2)}%`
|
||||||
kind,
|
|
||||||
side,
|
|
||||||
price: priceStr,
|
|
||||||
mark: markStr,
|
|
||||||
pct: (guard.maxPct! * 100).toFixed(2),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rounds down to the venue's step, but never to zero — a sub-step size is kept as-is. */
|
|
||||||
function normalizeQuantity(amount: number, qtyStep: number): number {
|
|
||||||
const raw = Math.abs(amount);
|
|
||||||
const rounded = roundQtyDownToStep(raw, qtyStep);
|
|
||||||
return rounded > 0 ? rounded : raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isOperating(locks: OrderLockMap, type: string): boolean {
|
export function isOperating(locks: OrderLockMap, type: string): boolean {
|
||||||
return Boolean(locks[type]);
|
return Boolean(locks[type]);
|
||||||
}
|
}
|
||||||
@@ -144,7 +67,7 @@ export function lockOperating(
|
|||||||
timers[type] = setTimeout(() => {
|
timers[type] = setTimeout(() => {
|
||||||
locks[type] = false;
|
locks[type] = false;
|
||||||
pendings[type] = null;
|
pendings[type] = null;
|
||||||
log("info", t("log.order.lockTimeout", { type }));
|
log("info", `${type} 操作超时自动解锁`);
|
||||||
}, timeout);
|
}, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,12 +86,16 @@ export function unlockOperating(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deduplicateOrders(
|
export async function deduplicateOrders(
|
||||||
ctx: OrderContext,
|
adapter: ExchangeAdapter,
|
||||||
|
symbol: string,
|
||||||
openOrders: Order[],
|
openOrders: Order[],
|
||||||
|
locks: OrderLockMap,
|
||||||
|
timers: OrderTimerMap,
|
||||||
|
pendings: OrderPendingMap,
|
||||||
type: string,
|
type: string,
|
||||||
side: string
|
side: string,
|
||||||
|
log: LogHandler
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { adapter, symbol, locks, timers, pendings, log } = ctx;
|
|
||||||
// Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated.
|
// Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated.
|
||||||
const sameTypeOrders = openOrders.filter((o) => {
|
const sameTypeOrders = openOrders.filter((o) => {
|
||||||
const normalizedType = String(o.type).toUpperCase();
|
const normalizedType = String(o.type).toUpperCase();
|
||||||
@@ -189,68 +116,80 @@ export async function deduplicateOrders(
|
|||||||
try {
|
try {
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
await adapter.cancelOrders({ symbol, orderIdList });
|
await adapter.cancelOrders({ symbol, orderIdList });
|
||||||
log("order", t("log.order.dedupeCancelled", { type, ids: orderIdList.join(",") }));
|
log("order", `去重撤销重复 ${type} 单: ${orderIdList.join(",")}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", t("log.order.dedupeGone"));
|
log("order", "去重时发现订单已不存在,跳过删除");
|
||||||
} else {
|
} else {
|
||||||
log("error", t("log.order.dedupeFailed", { error: String(err) }));
|
log("error", `去重撤单失败: ${String(err)}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlaceOrderOptions = {
|
||||||
|
priceTick: number;
|
||||||
|
qtyStep: number;
|
||||||
|
skipDedupe?: boolean;
|
||||||
|
slPrice?: number;
|
||||||
|
tpPrice?: number;
|
||||||
|
clientOrderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function placeOrder(
|
export async function placeOrder(
|
||||||
ctx: OrderContext,
|
adapter: ExchangeAdapter,
|
||||||
request: LimitOrderRequest
|
symbol: string,
|
||||||
|
openOrders: Order[],
|
||||||
|
locks: OrderLockMap,
|
||||||
|
timers: OrderTimerMap,
|
||||||
|
pendings: OrderPendingMap,
|
||||||
|
side: "BUY" | "SELL",
|
||||||
|
price: string, // 改为字符串价格
|
||||||
|
amount: number,
|
||||||
|
log: LogHandler,
|
||||||
|
reduceOnly = false,
|
||||||
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: PlaceOrderOptions
|
||||||
): Promise<Order | undefined> {
|
): Promise<Order | undefined> {
|
||||||
const { locks, timers, pendings, log } = ctx;
|
|
||||||
const { side, openOrders, guard, reduceOnly = false } = request;
|
|
||||||
const type = "LIMIT";
|
const type = "LIMIT";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
const priceNum = Number(request.price);
|
const priceNum = Number(price);
|
||||||
if (!enforceMarkPriceGuard(side, priceNum, guard, log, t("order.kind.limit"))) return;
|
if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return;
|
||||||
const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP);
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
|
const rawQuantity = Math.abs(amount);
|
||||||
|
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
||||||
|
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.limit") }));
|
log("error", "限价单数量无效,跳过下单");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!request.skipDedupe) {
|
if (!opts?.skipDedupe) {
|
||||||
await deduplicateOrders(ctx, openOrders, type, side);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
}
|
}
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const closePosition = reduceOnly ? true : undefined;
|
const closePosition = reduceOnly ? true : undefined;
|
||||||
const order = await routeLimitOrder({
|
const order = await routeLimitOrder({
|
||||||
adapter: ctx.adapter,
|
adapter,
|
||||||
symbol: ctx.symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
quantity,
|
quantity,
|
||||||
price: priceNum,
|
price: priceNum,
|
||||||
timeInForce: reduceOnly ? "GTC" : "GTX",
|
timeInForce: reduceOnly ? "GTC" : "GTX",
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
closePosition,
|
closePosition,
|
||||||
slPrice: request.slPrice,
|
slPrice: opts?.slPrice,
|
||||||
tpPrice: request.tpPrice,
|
tpPrice: opts?.tpPrice,
|
||||||
clientOrderId: request.clientOrderId,
|
clientOrderId: opts?.clientOrderId,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log(
|
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`);
|
||||||
"order",
|
|
||||||
t("log.order.limitPlaced", {
|
|
||||||
side,
|
|
||||||
price: priceNum,
|
|
||||||
quantity,
|
|
||||||
reduceOnly,
|
|
||||||
sl: request.slPrice ? ` sl=${request.slPrice}` : "",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", t("log.order.limitGone"));
|
log("order", "订单已成交或被撤销,跳过新单");
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -258,38 +197,49 @@ export async function placeOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function placeMarketOrder(
|
export async function placeMarketOrder(
|
||||||
ctx: OrderContext,
|
adapter: ExchangeAdapter,
|
||||||
request: MarketOrderRequest
|
symbol: string,
|
||||||
|
openOrders: Order[],
|
||||||
|
locks: OrderLockMap,
|
||||||
|
timers: OrderTimerMap,
|
||||||
|
pendings: OrderPendingMap,
|
||||||
|
side: "BUY" | "SELL",
|
||||||
|
amount: number,
|
||||||
|
log: LogHandler,
|
||||||
|
reduceOnly = false,
|
||||||
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { qtyStep: number }
|
||||||
): Promise<Order | undefined> {
|
): Promise<Order | undefined> {
|
||||||
const { locks, timers, pendings, log } = ctx;
|
|
||||||
const { side, openOrders, guard, reduceOnly = false } = request;
|
|
||||||
const type = "MARKET";
|
const type = "MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, t("order.kind.market"))) return;
|
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
|
||||||
const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP);
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
|
const rawQuantity = Math.abs(amount);
|
||||||
|
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
||||||
|
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.market") }));
|
log("error", "市价单数量无效,跳过下单");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deduplicateOrders(ctx, openOrders, type, side);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const closePosition = reduceOnly ? true : undefined;
|
const closePosition = reduceOnly ? true : undefined;
|
||||||
const order = await routeMarketOrder({
|
const order = await routeMarketOrder({
|
||||||
adapter: ctx.adapter,
|
adapter,
|
||||||
symbol: ctx.symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
quantity,
|
quantity,
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
closePosition,
|
closePosition,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("order", t("log.order.marketPlaced", { side, quantity, reduceOnly }));
|
log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", t("log.order.marketGone"));
|
log("order", "市价单失败但订单已不存在,忽略");
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -297,38 +247,51 @@ export async function placeMarketOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function placeStopLossOrder(
|
export async function placeStopLossOrder(
|
||||||
ctx: OrderContext,
|
adapter: ExchangeAdapter,
|
||||||
request: StopLossOrderRequest
|
symbol: string,
|
||||||
|
openOrders: Order[],
|
||||||
|
locks: OrderLockMap,
|
||||||
|
timers: OrderTimerMap,
|
||||||
|
pendings: OrderPendingMap,
|
||||||
|
side: "BUY" | "SELL",
|
||||||
|
stopPrice: number,
|
||||||
|
quantity: number,
|
||||||
|
lastPrice: number | null,
|
||||||
|
log: LogHandler,
|
||||||
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { priceTick: number; qtyStep: number }
|
||||||
): Promise<Order | undefined> {
|
): Promise<Order | undefined> {
|
||||||
const { locks, timers, pendings, log } = ctx;
|
|
||||||
const { side, openOrders, guard, stopPrice, lastPrice } = request;
|
|
||||||
const type = "STOP_MARKET";
|
const type = "STOP_MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, stopPrice, guard, log, t("order.kind.stop"))) return;
|
if (!enforceMarkPriceGuard(side, stopPrice, guard, log, "止损单")) return;
|
||||||
if (lastPrice != null) {
|
if (lastPrice != null) {
|
||||||
if (side === "SELL" && stopPrice >= lastPrice) {
|
if (side === "SELL" && stopPrice >= lastPrice) {
|
||||||
log("error", t("log.order.stopAboveLast", { stopPrice, lastPrice }));
|
log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (side === "BUY" && stopPrice <= lastPrice) {
|
if (side === "BUY" && stopPrice <= lastPrice) {
|
||||||
log("error", t("log.order.stopBelowLast", { stopPrice, lastPrice }));
|
log("error", `止损价 ${stopPrice} 低于或等于当前价 ${lastPrice},取消挂单`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const normalizedStop = roundDownToTick(stopPrice, request.priceTick ?? DEFAULT_PRICE_TICK);
|
const priceTick = opts?.priceTick ?? 0.1;
|
||||||
const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP);
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
|
const normalizedStop = roundDownToTick(stopPrice, priceTick);
|
||||||
|
const rawQuantity = Math.abs(quantity);
|
||||||
|
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
||||||
|
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
||||||
if (normalizedQty <= 0) {
|
if (normalizedQty <= 0) {
|
||||||
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.stop") }));
|
log("error", "止损单数量无效,跳过下单");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways
|
// Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways
|
||||||
await deduplicateOrders(ctx, openOrders, type, side);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const order = await routeStopOrder({
|
const order = await routeStopOrder({
|
||||||
adapter: ctx.adapter,
|
adapter,
|
||||||
symbol: ctx.symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
quantity: normalizedQty,
|
quantity: normalizedQty,
|
||||||
stopPrice: normalizedStop,
|
stopPrice: normalizedStop,
|
||||||
@@ -338,12 +301,12 @@ export async function placeStopLossOrder(
|
|||||||
triggerType: "STOP_LOSS",
|
triggerType: "STOP_LOSS",
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("stop", t("log.order.stopPlaced", { side, stopPrice: normalizedStop }));
|
log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", t("log.order.stopGone"));
|
log("order", "止损单已失效,跳过");
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -351,30 +314,43 @@ export async function placeStopLossOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function placeTrailingStopOrder(
|
export async function placeTrailingStopOrder(
|
||||||
ctx: OrderContext,
|
adapter: ExchangeAdapter,
|
||||||
request: TrailingStopOrderRequest
|
symbol: string,
|
||||||
|
openOrders: Order[],
|
||||||
|
locks: OrderLockMap,
|
||||||
|
timers: OrderTimerMap,
|
||||||
|
pendings: OrderPendingMap,
|
||||||
|
side: "BUY" | "SELL",
|
||||||
|
activationPrice: number,
|
||||||
|
quantity: number,
|
||||||
|
callbackRate: number,
|
||||||
|
log: LogHandler,
|
||||||
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { priceTick: number; qtyStep: number }
|
||||||
): Promise<Order | undefined> {
|
): Promise<Order | undefined> {
|
||||||
const { adapter, locks, timers, pendings, log } = ctx;
|
|
||||||
const { side, openOrders, guard, activationPrice, callbackRate } = request;
|
|
||||||
const type = "TRAILING_STOP_MARKET";
|
const type = "TRAILING_STOP_MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!adapter.supportsTrailingStops()) {
|
if (!adapter.supportsTrailingStops()) {
|
||||||
log("error", t("log.order.trailingUnsupported"));
|
log("error", "当前交易所不支持动态止盈单");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, t("order.kind.trailing"))) return;
|
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
|
||||||
const normalizedActivation = roundDownToTick(activationPrice, request.priceTick ?? DEFAULT_PRICE_TICK);
|
const priceTick = opts?.priceTick ?? 0.1;
|
||||||
const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP);
|
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||||
|
const normalizedActivation = roundDownToTick(activationPrice, priceTick);
|
||||||
|
const rawQuantity = Math.abs(quantity);
|
||||||
|
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
||||||
|
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
||||||
if (normalizedQty <= 0) {
|
if (normalizedQty <= 0) {
|
||||||
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.trailing") }));
|
log("error", "动态止盈单数量无效,跳过下单");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deduplicateOrders(ctx, openOrders, type, side);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const order = await routeTrailingStopOrder({
|
const order = await routeTrailingStopOrder({
|
||||||
adapter,
|
adapter,
|
||||||
symbol: ctx.symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
quantity: normalizedQty,
|
quantity: normalizedQty,
|
||||||
activationPrice: normalizedActivation,
|
activationPrice: normalizedActivation,
|
||||||
@@ -385,62 +361,68 @@ export async function placeTrailingStopOrder(
|
|||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log(
|
log(
|
||||||
"order",
|
"order",
|
||||||
t("log.order.trailingPlaced", {
|
`挂动态止盈单: ${side} activation=${normalizedActivation} callbackRate=${callbackRate}`
|
||||||
side,
|
|
||||||
activation: normalizedActivation,
|
|
||||||
callbackRate,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", t("log.order.trailingGone"));
|
log("order", "动态止盈单已失效,跳过");
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function marketClose(ctx: OrderContext, request: MarketCloseRequest): Promise<void> {
|
export async function marketClose(
|
||||||
const { locks, timers, pendings, log } = ctx;
|
adapter: ExchangeAdapter,
|
||||||
const { side, openOrders, guard, qtyStep } = request;
|
symbol: string,
|
||||||
|
openOrders: Order[],
|
||||||
|
locks: OrderLockMap,
|
||||||
|
timers: OrderTimerMap,
|
||||||
|
pendings: OrderPendingMap,
|
||||||
|
side: "BUY" | "SELL",
|
||||||
|
quantity: number,
|
||||||
|
log: LogHandler,
|
||||||
|
guard?: OrderGuardOptions,
|
||||||
|
opts?: { qtyStep: number }
|
||||||
|
): Promise<void> {
|
||||||
const type = "MARKET";
|
const type = "MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, t("order.kind.close"))) return;
|
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
|
||||||
|
|
||||||
const rawQuantity = Math.abs(request.quantity);
|
const qtyStep = opts?.qtyStep;
|
||||||
let normalizedQty = qtyStep != null ? normalizeQuantity(rawQuantity, qtyStep) : rawQuantity;
|
const rawQuantity = Math.abs(quantity);
|
||||||
|
const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity;
|
||||||
|
let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity;
|
||||||
if (qtyStep != null) {
|
if (qtyStep != null) {
|
||||||
// A step-rounded close that is within rounding noise of the real position
|
|
||||||
// would leave dust behind; close the exact amount instead.
|
|
||||||
const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
|
const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
|
||||||
if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
|
if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
|
||||||
normalizedQty = rawQuantity;
|
normalizedQty = rawQuantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (normalizedQty <= 0) {
|
if (normalizedQty <= 0) {
|
||||||
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.close") }));
|
log("error", "市价平仓数量无效,跳过下单");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deduplicateOrders(ctx, openOrders, type, side);
|
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const order = await routeCloseOrder({
|
const order = await routeCloseOrder({
|
||||||
adapter: ctx.adapter,
|
adapter,
|
||||||
symbol: ctx.symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
quantity: normalizedQty,
|
quantity: normalizedQty,
|
||||||
reduceOnly: true,
|
reduceOnly: true,
|
||||||
closePosition: true,
|
closePosition: true,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("close", t("log.order.closePlaced", { side }));
|
log("close", `市价平仓: ${side}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", t("log.order.closeGone"));
|
log("order", "市场平仓时订单已不存在");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ export class AsterSpotRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url, init);
|
response = await fetch(url, init);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterSpotRestClient] request failed: ${String(error)}`);
|
throw new Error(`[AsterSpotRestClient] 请求失败 ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -546,7 +546,7 @@ export class AsterSpotRestClient {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text) as T;
|
return JSON.parse(text) as T;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterSpotRestClient] could not parse response: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterSpotRestClient] 无法解析响应: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -789,7 +789,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url);
|
response = await fetch(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterRestClient] failed to fetch exchange info: ${String(error)}`);
|
throw new Error(`[AsterRestClient] 获取交易规则失败 ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -798,7 +798,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text) as AsterFuturesExchangeInfo;
|
return JSON.parse(text) as AsterFuturesExchangeInfo;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterRestClient] could not parse exchange info: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterRestClient] 无法解析交易规则响应: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,7 +863,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url);
|
response = await fetch(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterRestClient] failed to fetch klines: ${String(error)}`);
|
throw new Error(`[AsterRestClient] 获取K线失败 ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -873,7 +873,7 @@ export class AsterRestClient {
|
|||||||
const payload = JSON.parse(text) as any[];
|
const payload = JSON.parse(text) as any[];
|
||||||
return payload.map((entry) => fromRestKline(entry, interval, upper));
|
return payload.map((entry) => fromRestKline(entry, interval, upper));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterRestClient] could not parse klines: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterRestClient] 无法解析K线响应: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,7 +892,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url);
|
response = await fetch(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterRestClient] failed to fetch funding rate: ${String(error)}`);
|
throw new Error(`[AsterRestClient] 获取资金费率失败 ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -903,7 +903,7 @@ export class AsterRestClient {
|
|||||||
// The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time }
|
// The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time }
|
||||||
return payload;
|
return payload;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterRestClient] could not parse funding rate: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterRestClient] 无法解析资金费率响应: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,7 +937,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url, init);
|
response = await fetch(url, init);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterRestClient] request failed: ${String(error)}`);
|
throw new Error(`[AsterRestClient] 请求失败 ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -946,7 +946,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text) as T;
|
return JSON.parse(text) as T;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterRestClient] could not parse response: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterRestClient] 无法解析响应: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,7 +1037,7 @@ export class AsterPublicStreams {
|
|||||||
try {
|
try {
|
||||||
payload = JSON.parse(event.data);
|
payload = JSON.parse(event.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterPublicStreams] could not parse message", error, event.data);
|
console.error("[AsterPublicStreams] 无法解析消息", error, event.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1198,7 +1198,7 @@ export class AsterUserStream {
|
|||||||
try {
|
try {
|
||||||
payload = JSON.parse(event.data);
|
payload = JSON.parse(event.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterUserStream] could not parse message", error, event.data);
|
console.error("[AsterUserStream] 无法解析消息", error, event.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1509,7 +1509,7 @@ export class AsterGateway {
|
|||||||
positions = latestPositions;
|
positions = latestPositions;
|
||||||
}
|
}
|
||||||
} catch (positionError) {
|
} catch (positionError) {
|
||||||
console.error("[AsterGateway] failed to refresh positions", positionError);
|
console.error("[AsterGateway] 刷新持仓失败", positionError);
|
||||||
}
|
}
|
||||||
const normalizedPositions = clonePositions(positions);
|
const normalizedPositions = clonePositions(positions);
|
||||||
const snapshot: AccountSnapshot = {
|
const snapshot: AccountSnapshot = {
|
||||||
@@ -1521,7 +1521,7 @@ export class AsterGateway {
|
|||||||
this.accountSnapshot = snapshot;
|
this.accountSnapshot = snapshot;
|
||||||
this.accountEvent.emit(snapshot);
|
this.accountEvent.emit(snapshot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] failed to refresh account", error);
|
console.error("[AsterGateway] 刷新账户信息失败", error);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const orders = await this.rest.getOpenOrders();
|
const orders = await this.rest.getOpenOrders();
|
||||||
@@ -1529,7 +1529,7 @@ export class AsterGateway {
|
|||||||
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
|
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
|
||||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] failed to refresh open orders", error);
|
console.error("[AsterGateway] 刷新挂单失败", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1573,7 +1573,7 @@ export class AsterGateway {
|
|||||||
this.accountSnapshot = nextSnapshot;
|
this.accountSnapshot = nextSnapshot;
|
||||||
this.accountEvent.emit(nextSnapshot);
|
this.accountEvent.emit(nextSnapshot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] failed to sync positions", error);
|
console.error("[AsterGateway] 同步持仓失败", error);
|
||||||
} finally {
|
} finally {
|
||||||
this.positionSyncInFlight = false;
|
this.positionSyncInFlight = false;
|
||||||
}
|
}
|
||||||
@@ -1608,7 +1608,7 @@ export class AsterGateway {
|
|||||||
try {
|
try {
|
||||||
exchangeInfo = await this.loadExchangeInfo();
|
exchangeInfo = await this.loadExchangeInfo();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] failed to fetch exchange info", error);
|
console.error("[AsterGateway] 获取交易规则失败", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const symbols = exchangeInfo?.symbols ?? [];
|
const symbols = exchangeInfo?.symbols ?? [];
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import type {
|
|||||||
TickerListener,
|
TickerListener,
|
||||||
KlineListener,
|
KlineListener,
|
||||||
} from "../adapter";
|
} from "../adapter";
|
||||||
import { ReconnectScheduler, fixedBackoff } from "../reconnect-scheduler";
|
|
||||||
|
|
||||||
const WebSocketCtor: typeof globalThis.WebSocket =
|
const WebSocketCtor: typeof globalThis.WebSocket =
|
||||||
typeof globalThis.WebSocket !== "undefined"
|
typeof globalThis.WebSocket !== "undefined"
|
||||||
@@ -100,10 +99,7 @@ export class BackpackGateway {
|
|||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private wsReady = false;
|
private wsReady = false;
|
||||||
private wsPingTimer: ReturnType<typeof setInterval> | null = null;
|
private wsPingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private readonly wsReconnect = new ReconnectScheduler({
|
private wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
connect: () => this.ensurePrivateSocket(),
|
|
||||||
backoff: fixedBackoff(WS_RECONNECT_DELAY),
|
|
||||||
});
|
|
||||||
private readonly wsTopics = new Set<string>();
|
private readonly wsTopics = new Set<string>();
|
||||||
private wsConnecting = false;
|
private wsConnecting = false;
|
||||||
private readonly wsWindow: string;
|
private readonly wsWindow: string;
|
||||||
@@ -786,7 +782,7 @@ export class BackpackGateway {
|
|||||||
this.wsReady = false;
|
this.wsReady = false;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.stopPing();
|
this.stopPing();
|
||||||
this.wsReconnect.schedule();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleWsError = (_event: any): void => {
|
private handleWsError = (_event: any): void => {
|
||||||
@@ -949,6 +945,14 @@ export class BackpackGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.wsReconnectTimer) return;
|
||||||
|
this.wsReconnectTimer = setTimeout(() => {
|
||||||
|
this.wsReconnectTimer = null;
|
||||||
|
this.ensurePrivateSocket();
|
||||||
|
}, WS_RECONNECT_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
private detachWebSocket(): void {
|
private detachWebSocket(): void {
|
||||||
if (this.wsCleanup) {
|
if (this.wsCleanup) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ function loadSignatureProviderFromEnv(
|
|||||||
return loaded.default as GrvtSignatureProvider;
|
return loaded.default as GrvtSignatureProvider;
|
||||||
}
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`[GrvtExchangeAdapter] module ${resolved} does not export a signing function (function default export)`
|
`[GrvtExchangeAdapter] 模块 ${resolved} 未导出签名函数 (function default export)`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const log = logger ?? ((ctx, err) => console.error(`[GrvtExchangeAdapter] ${ctx}`, err));
|
const log = logger ?? ((ctx, err) => console.error(`[GrvtExchangeAdapter] ${ctx}`, err));
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ 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;
|
||||||
@@ -61,8 +60,7 @@ 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: environment as LighterGatewayOptions["environment"],
|
||||||
wsUrl: credentials.wsUrl ?? process.env.LIGHTER_WS_URL,
|
|
||||||
marketId,
|
marketId,
|
||||||
priceDecimals,
|
priceDecimals,
|
||||||
sizeDecimals,
|
sizeDecimals,
|
||||||
|
|||||||
@@ -1,114 +1,36 @@
|
|||||||
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev" | "rh" | "rh-testnet";
|
export type LighterEnvironment = "mainnet" | "testnet" | "staging" | "dev";
|
||||||
|
|
||||||
export interface LighterHostConfig {
|
export interface LighterHostConfig {
|
||||||
rest: string;
|
rest: string;
|
||||||
ws: string;
|
ws: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LighterNetworkConfig extends LighterHostConfig {
|
export const LIGHTER_HOSTS: Record<LighterEnvironment, 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",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Spellings users actually type, mapped onto canonical environment names. */
|
export const LIGHTER_CHAIN_IDS: Record<LighterEnvironment, number> = {
|
||||||
export const LIGHTER_ENVIRONMENT_ALIASES: Record<string, LighterEnvironment> = {
|
mainnet: 304,
|
||||||
robinhood: "rh",
|
testnet: 300,
|
||||||
robinhoodchain: "rh",
|
staging: 300,
|
||||||
"robinhood-chain": "rh",
|
dev: 300,
|
||||||
"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
|
||||||
|
|||||||
+143
-257
@@ -1,6 +1,5 @@
|
|||||||
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
|
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { ReconnectScheduler, linearBackoff } from "../reconnect-scheduler";
|
|
||||||
import type {
|
import type {
|
||||||
AccountListener,
|
AccountListener,
|
||||||
DepthListener,
|
DepthListener,
|
||||||
@@ -34,12 +33,13 @@ 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";
|
||||||
@@ -76,41 +76,55 @@ 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>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Spot balance of a single asset. `effective` is what every balance guard compares
|
|
||||||
* against: an unknown or unparseable asset collapses to 0 so guards fail closed
|
|
||||||
* instead of waving an order through.
|
|
||||||
*/
|
|
||||||
interface SpotAssetBalance {
|
|
||||||
available: number | null;
|
|
||||||
wallet: number | null;
|
|
||||||
effective: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSpotAssetBalance(available: number | null, wallet: number | null): SpotAssetBalance {
|
|
||||||
return {
|
|
||||||
available,
|
|
||||||
wallet,
|
|
||||||
effective: Math.max(
|
|
||||||
available != null && Number.isFinite(available) ? available : 0,
|
|
||||||
wallet != null && Number.isFinite(wallet) ? wallet : 0
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tolerance that absorbs float drift when comparing a balance against an order size. */
|
|
||||||
const BALANCE_EPSILON = 1e-9;
|
|
||||||
|
|
||||||
const KLINE_DEFAULT_COUNT = 120;
|
const KLINE_DEFAULT_COUNT = 120;
|
||||||
const DEFAULT_TICKER_POLL_MS = 3000;
|
const DEFAULT_TICKER_POLL_MS = 3000;
|
||||||
const DEFAULT_KLINE_POLL_MS = 15000;
|
const DEFAULT_KLINE_POLL_MS = 15000;
|
||||||
const WS_RECONNECT_BASE_MS = 2_000;
|
|
||||||
const WS_RECONNECT_MAX_MS = 30_000;
|
|
||||||
const WS_HEARTBEAT_INTERVAL_MS = 5_000;
|
const WS_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||||
const CLIENT_PING_INTERVAL_MS = 2_000;
|
const CLIENT_PING_INTERVAL_MS = 2_000;
|
||||||
const WS_STALE_TIMEOUT_MS = 20_000;
|
const WS_STALE_TIMEOUT_MS = 20_000;
|
||||||
@@ -139,34 +153,8 @@ const TERMINAL_ORDER_STATUSES = new Set([
|
|||||||
"canceled-reduce-only",
|
"canceled-reduce-only",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface SpotMarketPreset {
|
const KNOWN_SPOT_MARKETS: Record<string, { marketId: number; base: string; quote: string; priceDecimals?: number; sizeDecimals?: number }> = {
|
||||||
marketId: number;
|
ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 },
|
||||||
base: string;
|
|
||||||
quote: string;
|
|
||||||
priceDecimals?: number;
|
|
||||||
sizeDecimals?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Market ids are per-deployment, so presets are keyed by environment first — reusing a mainnet
|
|
||||||
* id on Robinhood Chain would silently trade a different instrument.
|
|
||||||
*/
|
|
||||||
const KNOWN_SPOT_MARKETS: Partial<Record<LighterEnvironment, Record<string, SpotMarketPreset>>> = {
|
|
||||||
mainnet: {
|
|
||||||
ETHUSDC: { marketId: 2048, base: "ETH", quote: "USDC", priceDecimals: 2, sizeDecimals: 4 },
|
|
||||||
},
|
|
||||||
rh: {
|
|
||||||
ETHUSDG: { marketId: 2048, base: "ETH", quote: "USDG", priceDecimals: 2, sizeDecimals: 4 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which deployment lists a given spot symbol. Used only to pick an environment when the user
|
|
||||||
* supplied neither LIGHTER_ENV nor LIGHTER_BASE_URL, since the default is testnet.
|
|
||||||
*/
|
|
||||||
const SPOT_PRESET_ENVIRONMENTS: Record<string, LighterEnvironment> = {
|
|
||||||
ETHUSDC: "mainnet",
|
|
||||||
ETHUSDG: "rh",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface LighterGatewayOptions {
|
export interface LighterGatewayOptions {
|
||||||
@@ -175,9 +163,7 @@ export interface LighterGatewayOptions {
|
|||||||
accountIndex: number;
|
accountIndex: number;
|
||||||
apiKeys: Record<number, string>;
|
apiKeys: Record<number, string>;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
/** Canonical name or alias; see LIGHTER_ENVIRONMENT_ALIASES. */
|
environment?: keyof typeof LIGHTER_HOSTS;
|
||||||
environment?: string;
|
|
||||||
wsUrl?: string;
|
|
||||||
marketId?: number;
|
marketId?: number;
|
||||||
priceDecimals?: number;
|
priceDecimals?: number;
|
||||||
sizeDecimals?: number;
|
sizeDecimals?: number;
|
||||||
@@ -197,9 +183,7 @@ 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 network: LighterNetworkResolution;
|
private readonly environment: keyof typeof LIGHTER_HOSTS;
|
||||||
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;
|
||||||
@@ -224,8 +208,6 @@ 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;
|
||||||
@@ -239,14 +221,8 @@ export class LighterGateway {
|
|||||||
private readonly orderIndexByClientId = new Map<string, string>();
|
private readonly orderIndexByClientId = new Map<string, string>();
|
||||||
|
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private readonly reconnect: ReconnectScheduler = new ReconnectScheduler({
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
connect: async () => {
|
private reconnectAttempts = 0;
|
||||||
await this.openWebSocket();
|
|
||||||
this.reconnect.onConnected();
|
|
||||||
},
|
|
||||||
backoff: linearBackoff(WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS),
|
|
||||||
onError: (error) => this.logger("reconnect", error),
|
|
||||||
});
|
|
||||||
private readonly wsUrl: string;
|
private readonly wsUrl: string;
|
||||||
private connectPromise: Promise<void> | null = null;
|
private connectPromise: Promise<void> | null = null;
|
||||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -285,43 +261,40 @@ 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();
|
||||||
// Explicit overrides are applied before presets so a preset can only fill a gap, never
|
if (process.env.LIGHTER_MARKET_ID) {
|
||||||
// overwrite what the operator asked for.
|
this.marketId = Number(process.env.LIGHTER_MARKET_ID);
|
||||||
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 =
|
||||||
const baseUrl = options.baseUrl ?? process.env.LIGHTER_BASE_URL ?? undefined;
|
options.environment ??
|
||||||
// A spot-only symbol implies its venue, but only when nothing more explicit was given —
|
process.env.LIGHTER_ENV ??
|
||||||
// otherwise the default (testnet) would be picked for a market that does not exist there.
|
(this.forcedSpotPreset && !options.baseUrl ? "mainnet" : undefined);
|
||||||
const presetEnvHint = SPOT_PRESET_ENVIRONMENTS[normalizeSymbolKey(this.marketSymbol)];
|
this.environment = inferEnvironment(envPreference, options.baseUrl);
|
||||||
this.network = resolveLighterNetwork({
|
const host = options.baseUrl ?? LIGHTER_HOSTS[this.environment]?.rest;
|
||||||
environment: options.environment ?? process.env.LIGHTER_ENV ?? (baseUrl ? undefined : presetEnvHint),
|
if (!host) {
|
||||||
baseUrl,
|
throw new Error(`Unknown Lighter environment ${this.environment}`);
|
||||||
wsUrl: options.wsUrl ?? process.env.LIGHTER_WS_URL,
|
}
|
||||||
chainId: options.chainId,
|
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
|
||||||
});
|
// eslint-disable-next-line no-console
|
||||||
this.environment = this.network.environment;
|
console.error(
|
||||||
|
"[LighterGateway] init",
|
||||||
// Market ids are per-deployment, so presets can only be applied once the venue is known.
|
JSON.stringify({ env: this.environment, host, marketId: this.marketId, marketType: this.marketType })
|
||||||
this.applyPresetMarket();
|
);
|
||||||
|
}
|
||||||
this.wsUrl = this.network.wsUrl;
|
const wsHost = LIGHTER_HOSTS[this.environment]?.ws;
|
||||||
this.http = new LighterHttpClient({ baseUrl: this.network.restUrl });
|
if (!wsHost) {
|
||||||
|
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: this.network.chainId,
|
chainId: options.chainId ?? (this.environment === "mainnet" ? 304 : 300),
|
||||||
apiKeys: options.apiKeys,
|
apiKeys: options.apiKeys,
|
||||||
baseUrl: this.network.restUrl,
|
baseUrl: host,
|
||||||
});
|
});
|
||||||
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) {
|
||||||
@@ -340,6 +313,9 @@ 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;
|
||||||
@@ -349,16 +325,6 @@ 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,64 +486,16 @@ 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 && this.environment) {
|
if (!this.marketId && wantsSpot) {
|
||||||
const preset = KNOWN_SPOT_MARKETS[this.environment]?.[normalizeSymbolKey(desiredSymbol)];
|
const normalized = desiredSymbol.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
|
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;
|
||||||
@@ -598,11 +516,8 @@ 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 on ${this.environment ?? this.network.restUrl}. ` +
|
`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).`
|
||||||
`Market ids differ per deployment — clear LIGHTER_MARKET_ID or set one listed by this venue.`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,9 +540,7 @@ 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;
|
||||||
@@ -649,31 +562,6 @@ 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();
|
||||||
@@ -846,7 +734,7 @@ export class LighterGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.stopStaleMonitor();
|
this.stopStaleMonitor();
|
||||||
this.reconnect.schedule();
|
this.scheduleReconnect();
|
||||||
});
|
});
|
||||||
ws.on("error", (error) => {
|
ws.on("error", (error) => {
|
||||||
this.logger("ws:error", error);
|
this.logger("ws:error", error);
|
||||||
@@ -856,7 +744,7 @@ export class LighterGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.stopStaleMonitor();
|
this.stopStaleMonitor();
|
||||||
this.reconnect.schedule();
|
this.scheduleReconnect();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1005,7 +893,8 @@ export class LighterGateway {
|
|||||||
desiredSymbol.includes("/") ||
|
desiredSymbol.includes("/") ||
|
||||||
desiredSymbol.includes("-") ||
|
desiredSymbol.includes("-") ||
|
||||||
desiredSymbol.includes(":") ||
|
desiredSymbol.includes(":") ||
|
||||||
SPOT_QUOTE_SUFFIXES.some((suffix) => normalizedDesired.includes(suffix));
|
normalizedDesired.includes("USDC") ||
|
||||||
|
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
|
||||||
);
|
);
|
||||||
@@ -1028,7 +917,25 @@ export class LighterGateway {
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return candidates[0] ?? null;
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimer) return;
|
||||||
|
const attempt = this.reconnectAttempts + 1;
|
||||||
|
const delay = Math.min(2000 * attempt, 30_000);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.openWebSocket()
|
||||||
|
.then(() => {
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.logger("reconnect", error);
|
||||||
|
this.reconnectAttempts = attempt;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
});
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private forceReconnect(reason: string): void {
|
private forceReconnect(reason: string): void {
|
||||||
@@ -1046,7 +953,7 @@ export class LighterGateway {
|
|||||||
}
|
}
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.stopClientPing();
|
this.stopClientPing();
|
||||||
this.reconnect.schedule();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
@@ -1063,7 +970,7 @@ export class LighterGateway {
|
|||||||
} finally {
|
} finally {
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.stopClientPing();
|
this.stopClientPing();
|
||||||
this.reconnect.schedule();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1548,8 +1455,7 @@ 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;
|
||||||
// Subscriptions use `account_market/{market}/{account}`; echoes may come back colon-separated.
|
const match = channel.match(/account_market:(\d+)/);
|
||||||
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;
|
||||||
@@ -1591,9 +1497,7 @@ 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,
|
||||||
// Falls back to the venue's settlement asset (USDG on rh, USDC elsewhere) so the
|
quoteAssetSymbol: this.quoteAssetSymbol,
|
||||||
// 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,
|
||||||
}
|
}
|
||||||
@@ -1741,17 +1645,10 @@ 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;
|
||||||
// Neither mainnet nor rh returns market_id in this payload today, so matching falls back
|
const match = stats.find(
|
||||||
// to the exact venue symbol. Compared delimiter-free (`ETH/USDG` vs `ETHUSDG`) but never
|
(entry) => Number(entry.market_id) === marketId || (entry.symbol ? entry.symbol.toUpperCase() : "") === this.marketSymbol
|
||||||
// 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;
|
||||||
@@ -1859,9 +1756,8 @@ export class LighterGateway {
|
|||||||
(matchSymbol ?? asset.symbol ?? (asset.asset_id != null ? String(asset.asset_id) : "ASSET")).toUpperCase();
|
(matchSymbol ?? asset.symbol ?? (asset.asset_id != null ? String(asset.asset_id) : "ASSET")).toUpperCase();
|
||||||
list.push({
|
list.push({
|
||||||
asset: assetSymbol,
|
asset: assetSymbol,
|
||||||
walletBalance: String(asset.balance ?? "0"),
|
walletBalance: asset.balance ?? "0",
|
||||||
availableBalance:
|
availableBalance: available != null && Number.isFinite(available) ? available.toString() : asset.balance ?? "0",
|
||||||
available != null && Number.isFinite(available) ? available.toString() : String(asset.balance ?? "0"),
|
|
||||||
updateTime: now,
|
updateTime: now,
|
||||||
assetId: Number.isFinite(assetId) ? assetId : undefined,
|
assetId: Number.isFinite(assetId) ? assetId : undefined,
|
||||||
});
|
});
|
||||||
@@ -1870,9 +1766,8 @@ export class LighterGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyPresetMarket(): void {
|
private applyPresetMarket(): void {
|
||||||
if (!this.environment) return;
|
const normalized = (this.marketSymbol ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||||
const normalized = normalizeSymbolKey(this.marketSymbol);
|
const preset = KNOWN_SPOT_MARKETS[normalized];
|
||||||
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;
|
||||||
@@ -1901,7 +1796,7 @@ export class LighterGateway {
|
|||||||
return (hasBase && hasQuote) || idMatch;
|
return (hasBase && hasQuote) || idMatch;
|
||||||
});
|
});
|
||||||
if (!matches.length) return null;
|
if (!matches.length) return null;
|
||||||
return matches[0] ?? null;
|
return matches[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrecision(): Promise<{
|
async getPrecision(): Promise<{
|
||||||
@@ -1951,7 +1846,8 @@ export class LighterGateway {
|
|||||||
return qty;
|
return qty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSpotAssetBalance(assetId?: number | null, symbol?: string | null): SpotAssetBalance {
|
private getAvailableAssetAmount(assetId?: number | null, symbol?: string | null): { available: number | null; wallet: number | null } {
|
||||||
|
if (!this.assets.size) return null;
|
||||||
const normalizedSymbol = symbol ? symbol.toUpperCase() : null;
|
const normalizedSymbol = symbol ? symbol.toUpperCase() : null;
|
||||||
for (const asset of this.assets.values()) {
|
for (const asset of this.assets.values()) {
|
||||||
const idMatches = assetId != null && Number.isFinite(Number(asset.asset_id)) && Number(asset.asset_id) === assetId;
|
const idMatches = assetId != null && Number.isFinite(Number(asset.asset_id)) && Number(asset.asset_id) === assetId;
|
||||||
@@ -1961,23 +1857,29 @@ export class LighterGateway {
|
|||||||
const locked = parseNumber(asset.locked_balance ?? 0);
|
const locked = parseNumber(asset.locked_balance ?? 0);
|
||||||
if (balance == null) continue;
|
if (balance == null) continue;
|
||||||
const available = locked != null ? balance - locked : balance;
|
const available = locked != null ? balance - locked : balance;
|
||||||
return makeSpotAssetBalance(
|
return {
|
||||||
Number.isFinite(available) ? available : null,
|
available: Number.isFinite(available) ? available : null,
|
||||||
Number.isFinite(balance) ? balance : null
|
wallet: Number.isFinite(balance) ? balance : null,
|
||||||
);
|
};
|
||||||
}
|
}
|
||||||
return makeSpotAssetBalance(null, null);
|
return { available: null, wallet: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertSpotBalance(params: { isAsk: boolean; quantity: number | null | undefined; price: number | null }): void {
|
private assertSpotBalance(params: { isAsk: boolean; quantity: number | null | undefined; price: number | null }): void {
|
||||||
const qty = Number(params.quantity);
|
const qty = Number(params.quantity);
|
||||||
if (!Number.isFinite(qty) || qty <= 0) return;
|
if (!Number.isFinite(qty) || qty <= 0) return;
|
||||||
if (params.isAsk) {
|
if (params.isAsk) {
|
||||||
const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
|
const baseAmounts = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
|
||||||
if (base.effective + BALANCE_EPSILON < qty) {
|
const availableBase = baseAmounts?.available ?? null;
|
||||||
|
const walletBase = baseAmounts?.wallet ?? null;
|
||||||
|
const effective = Math.max(
|
||||||
|
availableBase != null && Number.isFinite(availableBase) ? availableBase : 0,
|
||||||
|
walletBase != null && Number.isFinite(walletBase) ? walletBase : 0
|
||||||
|
);
|
||||||
|
if (effective + 1e-9 < qty) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${base.available ?? 0}${
|
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${availableBase ?? 0}${
|
||||||
base.wallet != null ? ` wallet ${base.wallet}` : ""
|
walletBase != null ? ` wallet ${walletBase}` : ""
|
||||||
}) for spot sell ${qty}`
|
}) for spot sell ${qty}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1986,12 +1888,10 @@ export class LighterGateway {
|
|||||||
const price = Number(params.price);
|
const price = Number(params.price);
|
||||||
if (!Number.isFinite(price) || price <= 0) return;
|
if (!Number.isFinite(price) || price <= 0) return;
|
||||||
const requiredQuote = qty * price;
|
const requiredQuote = qty * price;
|
||||||
const quote = this.getSpotAssetBalance(this.quoteAssetId, this.quoteAssetSymbol);
|
const availableQuote = this.getAvailableAssetAmount(this.quoteAssetId, this.quoteAssetSymbol);
|
||||||
if (quote.effective + BALANCE_EPSILON < requiredQuote) {
|
if (availableQuote != null && availableQuote + 1e-9 < requiredQuote) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${
|
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${availableQuote}) for spot buy requiring ${requiredQuote}`
|
||||||
quote.available ?? 0
|
|
||||||
}) for spot buy requiring ${requiredQuote}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2032,10 +1932,10 @@ export class LighterGateway {
|
|||||||
const isAsk = side === "SELL" ? 1 : 0;
|
const isAsk = side === "SELL" ? 1 : 0;
|
||||||
const enforcedQty = this.enforceMinimums(params.quantity, params.price ?? null);
|
const enforcedQty = this.enforceMinimums(params.quantity, params.price ?? null);
|
||||||
if (this.isSpotMarket() && isAsk === 1) {
|
if (this.isSpotMarket() && isAsk === 1) {
|
||||||
const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
|
const availableBase = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
|
||||||
if (base.effective + BALANCE_EPSILON < enforcedQty) {
|
if (availableBase != null && availableBase + 1e-9 < enforcedQty) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Spot sell quantity ${enforcedQty} exceeds available base ${base.effective} (min trade size may be higher than balance)`
|
`Spot sell quantity ${enforcedQty} exceeds available base ${availableBase} (min trade size may be higher than balance)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2280,19 +2180,10 @@ 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(":")) {
|
if (upper.includes("/") || upper.includes("-") || upper.includes(":") || upper.endsWith("USDC")) {
|
||||||
return "spot";
|
|
||||||
}
|
|
||||||
if (SPOT_QUOTE_SUFFIXES.some((suffix) => upper.endsWith(suffix))) {
|
|
||||||
return "spot";
|
return "spot";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -2321,11 +2212,6 @@ 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, LIGHTER_NETWORKS } from "./constants";
|
import { DEFAULT_LIGHTER_ENVIRONMENT, LIGHTER_HOSTS } from "./constants";
|
||||||
|
|
||||||
interface ApiResponseBase {
|
interface ApiResponseBase {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -38,11 +38,6 @@ 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;
|
||||||
@@ -83,7 +78,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_NETWORKS[env]?.rest;
|
const host = options.baseUrl ?? LIGHTER_HOSTS[env]?.rest;
|
||||||
if (!host) {
|
if (!host) {
|
||||||
throw new Error(`Unknown Lighter environment: ${env}`);
|
throw new Error(`Unknown Lighter environment: ${env}`);
|
||||||
}
|
}
|
||||||
@@ -100,31 +95,25 @@ 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: toPriceString((entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price) ?? "0",
|
index_price: (entry as any).index_price ?? entry.mark_price ?? entry.last_trade_price,
|
||||||
mid_price: toPriceString((entry as any).mid_price),
|
mid_price: (entry as any).mid_price,
|
||||||
mark_price: toPriceString(entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price),
|
mark_price: entry.mark_price ?? (entry as any).mid_price ?? entry.last_trade_price,
|
||||||
last_trade_price: toPriceString(entry.last_trade_price) ?? "0",
|
last_trade_price: entry.last_trade_price,
|
||||||
open_interest: toPriceString((entry as any).open_interest) ?? "0",
|
open_interest: (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: toPriceString(entry.current_funding_rate),
|
current_funding_rate: entry.current_funding_rate,
|
||||||
funding_rate: toPriceString(entry.funding_rate),
|
funding_rate: entry.funding_rate,
|
||||||
funding_timestamp: entry.funding_timestamp,
|
funding_timestamp: entry.funding_timestamp,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -304,10 +293,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function lighterOrderToAster(symbol: string, order: LighterOrder): Order
|
|||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
type: mapOrderType(order.type),
|
type: mapOrderType(order.type),
|
||||||
status: normalizeOrderStatus(String(order.status ?? order.trigger_status ?? "UNKNOWN")),
|
status: normalizeOrderStatus(order.status ?? order.trigger_status ?? "UNKNOWN"),
|
||||||
price: order.price ?? "0",
|
price: order.price ?? "0",
|
||||||
origQty: order.initial_base_amount ?? "0",
|
origQty: order.initial_base_amount ?? "0",
|
||||||
executedQty: computeExecutedQty(order),
|
executedQty: computeExecutedQty(order),
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -10,12 +10,6 @@ export type LighterOrderType =
|
|||||||
|
|
||||||
type StrOrNum = string | number;
|
type StrOrNum = string | number;
|
||||||
|
|
||||||
/**
|
|
||||||
* Lighter reports boolean fields inconsistently across endpoints — `true`, `1`, `"Yes"`.
|
|
||||||
* `normalizeBooleanFlag` in ./flags is what turns any of these into a real boolean.
|
|
||||||
*/
|
|
||||||
type BooleanFlag = boolean | string | number | bigint;
|
|
||||||
|
|
||||||
export interface LighterOrder {
|
export interface LighterOrder {
|
||||||
order_index: StrOrNum;
|
order_index: StrOrNum;
|
||||||
client_order_index: StrOrNum;
|
client_order_index: StrOrNum;
|
||||||
@@ -29,12 +23,12 @@ export interface LighterOrder {
|
|||||||
filled_quote_amount?: string;
|
filled_quote_amount?: string;
|
||||||
price: string;
|
price: string;
|
||||||
nonce?: number;
|
nonce?: number;
|
||||||
is_ask?: BooleanFlag;
|
is_ask?: boolean;
|
||||||
side?: LighterSide;
|
side?: LighterSide;
|
||||||
type?: LighterOrderType;
|
type?: LighterOrderType;
|
||||||
time_in_force?: string;
|
time_in_force?: string;
|
||||||
trigger_price?: string;
|
trigger_price?: string;
|
||||||
reduce_only?: BooleanFlag;
|
reduce_only?: boolean;
|
||||||
status?: string | number;
|
status?: string | number;
|
||||||
trigger_status?: string | number;
|
trigger_status?: string | number;
|
||||||
trigger_time?: number;
|
trigger_time?: number;
|
||||||
@@ -74,8 +68,7 @@ export interface LighterAccountDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LighterAccountAsset {
|
export interface LighterAccountAsset {
|
||||||
/** Optional: the account endpoint omits it for assets it only knows by id. */
|
symbol: string;
|
||||||
symbol?: string;
|
|
||||||
asset_id?: number;
|
asset_id?: number;
|
||||||
balance: string | number;
|
balance: string | number;
|
||||||
locked_balance?: string | number;
|
locked_balance?: string | number;
|
||||||
@@ -144,8 +137,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createHmac } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
import { ReconnectScheduler, exponentialBackoff } from "../reconnect-scheduler";
|
|
||||||
import type {
|
import type {
|
||||||
AccountListener,
|
AccountListener,
|
||||||
ConnectionEventListener,
|
ConnectionEventListener,
|
||||||
@@ -43,10 +42,10 @@ const DEFAULT_WS_URL = "wss://api.ondoperps.xyz/ws";
|
|||||||
const DEFAULT_SYMBOL = "BTC-USD.P";
|
const DEFAULT_SYMBOL = "BTC-USD.P";
|
||||||
const REQUEST_TIMEOUT_MS = 15_000;
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
const WS_HEARTBEAT_MS = 30_000;
|
const WS_HEARTBEAT_MS = 30_000;
|
||||||
const WS_RECONNECT_BASE_MS = 1_000;
|
|
||||||
const WS_RECONNECT_MAX_MS = 30_000;
|
const WS_RECONNECT_MAX_MS = 30_000;
|
||||||
|
|
||||||
type Timer = ReturnType<typeof setInterval>;
|
type Timer = ReturnType<typeof setInterval>;
|
||||||
|
type Timeout = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
export interface OndoperpsGatewayOptions {
|
export interface OndoperpsGatewayOptions {
|
||||||
apiKeyId: string;
|
apiKeyId: string;
|
||||||
@@ -212,11 +211,8 @@ export class OndoperpsGateway {
|
|||||||
private wsLoginInFlight = false;
|
private wsLoginInFlight = false;
|
||||||
private wsLoginFallbackAttempted = false;
|
private wsLoginFallbackAttempted = false;
|
||||||
private wsEverOpened = false;
|
private wsEverOpened = false;
|
||||||
private readonly wsReconnect = new ReconnectScheduler({
|
private wsReconnectDelayMs = 1_000;
|
||||||
connect: () => this.connectWebSocket(),
|
private wsReconnectTimer: Timeout | null = null;
|
||||||
backoff: exponentialBackoff(WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS),
|
|
||||||
onError: (error) => this.logger("reconnect", error),
|
|
||||||
});
|
|
||||||
private heartbeatTimer: Timer | null = null;
|
private heartbeatTimer: Timer | null = null;
|
||||||
private readonly sentSubscriptions = new Set<string>();
|
private readonly sentSubscriptions = new Set<string>();
|
||||||
|
|
||||||
@@ -594,7 +590,10 @@ export class OndoperpsGateway {
|
|||||||
|
|
||||||
private connectWebSocket(): void {
|
private connectWebSocket(): void {
|
||||||
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
|
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
|
||||||
this.wsReconnect.cancel();
|
if (this.wsReconnectTimer) {
|
||||||
|
clearTimeout(this.wsReconnectTimer);
|
||||||
|
this.wsReconnectTimer = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const ws = this.webSocketFactory(this.wsUrl);
|
const ws = this.webSocketFactory(this.wsUrl);
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
@@ -606,7 +605,7 @@ export class OndoperpsGateway {
|
|||||||
ws.addEventListener("error", (event) => this.logger("websocket", event));
|
ws.addEventListener("error", (event) => this.logger("websocket", event));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger("connectWebSocket", error);
|
this.logger("connectWebSocket", error);
|
||||||
this.wsReconnect.schedule();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +613,7 @@ export class OndoperpsGateway {
|
|||||||
if (this.ws !== ws) return;
|
if (this.ws !== ws) return;
|
||||||
const reconnected = this.wsEverOpened;
|
const reconnected = this.wsEverOpened;
|
||||||
this.wsEverOpened = true;
|
this.wsEverOpened = true;
|
||||||
this.wsReconnect.onConnected();
|
this.wsReconnectDelayMs = 1_000;
|
||||||
this.wsAuthenticated = false;
|
this.wsAuthenticated = false;
|
||||||
this.wsLoginInFlight = false;
|
this.wsLoginInFlight = false;
|
||||||
this.wsLoginFallbackAttempted = false;
|
this.wsLoginFallbackAttempted = false;
|
||||||
@@ -631,7 +630,7 @@ export class OndoperpsGateway {
|
|||||||
this.wsLoginInFlight = false;
|
this.wsLoginInFlight = false;
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.emitConnection("disconnected");
|
this.emitConnection("disconnected");
|
||||||
this.wsReconnect.schedule();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleWsMessage(raw: unknown): Promise<void> {
|
private async handleWsMessage(raw: unknown): Promise<void> {
|
||||||
@@ -773,6 +772,16 @@ export class OndoperpsGateway {
|
|||||||
this.heartbeatTimer = null;
|
this.heartbeatTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.wsReconnectTimer) return;
|
||||||
|
const delay = this.wsReconnectDelayMs;
|
||||||
|
this.wsReconnectDelayMs = Math.min(this.wsReconnectDelayMs * 2, WS_RECONNECT_MAX_MS);
|
||||||
|
this.wsReconnectTimer = setTimeout(() => {
|
||||||
|
this.wsReconnectTimer = null;
|
||||||
|
this.connectWebSocket();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
private startAccountPolling(): void {
|
private startAccountPolling(): void {
|
||||||
if (this.accountPollTimer) return;
|
if (this.accountPollTimer) return;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ export class ParadexGateway {
|
|||||||
const isClosePosition = (extraParams as any).closePosition === true;
|
const isClosePosition = (extraParams as any).closePosition === true;
|
||||||
if (isClosePosition) {
|
if (isClosePosition) {
|
||||||
const posAbs = this.getCurrentPositionAbs();
|
const posAbs = this.getCurrentPositionAbs();
|
||||||
if (posAbs != null && Number.isFinite(posAbs) && posAbs > 0) {
|
if (Number.isFinite(posAbs) && posAbs > 0) {
|
||||||
amount = posAbs;
|
amount = posAbs;
|
||||||
}
|
}
|
||||||
const current = Number(amount);
|
const current = Number(amount);
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* How long to wait before the nth reconnect attempt (1-based).
|
|
||||||
* Return a fixed value for a constant delay, or grow it for backoff.
|
|
||||||
*/
|
|
||||||
export type BackoffPolicy = (attempt: number) => number;
|
|
||||||
|
|
||||||
export const fixedBackoff = (delayMs: number): BackoffPolicy => () => delayMs;
|
|
||||||
|
|
||||||
/** `base * 2^(attempt-1)`, capped at `maxMs`. */
|
|
||||||
export const exponentialBackoff = (baseMs: number, maxMs: number): BackoffPolicy => (attempt) =>
|
|
||||||
Math.min(baseMs * Math.pow(2, attempt - 1), maxMs);
|
|
||||||
|
|
||||||
/** `base * attempt`, capped at `maxMs`. */
|
|
||||||
export const linearBackoff = (baseMs: number, maxMs: number): BackoffPolicy => (attempt) =>
|
|
||||||
Math.min(baseMs * attempt, maxMs);
|
|
||||||
|
|
||||||
export interface ReconnectSchedulerOptions {
|
|
||||||
/** Reopens the socket. Rejections are reported and then retried. */
|
|
||||||
connect: () => void | Promise<void>;
|
|
||||||
backoff: BackoffPolicy;
|
|
||||||
/** Returns false to abandon reconnecting (e.g. the gateway was closed). */
|
|
||||||
shouldReconnect?: () => boolean;
|
|
||||||
onError?: (error: unknown, attempt: number) => void;
|
|
||||||
onSchedule?: (delayMs: number, attempt: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the reconnect timer for one socket.
|
|
||||||
*
|
|
||||||
* Every gateway hand-rolled the same three pieces — a "one pending attempt at a
|
|
||||||
* time" guard, an attempt counter feeding a backoff formula, and resetting that
|
|
||||||
* counter once the socket opens — each with its own field names and a slightly
|
|
||||||
* different formula. Forgetting the reset is the classic way backoff silently
|
|
||||||
* degrades into a 30-second stall after a transient blip, so the reset lives
|
|
||||||
* here next to the counter it guards.
|
|
||||||
*
|
|
||||||
* Deliberately narrow: connect/auth/subscribe/heartbeat differ per venue and
|
|
||||||
* stay in each gateway.
|
|
||||||
*/
|
|
||||||
export class ReconnectScheduler {
|
|
||||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private attempts = 0;
|
|
||||||
private stopped = false;
|
|
||||||
|
|
||||||
constructor(private readonly options: ReconnectSchedulerOptions) {}
|
|
||||||
|
|
||||||
/** Consecutive failed attempts since the last successful open. */
|
|
||||||
get attemptCount(): number {
|
|
||||||
return this.attempts;
|
|
||||||
}
|
|
||||||
|
|
||||||
get pending(): boolean {
|
|
||||||
return this.timer != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queues a reconnect. A no-op while one is already pending. */
|
|
||||||
schedule(): void {
|
|
||||||
if (this.stopped || this.timer) return;
|
|
||||||
if (this.options.shouldReconnect && !this.options.shouldReconnect()) return;
|
|
||||||
|
|
||||||
const attempt = this.attempts + 1;
|
|
||||||
const delay = this.options.backoff(attempt);
|
|
||||||
this.options.onSchedule?.(delay, attempt);
|
|
||||||
|
|
||||||
this.timer = setTimeout(() => {
|
|
||||||
this.timer = null;
|
|
||||||
this.attempts = attempt;
|
|
||||||
if (this.stopped) return;
|
|
||||||
try {
|
|
||||||
const result = this.options.connect();
|
|
||||||
if (result && typeof result.then === "function") {
|
|
||||||
result.catch((error) => this.handleFailure(error, attempt));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.handleFailure(error, attempt);
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Call once the socket is open: clears backoff so the next blip retries fast. */
|
|
||||||
onConnected(): void {
|
|
||||||
this.attempts = 0;
|
|
||||||
this.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancels a pending attempt without ending the scheduler. */
|
|
||||||
cancel(): void {
|
|
||||||
if (this.timer) {
|
|
||||||
clearTimeout(this.timer);
|
|
||||||
this.timer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Permanently stops reconnecting; use when the gateway shuts down. */
|
|
||||||
stop(): void {
|
|
||||||
this.stopped = true;
|
|
||||||
this.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleFailure(error: unknown, attempt: number): void {
|
|
||||||
this.options.onError?.(error, attempt);
|
|
||||||
this.schedule();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,8 +84,6 @@ 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,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import NodeWebSocket from "ws";
|
import NodeWebSocket from "ws";
|
||||||
import { ReconnectScheduler, exponentialBackoff } from "../reconnect-scheduler";
|
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { sign, utils as edUtils, hashes as edHashes } from "@noble/ed25519";
|
import { sign, utils as edUtils, hashes as edHashes } from "@noble/ed25519";
|
||||||
import { sha512 } from "@noble/hashes/sha512";
|
import { sha512 } from "@noble/hashes/sha512";
|
||||||
@@ -435,6 +434,7 @@ export class StandxGateway {
|
|||||||
private marketWsReady = false;
|
private marketWsReady = false;
|
||||||
private marketWsAuthed = false;
|
private marketWsAuthed = false;
|
||||||
private marketWsAuthRequested = false;
|
private marketWsAuthRequested = false;
|
||||||
|
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private readonly subscriptions = new Set<string>();
|
private readonly subscriptions = new Set<string>();
|
||||||
|
|
||||||
// ========== 心跳与连接管理 ==========
|
// ========== 心跳与连接管理 ==========
|
||||||
@@ -443,15 +443,7 @@ export class StandxGateway {
|
|||||||
// 心跳检查定时器
|
// 心跳检查定时器
|
||||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
// 重连次数(用于指数退避)
|
// 重连次数(用于指数退避)
|
||||||
private readonly marketReconnect = new ReconnectScheduler({
|
private reconnectAttempts = 0;
|
||||||
connect: () => {
|
|
||||||
this.logDebug("attempting reconnect");
|
|
||||||
this.connectMarketWs();
|
|
||||||
},
|
|
||||||
backoff: exponentialBackoff(WS_RECONNECT_DELAY_BASE, WS_RECONNECT_DELAY_MAX),
|
|
||||||
onSchedule: (delay, attempt) =>
|
|
||||||
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${attempt})`),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== 数据过时检测与 REST 备用 ==========
|
// ========== 数据过时检测与 REST 备用 ==========
|
||||||
// 上次收到行情数据(price/depth)的时间戳
|
// 上次收到行情数据(price/depth)的时间戳
|
||||||
@@ -901,7 +893,7 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private connectMarketWs(): void {
|
private connectMarketWs(): void {
|
||||||
if (this.marketWs || this.marketReconnect.pending) return;
|
if (this.marketWs || this.marketReconnectTimer) return;
|
||||||
this.marketWs = new WebSocketCtor(this.wsUrl);
|
this.marketWs = new WebSocketCtor(this.wsUrl);
|
||||||
this.marketWsReady = false;
|
this.marketWsReady = false;
|
||||||
this.marketWsAuthed = false;
|
this.marketWsAuthed = false;
|
||||||
@@ -910,7 +902,7 @@ export class StandxGateway {
|
|||||||
this.marketWsReady = true;
|
this.marketWsReady = true;
|
||||||
this.marketWsAuthed = false;
|
this.marketWsAuthed = false;
|
||||||
// 重置重连计数和时间戳
|
// 重置重连计数和时间戳
|
||||||
this.marketReconnect.onConnected();
|
this.reconnectAttempts = 0;
|
||||||
this.lastMessageTime = Date.now();
|
this.lastMessageTime = Date.now();
|
||||||
this.lastMarketDataTime = Date.now();
|
this.lastMarketDataTime = Date.now();
|
||||||
this.lastAccountDataTime = Date.now();
|
this.lastAccountDataTime = Date.now();
|
||||||
@@ -937,7 +929,7 @@ export class StandxGateway {
|
|||||||
if (wasReady) {
|
if (wasReady) {
|
||||||
this.onDisconnect();
|
this.onDisconnect();
|
||||||
}
|
}
|
||||||
this.marketReconnect.schedule();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
this.logger("marketWs", error);
|
this.logger("marketWs", error);
|
||||||
@@ -945,7 +937,7 @@ export class StandxGateway {
|
|||||||
// 因为某些 WebSocket 实现在握手失败时可能不触发 close 事件
|
// 因为某些 WebSocket 实现在握手失败时可能不触发 close 事件
|
||||||
if (this.marketWs && !this.marketWsReady) {
|
if (this.marketWs && !this.marketWsReady) {
|
||||||
this.marketWs = null;
|
this.marketWs = null;
|
||||||
this.marketReconnect.schedule();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -968,6 +960,22 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.marketReconnectTimer) return;
|
||||||
|
// 指数退避:delay = min(base * 2^attempts, max)
|
||||||
|
const delay = Math.min(
|
||||||
|
WS_RECONNECT_DELAY_BASE * Math.pow(2, this.reconnectAttempts),
|
||||||
|
WS_RECONNECT_DELAY_MAX
|
||||||
|
);
|
||||||
|
this.reconnectAttempts += 1;
|
||||||
|
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||||
|
this.marketReconnectTimer = setTimeout(() => {
|
||||||
|
this.marketReconnectTimer = null;
|
||||||
|
this.logDebug("attempting reconnect");
|
||||||
|
this.connectMarketWs();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
private handleMarketMessage(event: { data: any }): void {
|
private handleMarketMessage(event: { data: any }): void {
|
||||||
// 更新最后收到消息的时间(心跳监控)
|
// 更新最后收到消息的时间(心跳监控)
|
||||||
this.lastMessageTime = Date.now();
|
this.lastMessageTime = Date.now();
|
||||||
@@ -1314,9 +1322,9 @@ export class StandxGateway {
|
|||||||
if (wasReady) {
|
if (wasReady) {
|
||||||
this.onDisconnect();
|
this.onDisconnect();
|
||||||
}
|
}
|
||||||
// Deliberate teardown, so reset the backoff and retry at the base delay.
|
// 立即重连(不使用指数退避,因为是主动行为)
|
||||||
this.marketReconnect.onConnected();
|
this.reconnectAttempts = 0;
|
||||||
this.marketReconnect.schedule();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
private logDebug(context: string, detail?: unknown): void {
|
private logDebug(context: string, detail?: unknown): void {
|
||||||
|
|||||||
+15
-661
@@ -243,10 +243,6 @@ 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}",
|
||||||
@@ -256,10 +252,9 @@ 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: "档位 {band} 目标 {target}bps | 买 {buyDist} ×{buyMult} 深度 {buy} | 卖 {sellDist} ×{sellMult} 深度 {sell}",
|
zh: "StandX 档位 {band}bps 深度: 买 {buy} | 卖 {sell}",
|
||||||
en: "Band {band} target {target}bps | buy {buyDist} ×{buyMult} depth {buy} | sell {sellDist} ×{sellMult} depth {sell}",
|
en: "StandX band {band}bps depth: buy {buy} | sell {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" },
|
||||||
@@ -533,6 +528,14 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "构建快照失败: {error}",
|
zh: "构建快照失败: {error}",
|
||||||
en: "Failed to build snapshot: {error}",
|
en: "Failed to build snapshot: {error}",
|
||||||
},
|
},
|
||||||
|
"log.guardian.precisionSynced": {
|
||||||
|
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
|
||||||
|
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
|
||||||
|
},
|
||||||
|
"log.guardian.precisionFailed": {
|
||||||
|
zh: "同步精度失败: {error}",
|
||||||
|
en: "Failed to sync precision: {error}",
|
||||||
|
},
|
||||||
"log.basis.subscribeFuturesDepthFail": {
|
"log.basis.subscribeFuturesDepthFail": {
|
||||||
zh: "订阅期货深度失败: {error}",
|
zh: "订阅期货深度失败: {error}",
|
||||||
en: "Failed to subscribe futures depth: {error}",
|
en: "Failed to subscribe futures depth: {error}",
|
||||||
@@ -728,662 +731,13 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
|
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
|
||||||
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" },
|
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" },
|
||||||
"log.trend.trailingFail": { zh: "挂动态止盈失败: {error}", en: "Failed to place trailing stop: {error}" },
|
"log.trend.trailingFail": { zh: "挂动态止盈失败: {error}", en: "Failed to place trailing stop: {error}" },
|
||||||
|
"log.trend.precisionSynced": {
|
||||||
|
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
|
||||||
|
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
|
||||||
|
},
|
||||||
|
"log.trend.precisionFailed": { zh: "同步精度失败: {error}", en: "Failed to sync precision: {error}" },
|
||||||
"log.trend.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
|
"log.trend.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
|
||||||
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
|
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
|
||||||
// --- core/order-coordinator ---
|
|
||||||
"order.kind.limit": { zh: "限价单", en: "Limit order" },
|
|
||||||
"order.kind.market": { zh: "市价单", en: "Market order" },
|
|
||||||
"order.kind.stop": { zh: "止损单", en: "Stop order" },
|
|
||||||
"order.kind.trailing": { zh: "动态止盈单", en: "Trailing stop order" },
|
|
||||||
"order.kind.close": { zh: "市价平仓", en: "Market close" },
|
|
||||||
"log.order.markGuardBlocked": {
|
|
||||||
zh: "{kind} 保护触发:side={side} price={price} mark={mark} 超过 {pct}%",
|
|
||||||
en: "{kind} blocked by mark-price guard: side={side} price={price} mark={mark} exceeds {pct}%",
|
|
||||||
},
|
|
||||||
"log.order.lockTimeout": {
|
|
||||||
zh: "{type} 操作超时自动解锁",
|
|
||||||
en: "{type} operation timed out; lock released",
|
|
||||||
},
|
|
||||||
"log.order.dedupeCancelled": {
|
|
||||||
zh: "去重撤销重复 {type} 单: {ids}",
|
|
||||||
en: "Cancelled duplicate {type} orders: {ids}",
|
|
||||||
},
|
|
||||||
"log.order.dedupeGone": {
|
|
||||||
zh: "去重时发现订单已不存在,跳过删除",
|
|
||||||
en: "Order already gone while deduplicating; skipping cancel",
|
|
||||||
},
|
|
||||||
"log.order.dedupeFailed": {
|
|
||||||
zh: "去重撤单失败: {error}",
|
|
||||||
en: "Failed to cancel duplicates: {error}",
|
|
||||||
},
|
|
||||||
"log.order.invalidQuantity": {
|
|
||||||
zh: "{kind}数量无效,跳过下单",
|
|
||||||
en: "{kind} quantity is invalid; skipping",
|
|
||||||
},
|
|
||||||
"log.order.limitPlaced": {
|
|
||||||
zh: "挂限价单: {side} @ {price} 数量 {quantity} reduceOnly={reduceOnly}{sl}",
|
|
||||||
en: "Placed limit order: {side} @ {price} qty {quantity} reduceOnly={reduceOnly}{sl}",
|
|
||||||
},
|
|
||||||
"log.order.limitGone": {
|
|
||||||
zh: "订单已成交或被撤销,跳过新单",
|
|
||||||
en: "Order already filled or cancelled; skipping new order",
|
|
||||||
},
|
|
||||||
"log.order.marketPlaced": {
|
|
||||||
zh: "市价单: {side} 数量 {quantity} reduceOnly={reduceOnly}",
|
|
||||||
en: "Market order: {side} qty {quantity} reduceOnly={reduceOnly}",
|
|
||||||
},
|
|
||||||
"log.order.marketGone": {
|
|
||||||
zh: "市价单失败但订单已不存在,忽略",
|
|
||||||
en: "Market order failed but the order is already gone; ignoring",
|
|
||||||
},
|
|
||||||
"log.order.stopAboveLast": {
|
|
||||||
zh: "止损价 {stopPrice} 高于或等于当前价 {lastPrice},取消挂单",
|
|
||||||
en: "Stop price {stopPrice} is at or above the last price {lastPrice}; not placing",
|
|
||||||
},
|
|
||||||
"log.order.stopBelowLast": {
|
|
||||||
zh: "止损价 {stopPrice} 低于或等于当前价 {lastPrice},取消挂单",
|
|
||||||
en: "Stop price {stopPrice} is at or below the last price {lastPrice}; not placing",
|
|
||||||
},
|
|
||||||
"log.order.stopPlaced": {
|
|
||||||
zh: "挂止损单: {side} STOP_MARKET @ {stopPrice}",
|
|
||||||
en: "Placed stop order: {side} STOP_MARKET @ {stopPrice}",
|
|
||||||
},
|
|
||||||
"log.order.stopGone": { zh: "止损单已失效,跳过", en: "Stop order no longer valid; skipping" },
|
|
||||||
"log.order.trailingUnsupported": {
|
|
||||||
zh: "当前交易所不支持动态止盈单",
|
|
||||||
en: "This exchange does not support trailing stop orders",
|
|
||||||
},
|
|
||||||
"log.order.trailingPlaced": {
|
|
||||||
zh: "挂动态止盈单: {side} activation={activation} callbackRate={callbackRate}",
|
|
||||||
en: "Placed trailing stop: {side} activation={activation} callbackRate={callbackRate}",
|
|
||||||
},
|
|
||||||
"log.order.trailingGone": {
|
|
||||||
zh: "动态止盈单已失效,跳过",
|
|
||||||
en: "Trailing stop no longer valid; skipping",
|
|
||||||
},
|
|
||||||
"log.order.closePlaced": { zh: "市价平仓: {side}", en: "Market close: {side}" },
|
|
||||||
"log.order.closeGone": {
|
|
||||||
zh: "市场平仓时订单已不存在",
|
|
||||||
en: "Order already gone while closing at market",
|
|
||||||
},
|
|
||||||
// --- utils/standx-token-expiry ---
|
|
||||||
"token.expiringSoon": {
|
|
||||||
zh: "StandX Token 将在 {minutes} 分钟后过期",
|
|
||||||
en: "StandX token expires in {minutes} minutes",
|
|
||||||
},
|
|
||||||
"token.expiredCancelling": {
|
|
||||||
zh: "StandX Token 已过期,正在取消所有挂单",
|
|
||||||
en: "StandX token expired; cancelling all open orders",
|
|
||||||
},
|
|
||||||
"token.expiredWithPosition": {
|
|
||||||
zh: "StandX Token 已过期,仅保留平仓/止损逻辑",
|
|
||||||
en: "StandX token expired; only close/stop logic remains active",
|
|
||||||
},
|
|
||||||
"token.expiredSilent": {
|
|
||||||
zh: "StandX Token 已过期,进入静默数据接收模式",
|
|
||||||
en: "StandX token expired; entering silent data-only mode",
|
|
||||||
},
|
|
||||||
"log.token.closeOnlyForced": {
|
|
||||||
zh: "Token 过期,强制进入平仓模式,仅允许 reduce-only 订单",
|
|
||||||
en: "Token expired; forcing close-only mode, reduce-only orders only",
|
|
||||||
},
|
|
||||||
"log.token.silentEntered": {
|
|
||||||
zh: "进入静默数据接收模式,不再进行任何交易操作",
|
|
||||||
en: "Entered silent data-only mode; no further trading actions",
|
|
||||||
},
|
|
||||||
"log.token.ordersCancelled": {
|
|
||||||
zh: "Token 过期,已撤销所有挂单",
|
|
||||||
en: "Token expired; cancelled all open orders",
|
|
||||||
},
|
|
||||||
"log.token.cancelOrderMissing": {
|
|
||||||
zh: "Token 过期撤单时订单已不存在",
|
|
||||||
en: "Order already gone while cancelling after token expiry",
|
|
||||||
},
|
|
||||||
"log.token.cancelFailed": {
|
|
||||||
zh: "Token 过期撤单失败: {error}",
|
|
||||||
en: "Failed to cancel orders after token expiry: {error}",
|
|
||||||
},
|
|
||||||
"notify.token.title": { zh: "Token 已过期", en: "Token expired" },
|
|
||||||
"notify.token.closeOnly": {
|
|
||||||
zh: "Token 已过期,进入平仓模式,不再开新仓",
|
|
||||||
en: "Token expired; entering close-only mode, no new positions",
|
|
||||||
},
|
|
||||||
"notify.token.silent": {
|
|
||||||
zh: "Token 已过期,策略进入静默模式",
|
|
||||||
en: "Token expired; strategy entering silent mode",
|
|
||||||
},
|
|
||||||
// --- strategy/common/isolated-margin-guard ---
|
|
||||||
"log.margin.switched": {
|
|
||||||
zh: "已切换为逐仓模式 (isolated),恢复策略运行",
|
|
||||||
en: "Switched to isolated margin; resuming strategy",
|
|
||||||
},
|
|
||||||
"log.margin.switchUnconfirmed": {
|
|
||||||
zh: "逐仓模式切换未确认,当前模式: {mode}",
|
|
||||||
en: "Isolated margin switch unconfirmed; current mode: {mode}",
|
|
||||||
},
|
|
||||||
"log.margin.switchFailed": {
|
|
||||||
zh: "切换逐仓模式失败: {error}",
|
|
||||||
en: "Failed to switch to isolated margin: {error}",
|
|
||||||
},
|
|
||||||
// --- strategy/grid-logic ---
|
|
||||||
"log.grid.entryFilled": {
|
|
||||||
zh: "ENTRY 成交: {side} @ {price} (线 {level})",
|
|
||||||
en: "ENTRY filled: {side} @ {price} (level {level})",
|
|
||||||
},
|
|
||||||
"log.grid.orphanExitFilled": {
|
|
||||||
zh: "孤儿 EXIT 成交: {side} @ {price}",
|
|
||||||
en: "Orphan EXIT filled: {side} @ {price}",
|
|
||||||
},
|
|
||||||
"log.grid.exitFilled": {
|
|
||||||
zh: "EXIT 成交: {side} @ {price} (释放线 {level})",
|
|
||||||
en: "EXIT filled: {side} @ {price} (level {level} released)",
|
|
||||||
},
|
|
||||||
"log.grid.entryCancelled": {
|
|
||||||
zh: "ENTRY 撤销: {side} @ {price} (线 {level})",
|
|
||||||
en: "ENTRY cancelled: {side} @ {price} (level {level})",
|
|
||||||
},
|
|
||||||
"log.grid.exitCancelled": {
|
|
||||||
zh: "EXIT 撤销: {side} @ {price} (线 {level})",
|
|
||||||
en: "EXIT cancelled: {side} @ {price} (level {level})",
|
|
||||||
},
|
|
||||||
"log.grid.orderVanished": {
|
|
||||||
zh: "订单消失待判定: {intent} {side} @ {price}",
|
|
||||||
en: "Order vanished, outcome unknown: {intent} {side} @ {price}",
|
|
||||||
},
|
|
||||||
"log.grid.belowLowerBound": {
|
|
||||||
zh: "价格跌破网格下边界 {pct}%",
|
|
||||||
en: "Price fell {pct}% below the grid's lower bound",
|
|
||||||
},
|
|
||||||
"log.grid.aboveUpperBound": {
|
|
||||||
zh: "价格突破网格上边界 {pct}%",
|
|
||||||
en: "Price rose {pct}% above the grid's upper bound",
|
|
||||||
},
|
|
||||||
"log.grid.coverageAuditClose": {
|
|
||||||
zh: "覆盖审计: 未覆盖 {qty} 且{cause},市价平仓",
|
|
||||||
en: "Coverage audit: {qty} uncovered and {cause}; closing at market",
|
|
||||||
},
|
|
||||||
"log.grid.causeOutOfRange": { zh: "价格已出区间", en: "price left the range" },
|
|
||||||
"log.grid.causeLossExceeded": { zh: "浮亏超限", en: "unrealised loss exceeded the limit" },
|
|
||||||
"log.grid.coverageAuditReason": { zh: "覆盖审计止损", en: "Coverage audit stop" },
|
|
||||||
"log.grid.coverageAuditRepost": {
|
|
||||||
zh: "覆盖审计: 未覆盖 {qty},补挂平仓单 @ {price}",
|
|
||||||
en: "Coverage audit: {qty} uncovered; reposting exit @ {price}",
|
|
||||||
},
|
|
||||||
"log.grid.shiftOutOfRange": {
|
|
||||||
zh: "价格越界,启动移格: {reason}",
|
|
||||||
en: "Price out of range; starting grid shift: {reason}",
|
|
||||||
},
|
|
||||||
"log.grid.shiftAnchorDrift": {
|
|
||||||
zh: "价格偏离锚定价超阈值,启动移格 (anchor={anchor} → {price})",
|
|
||||||
en: "Price drifted past the anchor threshold; starting grid shift (anchor={anchor} → {price})",
|
|
||||||
},
|
|
||||||
"log.grid.adoptOrphanExit": {
|
|
||||||
zh: "收编平仓方向挂单为孤儿 EXIT: {side} @ {price}",
|
|
||||||
en: "Adopted an unattributed exit-side order as orphan EXIT: {side} @ {price}",
|
|
||||||
},
|
|
||||||
"log.grid.cancelUnattributable": {
|
|
||||||
zh: "撤销无法归属的挂单: {side} @ {price}",
|
|
||||||
en: "Cancelling unattributable order: {side} @ {price}",
|
|
||||||
},
|
|
||||||
"log.grid.inflightMatched": {
|
|
||||||
zh: "inflight 归属确认: {intent} {side} @ {price}",
|
|
||||||
en: "In-flight order matched: {intent} {side} @ {price}",
|
|
||||||
},
|
|
||||||
"log.grid.cancelStaleVersion": {
|
|
||||||
zh: "撤销过期网格版本挂单: {clientOrderId}",
|
|
||||||
en: "Cancelling order from a stale grid version: {clientOrderId}",
|
|
||||||
},
|
|
||||||
"log.grid.orphanResidual": {
|
|
||||||
zh: "对账残余孤儿仓位: {qty}",
|
|
||||||
en: "Reconciliation left an orphan position: {qty}",
|
|
||||||
},
|
|
||||||
// --- strategy/grid-engine ---
|
|
||||||
"log.gridEngine.configInvalid": { zh: "配置无效,已暂停网格", en: "Invalid config; grid paused" },
|
|
||||||
"log.gridEngine.wsDisconnected": {
|
|
||||||
zh: "WebSocket 断连 ({symbol}),冻结网格下单",
|
|
||||||
en: "WebSocket disconnected ({symbol}); freezing grid orders",
|
|
||||||
},
|
|
||||||
"log.gridEngine.wsReconnected": {
|
|
||||||
zh: "WebSocket 重连成功 ({symbol}),下一轮执行对账",
|
|
||||||
en: "WebSocket reconnected ({symbol}); reconciling next tick",
|
|
||||||
},
|
|
||||||
"log.gridEngine.loadStateFailed": {
|
|
||||||
zh: "加载网格状态失败: {error}",
|
|
||||||
en: "Failed to load grid state: {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.stateRestored": {
|
|
||||||
zh: "已从磁盘恢复网格状态: gridVersion={gridVersion} anchor={anchor} 区间=[{lower}, {upper}]{shift}",
|
|
||||||
en: "Restored grid state from disk: gridVersion={gridVersion} anchor={anchor} range=[{lower}, {upper}]{shift}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.stateRestoredShift": {
|
|
||||||
zh: " 移格续跑({phase})",
|
|
||||||
en: " resuming shift ({phase})",
|
|
||||||
},
|
|
||||||
"log.gridEngine.fingerprintMismatch": {
|
|
||||||
zh: "磁盘网格状态与当前配置指纹不一致,全新建格并执行孤儿扫描",
|
|
||||||
en: "Stored grid state does not match the current config; rebuilding and scanning for orphans",
|
|
||||||
},
|
|
||||||
"log.gridEngine.gridCreated": {
|
|
||||||
zh: "以锚定价 {anchor} 建立网格 ({mode})",
|
|
||||||
en: "Grid created at anchor {anchor} ({mode})",
|
|
||||||
},
|
|
||||||
"log.gridEngine.initFailed": { zh: "网格初始化失败: {error}", en: "Grid init failed: {error}" },
|
|
||||||
"log.gridEngine.reconcileEvent": { zh: "[对账:{source}] {event}", en: "[reconcile:{source}] {event}" },
|
|
||||||
"log.gridEngine.reconcileCancelled": {
|
|
||||||
zh: "[对账:{source}] 撤销 {count} 个无法归属的挂单",
|
|
||||||
en: "[reconcile:{source}] cancelled {count} unattributable orders",
|
|
||||||
},
|
|
||||||
"log.gridEngine.reconcileCancelFailed": {
|
|
||||||
zh: "[对账:{source}] 撤单失败: {error}",
|
|
||||||
en: "[reconcile:{source}] cancel failed: {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.reconcileOrdersFailed": {
|
|
||||||
zh: "[对账:{source}] REST 查询挂单失败: {error}",
|
|
||||||
en: "[reconcile:{source}] REST open-order query failed: {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.reconcileAccountFailed": {
|
|
||||||
zh: "[对账:{source}] REST 查询账户失败: {error}",
|
|
||||||
en: "[reconcile:{source}] REST account query failed: {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.tickFailed": { zh: "网格轮询异常: {error}", en: "Grid tick failed: {error}" },
|
|
||||||
"log.gridEngine.shiftStarting": {
|
|
||||||
zh: "启动智能移格,目标锚定价 {anchor}",
|
|
||||||
en: "Starting grid shift to anchor {anchor}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.orderFeedStalled": {
|
|
||||||
zh: "订单流疑似停滞(下单后长时间未反映),暂停新下单",
|
|
||||||
en: "Order feed looks stalled (placements are not showing up); pausing new orders",
|
|
||||||
},
|
|
||||||
"log.gridEngine.placeFailed": {
|
|
||||||
zh: "挂单失败 ({side} @ {price}): {error}",
|
|
||||||
en: "Failed to place order ({side} @ {price}): {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.closeSlippageBlocked": {
|
|
||||||
zh: "市价平仓滑点守卫触发 ({reason}): close={close} mark={mark} 偏离 {pct}% > {limit}%,暂缓",
|
|
||||||
en: "Market close blocked by slippage guard ({reason}): close={close} mark={mark} deviates {pct}% > {limit}%; holding off",
|
|
||||||
},
|
|
||||||
"log.gridEngine.closed": { zh: "市价平仓 {side} {qty} ({reason})", en: "Market close {side} {qty} ({reason})" },
|
|
||||||
"log.gridEngine.closeFailed": {
|
|
||||||
zh: "市价平仓失败 ({reason}): {error}",
|
|
||||||
en: "Market close failed ({reason}): {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.shiftCancelRequested": {
|
|
||||||
zh: "移格: 已请求撤销全部挂单",
|
|
||||||
en: "Shift: requested cancellation of all orders",
|
|
||||||
},
|
|
||||||
"log.gridEngine.shiftCancelFailed": { zh: "移格撤单失败: {error}", en: "Shift cancel failed: {error}" },
|
|
||||||
"log.gridEngine.shiftCloseReason": { zh: "移格平仓", en: "Grid shift close" },
|
|
||||||
"log.gridEngine.shiftCloseDeferred": {
|
|
||||||
zh: "移格: 平仓被滑点守卫暂缓,下轮重试",
|
|
||||||
en: "Shift: close deferred by the slippage guard; retrying next tick",
|
|
||||||
},
|
|
||||||
"log.gridEngine.shiftDone": {
|
|
||||||
zh: "移格完成: 新锚定价 {anchor},区间 [{lower}, {upper}],gridVersion={gridVersion}",
|
|
||||||
en: "Shift complete: anchor {anchor}, range [{lower}, {upper}], gridVersion={gridVersion}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.stopCancelledFlat": {
|
|
||||||
zh: "已撤销交易所兜底止损单(仓位归零)",
|
|
||||||
en: "Cancelled the exchange stop order (position is flat)",
|
|
||||||
},
|
|
||||||
"log.gridEngine.stopCancelFailed": {
|
|
||||||
zh: "撤销兜底止损单失败: {error}",
|
|
||||||
en: "Failed to cancel the exchange stop: {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.stopCancelStaleFailed": {
|
|
||||||
zh: "撤销旧兜底止损单失败: {error}",
|
|
||||||
en: "Failed to cancel the previous exchange stop: {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.stopPlaceFailed": {
|
|
||||||
zh: "挂兜底止损单失败: {error}",
|
|
||||||
en: "Failed to place the exchange stop: {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.haltStarting": {
|
|
||||||
zh: "{reason},开始执行撤单与平仓",
|
|
||||||
en: "{reason}; cancelling orders and closing out",
|
|
||||||
},
|
|
||||||
"log.gridEngine.allCancelled": { zh: "已撤销全部网格挂单", en: "Cancelled all grid orders" },
|
|
||||||
"log.gridEngine.cancelAllFailed": {
|
|
||||||
zh: "撤销网格挂单失败: {error}",
|
|
||||||
en: "Failed to cancel grid orders: {error}",
|
|
||||||
},
|
|
||||||
"log.gridEngine.stopCloseDeferred": {
|
|
||||||
zh: "止损平仓被滑点守卫暂缓,下轮重试",
|
|
||||||
en: "Stop close deferred by the slippage guard; retrying next tick",
|
|
||||||
},
|
|
||||||
"log.gridEngine.resumed": {
|
|
||||||
zh: "价格重新回到网格区间,恢复网格运行 (gridVersion={gridVersion})",
|
|
||||||
en: "Price re-entered the grid range; resuming (gridVersion={gridVersion})",
|
|
||||||
},
|
|
||||||
"log.gridEngine.saveStateFailed": {
|
|
||||||
zh: "保存网格状态失败: {error}",
|
|
||||||
en: "Failed to save grid state: {error}",
|
|
||||||
},
|
|
||||||
// --- offset-maker / liquidity-maker (shared wording) ---
|
|
||||||
"log.subscribe.klineFail": { zh: "订阅K线失败: {error}", en: "Failed to subscribe klines: {error}" },
|
|
||||||
"log.process.klineError": { zh: "K线推送处理异常: {error}", en: "Kline update handler error: {error}" },
|
|
||||||
"log.spotMaker.belowMinSellHold": {
|
|
||||||
zh: "现货持仓低于最小卖单量,暂不挂卖单",
|
|
||||||
en: "Spot balance is below the minimum sell size; holding off on sell orders",
|
|
||||||
},
|
|
||||||
"log.spotMaker.belowMinSellSkip": {
|
|
||||||
zh: "现货持仓低于最小卖单量,跳过卖单",
|
|
||||||
en: "Spot balance is below the minimum sell size; skipping the sell order",
|
|
||||||
},
|
|
||||||
"log.spotMaker.buyOnlyOnGreenCandle": {
|
|
||||||
zh: "现货买入仅在1m阳线,当前跳过买单",
|
|
||||||
en: "Spot buys only on a green 1m candle; skipping the buy order",
|
|
||||||
},
|
|
||||||
"log.spotMaker.quoteBalanceShort": {
|
|
||||||
zh: "现货可用报价资产不足,跳过买单",
|
|
||||||
en: "Not enough quote asset available; skipping the buy order",
|
|
||||||
},
|
|
||||||
"log.spotMaker.baseBalanceShort": {
|
|
||||||
zh: "现货可用基础资产不足,跳过卖单",
|
|
||||||
en: "Not enough base asset available; skipping the sell order",
|
|
||||||
},
|
|
||||||
"log.spotMaker.spreadTooTightBuy": {
|
|
||||||
zh: "跳过买单:价差不足以构造maker价格",
|
|
||||||
en: "Skipping the buy order: the spread is too tight for a maker price",
|
|
||||||
},
|
|
||||||
"log.spotMaker.spreadTooTightSell": {
|
|
||||||
zh: "跳过卖单:价差不足以构造maker价格",
|
|
||||||
en: "Skipping the sell order: the spread is too tight for a maker price",
|
|
||||||
},
|
|
||||||
"log.spotMaker.sellBelowMinNotional": {
|
|
||||||
zh: "现货卖单低于最小成交量,跳过挂单等待累积",
|
|
||||||
en: "Sell size is below the venue minimum; waiting to accumulate",
|
|
||||||
},
|
|
||||||
"log.spotMaker.belowMinCloseSkipStop": {
|
|
||||||
zh: "现货持仓低于最小平仓数量,跳过止损检查",
|
|
||||||
en: "Spot position is below the minimum close size; skipping the stop check",
|
|
||||||
},
|
|
||||||
"log.spotMaker.rateLimitCloseMissing": {
|
|
||||||
zh: "限频强制平仓时订单已不存在",
|
|
||||||
en: "Order already gone during the rate-limit forced close",
|
|
||||||
},
|
|
||||||
"log.spotMaker.rateLimitCloseFailed": {
|
|
||||||
zh: "限频强制平仓失败: {error}",
|
|
||||||
en: "Rate-limit forced close failed: {error}",
|
|
||||||
},
|
|
||||||
"log.spotMaker.startupCleanup": { zh: "启动时清理历史挂单", en: "Cancelling stale orders on startup" },
|
|
||||||
"log.spotMaker.startupCleanupGone": {
|
|
||||||
zh: "历史挂单已消失,跳过启动清理",
|
|
||||||
en: "Stale orders already gone; skipping startup cleanup",
|
|
||||||
},
|
|
||||||
"log.spotMaker.startupCancelFailed": {
|
|
||||||
zh: "启动撤单失败: {error}",
|
|
||||||
en: "Startup cancel failed: {error}",
|
|
||||||
},
|
|
||||||
"log.spotMaker.cancelMismatched": {
|
|
||||||
zh: "撤销不匹配订单 {side} @ {price} reduceOnly={reduceOnly}",
|
|
||||||
en: "Cancelling mismatched order {side} @ {price} reduceOnly={reduceOnly}",
|
|
||||||
},
|
|
||||||
"log.spotMaker.cancelAlreadySettled": {
|
|
||||||
zh: "撤销时发现订单已被成交/取消,忽略",
|
|
||||||
en: "Order was already filled or cancelled; ignoring",
|
|
||||||
},
|
|
||||||
"log.spotMaker.cancelFailed": { zh: "撤销订单失败: {error}", en: "Failed to cancel order: {error}" },
|
|
||||||
"log.spotMaker.orderMissingOnCancel": {
|
|
||||||
zh: "订单已不存在,撤销跳过",
|
|
||||||
en: "Order no longer exists; skipping cancel",
|
|
||||||
},
|
|
||||||
"log.spotMaker.dustCloseFailed": {
|
|
||||||
zh: "小额市价平仓失败: {error}",
|
|
||||||
en: "Dust market close failed: {error}",
|
|
||||||
},
|
|
||||||
"log.spotMaker.dustClose": {
|
|
||||||
zh: "小额仓位使用市价平仓 {side} 数量 {qty}",
|
|
||||||
en: "Closing dust position at market: {side} qty {qty}",
|
|
||||||
},
|
|
||||||
"log.spotMaker.placeFailed": {
|
|
||||||
zh: "挂单失败({side} {price}): {error}",
|
|
||||||
en: "Failed to place order ({side} {price}): {error}",
|
|
||||||
},
|
|
||||||
"log.spotMaker.spotStop": {
|
|
||||||
zh: "现货止损,当前仓位={qty} PnL={pnl} USDT",
|
|
||||||
en: "Spot stop-loss: position={qty} PnL={pnl} USDT",
|
|
||||||
},
|
|
||||||
"log.spotMaker.spotStopFailed": { zh: "现货止损失败: {error}", en: "Spot stop-loss failed: {error}" },
|
|
||||||
"log.spotMaker.stopCloseMissing": {
|
|
||||||
zh: "止损平仓时订单已不存在",
|
|
||||||
en: "Order already gone while closing on stop",
|
|
||||||
},
|
|
||||||
"log.spotMaker.stopCloseFailed": { zh: "止损平仓失败: {error}", en: "Stop close failed: {error}" },
|
|
||||||
"log.spotMaker.entryPricePending": {
|
|
||||||
zh: "做市持仓均价未同步,等待账户快照刷新后再执行止损判断",
|
|
||||||
en: "Entry price not synced yet; waiting for an account refresh before evaluating the stop",
|
|
||||||
},
|
|
||||||
"log.spotMaker.stopTriggered": {
|
|
||||||
zh: "触发止损,方向={direction} 当前亏损={pnl} USDT",
|
|
||||||
en: "Stop-loss triggered: direction={direction} loss={pnl} USDT",
|
|
||||||
},
|
|
||||||
"log.spotMaker.updateHandlerError": {
|
|
||||||
zh: "更新回调处理异常: {error}",
|
|
||||||
en: "Update handler error: {error}",
|
|
||||||
},
|
|
||||||
"log.spotMaker.snapshotDispatchError": {
|
|
||||||
zh: "快照或更新分发异常: {error}",
|
|
||||||
en: "Snapshot/update dispatch error: {error}",
|
|
||||||
},
|
|
||||||
"log.offsetMaker.tickFailed": { zh: "偏移做市循环异常: {error}", en: "Offset maker tick failed: {error}" },
|
|
||||||
"log.offsetMaker.imbalanceClose": {
|
|
||||||
zh: "深度极端不平衡({buySum} vs {sellSum}), 市价平仓 {side}",
|
|
||||||
en: "Extreme depth imbalance ({buySum} vs {sellSum}); closing {side} at market",
|
|
||||||
},
|
|
||||||
"log.offsetMaker.imbalanceCloseMissing": {
|
|
||||||
zh: "深度不平衡平仓时订单已不存在",
|
|
||||||
en: "Order already gone during the imbalance close",
|
|
||||||
},
|
|
||||||
"log.offsetMaker.imbalanceCloseFailed": {
|
|
||||||
zh: "深度不平衡平仓失败: {error}",
|
|
||||||
en: "Imbalance close failed: {error}",
|
|
||||||
},
|
|
||||||
"log.liquidityMaker.tickFailed": {
|
|
||||||
zh: "流动性做市循环异常: {error}",
|
|
||||||
en: "Liquidity maker tick failed: {error}",
|
|
||||||
},
|
|
||||||
"log.liquidityMaker.fillDetected": {
|
|
||||||
zh: "检测到成交: {side} {qty} @ {price}",
|
|
||||||
en: "Fill detected: {side} {qty} @ {price}",
|
|
||||||
},
|
|
||||||
"log.liquidityMaker.exitRaisedToBreakeven": {
|
|
||||||
zh: "平仓价调整为入场价+1tick以确保不亏本: {price}",
|
|
||||||
en: "Exit raised to entry+1 tick to stay at or above breakeven: {price}",
|
|
||||||
},
|
|
||||||
"log.liquidityMaker.exitLoweredToBreakeven": {
|
|
||||||
zh: "平仓价调整为入场价-1tick以确保不亏本: {price}",
|
|
||||||
en: "Exit lowered to entry-1 tick to stay at or above breakeven: {price}",
|
|
||||||
},
|
|
||||||
// --- strategy/maker-points-engine ---
|
|
||||||
"log.mp.binanceError": { zh: "Binance {context} 异常: {error}", en: "Binance {context} error: {error}" },
|
|
||||||
"log.mp.binanceDisconnected": { zh: "Binance 深度连接断开", en: "Binance depth feed disconnected" },
|
|
||||||
"log.mp.binanceStale": { zh: "Binance 深度数据过时", en: "Binance depth data is stale" },
|
|
||||||
"log.mp.binanceRecovered": { zh: "Binance 深度连接恢复", en: "Binance depth feed recovered" },
|
|
||||||
"log.mp.wsDisconnected": {
|
|
||||||
zh: "WebSocket 断连 ({symbol}),启动断连保护",
|
|
||||||
en: "WebSocket disconnected ({symbol}); engaging disconnect protection",
|
|
||||||
},
|
|
||||||
"log.mp.wsReconnected": {
|
|
||||||
zh: "WebSocket 重连成功 ({symbol}),开始重连保护流程",
|
|
||||||
en: "WebSocket reconnected ({symbol}); running reconnect protection",
|
|
||||||
},
|
|
||||||
"log.mp.reconnectFoundOrders": {
|
|
||||||
zh: "重连后查询到 {count} 个挂单",
|
|
||||||
en: "Found {count} open orders after reconnecting",
|
|
||||||
},
|
|
||||||
"log.mp.reconnectCancelled": { zh: "重连保护:已取消所有挂单", en: "Reconnect protection: cancelled all orders" },
|
|
||||||
"log.mp.reconnectCancelPartial": {
|
|
||||||
zh: "重连保护:取消挂单未完全成功,将在下次循环重试",
|
|
||||||
en: "Reconnect protection: cancellation incomplete; retrying next tick",
|
|
||||||
},
|
|
||||||
"log.mp.reconnectFailed": { zh: "重连保护流程失败: {error}", en: "Reconnect protection failed: {error}" },
|
|
||||||
"log.mp.closeOnlyEntered": { zh: "进入平仓模式,仅挂 reduce-only", en: "Entered close-only mode; reduce-only quotes" },
|
|
||||||
"log.mp.closeOnlyExited": { zh: "退出平仓模式", en: "Left close-only mode" },
|
|
||||||
"log.mp.depthImbalancePause": {
|
|
||||||
zh: "Binance 深度失衡,暂停 {summary} 挂单",
|
|
||||||
en: "Binance depth imbalance; pausing {summary} quotes",
|
|
||||||
},
|
|
||||||
"log.mp.depthImbalanceResume": { zh: "Binance 深度恢复,继续挂单", en: "Binance depth recovered; resuming quotes" },
|
|
||||||
"log.mp.rateLimited": { zh: "限频触发,暂停挂单: {error}", en: "Rate limited; pausing quotes: {error}" },
|
|
||||||
"log.mp.tickFailed": { zh: "MakerPoints 主循环异常: {error}", en: "MakerPoints tick failed: {error}" },
|
|
||||||
"log.mp.precisionErrorResync": {
|
|
||||||
zh: "检测到精度错误,重新同步: {error}",
|
|
||||||
en: "Precision error detected; resyncing: {error}",
|
|
||||||
},
|
|
||||||
"log.mp.placeFailed": { zh: "挂单失败 {side} @ {price}: {error}", en: "Failed to place {side} @ {price}: {error}" },
|
|
||||||
"log.mp.unexpectedOrders": {
|
|
||||||
zh: "发现 {count} 个未预期挂单,执行强制取消",
|
|
||||||
en: "Found {count} unexpected orders; force-cancelling",
|
|
||||||
},
|
|
||||||
"log.mp.forceCancelled": {
|
|
||||||
zh: "已强制取消所有挂单,重置本地状态",
|
|
||||||
en: "Force-cancelled all orders and reset local state",
|
|
||||||
},
|
|
||||||
"log.mp.verifyOrdersFailed": { zh: "验证挂单状态失败: {error}", en: "Failed to verify order state: {error}" },
|
|
||||||
"log.mp.stopTriggered": {
|
|
||||||
zh: "触发止损: 实时未实现亏损 {pnl} USDT",
|
|
||||||
en: "Stop-loss triggered: live unrealised loss {pnl} USDT",
|
|
||||||
},
|
|
||||||
"log.mp.stopSucceeded": { zh: "止损成功: 仓位已清零", en: "Stop-loss done: position is flat" },
|
|
||||||
"log.mp.stopOrderMissing": {
|
|
||||||
zh: "止损平仓时订单已不存在,继续检查仓位",
|
|
||||||
en: "Order already gone during the stop close; rechecking the position",
|
|
||||||
},
|
|
||||||
"log.mp.stopPrecisionResync": {
|
|
||||||
zh: "止损平仓精度错误,重新同步: {error}",
|
|
||||||
en: "Precision error during the stop close; resyncing: {error}",
|
|
||||||
},
|
|
||||||
"log.mp.stopRetry": {
|
|
||||||
zh: "止损平仓失败 (重试 {attempt}/{max}): {error}",
|
|
||||||
en: "Stop close failed (retry {attempt}/{max}): {error}",
|
|
||||||
},
|
|
||||||
"log.mp.stopRetriesExhausted": {
|
|
||||||
zh: "止损重试已达上限 ({max} 次),请手动检查仓位",
|
|
||||||
en: "Stop retries exhausted ({max}); check the position manually",
|
|
||||||
},
|
|
||||||
"log.mp.updateHandlerError": { zh: "更新监听异常: {error}", en: "Update listener error: {error}" },
|
|
||||||
"log.mp.snapshotError": { zh: "快照生成异常: {error}", en: "Snapshot build error: {error}" },
|
|
||||||
"log.mp.noTargets": { zh: "暂无目标挂单", en: "No target orders" },
|
|
||||||
"log.mp.targets": { zh: "目标挂单: {summary}", en: "Target orders: {summary}" },
|
|
||||||
"log.mp.skipThinDepth": {
|
|
||||||
zh: "跳过 {side} {bps}bps 挂单: 深度 {depth} BTC < {min} BTC",
|
|
||||||
en: "Skipping {side} {bps}bps quote: depth {depth} BTC < {min} BTC",
|
|
||||||
},
|
|
||||||
"log.mp.depthRecovered": {
|
|
||||||
zh: "{side} {bps}bps 深度恢复,继续挂单",
|
|
||||||
en: "{side} {bps}bps depth recovered; resuming quotes",
|
|
||||||
},
|
|
||||||
"log.mp.insufficientBalance": {
|
|
||||||
zh: "余额不足,暂停挂单 {seconds}s: {detail}",
|
|
||||||
en: "Insufficient balance; pausing quotes for {seconds}s: {detail}",
|
|
||||||
},
|
|
||||||
"log.mp.balanceRecovered": { zh: "余额恢复,继续挂单", en: "Balance recovered; resuming quotes" },
|
|
||||||
"log.mp.defenseEntered": {
|
|
||||||
zh: "数据过时检测: {summary},进入防御模式",
|
|
||||||
en: "Stale-data check: {summary}; entering defense mode",
|
|
||||||
},
|
|
||||||
"log.mp.defenseExited": {
|
|
||||||
zh: "数据推送恢复正常,退出防御模式",
|
|
||||||
en: "Data feeds recovered; leaving defense mode",
|
|
||||||
},
|
|
||||||
"log.mp.defenseForceCancelled": {
|
|
||||||
zh: "防御模式: 已强制取消所有挂单",
|
|
||||||
en: "Defense mode: force-cancelled all orders",
|
|
||||||
},
|
|
||||||
"log.mp.defenseCancelPartial": {
|
|
||||||
zh: "防御模式: 取消挂单未完全成功,将继续重试",
|
|
||||||
en: "Defense mode: cancellation incomplete; will retry",
|
|
||||||
},
|
|
||||||
"log.mp.defenseCancelled": { zh: "防御模式: 已取消所有挂单", en: "Defense mode: cancelled all orders" },
|
|
||||||
"log.mp.defenseOrdersGone": { zh: "防御模式: 挂单已不存在", en: "Defense mode: orders already gone" },
|
|
||||||
"log.mp.defenseCancelFailed": {
|
|
||||||
zh: "防御模式取消挂单失败: {error}",
|
|
||||||
en: "Defense mode cancel failed: {error}",
|
|
||||||
},
|
|
||||||
"log.mp.defensePollStarted": {
|
|
||||||
zh: "防御模式: 启动 REST 数据轮询",
|
|
||||||
en: "Defense mode: started REST polling",
|
|
||||||
},
|
|
||||||
"log.mp.defensePollStopped": {
|
|
||||||
zh: "防御模式: 停止 REST 数据轮询",
|
|
||||||
en: "Defense mode: stopped REST polling",
|
|
||||||
},
|
|
||||||
"log.mp.defensePositionStillBad": {
|
|
||||||
zh: "防御模式: 仓位数据仍异常: {issues}",
|
|
||||||
en: "Defense mode: position data still invalid: {issues}",
|
|
||||||
},
|
|
||||||
"log.mp.defenseEmptySnapshot": {
|
|
||||||
zh: "防御模式: REST 获取账户快照为空",
|
|
||||||
en: "Defense mode: REST returned an empty account snapshot",
|
|
||||||
},
|
|
||||||
"log.mp.defenseFoundOrders": {
|
|
||||||
zh: "防御模式: 发现 {count} 个挂单,执行取消",
|
|
||||||
en: "Defense mode: found {count} open orders; cancelling",
|
|
||||||
},
|
|
||||||
"log.mp.defenseQueryFailed": {
|
|
||||||
zh: "防御模式查询挂单失败: {error}",
|
|
||||||
en: "Defense mode open-order query failed: {error}",
|
|
||||||
},
|
|
||||||
"log.mp.defensePollFailed": {
|
|
||||||
zh: "防御模式 REST 轮询失败: {error}",
|
|
||||||
en: "Defense mode REST poll failed: {error}",
|
|
||||||
},
|
|
||||||
"notify.mp.disconnectTitle": { zh: "连接断开", en: "Disconnected" },
|
|
||||||
"notify.mp.disconnectBody": {
|
|
||||||
zh: "WebSocket 断连,正在尝试取消所有挂单",
|
|
||||||
en: "WebSocket disconnected; cancelling all open orders",
|
|
||||||
},
|
|
||||||
"notify.mp.reconnectTitle": { zh: "重连完成", en: "Reconnected" },
|
|
||||||
"notify.mp.reconnectBody": {
|
|
||||||
zh: "WebSocket 重连成功,已清理挂单状态",
|
|
||||||
en: "WebSocket reconnected; order state cleaned up",
|
|
||||||
},
|
|
||||||
"notify.mp.stopTitle": { zh: "止损触发", en: "Stop-loss triggered" },
|
|
||||||
"notify.mp.stopBody": {
|
|
||||||
zh: "实时未实现亏损 {pnl} USDT,强制平仓",
|
|
||||||
en: "Live unrealised loss {pnl} USDT; forcing a close",
|
|
||||||
},
|
|
||||||
"notify.mp.defenseTitle": { zh: "防御模式", en: "Defense mode" },
|
|
||||||
"notify.mp.defenseBody": {
|
|
||||||
zh: "数据推送中断: {summary},已取消所有挂单",
|
|
||||||
en: "Data feed interrupted: {summary}; cancelled all open orders",
|
|
||||||
},
|
|
||||||
"notify.mp.defenseClearedTitle": { zh: "防御模式解除", en: "Defense mode cleared" },
|
|
||||||
"notify.mp.defenseClearedBody": {
|
|
||||||
zh: "数据推送恢复正常,恢复正常交易",
|
|
||||||
en: "Data feeds are healthy again; resuming normal trading",
|
|
||||||
},
|
|
||||||
"notify.mp.openTitle": { zh: "开仓", en: "Position opened" },
|
|
||||||
"notify.mp.closeTitle": { zh: "平仓", en: "Position closed" },
|
|
||||||
"notify.mp.closeTitleTokenExpired": { zh: "Token过期平仓", en: "Token-expiry close" },
|
|
||||||
"notify.mp.increaseTitle": { zh: "加仓", en: "Position increased" },
|
|
||||||
"notify.mp.reduceTitle": { zh: "减仓", en: "Position reduced" },
|
|
||||||
"notify.mp.reverseTitle": { zh: "反向开仓", en: "Position reversed" },
|
|
||||||
"notify.mp.openBody": { zh: "{direction} {qty}", en: "{direction} {qty}" },
|
|
||||||
"notify.mp.closeBody": { zh: "已平仓 {qty} ({direction})", en: "Closed {qty} ({direction})" },
|
|
||||||
"notify.mp.increaseBody": {
|
|
||||||
zh: "{direction} +{delta} → {qty}",
|
|
||||||
en: "{direction} +{delta} → {qty}",
|
|
||||||
},
|
|
||||||
"notify.mp.reduceBody": { zh: "{direction} -{delta} → {qty}", en: "{direction} -{delta} → {qty}" },
|
|
||||||
"notify.mp.reverseBody": { zh: "{transition} {qty}", en: "{transition} {qty}" },
|
|
||||||
"common.direction.longToShort": { zh: "多→空", en: "long → short" },
|
|
||||||
"common.direction.shortToLong": { zh: "空→多", en: "short → long" },
|
|
||||||
// --- strategy/maker-points-defense (stale-reason summary) ---
|
|
||||||
"defense.reason.depth": { zh: "StandX深度({seconds}s)", en: "StandX depth ({seconds}s)" },
|
|
||||||
"defense.reason.account": { zh: "StandX账户({seconds}s)", en: "StandX account ({seconds}s)" },
|
|
||||||
"defense.reason.accountInvalid": {
|
|
||||||
zh: "StandX仓位数据异常({issues})",
|
|
||||||
en: "StandX position data invalid ({issues})",
|
|
||||||
},
|
|
||||||
"defense.reason.rest": { zh: "StandX REST错误({count}次)", en: "StandX REST errors ({count})" },
|
|
||||||
"defense.reason.marginMode": { zh: "保证金模式({mode})", en: "Margin mode ({mode})" },
|
|
||||||
"defense.reason.binanceDepth": { zh: "Binance深度({seconds}s)", en: "Binance depth ({seconds}s)" },
|
|
||||||
"defense.reason.binanceBook": {
|
|
||||||
zh: "Binance簿记异常({reason})",
|
|
||||||
en: "Binance order book unhealthy ({reason})",
|
|
||||||
},
|
|
||||||
"defense.reason.unknown": { zh: "unknown", en: "unknown" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTemplate = (template: string, params: Record<string, unknown>): string => {
|
const formatTemplate = (template: string, params: Record<string, unknown>): string => {
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ export class BasisArbEngine {
|
|||||||
const spotTs = snapshot.spotLastUpdate ?? 0;
|
const spotTs = snapshot.spotLastUpdate ?? 0;
|
||||||
if (futTs <= readyAt || spotTs <= readyAt) return;
|
if (futTs <= readyAt || spotTs <= readyAt) return;
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
// Use net spread after taker fees to match the bp figure the UI labels as taker-fee-adjusted
|
// Use net spread after taker fees to match UI's "扣除 taker 手续费" bp
|
||||||
const spreadBps = snapshot.netSpreadBps;
|
const spreadBps = snapshot.netSpreadBps;
|
||||||
const fundingRate = snapshot.fundingRate;
|
const fundingRate = snapshot.fundingRate;
|
||||||
const nextFundingTime = snapshot.nextFundingTime;
|
const nextFundingTime = snapshot.nextFundingTime;
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { AccountSnapshot } from "../../exchanges/types";
|
|
||||||
import { extractMessage } from "../../utils/errors";
|
|
||||||
import { t } from "../../i18n";
|
|
||||||
import type { LogHandler } from "./subscriptions";
|
|
||||||
|
|
||||||
/** Snapshot refreshes to wait through before giving up on the switch (~5s total). */
|
|
||||||
const MAX_CONFIRM_ATTEMPTS = 10;
|
|
||||||
const CONFIRM_INTERVAL_MS = 500;
|
|
||||||
|
|
||||||
export interface IsolatedMarginGuardDeps {
|
|
||||||
symbol: string;
|
|
||||||
/** False on venues that do not expose a per-symbol margin mode; the guard is then inert. */
|
|
||||||
enabled: boolean;
|
|
||||||
log: LogHandler;
|
|
||||||
/** Latest account snapshot the engine holds. */
|
|
||||||
currentSnapshot: () => AccountSnapshot | null;
|
|
||||||
changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise<void>;
|
|
||||||
queryAccountSnapshot?: () => Promise<AccountSnapshot | null>;
|
|
||||||
/** Feeds a freshly polled snapshot back into the engine before re-reading the mode. */
|
|
||||||
applySnapshot: (snapshot: AccountSnapshot) => void;
|
|
||||||
sleep?: (ms: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps the traded symbol on isolated margin.
|
|
||||||
*
|
|
||||||
* The switch is asynchronous at the venue: the REST call returns before the
|
|
||||||
* account reflects it, so the guard polls until the new mode shows up. A single
|
|
||||||
* in-flight promise makes concurrent ticks share one attempt instead of firing
|
|
||||||
* the change repeatedly.
|
|
||||||
*/
|
|
||||||
export class IsolatedMarginGuard {
|
|
||||||
private ensuring: Promise<boolean> | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly deps: IsolatedMarginGuardDeps) {}
|
|
||||||
|
|
||||||
/** The venue's margin mode for this symbol, lowercased, or null when unknown. */
|
|
||||||
currentMode(snapshot: AccountSnapshot | null = this.deps.currentSnapshot()): string | null {
|
|
||||||
if (!this.deps.enabled) return null;
|
|
||||||
const positions = snapshot?.positions ?? [];
|
|
||||||
const match = positions.find((pos) => pos.symbol === this.deps.symbol);
|
|
||||||
const raw = (match as { marginType?: unknown; margin_mode?: unknown } | undefined)?.marginType ??
|
|
||||||
(match as { margin_mode?: unknown } | undefined)?.margin_mode;
|
|
||||||
const mode = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
|
||||||
return mode ? mode : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns true when the symbol is on isolated margin. A false result means the
|
|
||||||
* caller should hold off trading — either a switch is under way or it failed.
|
|
||||||
*/
|
|
||||||
async ensureIsolated(): Promise<boolean> {
|
|
||||||
if (!this.deps.enabled) return true;
|
|
||||||
if (this.currentMode() === "isolated") return true;
|
|
||||||
|
|
||||||
const { changeMarginMode, queryAccountSnapshot } = this.deps;
|
|
||||||
if (!changeMarginMode || !queryAccountSnapshot) return false;
|
|
||||||
// Another tick is already switching; do not stack a second request.
|
|
||||||
if (this.ensuring) return false;
|
|
||||||
|
|
||||||
this.ensuring = this.performSwitch(changeMarginMode, queryAccountSnapshot);
|
|
||||||
return await this.ensuring;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async performSwitch(
|
|
||||||
changeMarginMode: NonNullable<IsolatedMarginGuardDeps["changeMarginMode"]>,
|
|
||||||
queryAccountSnapshot: NonNullable<IsolatedMarginGuardDeps["queryAccountSnapshot"]>
|
|
||||||
): Promise<boolean> {
|
|
||||||
const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
||||||
try {
|
|
||||||
await changeMarginMode({ symbol: this.deps.symbol, marginMode: "isolated" });
|
|
||||||
for (let attempt = 0; attempt < MAX_CONFIRM_ATTEMPTS; attempt += 1) {
|
|
||||||
const next = await queryAccountSnapshot();
|
|
||||||
if (next) {
|
|
||||||
this.deps.applySnapshot(next);
|
|
||||||
}
|
|
||||||
if (this.currentMode() === "isolated") {
|
|
||||||
this.deps.log("info", t("log.margin.switched"));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await sleep(CONFIRM_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
this.deps.log("warn", t("log.margin.switchUnconfirmed", { mode: this.currentMode() ?? "unknown" }));
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
this.deps.log("error", t("log.margin.switchFailed", { error: extractMessage(error) }));
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this.ensuring = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import type { ExchangeAdapter, ExchangePrecision } from "../../exchanges/adapter";
|
|
||||||
import { extractMessage } from "../../utils/errors";
|
|
||||||
import { t } from "../../i18n";
|
|
||||||
import type { LogHandler } from "./subscriptions";
|
|
||||||
|
|
||||||
/** Smallest tick/step an engine will accept; guards against a config of 0. */
|
|
||||||
const MIN_INCREMENT = 1e-9;
|
|
||||||
/** Two ticks that differ by less than this are the same tick. */
|
|
||||||
const INCREMENT_EPSILON = 1e-12;
|
|
||||||
const RETRY_DELAY_MS = 2000;
|
|
||||||
|
|
||||||
export interface PrecisionSeed {
|
|
||||||
priceTick: number;
|
|
||||||
qtyStep: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Config slice the syncer writes through to. Engines that read
|
|
||||||
* `config.priceTick` / `config.qtyStep` directly stay correct without change.
|
|
||||||
* `qtyStep` is optional: the maker-family configs carry only a price tick.
|
|
||||||
*/
|
|
||||||
export interface PrecisionConfigTarget {
|
|
||||||
priceTick: number;
|
|
||||||
qtyStep?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrecisionSyncerMessages {
|
|
||||||
synced: (precision: ExchangePrecision) => string;
|
|
||||||
failed: (error: unknown) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches trading precision from the exchange once, retrying until it lands, and
|
|
||||||
* exposes the live values every engine quotes against.
|
|
||||||
*
|
|
||||||
* Owns its retry timer so a stopped engine stops retrying — the eight hand-rolled
|
|
||||||
* copies of this logic leaked one retry loop each.
|
|
||||||
*/
|
|
||||||
export class PrecisionSyncer {
|
|
||||||
private priceTickValue: number;
|
|
||||||
private qtyStepValue: number;
|
|
||||||
private minBaseAmountValue: number | null = null;
|
|
||||||
private minQuoteAmountValue: number | null = null;
|
|
||||||
|
|
||||||
private inFlight: Promise<void> | null = null;
|
|
||||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private stopped = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly exchange: ExchangeAdapter,
|
|
||||||
private readonly config: PrecisionConfigTarget,
|
|
||||||
seed: PrecisionSeed,
|
|
||||||
private readonly log: LogHandler,
|
|
||||||
private readonly messages: PrecisionSyncerMessages
|
|
||||||
) {
|
|
||||||
this.priceTickValue = Math.max(MIN_INCREMENT, seed.priceTick);
|
|
||||||
this.qtyStepValue = Math.max(MIN_INCREMENT, seed.qtyStep);
|
|
||||||
}
|
|
||||||
|
|
||||||
get priceTick(): number {
|
|
||||||
return this.priceTickValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
get qtyStep(): number {
|
|
||||||
return this.qtyStepValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
get minBaseAmount(): number | null {
|
|
||||||
return this.minBaseAmountValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
get minQuoteAmount(): number | null {
|
|
||||||
return this.minQuoteAmountValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Idempotent: a sync already in flight or already completed is not repeated. */
|
|
||||||
start(): void {
|
|
||||||
if (this.stopped || this.inFlight) return;
|
|
||||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
|
||||||
if (!getPrecision) return;
|
|
||||||
|
|
||||||
this.inFlight = getPrecision()
|
|
||||||
.then((precision) => {
|
|
||||||
if (this.stopped || !precision) return;
|
|
||||||
if (this.apply(precision)) {
|
|
||||||
this.log("info", this.messages.synced(precision));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.inFlight = null;
|
|
||||||
if (this.stopped) return;
|
|
||||||
this.log("error", this.messages.failed(extractMessage(error)));
|
|
||||||
this.retryTimer = setTimeout(() => {
|
|
||||||
this.retryTimer = null;
|
|
||||||
this.start();
|
|
||||||
}, RETRY_DELAY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discards the completed sync so the next start() refetches. */
|
|
||||||
refresh(): void {
|
|
||||||
this.inFlight = null;
|
|
||||||
this.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
this.stopped = true;
|
|
||||||
if (this.retryTimer) {
|
|
||||||
clearTimeout(this.retryTimer);
|
|
||||||
this.retryTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns whether either increment actually moved. */
|
|
||||||
private apply(precision: ExchangePrecision): boolean {
|
|
||||||
let changed = false;
|
|
||||||
if (isUsableIncrement(precision.priceTick) && differs(precision.priceTick, this.priceTickValue)) {
|
|
||||||
this.priceTickValue = precision.priceTick;
|
|
||||||
this.config.priceTick = precision.priceTick;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (isUsableIncrement(precision.qtyStep) && differs(precision.qtyStep, this.qtyStepValue)) {
|
|
||||||
this.qtyStepValue = precision.qtyStep;
|
|
||||||
this.config.qtyStep = precision.qtyStep;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (precision.minBaseAmount != null && Number.isFinite(precision.minBaseAmount)) {
|
|
||||||
this.minBaseAmountValue = precision.minBaseAmount;
|
|
||||||
}
|
|
||||||
if (precision.minQuoteAmount != null && Number.isFinite(precision.minQuoteAmount)) {
|
|
||||||
this.minQuoteAmountValue = precision.minQuoteAmount;
|
|
||||||
}
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every engine reports precision sync with the same wording, so they share one
|
|
||||||
* syncer built from `log.common.precision*`.
|
|
||||||
*
|
|
||||||
* @param seedQtyStep step used until the exchange reports one. Maker-family engines
|
|
||||||
* pass a fixed default; config-driven engines pass `config.qtyStep`.
|
|
||||||
*/
|
|
||||||
export function createPrecisionSyncer(
|
|
||||||
exchange: ExchangeAdapter,
|
|
||||||
config: PrecisionConfigTarget,
|
|
||||||
seedQtyStep: number,
|
|
||||||
log: LogHandler
|
|
||||||
): PrecisionSyncer {
|
|
||||||
return new PrecisionSyncer(
|
|
||||||
exchange,
|
|
||||||
config,
|
|
||||||
{ priceTick: config.priceTick, qtyStep: seedQtyStep },
|
|
||||||
log,
|
|
||||||
{
|
|
||||||
synced: (precision) =>
|
|
||||||
t("log.common.precisionSynced", {
|
|
||||||
priceTick: precision.priceTick,
|
|
||||||
qtyStep: precision.qtyStep,
|
|
||||||
}),
|
|
||||||
failed: (error) => t("log.common.precisionFailed", { error: String(error) }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUsableIncrement(value: number | undefined): value is number {
|
|
||||||
return value != null && Number.isFinite(value) && value > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function differs(next: number, current: number): boolean {
|
|
||||||
return Math.abs(next - current) > INCREMENT_EPSILON;
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { extractMessage, isUnknownOrderError } from "../../utils/errors";
|
|
||||||
import {
|
|
||||||
checkStandxTokenExpiry,
|
|
||||||
formatTokenExpiryMessage,
|
|
||||||
isTokenExpiryConfigured,
|
|
||||||
type TokenExpiryState,
|
|
||||||
type TokenExpiryStatus,
|
|
||||||
} from "../../utils/standx-token-expiry";
|
|
||||||
import { t } from "../../i18n";
|
|
||||||
import type { LogHandler } from "./subscriptions";
|
|
||||||
|
|
||||||
/** What the engine should do for the rest of this tick. */
|
|
||||||
export type TokenExpiryDecision =
|
|
||||||
/** Token is live, or expired but a position still needs managing — keep ticking. */
|
|
||||||
| { halt: false; closeOnly: boolean }
|
|
||||||
/** Expired with nothing left to manage — skip the rest of the tick. */
|
|
||||||
| { halt: true; closeOnly: boolean };
|
|
||||||
|
|
||||||
export interface TokenExpiryGuardDeps {
|
|
||||||
log: LogHandler;
|
|
||||||
notify: (notification: {
|
|
||||||
hasPosition: boolean;
|
|
||||||
hasOpenOrders: boolean;
|
|
||||||
state: TokenExpiryState;
|
|
||||||
}) => void;
|
|
||||||
/** Cancels every resting order; resolves once the venue has accepted. */
|
|
||||||
cancelAllOrders: () => Promise<void>;
|
|
||||||
/** Called after a successful cancel so the engine can drop its local copy. */
|
|
||||||
onOrdersCancelled: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the StandX token-expiry episode.
|
|
||||||
*
|
|
||||||
* The venue's token expires on a wall clock, and each consequence — the warning
|
|
||||||
* log, the alert, the cancel-everything sweep — must happen exactly once per
|
|
||||||
* episode and reset when a fresh token arrives. That is five latches whose only
|
|
||||||
* correctness property is that they move together, so they live in one class
|
|
||||||
* rather than loose beside forty other engine fields.
|
|
||||||
*/
|
|
||||||
export class TokenExpiryGuard {
|
|
||||||
private state: TokenExpiryState = "active";
|
|
||||||
private logged = false;
|
|
||||||
private notified = false;
|
|
||||||
private cancelDone = false;
|
|
||||||
private closeOnly = false;
|
|
||||||
|
|
||||||
constructor(private readonly deps: TokenExpiryGuardDeps) {}
|
|
||||||
|
|
||||||
/** True once expiry has forced the engine into reduce-only quoting. */
|
|
||||||
get closeOnlyMode(): boolean {
|
|
||||||
return this.closeOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
get currentState(): TokenExpiryState {
|
|
||||||
return this.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
async evaluate(params: { positionAmt: number; openOrderCount: number }): Promise<TokenExpiryDecision> {
|
|
||||||
if (!isTokenExpiryConfigured()) {
|
|
||||||
return { halt: false, closeOnly: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = checkStandxTokenExpiry(params);
|
|
||||||
if (!status.expired) {
|
|
||||||
this.reset();
|
|
||||||
return { halt: false, closeOnly: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousState = this.state;
|
|
||||||
this.state = status.state;
|
|
||||||
|
|
||||||
this.logOnce(status);
|
|
||||||
this.notifyOnce(status);
|
|
||||||
await this.cancelOnce(params.openOrderCount);
|
|
||||||
|
|
||||||
if (status.state === "expired_with_position") {
|
|
||||||
if (!this.closeOnly) {
|
|
||||||
this.closeOnly = true;
|
|
||||||
this.deps.log("info", t("log.token.closeOnlyForced"));
|
|
||||||
}
|
|
||||||
return { halt: false, closeOnly: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.state === "silent" && previousState !== "silent") {
|
|
||||||
this.deps.log("info", t("log.token.silentEntered"));
|
|
||||||
}
|
|
||||||
return { halt: true, closeOnly: this.closeOnly };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A fresh token clears every latch so the next episode reports itself again. */
|
|
||||||
private reset(): void {
|
|
||||||
if (this.state === "active") return;
|
|
||||||
this.state = "active";
|
|
||||||
this.logged = false;
|
|
||||||
this.notified = false;
|
|
||||||
this.cancelDone = false;
|
|
||||||
this.closeOnly = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private logOnce(status: TokenExpiryStatus): void {
|
|
||||||
if (this.logged) return;
|
|
||||||
const message = formatTokenExpiryMessage(status);
|
|
||||||
if (message) {
|
|
||||||
this.deps.log("warn", message);
|
|
||||||
}
|
|
||||||
this.logged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private notifyOnce(status: TokenExpiryStatus): void {
|
|
||||||
if (this.notified) return;
|
|
||||||
this.deps.notify({
|
|
||||||
hasPosition: status.hasPosition,
|
|
||||||
hasOpenOrders: status.hasOpenOrders,
|
|
||||||
state: status.state,
|
|
||||||
});
|
|
||||||
this.notified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async cancelOnce(openOrderCount: number): Promise<void> {
|
|
||||||
if (this.cancelDone || openOrderCount === 0) return;
|
|
||||||
try {
|
|
||||||
await this.deps.cancelAllOrders();
|
|
||||||
this.deps.log("order", t("log.token.ordersCancelled"));
|
|
||||||
this.deps.onOrdersCancelled();
|
|
||||||
this.cancelDone = true;
|
|
||||||
} catch (error) {
|
|
||||||
if (isUnknownOrderError(error)) {
|
|
||||||
// Nothing left to cancel is the outcome we wanted.
|
|
||||||
this.deps.log("order", t("log.token.cancelOrderMissing"));
|
|
||||||
this.cancelDone = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Leave cancelDone false so the next tick retries.
|
|
||||||
this.deps.log("error", t("log.token.cancelFailed", { error: extractMessage(error) }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+129
-113
@@ -10,14 +10,11 @@ import {
|
|||||||
placeOrder,
|
placeOrder,
|
||||||
placeStopLossOrder,
|
placeStopLossOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
type OrderContext,
|
|
||||||
type OrderLockMap,
|
type OrderLockMap,
|
||||||
type OrderPendingMap,
|
type OrderPendingMap,
|
||||||
type OrderTimerMap,
|
type OrderTimerMap,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { clearGridState, loadGridState, saveGridState } from "./common/grid-storage";
|
import { clearGridState, loadGridState, saveGridState } from "./common/grid-storage";
|
||||||
import {
|
import {
|
||||||
@@ -173,7 +170,7 @@ export class GridEngine {
|
|||||||
private savePending = false;
|
private savePending = false;
|
||||||
private uncoveredQty = 0;
|
private uncoveredQty = 0;
|
||||||
private desiredOrders: DesiredGridOrder[] = [];
|
private desiredOrders: DesiredGridOrder[] = [];
|
||||||
private readonly precision: PrecisionSyncer;
|
private precisionSync: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: GridConfig,
|
private readonly config: GridConfig,
|
||||||
@@ -187,28 +184,14 @@ export class GridEngine {
|
|||||||
this.configValid = this.validateConfig();
|
this.configValid = this.validateConfig();
|
||||||
this.running = this.configValid;
|
this.running = this.configValid;
|
||||||
if (!this.configValid) {
|
if (!this.configValid) {
|
||||||
this.stopReason = t("log.gridEngine.configInvalid");
|
this.stopReason = "配置无效,已暂停网格";
|
||||||
this.log("error", this.stopReason);
|
this.log("error", this.stopReason);
|
||||||
}
|
}
|
||||||
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, this.log);
|
this.syncPrecision();
|
||||||
this.precision.start();
|
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
this.setupConnectionProtection();
|
this.setupConnectionProtection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
|
||||||
private get orderContext(): OrderContext {
|
|
||||||
return (this.orderContextCache ??= {
|
|
||||||
adapter: this.exchange,
|
|
||||||
symbol: this.config.symbol,
|
|
||||||
locks: this.locks,
|
|
||||||
timers: this.timers,
|
|
||||||
pendings: this.pendings,
|
|
||||||
log: (type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private orderContextCache: OrderContext | null = null;
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer || !this.running) {
|
if (this.timer || !this.running) {
|
||||||
if (!this.timer && !this.running) {
|
if (!this.timer && !this.running) {
|
||||||
@@ -226,7 +209,6 @@ export class GridEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
this.precision.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: GridEvent, listener: GridListener): void {
|
on(event: GridEvent, listener: GridListener): void {
|
||||||
@@ -295,6 +277,37 @@ export class GridEngine {
|
|||||||
// Precision sync
|
// Precision sync
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private syncPrecision(): void {
|
||||||
|
if (this.precisionSync) return;
|
||||||
|
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||||
|
if (!getPrecision) return;
|
||||||
|
this.precisionSync = getPrecision()
|
||||||
|
.then((precision) => {
|
||||||
|
if (!precision) return;
|
||||||
|
let updated = false;
|
||||||
|
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||||
|
if (Math.abs(precision.priceTick - this.config.priceTick) > 1e-12) {
|
||||||
|
this.config.priceTick = precision.priceTick;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||||
|
if (Math.abs(precision.qtyStep - this.config.qtyStep) > 1e-12) {
|
||||||
|
this.config.qtyStep = precision.qtyStep;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
this.log("info", `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.log("error", `同步精度失败: ${extractMessage(error)}`);
|
||||||
|
this.precisionSync = null;
|
||||||
|
setTimeout(() => this.syncPrecision(), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Feed subscriptions / connection events
|
// Feed subscriptions / connection events
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -307,15 +320,15 @@ export class GridEngine {
|
|||||||
this.accountVersion += 1;
|
this.accountVersion += 1;
|
||||||
if (!this.feedArrived.account) {
|
if (!this.feedArrived.account) {
|
||||||
this.feedArrived.account = true;
|
this.feedArrived.account = true;
|
||||||
this.log("info", t("log.account.snapshotSynced"));
|
this.log("info", "账户快照已同步");
|
||||||
}
|
}
|
||||||
this.feedStatus.account = true;
|
this.feedStatus.account = true;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
},
|
},
|
||||||
this.log,
|
this.log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.accountFail", { error: extractMessage(error) }),
|
subscribeFail: (error) => `订阅账户失败: ${extractMessage(error)}`,
|
||||||
processFail: (error) => t("log.process.accountError", { error: extractMessage(error) }),
|
processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -330,7 +343,7 @@ export class GridEngine {
|
|||||||
this.ordersFeedLastAt = this.now();
|
this.ordersFeedLastAt = this.now();
|
||||||
if (!this.feedArrived.orders) {
|
if (!this.feedArrived.orders) {
|
||||||
this.feedArrived.orders = true;
|
this.feedArrived.orders = true;
|
||||||
this.log("info", t("log.order.snapshotReturned"));
|
this.log("info", "订单快照已同步");
|
||||||
}
|
}
|
||||||
this.feedStatus.orders = true;
|
this.feedStatus.orders = true;
|
||||||
void this.attemptInit();
|
void this.attemptInit();
|
||||||
@@ -338,8 +351,8 @@ export class GridEngine {
|
|||||||
},
|
},
|
||||||
this.log,
|
this.log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.orderFail", { error: extractMessage(error) }),
|
subscribeFail: (error) => `订阅订单失败: ${extractMessage(error)}`,
|
||||||
processFail: (error) => t("log.process.orderError", { error: extractMessage(error) }),
|
processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -349,14 +362,14 @@ export class GridEngine {
|
|||||||
this.depthSnapshot = depth;
|
this.depthSnapshot = depth;
|
||||||
if (!this.feedArrived.depth) {
|
if (!this.feedArrived.depth) {
|
||||||
this.feedArrived.depth = true;
|
this.feedArrived.depth = true;
|
||||||
this.log("info", t("log.depth.ready"));
|
this.log("info", "盘口深度已同步");
|
||||||
}
|
}
|
||||||
this.feedStatus.depth = true;
|
this.feedStatus.depth = true;
|
||||||
},
|
},
|
||||||
this.log,
|
this.log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.depthFail", { error: extractMessage(error) }),
|
subscribeFail: (error) => `订阅深度失败: ${extractMessage(error)}`,
|
||||||
processFail: (error) => t("log.process.depthError", { error: extractMessage(error) }),
|
processFail: (error) => `深度推送处理异常: ${extractMessage(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -367,7 +380,7 @@ export class GridEngine {
|
|||||||
this.tickerLastAt = this.now();
|
this.tickerLastAt = this.now();
|
||||||
if (!this.feedArrived.ticker) {
|
if (!this.feedArrived.ticker) {
|
||||||
this.feedArrived.ticker = true;
|
this.feedArrived.ticker = true;
|
||||||
this.log("info", t("log.ticker.ready"));
|
this.log("info", "行情推送已同步");
|
||||||
}
|
}
|
||||||
this.feedStatus.ticker = true;
|
this.feedStatus.ticker = true;
|
||||||
void this.attemptInit();
|
void this.attemptInit();
|
||||||
@@ -375,8 +388,8 @@ export class GridEngine {
|
|||||||
},
|
},
|
||||||
this.log,
|
this.log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: extractMessage(error) }),
|
subscribeFail: (error) => `订阅行情失败: ${extractMessage(error)}`,
|
||||||
processFail: (error) => t("log.process.tickerError", { error: extractMessage(error) }),
|
processFail: (error) => `行情推送处理异常: ${extractMessage(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -386,11 +399,11 @@ export class GridEngine {
|
|||||||
this.exchange.onConnectionEvent((event, symbol) => {
|
this.exchange.onConnectionEvent((event, symbol) => {
|
||||||
if (event === "disconnected") {
|
if (event === "disconnected") {
|
||||||
this.frozen = true;
|
this.frozen = true;
|
||||||
this.log("warn", t("log.gridEngine.wsDisconnected", { symbol }));
|
this.log("warn", `WebSocket 断连 (${symbol}),冻结网格下单`);
|
||||||
} else if (event === "reconnected") {
|
} else if (event === "reconnected") {
|
||||||
this.frozen = false;
|
this.frozen = false;
|
||||||
this.restReconcilePending = true;
|
this.restReconcilePending = true;
|
||||||
this.log("info", t("log.gridEngine.wsReconnected", { symbol }));
|
this.log("info", `WebSocket 重连成功 (${symbol}),下一轮执行对账`);
|
||||||
}
|
}
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
});
|
});
|
||||||
@@ -483,7 +496,7 @@ export class GridEngine {
|
|||||||
try {
|
try {
|
||||||
stored = await loadGridState(this.config.symbol);
|
stored = await loadGridState(this.config.symbol);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("error", t("log.gridEngine.loadStateFailed", { error: extractMessage(err) }));
|
this.log("error", `加载网格状态失败: ${extractMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const meta = this.stateMeta();
|
const meta = this.stateMeta();
|
||||||
@@ -491,27 +504,20 @@ export class GridEngine {
|
|||||||
this.state = fromStored(stored, this.logicSettings(), price);
|
this.state = fromStored(stored, this.logicSettings(), price);
|
||||||
this.log(
|
this.log(
|
||||||
"info",
|
"info",
|
||||||
t("log.gridEngine.stateRestored", {
|
`已从磁盘恢复网格状态: gridVersion=${this.state.gridVersion} anchor=${this.state.anchorPrice} ` +
|
||||||
gridVersion: this.state.gridVersion,
|
`区间=[${this.state.lowerPrice}, ${this.state.upperPrice}]${this.state.shift ? ` 移格续跑(${this.state.shift.phase})` : ""}`
|
||||||
anchor: this.state.anchorPrice,
|
|
||||||
lower: this.state.lowerPrice,
|
|
||||||
upper: this.state.upperPrice,
|
|
||||||
shift: this.state.shift
|
|
||||||
? t("log.gridEngine.stateRestoredShift", { phase: this.state.shift.phase })
|
|
||||||
: "",
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (stored) {
|
if (stored) {
|
||||||
this.log("warn", t("log.gridEngine.fingerprintMismatch"));
|
this.log("warn", "磁盘网格状态与当前配置指纹不一致,全新建格并执行孤儿扫描");
|
||||||
}
|
}
|
||||||
this.state = createInitialState(this.logicSettings(), price);
|
this.state = createInitialState(this.logicSettings(), price);
|
||||||
this.log("info", t("log.gridEngine.gridCreated", { anchor: this.state.anchorPrice, mode: this.tradeMode }));
|
this.log("info", `以锚定价 ${this.state.anchorPrice} 建立网格 (${this.tradeMode})`);
|
||||||
}
|
}
|
||||||
await this.applyReconcile(this.openOrders, "startup");
|
await this.applyReconcile(this.openOrders, "startup");
|
||||||
this.initDone = true;
|
this.initDone = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("error", t("log.gridEngine.initFailed", { error: extractMessage(err) }));
|
this.log("error", `网格初始化失败: ${extractMessage(err)}`);
|
||||||
this.initStarted = false;
|
this.initStarted = false;
|
||||||
}
|
}
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
@@ -531,7 +537,7 @@ export class GridEngine {
|
|||||||
now: this.now(),
|
now: this.now(),
|
||||||
});
|
});
|
||||||
for (const event of result.events) {
|
for (const event of result.events) {
|
||||||
this.log("info", t("log.gridEngine.reconcileEvent", { source, event }));
|
this.log("info", `[对账:${source}] ${event}`);
|
||||||
}
|
}
|
||||||
if (result.cancelOrderIds.length > 0) {
|
if (result.cancelOrderIds.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -539,10 +545,10 @@ export class GridEngine {
|
|||||||
symbol: this.config.symbol,
|
symbol: this.config.symbol,
|
||||||
orderIdList: result.cancelOrderIds,
|
orderIdList: result.cancelOrderIds,
|
||||||
});
|
});
|
||||||
this.log("order", t("log.gridEngine.reconcileCancelled", { source, count: result.cancelOrderIds.length }));
|
this.log("order", `[对账:${source}] 撤销 ${result.cancelOrderIds.length} 个无法归属的挂单`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isUnknownOrderError(err)) {
|
if (!isUnknownOrderError(err)) {
|
||||||
this.log("error", t("log.gridEngine.reconcileCancelFailed", { source, error: extractMessage(err) }));
|
this.log("error", `[对账:${source}] 撤单失败: ${extractMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,7 +564,7 @@ export class GridEngine {
|
|||||||
const fetched = await this.exchange.queryOpenOrders();
|
const fetched = await this.exchange.queryOpenOrders();
|
||||||
orders = fetched.filter((order) => order.symbol === this.config.symbol);
|
orders = fetched.filter((order) => order.symbol === this.config.symbol);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("error", t("log.gridEngine.reconcileOrdersFailed", { source, error: extractMessage(err) }));
|
this.log("error", `[对账:${source}] REST 查询挂单失败: ${extractMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.exchange.queryAccountSnapshot) {
|
if (this.exchange.queryAccountSnapshot) {
|
||||||
@@ -569,7 +575,7 @@ export class GridEngine {
|
|||||||
this.accountVersion += 1;
|
this.accountVersion += 1;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("error", t("log.gridEngine.reconcileAccountFailed", { source, error: extractMessage(err) }));
|
this.log("error", `[对账:${source}] REST 查询账户失败: ${extractMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (orders) {
|
if (orders) {
|
||||||
@@ -653,7 +659,7 @@ export class GridEngine {
|
|||||||
this.schedulePersist();
|
this.schedulePersist();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log("error", t("log.gridEngine.tickFailed", { error: extractMessage(error) }));
|
this.log("error", `网格轮询异常: ${extractMessage(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
@@ -669,7 +675,7 @@ export class GridEngine {
|
|||||||
}
|
}
|
||||||
if (action.kind === "BEGIN_SHIFT") {
|
if (action.kind === "BEGIN_SHIFT") {
|
||||||
// 移格标记已由 planTick 写入 state,落盘后由下个 tick 开始执行
|
// 移格标记已由 planTick 写入 state,落盘后由下个 tick 开始执行
|
||||||
this.log("warn", t("log.gridEngine.shiftStarting", { anchor: action.targetAnchor }));
|
this.log("warn", `启动智能移格,目标锚定价 ${action.targetAnchor}`);
|
||||||
await this.persistNow();
|
await this.persistNow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -700,7 +706,7 @@ export class GridEngine {
|
|||||||
) {
|
) {
|
||||||
if (now - this.lastStalenessLogAt > 30_000) {
|
if (now - this.lastStalenessLogAt > 30_000) {
|
||||||
this.lastStalenessLogAt = now;
|
this.lastStalenessLogAt = now;
|
||||||
this.log("warn", t("log.gridEngine.orderFeedStalled"));
|
this.log("warn", "订单流疑似停滞(下单后长时间未反映),暂停新下单");
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -738,18 +744,28 @@ export class GridEngine {
|
|||||||
const ordersVersionBeforePlace = this.ordersVersion;
|
const ordersVersionBeforePlace = this.ordersVersion;
|
||||||
try {
|
try {
|
||||||
this.lastLimitAttemptAt = now;
|
this.lastLimitAttemptAt = now;
|
||||||
placed = await placeOrder(this.orderContext, {
|
placed = await placeOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: action.side,
|
this.config.symbol,
|
||||||
price: action.price,
|
this.openOrders,
|
||||||
amount: action.qty,
|
this.locks,
|
||||||
reduceOnly: isEntry ? false : this.config.useReduceOnlyForExit,
|
this.timers,
|
||||||
qtyStep: this.config.qtyStep,
|
this.pendings,
|
||||||
skipDedupe: true,
|
action.side,
|
||||||
clientOrderId,
|
action.price,
|
||||||
});
|
action.qty,
|
||||||
|
this.log,
|
||||||
|
isEntry ? false : this.config.useReduceOnlyForExit,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
priceTick: this.config.priceTick,
|
||||||
|
qtyStep: this.config.qtyStep,
|
||||||
|
skipDedupe: true,
|
||||||
|
clientOrderId,
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log("error", t("log.gridEngine.placeFailed", { side: action.side, price: action.price, error: extractMessage(error) }));
|
this.log("error", `挂单失败 (${action.side} @ ${action.price}): ${extractMessage(error)}`);
|
||||||
}
|
}
|
||||||
state.inflight = null;
|
state.inflight = null;
|
||||||
|
|
||||||
@@ -807,33 +823,33 @@ export class GridEngine {
|
|||||||
if (pctDiff > limitPct) {
|
if (pctDiff > limitPct) {
|
||||||
this.log(
|
this.log(
|
||||||
"warn",
|
"warn",
|
||||||
t("log.gridEngine.closeSlippageBlocked", {
|
`市价平仓滑点守卫触发 (${reason}): close=${closeSidePrice} mark=${mark} 偏离 ${(pctDiff * 100).toFixed(2)}% > ${(limitPct * 100).toFixed(2)}%,暂缓`
|
||||||
reason,
|
|
||||||
close: closeSidePrice,
|
|
||||||
mark,
|
|
||||||
pct: (pctDiff * 100).toFixed(2),
|
|
||||||
limit: (limitPct * 100).toFixed(2),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
quantity: qty,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pendings,
|
||||||
|
side,
|
||||||
|
qty,
|
||||||
|
this.log,
|
||||||
|
{
|
||||||
markPrice: mark,
|
markPrice: mark,
|
||||||
expectedPrice: Number.isFinite(closeSidePrice) ? closeSidePrice : null,
|
expectedPrice: Number.isFinite(closeSidePrice) ? closeSidePrice : null,
|
||||||
maxPct: limitPct > 0 ? limitPct : undefined,
|
maxPct: limitPct > 0 ? limitPct : undefined,
|
||||||
},
|
},
|
||||||
qtyStep: this.config.qtyStep
|
{ qtyStep: this.config.qtyStep }
|
||||||
});
|
);
|
||||||
this.log("close", t("log.gridEngine.closed", { side, qty, reason }));
|
this.log("close", `市价平仓 ${side} ${qty} (${reason})`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log("error", t("log.gridEngine.closeFailed", { reason, error: extractMessage(error) }));
|
this.log("error", `市价平仓失败 (${reason}): ${extractMessage(error)}`);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
unlockOperating(this.locks, this.timers, this.pendings, "MARKET");
|
unlockOperating(this.locks, this.timers, this.pendings, "MARKET");
|
||||||
@@ -857,9 +873,9 @@ export class GridEngine {
|
|||||||
try {
|
try {
|
||||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||||||
state.exchangeStop = null;
|
state.exchangeStop = null;
|
||||||
this.log("order", t("log.gridEngine.shiftCancelRequested"));
|
this.log("order", "移格: 已请求撤销全部挂单");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("error", t("log.gridEngine.shiftCancelFailed", { error: extractMessage(err) }));
|
this.log("error", `移格撤单失败: ${extractMessage(err)}`);
|
||||||
}
|
}
|
||||||
} else if (step.kind === "CLOSE_POSITION") {
|
} else if (step.kind === "CLOSE_POSITION") {
|
||||||
// 平仓单已提交但仓位回报未到时不重复提交
|
// 平仓单已提交但仓位回报未到时不重复提交
|
||||||
@@ -867,24 +883,19 @@ export class GridEngine {
|
|||||||
this.shiftCloseAccountVersion === this.accountVersion &&
|
this.shiftCloseAccountVersion === this.accountVersion &&
|
||||||
this.now() - this.shiftCloseAt < 10_000;
|
this.now() - this.shiftCloseAt < 10_000;
|
||||||
if (!awaitingFill) {
|
if (!awaitingFill) {
|
||||||
const done = await this.guardedMarketClose(step.side, step.qty, t("log.gridEngine.shiftCloseReason"));
|
const done = await this.guardedMarketClose(step.side, step.qty, "移格平仓");
|
||||||
if (done) {
|
if (done) {
|
||||||
this.shiftCloseAccountVersion = this.accountVersion;
|
this.shiftCloseAccountVersion = this.accountVersion;
|
||||||
this.shiftCloseAt = this.now();
|
this.shiftCloseAt = this.now();
|
||||||
} else {
|
} else {
|
||||||
this.log("info", t("log.gridEngine.shiftCloseDeferred"));
|
this.log("info", "移格: 平仓被滑点守卫暂缓,下轮重试");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (step.kind === "REBUILD") {
|
} else if (step.kind === "REBUILD") {
|
||||||
applyRebuild(state, this.logicSettings(), step.anchor);
|
applyRebuild(state, this.logicSettings(), step.anchor);
|
||||||
this.log(
|
this.log(
|
||||||
"info",
|
"info",
|
||||||
t("log.gridEngine.shiftDone", {
|
`移格完成: 新锚定价 ${step.anchor},区间 [${state.lowerPrice.toFixed(4)}, ${state.upperPrice.toFixed(4)}],gridVersion=${state.gridVersion}`
|
||||||
anchor: step.anchor,
|
|
||||||
lower: state.lowerPrice.toFixed(4),
|
|
||||||
upper: state.upperPrice.toFixed(4),
|
|
||||||
gridVersion: state.gridVersion,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.lastUpdated = this.now();
|
this.lastUpdated = this.now();
|
||||||
@@ -924,10 +935,10 @@ export class GridEngine {
|
|||||||
if (live) {
|
if (live) {
|
||||||
try {
|
try {
|
||||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId });
|
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId });
|
||||||
this.log("order", t("log.gridEngine.stopCancelledFlat"));
|
this.log("order", "已撤销交易所兜底止损单(仓位归零)");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isUnknownOrderError(err)) {
|
if (!isUnknownOrderError(err)) {
|
||||||
this.log("error", t("log.gridEngine.stopCancelFailed", { error: extractMessage(err) }));
|
this.log("error", `撤销兜底止损单失败: ${extractMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -951,7 +962,7 @@ export class GridEngine {
|
|||||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId });
|
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isUnknownOrderError(err)) {
|
if (!isUnknownOrderError(err)) {
|
||||||
this.log("error", t("log.gridEngine.stopCancelStaleFailed", { error: extractMessage(err) }));
|
this.log("error", `撤销旧兜底止损单失败: ${extractMessage(err)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -960,16 +971,21 @@ export class GridEngine {
|
|||||||
|
|
||||||
const lastPrice = Number(this.tickerSnapshot?.lastPrice);
|
const lastPrice = Number(this.tickerSnapshot?.lastPrice);
|
||||||
try {
|
try {
|
||||||
const placed = await placeStopLossOrder(this.orderContext, {
|
const placed = await placeStopLossOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: desired.side,
|
this.config.symbol,
|
||||||
stopPrice: desired.stopPrice,
|
this.openOrders,
|
||||||
quantity: Math.abs(this.position.positionAmt),
|
this.locks,
|
||||||
lastPrice: Number.isFinite(lastPrice) ? lastPrice : price,
|
this.timers,
|
||||||
guard: undefined,
|
this.pendings,
|
||||||
priceTick: this.config.priceTick,
|
desired.side,
|
||||||
qtyStep: this.config.qtyStep
|
desired.stopPrice,
|
||||||
});
|
Math.abs(this.position.positionAmt),
|
||||||
|
Number.isFinite(lastPrice) ? lastPrice : price,
|
||||||
|
this.log,
|
||||||
|
undefined,
|
||||||
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
|
);
|
||||||
if (placed?.orderId != null) {
|
if (placed?.orderId != null) {
|
||||||
state.exchangeStop = {
|
state.exchangeStop = {
|
||||||
orderId: String(placed.orderId),
|
orderId: String(placed.orderId),
|
||||||
@@ -980,7 +996,7 @@ export class GridEngine {
|
|||||||
this.schedulePersist();
|
this.schedulePersist();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("error", t("log.gridEngine.stopPlaceFailed", { error: extractMessage(err) }));
|
this.log("error", `挂兜底止损单失败: ${extractMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,12 +1007,12 @@ export class GridEngine {
|
|||||||
private async haltGrid(reason: string): Promise<void> {
|
private async haltGrid(reason: string): Promise<void> {
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
this.stopReason = reason;
|
this.stopReason = reason;
|
||||||
this.log("warn", t("log.gridEngine.haltStarting", { reason }));
|
this.log("warn", `${reason},开始执行撤单与平仓`);
|
||||||
try {
|
try {
|
||||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||||||
this.log("order", t("log.gridEngine.allCancelled"));
|
this.log("order", "已撤销全部网格挂单");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log("error", t("log.gridEngine.cancelAllFailed", { error: extractMessage(error) }));
|
this.log("error", `撤销网格挂单失败: ${extractMessage(error)}`);
|
||||||
}
|
}
|
||||||
if (state) state.exchangeStop = null;
|
if (state) state.exchangeStop = null;
|
||||||
const qty = this.position.positionAmt;
|
const qty = this.position.positionAmt;
|
||||||
@@ -1004,7 +1020,7 @@ export class GridEngine {
|
|||||||
const closed = await this.guardedMarketClose(qty > 0 ? "SELL" : "BUY", Math.abs(qty), reason);
|
const closed = await this.guardedMarketClose(qty > 0 ? "SELL" : "BUY", Math.abs(qty), reason);
|
||||||
if (!closed) {
|
if (!closed) {
|
||||||
// 滑点守卫暂缓:保持 running,下个 tick 重新触发层①重试
|
// 滑点守卫暂缓:保持 running,下个 tick 重新触发层①重试
|
||||||
this.log("warn", t("log.gridEngine.stopCloseDeferred"));
|
this.log("warn", "止损平仓被滑点守卫暂缓,下轮重试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1057,7 +1073,7 @@ export class GridEngine {
|
|||||||
this.running = true;
|
this.running = true;
|
||||||
this.stopReason = null;
|
this.stopReason = null;
|
||||||
this.initDone = true;
|
this.initDone = true;
|
||||||
this.log("info", t("log.gridEngine.resumed", { gridVersion: nextVersion }));
|
this.log("info", `价格重新回到网格区间,恢复网格运行 (gridVersion=${nextVersion})`);
|
||||||
await this.persistNow();
|
await this.persistNow();
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
@@ -1083,7 +1099,7 @@ export class GridEngine {
|
|||||||
try {
|
try {
|
||||||
await saveGridState(toStored(state, this.stateMeta(), this.now()));
|
await saveGridState(toStored(state, this.stateMeta(), this.now()));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("error", t("log.gridEngine.saveStateFailed", { error: extractMessage(err) }));
|
this.log("error", `保存网格状态失败: ${extractMessage(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { t } from "../i18n";
|
|
||||||
import {
|
import {
|
||||||
ORPHAN_LEVEL,
|
ORPHAN_LEVEL,
|
||||||
applyRebuild,
|
applyRebuild,
|
||||||
@@ -553,12 +552,8 @@ describe("resolveAwaiting", () => {
|
|||||||
describe("checkPriceStop", () => {
|
describe("checkPriceStop", () => {
|
||||||
it("triggers below lower and above upper thresholds", () => {
|
it("triggers below lower and above upper thresholds", () => {
|
||||||
const state = createInitialState(settings, 141.4);
|
const state = createInitialState(settings, 141.4);
|
||||||
expect(checkPriceStop(state, settings, 98.9)).toBe(
|
expect(checkPriceStop(state, settings, 98.9)).toContain("跌破");
|
||||||
t("log.grid.belowLowerBound", { pct: "1.10" })
|
expect(checkPriceStop(state, settings, 202.1)).toContain("突破");
|
||||||
);
|
|
||||||
expect(checkPriceStop(state, settings, 202.1)).toBe(
|
|
||||||
t("log.grid.aboveUpperBound", { pct: "1.05" })
|
|
||||||
);
|
|
||||||
expect(checkPriceStop(state, settings, 150)).toBeNull();
|
expect(checkPriceStop(state, settings, 150)).toBeNull();
|
||||||
expect(checkPriceStop(state, settings, 99.5)).toBeNull(); // 1% 容忍内
|
expect(checkPriceStop(state, settings, 99.5)).toBeNull(); // 1% 容忍内
|
||||||
});
|
});
|
||||||
|
|||||||
+18
-24
@@ -1,4 +1,3 @@
|
|||||||
import { t } from "../i18n";
|
|
||||||
// 网格纯逻辑:无 I/O、无 Date.now、无 adapter 引用。所有时间通过参数传入。
|
// 网格纯逻辑:无 I/O、无 Date.now、无 adapter 引用。所有时间通过参数传入。
|
||||||
// 引擎每 tick 调 planTick(state, settings, input) 得到 actions,由引擎负责执行。
|
// 引擎每 tick 调 planTick(state, settings, input) 得到 actions,由引擎负责执行。
|
||||||
|
|
||||||
@@ -418,11 +417,11 @@ function applyFilled(
|
|||||||
level.phase = "holding";
|
level.phase = "holding";
|
||||||
level.holdQty = qty;
|
level.holdQty = qty;
|
||||||
delete level.entryOrderId;
|
delete level.entryOrderId;
|
||||||
events.push(t("log.grid.entryFilled", { side: intent.side, price: intent.price, level: intent.level }));
|
events.push(`ENTRY 成交: ${intent.side} @ ${intent.price} (线 ${intent.level})`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (intent.level === ORPHAN_LEVEL) {
|
if (intent.level === ORPHAN_LEVEL) {
|
||||||
events.push(t("log.grid.orphanExitFilled", { side: intent.side, price: intent.price }));
|
events.push(`孤儿 EXIT 成交: ${intent.side} @ ${intent.price}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const level = state.levels[intent.level];
|
const level = state.levels[intent.level];
|
||||||
@@ -430,7 +429,7 @@ function applyFilled(
|
|||||||
level.phase = "idle";
|
level.phase = "idle";
|
||||||
level.holdQty = 0;
|
level.holdQty = 0;
|
||||||
delete level.exitOrderId;
|
delete level.exitOrderId;
|
||||||
events.push(t("log.grid.exitFilled", { side: intent.side, price: intent.price, level: intent.level }));
|
events.push(`EXIT 成交: ${intent.side} @ ${intent.price} (释放线 ${intent.level})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,7 +441,7 @@ function applyCanceled(state: GridLogicState, intent: OrderIntentRecord, events:
|
|||||||
level.phase = "idle";
|
level.phase = "idle";
|
||||||
delete level.entryOrderId;
|
delete level.entryOrderId;
|
||||||
}
|
}
|
||||||
events.push(t("log.grid.entryCancelled", { side: intent.side, price: intent.price, level: intent.level }));
|
events.push(`ENTRY 撤销: ${intent.side} @ ${intent.price} (线 ${intent.level})`);
|
||||||
} else {
|
} else {
|
||||||
if (intent.level === ORPHAN_LEVEL) return;
|
if (intent.level === ORPHAN_LEVEL) return;
|
||||||
const level = state.levels[intent.level];
|
const level = state.levels[intent.level];
|
||||||
@@ -450,7 +449,7 @@ function applyCanceled(state: GridLogicState, intent: OrderIntentRecord, events:
|
|||||||
level.phase = "holding";
|
level.phase = "holding";
|
||||||
delete level.exitOrderId;
|
delete level.exitOrderId;
|
||||||
}
|
}
|
||||||
events.push(t("log.grid.exitCancelled", { side: intent.side, price: intent.price, level: intent.level }));
|
events.push(`EXIT 撤销: ${intent.side} @ ${intent.price} (线 ${intent.level})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,9 +502,7 @@ export function processOrderSnapshot(
|
|||||||
applyCanceled(state, intent, events);
|
applyCanceled(state, intent, events);
|
||||||
} else {
|
} else {
|
||||||
setAwaiting(state, intent, input);
|
setAwaiting(state, intent, input);
|
||||||
events.push(
|
events.push(`订单消失待判定: ${intent.intent} ${intent.side} @ ${intent.price}`);
|
||||||
t("log.grid.orderVanished", { intent: intent.intent, side: intent.side, price: intent.price })
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
state.intents.delete(id);
|
state.intents.delete(id);
|
||||||
state.seenOrderIds.delete(id);
|
state.seenOrderIds.delete(id);
|
||||||
@@ -672,10 +669,10 @@ export function checkPriceStop(
|
|||||||
const lowerTrigger = state.lowerPrice * (1 - settings.stopLossPct);
|
const lowerTrigger = state.lowerPrice * (1 - settings.stopLossPct);
|
||||||
const upperTrigger = state.upperPrice * (1 + settings.stopLossPct);
|
const upperTrigger = state.upperPrice * (1 + settings.stopLossPct);
|
||||||
if (price <= lowerTrigger) {
|
if (price <= lowerTrigger) {
|
||||||
return t("log.grid.belowLowerBound", { pct: ((1 - price / state.lowerPrice) * 100).toFixed(2) });
|
return `价格跌破网格下边界 ${((1 - price / state.lowerPrice) * 100).toFixed(2)}%`;
|
||||||
}
|
}
|
||||||
if (price >= upperTrigger) {
|
if (price >= upperTrigger) {
|
||||||
return t("log.grid.aboveUpperBound", { pct: ((price / state.upperPrice - 1) * 100).toFixed(2) });
|
return `价格突破网格上边界 ${((price / state.upperPrice - 1) * 100).toFixed(2)}%`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -734,20 +731,17 @@ export function auditExitCoverage(
|
|||||||
state.uncoveredSince = input.now;
|
state.uncoveredSince = input.now;
|
||||||
if (outOfRange || deepLoss) {
|
if (outOfRange || deepLoss) {
|
||||||
events.push(
|
events.push(
|
||||||
t("log.grid.coverageAuditClose", {
|
`覆盖审计: 未覆盖 ${uncovered.toFixed(6)} 且${outOfRange ? "价格已出区间" : "浮亏超限"},市价平仓`
|
||||||
qty: uncovered.toFixed(6),
|
|
||||||
cause: outOfRange ? t("log.grid.causeOutOfRange") : t("log.grid.causeLossExceeded"),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
uncoveredQty: uncovered,
|
uncoveredQty: uncovered,
|
||||||
action: { kind: "MARKET_CLOSE", side: exitSide, qty: uncovered, reason: t("log.grid.coverageAuditReason") },
|
action: { kind: "MARKET_CLOSE", side: exitSide, qty: uncovered, reason: "覆盖审计止损" },
|
||||||
events,
|
events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// 最近可盈利线补挂孤儿 EXIT
|
// 最近可盈利线补挂孤儿 EXIT
|
||||||
const targetPrice = findNearestProfitableExitPrice(state, pos > 0 ? "long" : "short", entry, input.price);
|
const targetPrice = findNearestProfitableExitPrice(state, pos > 0 ? "long" : "short", entry, input.price);
|
||||||
events.push(t("log.grid.coverageAuditRepost", { qty: uncovered.toFixed(6), price: targetPrice }));
|
events.push(`覆盖审计: 未覆盖 ${uncovered.toFixed(6)},补挂平仓单 @ ${targetPrice}`);
|
||||||
return {
|
return {
|
||||||
uncoveredQty: uncovered,
|
uncoveredQty: uncovered,
|
||||||
action: {
|
action: {
|
||||||
@@ -924,7 +918,7 @@ export function planTick(
|
|||||||
if (settings.shiftEnabled && !state.shift) {
|
if (settings.shiftEnabled && !state.shift) {
|
||||||
beginShift(state, input.price, input.now);
|
beginShift(state, input.price, input.now);
|
||||||
actions.push({ kind: "BEGIN_SHIFT", targetAnchor: input.price });
|
actions.push({ kind: "BEGIN_SHIFT", targetAnchor: input.price });
|
||||||
events.push(t("log.grid.shiftOutOfRange", { reason: stopReason }));
|
events.push(`价格越界,启动移格: ${stopReason}`);
|
||||||
return { actions, events, stateChanged: true, uncoveredQty: 0 };
|
return { actions, events, stateChanged: true, uncoveredQty: 0 };
|
||||||
}
|
}
|
||||||
actions.push({ kind: "HALT", reason: stopReason });
|
actions.push({ kind: "HALT", reason: stopReason });
|
||||||
@@ -935,7 +929,7 @@ export function planTick(
|
|||||||
if (shouldShift(state, settings, input.price, input.now)) {
|
if (shouldShift(state, settings, input.price, input.now)) {
|
||||||
beginShift(state, input.price, input.now);
|
beginShift(state, input.price, input.now);
|
||||||
actions.push({ kind: "BEGIN_SHIFT", targetAnchor: input.price });
|
actions.push({ kind: "BEGIN_SHIFT", targetAnchor: input.price });
|
||||||
events.push(t("log.grid.shiftAnchorDrift", { anchor: state.anchorPrice, price: input.price }));
|
events.push(`价格偏离锚定价超阈值,启动移格 (anchor=${state.anchorPrice} → ${input.price})`);
|
||||||
return { actions, events, stateChanged: true, uncoveredQty: 0 };
|
return { actions, events, stateChanged: true, uncoveredQty: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,11 +1034,11 @@ export function reconcile(
|
|||||||
gridVersion: state.gridVersion,
|
gridVersion: state.gridVersion,
|
||||||
createdAt: input.now,
|
createdAt: input.now,
|
||||||
});
|
});
|
||||||
events.push(t("log.grid.adoptOrphanExit", { side: order.side, price: order.price }));
|
events.push(`收编平仓方向挂单为孤儿 EXIT: ${order.side} @ ${order.price}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cancelOrderIds.push(order.orderId);
|
cancelOrderIds.push(order.orderId);
|
||||||
events.push(t("log.grid.cancelUnattributable", { side: order.side, price: order.price }));
|
events.push(`撤销无法归属的挂单: ${order.side} @ ${order.price}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const order of input.activeOrders) {
|
for (const order of input.activeOrders) {
|
||||||
@@ -1085,7 +1079,7 @@ export function reconcile(
|
|||||||
rec.intent === "ENTRY" ? adoptEntry(order, rec.level, intent) : adoptExit(order, rec.level, intent);
|
rec.intent === "ENTRY" ? adoptEntry(order, rec.level, intent) : adoptExit(order, rec.level, intent);
|
||||||
state.inflight = null;
|
state.inflight = null;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
events.push(t("log.grid.inflightMatched", { intent: rec.intent, side: rec.side, price: rec.price }));
|
events.push(`inflight 归属确认: ${rec.intent} ${rec.side} @ ${rec.price}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fallbackAdopt(order, remaining);
|
fallbackAdopt(order, remaining);
|
||||||
@@ -1096,7 +1090,7 @@ export function reconcile(
|
|||||||
if (parsed) {
|
if (parsed) {
|
||||||
if (parsed.gridVersion != null && parsed.gridVersion !== state.gridVersion) {
|
if (parsed.gridVersion != null && parsed.gridVersion !== state.gridVersion) {
|
||||||
cancelOrderIds.push(order.orderId);
|
cancelOrderIds.push(order.orderId);
|
||||||
events.push(t("log.grid.cancelStaleVersion", { clientOrderId: order.clientOrderId }));
|
events.push(`撤销过期网格版本挂单: ${order.clientOrderId}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const intent: OrderIntentRecord = {
|
const intent: OrderIntentRecord = {
|
||||||
@@ -1237,7 +1231,7 @@ export function reconcile(
|
|||||||
diff = 0;
|
diff = 0;
|
||||||
}
|
}
|
||||||
if (Math.abs(diff) > eps) {
|
if (Math.abs(diff) > eps) {
|
||||||
events.push(t("log.grid.orphanResidual", { qty: diff.toFixed(6) }));
|
events.push(`对账残余孤儿仓位: ${diff.toFixed(6)}`);
|
||||||
// 立即进入层②处置(跳过宽限期)
|
// 立即进入层②处置(跳过宽限期)
|
||||||
state.uncoveredSince = input.now - 86_400_000;
|
state.uncoveredSince = input.now - 86_400_000;
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-61
@@ -8,14 +8,13 @@ import {
|
|||||||
type PositionSnapshot,
|
type PositionSnapshot,
|
||||||
} from "../utils/strategy";
|
} from "../utils/strategy";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import {
|
import {
|
||||||
placeStopLossOrder,
|
placeStopLossOrder,
|
||||||
placeTrailingStopOrder,
|
placeTrailingStopOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||||
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
||||||
import { formatPriceToString } from "../utils/math";
|
import { formatPriceToString } from "../utils/math";
|
||||||
@@ -65,30 +64,14 @@ export class GuardianEngine {
|
|||||||
price: null,
|
price: null,
|
||||||
at: 0,
|
at: 0,
|
||||||
};
|
};
|
||||||
private readonly precision: PrecisionSyncer;
|
private precisionSync: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
this.syncPrecision();
|
||||||
this.tradeLog.push(type, detail)
|
|
||||||
);
|
|
||||||
this.precision.start();
|
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
|
||||||
private get orderContext(): OrderContext {
|
|
||||||
return (this.orderContextCache ??= {
|
|
||||||
adapter: this.exchange,
|
|
||||||
symbol: this.config.symbol,
|
|
||||||
locks: this.locks,
|
|
||||||
timers: this.timers,
|
|
||||||
pendings: this.pending,
|
|
||||||
log: (type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private orderContextCache: OrderContext | null = null;
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -101,7 +84,6 @@ export class GuardianEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
this.precision.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
||||||
@@ -403,19 +385,24 @@ export class GuardianEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await placeStopLossOrder(this.orderContext, {
|
await placeStopLossOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
stopPrice: stopPrice,
|
this.openOrders,
|
||||||
quantity: quantity,
|
this.locks,
|
||||||
lastPrice: lastPrice,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
side,
|
||||||
|
stopPrice,
|
||||||
|
quantity,
|
||||||
|
lastPrice,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
this.lastStopAttempt.side = side;
|
this.lastStopAttempt.side = side;
|
||||||
this.lastStopAttempt.price = stopPrice;
|
this.lastStopAttempt.price = stopPrice;
|
||||||
this.lastStopAttempt.at = now;
|
this.lastStopAttempt.at = now;
|
||||||
@@ -457,19 +444,24 @@ export class GuardianEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const order = await placeStopLossOrder(this.orderContext, {
|
const order = await placeStopLossOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
stopPrice: nextStopPrice,
|
this.openOrders,
|
||||||
quantity: quantity,
|
this.locks,
|
||||||
lastPrice: lastPrice,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
side,
|
||||||
|
nextStopPrice,
|
||||||
|
quantity,
|
||||||
|
lastPrice,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
if (order) {
|
if (order) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
@@ -487,19 +479,24 @@ export class GuardianEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const restored = await placeStopLossOrder(this.orderContext, {
|
const restored = await placeStopLossOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
stopPrice: Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
|
this.openOrders,
|
||||||
quantity: quantity,
|
this.locks,
|
||||||
lastPrice: lastPrice,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
side,
|
||||||
|
Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
|
||||||
|
quantity,
|
||||||
|
lastPrice,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
if (restored && Number.isFinite(existingStopPrice)) {
|
if (restored && Number.isFinite(existingStopPrice)) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
@@ -523,19 +520,24 @@ export class GuardianEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await placeTrailingStopOrder(this.orderContext, {
|
await placeTrailingStopOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
activationPrice: activationPrice,
|
this.openOrders,
|
||||||
quantity: quantity,
|
this.locks,
|
||||||
callbackRate: this.config.trailingCallbackRate,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
side,
|
||||||
|
activationPrice,
|
||||||
|
quantity,
|
||||||
|
this.config.trailingCallbackRate,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) }));
|
this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) }));
|
||||||
}
|
}
|
||||||
@@ -649,4 +651,42 @@ export class GuardianEngine {
|
|||||||
return Math.max(0, Math.min(12, Math.floor(digits)));
|
return Math.max(0, Math.min(12, Math.floor(digits)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncPrecision(): void {
|
||||||
|
if (this.precisionSync) return;
|
||||||
|
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||||
|
if (!getPrecision) return;
|
||||||
|
this.precisionSync = getPrecision()
|
||||||
|
.then((precision) => {
|
||||||
|
if (!precision) return;
|
||||||
|
let updated = false;
|
||||||
|
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||||
|
const delta = Math.abs(precision.priceTick - this.config.priceTick);
|
||||||
|
if (delta > 1e-12) {
|
||||||
|
this.config.priceTick = precision.priceTick;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||||
|
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
|
||||||
|
if (delta > 1e-12) {
|
||||||
|
this.config.qtyStep = precision.qtyStep;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
this.tradeLog.push(
|
||||||
|
"info",
|
||||||
|
t("log.guardian.precisionSynced", {
|
||||||
|
priceTick: precision.priceTick,
|
||||||
|
qtyStep: precision.qtyStep,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.tradeLog.push("error", t("log.guardian.precisionFailed", { error: extractMessage(error) }));
|
||||||
|
this.precisionSync = null;
|
||||||
|
setTimeout(() => this.syncPrecision(), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,14 @@ import {
|
|||||||
placeOrder,
|
placeOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import type { MakerEngineSnapshot } from "./maker-engine";
|
import type { MakerEngineSnapshot } from "./maker-engine";
|
||||||
import { makeOrderPlan } from "../core/lib/order-plan";
|
import { makeOrderPlan } from "../core/lib/order-plan";
|
||||||
import { safeCancelOrder } from "../core/lib/orders";
|
import { safeCancelOrder } from "../core/lib/orders";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
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 { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
side: "BUY" | "SELL";
|
side: "BUY" | "SELL";
|
||||||
@@ -65,8 +63,6 @@ type MakerEvent = "update";
|
|||||||
type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void;
|
type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void;
|
||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
/** Quantity step assumed until the exchange reports its own. */
|
|
||||||
const DEFAULT_QTY_STEP = 0.001;
|
|
||||||
|
|
||||||
export class LiquidityMakerEngine {
|
export class LiquidityMakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -84,7 +80,11 @@ export class LiquidityMakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private readonly precision: PrecisionSyncer;
|
private priceTick: number = 0.1;
|
||||||
|
private qtyStep: number = 0.001;
|
||||||
|
private minBaseAmount: number | null = null;
|
||||||
|
private minQuoteAmount: number | null = null;
|
||||||
|
private precisionSync: Promise<void> | null = null;
|
||||||
private marketType: "perp" | "spot" = "perp";
|
private marketType: "perp" | "spot" = "perp";
|
||||||
private baseAsset: string | null = null;
|
private baseAsset: string | null = null;
|
||||||
private quoteAsset: string | null = null;
|
private quoteAsset: string | null = null;
|
||||||
@@ -140,31 +140,17 @@ export class LiquidityMakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
||||||
this.tradeLog.push(type, detail)
|
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
||||||
);
|
|
||||||
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
||||||
this.baseAsset = parsedSymbols.base ?? null;
|
this.baseAsset = parsedSymbols.base ?? null;
|
||||||
this.quoteAsset = parsedSymbols.quote ?? null;
|
this.quoteAsset = parsedSymbols.quote ?? null;
|
||||||
this.precision.start();
|
this.syncPrecision();
|
||||||
// Debounce window defaults to 3x refresh interval, min 1s
|
// Debounce window defaults to 3x refresh interval, min 1s
|
||||||
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
|
||||||
private get orderContext(): OrderContext {
|
|
||||||
return (this.orderContextCache ??= {
|
|
||||||
adapter: this.exchange,
|
|
||||||
symbol: this.config.symbol,
|
|
||||||
locks: this.locks,
|
|
||||||
timers: this.timers,
|
|
||||||
pendings: this.pending,
|
|
||||||
log: (type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private orderContextCache: OrderContext | null = null;
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -177,7 +163,6 @@ export class LiquidityMakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
this.precision.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -234,8 +219,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.accountError", { error: String(error) }),
|
processFail: (error) => `账户推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -278,8 +263,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅订单失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.orderError", { error: String(error) }),
|
processFail: (error) => `订单推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -292,8 +277,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅深度失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.depthError", { error: String(error) }),
|
processFail: (error) => `深度推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -306,8 +291,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
|
processFail: (error) => `价格推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -326,8 +311,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.klineFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅K线失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.klineError", { error: String(error) }),
|
processFail: (error) => `K线推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -365,11 +350,7 @@ export class LiquidityMakerEngine {
|
|||||||
|
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
t("log.liquidityMaker.fillDetected", {
|
`检测到成交: ${order.side} ${filledQty.toFixed(6)} @ ${avgPrice.toFixed(this.getPriceDecimals())}`
|
||||||
side: order.side,
|
|
||||||
qty: filledQty.toFixed(6),
|
|
||||||
price: avgPrice.toFixed(this.getPriceDecimals()),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,9 +448,9 @@ export class LiquidityMakerEngine {
|
|||||||
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
||||||
const rawAbsPosition = Math.abs(position.positionAmt);
|
const rawAbsPosition = Math.abs(position.positionAmt);
|
||||||
const minSell =
|
const minSell =
|
||||||
Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
|
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
|
||||||
? this.precision.minBaseAmount!
|
? this.minBaseAmount!
|
||||||
: Math.max(this.config.tradeAmount, this.precision.qtyStep);
|
: Math.max(this.config.tradeAmount, this.qtyStep);
|
||||||
let absPosition = rawAbsPosition;
|
let absPosition = rawAbsPosition;
|
||||||
const tinySpotPosition =
|
const tinySpotPosition =
|
||||||
isSpotMarket &&
|
isSpotMarket &&
|
||||||
@@ -492,13 +473,13 @@ export class LiquidityMakerEngine {
|
|||||||
// 无法卖出,跳过卖单,允许买单累计
|
// 无法卖出,跳过卖单,允许买单累计
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
if (!skipSellSide) {
|
if (!skipSellSide) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.belowMinSellHold"));
|
this.tradeLog.push("info", "现货持仓低于最小卖单量,暂不挂卖单");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipBuySide && canEnter) {
|
if (!skipBuySide && canEnter) {
|
||||||
if (!allowSpotBuy) {
|
if (!allowSpotBuy) {
|
||||||
if (this.lastBuyPriceViable) {
|
if (this.lastBuyPriceViable) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
|
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
|
||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -515,8 +496,8 @@ export class LiquidityMakerEngine {
|
|||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
const reason =
|
const reason =
|
||||||
buyAmount < EPS && isSpotMarket
|
buyAmount < EPS && isSpotMarket
|
||||||
? t("log.spotMaker.quoteBalanceShort")
|
? "现货可用报价资产不足,跳过买单"
|
||||||
: t("log.spotMaker.spreadTooTightBuy");
|
: "跳过买单:价差不足以构造maker价格";
|
||||||
this.tradeLog.push("info", reason);
|
this.tradeLog.push("info", reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,7 +510,7 @@ export class LiquidityMakerEngine {
|
|||||||
// 持仓低于最小卖单量,跳过卖单,等待累积
|
// 持仓低于最小卖单量,跳过卖单,等待累积
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
|
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const desiredSellAmount =
|
const desiredSellAmount =
|
||||||
@@ -547,8 +528,8 @@ export class LiquidityMakerEngine {
|
|||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
const reason =
|
const reason =
|
||||||
sellAmount < EPS && isSpotMarket
|
sellAmount < EPS && isSpotMarket
|
||||||
? t("log.spotMaker.baseBalanceShort")
|
? "现货可用基础资产不足,跳过卖单"
|
||||||
: t("log.spotMaker.spreadTooTightSell");
|
: "跳过卖单:价差不足以构造maker价格";
|
||||||
this.tradeLog.push("info", reason);
|
this.tradeLog.push("info", reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -559,7 +540,7 @@ export class LiquidityMakerEngine {
|
|||||||
if (!skipBuySide && canEnter) {
|
if (!skipBuySide && canEnter) {
|
||||||
if (isSpotMarket && !allowSpotBuy) {
|
if (isSpotMarket && !allowSpotBuy) {
|
||||||
if (this.lastBuyPriceViable) {
|
if (this.lastBuyPriceViable) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
|
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
|
||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
}
|
}
|
||||||
} else if (bidPrice != null) {
|
} else if (bidPrice != null) {
|
||||||
@@ -567,12 +548,12 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipSellSide && canEnter) {
|
if (!skipSellSide && canEnter) {
|
||||||
if (isSpotMarket && minSell > 0 && this.precision.minBaseAmount != null) {
|
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) {
|
||||||
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
||||||
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
||||||
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
|
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
|
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (askPrice != null) {
|
if (askPrice != null) {
|
||||||
@@ -627,7 +608,7 @@ export class LiquidityMakerEngine {
|
|||||||
await this.enforceRateLimitStop();
|
await this.enforceRateLimitStop();
|
||||||
this.tradeLog.push("warn", `LiquidityMakerEngine 429: ${String(error)}`);
|
this.tradeLog.push("warn", `LiquidityMakerEngine 429: ${String(error)}`);
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.liquidityMaker.tickFailed", { error: String(error) }));
|
this.tradeLog.push("error", `流动性做市循环异常: ${String(error)}`);
|
||||||
}
|
}
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -649,7 +630,7 @@ export class LiquidityMakerEngine {
|
|||||||
topAsk: number,
|
topAsk: number,
|
||||||
priceDecimals: number
|
priceDecimals: number
|
||||||
): string | null {
|
): string | null {
|
||||||
const tickOffset = this.config.closeTickOffset * this.precision.priceTick;
|
const tickOffset = this.config.closeTickOffset * this.priceTick;
|
||||||
const entryPrice = position.entryPrice || this.positionEntryPrice;
|
const entryPrice = position.entryPrice || this.positionEntryPrice;
|
||||||
|
|
||||||
let targetPrice: number;
|
let targetPrice: number;
|
||||||
@@ -683,14 +664,14 @@ export class LiquidityMakerEngine {
|
|||||||
if (closeSide === "SELL") {
|
if (closeSide === "SELL") {
|
||||||
// 多头平仓:卖价必须 >= 入场价
|
// 多头平仓:卖价必须 >= 入场价
|
||||||
if (targetPrice < entryPrice) {
|
if (targetPrice < entryPrice) {
|
||||||
targetPrice = entryPrice + this.precision.priceTick;
|
targetPrice = entryPrice + this.priceTick;
|
||||||
this.tradeLog.push("info", t("log.liquidityMaker.exitRaisedToBreakeven", { price: targetPrice.toFixed(priceDecimals) }));
|
this.tradeLog.push("info", `平仓价调整为入场价+1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 空头平仓:买价必须 <= 入场价
|
// 空头平仓:买价必须 <= 入场价
|
||||||
if (targetPrice > entryPrice) {
|
if (targetPrice > entryPrice) {
|
||||||
targetPrice = entryPrice - this.precision.priceTick;
|
targetPrice = entryPrice - this.priceTick;
|
||||||
this.tradeLog.push("info", t("log.liquidityMaker.exitLoweredToBreakeven", { price: targetPrice.toFixed(priceDecimals) }));
|
this.tradeLog.push("info", `平仓价调整为入场价-1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,11 +697,17 @@ export class LiquidityMakerEngine {
|
|||||||
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
||||||
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
||||||
try {
|
try {
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
quantity: absPosition,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
side,
|
||||||
|
absPosition,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice:
|
expectedPrice:
|
||||||
side === "SELL"
|
side === "SELL"
|
||||||
@@ -728,13 +715,13 @@ export class LiquidityMakerEngine {
|
|||||||
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.rateLimitCloseMissing"));
|
this.tradeLog.push("order", "限频强制平仓时订单已不存在");
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.rateLimitCloseFailed", { error: String(error) }));
|
this.tradeLog.push("error", `限频强制平仓失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -752,18 +739,18 @@ export class LiquidityMakerEngine {
|
|||||||
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
||||||
this.openOrders = [];
|
this.openOrders = [];
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
this.tradeLog.push("order", t("log.spotMaker.startupCleanup"));
|
this.tradeLog.push("order", "启动时清理历史挂单");
|
||||||
this.initialOrderResetDone = true;
|
this.initialOrderResetDone = true;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.startupCleanupGone"));
|
this.tradeLog.push("order", "历史挂单已消失,跳过启动清理");
|
||||||
this.initialOrderResetDone = true;
|
this.initialOrderResetDone = true;
|
||||||
this.openOrders = [];
|
this.openOrders = [];
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", t("log.spotMaker.startupCancelFailed", { error: String(error) }));
|
this.tradeLog.push("error", `启动撤单失败: ${String(error)}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -836,7 +823,7 @@ export class LiquidityMakerEngine {
|
|||||||
const newPrice = Number(t.price);
|
const newPrice = Number(t.price);
|
||||||
const oldPrice = Number(existing.price);
|
const oldPrice = Number(existing.price);
|
||||||
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
||||||
const ticksDiff = Math.abs(newPrice - oldPrice) / this.precision.priceTick;
|
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
|
||||||
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
||||||
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
||||||
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
||||||
@@ -862,21 +849,17 @@ export class LiquidityMakerEngine {
|
|||||||
() => {
|
() => {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
t("log.spotMaker.cancelMismatched", {
|
`撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`
|
||||||
side: order.side,
|
|
||||||
price: order.price,
|
|
||||||
reduceOnly: order.reduceOnly,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
|
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.cancelAlreadySettled"));
|
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
|
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
|
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
@@ -889,31 +872,40 @@ export class LiquidityMakerEngine {
|
|||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
if (
|
if (
|
||||||
this.marketType === "spot" &&
|
this.marketType === "spot" &&
|
||||||
this.precision.minBaseAmount != null &&
|
this.minBaseAmount != null &&
|
||||||
target.side === "SELL" &&
|
target.side === "SELL" &&
|
||||||
target.amount + EPS < this.precision.minBaseAmount
|
target.amount + EPS < this.minBaseAmount
|
||||||
) {
|
) {
|
||||||
// Skip placing sells that would be bumped by venue minimums
|
// Skip placing sells that would be bumped by venue minimums
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", t("log.spotMaker.sellBelowMinNotional"));
|
this.tradeLog.push("info", "现货卖单低于最小成交量,跳过挂单等待累积");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
|
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
|
||||||
await placeOrder(this.orderContext, {
|
await placeOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: target.side,
|
this.config.symbol,
|
||||||
price: target.price,
|
this.openOrders,
|
||||||
amount: target.amount,
|
this.locks,
|
||||||
reduceOnly: reduceOnlyFlag,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
target.side,
|
||||||
|
target.price, // 已经是字符串价格
|
||||||
|
target.amount,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
reduceOnlyFlag,
|
||||||
|
{
|
||||||
markPrice: this.getPositionSnapshot().markPrice,
|
markPrice: this.getPositionSnapshot().markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{
|
||||||
});
|
priceTick: this.priceTick,
|
||||||
|
qtyStep: this.qtyStep,
|
||||||
|
}
|
||||||
|
);
|
||||||
// Record last placed entry order timing and price
|
// Record last placed entry order timing and price
|
||||||
if (!target.reduceOnly) {
|
if (!target.reduceOnly) {
|
||||||
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
||||||
@@ -929,10 +921,10 @@ export class LiquidityMakerEngine {
|
|||||||
if (isRateLimitError(dustError)) {
|
if (isRateLimitError(dustError)) {
|
||||||
throw dustError;
|
throw dustError;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(dustError) }));
|
this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`);
|
||||||
}
|
}
|
||||||
if (dustClosed) continue;
|
if (dustClosed) continue;
|
||||||
this.tradeLog.push("error", t("log.spotMaker.placeFailed", { side: target.side, price: target.price, error: String(error) }));
|
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -945,10 +937,10 @@ export class LiquidityMakerEngine {
|
|||||||
this.lastSpotStopSkipped = false;
|
this.lastSpotStopSkipped = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const minStopQty = Number.isFinite(this.precision.minBaseAmount) ? this.precision.minBaseAmount! : null;
|
const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null;
|
||||||
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
||||||
if (!this.lastSpotStopSkipped) {
|
if (!this.lastSpotStopSkipped) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.belowMinCloseSkipStop"));
|
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
|
||||||
this.lastSpotStopSkipped = true;
|
this.lastSpotStopSkipped = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -957,28 +949,34 @@ export class LiquidityMakerEngine {
|
|||||||
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
||||||
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
||||||
if (!triggerStop) return;
|
if (!triggerStop) return;
|
||||||
this.tradeLog.push("stop", t("log.spotMaker.spotStop", { qty: absPosition.toFixed(6), pnl: pnl.toFixed(4) }));
|
this.tradeLog.push("stop", `现货止损,当前仓位=${absPosition.toFixed(6)} PnL=${pnl.toFixed(4)} USDT`);
|
||||||
try {
|
try {
|
||||||
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
|
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
|
||||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: "SELL",
|
this.config.symbol,
|
||||||
quantity: absPosition,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
"SELL",
|
||||||
|
absPosition,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: bidPrice || null,
|
expectedPrice: bidPrice || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRateLimitError(error)) throw error;
|
if (isRateLimitError(error)) throw error;
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
|
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.spotStopFailed", { error: String(error) }));
|
this.tradeLog.push("error", `现货止损失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -989,7 +987,7 @@ export class LiquidityMakerEngine {
|
|||||||
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||||
if (!hasEntryPrice) {
|
if (!hasEntryPrice) {
|
||||||
if (!this.entryPricePendingLogged) {
|
if (!this.entryPricePendingLogged) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.entryPricePending"));
|
this.tradeLog.push("info", "做市持仓均价未同步,等待账户快照刷新后再执行止损判断");
|
||||||
this.entryPricePendingLogged = true;
|
this.entryPricePendingLogged = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1002,29 +1000,32 @@ export class LiquidityMakerEngine {
|
|||||||
if (triggerStop) {
|
if (triggerStop) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
t("log.spotMaker.stopTriggered", {
|
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
|
||||||
direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
|
||||||
pnl: pnl.toFixed(4),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: position.positionAmt > 0 ? "SELL" : "BUY",
|
this.config.symbol,
|
||||||
quantity: absPosition,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
position.positionAmt > 0 ? "SELL" : "BUY",
|
||||||
|
absPosition,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
|
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.stopCloseFailed", { error: String(error) }));
|
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1043,12 +1044,12 @@ export class LiquidityMakerEngine {
|
|||||||
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
|
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.orderMissingOnCancel"));
|
this.tradeLog.push("order", "订单已不存在,撤销跳过");
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
|
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
|
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
@@ -1057,8 +1058,49 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncPrecision(): void {
|
||||||
|
if (this.precisionSync) return;
|
||||||
|
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||||
|
if (!getPrecision) return;
|
||||||
|
this.precisionSync = getPrecision()
|
||||||
|
.then((precision) => {
|
||||||
|
if (!precision) return;
|
||||||
|
let updated = false;
|
||||||
|
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||||
|
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
||||||
|
this.priceTick = precision.priceTick;
|
||||||
|
this.config.priceTick = precision.priceTick;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||||
|
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
||||||
|
this.qtyStep = precision.qtyStep;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.minBaseAmount)) {
|
||||||
|
this.minBaseAmount = precision.minBaseAmount!;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.minQuoteAmount)) {
|
||||||
|
this.minQuoteAmount = precision.minQuoteAmount!;
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
this.tradeLog.push(
|
||||||
|
"info",
|
||||||
|
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.tradeLog.push("error", `同步精度失败: ${String(error)}`);
|
||||||
|
this.precisionSync = null;
|
||||||
|
setTimeout(() => this.syncPrecision(), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getPriceDecimals(): number {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.precision.priceTick);
|
const tick = Math.max(1e-9, this.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
@@ -1068,10 +1110,10 @@ export class LiquidityMakerEngine {
|
|||||||
try {
|
try {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
this.events.emit("update", snapshot, (error) => {
|
this.events.emit("update", snapshot, (error) => {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.updateHandlerError", { error: String(error) }));
|
this.tradeLog.push("error", `更新回调处理异常: ${String(error)}`);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.snapshotDispatchError", { error: String(err) }));
|
this.tradeLog.push("error", `快照或更新分发异常: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,7 +1231,7 @@ export class LiquidityMakerEngine {
|
|||||||
if (!params.balances) return desired;
|
if (!params.balances) return desired;
|
||||||
if (params.side === "SELL") {
|
if (params.side === "SELL") {
|
||||||
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
||||||
if (this.precision.minBaseAmount != null && cap + EPS < this.precision.minBaseAmount) {
|
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
|
||||||
return 0; // below venue min trade size; skip sell until enough balance
|
return 0; // below venue min trade size; skip sell until enough balance
|
||||||
}
|
}
|
||||||
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
||||||
@@ -1202,7 +1244,7 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private roundToStep(amount: number): number {
|
private roundToStep(amount: number): number {
|
||||||
const step = Math.max(1e-9, this.precision.qtyStep);
|
const step = Math.max(1e-9, this.qtyStep);
|
||||||
return Math.floor(amount / step) * step;
|
return Math.floor(amount / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,7 +1255,7 @@ export class LiquidityMakerEngine {
|
|||||||
topAsk: number | null
|
topAsk: number | null
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
||||||
const tick = Math.max(this.precision.priceTick, 1e-9);
|
const tick = Math.max(this.priceTick, 1e-9);
|
||||||
if (side === "BUY") {
|
if (side === "BUY") {
|
||||||
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
||||||
const maxPrice = Number(topAsk) - tick;
|
const maxPrice = Number(topAsk) - tick;
|
||||||
@@ -1251,11 +1293,17 @@ export class LiquidityMakerEngine {
|
|||||||
if (absQty < EPS) return false;
|
if (absQty < EPS) return false;
|
||||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||||
try {
|
try {
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: target.side,
|
this.config.symbol,
|
||||||
quantity: absQty,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
target.side,
|
||||||
|
absQty,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice:
|
expectedPrice:
|
||||||
target.side === "SELL"
|
target.side === "SELL"
|
||||||
@@ -1263,15 +1311,15 @@ export class LiquidityMakerEngine {
|
|||||||
: (topAsk != null ? Number(topAsk) : null),
|
: (topAsk != null ? Number(topAsk) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
this.tradeLog.push("order", t("log.spotMaker.dustClose", { side: target.side, qty: absQty.toFixed(6) }));
|
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
if (isRateLimitError(closeError)) {
|
if (isRateLimitError(closeError)) {
|
||||||
throw closeError;
|
throw closeError;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(closeError) }));
|
this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,14 +20,13 @@ import {
|
|||||||
placeOrder,
|
placeOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import { makeOrderPlan } from "../core/lib/order-plan";
|
import { makeOrderPlan } from "../core/lib/order-plan";
|
||||||
import { safeCancelOrder } from "../core/lib/orders";
|
import { safeCancelOrder } from "../core/lib/orders";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
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 { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
@@ -65,8 +64,6 @@ type MakerListener = (snapshot: MakerEngineSnapshot) => void;
|
|||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
|
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
|
||||||
/** Quantity step assumed until the exchange reports its own. */
|
|
||||||
const DEFAULT_QTY_STEP = 0.001;
|
|
||||||
|
|
||||||
export class MakerEngine {
|
export class MakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -82,7 +79,9 @@ export class MakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private readonly precision: PrecisionSyncer;
|
private priceTick: number = 0.1;
|
||||||
|
private qtyStep: number = 0.001;
|
||||||
|
private precisionSync: Promise<void> | null = null;
|
||||||
|
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private processing = false;
|
private processing = false;
|
||||||
@@ -120,26 +119,12 @@ export class MakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
||||||
this.tradeLog.push(type, detail)
|
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
||||||
);
|
this.syncPrecision();
|
||||||
this.precision.start();
|
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
|
||||||
private get orderContext(): OrderContext {
|
|
||||||
return (this.orderContextCache ??= {
|
|
||||||
adapter: this.exchange,
|
|
||||||
symbol: this.config.symbol,
|
|
||||||
locks: this.locks,
|
|
||||||
timers: this.timers,
|
|
||||||
pendings: this.pending,
|
|
||||||
log: (type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private orderContextCache: OrderContext | null = null;
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -152,7 +137,6 @@ export class MakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
this.precision.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -452,18 +436,27 @@ export class MakerEngine {
|
|||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
try {
|
try {
|
||||||
await placeOrder(this.orderContext, {
|
await placeOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: target.side,
|
this.config.symbol,
|
||||||
price: target.price,
|
this.openOrders,
|
||||||
amount: target.amount,
|
this.locks,
|
||||||
reduceOnly: target.reduceOnly,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
target.side,
|
||||||
|
target.price, // 已经是字符串价格
|
||||||
|
target.amount,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
target.reduceOnly,
|
||||||
|
{
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{
|
||||||
});
|
priceTick: this.priceTick,
|
||||||
|
qtyStep: this.qtyStep,
|
||||||
|
}
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isInsufficientBalanceError(error)) {
|
if (isInsufficientBalanceError(error)) {
|
||||||
this.registerInsufficientBalance(error);
|
this.registerInsufficientBalance(error);
|
||||||
@@ -511,17 +504,23 @@ export class MakerEngine {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: position.positionAmt > 0 ? "SELL" : "BUY",
|
this.config.symbol,
|
||||||
quantity: absPosition,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
position.positionAmt > 0 ? "SELL" : "BUY",
|
||||||
|
absPosition,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(closeSidePrice) || null,
|
expectedPrice: Number(closeSidePrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.maker.stopOrderMissing"));
|
this.tradeLog.push("order", t("log.maker.stopOrderMissing"));
|
||||||
@@ -558,8 +557,46 @@ export class MakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncPrecision(): void {
|
||||||
|
if (this.precisionSync) return;
|
||||||
|
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||||
|
if (!getPrecision) return;
|
||||||
|
this.precisionSync = getPrecision()
|
||||||
|
.then((precision) => {
|
||||||
|
if (!precision) return;
|
||||||
|
let updated = false;
|
||||||
|
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||||
|
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
||||||
|
this.priceTick = precision.priceTick;
|
||||||
|
this.config.priceTick = precision.priceTick;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||||
|
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
||||||
|
this.qtyStep = precision.qtyStep;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
this.tradeLog.push(
|
||||||
|
"info",
|
||||||
|
t("log.common.precisionSynced", {
|
||||||
|
priceTick: precision.priceTick,
|
||||||
|
qtyStep: precision.qtyStep,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.tradeLog.push("error", t("log.common.precisionFailed", { error: extractMessage(error) }));
|
||||||
|
this.precisionSync = null;
|
||||||
|
setTimeout(() => this.syncPrecision(), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getPriceDecimals(): number {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.precision.priceTick);
|
const tick = Math.max(1e-9, this.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
ACCOUNT_DATA_STALE_THRESHOLD_MS,
|
|
||||||
DATA_STALE_THRESHOLD_MS,
|
|
||||||
REST_ERROR_DEFENSE_THRESHOLD,
|
|
||||||
defenseReasonsFor,
|
|
||||||
describeDefenseReasons,
|
|
||||||
evaluateDefense,
|
|
||||||
type DefenseInputs,
|
|
||||||
} from "./maker-points-defense";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
const NOW = 1_700_000_000_000;
|
|
||||||
|
|
||||||
function inputs(overrides: Partial<DefenseInputs> = {}): DefenseInputs {
|
|
||||||
return {
|
|
||||||
now: NOW,
|
|
||||||
lastDepthTime: NOW,
|
|
||||||
lastAccountTime: NOW,
|
|
||||||
lastBinanceDepthTime: NOW,
|
|
||||||
binanceHealth: { healthy: true },
|
|
||||||
accountHealth: { ok: true },
|
|
||||||
hasAccountSnapshot: true,
|
|
||||||
accountProbeFailures: 0,
|
|
||||||
accountProbeInFlight: false,
|
|
||||||
restUnhealthy: false,
|
|
||||||
restConsecutiveErrors: 0,
|
|
||||||
restLastError: null,
|
|
||||||
marginMode: "isolated",
|
|
||||||
enforceIsolatedMargin: true,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("evaluateDefense", () => {
|
|
||||||
it("stays out of defense when every feed is fresh", () => {
|
|
||||||
expect(evaluateDefense(inputs()).shouldDefend).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a feed that has never reported as fresh, not stale", () => {
|
|
||||||
// Startup: age 0 must not be read as "infinitely old".
|
|
||||||
const verdict = evaluateDefense(
|
|
||||||
inputs({ lastDepthTime: 0, lastAccountTime: 0, lastBinanceDepthTime: 0 })
|
|
||||||
);
|
|
||||||
expect(verdict.shouldDefend).toBe(false);
|
|
||||||
expect(verdict.reasons.depthAge).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defends on a stale venue depth feed", () => {
|
|
||||||
const verdict = evaluateDefense(
|
|
||||||
inputs({ lastDepthTime: NOW - DATA_STALE_THRESHOLD_MS - 1 })
|
|
||||||
);
|
|
||||||
expect(verdict.shouldDefend).toBe(true);
|
|
||||||
expect(verdict.reasons.depthStale).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not defend exactly at the staleness threshold", () => {
|
|
||||||
expect(evaluateDefense(inputs({ lastDepthTime: NOW - DATA_STALE_THRESHOLD_MS })).shouldDefend).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defends on a stale Binance depth feed or an unhealthy book", () => {
|
|
||||||
expect(
|
|
||||||
evaluateDefense(inputs({ lastBinanceDepthTime: NOW - DATA_STALE_THRESHOLD_MS - 1 })).shouldDefend
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
evaluateDefense(inputs({ binanceHealth: { healthy: false, reason: "gap" } })).shouldDefend
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("probes but does not defend when the account feed first goes quiet", () => {
|
|
||||||
const verdict = evaluateDefense(
|
|
||||||
inputs({ lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1 })
|
|
||||||
);
|
|
||||||
expect(verdict.needsAccountProbe).toBe(true);
|
|
||||||
expect(verdict.shouldDefend).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("holds off while the account REST probe is still in flight", () => {
|
|
||||||
const verdict = evaluateDefense(
|
|
||||||
inputs({
|
|
||||||
lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1,
|
|
||||||
accountProbeFailures: 2,
|
|
||||||
accountProbeInFlight: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(verdict.shouldDefend).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defends once the account REST probe has failed", () => {
|
|
||||||
const verdict = evaluateDefense(
|
|
||||||
inputs({
|
|
||||||
lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1,
|
|
||||||
accountProbeFailures: 1,
|
|
||||||
accountProbeInFlight: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(verdict.shouldDefend).toBe(true);
|
|
||||||
expect(verdict.reasons.accountStale).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defends on an invalid account snapshot and carries its issues", () => {
|
|
||||||
const verdict = evaluateDefense(
|
|
||||||
inputs({ accountHealth: { ok: false, issues: ["missing position"] } })
|
|
||||||
);
|
|
||||||
expect(verdict.shouldDefend).toBe(true);
|
|
||||||
expect(verdict.reasons.accountIssues).toEqual(["missing position"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores account validity before any snapshot has arrived", () => {
|
|
||||||
const verdict = evaluateDefense(
|
|
||||||
inputs({ hasAccountSnapshot: false, accountHealth: { ok: false, issues: ["x"] } })
|
|
||||||
);
|
|
||||||
expect(verdict.shouldDefend).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defends only after REST failures reach the threshold", () => {
|
|
||||||
const below = evaluateDefense(
|
|
||||||
inputs({ restUnhealthy: true, restConsecutiveErrors: REST_ERROR_DEFENSE_THRESHOLD - 1 })
|
|
||||||
);
|
|
||||||
expect(below.shouldDefend).toBe(false);
|
|
||||||
|
|
||||||
const at = evaluateDefense(
|
|
||||||
inputs({ restUnhealthy: true, restConsecutiveErrors: REST_ERROR_DEFENSE_THRESHOLD })
|
|
||||||
);
|
|
||||||
expect(at.shouldDefend).toBe(true);
|
|
||||||
expect(at.reasons.restUnhealthy).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defends on a non-isolated margin mode only where it is enforced", () => {
|
|
||||||
expect(evaluateDefense(inputs({ marginMode: "cross" })).shouldDefend).toBe(true);
|
|
||||||
expect(
|
|
||||||
evaluateDefense(inputs({ marginMode: "cross", enforceIsolatedMargin: false })).shouldDefend
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not defend on an unknown margin mode", () => {
|
|
||||||
expect(evaluateDefense(inputs({ marginMode: null })).shouldDefend).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("describeDefenseReasons", () => {
|
|
||||||
it("names every active cause", () => {
|
|
||||||
const summary = describeDefenseReasons(
|
|
||||||
defenseReasonsFor({
|
|
||||||
depthStale: true,
|
|
||||||
depthAge: 7_000,
|
|
||||||
restUnhealthy: true,
|
|
||||||
restConsecutiveErrors: 4,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(summary).toContain(t("defense.reason.depth", { seconds: 7 }));
|
|
||||||
expect(summary).toContain(t("defense.reason.rest", { count: 4 }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to unknown when nothing is flagged", () => {
|
|
||||||
expect(describeDefenseReasons(defenseReasonsFor({}))).toBe(t("defense.reason.unknown"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the Binance book reason when there is none", () => {
|
|
||||||
const summary = describeDefenseReasons(
|
|
||||||
defenseReasonsFor({ binanceUnhealthy: true, binanceHealthReason: null })
|
|
||||||
);
|
|
||||||
expect(summary).toBe(t("defense.reason.unknown"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* Defense-mode decision logic for the Maker Points engine.
|
|
||||||
*
|
|
||||||
* Pure by design (mirrors maker-points-logic.ts / grid-logic.ts): it reads a
|
|
||||||
* snapshot of feed ages and health flags and returns a verdict. Acting on the
|
|
||||||
* verdict — cancelling orders, starting REST polling — stays in the engine,
|
|
||||||
* so the rule that decides "is our market data trustworthy" can be tested
|
|
||||||
* without a live adapter.
|
|
||||||
*/
|
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
/** A feed older than this is considered stale. */
|
|
||||||
export const DATA_STALE_THRESHOLD_MS = 5_000;
|
|
||||||
/**
|
|
||||||
* Account pushes can legitimately be sparse, so age alone does not trigger
|
|
||||||
* defense — it only triggers a REST probe. Defense follows a failed probe.
|
|
||||||
*/
|
|
||||||
export const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000;
|
|
||||||
/** Consecutive REST failures before the venue is treated as down. */
|
|
||||||
export const REST_ERROR_DEFENSE_THRESHOLD = 3;
|
|
||||||
|
|
||||||
export interface DefenseInputs {
|
|
||||||
now: number;
|
|
||||||
/** Epoch ms of the last venue depth update; 0 when none has arrived yet. */
|
|
||||||
lastDepthTime: number;
|
|
||||||
/** Epoch ms of the last venue account update; 0 when none has arrived yet. */
|
|
||||||
lastAccountTime: number;
|
|
||||||
/** Epoch ms of the last Binance depth update; 0 when none has arrived yet. */
|
|
||||||
lastBinanceDepthTime: number;
|
|
||||||
binanceHealth: { healthy: boolean; reason?: string | null };
|
|
||||||
/** Result of validating the current account snapshot for the traded symbol. */
|
|
||||||
accountHealth: { ok: boolean; issues?: string[] };
|
|
||||||
hasAccountSnapshot: boolean;
|
|
||||||
/** Consecutive failures of the REST fallback that refreshes a stale account. */
|
|
||||||
accountProbeFailures: number;
|
|
||||||
accountProbeInFlight: boolean;
|
|
||||||
restUnhealthy: boolean;
|
|
||||||
restConsecutiveErrors: number;
|
|
||||||
restLastError: string | null;
|
|
||||||
/** Current margin mode as the venue reports it, or null when unknown. */
|
|
||||||
marginMode: string | null;
|
|
||||||
/** Margin mode is only enforced on StandX. */
|
|
||||||
enforceIsolatedMargin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Why defense mode was entered; carried into the log line and the notification.
|
|
||||||
* A type alias rather than an interface so it satisfies the notification
|
|
||||||
* payload's index signature without a cast.
|
|
||||||
*/
|
|
||||||
export type DefenseReasons = {
|
|
||||||
depthStale: boolean;
|
|
||||||
binanceStale: boolean;
|
|
||||||
binanceUnhealthy: boolean;
|
|
||||||
binanceHealthReason: string | null;
|
|
||||||
accountStale: boolean;
|
|
||||||
accountInvalid: boolean;
|
|
||||||
restUnhealthy: boolean;
|
|
||||||
restConsecutiveErrors: number;
|
|
||||||
restLastError: string | null;
|
|
||||||
marginModeNotIsolated: boolean;
|
|
||||||
marginMode: string | null;
|
|
||||||
depthAge: number;
|
|
||||||
binanceAge: number;
|
|
||||||
accountAge: number;
|
|
||||||
accountIssues: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DefenseVerdict {
|
|
||||||
shouldDefend: boolean;
|
|
||||||
/** The account feed is old enough that the engine should refresh it over REST. */
|
|
||||||
needsAccountProbe: boolean;
|
|
||||||
reasons: DefenseReasons;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Age of a feed that has produced at least one update; 0 for one that has not. */
|
|
||||||
function feedAge(now: number, lastUpdate: number): number {
|
|
||||||
return lastUpdate > 0 ? now - lastUpdate : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStale(now: number, lastUpdate: number, threshold: number): boolean {
|
|
||||||
return lastUpdate > 0 && now - lastUpdate > threshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluateDefense(inputs: DefenseInputs): DefenseVerdict {
|
|
||||||
const { now } = inputs;
|
|
||||||
|
|
||||||
const depthStale = isStale(now, inputs.lastDepthTime, DATA_STALE_THRESHOLD_MS);
|
|
||||||
const binanceStale = isStale(now, inputs.lastBinanceDepthTime, DATA_STALE_THRESHOLD_MS);
|
|
||||||
const binanceUnhealthy = !inputs.binanceHealth.healthy;
|
|
||||||
|
|
||||||
const accountAge = feedAge(now, inputs.lastAccountTime);
|
|
||||||
const accountStaleByAge = isStale(now, inputs.lastAccountTime, ACCOUNT_DATA_STALE_THRESHOLD_MS);
|
|
||||||
// Defense waits for the REST fallback to have been tried and failed.
|
|
||||||
const accountStale =
|
|
||||||
accountStaleByAge && inputs.accountProbeFailures > 0 && !inputs.accountProbeInFlight;
|
|
||||||
|
|
||||||
const accountInvalid = inputs.hasAccountSnapshot && !inputs.accountHealth.ok;
|
|
||||||
const restUnhealthy =
|
|
||||||
inputs.restUnhealthy && inputs.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD;
|
|
||||||
const marginModeNotIsolated =
|
|
||||||
inputs.enforceIsolatedMargin && inputs.marginMode != null && inputs.marginMode !== "isolated";
|
|
||||||
|
|
||||||
const shouldDefend =
|
|
||||||
depthStale ||
|
|
||||||
binanceStale ||
|
|
||||||
binanceUnhealthy ||
|
|
||||||
accountStale ||
|
|
||||||
accountInvalid ||
|
|
||||||
restUnhealthy ||
|
|
||||||
marginModeNotIsolated;
|
|
||||||
|
|
||||||
return {
|
|
||||||
shouldDefend,
|
|
||||||
needsAccountProbe: accountStaleByAge,
|
|
||||||
reasons: {
|
|
||||||
depthStale,
|
|
||||||
binanceStale,
|
|
||||||
binanceUnhealthy,
|
|
||||||
binanceHealthReason: inputs.binanceHealth.reason ?? null,
|
|
||||||
accountStale,
|
|
||||||
accountInvalid,
|
|
||||||
restUnhealthy,
|
|
||||||
restConsecutiveErrors: inputs.restConsecutiveErrors,
|
|
||||||
restLastError: inputs.restLastError,
|
|
||||||
marginModeNotIsolated,
|
|
||||||
marginMode: inputs.marginMode,
|
|
||||||
depthAge: feedAge(now, inputs.lastDepthTime),
|
|
||||||
binanceAge: feedAge(now, inputs.lastBinanceDepthTime),
|
|
||||||
accountAge,
|
|
||||||
accountIssues: accountInvalid ? inputs.accountHealth.issues ?? [] : [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nothing wrong; the baseline every single-cause reason set starts from. */
|
|
||||||
const NO_REASONS: DefenseReasons = {
|
|
||||||
depthStale: false,
|
|
||||||
binanceStale: false,
|
|
||||||
binanceUnhealthy: false,
|
|
||||||
binanceHealthReason: null,
|
|
||||||
accountStale: false,
|
|
||||||
accountInvalid: false,
|
|
||||||
restUnhealthy: false,
|
|
||||||
restConsecutiveErrors: 0,
|
|
||||||
restLastError: null,
|
|
||||||
marginModeNotIsolated: false,
|
|
||||||
marginMode: null,
|
|
||||||
depthAge: 0,
|
|
||||||
binanceAge: 0,
|
|
||||||
accountAge: 0,
|
|
||||||
accountIssues: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reason set for a defense trigger that fires outside the periodic check — a REST
|
|
||||||
* health event, a rejected margin mode — where only one or two causes are known.
|
|
||||||
*/
|
|
||||||
export function defenseReasonsFor(known: Partial<DefenseReasons>): DefenseReasons {
|
|
||||||
return { ...NO_REASONS, ...known };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Human-readable summary of what went stale, for the log and the alert. */
|
|
||||||
export function describeDefenseReasons(reasons: DefenseReasons): string {
|
|
||||||
const items: string[] = [];
|
|
||||||
const seconds = (ms: number) => Math.round(ms / 1000);
|
|
||||||
|
|
||||||
if (reasons.depthStale) items.push(t("defense.reason.depth", { seconds: seconds(reasons.depthAge) }));
|
|
||||||
if (reasons.accountStale) {
|
|
||||||
items.push(t("defense.reason.account", { seconds: seconds(reasons.accountAge) }));
|
|
||||||
}
|
|
||||||
if (reasons.accountInvalid) {
|
|
||||||
items.push(
|
|
||||||
t("defense.reason.accountInvalid", {
|
|
||||||
issues: reasons.accountIssues.join(",") || t("defense.reason.unknown"),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (reasons.restUnhealthy) {
|
|
||||||
items.push(t("defense.reason.rest", { count: reasons.restConsecutiveErrors }));
|
|
||||||
}
|
|
||||||
if (reasons.marginModeNotIsolated) {
|
|
||||||
items.push(
|
|
||||||
t("defense.reason.marginMode", { mode: reasons.marginMode ?? t("defense.reason.unknown") })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (reasons.binanceStale) {
|
|
||||||
items.push(t("defense.reason.binanceDepth", { seconds: seconds(reasons.binanceAge) }));
|
|
||||||
}
|
|
||||||
if (reasons.binanceUnhealthy && reasons.binanceHealthReason) {
|
|
||||||
items.push(t("defense.reason.binanceBook", { reason: reasons.binanceHealthReason }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.length > 0 ? items.join(", ") : t("defense.reason.unknown");
|
|
||||||
}
|
|
||||||
+670
-556
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { buildBpsTargets } from "./maker-points-logic";
|
||||||
bandRepriceToleranceBps,
|
|
||||||
buildBandTargets,
|
|
||||||
buildBpsTargets,
|
|
||||||
makerPointsMultiplier,
|
|
||||||
resolveSafeQuotePrice,
|
|
||||||
shouldKeepQuote,
|
|
||||||
signedDistanceBps,
|
|
||||||
} from "./maker-points-logic";
|
|
||||||
|
|
||||||
describe("maker points target builder", () => {
|
describe("maker points target builder", () => {
|
||||||
it("uses the default bps per enabled band", () => {
|
it("builds fixed bps targets 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, 40]);
|
expect(targets).toEqual([9, 29, 99]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips disabled bands", () => {
|
it("skips disabled bands", () => {
|
||||||
@@ -25,115 +17,6 @@ describe("maker points target builder", () => {
|
|||||||
band10To30: false,
|
band10To30: false,
|
||||||
band30To100: true,
|
band30To100: true,
|
||||||
});
|
});
|
||||||
expect(targets).toEqual([9, 40]);
|
expect(targets).toEqual([9, 99]);
|
||||||
});
|
|
||||||
|
|
||||||
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,168 +1,13 @@
|
|||||||
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[] {
|
||||||
return buildBandTargets(config).map((target) => target.bps);
|
const targets: number[] = [];
|
||||||
}
|
if (config.band0To10) targets.push(9);
|
||||||
|
if (config.band10To30) targets.push(29);
|
||||||
/**
|
if (config.band30To100) targets.push(99);
|
||||||
* Maker Points 的线性梯度倍率,三段折线:
|
return targets.sort((a, b) => a - b);
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+207
-171
@@ -22,16 +22,14 @@ import {
|
|||||||
placeOrder,
|
placeOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import type { MakerEngineSnapshot } from "./maker-engine";
|
import type { MakerEngineSnapshot } from "./maker-engine";
|
||||||
import { makeOrderPlan } from "../core/lib/order-plan";
|
import { makeOrderPlan } from "../core/lib/order-plan";
|
||||||
import { safeCancelOrder } from "../core/lib/orders";
|
import { safeCancelOrder } from "../core/lib/orders";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
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 { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
side: "BUY" | "SELL";
|
side: "BUY" | "SELL";
|
||||||
@@ -40,13 +38,6 @@ interface DesiredOrder {
|
|||||||
reduceOnly: boolean;
|
reduceOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spot wallet view the quoting logic reads; `baseWallet` may lag `baseAvailable` after a fill. */
|
|
||||||
export interface SpotBalances {
|
|
||||||
baseAvailable: number;
|
|
||||||
quoteAvailable: number;
|
|
||||||
baseWallet: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot {
|
export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot {
|
||||||
buyDepthSum10: number;
|
buyDepthSum10: number;
|
||||||
sellDepthSum10: number;
|
sellDepthSum10: number;
|
||||||
@@ -63,8 +54,6 @@ type MakerEvent = "update";
|
|||||||
type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void;
|
type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void;
|
||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
/** Quantity step assumed until the exchange reports its own. */
|
|
||||||
const DEFAULT_QTY_STEP = 0.001;
|
|
||||||
|
|
||||||
export class OffsetMakerEngine {
|
export class OffsetMakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -82,7 +71,11 @@ export class OffsetMakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private readonly precision: PrecisionSyncer;
|
private priceTick: number = 0.1;
|
||||||
|
private qtyStep: number = 0.001;
|
||||||
|
private minBaseAmount: number | null = null;
|
||||||
|
private minQuoteAmount: number | null = null;
|
||||||
|
private precisionSync: Promise<void> | null = null;
|
||||||
private marketType: "perp" | "spot" = "perp";
|
private marketType: "perp" | "spot" = "perp";
|
||||||
private baseAsset: string | null = null;
|
private baseAsset: string | null = null;
|
||||||
private quoteAsset: string | null = null;
|
private quoteAsset: string | null = null;
|
||||||
@@ -130,31 +123,17 @@ export class OffsetMakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
||||||
this.tradeLog.push(type, detail)
|
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
||||||
);
|
|
||||||
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
||||||
this.baseAsset = parsedSymbols.base ?? null;
|
this.baseAsset = parsedSymbols.base ?? null;
|
||||||
this.quoteAsset = parsedSymbols.quote ?? null;
|
this.quoteAsset = parsedSymbols.quote ?? null;
|
||||||
this.precision.start();
|
this.syncPrecision();
|
||||||
// Debounce window defaults to 3x refresh interval, min 1s
|
// Debounce window defaults to 3x refresh interval, min 1s
|
||||||
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
|
||||||
private get orderContext(): OrderContext {
|
|
||||||
return (this.orderContextCache ??= {
|
|
||||||
adapter: this.exchange,
|
|
||||||
symbol: this.config.symbol,
|
|
||||||
locks: this.locks,
|
|
||||||
timers: this.timers,
|
|
||||||
pendings: this.pending,
|
|
||||||
log: (type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private orderContextCache: OrderContext | null = null;
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -167,7 +146,6 @@ export class OffsetMakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
this.precision.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -224,8 +202,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.accountError", { error: String(error) }),
|
processFail: (error) => `账户推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,8 +231,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅订单失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.orderError", { error: String(error) }),
|
processFail: (error) => `订单推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -267,8 +245,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅深度失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.depthError", { error: String(error) }),
|
processFail: (error) => `深度推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -281,8 +259,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
|
processFail: (error) => `价格推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -291,7 +269,6 @@ export class OffsetMakerEngine {
|
|||||||
(klines) => {
|
(klines) => {
|
||||||
if (!Array.isArray(klines) || !klines.length) return;
|
if (!Array.isArray(klines) || !klines.length) return;
|
||||||
const latest = klines[klines.length - 1];
|
const latest = klines[klines.length - 1];
|
||||||
if (!latest) return;
|
|
||||||
this.lastKline = latest;
|
this.lastKline = latest;
|
||||||
const open = Number(latest.open);
|
const open = Number(latest.open);
|
||||||
const close = Number(latest.close);
|
const close = Number(latest.close);
|
||||||
@@ -301,8 +278,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => t("log.subscribe.klineFail", { error: String(error) }),
|
subscribeFail: (error) => `订阅K线失败: ${String(error)}`,
|
||||||
processFail: (error) => t("log.process.klineError", { error: String(error) }),
|
processFail: (error) => `K线推送处理异常: ${String(error)}`,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -363,9 +340,7 @@ export class OffsetMakerEngine {
|
|||||||
const position = this.getPositionSnapshot();
|
const position = this.getPositionSnapshot();
|
||||||
const isSpotMarket = this.marketType === "spot";
|
const isSpotMarket = this.marketType === "spot";
|
||||||
const spotBalances = isSpotMarket ? this.getSpotBalances() : null;
|
const spotBalances = isSpotMarket ? this.getSpotBalances() : null;
|
||||||
const balancesForSpot = isSpotMarket
|
const balancesForSpot = isSpotMarket ? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0 } : spotBalances;
|
||||||
? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0, baseWallet: 0 }
|
|
||||||
: spotBalances;
|
|
||||||
this.updateLiveCandle();
|
this.updateLiveCandle();
|
||||||
const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum);
|
const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum);
|
||||||
if (handledImbalance) {
|
if (handledImbalance) {
|
||||||
@@ -399,9 +374,9 @@ export class OffsetMakerEngine {
|
|||||||
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
||||||
const rawAbsPosition = Math.abs(position.positionAmt);
|
const rawAbsPosition = Math.abs(position.positionAmt);
|
||||||
const minSell =
|
const minSell =
|
||||||
Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
|
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
|
||||||
? this.precision.minBaseAmount!
|
? this.minBaseAmount!
|
||||||
: Math.max(this.config.tradeAmount, this.precision.qtyStep);
|
: Math.max(this.config.tradeAmount, this.qtyStep);
|
||||||
let absPosition = rawAbsPosition;
|
let absPosition = rawAbsPosition;
|
||||||
const tinySpotPosition =
|
const tinySpotPosition =
|
||||||
isSpotMarket &&
|
isSpotMarket &&
|
||||||
@@ -417,18 +392,20 @@ export class OffsetMakerEngine {
|
|||||||
|
|
||||||
if (absPosition < EPS && isSpotMarket) {
|
if (absPosition < EPS && isSpotMarket) {
|
||||||
this.entryPricePendingLogged = false;
|
this.entryPricePendingLogged = false;
|
||||||
const maxBase = this.sellableBase(balancesForSpot);
|
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
||||||
|
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
||||||
|
const maxBase = Math.max(baseAvail, baseWallet);
|
||||||
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
|
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
|
||||||
// 无法卖出,跳过卖单,允许买单累计
|
// 无法卖出,跳过卖单,允许买单累计
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
if (!skipSellSide) {
|
if (!skipSellSide) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.belowMinSellHold"));
|
this.tradeLog.push("info", "现货持仓低于最小卖单量,暂不挂卖单");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipBuySide && canEnter) {
|
if (!skipBuySide && canEnter) {
|
||||||
if (!allowSpotBuy) {
|
if (!allowSpotBuy) {
|
||||||
if (this.lastBuyPriceViable) {
|
if (this.lastBuyPriceViable) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
|
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
|
||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -445,19 +422,21 @@ export class OffsetMakerEngine {
|
|||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
const reason =
|
const reason =
|
||||||
buyAmount < EPS && isSpotMarket
|
buyAmount < EPS && isSpotMarket
|
||||||
? t("log.spotMaker.quoteBalanceShort")
|
? "现货可用报价资产不足,跳过买单"
|
||||||
: t("log.spotMaker.spreadTooTightBuy");
|
: "跳过买单:价差不足以构造maker价格";
|
||||||
this.tradeLog.push("info", reason);
|
this.tradeLog.push("info", reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipSellSide && canEnter) {
|
if (!skipSellSide && canEnter) {
|
||||||
const maxBase = this.sellableBase(balancesForSpot);
|
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
||||||
|
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
||||||
|
const maxBase = Math.max(baseAvail, baseWallet);
|
||||||
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
|
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
|
||||||
// 持仓低于最小卖单量,跳过卖单,等待累积
|
// 持仓低于最小卖单量,跳过卖单,等待累积
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
|
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const desiredSellAmount =
|
const desiredSellAmount =
|
||||||
@@ -475,8 +454,8 @@ export class OffsetMakerEngine {
|
|||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
const reason =
|
const reason =
|
||||||
sellAmount < EPS && isSpotMarket
|
sellAmount < EPS && isSpotMarket
|
||||||
? t("log.spotMaker.baseBalanceShort")
|
? "现货可用基础资产不足,跳过卖单"
|
||||||
: t("log.spotMaker.spreadTooTightSell");
|
: "跳过卖单:价差不足以构造maker价格";
|
||||||
this.tradeLog.push("info", reason);
|
this.tradeLog.push("info", reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,25 +465,23 @@ export class OffsetMakerEngine {
|
|||||||
if (!skipBuySide && canEnter) {
|
if (!skipBuySide && canEnter) {
|
||||||
if (isSpotMarket && !allowSpotBuy) {
|
if (isSpotMarket && !allowSpotBuy) {
|
||||||
if (this.lastBuyPriceViable) {
|
if (this.lastBuyPriceViable) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
|
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
|
||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
}
|
}
|
||||||
} else if (bidPrice != null) {
|
} else {
|
||||||
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipSellSide && canEnter) {
|
if (!skipSellSide && canEnter) {
|
||||||
const belowMinSell =
|
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) {
|
||||||
isSpotMarket &&
|
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
||||||
minSell > 0 &&
|
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
||||||
this.precision.minBaseAmount != null &&
|
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
|
||||||
this.sellableBase(balancesForSpot) + EPS < minSell;
|
this.lastSellPriceViable = false;
|
||||||
if (belowMinSell) {
|
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
|
||||||
this.lastSellPriceViable = false;
|
}
|
||||||
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
|
|
||||||
} else if (askPrice != null) {
|
|
||||||
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
|
||||||
}
|
}
|
||||||
|
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
||||||
@@ -544,7 +521,7 @@ export class OffsetMakerEngine {
|
|||||||
await this.enforceRateLimitStop();
|
await this.enforceRateLimitStop();
|
||||||
this.tradeLog.push("warn", `OffsetMakerEngine 429: ${String(error)}`);
|
this.tradeLog.push("warn", `OffsetMakerEngine 429: ${String(error)}`);
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.offsetMaker.tickFailed", { error: String(error) }));
|
this.tradeLog.push("error", `偏移做市循环异常: ${String(error)}`);
|
||||||
}
|
}
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -565,11 +542,17 @@ export class OffsetMakerEngine {
|
|||||||
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
||||||
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
||||||
try {
|
try {
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
quantity: absPosition,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
side,
|
||||||
|
absPosition,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice:
|
expectedPrice:
|
||||||
side === "SELL"
|
side === "SELL"
|
||||||
@@ -577,13 +560,13 @@ export class OffsetMakerEngine {
|
|||||||
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.rateLimitCloseMissing"));
|
this.tradeLog.push("order", "限频强制平仓时订单已不存在");
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.rateLimitCloseFailed", { error: String(error) }));
|
this.tradeLog.push("error", `限频强制平仓失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,18 +584,18 @@ export class OffsetMakerEngine {
|
|||||||
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
||||||
this.openOrders = [];
|
this.openOrders = [];
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
this.tradeLog.push("order", t("log.spotMaker.startupCleanup"));
|
this.tradeLog.push("order", "启动时清理历史挂单");
|
||||||
this.initialOrderResetDone = true;
|
this.initialOrderResetDone = true;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.startupCleanupGone"));
|
this.tradeLog.push("order", "历史挂单已消失,跳过启动清理");
|
||||||
this.initialOrderResetDone = true;
|
this.initialOrderResetDone = true;
|
||||||
this.openOrders = [];
|
this.openOrders = [];
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", t("log.spotMaker.startupCancelFailed", { error: String(error) }));
|
this.tradeLog.push("error", `启动撤单失败: ${String(error)}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,30 +631,32 @@ export class OffsetMakerEngine {
|
|||||||
const closeSidePrice = side === "SELL" ? bid : ask;
|
const closeSidePrice = side === "SELL" ? bid : ask;
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
t("log.offsetMaker.imbalanceClose", {
|
`深度极端不平衡(${buySum.toFixed(4)} vs ${sellSum.toFixed(4)}), 市价平仓 ${side}`
|
||||||
buySum: buySum.toFixed(4),
|
|
||||||
sellSum: sellSum.toFixed(4),
|
|
||||||
side,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
quantity: absPosition,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
side,
|
||||||
|
absPosition,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(closeSidePrice) || null,
|
expectedPrice: Number(closeSidePrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.offsetMaker.imbalanceCloseMissing"));
|
this.tradeLog.push("order", "深度不平衡平仓时订单已不存在");
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.offsetMaker.imbalanceCloseFailed", { error: String(error) }));
|
this.tradeLog.push("error", `深度不平衡平仓失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -691,7 +676,7 @@ export class OffsetMakerEngine {
|
|||||||
const newPrice = Number(t.price);
|
const newPrice = Number(t.price);
|
||||||
const oldPrice = Number(existing.price);
|
const oldPrice = Number(existing.price);
|
||||||
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
||||||
const ticksDiff = Math.abs(newPrice - oldPrice) / this.precision.priceTick;
|
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
|
||||||
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
||||||
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
||||||
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
||||||
@@ -717,21 +702,17 @@ export class OffsetMakerEngine {
|
|||||||
() => {
|
() => {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
t("log.spotMaker.cancelMismatched", {
|
`撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`
|
||||||
side: order.side,
|
|
||||||
price: order.price,
|
|
||||||
reduceOnly: order.reduceOnly,
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
|
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.cancelAlreadySettled"));
|
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
|
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
|
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
@@ -744,31 +725,40 @@ export class OffsetMakerEngine {
|
|||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
if (
|
if (
|
||||||
this.marketType === "spot" &&
|
this.marketType === "spot" &&
|
||||||
this.precision.minBaseAmount != null &&
|
this.minBaseAmount != null &&
|
||||||
target.side === "SELL" &&
|
target.side === "SELL" &&
|
||||||
target.amount + EPS < this.precision.minBaseAmount
|
target.amount + EPS < this.minBaseAmount
|
||||||
) {
|
) {
|
||||||
// Skip placing sells that would be bumped by venue minimums
|
// Skip placing sells that would be bumped by venue minimums
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", t("log.spotMaker.sellBelowMinNotional"));
|
this.tradeLog.push("info", "现货卖单低于最小成交量,跳过挂单等待累积");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
|
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
|
||||||
await placeOrder(this.orderContext, {
|
await placeOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: target.side,
|
this.config.symbol,
|
||||||
price: target.price,
|
this.openOrders,
|
||||||
amount: target.amount,
|
this.locks,
|
||||||
reduceOnly: reduceOnlyFlag,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
target.side,
|
||||||
|
target.price, // 已经是字符串价格
|
||||||
|
target.amount,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
reduceOnlyFlag,
|
||||||
|
{
|
||||||
markPrice: this.getPositionSnapshot().markPrice,
|
markPrice: this.getPositionSnapshot().markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{
|
||||||
});
|
priceTick: this.priceTick,
|
||||||
|
qtyStep: this.qtyStep,
|
||||||
|
}
|
||||||
|
);
|
||||||
// Record last placed entry order timing and price
|
// Record last placed entry order timing and price
|
||||||
if (!target.reduceOnly) {
|
if (!target.reduceOnly) {
|
||||||
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
||||||
@@ -784,10 +774,10 @@ export class OffsetMakerEngine {
|
|||||||
if (isRateLimitError(dustError)) {
|
if (isRateLimitError(dustError)) {
|
||||||
throw dustError;
|
throw dustError;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(dustError) }));
|
this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`);
|
||||||
}
|
}
|
||||||
if (dustClosed) continue;
|
if (dustClosed) continue;
|
||||||
this.tradeLog.push("error", t("log.spotMaker.placeFailed", { side: target.side, price: target.price, error: String(error) }));
|
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -800,10 +790,10 @@ export class OffsetMakerEngine {
|
|||||||
this.lastSpotStopSkipped = false;
|
this.lastSpotStopSkipped = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const minStopQty = Number.isFinite(this.precision.minBaseAmount) ? this.precision.minBaseAmount! : null;
|
const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null;
|
||||||
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
||||||
if (!this.lastSpotStopSkipped) {
|
if (!this.lastSpotStopSkipped) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.belowMinCloseSkipStop"));
|
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
|
||||||
this.lastSpotStopSkipped = true;
|
this.lastSpotStopSkipped = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -812,28 +802,34 @@ export class OffsetMakerEngine {
|
|||||||
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
||||||
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
||||||
if (!triggerStop) return;
|
if (!triggerStop) return;
|
||||||
this.tradeLog.push("stop", t("log.spotMaker.spotStop", { qty: absPosition.toFixed(6), pnl: pnl.toFixed(4) }));
|
this.tradeLog.push("stop", `现货止损,当前仓位=${absPosition.toFixed(6)} PnL=${pnl.toFixed(4)} USDT`);
|
||||||
try {
|
try {
|
||||||
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
|
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
|
||||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: "SELL",
|
this.config.symbol,
|
||||||
quantity: absPosition,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
"SELL",
|
||||||
|
absPosition,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: bidPrice || null,
|
expectedPrice: bidPrice || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRateLimitError(error)) throw error;
|
if (isRateLimitError(error)) throw error;
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
|
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.spotStopFailed", { error: String(error) }));
|
this.tradeLog.push("error", `现货止损失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -844,7 +840,7 @@ export class OffsetMakerEngine {
|
|||||||
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||||
if (!hasEntryPrice) {
|
if (!hasEntryPrice) {
|
||||||
if (!this.entryPricePendingLogged) {
|
if (!this.entryPricePendingLogged) {
|
||||||
this.tradeLog.push("info", t("log.spotMaker.entryPricePending"));
|
this.tradeLog.push("info", "做市持仓均价未同步,等待账户快照刷新后再执行止损判断");
|
||||||
this.entryPricePendingLogged = true;
|
this.entryPricePendingLogged = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -857,29 +853,32 @@ export class OffsetMakerEngine {
|
|||||||
if (triggerStop) {
|
if (triggerStop) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
t("log.spotMaker.stopTriggered", {
|
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
|
||||||
direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
|
||||||
pnl: pnl.toFixed(4),
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: position.positionAmt > 0 ? "SELL" : "BUY",
|
this.config.symbol,
|
||||||
quantity: absPosition,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
position.positionAmt > 0 ? "SELL" : "BUY",
|
||||||
|
absPosition,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
|
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.stopCloseFailed", { error: String(error) }));
|
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -898,12 +897,12 @@ export class OffsetMakerEngine {
|
|||||||
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
|
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.tradeLog.push("order", t("log.spotMaker.orderMissingOnCancel"));
|
this.tradeLog.push("order", "订单已不存在,撤销跳过");
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
|
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
|
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
@@ -912,8 +911,49 @@ export class OffsetMakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncPrecision(): void {
|
||||||
|
if (this.precisionSync) return;
|
||||||
|
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||||
|
if (!getPrecision) return;
|
||||||
|
this.precisionSync = getPrecision()
|
||||||
|
.then((precision) => {
|
||||||
|
if (!precision) return;
|
||||||
|
let updated = false;
|
||||||
|
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||||
|
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
||||||
|
this.priceTick = precision.priceTick;
|
||||||
|
this.config.priceTick = precision.priceTick;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||||
|
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
||||||
|
this.qtyStep = precision.qtyStep;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.minBaseAmount)) {
|
||||||
|
this.minBaseAmount = precision.minBaseAmount!;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.minQuoteAmount)) {
|
||||||
|
this.minQuoteAmount = precision.minQuoteAmount!;
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
this.tradeLog.push(
|
||||||
|
"info",
|
||||||
|
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.tradeLog.push("error", `同步精度失败: ${String(error)}`);
|
||||||
|
this.precisionSync = null;
|
||||||
|
setTimeout(() => this.syncPrecision(), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private getPriceDecimals(): number {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.precision.priceTick);
|
const tick = Math.max(1e-9, this.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
@@ -923,10 +963,10 @@ export class OffsetMakerEngine {
|
|||||||
try {
|
try {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
this.events.emit("update", snapshot, (error) => {
|
this.events.emit("update", snapshot, (error) => {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.updateHandlerError", { error: String(error) }));
|
this.tradeLog.push("error", `更新回调处理异常: ${String(error)}`);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", t("log.spotMaker.snapshotDispatchError", { error: String(err) }));
|
this.tradeLog.push("error", `快照或更新分发异常: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,16 +1012,6 @@ export class OffsetMakerEngine {
|
|||||||
return this.spotKlineUp === true || this.isLiveCandleUp();
|
return this.spotKlineUp === true || this.isLiveCandleUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base asset the venue will actually let us sell. Wallet balance can exceed the
|
|
||||||
* available figure right after a fill settles, so the larger of the two wins.
|
|
||||||
*/
|
|
||||||
private sellableBase(balances: SpotBalances | null): number {
|
|
||||||
const available = balances?.baseAvailable ?? 0;
|
|
||||||
const wallet = balances?.baseWallet ?? available;
|
|
||||||
return Math.max(available, wallet);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isLiveCandleUp(): boolean {
|
private isLiveCandleUp(): boolean {
|
||||||
if (!this.liveCandle) return false;
|
if (!this.liveCandle) return false;
|
||||||
return this.liveCandle.close > this.liveCandle.open;
|
return this.liveCandle.close > this.liveCandle.open;
|
||||||
@@ -1053,7 +1083,7 @@ export class OffsetMakerEngine {
|
|||||||
if (!params.balances) return desired;
|
if (!params.balances) return desired;
|
||||||
if (params.side === "SELL") {
|
if (params.side === "SELL") {
|
||||||
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
||||||
if (this.precision.minBaseAmount != null && cap + EPS < this.precision.minBaseAmount) {
|
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
|
||||||
return 0; // below venue min trade size; skip sell until enough balance
|
return 0; // below venue min trade size; skip sell until enough balance
|
||||||
}
|
}
|
||||||
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
||||||
@@ -1066,7 +1096,7 @@ export class OffsetMakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private roundToStep(amount: number): number {
|
private roundToStep(amount: number): number {
|
||||||
const step = Math.max(1e-9, this.precision.qtyStep);
|
const step = Math.max(1e-9, this.qtyStep);
|
||||||
return Math.floor(amount / step) * step;
|
return Math.floor(amount / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,7 +1107,7 @@ export class OffsetMakerEngine {
|
|||||||
topAsk: number | null
|
topAsk: number | null
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
||||||
const tick = Math.max(this.precision.priceTick, 1e-9);
|
const tick = Math.max(this.priceTick, 1e-9);
|
||||||
if (side === "BUY") {
|
if (side === "BUY") {
|
||||||
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
||||||
const maxPrice = Number(topAsk) - tick;
|
const maxPrice = Number(topAsk) - tick;
|
||||||
@@ -1115,11 +1145,17 @@ export class OffsetMakerEngine {
|
|||||||
if (absQty < EPS) return false;
|
if (absQty < EPS) return false;
|
||||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||||
try {
|
try {
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: target.side,
|
this.config.symbol,
|
||||||
quantity: absQty,
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
target.side,
|
||||||
|
absQty,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice:
|
expectedPrice:
|
||||||
target.side === "SELL"
|
target.side === "SELL"
|
||||||
@@ -1127,15 +1163,15 @@ export class OffsetMakerEngine {
|
|||||||
: (topAsk != null ? Number(topAsk) : null),
|
: (topAsk != null ? Number(topAsk) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.precision.qtyStep
|
{ qtyStep: this.qtyStep }
|
||||||
});
|
);
|
||||||
this.tradeLog.push("order", t("log.spotMaker.dustClose", { side: target.side, qty: absQty.toFixed(6) }));
|
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
if (isRateLimitError(closeError)) {
|
if (isRateLimitError(closeError)) {
|
||||||
throw closeError;
|
throw closeError;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(closeError) }));
|
this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
import {
|
|
||||||
basisConfig,
|
|
||||||
gridConfig,
|
|
||||||
isBasisStrategyEnabled,
|
|
||||||
liquidityMakerConfig,
|
|
||||||
makerConfig,
|
|
||||||
makerPointsConfig,
|
|
||||||
swingConfig,
|
|
||||||
tradingConfig,
|
|
||||||
} from "../config";
|
|
||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
|
||||||
import { isBasisSupportedExchangeId, type SupportedExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import type { TradeLogEntry } from "../logging/trade-log";
|
|
||||||
import { BasisArbEngine } from "./basis-arb-engine";
|
|
||||||
import { GridEngine } from "./grid-engine";
|
|
||||||
import { GuardianEngine } from "./guardian-engine";
|
|
||||||
import { LiquidityMakerEngine } from "./liquidity-maker-engine";
|
|
||||||
import { MakerEngine } from "./maker-engine";
|
|
||||||
import { MakerPointsEngine } from "./maker-points-engine";
|
|
||||||
import { OffsetMakerEngine } from "./offset-maker-engine";
|
|
||||||
import { SwingEngine } from "./swing-engine";
|
|
||||||
import { TrendEngine } from "./trend-engine";
|
|
||||||
import { STRATEGY_IDS, type StrategyId } from "./strategy-ids";
|
|
||||||
|
|
||||||
/** The slice of every engine snapshot that generic consumers (CLI, UI) rely on. */
|
|
||||||
export interface StrategySnapshot {
|
|
||||||
ready: boolean;
|
|
||||||
tradeLog: TradeLogEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What every strategy engine offers its host. Consumers depend on this instead of
|
|
||||||
* the nine concrete classes, so neither the runner nor the UI needs a union of
|
|
||||||
* snapshot types that grows with each new strategy.
|
|
||||||
*/
|
|
||||||
export interface StrategyEngine<TSnapshot extends StrategySnapshot = StrategySnapshot> {
|
|
||||||
start(): void;
|
|
||||||
stop(): void;
|
|
||||||
getSnapshot(): TSnapshot;
|
|
||||||
on(event: "update", handler: (snapshot: TSnapshot) => void): void;
|
|
||||||
off(event: "update", handler: (snapshot: TSnapshot) => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StrategyDefinition {
|
|
||||||
id: StrategyId;
|
|
||||||
/** Prefix for non-interactive console output; not translated. */
|
|
||||||
consoleLabel: string;
|
|
||||||
labelKey: string;
|
|
||||||
descriptionKey: string;
|
|
||||||
/** Market the adapter must be built for before the engine is constructed. */
|
|
||||||
symbol(): string;
|
|
||||||
createEngine(adapter: ExchangeAdapter): StrategyEngine;
|
|
||||||
/**
|
|
||||||
* Why this strategy cannot run in the current environment, or null when it can.
|
|
||||||
* The menu hides strategies with a reason; the CLI reports it. One predicate
|
|
||||||
* keeps those two surfaces from disagreeing.
|
|
||||||
*/
|
|
||||||
unavailableReason?(exchangeId: SupportedExchangeId): string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keyed by StrategyId so a new id in strategy-ids.ts is a compile error here
|
|
||||||
* until it gets a definition.
|
|
||||||
*/
|
|
||||||
const DEFINITIONS: Record<StrategyId, StrategyDefinition> = {
|
|
||||||
trend: {
|
|
||||||
id: "trend",
|
|
||||||
consoleLabel: "Trend Following",
|
|
||||||
labelKey: "app.strategy.trend.label",
|
|
||||||
descriptionKey: "app.strategy.trend.desc",
|
|
||||||
symbol: () => tradingConfig.symbol,
|
|
||||||
createEngine: (adapter) => new TrendEngine(tradingConfig, adapter),
|
|
||||||
},
|
|
||||||
swing: {
|
|
||||||
id: "swing",
|
|
||||||
consoleLabel: "Swing",
|
|
||||||
labelKey: "app.strategy.swing.label",
|
|
||||||
descriptionKey: "app.strategy.swing.desc",
|
|
||||||
symbol: () => swingConfig.symbol,
|
|
||||||
createEngine: (adapter) => new SwingEngine(swingConfig, adapter),
|
|
||||||
},
|
|
||||||
guardian: {
|
|
||||||
id: "guardian",
|
|
||||||
consoleLabel: "Guardian",
|
|
||||||
labelKey: "app.strategy.guardian.label",
|
|
||||||
descriptionKey: "app.strategy.guardian.desc",
|
|
||||||
symbol: () => tradingConfig.symbol,
|
|
||||||
createEngine: (adapter) => new GuardianEngine(tradingConfig, adapter),
|
|
||||||
},
|
|
||||||
maker: {
|
|
||||||
id: "maker",
|
|
||||||
consoleLabel: "Maker",
|
|
||||||
labelKey: "app.strategy.maker.label",
|
|
||||||
descriptionKey: "app.strategy.maker.desc",
|
|
||||||
symbol: () => makerConfig.symbol,
|
|
||||||
createEngine: (adapter) => new MakerEngine(makerConfig, adapter),
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
id: "grid",
|
|
||||||
consoleLabel: "Grid",
|
|
||||||
labelKey: "app.strategy.grid.label",
|
|
||||||
descriptionKey: "app.strategy.grid.desc",
|
|
||||||
symbol: () => gridConfig.symbol,
|
|
||||||
createEngine: (adapter) => new GridEngine(gridConfig, adapter),
|
|
||||||
},
|
|
||||||
"maker-points": {
|
|
||||||
id: "maker-points",
|
|
||||||
consoleLabel: "Maker Points",
|
|
||||||
labelKey: "app.strategy.makerPoints.label",
|
|
||||||
descriptionKey: "app.strategy.makerPoints.desc",
|
|
||||||
symbol: () => makerPointsConfig.symbol,
|
|
||||||
createEngine: (adapter) => new MakerPointsEngine(makerPointsConfig, adapter),
|
|
||||||
unavailableReason: (exchangeId) =>
|
|
||||||
exchangeId === "standx" ? null : "Maker Points strategy only supports the StandX exchange.",
|
|
||||||
},
|
|
||||||
"offset-maker": {
|
|
||||||
id: "offset-maker",
|
|
||||||
consoleLabel: "Offset Maker",
|
|
||||||
labelKey: "app.strategy.offset.label",
|
|
||||||
descriptionKey: "app.strategy.offset.desc",
|
|
||||||
symbol: () => makerConfig.symbol,
|
|
||||||
createEngine: (adapter) => new OffsetMakerEngine(makerConfig, adapter),
|
|
||||||
},
|
|
||||||
"liquidity-maker": {
|
|
||||||
id: "liquidity-maker",
|
|
||||||
consoleLabel: "Liquidity Maker",
|
|
||||||
labelKey: "app.strategy.liquidityMaker.label",
|
|
||||||
descriptionKey: "app.strategy.liquidityMaker.desc",
|
|
||||||
symbol: () => liquidityMakerConfig.symbol,
|
|
||||||
createEngine: (adapter) => new LiquidityMakerEngine(liquidityMakerConfig, adapter),
|
|
||||||
},
|
|
||||||
basis: {
|
|
||||||
id: "basis",
|
|
||||||
consoleLabel: "Basis Arbitrage",
|
|
||||||
labelKey: "app.strategy.basis.label",
|
|
||||||
descriptionKey: "app.strategy.basis.desc",
|
|
||||||
symbol: () => basisConfig.futuresSymbol,
|
|
||||||
createEngine: (adapter) => new BasisArbEngine(basisConfig, adapter),
|
|
||||||
unavailableReason: (exchangeId) => {
|
|
||||||
if (!isBasisStrategyEnabled()) {
|
|
||||||
return "Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.";
|
|
||||||
}
|
|
||||||
if (!isBasisSupportedExchangeId(exchangeId)) {
|
|
||||||
return "Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Menu order. */
|
|
||||||
export const STRATEGY_DEFINITIONS: readonly StrategyDefinition[] = STRATEGY_IDS.map((id) => DEFINITIONS[id]);
|
|
||||||
|
|
||||||
export function getStrategyDefinition(id: StrategyId): StrategyDefinition {
|
|
||||||
return DEFINITIONS[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function strategyUnavailableReason(id: StrategyId, exchangeId: SupportedExchangeId): string | null {
|
|
||||||
return DEFINITIONS[id].unavailableReason?.(exchangeId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Strategies runnable on this exchange, in menu order. */
|
|
||||||
export function availableStrategies(exchangeId: SupportedExchangeId): StrategyDefinition[] {
|
|
||||||
return STRATEGY_DEFINITIONS.filter((definition) => definition.unavailableReason?.(exchangeId) == null);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* The canonical list of strategies. Dependency-free on purpose: CLI argument
|
|
||||||
* parsing imports this without dragging in every engine and its config.
|
|
||||||
*
|
|
||||||
* Adding a strategy starts here; `strategy/registry.ts` then fails to compile
|
|
||||||
* until the new id has a definition.
|
|
||||||
*/
|
|
||||||
/** Order is the interactive menu's order. */
|
|
||||||
export const STRATEGY_IDS = [
|
|
||||||
"trend",
|
|
||||||
"swing",
|
|
||||||
"guardian",
|
|
||||||
"maker",
|
|
||||||
"maker-points",
|
|
||||||
"grid",
|
|
||||||
"offset-maker",
|
|
||||||
"liquidity-maker",
|
|
||||||
"basis",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type StrategyId = (typeof STRATEGY_IDS)[number];
|
|
||||||
|
|
||||||
/** Spellings accepted on the command line beyond the canonical ids. */
|
|
||||||
const STRATEGY_ALIASES: Record<string, StrategyId> = {
|
|
||||||
offset: "offset-maker",
|
|
||||||
offsetmaker: "offset-maker",
|
|
||||||
makerpoints: "maker-points",
|
|
||||||
maker_points: "maker-points",
|
|
||||||
liquidity: "liquidity-maker",
|
|
||||||
liquiditymaker: "liquidity-maker",
|
|
||||||
liquidity_maker: "liquidity-maker",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isStrategyId(value: string): value is StrategyId {
|
|
||||||
return (STRATEGY_IDS as readonly string[]).includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns the strategy the input names, or null when it names none. */
|
|
||||||
export function parseStrategyId(raw: string): StrategyId | null {
|
|
||||||
const normalized = raw.trim().toLowerCase();
|
|
||||||
if (!normalized) return null;
|
|
||||||
if (isStrategyId(normalized)) return normalized;
|
|
||||||
return STRATEGY_ALIASES[normalized] ?? null;
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,13 @@ import type { ExchangeAdapter } from "../exchanges/adapter";
|
|||||||
import type { AccountSnapshot, Depth, Order, Ticker } from "../exchanges/types";
|
import type { AccountSnapshot, Depth, Order, Ticker } from "../exchanges/types";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||||
import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator";
|
import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator";
|
||||||
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors";
|
import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors";
|
||||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
import { getMidOrLast, getTopPrices } from "../utils/price";
|
import { getMidOrLast, getTopPrices } from "../utils/price";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
||||||
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 { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -94,7 +93,7 @@ export class SwingEngine {
|
|||||||
private lastError: string | null = null;
|
private lastError: string | null = null;
|
||||||
|
|
||||||
private ordersSnapshotReady = false;
|
private ordersSnapshotReady = false;
|
||||||
private readonly precision: PrecisionSyncer;
|
private precisionSync: Promise<void> | null = null;
|
||||||
private swingState: SwingState = createInitialSwingState();
|
private swingState: SwingState = createInitialSwingState();
|
||||||
|
|
||||||
// Stop-loss placement de-bounce
|
// Stop-loss placement de-bounce
|
||||||
@@ -129,26 +128,10 @@ export class SwingEngine {
|
|||||||
});
|
});
|
||||||
this.binanceRsi.start();
|
this.binanceRsi.start();
|
||||||
|
|
||||||
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
this.syncPrecision();
|
||||||
this.tradeLog.push(type, detail)
|
|
||||||
);
|
|
||||||
this.precision.start();
|
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
|
||||||
private get orderContext(): OrderContext {
|
|
||||||
return (this.orderContextCache ??= {
|
|
||||||
adapter: this.exchange,
|
|
||||||
symbol: this.config.symbol,
|
|
||||||
locks: this.locks,
|
|
||||||
timers: this.timers,
|
|
||||||
pendings: this.pending,
|
|
||||||
log: (type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private orderContextCache: OrderContext | null = null;
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -161,7 +144,6 @@ export class SwingEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
this.precision.stop();
|
|
||||||
// Binance tracker is external IO; stop it too.
|
// Binance tracker is external IO; stop it too.
|
||||||
this.binanceRsi.stop();
|
this.binanceRsi.stop();
|
||||||
}
|
}
|
||||||
@@ -361,18 +343,24 @@ export class SwingEngine {
|
|||||||
if (Math.abs(position.positionAmt) > EPS) {
|
if (Math.abs(position.positionAmt) > EPS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await placeMarketOrder(this.orderContext, {
|
await placeMarketOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
amount: this.config.tradeAmount,
|
this.openOrders,
|
||||||
reduceOnly: false,
|
this.locks,
|
||||||
guard: {
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
side,
|
||||||
|
this.config.tradeAmount,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
false,
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.config.qtyStep
|
{ qtyStep: this.config.qtyStep }
|
||||||
});
|
);
|
||||||
this.tradeLog.push("open", `${reason}: ${side} (market)`);
|
this.tradeLog.push("open", `${reason}: ${side} (market)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
|
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
|
||||||
@@ -387,17 +375,23 @@ export class SwingEngine {
|
|||||||
side === "SELL"
|
side === "SELL"
|
||||||
? Number(this.depthSnapshot?.bids?.[0]?.[0])
|
? Number(this.depthSnapshot?.bids?.[0]?.[0])
|
||||||
: Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
: Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
quantity: Math.abs(position.positionAmt),
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
side,
|
||||||
|
Math.abs(position.positionAmt),
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
|
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.config.qtyStep
|
{ qtyStep: this.config.qtyStep }
|
||||||
});
|
);
|
||||||
this.tradeLog.push("close", `${reason}: ${side} (market close)`);
|
this.tradeLog.push("close", `${reason}: ${side} (market close)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
@@ -460,19 +454,24 @@ export class SwingEngine {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const qty = Math.abs(position.positionAmt);
|
const qty = Math.abs(position.positionAmt);
|
||||||
await placeStopLossOrder(this.orderContext, {
|
await placeStopLossOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: stopSide,
|
this.config.symbol,
|
||||||
stopPrice: stopPrice,
|
this.openOrders,
|
||||||
quantity: qty,
|
this.locks,
|
||||||
lastPrice: lastPrice,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
stopSide,
|
||||||
|
stopPrice,
|
||||||
|
qty,
|
||||||
|
lastPrice,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
||||||
@@ -586,4 +585,39 @@ export class SwingEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncPrecision(): void {
|
||||||
|
if (this.precisionSync) return;
|
||||||
|
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||||
|
if (!getPrecision) return;
|
||||||
|
this.precisionSync = getPrecision()
|
||||||
|
.then((precision) => {
|
||||||
|
if (!precision) return;
|
||||||
|
let updated = false;
|
||||||
|
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||||
|
const delta = Math.abs(precision.priceTick - this.config.priceTick);
|
||||||
|
if (delta > 1e-12) {
|
||||||
|
this.config.priceTick = precision.priceTick;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||||
|
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
|
||||||
|
if (delta > 1e-12) {
|
||||||
|
this.config.qtyStep = precision.qtyStep;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
this.tradeLog.push(
|
||||||
|
"info",
|
||||||
|
`Synced precision: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.tradeLog.push("error", `Precision sync failed: ${extractMessage(error)}`);
|
||||||
|
this.precisionSync = null;
|
||||||
|
setTimeout(() => this.syncPrecision(), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-86
@@ -25,16 +25,14 @@ import {
|
|||||||
placeTrailingStopOrder,
|
placeTrailingStopOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
||||||
import { formatPriceToString } from "../utils/math";
|
import { formatPriceToString } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||||
import { decryptCopyright } from "../utils/copyright";
|
import { decryptCopyright } from "../utils/copyright";
|
||||||
import { isRateLimitError } from "../utils/errors";
|
import { isRateLimitError } from "../utils/errors";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import type { TrendLabel } from "../utils/format";
|
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
|
||||||
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 { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -45,7 +43,7 @@ export interface TrendEngineSnapshot {
|
|||||||
lastPrice: number | null;
|
lastPrice: number | null;
|
||||||
sma30: number | null;
|
sma30: number | null;
|
||||||
bollingerBandwidth: number | null;
|
bollingerBandwidth: number | null;
|
||||||
trend: TrendLabel;
|
trend: "做多" | "做空" | "无信号";
|
||||||
position: PositionSnapshot;
|
position: PositionSnapshot;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
unrealized: number;
|
unrealized: number;
|
||||||
@@ -126,33 +124,17 @@ export class TrendEngine {
|
|||||||
.digest("hex");
|
.digest("hex");
|
||||||
|
|
||||||
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
||||||
private readonly precision: PrecisionSyncer;
|
private precisionSync: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
this.syncPrecision();
|
||||||
this.tradeLog.push(type, detail)
|
|
||||||
);
|
|
||||||
this.precision.start();
|
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
|
||||||
private get orderContext(): OrderContext {
|
|
||||||
return (this.orderContextCache ??= {
|
|
||||||
adapter: this.exchange,
|
|
||||||
symbol: this.config.symbol,
|
|
||||||
locks: this.locks,
|
|
||||||
timers: this.timers,
|
|
||||||
pendings: this.pending,
|
|
||||||
log: (type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private orderContextCache: OrderContext | null = null;
|
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -165,7 +147,6 @@ export class TrendEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
this.precision.stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
||||||
@@ -504,18 +485,24 @@ export class TrendEngine {
|
|||||||
|
|
||||||
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
|
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await placeMarketOrder(this.orderContext, {
|
await placeMarketOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
amount: this.config.tradeAmount,
|
this.openOrders,
|
||||||
reduceOnly: false,
|
this.locks,
|
||||||
guard: {
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
side,
|
||||||
|
this.config.tradeAmount,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
false,
|
||||||
|
{
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.config.qtyStep
|
{ qtyStep: this.config.qtyStep }
|
||||||
});
|
);
|
||||||
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
||||||
this.lastOpenPlan = { side, price };
|
this.lastOpenPlan = { side, price };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -756,11 +743,17 @@ export class TrendEngine {
|
|||||||
return { closed: false, pnl };
|
return { closed: false, pnl };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await marketClose(this.orderContext, {
|
await marketClose(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: direction === "long" ? "SELL" : "BUY",
|
this.config.symbol,
|
||||||
quantity: Math.abs(position.positionAmt),
|
this.openOrders,
|
||||||
guard: {
|
this.locks,
|
||||||
|
this.timers,
|
||||||
|
this.pending,
|
||||||
|
direction === "long" ? "SELL" : "BUY",
|
||||||
|
Math.abs(position.positionAmt),
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
expectedPrice: Number(
|
expectedPrice: Number(
|
||||||
direction === "long"
|
direction === "long"
|
||||||
@@ -769,8 +762,8 @@ export class TrendEngine {
|
|||||||
) || null,
|
) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
qtyStep: this.config.qtyStep
|
{ qtyStep: this.config.qtyStep }
|
||||||
});
|
);
|
||||||
result.closed = true;
|
result.closed = true;
|
||||||
this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" }));
|
this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" }));
|
||||||
// 记录止损时间以便短期内抑制再次入场
|
// 记录止损时间以便短期内抑制再次入场
|
||||||
@@ -813,19 +806,24 @@ export class TrendEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await placeStopLossOrder(this.orderContext, {
|
await placeStopLossOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
stopPrice: stopPrice,
|
this.openOrders,
|
||||||
quantity: quantity,
|
this.locks,
|
||||||
lastPrice: lastPrice,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
side,
|
||||||
|
stopPrice,
|
||||||
|
quantity,
|
||||||
|
lastPrice,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
this.lastStopAttempt = { side, price: stopPrice, at: Date.now() };
|
this.lastStopAttempt = { side, price: stopPrice, at: Date.now() };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) }));
|
this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) }));
|
||||||
@@ -868,19 +866,24 @@ export class TrendEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const order = await placeStopLossOrder(this.orderContext, {
|
const order = await placeStopLossOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
stopPrice: nextStopPrice,
|
this.openOrders,
|
||||||
quantity: quantity,
|
this.locks,
|
||||||
lastPrice: lastPrice,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
side,
|
||||||
|
nextStopPrice,
|
||||||
|
quantity,
|
||||||
|
lastPrice,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
if (order) {
|
if (order) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
@@ -906,19 +909,24 @@ export class TrendEngine {
|
|||||||
(side === "SELL" && existingStopPrice >= lastPrice) ||
|
(side === "SELL" && existingStopPrice >= lastPrice) ||
|
||||||
(side === "BUY" && existingStopPrice <= lastPrice);
|
(side === "BUY" && existingStopPrice <= lastPrice);
|
||||||
if (!restoreInvalid) {
|
if (!restoreInvalid) {
|
||||||
const restored = await placeStopLossOrder(this.orderContext, {
|
const restored = await placeStopLossOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
stopPrice: existingStopPrice,
|
this.openOrders,
|
||||||
quantity: quantity,
|
this.locks,
|
||||||
lastPrice: lastPrice,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
side,
|
||||||
|
existingStopPrice,
|
||||||
|
quantity,
|
||||||
|
lastPrice,
|
||||||
|
(t, d) => this.tradeLog.push(t, d),
|
||||||
|
{
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
if (restored) {
|
if (restored) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
@@ -946,24 +954,65 @@ export class TrendEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await placeTrailingStopOrder(this.orderContext, {
|
await placeTrailingStopOrder(
|
||||||
openOrders: this.openOrders,
|
this.exchange,
|
||||||
side: side,
|
this.config.symbol,
|
||||||
activationPrice: activationPrice,
|
this.openOrders,
|
||||||
quantity: quantity,
|
this.locks,
|
||||||
callbackRate: this.config.trailingCallbackRate,
|
this.timers,
|
||||||
guard: {
|
this.pending,
|
||||||
|
side,
|
||||||
|
activationPrice,
|
||||||
|
quantity,
|
||||||
|
this.config.trailingCallbackRate,
|
||||||
|
(type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
{
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
priceTick: this.config.priceTick,
|
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||||
qtyStep: this.config.qtyStep
|
);
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", t("log.trend.trailingFail", { error: String(err) }));
|
this.tradeLog.push("error", t("log.trend.trailingFail", { error: String(err) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncPrecision(): void {
|
||||||
|
if (this.precisionSync) return;
|
||||||
|
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||||
|
if (!getPrecision) return;
|
||||||
|
this.precisionSync = getPrecision()
|
||||||
|
.then((precision) => {
|
||||||
|
if (!precision) return;
|
||||||
|
let updated = false;
|
||||||
|
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||||
|
const delta = Math.abs(precision.priceTick - this.config.priceTick);
|
||||||
|
if (delta > 1e-12) {
|
||||||
|
this.config.priceTick = precision.priceTick;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||||
|
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
|
||||||
|
if (delta > 1e-12) {
|
||||||
|
this.config.qtyStep = precision.qtyStep;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
this.tradeLog.push(
|
||||||
|
"info",
|
||||||
|
t("log.trend.precisionSynced", { priceTick: precision.priceTick, qtyStep: precision.qtyStep })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.tradeLog.push("error", t("log.trend.precisionFailed", { error: extractMessage(error) }));
|
||||||
|
this.precisionSync = null;
|
||||||
|
setTimeout(() => this.syncPrecision(), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private emitUpdate(): void {
|
private emitUpdate(): void {
|
||||||
try {
|
try {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
@@ -979,14 +1028,13 @@ export class TrendEngine {
|
|||||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
||||||
const sma30 = this.lastSma30;
|
const sma30 = this.lastSma30;
|
||||||
const trend: TrendLabel =
|
const trend = price == null || sma30 == null
|
||||||
price == null || sma30 == null
|
? "无信号"
|
||||||
? "none"
|
: price > sma30
|
||||||
: price > sma30
|
? "做多"
|
||||||
? "long"
|
: price < sma30
|
||||||
: price < sma30
|
? "做空"
|
||||||
? "short"
|
: "无信号";
|
||||||
: "none";
|
|
||||||
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
|
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
|
||||||
return {
|
return {
|
||||||
ready: this.isReady(),
|
ready: this.isReady(),
|
||||||
|
|||||||
+78
-25
@@ -9,40 +9,93 @@ import { OffsetMakerApp } from "./OffsetMakerApp";
|
|||||||
import { LiquidityMakerApp } from "./LiquidityMakerApp";
|
import { LiquidityMakerApp } from "./LiquidityMakerApp";
|
||||||
import { GridApp } from "./GridApp";
|
import { GridApp } from "./GridApp";
|
||||||
import { BasisApp } from "./BasisApp";
|
import { BasisApp } from "./BasisApp";
|
||||||
|
import { isBasisStrategyEnabled } from "../config";
|
||||||
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
|
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
|
||||||
import { resolveExchangeId } from "../exchanges/create-adapter";
|
import { resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
import { availableStrategies } from "../strategy/registry";
|
|
||||||
import type { StrategyId } from "../strategy/strategy-ids";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
type StrategyView = React.ComponentType<{ onExit: () => void }>;
|
interface StrategyOption {
|
||||||
|
id: "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
component: React.ComponentType<{ onExit: () => void }>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
const BASE_STRATEGIES: StrategyOption[] = [
|
||||||
* The only strategy knowledge the UI owns: which screen renders which engine.
|
{
|
||||||
* Typed as a total Record, so adding a strategy to the registry fails to compile
|
id: "trend",
|
||||||
* here until it has a view.
|
label: t("app.strategy.trend.label"),
|
||||||
*/
|
description: t("app.strategy.trend.desc"),
|
||||||
const STRATEGY_VIEWS: Record<StrategyId, StrategyView> = {
|
component: TrendApp,
|
||||||
trend: TrendApp,
|
},
|
||||||
swing: SwingApp,
|
{
|
||||||
guardian: GuardianApp,
|
id: "swing",
|
||||||
maker: MakerApp,
|
label: t("app.strategy.swing.label"),
|
||||||
"maker-points": MakerPointsApp,
|
description: t("app.strategy.swing.desc"),
|
||||||
grid: GridApp,
|
component: SwingApp,
|
||||||
"offset-maker": OffsetMakerApp,
|
},
|
||||||
"liquidity-maker": LiquidityMakerApp,
|
{
|
||||||
basis: BasisApp,
|
id: "guardian",
|
||||||
};
|
label: t("app.strategy.guardian.label"),
|
||||||
|
description: t("app.strategy.guardian.desc"),
|
||||||
|
component: GuardianApp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maker",
|
||||||
|
label: t("app.strategy.maker.label"),
|
||||||
|
description: t("app.strategy.maker.desc"),
|
||||||
|
component: MakerApp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grid",
|
||||||
|
label: t("app.strategy.grid.label"),
|
||||||
|
description: t("app.strategy.grid.desc"),
|
||||||
|
component: GridApp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "offset-maker",
|
||||||
|
label: t("app.strategy.offset.label"),
|
||||||
|
description: t("app.strategy.offset.desc"),
|
||||||
|
component: OffsetMakerApp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "liquidity-maker",
|
||||||
|
label: t("app.strategy.liquidityMaker.label"),
|
||||||
|
description: t("app.strategy.liquidityMaker.desc"),
|
||||||
|
component: LiquidityMakerApp,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [cursor, setCursor] = useState(0);
|
const [cursor, setCursor] = useState(0);
|
||||||
const [selected, setSelected] = useState<StrategyId | null>(null);
|
const [selected, setSelected] = useState<StrategyOption | null>(null);
|
||||||
const copyright = useMemo(() => loadCopyrightFragments(), []);
|
const copyright = useMemo(() => loadCopyrightFragments(), []);
|
||||||
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
const strategies = useMemo(() => availableStrategies(exchangeId), [exchangeId]);
|
const strategies = useMemo(() => {
|
||||||
|
const next: StrategyOption[] = [...BASE_STRATEGIES];
|
||||||
|
if (exchangeId === "standx") {
|
||||||
|
const gridIndex = next.findIndex((s) => s.id === "grid");
|
||||||
|
const insertAt = gridIndex === -1 ? next.length : gridIndex;
|
||||||
|
next.splice(insertAt, 0, {
|
||||||
|
id: "maker-points" as const,
|
||||||
|
label: t("app.strategy.makerPoints.label"),
|
||||||
|
description: t("app.strategy.makerPoints.desc"),
|
||||||
|
component: MakerPointsApp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isBasisStrategyEnabled()) {
|
||||||
|
next.push({
|
||||||
|
id: "basis" as const,
|
||||||
|
label: t("app.strategy.basis.label"),
|
||||||
|
description: t("app.strategy.basis.desc"),
|
||||||
|
component: BasisApp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(input, key) => {
|
(input, key) => {
|
||||||
@@ -54,7 +107,7 @@ export function App() {
|
|||||||
} else if (key.return) {
|
} else if (key.return) {
|
||||||
const strategy = strategies[cursor];
|
const strategy = strategies[cursor];
|
||||||
if (strategy) {
|
if (strategy) {
|
||||||
setSelected(strategy.id);
|
setSelected(strategy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -62,7 +115,7 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const Selected = STRATEGY_VIEWS[selected];
|
const Selected = selected.component;
|
||||||
return <Selected onExit={() => setSelected(null)} />;
|
return <Selected onExit={() => setSelected(null)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +136,9 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<Box key={strategy.id} flexDirection="column" marginBottom={1}>
|
<Box key={strategy.id} flexDirection="column" marginBottom={1}>
|
||||||
<Text color={active ? "greenBright" : undefined}>
|
<Text color={active ? "greenBright" : undefined}>
|
||||||
{active ? "➤" : " "} {t(strategy.labelKey)}
|
{active ? "➤" : " "} {strategy.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="gray"> {t(strategy.descriptionKey)}</Text>
|
<Text color="gray"> {strategy.description}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+47
-7
@@ -1,19 +1,59 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import { basisConfig } from "../config";
|
import { basisConfig } from "../config";
|
||||||
import type { BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface BasisAppProps {
|
interface BasisAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function BasisApp({ onExit }: BasisAppProps) {
|
export function BasisApp({ onExit }: BasisAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<BasisArbSnapshot>("basis", {
|
const [snapshot, setSnapshot] = useState<BasisArbSnapshot | null>(null);
|
||||||
onExit
|
const [error, setError] = useState<Error | null>(null);
|
||||||
});
|
const engineRef = useRef<BasisArbEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBasisSupportedExchangeId(exchangeId)) {
|
||||||
|
setError(new Error(t("basis.onlyAster")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: basisConfig.futuresSymbol });
|
||||||
|
const engine = new BasisArbEngine(basisConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: BasisArbSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+48
-13
@@ -1,26 +1,61 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import { gridConfig } from "../config";
|
import { gridConfig } from "../config";
|
||||||
import type { GridEngineSnapshot } from "../strategy/grid-engine";
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface GridAppProps {
|
interface GridAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function GridApp({ onExit }: GridAppProps) {
|
export function GridApp({ onExit }: GridAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<GridEngineSnapshot>("grid", {
|
const [snapshot, setSnapshot] = useState<GridEngineSnapshot | null>(null);
|
||||||
onExit,
|
const [error, setError] = useState<Error | null>(null);
|
||||||
cloneSnapshot: (next) => ({
|
const engineRef = useRef<GridEngine | null>(null);
|
||||||
...next,
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
desiredOrders: [...next.desiredOrders],
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
gridLines: [...next.gridLines],
|
|
||||||
tradeLog: [...next.tradeLog],
|
useInput(
|
||||||
}),
|
(input, key) => {
|
||||||
});
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: gridConfig.symbol });
|
||||||
|
const engine = new GridEngine(gridConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: GridEngineSnapshot) => {
|
||||||
|
setSnapshot({
|
||||||
|
...next,
|
||||||
|
desiredOrders: [...next.desiredOrders],
|
||||||
|
gridLines: [...next.gridLines],
|
||||||
|
tradeLog: [...next.tradeLog],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+44
-7
@@ -1,9 +1,11 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import type { GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
import { tradingConfig } from "../config";
|
||||||
|
import { resolveExchangeId, getExchangeDisplayName } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface GuardianAppProps {
|
interface GuardianAppProps {
|
||||||
@@ -11,10 +13,45 @@ interface GuardianAppProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const READY_MESSAGE = t("guardian.readyMessage");
|
const READY_MESSAGE = t("guardian.readyMessage");
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function GuardianApp({ onExit }: GuardianAppProps) {
|
export function GuardianApp({ onExit }: GuardianAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<GuardianEngineSnapshot>("guardian", {
|
const [snapshot, setSnapshot] = useState<GuardianEngineSnapshot | null>(null);
|
||||||
onExit
|
const [error, setError] = useState<Error | null>(null);
|
||||||
});
|
const engineRef = useRef<GuardianEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol });
|
||||||
|
const engine = new GuardianEngine(tradingConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: GuardianEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,19 +1,56 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import type { LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
import { liquidityMakerConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface LiquidityMakerAppProps {
|
interface LiquidityMakerAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) {
|
export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<LiquidityMakerEngineSnapshot>("liquidity-maker", {
|
const [snapshot, setSnapshot] = useState<LiquidityMakerEngineSnapshot | null>(null);
|
||||||
onExit
|
const [error, setError] = useState<Error | null>(null);
|
||||||
});
|
const engineRef = useRef<LiquidityMakerEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: liquidityMakerConfig.symbol });
|
||||||
|
const engine = new LiquidityMakerEngine(liquidityMakerConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: LiquidityMakerEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+44
-5
@@ -1,8 +1,10 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import { type MakerEngineSnapshot } from "../strategy/maker-engine";
|
import { makerConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
@@ -10,8 +12,45 @@ interface MakerAppProps {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function MakerApp({ onExit }: MakerAppProps) {
|
export function MakerApp({ onExit }: MakerAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<MakerEngineSnapshot>("maker", { onExit });
|
const [snapshot, setSnapshot] = useState<MakerEngineSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const engineRef = useRef<MakerEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol });
|
||||||
|
const engine = new MakerEngine(makerConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: MakerEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+47
-31
@@ -1,19 +1,59 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import type { MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
import { makerPointsConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface MakerPointsAppProps {
|
interface MakerPointsAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<MakerPointsSnapshot>("maker-points", {
|
const [snapshot, setSnapshot] = useState<MakerPointsSnapshot | null>(null);
|
||||||
onExit
|
const [error, setError] = useState<Error | null>(null);
|
||||||
});
|
const engineRef = useRef<MakerPointsEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (exchangeId !== "standx") {
|
||||||
|
throw new Error("Maker Points strategy only supports the StandX exchange.");
|
||||||
|
}
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerPointsConfig.symbol });
|
||||||
|
const engine = new MakerPointsEngine(makerPointsConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: MakerPointsSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -43,20 +83,12 @@ 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,
|
||||||
}));
|
}));
|
||||||
@@ -66,7 +98,6 @@ 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 },
|
||||||
];
|
];
|
||||||
@@ -105,9 +136,6 @@ 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}>
|
||||||
@@ -122,12 +150,6 @@ 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", {
|
||||||
@@ -148,15 +170,9 @@ 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>
|
||||||
|
|||||||
@@ -1,19 +1,56 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import type { OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
import { makerConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface OffsetMakerAppProps {
|
interface OffsetMakerAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
|
export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<OffsetMakerEngineSnapshot>("offset-maker", {
|
const [snapshot, setSnapshot] = useState<OffsetMakerEngineSnapshot | null>(null);
|
||||||
onExit
|
const [error, setError] = useState<Error | null>(null);
|
||||||
});
|
const engineRef = useRef<OffsetMakerEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerConfig.symbol });
|
||||||
|
const engine = new OffsetMakerEngine(makerConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: OffsetMakerEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+44
-8
@@ -1,9 +1,11 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import type { SwingEngineSnapshot } from "../strategy/swing-engine";
|
import { swingConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
const READY_MESSAGE = t("swing.readyMessage");
|
const READY_MESSAGE = t("swing.readyMessage");
|
||||||
@@ -12,11 +14,45 @@ interface SwingAppProps {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function SwingApp({ onExit }: SwingAppProps) {
|
export function SwingApp({ onExit }: SwingAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<SwingEngineSnapshot>("swing", {
|
const [snapshot, setSnapshot] = useState<SwingEngineSnapshot | null>(null);
|
||||||
onExit,
|
const [error, setError] = useState<Error | null>(null);
|
||||||
cloneSnapshot: (next) => ({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] }),
|
const engineRef = useRef<SwingEngine | null>(null);
|
||||||
});
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: swingConfig.symbol });
|
||||||
|
const engine = new SwingEngine(swingConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: SwingEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+44
-7
@@ -1,9 +1,11 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import type { TrendEngineSnapshot } from "../strategy/trend-engine";
|
import { tradingConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||||
import { formatNumber, formatTrendLabel } from "../utils/format";
|
import { formatNumber, formatTrendLabel } from "../utils/format";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { useStrategyEngine } from "./useStrategyEngine";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
const READY_MESSAGE = t("trend.readyMessage");
|
const READY_MESSAGE = t("trend.readyMessage");
|
||||||
@@ -12,10 +14,45 @@ interface TrendAppProps {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function TrendApp({ onExit }: TrendAppProps) {
|
export function TrendApp({ onExit }: TrendAppProps) {
|
||||||
const { snapshot, error, exchangeName } = useStrategyEngine<TrendEngineSnapshot>("trend", {
|
const [snapshot, setSnapshot] = useState<TrendEngineSnapshot | null>(null);
|
||||||
onExit
|
const [error, setError] = useState<Error | null>(null);
|
||||||
});
|
const engineRef = useRef<TrendEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol });
|
||||||
|
const engine = new TrendEngine(tradingConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: TrendEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useInput } from "ink";
|
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import {
|
|
||||||
getStrategyDefinition,
|
|
||||||
strategyUnavailableReason,
|
|
||||||
type StrategyEngine,
|
|
||||||
type StrategySnapshot,
|
|
||||||
} from "../strategy/registry";
|
|
||||||
import type { StrategyId } from "../strategy/strategy-ids";
|
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export interface UseStrategyEngineOptions<TSnapshot extends StrategySnapshot> {
|
|
||||||
/** Called when the user presses Escape, after the engine is stopped. */
|
|
||||||
onExit: () => void;
|
|
||||||
/**
|
|
||||||
* Copies the mutable parts of a snapshot so React sees a new value. Defaults to
|
|
||||||
* a shallow copy with a fresh tradeLog; screens that render other engine-owned
|
|
||||||
* arrays must copy those too.
|
|
||||||
*/
|
|
||||||
cloneSnapshot?: (snapshot: TSnapshot) => TSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseStrategyEngineResult<TSnapshot extends StrategySnapshot> {
|
|
||||||
snapshot: TSnapshot | null;
|
|
||||||
error: Error | null;
|
|
||||||
exchangeName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultClone<TSnapshot extends StrategySnapshot>(snapshot: TSnapshot): TSnapshot {
|
|
||||||
return { ...snapshot, tradeLog: [...snapshot.tradeLog] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns a strategy engine for the lifetime of a screen: builds the adapter, wires
|
|
||||||
* the update subscription into React state, stops the engine on unmount or Escape.
|
|
||||||
*
|
|
||||||
* Availability is read from the registry, so a screen cannot disagree with the
|
|
||||||
* menu or the CLI about where its strategy may run.
|
|
||||||
*/
|
|
||||||
export function useStrategyEngine<TSnapshot extends StrategySnapshot>(
|
|
||||||
strategyId: StrategyId,
|
|
||||||
options: UseStrategyEngineOptions<TSnapshot>
|
|
||||||
): UseStrategyEngineResult<TSnapshot> {
|
|
||||||
const { onExit, cloneSnapshot } = options;
|
|
||||||
const [snapshot, setSnapshot] = useState<TSnapshot | null>(null);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const engineRef = useRef<StrategyEngine<TSnapshot> | null>(null);
|
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
|
||||||
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
|
||||||
|
|
||||||
const cloneRef = useRef(cloneSnapshot);
|
|
||||||
cloneRef.current = cloneSnapshot;
|
|
||||||
|
|
||||||
useInput(
|
|
||||||
(_input, key) => {
|
|
||||||
if (key.escape) {
|
|
||||||
engineRef.current?.stop();
|
|
||||||
onExit();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive: inputSupported }
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const blocked = strategyUnavailableReason(strategyId, exchangeId);
|
|
||||||
if (blocked) {
|
|
||||||
setError(new Error(blocked));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const definition = getStrategyDefinition(strategyId);
|
|
||||||
const adapter = buildAdapterFromEnv({ exchangeId, symbol: definition.symbol() });
|
|
||||||
const engine = definition.createEngine(adapter) as StrategyEngine<TSnapshot>;
|
|
||||||
engineRef.current = engine;
|
|
||||||
setSnapshot(engine.getSnapshot());
|
|
||||||
|
|
||||||
const handler = (next: TSnapshot) => {
|
|
||||||
setSnapshot((cloneRef.current ?? defaultClone)(next));
|
|
||||||
};
|
|
||||||
engine.on("update", handler);
|
|
||||||
engine.start();
|
|
||||||
return () => {
|
|
||||||
engine.off("update", handler);
|
|
||||||
engine.stop();
|
|
||||||
engineRef.current = null;
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [exchangeId, strategyId]);
|
|
||||||
|
|
||||||
return { snapshot, error, exchangeName };
|
|
||||||
}
|
|
||||||
+3
-4
@@ -1,11 +1,10 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
/** Direction the trend engine reports; a domain value, not display text. */
|
export type TrendLabel = "做多" | "做空" | "无信号";
|
||||||
export type TrendLabel = "long" | "short" | "none";
|
|
||||||
|
|
||||||
export function formatTrendLabel(trend: TrendLabel): string {
|
export function formatTrendLabel(trend: TrendLabel): string {
|
||||||
if (trend === "long") return t("trend.label.long");
|
if (trend === "做多") return t("trend.label.long");
|
||||||
if (trend === "short") return t("trend.label.short");
|
if (trend === "做空") return t("trend.label.short");
|
||||||
return t("trend.label.none");
|
return t("trend.label.none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
|
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
|
||||||
import { t } from "../i18n";
|
|
||||||
|
|
||||||
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
|
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
|
||||||
|
|
||||||
@@ -69,18 +68,18 @@ export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | nu
|
|||||||
if (!status.expired) {
|
if (!status.expired) {
|
||||||
if (status.remainingMs != null && status.remainingMs < 3600_000) {
|
if (status.remainingMs != null && status.remainingMs < 3600_000) {
|
||||||
const mins = Math.ceil(status.remainingMs / 60_000);
|
const mins = Math.ceil(status.remainingMs / 60_000);
|
||||||
return t("token.expiringSoon", { minutes: mins });
|
return `StandX Token 将在 ${mins} 分钟后过期`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (status.state) {
|
switch (status.state) {
|
||||||
case "expired":
|
case "expired":
|
||||||
return t("token.expiredCancelling");
|
return "StandX Token 已过期,正在取消所有挂单";
|
||||||
case "expired_with_position":
|
case "expired_with_position":
|
||||||
return t("token.expiredWithPosition");
|
return "StandX Token 已过期,仅保留平仓/止损逻辑";
|
||||||
case "silent":
|
case "silent":
|
||||||
return t("token.expiredSilent");
|
return "StandX Token 已过期,进入静默数据接收模式";
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { t } from "../src/i18n";
|
|
||||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||||
import type { AccountSnapshot, Depth, Kline, Order, Ticker } from "../src/exchanges/types";
|
import type { AccountSnapshot, Depth, Kline, Order, Ticker } from "../src/exchanges/types";
|
||||||
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
|
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
|
||||||
@@ -72,13 +71,7 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -110,11 +103,7 @@ describe("MakerPointsEngine Binance depth health defense", () => {
|
|||||||
expect((engine as any).defenseMode).toBe(true);
|
expect((engine as any).defenseMode).toBe(true);
|
||||||
|
|
||||||
const logs = ((engine as any).tradeLog.all() as Array<{ detail: string }>).map((entry) => entry.detail);
|
const logs = ((engine as any).tradeLog.all() as Array<{ detail: string }>).map((entry) => entry.detail);
|
||||||
expect(
|
expect(logs.some((detail) => detail.includes("Binance簿记异常(orderbook_not_ready)"))).toBe(true);
|
||||||
logs.some((detail) =>
|
|
||||||
detail.includes(t("defense.reason.binanceBook", { reason: "orderbook_not_ready" }))
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
engine.stop();
|
engine.stop();
|
||||||
});
|
});
|
||||||
@@ -142,13 +131,7 @@ 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,
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-68
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { resolveSymbolFromEnv } from "../src/config";
|
import { resolveSymbolFromEnv } from "../src/config";
|
||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env };
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
@@ -72,70 +72,3 @@ 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { t } from "../src/i18n";
|
|
||||||
|
|
||||||
const SRC = join(import.meta.dirname, "..", "src");
|
|
||||||
const I18N_FILE = join(SRC, "i18n", "index.ts");
|
|
||||||
|
|
||||||
function walk(dir: string, out: string[] = []): string[] {
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
const full = join(dir, entry);
|
|
||||||
if (statSync(full).isDirectory()) {
|
|
||||||
walk(full, out);
|
|
||||||
} else if (/\.tsx?$/.test(entry)) {
|
|
||||||
out.push(full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A string or template literal containing a CJK character. */
|
|
||||||
const CJK_IN_LITERAL = /["`][^"`\n]*[一-龥][^"`\n]*["`]/;
|
|
||||||
|
|
||||||
describe("i18n coverage", () => {
|
|
||||||
it("keeps user-facing text out of source files", () => {
|
|
||||||
// Chinese literals outside the translation table cannot be shown in English,
|
|
||||||
// which is how the order log, grid events and defense alerts stayed
|
|
||||||
// untranslatable for so long.
|
|
||||||
const offenders: string[] = [];
|
|
||||||
for (const file of walk(SRC)) {
|
|
||||||
if (file === I18N_FILE) continue;
|
|
||||||
if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue;
|
|
||||||
const lines = readFileSync(file, "utf8").split("\n");
|
|
||||||
lines.forEach((line, index) => {
|
|
||||||
if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*")) return;
|
|
||||||
if (CJK_IN_LITERAL.test(line)) {
|
|
||||||
offenders.push(`${file.slice(SRC.length + 1)}:${index + 1} ${line.trim()}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
expect(offenders).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives every key both a zh and an en translation", () => {
|
|
||||||
const source = readFileSync(I18N_FILE, "utf8");
|
|
||||||
const table = source.slice(
|
|
||||||
source.indexOf("const translations"),
|
|
||||||
source.indexOf("const formatTemplate")
|
|
||||||
);
|
|
||||||
const keys = [...table.matchAll(/^ {2}"([\w.]+)":/gm)].map((m) => m[1]!);
|
|
||||||
expect(keys.length).toBeGreaterThan(400);
|
|
||||||
|
|
||||||
const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index);
|
|
||||||
expect(duplicates).toEqual([]);
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
expect(t(key, {}, "zh"), `${key} missing zh`).not.toBe(key);
|
|
||||||
expect(t(key, {}, "en"), `${key} missing en`).not.toBe(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("substitutes placeholders in both languages", () => {
|
|
||||||
expect(t("log.order.closePlaced", { side: "BUY" }, "zh")).toContain("BUY");
|
|
||||||
expect(t("log.order.closePlaced", { side: "BUY" }, "en")).toContain("BUY");
|
|
||||||
expect(t("log.order.closePlaced", { side: "BUY" }, "en")).not.toContain("{side}");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves an unknown placeholder visible rather than printing undefined", () => {
|
|
||||||
expect(t("log.order.closePlaced", {}, "en")).toContain("{side}");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { IsolatedMarginGuard } from "../src/strategy/common/isolated-margin-guard";
|
|
||||||
import type { AccountSnapshot } from "../src/exchanges/types";
|
|
||||||
import { t } from "../src/i18n";
|
|
||||||
|
|
||||||
const SYMBOL = "BTC-USD";
|
|
||||||
|
|
||||||
function snapshotWithMode(mode: string | null): AccountSnapshot {
|
|
||||||
return {
|
|
||||||
positions: [{ symbol: SYMBOL, ...(mode ? { marginType: mode } : {}) }],
|
|
||||||
} as unknown as AccountSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeGuard(options: {
|
|
||||||
enabled?: boolean;
|
|
||||||
initialMode?: string | null;
|
|
||||||
/** Modes the account reports on successive polls. */
|
|
||||||
polledModes?: Array<string | null>;
|
|
||||||
changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise<void>;
|
|
||||||
omitCapabilities?: boolean;
|
|
||||||
} = {}) {
|
|
||||||
const logs: Array<[string, string]> = [];
|
|
||||||
let current = snapshotWithMode("initialMode" in options ? options.initialMode! : "cross");
|
|
||||||
const polled = [...(options.polledModes ?? [])];
|
|
||||||
const queryAccountSnapshot = vi.fn(async () => snapshotWithMode(polled.shift() ?? "cross"));
|
|
||||||
const changeMarginMode = vi.fn(options.changeMarginMode ?? (async () => {}));
|
|
||||||
|
|
||||||
const guard = new IsolatedMarginGuard({
|
|
||||||
symbol: SYMBOL,
|
|
||||||
enabled: options.enabled ?? true,
|
|
||||||
log: (type, detail) => logs.push([type, detail]),
|
|
||||||
currentSnapshot: () => current,
|
|
||||||
changeMarginMode: options.omitCapabilities ? undefined : changeMarginMode,
|
|
||||||
queryAccountSnapshot: options.omitCapabilities ? undefined : queryAccountSnapshot,
|
|
||||||
applySnapshot: (next) => {
|
|
||||||
current = next;
|
|
||||||
},
|
|
||||||
// No real waiting in tests.
|
|
||||||
sleep: async () => {},
|
|
||||||
});
|
|
||||||
return { guard, logs, changeMarginMode, queryAccountSnapshot };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("IsolatedMarginGuard", () => {
|
|
||||||
it("is inert on venues without a per-symbol margin mode", async () => {
|
|
||||||
const { guard, changeMarginMode } = makeGuard({ enabled: false });
|
|
||||||
expect(await guard.ensureIsolated()).toBe(true);
|
|
||||||
expect(guard.currentMode()).toBeNull();
|
|
||||||
expect(changeMarginMode).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when already isolated", async () => {
|
|
||||||
const { guard, changeMarginMode } = makeGuard({ initialMode: "isolated" });
|
|
||||||
expect(await guard.ensureIsolated()).toBe(true);
|
|
||||||
expect(changeMarginMode).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalises the reported mode", async () => {
|
|
||||||
const { guard } = makeGuard({ initialMode: " ISOLATED " });
|
|
||||||
expect(guard.currentMode()).toBe("isolated");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an unknown mode as null", async () => {
|
|
||||||
const { guard } = makeGuard({ initialMode: null });
|
|
||||||
expect(guard.currentMode()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches and confirms through a snapshot poll", async () => {
|
|
||||||
const { guard, logs, changeMarginMode } = makeGuard({
|
|
||||||
initialMode: "cross",
|
|
||||||
polledModes: ["cross", "isolated"],
|
|
||||||
});
|
|
||||||
expect(await guard.ensureIsolated()).toBe(true);
|
|
||||||
expect(changeMarginMode).toHaveBeenCalledWith({ symbol: SYMBOL, marginMode: "isolated" });
|
|
||||||
expect(logs.some(([, detail]) => detail === t("log.margin.switched"))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives up after the confirm attempts run out", async () => {
|
|
||||||
const { guard, logs, queryAccountSnapshot } = makeGuard({ polledModes: [] });
|
|
||||||
expect(await guard.ensureIsolated()).toBe(false);
|
|
||||||
expect(queryAccountSnapshot).toHaveBeenCalledTimes(10);
|
|
||||||
expect(logs.some(([type]) => type === "warn")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports failure when the venue rejects the change", async () => {
|
|
||||||
const { guard, logs } = makeGuard({
|
|
||||||
changeMarginMode: async () => {
|
|
||||||
throw new Error("rejected");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(await guard.ensureIsolated()).toBe(false);
|
|
||||||
expect(logs.some(([type]) => type === "error")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when the adapter cannot change margin mode", async () => {
|
|
||||||
const { guard } = makeGuard({ omitCapabilities: true });
|
|
||||||
expect(await guard.ensureIsolated()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shares one in-flight switch across concurrent ticks", async () => {
|
|
||||||
let release!: () => void;
|
|
||||||
const gate = new Promise<void>((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
const { guard, changeMarginMode } = makeGuard({
|
|
||||||
polledModes: ["isolated"],
|
|
||||||
changeMarginMode: async () => {
|
|
||||||
await gate;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = guard.ensureIsolated();
|
|
||||||
// A tick arriving mid-switch must not fire a second change request.
|
|
||||||
const second = await guard.ensureIsolated();
|
|
||||||
expect(second).toBe(false);
|
|
||||||
release();
|
|
||||||
expect(await first).toBe(true);
|
|
||||||
expect(changeMarginMode).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a fresh attempt after the previous one settles", async () => {
|
|
||||||
const { guard, changeMarginMode } = makeGuard({ polledModes: [] });
|
|
||||||
expect(await guard.ensureIsolated()).toBe(false);
|
|
||||||
expect(await guard.ensureIsolated()).toBe(false);
|
|
||||||
expect(changeMarginMode).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -50,6 +50,6 @@ describe("LighterSigner", () => {
|
|||||||
expect(signed.txHash.length).toBeGreaterThan(0);
|
expect(signed.txHash.length).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
expect(typeof signed.signature).toBe("string");
|
expect(typeof signed.signature).toBe("string");
|
||||||
expect(signed.signature?.length ?? 0).toBeGreaterThan(0);
|
expect(signed.signature.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,13 +45,7 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -85,13 +79,7 @@ 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,13 +67,7 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -113,13 +107,7 @@ 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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,11 +60,10 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
|
|||||||
price: "100",
|
price: "100",
|
||||||
origQty: "1",
|
origQty: "1",
|
||||||
executedQty: "0",
|
executedQty: "0",
|
||||||
stopPrice: "0",
|
|
||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
updateTime: Date.now(),
|
updateTime: Date.now(),
|
||||||
reduceOnly: false,
|
reduceOnly: "false",
|
||||||
closePosition: false,
|
closePosition: "false",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -85,13 +84,7 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -145,13 +138,7 @@ 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,13 +67,7 @@ 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,110 +45,70 @@ afterEach(() => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildEngine(adapter: StubAdapter, restingBuyPrice: string): MakerPointsEngine {
|
|
||||||
const engine = new MakerPointsEngine(
|
|
||||||
{
|
|
||||||
symbol: "BTC-USD",
|
|
||||||
perOrderAmount: 0.01,
|
|
||||||
closeThreshold: 0,
|
|
||||||
stopLossUsd: 1,
|
|
||||||
refreshIntervalMs: 10_000,
|
|
||||||
maxLogEntries: 20,
|
|
||||||
maxCloseSlippagePct: 0.05,
|
|
||||||
priceTick: 0.1,
|
|
||||||
qtyStep: 0.001,
|
|
||||||
enableBand0To10: true,
|
|
||||||
enableBand10To30: false,
|
|
||||||
enableBand30To100: false,
|
|
||||||
band0To10Amount: 0.01,
|
|
||||||
band10To30Amount: 0.01,
|
|
||||||
band30To100Amount: 0.01,
|
|
||||||
band0To10Bps: 9,
|
|
||||||
band10To30Bps: 29,
|
|
||||||
band30To100Bps: 40,
|
|
||||||
maxDistanceBps: 95,
|
|
||||||
minRepriceBps: 3,
|
|
||||||
bandRepriceRatio: 0.15,
|
|
||||||
slOffsetBps: 2,
|
|
||||||
enableBinanceDepthCancel: false,
|
|
||||||
filterMinDepth: 0,
|
|
||||||
},
|
|
||||||
adapter
|
|
||||||
);
|
|
||||||
|
|
||||||
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
|
|
||||||
(engine as any).initialOrderSnapshotReady = true;
|
|
||||||
(engine as any).defenseMode = false;
|
|
||||||
(engine as any).reconnectResetPending = false;
|
|
||||||
(engine as any).stopLossProcessing = false;
|
|
||||||
(engine as any).openOrders = [
|
|
||||||
{
|
|
||||||
orderId: 1,
|
|
||||||
clientOrderId: "entry-order",
|
|
||||||
symbol: "BTC-USD",
|
|
||||||
side: "BUY",
|
|
||||||
type: "LIMIT",
|
|
||||||
status: "NEW",
|
|
||||||
price: restingBuyPrice,
|
|
||||||
origQty: "0.01",
|
|
||||||
executedQty: "0",
|
|
||||||
stopPrice: "0",
|
|
||||||
time: Date.now(),
|
|
||||||
updateTime: Date.now(),
|
|
||||||
reduceOnly: false,
|
|
||||||
closePosition: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return engine;
|
|
||||||
}
|
|
||||||
|
|
||||||
// bid1 99.9 / ask1 100.9 → 中值 100.4;0-10 档目标 9 bps,容差 max(3, 9×0.15)=3
|
|
||||||
// 所以保留窗口是距中值 6–12 bps,即 100.28–100.34
|
|
||||||
const DEPTH = {
|
|
||||||
lastUpdateId: 1,
|
|
||||||
bids: [["99.9", "1"]] as Array<[string, string]>,
|
|
||||||
asks: [["100.9", "1"]] as Array<[string, string]>,
|
|
||||||
eventTime: Date.now(),
|
|
||||||
symbol: "BTC-USD",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("MakerPointsEngine immediate reprice", () => {
|
describe("MakerPointsEngine immediate reprice", () => {
|
||||||
it("leaves a quote alone while it is still inside its band tolerance", () => {
|
it("triggers an immediate tick when min reprice bps threshold is reached", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const adapter = new StubAdapter();
|
const adapter = new StubAdapter();
|
||||||
// 100.31 距中值 8.96 bps,仍在 9±3 内 —— 不该撤挂,订单得以跨过 3 秒计分门槛
|
|
||||||
const engine = buildEngine(adapter, "100.31");
|
const engine = new MakerPointsEngine(
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
perOrderAmount: 0.01,
|
||||||
|
closeThreshold: 0,
|
||||||
|
stopLossUsd: 1,
|
||||||
|
refreshIntervalMs: 10_000,
|
||||||
|
maxLogEntries: 20,
|
||||||
|
maxCloseSlippagePct: 0.05,
|
||||||
|
priceTick: 0.1,
|
||||||
|
qtyStep: 0.001,
|
||||||
|
enableBand0To10: true,
|
||||||
|
enableBand10To30: false,
|
||||||
|
enableBand30To100: false,
|
||||||
|
band0To10Amount: 0.01,
|
||||||
|
band10To30Amount: 0.01,
|
||||||
|
band30To100Amount: 0.01,
|
||||||
|
minRepriceBps: 3,
|
||||||
|
enableBinanceDepthCancel: false,
|
||||||
|
filterMinDepth: 0,
|
||||||
|
},
|
||||||
|
adapter
|
||||||
|
);
|
||||||
|
|
||||||
|
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
|
||||||
|
(engine as any).initialOrderSnapshotReady = true;
|
||||||
|
(engine as any).defenseMode = false;
|
||||||
|
(engine as any).reconnectResetPending = false;
|
||||||
|
(engine as any).stopLossProcessing = false;
|
||||||
|
(engine as any).lastQuoteBid1 = 100;
|
||||||
|
(engine as any).lastQuoteAsk1 = 101;
|
||||||
|
(engine as any).openOrders = [
|
||||||
|
{
|
||||||
|
orderId: 1,
|
||||||
|
clientOrderId: "entry-order",
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
side: "BUY",
|
||||||
|
type: "LIMIT",
|
||||||
|
status: "NEW",
|
||||||
|
price: "99.0",
|
||||||
|
origQty: "0.01",
|
||||||
|
executedQty: "0",
|
||||||
|
stopPrice: "0",
|
||||||
|
time: Date.now(),
|
||||||
|
updateTime: Date.now(),
|
||||||
|
reduceOnly: false,
|
||||||
|
closePosition: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
|
const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined);
|
||||||
|
|
||||||
adapter.emitDepth(DEPTH);
|
adapter.emitDepth({
|
||||||
|
lastUpdateId: 1,
|
||||||
expect(tickSpy).not.toHaveBeenCalled();
|
bids: [["99.9", "1"]],
|
||||||
engine.stop();
|
asks: [["100.9", "1"]],
|
||||||
});
|
eventTime: Date.now(),
|
||||||
|
symbol: "BTC-USD",
|
||||||
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,13 +81,7 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -152,13 +146,7 @@ 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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
|
||||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||||
import type { Order } from "../src/exchanges/types";
|
import type { Order } from "../src/exchanges/types";
|
||||||
import { t } from "../src/i18n";
|
import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator";
|
||||||
import type {
|
|
||||||
OrderContext,
|
|
||||||
OrderLockMap,
|
|
||||||
OrderPendingMap,
|
|
||||||
OrderTimerMap,
|
|
||||||
} from "../src/core/order-coordinator";
|
|
||||||
import {
|
import {
|
||||||
deduplicateOrders,
|
deduplicateOrders,
|
||||||
placeOrder,
|
placeOrder,
|
||||||
@@ -66,81 +60,132 @@ describe("order-coordinator", () => {
|
|||||||
process.env.EXCHANGE = originalExchange;
|
process.env.EXCHANGE = originalExchange;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** One order context plus handles on the pieces the assertions poke at. */
|
it("deduplicates orders by type and side", async () => {
|
||||||
function createContext() {
|
|
||||||
const adapter = createMockExchange();
|
const adapter = createMockExchange();
|
||||||
const locks: OrderLockMap = {};
|
const locks: OrderLockMap = {};
|
||||||
const timers: OrderTimerMap = {};
|
const timers: OrderTimerMap = {};
|
||||||
const pending: OrderPendingMap = {};
|
const pending: OrderPendingMap = {};
|
||||||
const log = vi.fn();
|
const log = vi.fn();
|
||||||
const ctx: OrderContext = { adapter, symbol: "BTCUSDT", locks, timers, pendings: pending, log };
|
|
||||||
return { ctx, adapter, locks, timers, pending, log };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("deduplicates orders by type and side", async () => {
|
|
||||||
const { ctx, adapter, log } = createContext();
|
|
||||||
const openOrders: Order[] = [
|
const openOrders: Order[] = [
|
||||||
{ ...baseOrder, orderId: 1 },
|
{ ...baseOrder, orderId: 1 },
|
||||||
{ ...baseOrder, orderId: 2 },
|
{ ...baseOrder, orderId: 2 },
|
||||||
];
|
];
|
||||||
await deduplicateOrders(ctx, openOrders, "LIMIT", "BUY");
|
await deduplicateOrders(adapter, "BTCUSDT", openOrders, locks, timers, pending, "LIMIT", "BUY", log);
|
||||||
expect(adapter.cancelOrders).toHaveBeenCalledWith({ symbol: "BTCUSDT", orderIdList: [2] });
|
expect(adapter.cancelOrders).toHaveBeenCalledWith({ symbol: "BTCUSDT", orderIdList: [2] });
|
||||||
expect(log).toHaveBeenCalledWith("order", t("log.order.dedupeCancelled", { type: "LIMIT", ids: "2" }));
|
expect(log).toHaveBeenCalledWith("order", expect.stringContaining("去重撤销重复"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places limit orders and records pending id", async () => {
|
it("places limit orders and records pending id", async () => {
|
||||||
const { ctx, adapter, pending } = createContext();
|
const adapter = createMockExchange();
|
||||||
await placeOrder(ctx, { openOrders: [], side: "BUY", price: "100", amount: 1, reduceOnly: false });
|
const locks: OrderLockMap = {};
|
||||||
|
const timers: OrderTimerMap = {};
|
||||||
|
const pending: OrderPendingMap = {};
|
||||||
|
const log = vi.fn();
|
||||||
|
await placeOrder(
|
||||||
|
adapter,
|
||||||
|
"BTCUSDT",
|
||||||
|
[],
|
||||||
|
locks,
|
||||||
|
timers,
|
||||||
|
pending,
|
||||||
|
"BUY",
|
||||||
|
100,
|
||||||
|
1,
|
||||||
|
log,
|
||||||
|
false
|
||||||
|
);
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(pending.MARKET).toBeUndefined();
|
expect(pending.MARKET).toBeUndefined();
|
||||||
expect(pending.LIMIT).toBe(String(baseOrder.orderId));
|
expect(pending.LIMIT).toBe(String(baseOrder.orderId));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places market order and unlocks after completion", async () => {
|
it("places market order and unlocks after completion", async () => {
|
||||||
const { ctx, adapter, pending } = createContext();
|
const adapter = createMockExchange();
|
||||||
await placeMarketOrder(ctx, { openOrders: [], side: "SELL", amount: 1, reduceOnly: true });
|
const locks: OrderLockMap = {};
|
||||||
|
const timers: OrderTimerMap = {};
|
||||||
|
const pending: OrderPendingMap = {};
|
||||||
|
const log = vi.fn();
|
||||||
|
await placeMarketOrder(
|
||||||
|
adapter,
|
||||||
|
"BTCUSDT",
|
||||||
|
[],
|
||||||
|
locks,
|
||||||
|
timers,
|
||||||
|
pending,
|
||||||
|
"SELL",
|
||||||
|
1,
|
||||||
|
log,
|
||||||
|
true
|
||||||
|
);
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(pending.MARKET).toBe(String(baseOrder.orderId));
|
expect(pending.MARKET).toBe(String(baseOrder.orderId));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places stop loss order only when valid", async () => {
|
it("places stop loss order only when valid", async () => {
|
||||||
const { ctx, adapter, log } = createContext();
|
const adapter = createMockExchange();
|
||||||
await placeStopLossOrder(ctx, {
|
const locks: OrderLockMap = {};
|
||||||
openOrders: [],
|
const timers: OrderTimerMap = {};
|
||||||
side: "SELL",
|
const pending: OrderPendingMap = {};
|
||||||
stopPrice: 99,
|
const log = vi.fn();
|
||||||
quantity: 1,
|
await placeStopLossOrder(
|
||||||
lastPrice: 100,
|
adapter,
|
||||||
});
|
"BTCUSDT",
|
||||||
|
[],
|
||||||
|
locks,
|
||||||
|
timers,
|
||||||
|
pending,
|
||||||
|
"SELL",
|
||||||
|
99,
|
||||||
|
1,
|
||||||
|
100,
|
||||||
|
log
|
||||||
|
);
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(log).toHaveBeenCalledWith("stop", t("log.order.stopPlaced", { side: "SELL", stopPrice: 99 }));
|
expect(log).toHaveBeenCalledWith("stop", expect.stringContaining("STOP_MARKET"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places trailing stop order", async () => {
|
it("places trailing stop order", async () => {
|
||||||
const { ctx, adapter, log } = createContext();
|
const adapter = createMockExchange();
|
||||||
await placeTrailingStopOrder(ctx, {
|
const locks: OrderLockMap = {};
|
||||||
openOrders: [],
|
const timers: OrderTimerMap = {};
|
||||||
side: "SELL",
|
const pending: OrderPendingMap = {};
|
||||||
activationPrice: 101,
|
const log = vi.fn();
|
||||||
quantity: 1,
|
await placeTrailingStopOrder(
|
||||||
callbackRate: 0.2,
|
adapter,
|
||||||
});
|
"BTCUSDT",
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
[],
|
||||||
expect(log).toHaveBeenCalledWith(
|
locks,
|
||||||
"order",
|
timers,
|
||||||
t("log.order.trailingPlaced", { side: "SELL", activation: 101, callbackRate: 0.2 })
|
pending,
|
||||||
|
"SELL",
|
||||||
|
101,
|
||||||
|
1,
|
||||||
|
0.2,
|
||||||
|
log
|
||||||
);
|
);
|
||||||
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
|
expect(log).toHaveBeenCalledWith("order", expect.stringContaining("挂动态止盈单"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("market close cancels open orders before placing close order", async () => {
|
it("market close cancels open orders before placing close order", async () => {
|
||||||
const { ctx, adapter, log } = createContext();
|
const adapter = createMockExchange();
|
||||||
await marketClose(ctx, {
|
const locks: OrderLockMap = {};
|
||||||
openOrders: [{ ...baseOrder, orderId: 2 }],
|
const timers: OrderTimerMap = {};
|
||||||
side: "SELL",
|
const pending: OrderPendingMap = {};
|
||||||
quantity: 1,
|
const log = vi.fn();
|
||||||
});
|
await marketClose(
|
||||||
|
adapter,
|
||||||
|
"BTCUSDT",
|
||||||
|
[{ ...baseOrder, orderId: 2 }],
|
||||||
|
locks,
|
||||||
|
timers,
|
||||||
|
pending,
|
||||||
|
"SELL",
|
||||||
|
1,
|
||||||
|
log
|
||||||
|
);
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(log).toHaveBeenCalledWith("close", t("log.order.closePlaced", { side: "SELL" }));
|
expect(log).toHaveBeenCalledWith("close", expect.stringContaining("市价平仓"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unlockOperating clears timers and pending", () => {
|
it("unlockOperating clears timers and pending", () => {
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { PrecisionSyncer } from "../src/strategy/common/precision-syncer";
|
|
||||||
import type { ExchangeAdapter, ExchangePrecision } from "../src/exchanges/adapter";
|
|
||||||
|
|
||||||
function makeExchange(getPrecision?: () => Promise<ExchangePrecision | null>): ExchangeAdapter {
|
|
||||||
return { id: "stub", getPrecision } as unknown as ExchangeAdapter;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MESSAGES = {
|
|
||||||
synced: (p: ExchangePrecision) => `synced ${p.priceTick}/${p.qtyStep}`,
|
|
||||||
failed: (error: unknown) => `failed ${String(error)}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("PrecisionSyncer", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeds from config and writes exchange precision through to config", async () => {
|
|
||||||
const config = { priceTick: 0.1, qtyStep: 0.001 };
|
|
||||||
const syncer = new PrecisionSyncer(
|
|
||||||
makeExchange(async () => ({ priceTick: 0.01, qtyStep: 0.1 })),
|
|
||||||
config,
|
|
||||||
{ priceTick: config.priceTick, qtyStep: config.qtyStep },
|
|
||||||
() => {},
|
|
||||||
MESSAGES
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(syncer.priceTick).toBe(0.1);
|
|
||||||
syncer.start();
|
|
||||||
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.01));
|
|
||||||
|
|
||||||
expect(syncer.qtyStep).toBe(0.1);
|
|
||||||
expect(config.priceTick).toBe(0.01);
|
|
||||||
expect(config.qtyStep).toBe(0.1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs only when an increment actually moves", async () => {
|
|
||||||
const logs: string[] = [];
|
|
||||||
const syncer = new PrecisionSyncer(
|
|
||||||
makeExchange(async () => ({ priceTick: 0.1, qtyStep: 0.001 })),
|
|
||||||
{ priceTick: 0.1, qtyStep: 0.001 },
|
|
||||||
{ priceTick: 0.1, qtyStep: 0.001 },
|
|
||||||
(_type, detail) => logs.push(detail),
|
|
||||||
MESSAGES
|
|
||||||
);
|
|
||||||
|
|
||||||
syncer.start();
|
|
||||||
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.1));
|
|
||||||
expect(logs).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores non-positive increments from the exchange", async () => {
|
|
||||||
const syncer = new PrecisionSyncer(
|
|
||||||
makeExchange(async () => ({ priceTick: 0, qtyStep: Number.NaN })),
|
|
||||||
{ priceTick: 0.5, qtyStep: 0.25 },
|
|
||||||
{ priceTick: 0.5, qtyStep: 0.25 },
|
|
||||||
() => {},
|
|
||||||
MESSAGES
|
|
||||||
);
|
|
||||||
|
|
||||||
syncer.start();
|
|
||||||
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.5));
|
|
||||||
expect(syncer.qtyStep).toBe(0.25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries after a failure until the exchange answers", async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
const syncer = new PrecisionSyncer(
|
|
||||||
makeExchange(async () => {
|
|
||||||
attempts += 1;
|
|
||||||
if (attempts === 1) throw new Error("boom");
|
|
||||||
return { priceTick: 0.05, qtyStep: 0.5 };
|
|
||||||
}),
|
|
||||||
{ priceTick: 1, qtyStep: 1 },
|
|
||||||
{ priceTick: 1, qtyStep: 1 },
|
|
||||||
() => {},
|
|
||||||
MESSAGES
|
|
||||||
);
|
|
||||||
|
|
||||||
syncer.start();
|
|
||||||
await vi.waitFor(() => expect(attempts).toBe(1));
|
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
|
||||||
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.05));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stop() cancels the pending retry so a dead engine stops polling", async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
const syncer = new PrecisionSyncer(
|
|
||||||
makeExchange(async () => {
|
|
||||||
attempts += 1;
|
|
||||||
throw new Error("boom");
|
|
||||||
}),
|
|
||||||
{ priceTick: 1, qtyStep: 1 },
|
|
||||||
{ priceTick: 1, qtyStep: 1 },
|
|
||||||
() => {},
|
|
||||||
MESSAGES
|
|
||||||
);
|
|
||||||
|
|
||||||
syncer.start();
|
|
||||||
await vi.waitFor(() => expect(attempts).toBe(1));
|
|
||||||
syncer.stop();
|
|
||||||
await vi.advanceTimersByTimeAsync(10_000);
|
|
||||||
expect(attempts).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("start() is idempotent while a sync is in flight", async () => {
|
|
||||||
let attempts = 0;
|
|
||||||
const syncer = new PrecisionSyncer(
|
|
||||||
makeExchange(async () => {
|
|
||||||
attempts += 1;
|
|
||||||
return { priceTick: 0.2, qtyStep: 0.2 };
|
|
||||||
}),
|
|
||||||
{ priceTick: 1, qtyStep: 1 },
|
|
||||||
{ priceTick: 1, qtyStep: 1 },
|
|
||||||
() => {},
|
|
||||||
MESSAGES
|
|
||||||
);
|
|
||||||
|
|
||||||
syncer.start();
|
|
||||||
syncer.start();
|
|
||||||
syncer.start();
|
|
||||||
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
|
|
||||||
expect(attempts).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refresh() refetches after a completed sync", async () => {
|
|
||||||
let tick = 0.2;
|
|
||||||
let attempts = 0;
|
|
||||||
const syncer = new PrecisionSyncer(
|
|
||||||
makeExchange(async () => {
|
|
||||||
attempts += 1;
|
|
||||||
return { priceTick: tick, qtyStep: 1 };
|
|
||||||
}),
|
|
||||||
{ priceTick: 1, qtyStep: 1 },
|
|
||||||
{ priceTick: 1, qtyStep: 1 },
|
|
||||||
() => {},
|
|
||||||
MESSAGES
|
|
||||||
);
|
|
||||||
|
|
||||||
syncer.start();
|
|
||||||
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
|
|
||||||
tick = 0.4;
|
|
||||||
syncer.refresh();
|
|
||||||
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.4));
|
|
||||||
expect(attempts).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is inert when the adapter cannot report precision", async () => {
|
|
||||||
const syncer = new PrecisionSyncer(
|
|
||||||
makeExchange(undefined),
|
|
||||||
{ priceTick: 0.3, qtyStep: 0.3 },
|
|
||||||
{ priceTick: 0.3, qtyStep: 0.3 },
|
|
||||||
() => {},
|
|
||||||
MESSAGES
|
|
||||||
);
|
|
||||||
|
|
||||||
syncer.start();
|
|
||||||
await vi.advanceTimersByTimeAsync(5000);
|
|
||||||
expect(syncer.priceTick).toBe(0.3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import {
|
|
||||||
ReconnectScheduler,
|
|
||||||
exponentialBackoff,
|
|
||||||
fixedBackoff,
|
|
||||||
linearBackoff,
|
|
||||||
} from "../src/exchanges/reconnect-scheduler";
|
|
||||||
|
|
||||||
describe("backoff policies", () => {
|
|
||||||
it("fixed returns the same delay every attempt", () => {
|
|
||||||
const policy = fixedBackoff(3000);
|
|
||||||
expect([1, 2, 5].map(policy)).toEqual([3000, 3000, 3000]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exponential doubles from the base and caps", () => {
|
|
||||||
const policy = exponentialBackoff(1000, 8000);
|
|
||||||
expect([1, 2, 3, 4, 5].map(policy)).toEqual([1000, 2000, 4000, 8000, 8000]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("linear grows by the base and caps", () => {
|
|
||||||
const policy = linearBackoff(2000, 30_000);
|
|
||||||
expect([1, 2, 3, 20].map(policy)).toEqual([2000, 4000, 6000, 30_000]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ReconnectScheduler", () => {
|
|
||||||
beforeEach(() => vi.useFakeTimers());
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
it("reconnects after the backoff delay", async () => {
|
|
||||||
const connect = vi.fn();
|
|
||||||
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(1000) });
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
expect(connect).not.toHaveBeenCalled();
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
|
||||||
expect(connect).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses repeated schedule() calls into one pending attempt", async () => {
|
|
||||||
const connect = vi.fn();
|
|
||||||
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(1000) });
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
scheduler.schedule();
|
|
||||||
scheduler.schedule();
|
|
||||||
expect(scheduler.pending).toBe(true);
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
|
||||||
expect(connect).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("grows the delay across consecutive failures", async () => {
|
|
||||||
const delays: number[] = [];
|
|
||||||
const scheduler = new ReconnectScheduler({
|
|
||||||
connect: async () => {
|
|
||||||
throw new Error("refused");
|
|
||||||
},
|
|
||||||
backoff: exponentialBackoff(1000, 60_000),
|
|
||||||
onSchedule: (delay) => delays.push(delay),
|
|
||||||
});
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
|
||||||
await vi.advanceTimersByTimeAsync(4000);
|
|
||||||
expect(delays.slice(0, 3)).toEqual([1000, 2000, 4000]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resets the backoff once the socket opens", async () => {
|
|
||||||
const delays: number[] = [];
|
|
||||||
let failing = true;
|
|
||||||
const scheduler = new ReconnectScheduler({
|
|
||||||
connect: async () => {
|
|
||||||
if (failing) throw new Error("refused");
|
|
||||||
},
|
|
||||||
backoff: exponentialBackoff(1000, 60_000),
|
|
||||||
onSchedule: (delay) => delays.push(delay),
|
|
||||||
});
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
|
||||||
await vi.advanceTimersByTimeAsync(2000);
|
|
||||||
expect(scheduler.attemptCount).toBe(2);
|
|
||||||
|
|
||||||
// A successful open must clear the counter, or the next transient blip
|
|
||||||
// would wait as long as the last outage did.
|
|
||||||
failing = false;
|
|
||||||
scheduler.onConnected();
|
|
||||||
expect(scheduler.attemptCount).toBe(0);
|
|
||||||
|
|
||||||
delays.length = 0;
|
|
||||||
scheduler.schedule();
|
|
||||||
expect(delays[0]).toBe(1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a synchronous connect failure and retries", async () => {
|
|
||||||
const errors: unknown[] = [];
|
|
||||||
let calls = 0;
|
|
||||||
const scheduler = new ReconnectScheduler({
|
|
||||||
connect: () => {
|
|
||||||
calls += 1;
|
|
||||||
if (calls === 1) throw new Error("boom");
|
|
||||||
},
|
|
||||||
backoff: fixedBackoff(500),
|
|
||||||
onError: (error) => errors.push(error),
|
|
||||||
});
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
|
||||||
expect(errors).toHaveLength(1);
|
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
|
||||||
expect(calls).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honours shouldReconnect", async () => {
|
|
||||||
const connect = vi.fn();
|
|
||||||
let running = false;
|
|
||||||
const scheduler = new ReconnectScheduler({
|
|
||||||
connect,
|
|
||||||
backoff: fixedBackoff(100),
|
|
||||||
shouldReconnect: () => running,
|
|
||||||
});
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
expect(connect).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
running = true;
|
|
||||||
scheduler.schedule();
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
expect(connect).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancel() drops the pending attempt but keeps the scheduler usable", async () => {
|
|
||||||
const connect = vi.fn();
|
|
||||||
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
scheduler.cancel();
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
|
||||||
expect(connect).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
expect(connect).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stop() is permanent", async () => {
|
|
||||||
const connect = vi.fn();
|
|
||||||
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
scheduler.stop();
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
|
||||||
scheduler.schedule();
|
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
|
||||||
expect(connect).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not reconnect when the timer fires after stop()", async () => {
|
|
||||||
const connect = vi.fn();
|
|
||||||
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
|
||||||
|
|
||||||
scheduler.schedule();
|
|
||||||
scheduler.stop();
|
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
|
||||||
expect(connect).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
STRATEGY_DEFINITIONS,
|
|
||||||
availableStrategies,
|
|
||||||
getStrategyDefinition,
|
|
||||||
strategyUnavailableReason,
|
|
||||||
} from "../src/strategy/registry";
|
|
||||||
import { STRATEGY_IDS, isStrategyId, parseStrategyId } from "../src/strategy/strategy-ids";
|
|
||||||
|
|
||||||
describe("strategy ids", () => {
|
|
||||||
it("resolves canonical ids and documented aliases", () => {
|
|
||||||
expect(parseStrategyId("trend")).toBe("trend");
|
|
||||||
expect(parseStrategyId(" GRID ")).toBe("grid");
|
|
||||||
expect(parseStrategyId("offset")).toBe("offset-maker");
|
|
||||||
expect(parseStrategyId("offsetmaker")).toBe("offset-maker");
|
|
||||||
expect(parseStrategyId("makerpoints")).toBe("maker-points");
|
|
||||||
expect(parseStrategyId("maker_points")).toBe("maker-points");
|
|
||||||
expect(parseStrategyId("liquidity")).toBe("liquidity-maker");
|
|
||||||
expect(parseStrategyId("liquidity_maker")).toBe("liquidity-maker");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown names", () => {
|
|
||||||
expect(parseStrategyId("nope")).toBeNull();
|
|
||||||
expect(parseStrategyId("")).toBeNull();
|
|
||||||
expect(isStrategyId("nope")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("strategy registry", () => {
|
|
||||||
it("defines every id exactly once, in menu order", () => {
|
|
||||||
expect(STRATEGY_DEFINITIONS.map((d) => d.id)).toEqual([...STRATEGY_IDS]);
|
|
||||||
expect(new Set(STRATEGY_DEFINITIONS.map((d) => d.id)).size).toBe(STRATEGY_IDS.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives every strategy a console label and i18n keys", () => {
|
|
||||||
for (const definition of STRATEGY_DEFINITIONS) {
|
|
||||||
expect(definition.consoleLabel).toBeTruthy();
|
|
||||||
expect(definition.labelKey).toMatch(/^app\.strategy\./);
|
|
||||||
expect(definition.descriptionKey).toMatch(/^app\.strategy\./);
|
|
||||||
expect(typeof definition.symbol()).toBe("string");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gates maker-points to StandX", () => {
|
|
||||||
expect(strategyUnavailableReason("maker-points", "standx")).toBeNull();
|
|
||||||
expect(strategyUnavailableReason("maker-points", "aster")).toContain("StandX");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the menu and the CLI on one availability rule", () => {
|
|
||||||
// The menu shows exactly what startStrategy would accept — the two used to
|
|
||||||
// disagree, so basis appeared on exchanges where the runner then threw.
|
|
||||||
for (const exchangeId of ["aster", "standx", "backpack"] as const) {
|
|
||||||
const shown = availableStrategies(exchangeId).map((d) => d.id);
|
|
||||||
const runnable = STRATEGY_IDS.filter((id) => strategyUnavailableReason(id, exchangeId) == null);
|
|
||||||
expect(shown).toEqual(runnable);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides strategies whose environment gate is closed", () => {
|
|
||||||
const shown = availableStrategies("backpack").map((d) => d.id);
|
|
||||||
expect(shown).not.toContain("maker-points");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes an engine factory per strategy", () => {
|
|
||||||
for (const id of STRATEGY_IDS) {
|
|
||||||
expect(typeof getStrategyDefinition(id).createEngine).toBe("function");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
|
||||||
import { standxTokenConfig } from "../src/config";
|
|
||||||
import { t } from "../src/i18n";
|
|
||||||
import { TokenExpiryGuard } from "../src/strategy/common/token-expiry-guard";
|
|
||||||
|
|
||||||
const HOUR_MS = 3_600_000;
|
|
||||||
const original = standxTokenConfig.expiryTimestamp;
|
|
||||||
|
|
||||||
/** The config field is read on every call, so tests set it directly. */
|
|
||||||
function setExpiry(atMs: number | null): void {
|
|
||||||
standxTokenConfig.expiryTimestamp = atMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeGuard() {
|
|
||||||
const logs: Array<[string, string]> = [];
|
|
||||||
const notifications: unknown[] = [];
|
|
||||||
const cancelAllOrders = vi.fn(async () => {});
|
|
||||||
const onOrdersCancelled = vi.fn();
|
|
||||||
const guard = new TokenExpiryGuard({
|
|
||||||
log: (type, detail) => logs.push([type, detail]),
|
|
||||||
notify: (n) => notifications.push(n),
|
|
||||||
cancelAllOrders,
|
|
||||||
onOrdersCancelled,
|
|
||||||
});
|
|
||||||
return { guard, logs, notifications, cancelAllOrders, onOrdersCancelled };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TokenExpiryGuard", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
standxTokenConfig.expiryTimestamp = original;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays out of the way when no expiry is configured", async () => {
|
|
||||||
setExpiry(null);
|
|
||||||
const { guard, cancelAllOrders } = makeGuard();
|
|
||||||
const decision = await guard.evaluate({ positionAmt: 1, openOrderCount: 3 });
|
|
||||||
expect(decision).toEqual({ halt: false, closeOnly: false });
|
|
||||||
expect(cancelAllOrders).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing while the token is still valid", async () => {
|
|
||||||
setExpiry(Date.now() + HOUR_MS * 24);
|
|
||||||
const { guard, cancelAllOrders, notifications } = makeGuard();
|
|
||||||
const decision = await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
|
|
||||||
expect(decision).toEqual({ halt: false, closeOnly: false });
|
|
||||||
expect(cancelAllOrders).not.toHaveBeenCalled();
|
|
||||||
expect(notifications).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels once and keeps ticking while a position is still open", async () => {
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard, cancelAllOrders, onOrdersCancelled } = makeGuard();
|
|
||||||
|
|
||||||
const first = await guard.evaluate({ positionAmt: 2, openOrderCount: 4 });
|
|
||||||
expect(first).toEqual({ halt: false, closeOnly: true });
|
|
||||||
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onOrdersCancelled).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
await guard.evaluate({ positionAmt: 2, openOrderCount: 4 });
|
|
||||||
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs and notifies exactly once per episode", async () => {
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard, logs, notifications } = makeGuard();
|
|
||||||
|
|
||||||
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
|
|
||||||
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
|
|
||||||
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
|
|
||||||
|
|
||||||
expect(notifications).toHaveLength(1);
|
|
||||||
expect(logs.filter(([type]) => type === "warn")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("halts the tick once nothing is left to manage", async () => {
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard } = makeGuard();
|
|
||||||
expect((await guard.evaluate({ positionAmt: 0, openOrderCount: 0 })).halt).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("announces the silent mode only on entry", async () => {
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard, logs } = makeGuard();
|
|
||||||
await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
|
|
||||||
await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
|
|
||||||
const entryLogs = logs.filter(
|
|
||||||
([type, detail]) => type === "info" && detail === t("log.token.silentEntered")
|
|
||||||
);
|
|
||||||
expect(entryLogs).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries the cancel on the next tick when it fails", async () => {
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard, cancelAllOrders, logs } = makeGuard();
|
|
||||||
cancelAllOrders.mockRejectedValueOnce(new Error("network down"));
|
|
||||||
|
|
||||||
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
|
|
||||||
expect(logs.some(([type]) => type === "error")).toBe(true);
|
|
||||||
|
|
||||||
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
|
|
||||||
expect(cancelAllOrders).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats an already-gone order as a successful cancel", async () => {
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard, cancelAllOrders } = makeGuard();
|
|
||||||
cancelAllOrders.mockRejectedValueOnce(new Error("Unknown order sent."));
|
|
||||||
|
|
||||||
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
|
|
||||||
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
|
|
||||||
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips the cancel when there is nothing resting", async () => {
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard, cancelAllOrders } = makeGuard();
|
|
||||||
await guard.evaluate({ positionAmt: 1, openOrderCount: 0 });
|
|
||||||
expect(cancelAllOrders).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes closeOnlyMode for the engine's close-reason label", async () => {
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard } = makeGuard();
|
|
||||||
expect(guard.closeOnlyMode).toBe(false);
|
|
||||||
await guard.evaluate({ positionAmt: 3, openOrderCount: 0 });
|
|
||||||
expect(guard.closeOnlyMode).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-arms every latch once a fresh token arrives", async () => {
|
|
||||||
// The five latches must reset together; a stale one would silently suppress
|
|
||||||
// the log, alert, or cancel for the next expiry.
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
const { guard, notifications, cancelAllOrders, logs } = makeGuard();
|
|
||||||
|
|
||||||
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
|
|
||||||
expect(guard.closeOnlyMode).toBe(true);
|
|
||||||
expect(notifications).toHaveLength(1);
|
|
||||||
|
|
||||||
setExpiry(Date.now() + HOUR_MS * 24);
|
|
||||||
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
|
|
||||||
expect(guard.closeOnlyMode).toBe(false);
|
|
||||||
expect(guard.currentState).toBe("active");
|
|
||||||
|
|
||||||
setExpiry(Date.now() - HOUR_MS);
|
|
||||||
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
|
|
||||||
expect(notifications).toHaveLength(2);
|
|
||||||
expect(cancelAllOrders).toHaveBeenCalledTimes(2);
|
|
||||||
expect(logs.filter(([type]) => type === "warn")).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+1
-6
@@ -25,10 +25,5 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
}
|
||||||
|
|
||||||
// docs/ holds vendored third-party samples (ccxt examples); they are reference
|
|
||||||
// material, not compilation units, and their errors mask real ones in src/.
|
|
||||||
"include": ["index.ts", "src/**/*", "tests/**/*", "scripts/**/*"],
|
|
||||||
"exclude": ["node_modules", "docs"]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user