mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 17:28:08 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d88ddefb5 | ||
|
|
683352f737 | ||
|
|
a629bc940c | ||
|
|
fe7b8eb6f3 | ||
|
|
24339929dc | ||
|
|
ed855f6859 | ||
|
|
e144c1822f | ||
|
|
69271d33ca | ||
|
|
f1140f106a | ||
|
|
3b935b7979 | ||
|
|
00388f9166 | ||
|
|
a32efa2ba0 | ||
|
|
76704b6bdd | ||
|
|
168d8cbb08 | ||
|
|
9629c22496 | ||
|
|
a34d06f9b4 | ||
|
|
2ba3e80ad9 | ||
|
|
12e8e3e064 | ||
|
|
aa24995d28 | ||
|
|
d493642935 | ||
|
|
86670486a6 | ||
|
|
6496011d8f | ||
|
|
792351ab8a | ||
|
|
4915dc574e | ||
|
|
9866e8068f | ||
|
|
099af3ce01 | ||
|
|
445e634aa1 | ||
|
|
4bb1fee995 | ||
|
|
aad14395e0 | ||
|
|
598f2a0eb6 | ||
|
|
cb1cef6f1b | ||
|
|
fd034d493f | ||
|
|
2551670874 | ||
|
|
3ec7e9b8d6 |
+12
-1
@@ -14,8 +14,14 @@ STANDX_SYMBOL=BTC-USD
|
|||||||
# STANDX_BASE_URL=https://perps.standx.com
|
# STANDX_BASE_URL=https://perps.standx.com
|
||||||
# STANDX_WS_URL=wss://perps.standx.com/ws-stream/v1
|
# STANDX_WS_URL=wss://perps.standx.com/ws-stream/v1
|
||||||
# STANDX_SESSION_ID=
|
# STANDX_SESSION_ID=
|
||||||
# Optional: request signing key (ed25519 private key, hex or base64)
|
# 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)
|
||||||
|
# Get these values when generating API token at https://standx.com/user/session
|
||||||
|
# STANDX_TOKEN_CREATE_DATE=2026-01-15 # Token creation date (YYYY-MM-DD format)
|
||||||
|
# STANDX_TOKEN_VALIDITY_DAYS=30 # Token validity period in days
|
||||||
|
# Legacy method: direct expiry timestamp (Unix seconds)
|
||||||
|
# STANDX_TOKEN_EXPIRY=1737092800
|
||||||
|
|
||||||
# Core trading symbol and sizing
|
# Core trading symbol and sizing
|
||||||
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
||||||
@@ -154,3 +160,8 @@ NADO_MIN_SIZE_POLICY=adjust
|
|||||||
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
|
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
|
||||||
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
|
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
|
||||||
# NADO_DEBUG=false
|
# NADO_DEBUG=false
|
||||||
|
|
||||||
|
# Telegram notification configuration
|
||||||
|
# TELEGRAM_BOT_TOKEN= # Telegram bot token from @BotFather
|
||||||
|
# TELEGRAM_CHAT_ID= # Chat ID to receive notifications
|
||||||
|
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# RitMEX Bot - Claude Instructions
|
||||||
|
|
||||||
|
## Package Manager
|
||||||
|
|
||||||
|
**必须使用 Bun** - 这个项目使用 Bun 作为包管理器和运行时。所有能用 bun 执行的命令都必须使用 bun:
|
||||||
|
|
||||||
|
- 安装依赖: `bun install`
|
||||||
|
- 运行脚本: `bun run <script>`
|
||||||
|
- 执行测试: `bun test`
|
||||||
|
- 类型检查: `bun run typecheck`
|
||||||
|
|
||||||
|
**不要使用 npm、yarn 或 npx**
|
||||||
@@ -41,6 +41,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
| Lighter | zkLighter 永续 | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | 默认 `LIGHTER_ENV=testnet`
|
| Lighter | zkLighter 永续 | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | 默认 `LIGHTER_ENV=testnet`
|
||||||
| Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒
|
| Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒
|
||||||
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
|
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
|
||||||
|
| Nado | USDC 永续 | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | `NADO_ENV` 可切换 `inkMainnet`/`inkTestnet`
|
||||||
|
|
||||||
## 系统要求
|
## 系统要求
|
||||||
- Bun ≥ 1.2(需同时包含 `bun`、`bunx` 命令)
|
- Bun ≥ 1.2(需同时包含 `bun`、`bunx` 命令)
|
||||||
@@ -114,21 +115,30 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
|
|||||||
|
|
||||||
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
|
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
|
||||||
|
|
||||||
策略需要 StandX 的登录 token 才能下单
|
策略需要 StandX 的 API Token 和签名私钥才能下单。
|
||||||
|
|
||||||
获取方式:
|
**获取方式(使用 StandX 官方 API 生成功能):**
|
||||||
1. 打开 https://standx.ritmex.one/
|
1. 打开 StandX 官方 API 创建页面:https://standx.com/user/session
|
||||||
2. 连接钱包
|
2. 连接钱包并登录
|
||||||
3. 点击“登录”
|
3. 点击 **"Generate API Token"** 按钮
|
||||||
4. 导出登录信息里面会包含 token和代理钱包私钥,代理钱包仅用于交易签名,有效保证资产钱包安全
|
4. 页面会显示以下信息:
|
||||||
|
- **Token**(以 `eyJ` 开头的 JWT 字符串)→ 填入 `STANDX_TOKEN`
|
||||||
|
- **Ed25519 Private Key**(Base58 格式私钥,类似 `HdsyJD7oWgT...`)→ 填入 `STANDX_REQUEST_PRIVATE_KEY`
|
||||||
|
- **创建日期** 和 **有效期天数** → 用于配置 Token 过期提醒
|
||||||
|
|
||||||
请妥善保存,不要分享给他人
|
> Ed25519 Private Key 是系统自动生成的签名私钥,仅用于交易请求签名,你的资产仍在主钱包中,非常安全。
|
||||||
|
|
||||||
|
请妥善保存这些凭证,不要分享给他人。
|
||||||
|
|
||||||
|
**配置步骤:**
|
||||||
1. 设置 `EXCHANGE=standx`。
|
1. 设置 `EXCHANGE=standx`。
|
||||||
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
||||||
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(代理钱包私钥)。
|
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(Ed25519 签名私钥,Base58 格式)。
|
||||||
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
||||||
5. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
5. 推荐配置 Token 过期时间:
|
||||||
|
- `STANDX_TOKEN_CREATE_DATE`(创建日期,格式 `YYYY-MM-DD`)
|
||||||
|
- `STANDX_TOKEN_VALIDITY_DAYS`(有效期天数)
|
||||||
|
6. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
||||||
|
|
||||||
|
|
||||||
### GRVT
|
### GRVT
|
||||||
|
|||||||
+39
-11
@@ -1,7 +1,11 @@
|
|||||||
# ritmex-bot
|
# ritmex-bot
|
||||||
|
|
||||||
|
**Language Setting**: Set `LANG=en` in your `.env` file to display the CLI interface in English.
|
||||||
|
|
||||||
A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend engine, a Guardian stop sentinel, and two market-making modes. It offers instant restarts, realtime market data, structured logging, and an Ink-based CLI dashboard.
|
A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend engine, a Guardian stop sentinel, and two market-making modes. It offers instant restarts, realtime market data, structured logging, and an Ink-based CLI dashboard.
|
||||||
|
|
||||||
|
If you'd like to support this project and get fee discounts, please consider using these referral links:
|
||||||
|
|
||||||
* [Lighter referral link](https://app.lighter.xyz/?referral=111909FA)
|
* [Lighter referral link](https://app.lighter.xyz/?referral=111909FA)
|
||||||
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
||||||
* [StandX referral link](https://standx.com/referral?code=xingxingjun)
|
* [StandX referral link](https://standx.com/referral?code=xingxingjun)
|
||||||
@@ -11,11 +15,11 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
* [Backpack referral link](https://backpack.exchange/join/ritmex)
|
* [Backpack referral link](https://backpack.exchange/join/ritmex)
|
||||||
* [edgex referral link](https://pro.edgex.exchange/referral/BULL)
|
* [edgex referral link](https://pro.edgex.exchange/referral/BULL)
|
||||||
* [Paradex referral link](https://paradex.io/ref/xingxingjun)
|
* [Paradex referral link](https://paradex.io/ref/xingxingjun)
|
||||||
* [Apex referral link](https://join.omni.apex.exchange/RITHMEX)
|
* [Apex referral link](https://join.omni.apex.exchange/SEA)
|
||||||
|
|
||||||
## Documentation Map
|
## Documentation Map
|
||||||
- [中文 README](README.md)
|
|
||||||
- [Beginner-friendly Quick Start](simple-readme.md)
|
- [Beginner-friendly Quick Start](simple-readme.md)
|
||||||
|
- [Grid Trading Strategy Guide](grid-trading.md)
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
- **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
|
- **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
|
||||||
@@ -33,9 +37,10 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
| Lighter | zkLighter perpetuals | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | Defaults to `LIGHTER_ENV=testnet` |
|
| Lighter | zkLighter perpetuals | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | Defaults to `LIGHTER_ENV=testnet` |
|
||||||
| Backpack | USDC perpetuals | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | Set `BACKPACK_SANDBOX=true` for the sandbox |
|
| Backpack | USDC perpetuals | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | Set `BACKPACK_SANDBOX=true` for the sandbox |
|
||||||
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
|
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
|
||||||
|
| Nado | USDC perpetuals | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | Switch `NADO_ENV` between `inkMainnet` and `inkTestnet` |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Bun ≥ 1.2 (both `bun` and `bunx` on PATH)
|
- Bun >= 1.2 (both `bun` and `bunx` on PATH)
|
||||||
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
|
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
|
||||||
- Node.js is optional unless your tooling requires it
|
- Node.js is optional unless your tooling requires it
|
||||||
|
|
||||||
@@ -96,6 +101,7 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
|||||||
> ```
|
> ```
|
||||||
|
|
||||||
## Exchange Setup Guides
|
## Exchange Setup Guides
|
||||||
|
|
||||||
### Aster
|
### Aster
|
||||||
1. Keep `EXCHANGE=aster` (default value).
|
1. Keep `EXCHANGE=aster` (default value).
|
||||||
2. Supply `ASTER_API_KEY` and `ASTER_API_SECRET`.
|
2. Supply `ASTER_API_KEY` and `ASTER_API_SECRET`.
|
||||||
@@ -103,11 +109,33 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
|||||||
4. The bootstrap script auto-populates these variables; manual installs must maintain them.
|
4. The bootstrap script auto-populates these variables; manual installs must maintain them.
|
||||||
|
|
||||||
### StandX
|
### StandX
|
||||||
|
|
||||||
|
* [StandX Maker Points Strategy Guide](docs/standx/maker-points-guide.md)
|
||||||
|
|
||||||
|
The strategy requires a StandX API Token and signing private key to place orders.
|
||||||
|
|
||||||
|
**How to obtain (using StandX's official API generation feature):**
|
||||||
|
1. Open the StandX official API creation page: https://standx.com/user/session
|
||||||
|
2. Connect your wallet and log in
|
||||||
|
3. Click the **"Generate API Token"** button
|
||||||
|
4. The page will display the following information:
|
||||||
|
- **Token** (JWT string starting with `eyJ`) → Fill in `STANDX_TOKEN`
|
||||||
|
- **Ed25519 Private Key** (Base58 format, like `HdsyJD7oWgT...`) → Fill in `STANDX_REQUEST_PRIVATE_KEY`
|
||||||
|
- **Creation date** and **Validity days** → Used to configure token expiry reminders
|
||||||
|
|
||||||
|
> The Ed25519 Private Key is an auto-generated signing key used only for trade request signatures. Your assets remain in your main wallet and are completely safe.
|
||||||
|
|
||||||
|
Please keep these credentials safe and do not share them with anyone.
|
||||||
|
|
||||||
|
**Configuration steps:**
|
||||||
1. Set `EXCHANGE=standx`.
|
1. Set `EXCHANGE=standx`.
|
||||||
2. Provide `STANDX_TOKEN` (JWT token for perps API).
|
2. Provide `STANDX_TOKEN` (JWT token for perps API).
|
||||||
3. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`.
|
3. Provide `STANDX_REQUEST_PRIVATE_KEY` (Ed25519 signing private key, Base58 format).
|
||||||
4. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints.
|
4. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`.
|
||||||
5. Optional: `STANDX_REQUEST_PRIVATE_KEY` if the API requires body signatures.
|
5. Recommended: configure token expiry settings:
|
||||||
|
- `STANDX_TOKEN_CREATE_DATE` (creation date, format `YYYY-MM-DD`)
|
||||||
|
- `STANDX_TOKEN_VALIDITY_DAYS` (validity days)
|
||||||
|
6. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints.
|
||||||
|
|
||||||
### GRVT
|
### GRVT
|
||||||
1. Set `EXCHANGE=grvt` inside `.env`.
|
1. Set `EXCHANGE=grvt` inside `.env`.
|
||||||
@@ -117,25 +145,25 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
|||||||
|
|
||||||
### Lighter
|
### Lighter
|
||||||
1. Set `EXCHANGE=lighter`.
|
1. Set `EXCHANGE=lighter`.
|
||||||
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key).
|
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key). `LIGHTER_ACCOUNT_INDEX` is your account index, which you can find by opening DevTools (F12) on the official website and observing API requests. `LIGHTER_API_PRIVATE_KEY` is your API private key.
|
||||||
3. Switch `LIGHTER_ENV` to `mainnet`, `staging`, or `dev` when necessary; override `LIGHTER_BASE_URL` if endpoints differ.
|
3. Switch `LIGHTER_ENV` to `mainnet`, `staging`, or `dev` when necessary; override `LIGHTER_BASE_URL` if endpoints differ.
|
||||||
4. `LIGHTER_SYMBOL` defaults to `BTCUSDT`; override price/size decimals when markets differ.
|
4. `LIGHTER_SYMBOL` defaults to `BTCUSDT`; override price/size decimals when markets differ.
|
||||||
|
|
||||||
### Backpack
|
### Backpack
|
||||||
1. Set `EXCHANGE=backpack`.
|
1. Set `EXCHANGE=backpack`.
|
||||||
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount.
|
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount (defaults to main account ID).
|
||||||
3. Toggle `BACKPACK_SANDBOX=true` for the sandbox environment and verify `BACKPACK_SYMBOL` matches the contract (defaults to `BTC_USD_PERP`).
|
3. Toggle `BACKPACK_SANDBOX=true` for the sandbox environment and verify `BACKPACK_SYMBOL` matches the contract (defaults to `BTC_USD_PERP`).
|
||||||
4. Enable `BACKPACK_DEBUG=true` for verbose adapter logging.
|
4. Enable `BACKPACK_DEBUG=true` for verbose adapter logging.
|
||||||
|
|
||||||
### Paradex
|
### Paradex
|
||||||
1. Set `EXCHANGE=paradex`.
|
1. Set `EXCHANGE=paradex`.
|
||||||
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`.
|
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`. Note: These are your EVM wallet address and private key. It is recommended to create a brand new wallet and avoid storing unrelated assets in it.
|
||||||
3. The adapter connects to mainnet by default; enable `PARADEX_SANDBOX=true` and adjust `PARADEX_SYMBOL` for testnet usage.
|
3. The adapter connects to mainnet by default; enable `PARADEX_SANDBOX=true` and adjust `PARADEX_SYMBOL` for testnet usage.
|
||||||
4. Advanced tuning: use `PARADEX_USE_PRO`, `PARADEX_RECONNECT_DELAY_MS`, or debug flags as needed.
|
4. Advanced tuning: use `PARADEX_USE_PRO`, `PARADEX_RECONNECT_DELAY_MS`, or debug flags as needed.
|
||||||
|
|
||||||
### Nado
|
### Nado
|
||||||
1. Set `EXCHANGE=nado`.
|
1. Set `EXCHANGE=nado`.
|
||||||
2. On the Nado web app, open DevTools → switch to the `Application` tab → `Local Storage`, locate `nado.userSettings`, then grab the `privateKey` field from its JSON value and paste it into `.env` as `NADO_SIGNER_PRIVATE_KEY`.
|
2. On the Nado web app (trading interface), open DevTools (F12) -> switch to the `Application` tab -> `Local Storage`, locate `nado.userSettings`, then grab the `privateKey` field from its JSON value and paste it into `.env` as `NADO_SIGNER_PRIVATE_KEY`.
|
||||||
3. Provide `NADO_SUBACCOUNT_OWNER` (or `NADO_EVM_ADDRESS`).
|
3. Provide `NADO_SUBACCOUNT_OWNER` (or `NADO_EVM_ADDRESS`).
|
||||||
4. Select network via `NADO_ENV=inkMainnet` (mainnet) or `inkTestnet` (testnet).
|
4. Select network via `NADO_ENV=inkMainnet` (mainnet) or `inkTestnet` (testnet).
|
||||||
5. Set `NADO_SYMBOL` using Nado product symbols like `BTC-PERP` (it also accepts `BTCUSDT0` and maps it to `BTC-PERP`).
|
5. Set `NADO_SYMBOL` using Nado product symbols like `BTC-PERP` (it also accepts `BTCUSDT0` and maps it to `BTC-PERP`).
|
||||||
@@ -187,7 +215,7 @@ bun x vitest --watch
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- Keep at least 50–100 USDT in the account before deploying a live strategy.
|
- Keep at least 50-100 USDT in the account before deploying a live strategy.
|
||||||
- Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
|
- Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
|
||||||
- Ensure your server or workstation clock is in sync to avoid signature errors.
|
- Ensure your server or workstation clock is in sync to avoid signature errors.
|
||||||
- Accounts must run in one-way position mode.
|
- Accounts must run in one-way position mode.
|
||||||
|
|||||||
+329
-8
@@ -1,5 +1,9 @@
|
|||||||
## StandX Perps Authentication
|
## StandX Perps Authentication
|
||||||
|
|
||||||
|
官网创建的 API 提供了
|
||||||
|
|
||||||
|
API Token 以及 Ed25519 Private Key,用于签名交易。
|
||||||
|
|
||||||
⚠️ This document is under construction.
|
⚠️ This document is under construction.
|
||||||
|
|
||||||
This document explains how to obtain JWT access tokens for the StandX Perps API through wallet signatures.
|
This document explains how to obtain JWT access tokens for the StandX Perps API through wallet signatures.
|
||||||
@@ -69,7 +73,7 @@ curl 'https://api.standx.com/v1/offchain/certs'
|
|||||||
|
|
||||||
Sign `payload.message` with your wallet private key to generate the `signature`.
|
Sign `payload.message` with your wallet private key to generate the `signature`.
|
||||||
|
|
||||||
#### TypeScript/ES6 Implementation Reference
|
#### BSC (EVM) Implementation Reference
|
||||||
|
|
||||||
```
|
```
|
||||||
import { ethers } from "ethers";
|
import { ethers } from "ethers";
|
||||||
@@ -84,6 +88,38 @@ const wallet = new ethers.Wallet(privateKey, provider);
|
|||||||
const signature = await wallet.signMessage(payload.message);
|
const signature = await wallet.signMessage(payload.message);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Solana Implementation Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
import bs58 from "bs58";
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { Keypair } from "@solana/web3.js";
|
||||||
|
|
||||||
|
const privateKey = "<your_base58_encoded_private_key>"; // Keep secure; use environment variables
|
||||||
|
const walletKeypair = Keypair.fromSecretKey(bs58.decode(privateKey));
|
||||||
|
|
||||||
|
// Sign using the message from the parsed payload
|
||||||
|
const messageBytes = new TextEncoder().encode(payload.message);
|
||||||
|
const signatureBytes = ed25519.sign(
|
||||||
|
messageBytes,
|
||||||
|
walletKeypair.secretKey.slice(0, 32) // First 32 bytes are the private key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solana requires a specific signature format
|
||||||
|
const signature = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
input: payload,
|
||||||
|
output: {
|
||||||
|
signedMessage: Array.from(messageBytes),
|
||||||
|
signature: Array.from(signatureBytes),
|
||||||
|
account: {
|
||||||
|
publicKey: Array.from(walletKeypair.publicKey.toBytes()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toString("base64");
|
||||||
|
```
|
||||||
|
|
||||||
### 5\. Get Access Token
|
### 5\. Get Access Token
|
||||||
|
|
||||||
Submit the `signature` and original `signedData` to the login endpoint.
|
Submit the `signature` and original `signedData` to the login endpoint.
|
||||||
@@ -220,14 +256,38 @@ fetch("/api/request_need_body_signature", {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Complete Authentication Class Example
|
### Complete Authentication Examples
|
||||||
|
|
||||||
Here’s a complete implementation using a class-based approach:
|
For complete, runnable implementations, see the chain-specific examples:
|
||||||
|
|
||||||
|
- [EVM (BSC) Example](https://docs.standx.com/standx-api/perps-auth-evm-example) - Authentication using ethers.js for BSC and other EVM-compatible chains
|
||||||
|
- [Solana (SVM) Example](https://docs.standx.com/standx-api/perps-auth-svm-example) - Authentication using @solana/web3.js for Solana
|
||||||
|
|
||||||
|
Last updated on
|
||||||
|
|
||||||
|
[About StandX API](https://docs.standx.com/standx-api/standx-api "About StandX API") [Perps Auth EVM Example](https://docs.standx.com/standx-api/perps-auth-evm-example "Perps Auth EVM Example")
|
||||||
|
|
||||||
|
## StandX Perps Authentication - EVM Example
|
||||||
|
|
||||||
|
This example demonstrates how to authenticate with the StandX Perps API using an EVM-compatible wallet (e.g., BSC).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js environment with TypeScript support
|
||||||
|
- EVM wallet with private key
|
||||||
|
- Required packages:
|
||||||
|
```
|
||||||
|
npm install @noble/curves @scure/base ethers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Implementation
|
||||||
|
|
||||||
```
|
```
|
||||||
import { ed25519 } from "@noble/curves/ed25519";
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
import { base58 } from "@scure/base";
|
import { base58 } from "@scure/base";
|
||||||
|
import { ethers } from "ethers";
|
||||||
|
|
||||||
|
// Types
|
||||||
export type Chain = "bsc" | "solana";
|
export type Chain = "bsc" | "solana";
|
||||||
|
|
||||||
export interface SignedData {
|
export interface SignedData {
|
||||||
@@ -260,6 +320,7 @@ export interface RequestSignatureHeaders {
|
|||||||
"x-request-signature": string;
|
"x-request-signature": string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication Class
|
||||||
export class StandXAuth {
|
export class StandXAuth {
|
||||||
private ed25519PrivateKey: Uint8Array;
|
private ed25519PrivateKey: Uint8Array;
|
||||||
private ed25519PublicKey: Uint8Array;
|
private ed25519PublicKey: Uint8Array;
|
||||||
@@ -343,9 +404,7 @@ export class StandXAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Usage Example
|
// Usage Example
|
||||||
import { ethers } from "ethers";
|
async function main() {
|
||||||
|
|
||||||
async function example() {
|
|
||||||
// Initialize auth
|
// Initialize auth
|
||||||
const auth = new StandXAuth();
|
const auth = new StandXAuth();
|
||||||
|
|
||||||
@@ -389,8 +448,270 @@ async function example() {
|
|||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
```
|
```
|
||||||
|
|
||||||
Last updated on
|
## Key Points
|
||||||
|
|
||||||
[About StandX API](https://docs.standx.com/standx-api/standx-api "About StandX API") [Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API")
|
1. **Wallet Setup**: Uses `ethers.js` to create a wallet from a private key
|
||||||
|
2. **Message Signing**: EVM wallets sign the message directly using `wallet.signMessage()`
|
||||||
|
3. **Signature Format**: The signature is returned as-is from the wallet (hex string)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file with:
|
||||||
|
|
||||||
|
```
|
||||||
|
WALLET_PRIVATE_KEY=your_private_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Security Note**: Never commit private keys to version control. Use environment variables or secure key management solutions.
|
||||||
|
|
||||||
|
[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth") [Perps Auth SVM Example](https://docs.standx.com/standx-api/perps-auth-svm-example "Perps Auth SVM Example")
|
||||||
|
|
||||||
|
## StandX Perps Authentication - Solana (SVM) Example
|
||||||
|
|
||||||
|
This example demonstrates how to authenticate with the StandX Perps API using a Solana wallet.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js environment with TypeScript support
|
||||||
|
- Solana wallet with private key (base58-encoded)
|
||||||
|
- Required packages:
|
||||||
|
```
|
||||||
|
npm install @noble/curves @scure/base @solana/web3.js bs58
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Implementation
|
||||||
|
|
||||||
|
```
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { base58 } from "@scure/base";
|
||||||
|
import bs58 from "bs58";
|
||||||
|
import { Keypair } from "@solana/web3.js";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type Chain = "bsc" | "solana";
|
||||||
|
|
||||||
|
export interface SignedData {
|
||||||
|
domain: string;
|
||||||
|
uri: string;
|
||||||
|
statement: string;
|
||||||
|
version: string;
|
||||||
|
chainId: number;
|
||||||
|
nonce: string;
|
||||||
|
address: string;
|
||||||
|
requestId: string;
|
||||||
|
issuedAt: string;
|
||||||
|
message: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
address: string;
|
||||||
|
alias: string;
|
||||||
|
chain: string;
|
||||||
|
perpsAlpha: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestSignatureHeaders {
|
||||||
|
"x-request-sign-version": string;
|
||||||
|
"x-request-id": string;
|
||||||
|
"x-request-timestamp": string;
|
||||||
|
"x-request-signature": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication Class
|
||||||
|
export class StandXAuth {
|
||||||
|
private ed25519PrivateKey: Uint8Array;
|
||||||
|
private ed25519PublicKey: Uint8Array;
|
||||||
|
private requestId: string;
|
||||||
|
private baseUrl = "https://api.standx.com";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const privateKey = ed25519.utils.randomSecretKey();
|
||||||
|
this.ed25519PrivateKey = privateKey;
|
||||||
|
this.ed25519PublicKey = ed25519.getPublicKey(privateKey);
|
||||||
|
this.requestId = base58.encode(this.ed25519PublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(
|
||||||
|
chain: Chain,
|
||||||
|
walletAddress: string,
|
||||||
|
signMessage: (msg: string, payload: SignedData) => Promise<string>
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const signedDataJwt = await this.prepareSignIn(chain, walletAddress);
|
||||||
|
const payload = this.parseJwt<SignedData>(signedDataJwt);
|
||||||
|
const signature = await signMessage(payload.message, payload);
|
||||||
|
return this.login(chain, signature, signedDataJwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareSignIn(chain: Chain, address: string): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
\`${this.baseUrl}/v1/offchain/prepare-signin?chain=${chain}\`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ address, requestId: this.requestId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error("Failed to prepare sign-in");
|
||||||
|
return data.signedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(
|
||||||
|
chain: Chain,
|
||||||
|
signature: string,
|
||||||
|
signedData: string,
|
||||||
|
expiresSeconds: number = 604800 // default: 7 days
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const res = await fetch(
|
||||||
|
\`${this.baseUrl}/v1/offchain/login?chain=${chain}\`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ signature, signedData, expiresSeconds }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
signRequest(
|
||||||
|
payload: string,
|
||||||
|
requestId: string,
|
||||||
|
timestamp: number
|
||||||
|
): RequestSignatureHeaders {
|
||||||
|
const version = "v1";
|
||||||
|
const message = \`${version},${requestId},${timestamp},${payload}\`;
|
||||||
|
const signature = ed25519.sign(
|
||||||
|
Buffer.from(message, "utf-8"),
|
||||||
|
this.ed25519PrivateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"x-request-sign-version": version,
|
||||||
|
"x-request-id": requestId,
|
||||||
|
"x-request-timestamp": timestamp.toString(),
|
||||||
|
"x-request-signature": Buffer.from(signature).toString("base64"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseJwt<T>(token: string): T {
|
||||||
|
const base64Url = token.split(".")[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
return JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage Example
|
||||||
|
async function main() {
|
||||||
|
// Initialize auth
|
||||||
|
const auth = new StandXAuth();
|
||||||
|
|
||||||
|
// Setup wallet from base58-encoded private key
|
||||||
|
const privateKey = process.env.SOLANA_PRIVATE_KEY!;
|
||||||
|
const walletKeypair = Keypair.fromSecretKey(bs58.decode(privateKey));
|
||||||
|
const walletAddress = walletKeypair.publicKey.toBase58();
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
const loginResponse = await auth.authenticate(
|
||||||
|
"solana",
|
||||||
|
walletAddress,
|
||||||
|
async (message, payload) => {
|
||||||
|
const messageBytes = new TextEncoder().encode(message);
|
||||||
|
const signatureBytes = ed25519.sign(
|
||||||
|
messageBytes,
|
||||||
|
walletKeypair.secretKey.slice(0, 32) // First 32 bytes are the private key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solana requires a specific signature format
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
input: payload,
|
||||||
|
output: {
|
||||||
|
signedMessage: Array.from(messageBytes),
|
||||||
|
signature: Array.from(signatureBytes),
|
||||||
|
account: {
|
||||||
|
publicKey: Array.from(walletKeypair.publicKey.toBytes()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toString("base64");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Access Token:", loginResponse.token);
|
||||||
|
|
||||||
|
// Sign a request
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
side: "buy",
|
||||||
|
order_type: "limit",
|
||||||
|
qty: "0.1",
|
||||||
|
price: "50000",
|
||||||
|
time_in_force: "gtc",
|
||||||
|
reduce_only: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = auth.signRequest(payload, crypto.randomUUID(), Date.now());
|
||||||
|
|
||||||
|
// Make authenticated request
|
||||||
|
await fetch("https://perps.standx.com/api/new_order", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: \`Bearer ${loginResponse.token}\`,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
1. **Wallet Setup**: Uses `@solana/web3.js` Keypair with a base58-encoded private key
|
||||||
|
2. **Message Signing**: Uses `@noble/curves/ed25519` for Ed25519 signing with `walletKeypair.secretKey.slice(0, 32)` (first 32 bytes are the private key)
|
||||||
|
3. **Signature Format**: Solana requires a specific JSON structure containing:
|
||||||
|
- `input`: The original payload from the server
|
||||||
|
- `output.signedMessage`: The message bytes as an array
|
||||||
|
- `output.signature`: The signature bytes as an array
|
||||||
|
- `output.account.publicKey`: The wallet’s public key bytes as an array
|
||||||
|
This JSON is then base64-encoded before being sent to the server.
|
||||||
|
|
||||||
|
## Signature Format Explanation
|
||||||
|
|
||||||
|
Unlike EVM wallets that return a simple hex signature, Solana authentication requires a structured response:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
input: payload, // Original SignedData from server
|
||||||
|
output: {
|
||||||
|
signedMessage: [...], // Message bytes as number array
|
||||||
|
signature: [...], // Ed25519 signature bytes as number array
|
||||||
|
account: {
|
||||||
|
publicKey: [...] // Wallet public key bytes as number array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This format allows the server to verify both the signature and the signing account.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file with:
|
||||||
|
|
||||||
|
```
|
||||||
|
SOLANA_PRIVATE_KEY=your_base58_encoded_private_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Security Note**: Never commit private keys to version control. Use environment variables or secure key management solutions.
|
||||||
|
|
||||||
|
[Perps Auth EVM Example](https://docs.standx.com/standx-api/perps-auth-evm-example "Perps Auth EVM Example") [Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API")
|
||||||
+4
-2
@@ -679,6 +679,8 @@ To receive order updates via [Order Response Stream](https://docs.standx.com/sta
|
|||||||
|
|
||||||
`GET /api/query_depth_book`
|
`GET /api/query_depth_book`
|
||||||
|
|
||||||
|
**⚠️ Note: The sequence of price levels in the asks and bids arrays is not guaranteed. Please implement local sorting on the client side based on your specific requirements.**
|
||||||
|
|
||||||
**Required Parameters**
|
**Required Parameters**
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
@@ -791,7 +793,7 @@ To receive order updates via [Order Response Stream](https://docs.standx.com/sta
|
|||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| countBack | u64 | The required amount of bars to load |
|
| countback | u64 | The required amount of bars to load |
|
||||||
|
|
||||||
**Response Example**:
|
**Response Example**:
|
||||||
|
|
||||||
@@ -840,4 +842,4 @@ For enums, constants, and error codes, see [API Reference](https://docs.standx.c
|
|||||||
|
|
||||||
Last updated on
|
Last updated on
|
||||||
|
|
||||||
[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth") [Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API")
|
[Perps Auth SVM Example](https://docs.standx.com/standx-api/perps-auth-svm-example "Perps Auth SVM Example") [Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API")
|
||||||
@@ -1,112 +1,357 @@
|
|||||||
# StandX 做市积分策略使用教程(新手版)
|
# StandX 做市积分策略使用教程(超详细新手版)
|
||||||
|
|
||||||
本教程用于帮助你快速上手 StandX 做市积分策略(Maker Points)。无需交易经验,照着步骤做即可。
|
本教程会 **手把手** 教你如何运行 StandX 做市积分策略。每一步都有详细说明,按顺序操作即可。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 准备工作
|
## 第一步:安装 Bun(运行环境)
|
||||||
|
|
||||||
### 1.1 安装 Bun
|
本项目需要 Bun 才能运行。
|
||||||
本项目使用 Bun 运行。
|
|
||||||
|
|
||||||
如果你还没有安装 Bun,请先参考官方文档完成安装:
|
### macOS / Linux 用户:
|
||||||
- https://bun.sh/
|
打开终端,复制粘贴以下命令后按回车:
|
||||||
|
|
||||||
安装完成后,进入项目目录执行:
|
|
||||||
```bash
|
```bash
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 用户:
|
||||||
|
打开 PowerShell,复制粘贴以下命令后按回车:
|
||||||
|
```powershell
|
||||||
|
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||||
|
```
|
||||||
|
|
||||||
|
安装完成后,**关闭终端,重新打开一个新的终端窗口**,然后输入:
|
||||||
|
```bash
|
||||||
|
bun -v
|
||||||
|
```
|
||||||
|
如果显示版本号(如 `1.2.x`),说明安装成功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二步:下载项目并安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/discountry/ritmex-bot.git
|
||||||
|
cd ritmex-bot
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2 获取 StandX 登录凭证(Token)
|
---
|
||||||
策略需要 StandX 的登录 token 才能下单。
|
|
||||||
|
|
||||||
获取方式:
|
## 第三步:获取 StandX API Token(最重要的一步)
|
||||||
1. 打开 https://standx.ritmex.one/
|
|
||||||
2. 连接钱包
|
|
||||||
3. 点击“登录”
|
|
||||||
4. 导出登录信息(里面会包含 token)
|
|
||||||
|
|
||||||
请妥善保存,不要分享给他人。
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
>
|
||||||
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
>
|
||||||
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
|
||||||
|
策略需要两样东西才能帮你下单:
|
||||||
|
1. **TOKEN**(API 令牌)
|
||||||
|
2. **代理钱包私钥**(用于签名交易)
|
||||||
|
|
||||||
|
### 获取步骤(图文说明):
|
||||||
|
|
||||||
|
#### 3.1 打开 StandX 官方 API 创建页面
|
||||||
|
|
||||||
|
在浏览器打开这个网址:
|
||||||
|
```
|
||||||
|
https://standx.com/user/session
|
||||||
|
```
|
||||||
|
|
||||||
|
> **现在可以直接在 StandX 官网创建 API Token 了!**
|
||||||
|
|
||||||
|
#### 3.2 连接你的钱包并登录
|
||||||
|
|
||||||
|
如果还没登录,先连接钱包并登录你的 StandX 账户。
|
||||||
|
|
||||||
|
#### 3.3 生成 API Token
|
||||||
|
|
||||||
|
点击页面上的 **"Generate API Token"** 按钮。
|
||||||
|
|
||||||
|
你会看到类似这样的信息:
|
||||||
|
- **Token**(很长一串以 eyJ 开头的字符串)
|
||||||
|
- **Ed25519 Private Key**(Base58 格式的私钥,类似 `HdsyJD7oWgT756124j3taSPGv...`)
|
||||||
|
- **创建日期**(例如:2026-01-15)
|
||||||
|
- **有效期天数**(例如:30 天)
|
||||||
|
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
>
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
>
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
|
||||||
|
### 什么是 Ed25519 Private Key?
|
||||||
|
|
||||||
|
- 这是系统 **自动为你生成** 的一个 Ed25519 签名私钥
|
||||||
|
- 它 **只用于签名交易请求**,不存放你的资金
|
||||||
|
- 你的资产仍然在你自己的钱包里,非常安全
|
||||||
|
- **你不需要手动创建**,生成 API Token 时系统会自动创建
|
||||||
|
- 格式为 Base58 编码(类似 `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 配置环境变量
|
## 第四步:配置环境变量
|
||||||
|
|
||||||
在项目根目录新建 `.env` 文件(或修改已有 `.env`),填入以下配置:
|
在项目根目录创建一个 `.env` 文件(如果已存在就修改它)。
|
||||||
|
|
||||||
|
### 4.1 创建/编辑 .env 文件
|
||||||
|
|
||||||
|
**macOS / Linux:**
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
用记事本打开项目文件夹,新建一个文本文件,命名为 `.env`(注意前面有个点)
|
||||||
|
|
||||||
|
### 4.2 填入以下内容
|
||||||
|
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
>
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
>
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ===== 交易所设置 =====
|
||||||
EXCHANGE=standx
|
EXCHANGE=standx
|
||||||
|
|
||||||
|
# ===== 你的 API 凭证(第三步获取的) =====
|
||||||
|
# 把下面的 "你的TOKEN" 替换成你生成的 Token(很长一串以 eyJ 开头的)
|
||||||
|
STANDX_TOKEN=你的TOKEN
|
||||||
|
|
||||||
|
# 把下面的 "你的私钥" 替换成页面中显示的代理钱包私钥(按页面原样粘贴即可)
|
||||||
STANDX_REQUEST_PRIVATE_KEY=你的代理钱包私钥
|
STANDX_REQUEST_PRIVATE_KEY=你的代理钱包私钥
|
||||||
STANDX_TOKEN=你的token
|
|
||||||
|
# ===== 交易品种 =====
|
||||||
STANDX_SYMBOL=BTC-USD
|
STANDX_SYMBOL=BTC-USD
|
||||||
|
|
||||||
|
# ===== 策略参数(新手直接用默认值就行) =====
|
||||||
MAKER_POINTS_ORDER_AMOUNT=0.01
|
MAKER_POINTS_ORDER_AMOUNT=0.01
|
||||||
MAKER_POINTS_CLOSE_THRESHOLD=0.1
|
MAKER_POINTS_CLOSE_THRESHOLD=0.1
|
||||||
MAKER_POINTS_STOP_LOSS_USD=0
|
MAKER_POINTS_STOP_LOSS_USD=0
|
||||||
MAKER_POINTS_MIN_REPRICE_BPS=3
|
MAKER_POINTS_MIN_REPRICE_BPS=3
|
||||||
|
|
||||||
|
# ===== 挂单档位开关 =====
|
||||||
MAKER_POINTS_BAND_0_10=true
|
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
|
||||||
|
|
||||||
|
# ===== Token 过期时间配置(推荐配置) =====
|
||||||
|
# 填写你创建 API Token 时显示的创建日期和有效期天数
|
||||||
|
# 创建日期格式:YYYY-MM-DD(例如:2026-01-15)
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
# 有效期天数(例如:30)
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
|
||||||
|
# ===== Telegram 通知配置(可选) =====
|
||||||
|
# 配置后,策略会通过 Telegram 发送重要通知(订单成交、开仓、平仓、止损、Token过期等)
|
||||||
|
# 如何获取 Bot Token:在 Telegram 搜索 @BotFather,发送 /newbot 创建机器人,获取 Token
|
||||||
|
# 如何获取 Chat ID:在 Telegram 搜索 @userinfobot,发送任意消息即可看到你的 Chat ID
|
||||||
|
# TELEGRAM_BOT_TOKEN=你的BotToken
|
||||||
|
# TELEGRAM_CHAT_ID=你的ChatID
|
||||||
|
# TELEGRAM_ACCOUNT_LABEL=我的账户(可选,用于区分多个账户的通知)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置说明(新手可直接照抄)
|
### 正确填写示例
|
||||||
- `STANDX_TOKEN`: 登录后导出的 token(必须)
|
|
||||||
- `STANDX_SYMBOL`: 交易对(默认 `BTC-USD`)
|
假设你生成的 API Token 信息是:
|
||||||
- `MAKER_POINTS_ORDER_AMOUNT`: 每一笔挂单数量
|
- Token: `eyJhbGciOiJFUzI1NiIsImtpZCI6IlhnaEJQSVNuN0RQVHlMcWJtLUVHVkVhOU1lMFpwdU9iMk1Qc2gtbUFlencifQ...`
|
||||||
- `MAKER_POINTS_CLOSE_THRESHOLD`: 持仓达到该数值进入平仓模式(0 表示不自动平仓)
|
- Ed25519 Private Key: `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`
|
||||||
- `MAKER_POINTS_STOP_LOSS_USD`: 亏损超过该值时市价平仓(0 表示关闭)
|
- 创建日期: `2026-01-15`
|
||||||
- `MAKER_POINTS_MIN_REPRICE_BPS`: 盘口变动达到多少 bps 才会重算并撤单(默认 3)
|
- 有效期: `30` 天
|
||||||
- `MAKER_POINTS_BAND_0_10` / `10_30` / `30_100`: 三个档位开关
|
|
||||||
|
那么你的 `.env` 应该这样写:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXCHANGE=standx
|
||||||
|
STANDX_TOKEN=eyJhbGciOiJFUzI1NiIsImtpZCI6IlhnaEJQSVNuN0RQVHlMcWJtLUVHVkVhOU1lMFpwdU9iMk1Qc2gtbUFlencifQ...
|
||||||
|
STANDX_REQUEST_PRIVATE_KEY=HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f
|
||||||
|
STANDX_SYMBOL=BTC-USD
|
||||||
|
MAKER_POINTS_ORDER_AMOUNT=0.01
|
||||||
|
MAKER_POINTS_CLOSE_THRESHOLD=0.1
|
||||||
|
MAKER_POINTS_STOP_LOSS_USD=0
|
||||||
|
MAKER_POINTS_MIN_REPRICE_BPS=3
|
||||||
|
MAKER_POINTS_BAND_0_10=true
|
||||||
|
MAKER_POINTS_BAND_10_30=true
|
||||||
|
MAKER_POINTS_BAND_30_100=true
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
# TELEGRAM_BOT_TOKEN=你的BotToken
|
||||||
|
# TELEGRAM_CHAT_ID=你的ChatID
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
>
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
>
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 启动策略
|
## 第五步:启动策略
|
||||||
|
|
||||||
### 3.1 普通启动
|
### 普通启动(看实时仪表盘)
|
||||||
```bash
|
```bash
|
||||||
bun run index.ts --strategy maker-points --exchange standx
|
bun run index.ts --strategy maker-points --exchange standx
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 使用 PM2 后台运行(推荐)
|
### 后台运行(推荐长期挂机)
|
||||||
```bash
|
```bash
|
||||||
bun run pm2:start:maker-points
|
bun run pm2:start:maker-points
|
||||||
```
|
```
|
||||||
|
|
||||||
PM2 会自动重启策略,适合长期运行。
|
---
|
||||||
|
|
||||||
|
## 配置参数说明
|
||||||
|
|
||||||
|
| 参数 | 含义 | 新手建议 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `STANDX_TOKEN` | API 令牌 | 必填,从第三步获取 |
|
||||||
|
| `STANDX_REQUEST_PRIVATE_KEY` | 代理钱包私钥 | 必填,从第三步获取 |
|
||||||
|
| `STANDX_SYMBOL` | 交易品种 | 默认 `BTC-USD` |
|
||||||
|
| `MAKER_POINTS_ORDER_AMOUNT` | 每笔挂单数量 | 建议 `0.01` 起步 |
|
||||||
|
| `MAKER_POINTS_CLOSE_THRESHOLD` | 持仓达到多少开始平仓 | 设为 `0` 表示不自动平仓 |
|
||||||
|
| `MAKER_POINTS_STOP_LOSS_USD` | 亏损多少美元强制平仓 | 设为 `0` 表示关闭止损 |
|
||||||
|
| `MAKER_POINTS_BAND_*` | 三个挂单档位的开关 | 全部 `true` 即可 |
|
||||||
|
| `STANDX_TOKEN_CREATE_DATE` | Token 创建日期 | 推荐配置,格式 YYYY-MM-DD |
|
||||||
|
| `STANDX_TOKEN_VALIDITY_DAYS` | Token 有效期天数 | 推荐配置,与创建日期配合使用 |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
|
||||||
|
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
|
||||||
|
| `TELEGRAM_ACCOUNT_LABEL` | Telegram 通知账户标签 | 可选,用于区分多个账户 |
|
||||||
|
|
||||||
|
### Token 过期时间配置详解
|
||||||
|
|
||||||
|
`STANDX_TOKEN_CREATE_DATE` 和 `STANDX_TOKEN_VALIDITY_DAYS` 用于设置 Token 的过期时间。配置后,策略会:
|
||||||
|
|
||||||
|
1. **Token 过期前 1 小时**:在日志中提醒你 Token 即将过期
|
||||||
|
2. **Token 过期后**:
|
||||||
|
- 如果有持仓:进入**平仓模式**,只允许平仓和止损,不再开新仓
|
||||||
|
- 如果无持仓但有挂单:**自动取消所有挂单**
|
||||||
|
- 如果无持仓无挂单:进入**静默模式**,只接收数据,不下单
|
||||||
|
|
||||||
|
**推荐配置方式(创建日期 + 有效期天数):**
|
||||||
|
|
||||||
|
在 StandX 官网生成 API Token 时,页面会显示创建日期和有效期天数,直接填入即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建日期(格式:YYYY-MM-DD)
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
# 有效期天数
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例计算:**
|
||||||
|
- 创建日期:2026-01-15
|
||||||
|
- 有效期:30 天
|
||||||
|
- 过期时间:2025-02-14 00:00:00 UTC
|
||||||
|
|
||||||
|
**兼容旧版配置(直接指定过期时间戳):**
|
||||||
|
|
||||||
|
如果你之前使用的是 `STANDX_TOKEN_EXPIRY`,仍然可以继续使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式1:使用时间戳(秒)
|
||||||
|
STANDX_TOKEN_EXPIRY=1735689600
|
||||||
|
|
||||||
|
# 方式2:使用 ISO 日期字符串
|
||||||
|
STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **提示**:推荐使用新的创建日期 + 有效期天数方式,更直观易懂。
|
||||||
|
|
||||||
|
### Telegram 通知配置详解
|
||||||
|
|
||||||
|
配置 Telegram 通知后,策略会在以下情况发送通知:
|
||||||
|
|
||||||
|
- 📝 **订单成交**:挂单被成交时
|
||||||
|
- 📈 **开仓**:持仓从 0 变为非 0 时
|
||||||
|
- 📉 **平仓**:持仓从非 0 变为 0 时
|
||||||
|
- 🛑 **止损触发**:触发止损平仓时
|
||||||
|
- ⏰ **Token 过期**:Token 过期时
|
||||||
|
|
||||||
|
**配置步骤:**
|
||||||
|
|
||||||
|
1. **创建 Telegram 机器人**:
|
||||||
|
- 在 Telegram 搜索 `@BotFather`
|
||||||
|
- 发送 `/newbot` 命令
|
||||||
|
- 按提示设置机器人名称和用户名
|
||||||
|
- 获取 Bot Token(格式类似:`123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||||
|
|
||||||
|
2. **获取你的 Chat ID**:
|
||||||
|
- 在 Telegram 搜索 `@userinfobot`
|
||||||
|
- 发送任意消息
|
||||||
|
- 机器人会返回你的 Chat ID(一串数字,例如:`123456789`)
|
||||||
|
|
||||||
|
3. **配置环境变量**:
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_ACCOUNT_LABEL=我的账户(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **测试通知**:
|
||||||
|
- 启动策略后,如果配置正确,会在 Token 过期或重要事件时收到通知
|
||||||
|
- 如果收不到通知,检查 Bot Token 和 Chat ID 是否正确
|
||||||
|
|
||||||
|
> 💡 **提示**:`TELEGRAM_ACCOUNT_LABEL` 是可选的,如果你有多个账户在运行策略,可以用这个标签区分不同账户的通知。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 运行后你会看到什么
|
## 常见问题
|
||||||
|
|
||||||
启动后会显示仪表盘,包含:
|
### Q:报错说 Token 无效怎么办?
|
||||||
- 当前交易对与盘口
|
|
||||||
- 当前仓位与浮动盈亏
|
重新去 https://standx.com/user/session 生成新的 API Token。Token 可能过期了。
|
||||||
- 目标挂单列表
|
|
||||||
- 已挂单列表
|
### Q:Ed25519 Private Key 从哪来的?
|
||||||
- Binance 深度失衡状态
|
|
||||||
|
在 StandX 官网(https://standx.com/user/session)点击 "Generate API Token" 按钮时会显示。
|
||||||
|
私钥格式为 Base58 编码(类似 `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`)。
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
|
||||||
|
### Q:.env 文件放在哪?
|
||||||
|
|
||||||
|
放在项目根目录,就是 `ritmex-bot` 文件夹里,和 `package.json` 同一个目录。
|
||||||
|
|
||||||
|
### Q:为什么策略没有下单?
|
||||||
|
|
||||||
|
1. 检查账户里有没有足够的保证金
|
||||||
|
2. 检查 TOKEN 和私钥是否正确填写
|
||||||
|
3. 检查 .env 文件是否保存成功
|
||||||
|
|
||||||
|
### Q:担心平掉我手动开的仓位?
|
||||||
|
|
||||||
|
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
|
||||||
|
|
||||||
|
### Q:如何知道 Token 什么时候过期?
|
||||||
|
|
||||||
|
配置 `STANDX_TOKEN_CREATE_DATE`(创建日期)和 `STANDX_TOKEN_VALIDITY_DAYS`(有效期天数),策略会在 Token 过期前 1 小时提醒你。这两个值在生成 API Token 时会显示。Token 过期后,如果有持仓会进入平仓模式,只允许平仓和止损。
|
||||||
|
|
||||||
|
### Q:Telegram 通知收不到怎么办?
|
||||||
|
|
||||||
|
1. 检查 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID` 是否正确填写
|
||||||
|
2. 确保没有在 Bot Token 和 Chat ID 前后加引号或空格
|
||||||
|
3. 在 Telegram 中先给机器人发送一条消息(任意内容),然后再启动策略
|
||||||
|
4. 检查网络连接是否正常
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 新手常见问题
|
## 安全提示
|
||||||
|
|
||||||
### Q1:为什么会撤单重挂?
|
1. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
策略只有在盘口变化超过 `MAKER_POINTS_MIN_REPRICE_BPS` 或 Binance 深度状态变化时才会重算挂单。
|
2. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
3. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
|
||||||
### Q2:担心平掉手动持仓怎么办?
|
代理钱包只用于签名,你的资产始终在你自己的主钱包里。但如果泄露了 TOKEN,别人可以用你的账户交易。
|
||||||
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 0 或者设成大于你的持仓即可。
|
|
||||||
|
|
||||||
### Q3:我只想挂某个档位?
|
|
||||||
把不需要的档位开关设为 `false` 即可。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 注意事项
|
## 还是不会?
|
||||||
|
|
||||||
1. 请确保钱包里有足够的保证金/资金。
|
把你的报错信息截图发到 Telegram 群里,会有人帮你:
|
||||||
2. 不要泄露 `STANDX_TOKEN`。
|
|
||||||
3. 初次使用请先用小仓位测试。
|
|
||||||
|
|
||||||
---
|
Telegram 群:https://t.me/+4fdo0quY87o4Mjhh
|
||||||
|
|
||||||
如果你需要我帮你定制参数或排查问题,直接把日志贴给我即可。
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ The WebSocket API provides two streams: **Market Stream** for market data and us
|
|||||||
|
|
||||||
Both WebSocket streams implement the following connection management behavior:
|
Both WebSocket streams implement the following connection management behavior:
|
||||||
|
|
||||||
|
### Connection Duration Limit
|
||||||
|
|
||||||
|
- **Maximum Duration**: A single WebSocket connection can be maintained for a maximum of **24 hours**
|
||||||
|
- After 24 hours, the connection will be automatically terminated
|
||||||
|
- Clients should implement reconnection logic to handle this gracefully
|
||||||
|
|
||||||
### Ping/Pong Mechanism
|
### Ping/Pong Mechanism
|
||||||
|
|
||||||
- **Server Ping Interval**: The server sends a WebSocket Ping frame every 10 seconds
|
- **Server Ping Interval**: The server sends a WebSocket Ping frame every 10 seconds
|
||||||
@@ -45,6 +51,7 @@ Base Endpoint: `wss://perps.standx.com/ws-stream/v1`
|
|||||||
// public channels
|
// public channels
|
||||||
{ channel: "price", symbol: "<symbol>" },
|
{ channel: "price", symbol: "<symbol>" },
|
||||||
{ channel: "depth_book", symbol: "<symbol>" },
|
{ channel: "depth_book", symbol: "<symbol>" },
|
||||||
|
{ channel: "public_trade", symbol: "<symbol>" },
|
||||||
// user-level authenticated channels
|
// user-level authenticated channels
|
||||||
{ channel: "order" },
|
{ channel: "order" },
|
||||||
{ channel: "position" },
|
{ channel: "position" },
|
||||||
@@ -55,6 +62,8 @@ Base Endpoint: `wss://perps.standx.com/ws-stream/v1`
|
|||||||
|
|
||||||
### Subscribe to Depth Book
|
### Subscribe to Depth Book
|
||||||
|
|
||||||
|
**⚠️ Note: The sequence of price levels in the asks and bids arrays is not guaranteed. Please implement local sorting on the client side based on your specific requirements.**
|
||||||
|
|
||||||
- Request:
|
- Request:
|
||||||
- Response:
|
- Response:
|
||||||
```
|
```
|
||||||
|
|||||||
+6
-2
@@ -1,4 +1,4 @@
|
|||||||
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
|
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
|
|
||||||
export interface CliOptions {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
@@ -13,6 +13,7 @@ const STRATEGY_VALUES = new Set<StrategyId>([
|
|||||||
"maker",
|
"maker",
|
||||||
"maker-points",
|
"maker-points",
|
||||||
"offset-maker",
|
"offset-maker",
|
||||||
|
"liquidity-maker",
|
||||||
"basis",
|
"basis",
|
||||||
"grid",
|
"grid",
|
||||||
]);
|
]);
|
||||||
@@ -72,6 +73,8 @@ function assignStrategy(options: CliOptions, raw: string): void {
|
|||||||
options.strategy = "offset-maker";
|
options.strategy = "offset-maker";
|
||||||
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
|
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
|
||||||
options.strategy = "maker-points";
|
options.strategy = "maker-points";
|
||||||
|
} else if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity-maker" || normalized === "liquidity_maker") {
|
||||||
|
options.strategy = "liquidity-maker";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +98,11 @@ function assignExchange(options: CliOptions, raw: string): void {
|
|||||||
|
|
||||||
export function printCliHelp(): void {
|
export function printCliHelp(): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|maker-points|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--silent]\n\n` +
|
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--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` +
|
||||||
|
` Aliases: liquidity, liquidity-maker for the liquidity maker engine.\n` +
|
||||||
` --exchange, -e Choose exchange. Overrides EXCHANGE/TRADE_EXCHANGE environment variables.\n` +
|
` --exchange, -e Choose exchange. Overrides EXCHANGE/TRADE_EXCHANGE environment variables.\n` +
|
||||||
` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` +
|
` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` +
|
||||||
` --help, -h Show this help message.\n`);
|
` --help, -h Show this help message.\n`);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, makerPointsConfig, tradingConfig } from "../config";
|
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, tradingConfig } from "../config";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
import { getExchangeDisplayName, 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 { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
||||||
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
||||||
|
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||||
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
||||||
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||||
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
||||||
@@ -24,6 +25,7 @@ export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
|||||||
maker: "Maker",
|
maker: "Maker",
|
||||||
"maker-points": "Maker Points",
|
"maker-points": "Maker Points",
|
||||||
"offset-maker": "Offset Maker",
|
"offset-maker": "Offset Maker",
|
||||||
|
"liquidity-maker": "Liquidity Maker",
|
||||||
basis: "Basis Arbitrage",
|
basis: "Basis Arbitrage",
|
||||||
grid: "Grid",
|
grid: "Grid",
|
||||||
};
|
};
|
||||||
@@ -106,6 +108,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
|||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
"liquidity-maker": async (opts) => {
|
||||||
|
const config = liquidityMakerConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol);
|
||||||
|
const engine = new LiquidityMakerEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "liquidity-maker",
|
||||||
|
silent: opts.silent,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
basis: async (opts) => {
|
basis: async (opts) => {
|
||||||
if (!isBasisStrategyEnabled()) {
|
if (!isBasisStrategyEnabled()) {
|
||||||
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
||||||
@@ -156,6 +171,7 @@ async function runEngine<
|
|||||||
| MakerEngineSnapshot
|
| MakerEngineSnapshot
|
||||||
| MakerPointsSnapshot
|
| MakerPointsSnapshot
|
||||||
| OffsetMakerEngineSnapshot
|
| OffsetMakerEngineSnapshot
|
||||||
|
| LiquidityMakerEngineSnapshot
|
||||||
| BasisArbSnapshot
|
| BasisArbSnapshot
|
||||||
| GridEngineSnapshot
|
| GridEngineSnapshot
|
||||||
>(
|
>(
|
||||||
|
|||||||
+156
-8
@@ -6,6 +6,70 @@
|
|||||||
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";
|
||||||
|
|
||||||
|
export interface StandxTokenConfig {
|
||||||
|
expiryTimestamp: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenExpiry(): number | null {
|
||||||
|
// Method 1: Use creation date + validity days (recommended for official API tokens)
|
||||||
|
const createDate = process.env.STANDX_TOKEN_CREATE_DATE?.trim();
|
||||||
|
const validityDays = process.env.STANDX_TOKEN_VALIDITY_DAYS?.trim();
|
||||||
|
|
||||||
|
if (createDate && validityDays) {
|
||||||
|
// Parse date in YYYY-MM-DD format
|
||||||
|
const dateMatch = createDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (dateMatch) {
|
||||||
|
const [, year, month, day] = dateMatch;
|
||||||
|
const createTimestamp = Date.UTC(
|
||||||
|
Number(year),
|
||||||
|
Number(month) - 1, // Month is 0-indexed
|
||||||
|
Number(day),
|
||||||
|
0, 0, 0, 0
|
||||||
|
);
|
||||||
|
const days = Number(validityDays);
|
||||||
|
if (Number.isFinite(createTimestamp) && Number.isFinite(days) && days > 0) {
|
||||||
|
return createTimestamp + days * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Use legacy STANDX_TOKEN_EXPIRY (timestamp or ISO date string)
|
||||||
|
const legacyExpiry = process.env.STANDX_TOKEN_EXPIRY?.trim();
|
||||||
|
if (legacyExpiry) {
|
||||||
|
const asNumber = Number(legacyExpiry);
|
||||||
|
if (Number.isFinite(asNumber) && asNumber > 0) {
|
||||||
|
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||||
|
}
|
||||||
|
const asDate = Date.parse(legacyExpiry);
|
||||||
|
if (Number.isFinite(asDate) && asDate > 0) {
|
||||||
|
return asDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const standxTokenConfig: StandxTokenConfig = {
|
||||||
|
expiryTimestamp: parseTokenExpiry(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isStandxTokenExpired(): boolean {
|
||||||
|
const expiry = standxTokenConfig.expiryTimestamp;
|
||||||
|
if (expiry == null) return false;
|
||||||
|
return Date.now() >= expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStandxTokenExpiryInfo(): { expired: boolean; expiryTimestamp: number | null; remainingMs: number | null } {
|
||||||
|
const expiry = standxTokenConfig.expiryTimestamp;
|
||||||
|
if (expiry == null) {
|
||||||
|
return { expired: false, expiryTimestamp: null, remainingMs: null };
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const expired = now >= expiry;
|
||||||
|
const remainingMs = expired ? 0 : expiry - now;
|
||||||
|
return { expired, expiryTimestamp: expiry, remainingMs };
|
||||||
|
}
|
||||||
|
|
||||||
export interface TradingConfig {
|
export interface TradingConfig {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
tradeAmount: number;
|
tradeAmount: number;
|
||||||
@@ -41,23 +105,51 @@ export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId |
|
|||||||
: resolveExchangeId();
|
: resolveExchangeId();
|
||||||
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
|
const { envKeys, fallback } = SYMBOL_PRIORITY_BY_EXCHANGE[exchangeId];
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
const value = process.env[key];
|
const value = normalizeEnvValue(process.env[key]);
|
||||||
if (value && value.trim()) {
|
if (value) {
|
||||||
return value.trim();
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEnvValue(value: string | undefined): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
const quote = trimmed[0];
|
||||||
|
if ((quote === "'" || quote === "\"") && trimmed.endsWith(quote)) {
|
||||||
|
const unquoted = trimmed.slice(1, -1).trim();
|
||||||
|
return unquoted ? unquoted : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow shell-style inline comments: KEY=value # comment
|
||||||
|
const commentIndexHash = trimmed.search(/\s#/);
|
||||||
|
const commentIndexSemi = trimmed.search(/\s;/);
|
||||||
|
const commentIndex =
|
||||||
|
commentIndexHash === -1
|
||||||
|
? commentIndexSemi
|
||||||
|
: commentIndexSemi === -1
|
||||||
|
? commentIndexHash
|
||||||
|
: Math.min(commentIndexHash, commentIndexSemi);
|
||||||
|
if (commentIndex !== -1) {
|
||||||
|
const withoutComment = trimmed.slice(0, commentIndex).trim();
|
||||||
|
return withoutComment ? withoutComment : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
function parseNumber(value: string | undefined, fallback: number): number {
|
function parseNumber(value: string | undefined, fallback: number): number {
|
||||||
if (!value) return fallback;
|
const normalized = normalizeEnvValue(value);
|
||||||
const next = Number(value);
|
if (!normalized) return fallback;
|
||||||
|
const next = Number(normalized);
|
||||||
return Number.isFinite(next) ? next : fallback;
|
return Number.isFinite(next) ? next : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||||
if (!value) return fallback;
|
const normalized = normalizeEnvValue(value)?.toLowerCase();
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
if (!normalized) return fallback;
|
if (!normalized) return fallback;
|
||||||
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
|
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
|
||||||
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
|
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
|
||||||
@@ -93,6 +185,8 @@ export interface MakerConfig {
|
|||||||
maxLogEntries: number;
|
maxLogEntries: number;
|
||||||
maxCloseSlippagePct: number;
|
maxCloseSlippagePct: number;
|
||||||
priceTick: number;
|
priceTick: number;
|
||||||
|
/** 开仓挂单档位:1=买1/卖1,2=买2/卖2,以此类推。仅影响无仓位时的开仓挂单,平仓逻辑不受影响。默认1 */
|
||||||
|
entryDepthLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makerConfig: MakerConfig = {
|
export const makerConfig: MakerConfig = {
|
||||||
@@ -108,6 +202,7 @@ export const makerConfig: MakerConfig = {
|
|||||||
0.05
|
0.05
|
||||||
),
|
),
|
||||||
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
|
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MakerPointsConfig {
|
export interface MakerPointsConfig {
|
||||||
@@ -123,12 +218,24 @@ export interface MakerPointsConfig {
|
|||||||
enableBand0To10: boolean;
|
enableBand0To10: boolean;
|
||||||
enableBand10To30: boolean;
|
enableBand10To30: boolean;
|
||||||
enableBand30To100: boolean;
|
enableBand30To100: boolean;
|
||||||
|
/** 0-10 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band0To10Amount: number;
|
||||||
|
/** 10-30 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band10To30Amount: number;
|
||||||
|
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band30To100Amount: number;
|
||||||
minRepriceBps: number;
|
minRepriceBps: number;
|
||||||
|
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
|
||||||
|
enableBinanceDepthCancel: boolean;
|
||||||
|
/** 各档位最小深度阈值 (BTC),盘口到目标价之间的挂单量低于此值则跳过该档位,默认 50 */
|
||||||
|
filterMinDepth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultMakerPointsAmount = parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001));
|
||||||
|
|
||||||
export const makerPointsConfig: MakerPointsConfig = {
|
export const makerPointsConfig: MakerPointsConfig = {
|
||||||
symbol: resolveSymbolFromEnv("standx"),
|
symbol: resolveSymbolFromEnv("standx"),
|
||||||
perOrderAmount: parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001)),
|
perOrderAmount: defaultMakerPointsAmount,
|
||||||
closeThreshold: parseNumber(process.env.MAKER_POINTS_CLOSE_THRESHOLD, 0),
|
closeThreshold: parseNumber(process.env.MAKER_POINTS_CLOSE_THRESHOLD, 0),
|
||||||
stopLossUsd: parseNumber(process.env.MAKER_POINTS_STOP_LOSS_USD, 0),
|
stopLossUsd: parseNumber(process.env.MAKER_POINTS_STOP_LOSS_USD, 0),
|
||||||
refreshIntervalMs: parseNumber(process.env.MAKER_POINTS_REFRESH_INTERVAL_MS, 500),
|
refreshIntervalMs: parseNumber(process.env.MAKER_POINTS_REFRESH_INTERVAL_MS, 500),
|
||||||
@@ -142,7 +249,12 @@ export const makerPointsConfig: MakerPointsConfig = {
|
|||||||
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
|
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
|
||||||
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
|
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
|
||||||
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
|
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
|
||||||
|
band0To10Amount: parseNumber(process.env.MAKER_POINTS_BAND_0_10_AMOUNT, defaultMakerPointsAmount),
|
||||||
|
band10To30Amount: parseNumber(process.env.MAKER_POINTS_BAND_10_30_AMOUNT, defaultMakerPointsAmount),
|
||||||
|
band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_AMOUNT, defaultMakerPointsAmount),
|
||||||
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
|
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
|
||||||
|
enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true),
|
||||||
|
filterMinDepth: parseNumber(process.env.MAKER_POINTS_FILTER_MIN_DEPTH, 50),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BasisArbConfig {
|
export interface BasisArbConfig {
|
||||||
@@ -255,6 +367,42 @@ export const gridConfig: GridConfig = {
|
|||||||
|
|
||||||
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
|
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
|
||||||
|
|
||||||
|
export interface LiquidityMakerConfig {
|
||||||
|
symbol: string;
|
||||||
|
tradeAmount: number;
|
||||||
|
lossLimit: number;
|
||||||
|
bidOffset: number;
|
||||||
|
askOffset: number;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
maxLogEntries: number;
|
||||||
|
maxCloseSlippagePct: number;
|
||||||
|
priceTick: number;
|
||||||
|
/** 平仓挂单距成交价的档位数,默认1档 */
|
||||||
|
closeTickOffset: number;
|
||||||
|
/** 偏移判断阈值倍数,当一侧深度超出另一侧此倍数时取消薄端订单,默认2 */
|
||||||
|
depthImbalanceRatio: number;
|
||||||
|
/** 开仓挂单档位:1=买1/卖1,2=买2/卖2,以此类推。仅影响无仓位时的开仓挂单,平仓逻辑不受影响。默认1 */
|
||||||
|
entryDepthLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const liquidityMakerConfig: LiquidityMakerConfig = {
|
||||||
|
symbol: resolveSymbolFromEnv(),
|
||||||
|
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||||
|
lossLimit: parseNumber(process.env.LIQUIDITY_MAKER_LOSS_LIMIT, parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.03))),
|
||||||
|
bidOffset: parseNumber(process.env.LIQUIDITY_MAKER_BID_OFFSET, parseNumber(process.env.MAKER_BID_OFFSET, 0)),
|
||||||
|
askOffset: parseNumber(process.env.LIQUIDITY_MAKER_ASK_OFFSET, parseNumber(process.env.MAKER_ASK_OFFSET, 0)),
|
||||||
|
refreshIntervalMs: parseNumber(process.env.LIQUIDITY_MAKER_REFRESH_INTERVAL_MS, parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 500)),
|
||||||
|
maxLogEntries: parseNumber(process.env.LIQUIDITY_MAKER_MAX_LOG_ENTRIES, parseNumber(process.env.MAKER_MAX_LOG_ENTRIES, 200)),
|
||||||
|
maxCloseSlippagePct: parseNumber(
|
||||||
|
process.env.LIQUIDITY_MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
|
||||||
|
0.05
|
||||||
|
),
|
||||||
|
priceTick: parseNumber(process.env.LIQUIDITY_MAKER_PRICE_TICK ?? process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
|
closeTickOffset: Math.max(1, Math.floor(parseNumber(process.env.LIQUIDITY_MAKER_CLOSE_TICK_OFFSET, 1))),
|
||||||
|
depthImbalanceRatio: Math.max(1.1, parseNumber(process.env.LIQUIDITY_MAKER_DEPTH_IMBALANCE_RATIO, 2)),
|
||||||
|
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
|
||||||
|
};
|
||||||
|
|
||||||
export function isBasisStrategyEnabled(): boolean {
|
export function isBasisStrategyEnabled(): boolean {
|
||||||
const raw = process.env.ENABLE_BASIS_STRATEGY;
|
const raw = process.env.ENABLE_BASIS_STRATEGY;
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ type PlaceOrderOptions = {
|
|||||||
priceTick: number;
|
priceTick: number;
|
||||||
qtyStep: number;
|
qtyStep: number;
|
||||||
skipDedupe?: boolean;
|
skipDedupe?: boolean;
|
||||||
|
slPrice?: number;
|
||||||
|
tpPrice?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function placeOrder(
|
export async function placeOrder(
|
||||||
@@ -176,9 +178,11 @@ export async function placeOrder(
|
|||||||
timeInForce: reduceOnly ? "GTC" : "GTX",
|
timeInForce: reduceOnly ? "GTC" : "GTX",
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
closePosition,
|
closePosition,
|
||||||
|
slPrice: opts?.slPrice,
|
||||||
|
tpPrice: opts?.tpPrice,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`);
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ export interface FundingRateListener {
|
|||||||
(snapshot: FundingRateSnapshot): void;
|
(snapshot: FundingRateSnapshot): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RestHealthState = "healthy" | "unhealthy";
|
||||||
|
export interface RestHealthInfo {
|
||||||
|
consecutiveErrors: number;
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
export interface RestHealthListener {
|
||||||
|
(state: RestHealthState, info: RestHealthInfo): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExchangePrecision {
|
export interface ExchangePrecision {
|
||||||
priceTick: number;
|
priceTick: number;
|
||||||
qtyStep: number;
|
qtyStep: number;
|
||||||
@@ -47,6 +58,11 @@ export interface ExchangePrecision {
|
|||||||
minQuoteAmount?: number;
|
minQuoteAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectionEventType = "disconnected" | "reconnected";
|
||||||
|
export interface ConnectionEventListener {
|
||||||
|
(event: ConnectionEventType, symbol: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExchangeAdapter {
|
export interface ExchangeAdapter {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
supportsTrailingStops(): boolean;
|
supportsTrailingStops(): boolean;
|
||||||
@@ -61,4 +77,13 @@ export interface ExchangeAdapter {
|
|||||||
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
|
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
|
||||||
cancelAllOrders(params: { symbol: string }): Promise<void>;
|
cancelAllOrders(params: { symbol: string }): Promise<void>;
|
||||||
getPrecision?(): Promise<ExchangePrecision | null>;
|
getPrecision?(): Promise<ExchangePrecision | null>;
|
||||||
|
// 连接保护相关方法(可选,仅 StandX 支持)
|
||||||
|
onConnectionEvent?(listener: ConnectionEventListener): void;
|
||||||
|
offConnectionEvent?(listener: ConnectionEventListener): void;
|
||||||
|
onRestHealthEvent?(listener: RestHealthListener): void;
|
||||||
|
offRestHealthEvent?(listener: RestHealthListener): void;
|
||||||
|
queryOpenOrders?(): Promise<AsterOrder[]>;
|
||||||
|
queryAccountSnapshot?(): Promise<AsterAccountSnapshot | null>;
|
||||||
|
changeMarginMode?(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void>;
|
||||||
|
forceCancelAllOrders?(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface BaseOrderIntent {
|
|||||||
|
|
||||||
export interface LimitOrderIntent extends BaseOrderIntent {
|
export interface LimitOrderIntent extends BaseOrderIntent {
|
||||||
price: number;
|
price: number;
|
||||||
|
// StandX TPSL 参数
|
||||||
|
slPrice?: number; // 止损价格
|
||||||
|
tpPrice?: number; // 止盈价格
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketOrderIntent extends BaseOrderIntent {
|
export interface MarketOrderIntent extends BaseOrderIntent {
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import type {
|
|||||||
FundingRateListener,
|
FundingRateListener,
|
||||||
KlineListener,
|
KlineListener,
|
||||||
OrderListener,
|
OrderListener,
|
||||||
|
RestHealthListener,
|
||||||
TickerListener,
|
TickerListener,
|
||||||
} from "../adapter";
|
} from "../adapter";
|
||||||
import type { AsterOrder, CreateOrderParams } from "../types";
|
import type { AsterOrder, CreateOrderParams } from "../types";
|
||||||
import { extractMessage } from "../../utils/errors";
|
import { extractMessage } from "../../utils/errors";
|
||||||
import { StandxGateway, type StandxGatewayOptions } from "./gateway";
|
import { StandxGateway, type StandxGatewayOptions, type ConnectionEventListener, type ConnectionEventType } from "./gateway";
|
||||||
|
|
||||||
|
export type { ConnectionEventListener, ConnectionEventType };
|
||||||
|
|
||||||
export interface StandxCredentials {
|
export interface StandxCredentials {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -122,6 +125,56 @@ export class StandxExchangeAdapter implements ExchangeAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听连接事件(断连/重连)
|
||||||
|
*/
|
||||||
|
onConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.gateway.onConnectionEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消连接事件监听
|
||||||
|
*/
|
||||||
|
offConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.gateway.offConnectionEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.gateway.onRestHealthEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
offRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.gateway.offRestHealthEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前真实的挂单状态(通过 HTTP API)
|
||||||
|
* 用于验证实际挂单情况,防止取消请求丢失
|
||||||
|
*/
|
||||||
|
async queryOpenOrders(): Promise<AsterOrder[]> {
|
||||||
|
await this.ensureInitialized("queryOpenOrders");
|
||||||
|
return this.gateway.queryOpenOrders(this.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAccountSnapshot() {
|
||||||
|
await this.ensureInitialized("queryAccountSnapshot");
|
||||||
|
return this.gateway.queryAccountSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
|
||||||
|
await this.ensureInitialized("changeMarginMode");
|
||||||
|
await this.gateway.changeMarginMode(params.symbol, params.marginMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制取消所有挂单
|
||||||
|
* 会查询当前挂单然后取消,并验证取消成功
|
||||||
|
*/
|
||||||
|
async forceCancelAllOrders(): Promise<boolean> {
|
||||||
|
await this.ensureInitialized("forceCancelAllOrders");
|
||||||
|
return this.gateway.forceCancelAllOrders(this.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
|
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
|
||||||
const wrapped = ((...args: any[]) => {
|
const wrapped = ((...args: any[]) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+647
-33
@@ -8,6 +8,9 @@ import type {
|
|||||||
FundingRateListener,
|
FundingRateListener,
|
||||||
KlineListener,
|
KlineListener,
|
||||||
OrderListener,
|
OrderListener,
|
||||||
|
RestHealthInfo,
|
||||||
|
RestHealthListener,
|
||||||
|
RestHealthState,
|
||||||
TickerListener,
|
TickerListener,
|
||||||
} from "../adapter";
|
} from "../adapter";
|
||||||
import type {
|
import type {
|
||||||
@@ -48,7 +51,21 @@ const DEFAULT_WS_URL = "wss://perps.standx.com/ws-stream/v1";
|
|||||||
const DEFAULT_KLINE_LIMIT = 200;
|
const DEFAULT_KLINE_LIMIT = 200;
|
||||||
const KLINE_REFRESH_MS = 30_000;
|
const KLINE_REFRESH_MS = 30_000;
|
||||||
const FUNDING_REFRESH_MS = 60_000;
|
const FUNDING_REFRESH_MS = 60_000;
|
||||||
const WS_RECONNECT_DELAY = 2000;
|
|
||||||
|
// ========== WebSocket 连接管理常量 ==========
|
||||||
|
// 基础重连延迟(毫秒)
|
||||||
|
const WS_RECONNECT_DELAY_BASE = 2000;
|
||||||
|
// 最大重连延迟(毫秒)- 指数退避上限
|
||||||
|
const WS_RECONNECT_DELAY_MAX = 30_000;
|
||||||
|
// 心跳超时(毫秒)- StandX 服务器 5 分钟无 pong 会断连,我们设置 2 分钟作为安全阈值
|
||||||
|
const WS_HEARTBEAT_TIMEOUT = 120_000;
|
||||||
|
// 心跳检查间隔(毫秒)- 每 30 秒检查一次是否收到消息
|
||||||
|
const WS_HEARTBEAT_CHECK_INTERVAL = 30_000;
|
||||||
|
// 数据过时阈值(毫秒)- 超过此时间未收到行情/仓位数据,启动 REST 主动拉取
|
||||||
|
const WS_DATA_STALE_THRESHOLD = 3000;
|
||||||
|
// REST 轮询间隔(毫秒)- WS 断连或数据过时时的 REST 拉取间隔
|
||||||
|
const REST_POLL_INTERVAL = 2000;
|
||||||
|
const REST_ERROR_DEFENSE_THRESHOLD = 3;
|
||||||
|
|
||||||
const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"];
|
const SUPPORTED_QUOTES = ["USD", "USDT", "USDC", "DUSD"];
|
||||||
|
|
||||||
@@ -80,6 +97,9 @@ export interface StandxGatewayOptions {
|
|||||||
logger?: (context: string, error: unknown) => void;
|
logger?: (context: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectionEventType = "disconnected" | "reconnected";
|
||||||
|
export type ConnectionEventListener = (event: ConnectionEventType, symbol: string) => void;
|
||||||
|
|
||||||
class StandxRequestSigner {
|
class StandxRequestSigner {
|
||||||
private readonly privateKey: Uint8Array | null;
|
private readonly privateKey: Uint8Array | null;
|
||||||
|
|
||||||
@@ -97,7 +117,7 @@ class StandxRequestSigner {
|
|||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const signMessage = `${version},${requestId},${timestamp},${payload}`;
|
const signMessage = `${version},${requestId},${timestamp},${payload}`;
|
||||||
const signatureBytes = await sign(Buffer.from(signMessage, "utf-8"), this.privateKey);
|
const signatureBytes = sign(Buffer.from(signMessage, "utf-8"), this.privateKey);
|
||||||
return {
|
return {
|
||||||
"x-request-sign-version": version,
|
"x-request-sign-version": version,
|
||||||
"x-request-id": requestId,
|
"x-request-id": requestId,
|
||||||
@@ -107,21 +127,66 @@ class StandxRequestSigner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
function decodeBase58(input: string): Uint8Array | null {
|
||||||
|
try {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (const char of input) {
|
||||||
|
const value = BASE58_ALPHABET.indexOf(char);
|
||||||
|
if (value === -1) return null;
|
||||||
|
let carry = value;
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
const current = bytes[i] ?? 0;
|
||||||
|
carry += current * 58;
|
||||||
|
bytes[i] = carry & 0xff;
|
||||||
|
carry >>= 8;
|
||||||
|
}
|
||||||
|
while (carry > 0) {
|
||||||
|
bytes.push(carry & 0xff);
|
||||||
|
carry >>= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle leading zeros
|
||||||
|
for (const char of input) {
|
||||||
|
if (char !== "1") break;
|
||||||
|
bytes.push(0);
|
||||||
|
}
|
||||||
|
return Uint8Array.from(bytes.reverse());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseSigningKey(value?: string): Uint8Array | null {
|
function parseSigningKey(value?: string): Uint8Array | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
// 0x-prefixed hex
|
||||||
if (/^0x[0-9a-fA-F]+$/.test(trimmed)) {
|
if (/^0x[0-9a-fA-F]+$/.test(trimmed)) {
|
||||||
return Uint8Array.from(Buffer.from(trimmed.slice(2), "hex"));
|
return Uint8Array.from(Buffer.from(trimmed.slice(2), "hex"));
|
||||||
}
|
}
|
||||||
|
// Pure hex (64 chars = 32 bytes for ed25519 private key)
|
||||||
if (/^[0-9a-fA-F]+$/.test(trimmed)) {
|
if (/^[0-9a-fA-F]+$/.test(trimmed)) {
|
||||||
return Uint8Array.from(Buffer.from(trimmed, "hex"));
|
return Uint8Array.from(Buffer.from(trimmed, "hex"));
|
||||||
}
|
}
|
||||||
try {
|
// Base58 (official StandX API format)
|
||||||
return Uint8Array.from(Buffer.from(trimmed, "base64"));
|
if (/^[1-9A-HJ-NP-Za-km-z]+$/.test(trimmed)) {
|
||||||
} catch {
|
const decoded = decodeBase58(trimmed);
|
||||||
return null;
|
if (decoded && decoded.length === 32) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Base64 fallback
|
||||||
|
try {
|
||||||
|
const decoded = Uint8Array.from(Buffer.from(trimmed, "base64"));
|
||||||
|
if (decoded.length === 32) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSymbol(raw: string): string {
|
function normalizeSymbol(raw: string): string {
|
||||||
@@ -351,6 +416,7 @@ export class StandxGateway {
|
|||||||
private readonly tickerListeners = new Map<string, Set<TickerListener>>();
|
private readonly tickerListeners = new Map<string, Set<TickerListener>>();
|
||||||
private readonly klineListeners = new Map<string, Set<KlineListener>>();
|
private readonly klineListeners = new Map<string, Set<KlineListener>>();
|
||||||
private readonly fundingListeners = new Map<string, Set<FundingRateListener>>();
|
private readonly fundingListeners = new Map<string, Set<FundingRateListener>>();
|
||||||
|
private readonly connectionListeners = new Set<ConnectionEventListener>();
|
||||||
|
|
||||||
private readonly openOrders = new Map<string, AsterOrder>();
|
private readonly openOrders = new Map<string, AsterOrder>();
|
||||||
private readonly positions = new Map<string, AsterAccountPosition>();
|
private readonly positions = new Map<string, AsterAccountPosition>();
|
||||||
@@ -358,6 +424,10 @@ export class StandxGateway {
|
|||||||
private readonly virtualStops = new Map<string, VirtualStop>();
|
private readonly virtualStops = new Map<string, VirtualStop>();
|
||||||
|
|
||||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||||
|
private readonly restHealthListeners = new Set<RestHealthListener>();
|
||||||
|
private restConsecutiveErrors = 0;
|
||||||
|
private restUnhealthy = false;
|
||||||
|
private restLastError: string | null = null;
|
||||||
private fundingState = new Map<string, FundingState>();
|
private fundingState = new Map<string, FundingState>();
|
||||||
|
|
||||||
private marketWs: WebSocket | null = null;
|
private marketWs: WebSocket | null = null;
|
||||||
@@ -367,11 +437,37 @@ export class StandxGateway {
|
|||||||
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private readonly subscriptions = new Set<string>();
|
private readonly subscriptions = new Set<string>();
|
||||||
|
|
||||||
|
// ========== 心跳与连接管理 ==========
|
||||||
|
// 上次收到消息的时间戳
|
||||||
|
private lastMessageTime = 0;
|
||||||
|
// 心跳检查定时器
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
// 重连次数(用于指数退避)
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
|
||||||
|
// ========== 数据过时检测与 REST 备用 ==========
|
||||||
|
// 上次收到行情数据(price/depth)的时间戳
|
||||||
|
private lastMarketDataTime = 0;
|
||||||
|
// 上次收到账户数据(position/balance)的时间戳
|
||||||
|
private lastAccountDataTime = 0;
|
||||||
|
// 数据过时检查定时器
|
||||||
|
private dataStaleCheckTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
// REST 轮询定时器(WS 断连时启用)
|
||||||
|
private restPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
// REST 轮询是否激活
|
||||||
|
private restPollActive = false;
|
||||||
|
|
||||||
private readonly klineTimers = new Map<string, PollTimer>();
|
private readonly klineTimers = new Map<string, PollTimer>();
|
||||||
private readonly fundingTimers = new Map<string, PollTimer>();
|
private readonly fundingTimers = new Map<string, PollTimer>();
|
||||||
|
|
||||||
private lastPriceBySymbol = new Map<string, number>();
|
private lastPriceBySymbol = new Map<string, number>();
|
||||||
|
|
||||||
|
// 断连保护相关
|
||||||
|
private disconnectCancelRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private disconnectCancelRetryActive = false;
|
||||||
|
private lastKnownOpenOrders: Array<{ orderId: string; clOrdId?: string }> = [];
|
||||||
|
private disconnectedSymbol: string | null = null;
|
||||||
|
|
||||||
constructor(options: StandxGatewayOptions) {
|
constructor(options: StandxGatewayOptions) {
|
||||||
this.token = options.token ?? process.env.STANDX_TOKEN ?? "";
|
this.token = options.token ?? process.env.STANDX_TOKEN ?? "";
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
@@ -466,6 +562,62 @@ export class StandxGateway {
|
|||||||
this.startFundingPolling(key);
|
this.startFundingPolling(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.connectionListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.restHealthListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
offRestHealthEvent(listener: RestHealthListener): void {
|
||||||
|
this.restHealthListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
offConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.connectionListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前真实的挂单状态(通过 HTTP API)
|
||||||
|
* 用于在网络恢复后验证实际挂单情况
|
||||||
|
*/
|
||||||
|
async queryOpenOrders(symbol: string): Promise<AsterOrder[]> {
|
||||||
|
const normalized = normalizeSymbol(symbol);
|
||||||
|
const ordersPayload = await this.requestJson<unknown>("/api/query_open_orders", {
|
||||||
|
method: "GET",
|
||||||
|
params: { symbol: normalized },
|
||||||
|
});
|
||||||
|
const orders = extractOrders(ordersPayload);
|
||||||
|
const result: AsterOrder[] = [];
|
||||||
|
for (const raw of orders) {
|
||||||
|
const order = this.mapOrder(raw);
|
||||||
|
result.push(order);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制取消所有挂单(用于断连保护)
|
||||||
|
* 会不断重试直到成功或确认没有挂单
|
||||||
|
*/
|
||||||
|
async forceCancelAllOrders(symbol: string): Promise<boolean> {
|
||||||
|
const normalized = normalizeSymbol(symbol);
|
||||||
|
try {
|
||||||
|
const currentOrders = await this.queryOpenOrders(normalized);
|
||||||
|
if (currentOrders.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await this.cancelAllOrders({ symbol: normalized });
|
||||||
|
// 再次查询确认
|
||||||
|
const afterCancel = await this.queryOpenOrders(normalized);
|
||||||
|
return afterCancel.length === 0;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("forceCancelAllOrders", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||||
const normalizedSymbol = normalizeSymbol(params.symbol);
|
const normalizedSymbol = normalizeSymbol(params.symbol);
|
||||||
if (params.type === "STOP_MARKET") {
|
if (params.type === "STOP_MARKET") {
|
||||||
@@ -696,6 +848,13 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
payload.price = price;
|
payload.price = price;
|
||||||
}
|
}
|
||||||
|
// StandX TPSL 参数
|
||||||
|
if (params.slPrice != null && Number.isFinite(params.slPrice)) {
|
||||||
|
payload.sl_price = toDecimalString(params.slPrice);
|
||||||
|
}
|
||||||
|
if (params.tpPrice != null && Number.isFinite(params.tpPrice)) {
|
||||||
|
payload.tp_price = toDecimalString(params.tpPrice);
|
||||||
|
}
|
||||||
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
|
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
|
||||||
"/api/new_order",
|
"/api/new_order",
|
||||||
{
|
{
|
||||||
@@ -742,19 +901,44 @@ export class StandxGateway {
|
|||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
this.marketWsReady = true;
|
this.marketWsReady = true;
|
||||||
this.marketWsAuthed = false;
|
this.marketWsAuthed = false;
|
||||||
|
// 重置重连计数和时间戳
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
this.logDebug("ws open");
|
this.logDebug("ws open");
|
||||||
|
// 启动心跳监控
|
||||||
|
this.startHeartbeatMonitor();
|
||||||
|
// 启动数据过时检测
|
||||||
|
this.startDataStaleCheck();
|
||||||
|
// 停止 REST 轮询(WS 恢复后不再需要)
|
||||||
|
this.stopRestPoll();
|
||||||
this.sendAuthIfNeeded();
|
this.sendAuthIfNeeded();
|
||||||
};
|
};
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
const wasReady = this.marketWsReady;
|
||||||
this.marketWsReady = false;
|
this.marketWsReady = false;
|
||||||
this.marketWsAuthed = false;
|
this.marketWsAuthed = false;
|
||||||
this.marketWsAuthRequested = false;
|
this.marketWsAuthRequested = false;
|
||||||
this.marketWs = null;
|
this.marketWs = null;
|
||||||
|
// 停止心跳监控和数据过时检测
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopDataStaleCheck();
|
||||||
this.logDebug("ws close");
|
this.logDebug("ws close");
|
||||||
|
// 触发断连事件,启动断连保护
|
||||||
|
if (wasReady) {
|
||||||
|
this.onDisconnect();
|
||||||
|
}
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
};
|
};
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
this.logger("marketWs", error);
|
this.logger("marketWs", error);
|
||||||
|
// 如果连接从未成功建立(握手失败),需要清理并重连
|
||||||
|
// 因为某些 WebSocket 实现在握手失败时可能不触发 close 事件
|
||||||
|
if (this.marketWs && !this.marketWsReady) {
|
||||||
|
this.marketWs = null;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if ("addEventListener" in this.marketWs && typeof this.marketWs.addEventListener === "function") {
|
if ("addEventListener" in this.marketWs && typeof this.marketWs.addEventListener === "function") {
|
||||||
@@ -778,13 +962,23 @@ export class StandxGateway {
|
|||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
if (this.marketReconnectTimer) return;
|
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 = setTimeout(() => {
|
||||||
this.marketReconnectTimer = null;
|
this.marketReconnectTimer = null;
|
||||||
|
this.logDebug("attempting reconnect");
|
||||||
this.connectMarketWs();
|
this.connectMarketWs();
|
||||||
}, WS_RECONNECT_DELAY);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleMarketMessage(event: { data: any }): void {
|
private handleMarketMessage(event: { data: any }): void {
|
||||||
|
// 更新最后收到消息的时间(心跳监控)
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
this.logRawPayload(event.data);
|
this.logRawPayload(event.data);
|
||||||
const payloads = parseJsonPayloads(event.data);
|
const payloads = parseJsonPayloads(event.data);
|
||||||
if (payloads.length === 0) return;
|
if (payloads.length === 0) return;
|
||||||
@@ -809,13 +1003,17 @@ export class StandxGateway {
|
|||||||
this.marketWsAuthed = true;
|
this.marketWsAuthed = true;
|
||||||
this.marketWsAuthRequested = false;
|
this.marketWsAuthRequested = false;
|
||||||
this.flushSubscriptions();
|
this.flushSubscriptions();
|
||||||
|
// 触发重连事件
|
||||||
|
this.onReconnect();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "depth_book") {
|
if (channel === "depth_book") {
|
||||||
|
// 更新行情数据时间戳
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
const data = message.data as StandxDepthBook | undefined;
|
const data = message.data as StandxDepthBook | undefined;
|
||||||
const rawSymbol = data?.symbol ?? message?.symbol;
|
const rawSymbol = data?.symbol ?? message?.symbol;
|
||||||
if (!rawSymbol) return;
|
if (!rawSymbol || !data) return;
|
||||||
const bids = normalizeDepthLevels((data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), "bid");
|
const bids = normalizeDepthLevels((data.bids ?? []).map(([price, qty]) => [String(price), String(qty)]), "bid");
|
||||||
const asks = normalizeDepthLevels((data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), "ask");
|
const asks = normalizeDepthLevels((data.asks ?? []).map(([price, qty]) => [String(price), String(qty)]), "ask");
|
||||||
const decrossed = decrossDepthBook(bids, asks);
|
const decrossed = decrossDepthBook(bids, asks);
|
||||||
@@ -844,13 +1042,15 @@ export class StandxGateway {
|
|||||||
lastUpdateId: Number(message.seq ?? Date.now()),
|
lastUpdateId: Number(message.seq ?? Date.now()),
|
||||||
bids: finalBids,
|
bids: finalBids,
|
||||||
asks: finalAsks,
|
asks: finalAsks,
|
||||||
eventTime: toTimestamp(data.time),
|
eventTime: Date.now(),
|
||||||
symbol: rawSymbol,
|
symbol: rawSymbol,
|
||||||
};
|
};
|
||||||
this.emitDepth(rawSymbol, depth);
|
this.emitDepth(rawSymbol, depth);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "price") {
|
if (channel === "price") {
|
||||||
|
// 更新行情数据时间戳
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
const data = message.data as StandxPrice | undefined;
|
const data = message.data as StandxPrice | undefined;
|
||||||
if (!data?.symbol) return;
|
if (!data?.symbol) return;
|
||||||
const ticker = this.mapTicker(data);
|
const ticker = this.mapTicker(data);
|
||||||
@@ -858,6 +1058,8 @@ export class StandxGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "order") {
|
if (channel === "order") {
|
||||||
|
// 更新账户数据时间戳
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
const payload = message.data as StandxOrder | StandxOrder[] | undefined;
|
const payload = message.data as StandxOrder | StandxOrder[] | undefined;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const items = Array.isArray(payload) ? payload : [payload];
|
const items = Array.isArray(payload) ? payload : [payload];
|
||||||
@@ -869,6 +1071,8 @@ export class StandxGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "position") {
|
if (channel === "position") {
|
||||||
|
// 更新账户数据时间戳
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
const payload = message.data as StandxPosition | StandxPosition[] | undefined;
|
const payload = message.data as StandxPosition | StandxPosition[] | undefined;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const items = Array.isArray(payload) ? payload : [payload];
|
const items = Array.isArray(payload) ? payload : [payload];
|
||||||
@@ -881,6 +1085,8 @@ export class StandxGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channel === "balance") {
|
if (channel === "balance") {
|
||||||
|
// 更新账户数据时间戳
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
const payload = message.data as StandxBalance | StandxBalance[] | undefined;
|
const payload = message.data as StandxBalance | StandxBalance[] | undefined;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const items = Array.isArray(payload) ? payload : [payload];
|
const items = Array.isArray(payload) ? payload : [payload];
|
||||||
@@ -923,6 +1129,7 @@ export class StandxGateway {
|
|||||||
if (!this.marketWsAuthed) return;
|
if (!this.marketWsAuthed) return;
|
||||||
for (const entry of this.subscriptions) {
|
for (const entry of this.subscriptions) {
|
||||||
const [channel, symbol] = entry.split(":");
|
const [channel, symbol] = entry.split(":");
|
||||||
|
if (!channel) continue;
|
||||||
this.sendSubscribe({ channel, ...(symbol ? { symbol } : {}) });
|
this.sendSubscribe({ channel, ...(symbol ? { symbol } : {}) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -932,6 +1139,194 @@ export class StandxGateway {
|
|||||||
this.marketWs?.send(JSON.stringify({ subscribe: stream }));
|
this.marketWs?.send(JSON.stringify({ subscribe: stream }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 心跳监控 ==========
|
||||||
|
/**
|
||||||
|
* 启动心跳监控
|
||||||
|
* 定期检查是否收到消息,如果超时则主动触发重连
|
||||||
|
*/
|
||||||
|
private startHeartbeatMonitor(): void {
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastMessageTime;
|
||||||
|
if (elapsed > WS_HEARTBEAT_TIMEOUT) {
|
||||||
|
this.logDebug(`heartbeat timeout (${elapsed}ms since last message), forcing reconnect`);
|
||||||
|
this.forceReconnect("heartbeat_timeout");
|
||||||
|
}
|
||||||
|
}, WS_HEARTBEAT_CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止心跳监控
|
||||||
|
*/
|
||||||
|
private stopHeartbeatMonitor(): void {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 数据过时检测与 REST 备用拉取 ==========
|
||||||
|
/**
|
||||||
|
* 启动数据过时检测
|
||||||
|
* 即使 WS 连接正常,如果超过 3 秒未收到行情/账户数据,也主动通过 REST 拉取
|
||||||
|
*/
|
||||||
|
private startDataStaleCheck(): void {
|
||||||
|
this.stopDataStaleCheck();
|
||||||
|
this.dataStaleCheckTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const marketStale = now - this.lastMarketDataTime > WS_DATA_STALE_THRESHOLD;
|
||||||
|
const accountStale = now - this.lastAccountDataTime > WS_DATA_STALE_THRESHOLD;
|
||||||
|
|
||||||
|
if (marketStale || accountStale) {
|
||||||
|
this.logDebug("data stale detected", {
|
||||||
|
marketStaleMs: now - this.lastMarketDataTime,
|
||||||
|
accountStaleMs: now - this.lastAccountDataTime,
|
||||||
|
marketStale,
|
||||||
|
accountStale,
|
||||||
|
});
|
||||||
|
// 主动通过 REST 拉取数据
|
||||||
|
this.fetchStaleData(marketStale, accountStale);
|
||||||
|
}
|
||||||
|
}, 1000); // 每秒检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止数据过时检测
|
||||||
|
*/
|
||||||
|
private stopDataStaleCheck(): void {
|
||||||
|
if (this.dataStaleCheckTimer) {
|
||||||
|
clearInterval(this.dataStaleCheckTimer);
|
||||||
|
this.dataStaleCheckTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动拉取过时的数据
|
||||||
|
*/
|
||||||
|
private fetchStaleData(marketStale: boolean, accountStale: boolean): void {
|
||||||
|
// 获取当前订阅的 symbols
|
||||||
|
const symbols = new Set<string>();
|
||||||
|
for (const key of this.subscriptions) {
|
||||||
|
const [, symbol] = key.split(":");
|
||||||
|
if (symbol) symbols.add(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marketStale) {
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
void this.fetchTickerSnapshot(symbol).catch((e) => this.logger("staleTickerFetch", e));
|
||||||
|
void this.fetchDepthSnapshot(symbol).catch((e) => this.logger("staleDepthFetch", e));
|
||||||
|
}
|
||||||
|
// 更新时间戳避免重复拉取
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountStale) {
|
||||||
|
void this.refreshAccountSnapshot().catch((e) => this.logger("staleAccountFetch", e));
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
void this.refreshOpenOrders(symbol).catch((e) => this.logger("staleOrdersFetch", e));
|
||||||
|
}
|
||||||
|
// 更新时间戳避免重复拉取
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 REST 轮询(WS 断连时使用)
|
||||||
|
* 持续通过 REST API 拉取行情和账户数据,确保止损等逻辑能正常工作
|
||||||
|
*/
|
||||||
|
private startRestPoll(): void {
|
||||||
|
if (this.restPollActive) return;
|
||||||
|
this.restPollActive = true;
|
||||||
|
this.logDebug("REST poll started (WS disconnected)");
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
if (!this.restPollActive) return;
|
||||||
|
|
||||||
|
// 获取当前订阅的 symbols
|
||||||
|
const symbols = new Set<string>();
|
||||||
|
for (const key of this.subscriptions) {
|
||||||
|
const [, symbol] = key.split(":");
|
||||||
|
if (symbol) symbols.add(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取行情数据
|
||||||
|
for (const symbol of symbols) {
|
||||||
|
try {
|
||||||
|
await this.fetchTickerSnapshot(symbol);
|
||||||
|
this.lastMarketDataTime = Date.now();
|
||||||
|
} catch (e) {
|
||||||
|
this.logger("restPollTicker", e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.fetchDepthSnapshot(symbol);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger("restPollDepth", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取账户数据
|
||||||
|
try {
|
||||||
|
await this.refreshAccountSnapshot();
|
||||||
|
this.lastAccountDataTime = Date.now();
|
||||||
|
} catch (e) {
|
||||||
|
this.logger("restPollAccount", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 继续下一次轮询
|
||||||
|
if (this.restPollActive) {
|
||||||
|
this.restPollTimer = setTimeout(() => void poll(), REST_POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止 REST 轮询
|
||||||
|
*/
|
||||||
|
private stopRestPoll(): void {
|
||||||
|
if (!this.restPollActive) return;
|
||||||
|
this.restPollActive = false;
|
||||||
|
if (this.restPollTimer) {
|
||||||
|
clearTimeout(this.restPollTimer);
|
||||||
|
this.restPollTimer = null;
|
||||||
|
}
|
||||||
|
this.logDebug("REST poll stopped (WS restored)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制重连(用于心跳超时)
|
||||||
|
* 与普通断连不同,这里不增加重连计数(因为是主动行为)
|
||||||
|
*/
|
||||||
|
private forceReconnect(reason: string): void {
|
||||||
|
this.logDebug(`force reconnect: ${reason}`);
|
||||||
|
// 停止监控
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopDataStaleCheck();
|
||||||
|
// 关闭现有连接
|
||||||
|
if (this.marketWs) {
|
||||||
|
try {
|
||||||
|
this.marketWs.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close errors
|
||||||
|
}
|
||||||
|
this.marketWs = null;
|
||||||
|
}
|
||||||
|
// 重置状态
|
||||||
|
const wasReady = this.marketWsReady;
|
||||||
|
this.marketWsReady = false;
|
||||||
|
this.marketWsAuthed = false;
|
||||||
|
this.marketWsAuthRequested = false;
|
||||||
|
// 触发断连事件
|
||||||
|
if (wasReady) {
|
||||||
|
this.onDisconnect();
|
||||||
|
}
|
||||||
|
// 立即重连(不使用指数退避,因为是主动行为)
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
private logDebug(context: string, detail?: unknown): void {
|
private logDebug(context: string, detail?: unknown): void {
|
||||||
if (!this.debugWs) return;
|
if (!this.debugWs) return;
|
||||||
if (detail === undefined) {
|
if (detail === undefined) {
|
||||||
@@ -1003,7 +1398,7 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitAccountSnapshot(): void {
|
private emitAccountSnapshot(updateTime?: number): void {
|
||||||
const positions = Array.from(this.positions.values());
|
const positions = Array.from(this.positions.values());
|
||||||
const assets = Array.from(this.balances.values());
|
const assets = Array.from(this.balances.values());
|
||||||
const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0);
|
const totalWalletBalance = assets.reduce((sum, asset) => sum + Number(asset.walletBalance ?? 0), 0);
|
||||||
@@ -1015,7 +1410,7 @@ export class StandxGateway {
|
|||||||
canTrade: true,
|
canTrade: true,
|
||||||
canDeposit: true,
|
canDeposit: true,
|
||||||
canWithdraw: true,
|
canWithdraw: true,
|
||||||
updateTime: Date.now(),
|
updateTime: typeof updateTime === "number" && Number.isFinite(updateTime) && updateTime > 0 ? updateTime : Date.now(),
|
||||||
totalWalletBalance: String(totalWalletBalance || 0),
|
totalWalletBalance: String(totalWalletBalance || 0),
|
||||||
totalUnrealizedProfit: String(totalUnrealizedProfit || 0),
|
totalUnrealizedProfit: String(totalUnrealizedProfit || 0),
|
||||||
positions,
|
positions,
|
||||||
@@ -1032,16 +1427,19 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshAccountSnapshot(): Promise<void> {
|
private async refreshAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
try {
|
try {
|
||||||
const [balance, positions] = await Promise.all([
|
const [balance, positions] = await Promise.all([
|
||||||
this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
|
this.requestJson<StandxBalanceSnapshot>("/api/query_balance", { method: "GET" }),
|
||||||
this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }),
|
this.requestJson<StandxPosition[]>("/api/query_positions", { method: "GET" }),
|
||||||
]);
|
]);
|
||||||
|
let restSnapshotTime = 0;
|
||||||
if (Array.isArray(positions)) {
|
if (Array.isArray(positions)) {
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
const mapped = this.mapPosition(position);
|
const mapped = this.mapPosition(position);
|
||||||
this.positions.set(mapped.symbol, mapped);
|
this.positions.set(mapped.symbol, mapped);
|
||||||
|
const positionTime = toTimestamp(position.time ?? position.updated_at);
|
||||||
|
restSnapshotTime = Math.max(restSnapshotTime, positionTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (balance) {
|
if (balance) {
|
||||||
@@ -1050,14 +1448,44 @@ export class StandxGateway {
|
|||||||
asset: token,
|
asset: token,
|
||||||
walletBalance: String(balance.balance ?? "0"),
|
walletBalance: String(balance.balance ?? "0"),
|
||||||
availableBalance: String(balance.cross_available ?? balance.balance ?? "0"),
|
availableBalance: String(balance.cross_available ?? balance.balance ?? "0"),
|
||||||
updateTime: Date.now(),
|
updateTime: restSnapshotTime > 0 ? restSnapshotTime : Date.now(),
|
||||||
unrealizedProfit: String(balance.upnl ?? "0"),
|
unrealizedProfit: String(balance.upnl ?? "0"),
|
||||||
};
|
};
|
||||||
this.balances.set(token, asset);
|
this.balances.set(token, asset);
|
||||||
}
|
}
|
||||||
this.emitAccountSnapshot();
|
this.emitAccountSnapshot(restSnapshotTime > 0 ? restSnapshotTime : undefined);
|
||||||
|
return this.accountSnapshot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger("accountSnapshot", error);
|
this.logger("accountSnapshot", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
|
return await this.refreshAccountSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMarginMode(symbol: string, marginMode: "isolated" | "cross"): Promise<void> {
|
||||||
|
if (!this.signer.hasKey()) {
|
||||||
|
throw new Error("StandX change_margin_mode requires STANDX_REQUEST_PRIVATE_KEY for signed requests");
|
||||||
|
}
|
||||||
|
const normalized = normalizeSymbol(symbol);
|
||||||
|
const response = await this.requestJson<{ code?: number; message?: string; request_id?: string }>(
|
||||||
|
"/api/change_margin_mode",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
symbol: normalized,
|
||||||
|
margin_mode: marginMode,
|
||||||
|
},
|
||||||
|
signed: true,
|
||||||
|
extraHeaders: {
|
||||||
|
"x-session-id": this.sessionId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response && typeof response.code === "number" && response.code !== 0) {
|
||||||
|
throw new Error(response.message ?? "StandX change margin mode rejected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1072,9 +1500,8 @@ export class StandxGateway {
|
|||||||
const order = this.mapOrder(raw);
|
const order = this.mapOrder(raw);
|
||||||
mergeOrderSnapshot(this.openOrders, order);
|
mergeOrderSnapshot(this.openOrders, order);
|
||||||
}
|
}
|
||||||
if (orders.length) {
|
// 无论是否有挂单都触发推送,确保上层状态更新
|
||||||
this.emitOrders();
|
this.emitOrders();
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger("openOrders", error);
|
this.logger("openOrders", error);
|
||||||
}
|
}
|
||||||
@@ -1255,7 +1682,7 @@ export class StandxGateway {
|
|||||||
entryPrice: String(data.entry_price ?? "0"),
|
entryPrice: String(data.entry_price ?? "0"),
|
||||||
unrealizedProfit: String(data.upnl ?? "0"),
|
unrealizedProfit: String(data.upnl ?? "0"),
|
||||||
positionSide: "BOTH",
|
positionSide: "BOTH",
|
||||||
updateTime: toTimestamp(data.updated_at),
|
updateTime: toTimestamp(data.time ?? data.updated_at),
|
||||||
leverage: data.leverage ? String(data.leverage) : undefined,
|
leverage: data.leverage ? String(data.leverage) : undefined,
|
||||||
marginType: data.margin_mode,
|
marginType: data.margin_mode,
|
||||||
liquidationPrice: data.liq_price ? String(data.liq_price) : undefined,
|
liquidationPrice: data.liq_price ? String(data.liq_price) : undefined,
|
||||||
@@ -1330,22 +1757,209 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: options.method,
|
|
||||||
headers,
|
|
||||||
body: options.method === "GET" ? undefined : body,
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`);
|
|
||||||
}
|
|
||||||
if (!text) {
|
|
||||||
return {} as T;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text) as T;
|
const response = await fetch(url.toString(), {
|
||||||
} catch {
|
method: options.method,
|
||||||
return text as unknown as T;
|
headers,
|
||||||
|
body: options.method === "GET" ? undefined : body,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${options.method} ${path} failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
this.recordRestSuccess();
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as T;
|
||||||
|
this.recordRestSuccess();
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
this.recordRestSuccess();
|
||||||
|
return text as unknown as T;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.recordRestError({
|
||||||
|
consecutiveErrors: this.restConsecutiveErrors + 1,
|
||||||
|
method: options.method,
|
||||||
|
path,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordRestSuccess(): void {
|
||||||
|
if (this.restConsecutiveErrors === 0 && !this.restUnhealthy) return;
|
||||||
|
this.restConsecutiveErrors = 0;
|
||||||
|
this.restLastError = null;
|
||||||
|
if (!this.restUnhealthy) return;
|
||||||
|
this.restUnhealthy = false;
|
||||||
|
this.emitRestHealth("healthy", { consecutiveErrors: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordRestError(info: RestHealthInfo): void {
|
||||||
|
this.restConsecutiveErrors = Math.max(0, Number(info.consecutiveErrors) || 0);
|
||||||
|
this.restLastError = info.error ?? this.restLastError;
|
||||||
|
|
||||||
|
if (!this.restUnhealthy && this.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD) {
|
||||||
|
this.restUnhealthy = true;
|
||||||
|
this.emitRestHealth("unhealthy", {
|
||||||
|
consecutiveErrors: this.restConsecutiveErrors,
|
||||||
|
method: info.method,
|
||||||
|
path: info.path,
|
||||||
|
error: info.error ?? this.restLastError ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitRestHealth(state: RestHealthState, info: RestHealthInfo): void {
|
||||||
|
for (const listener of this.restHealthListeners) {
|
||||||
|
try {
|
||||||
|
listener(state, info);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("restHealthListener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断连时触发,记录当前挂单状态并启动持久重试取消
|
||||||
|
*/
|
||||||
|
private onDisconnect(): void {
|
||||||
|
// 记录最后已知的挂单状态
|
||||||
|
this.lastKnownOpenOrders = Array.from(this.openOrders.values()).map((order) => ({
|
||||||
|
orderId: String(order.orderId),
|
||||||
|
clOrdId: order.clientOrderId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 获取当前订阅的 symbol
|
||||||
|
const symbols = new Set<string>();
|
||||||
|
for (const key of this.subscriptions) {
|
||||||
|
const [, symbol] = key.split(":");
|
||||||
|
if (symbol) symbols.add(symbol);
|
||||||
|
}
|
||||||
|
this.disconnectedSymbol = symbols.size > 0 ? Array.from(symbols)[0] ?? null : null;
|
||||||
|
|
||||||
|
this.logDebug("disconnect protection", {
|
||||||
|
openOrderCount: this.lastKnownOpenOrders.length,
|
||||||
|
symbol: this.disconnectedSymbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触发断连事件
|
||||||
|
for (const listener of this.connectionListeners) {
|
||||||
|
try {
|
||||||
|
listener("disconnected", this.disconnectedSymbol ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("connectionListener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动断连保护:持续重试取消所有挂单
|
||||||
|
if (this.lastKnownOpenOrders.length > 0 && this.disconnectedSymbol) {
|
||||||
|
this.startDisconnectCancelRetry(this.disconnectedSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动 REST 轮询,确保断连期间仍能获取行情和账户数据(用于止损等逻辑)
|
||||||
|
this.startRestPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重连成功时触发,停止断连保护并通知监听器
|
||||||
|
*/
|
||||||
|
private onReconnect(): void {
|
||||||
|
this.logDebug("reconnect protection", {
|
||||||
|
wasRetrying: this.disconnectCancelRetryActive,
|
||||||
|
symbol: this.disconnectedSymbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 停止断连保护重试
|
||||||
|
this.stopDisconnectCancelRetry();
|
||||||
|
|
||||||
|
// 清空本地挂单状态(重连后需要重新同步)
|
||||||
|
this.openOrders.clear();
|
||||||
|
|
||||||
|
// 主动触发一次数据推送,确保上层 feedStatus 能更新
|
||||||
|
this.emitOrders();
|
||||||
|
this.emitAccountSnapshot();
|
||||||
|
|
||||||
|
// 主动刷新账户和挂单数据
|
||||||
|
void this.refreshAccountSnapshot();
|
||||||
|
|
||||||
|
// 获取当前订阅的 symbols 并刷新数据
|
||||||
|
const subscribedSymbols = new Set<string>();
|
||||||
|
for (const key of this.subscriptions) {
|
||||||
|
const [, symbol] = key.split(":");
|
||||||
|
if (symbol) subscribedSymbols.add(symbol);
|
||||||
|
}
|
||||||
|
if (this.disconnectedSymbol) {
|
||||||
|
subscribedSymbols.add(this.disconnectedSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新每个 symbol 的数据
|
||||||
|
for (const symbol of subscribedSymbols) {
|
||||||
|
void this.refreshOpenOrders(symbol);
|
||||||
|
void this.fetchDepthSnapshot(symbol).catch((e) => this.logger("depthSnapshot", e));
|
||||||
|
void this.fetchTickerSnapshot(symbol).catch((e) => this.logger("tickerSnapshot", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发重连事件
|
||||||
|
for (const listener of this.connectionListeners) {
|
||||||
|
try {
|
||||||
|
listener("reconnected", this.disconnectedSymbol ?? "");
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("connectionListener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disconnectedSymbol = null;
|
||||||
|
this.lastKnownOpenOrders = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动断连保护:持续重试取消所有挂单
|
||||||
|
* 即使网络不通也不停止重试
|
||||||
|
*/
|
||||||
|
private startDisconnectCancelRetry(symbol: string): void {
|
||||||
|
if (this.disconnectCancelRetryActive) return;
|
||||||
|
this.disconnectCancelRetryActive = true;
|
||||||
|
|
||||||
|
const retryCancel = async () => {
|
||||||
|
if (!this.disconnectCancelRetryActive) return;
|
||||||
|
|
||||||
|
this.logDebug("disconnect cancel retry attempt", { symbol });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await this.forceCancelAllOrders(symbol);
|
||||||
|
if (success) {
|
||||||
|
this.logDebug("disconnect cancel retry success");
|
||||||
|
this.stopDisconnectCancelRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger("disconnectCancelRetry", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍在重试状态,继续下一次重试
|
||||||
|
if (this.disconnectCancelRetryActive) {
|
||||||
|
this.disconnectCancelRetryTimer = setTimeout(() => {
|
||||||
|
void retryCancel();
|
||||||
|
}, 2000); // 每 2 秒重试一次
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void retryCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止断连保护重试
|
||||||
|
*/
|
||||||
|
private stopDisconnectCancelRetry(): void {
|
||||||
|
this.disconnectCancelRetryActive = false;
|
||||||
|
if (this.disconnectCancelRetryTimer) {
|
||||||
|
clearTimeout(this.disconnectCancelRetryTimer);
|
||||||
|
this.disconnectCancelRetryTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterO
|
|||||||
quantity: intent.quantity,
|
quantity: intent.quantity,
|
||||||
price: intent.price,
|
price: intent.price,
|
||||||
timeInForce: intent.timeInForce ?? "GTX",
|
timeInForce: intent.timeInForce ?? "GTX",
|
||||||
|
slPrice: intent.slPrice,
|
||||||
|
tpPrice: intent.tpPrice,
|
||||||
},
|
},
|
||||||
intent
|
intent
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface StandxPosition {
|
|||||||
leverage?: string;
|
leverage?: string;
|
||||||
liq_price?: string;
|
liq_price?: string;
|
||||||
margin_mode?: string;
|
margin_mode?: string;
|
||||||
|
time?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface CreateOrderParams {
|
|||||||
reduceOnly?: StringBoolean;
|
reduceOnly?: StringBoolean;
|
||||||
closePosition?: StringBoolean;
|
closePosition?: StringBoolean;
|
||||||
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
||||||
|
// StandX TPSL 参数
|
||||||
|
slPrice?: number; // 止损价格
|
||||||
|
tpPrice?: number; // 止盈价格
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterAccountPosition {
|
export interface AsterAccountPosition {
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "监控期货与现货盘口差价,辅助发现套利机会",
|
zh: "监控期货与现货盘口差价,辅助发现套利机会",
|
||||||
en: "Monitors futures/spot spread to surface arbitrage windows.",
|
en: "Monitors futures/spot spread to surface arbitrage windows.",
|
||||||
},
|
},
|
||||||
|
"app.strategy.liquidityMaker.label": { zh: "流动性做市商", en: "Liquidity Maker" },
|
||||||
|
"app.strategy.liquidityMaker.desc": {
|
||||||
|
zh: "成交后在更优价位挂单平仓,更敏感的深度偏移判断",
|
||||||
|
en: "Places close orders at better prices after fills, with sensitive depth imbalance detection.",
|
||||||
|
},
|
||||||
|
"liquidityMaker.title": { zh: "流动性做市商 (Liquidity Maker)", en: "Liquidity Maker" },
|
||||||
|
"liquidityMaker.initializing": { zh: "流动性做市商初始化中...", en: "Initializing Liquidity Maker..." },
|
||||||
|
"liquidityMaker.lastFill": { zh: "最近成交: {info}", en: "Last fill: {info}" },
|
||||||
|
"liquidityMaker.noFill": { zh: "无", en: "None" },
|
||||||
"app.integrity.warning": {
|
"app.integrity.warning": {
|
||||||
zh: "警告: 版权校验失败,当前版本可能被篡改。",
|
zh: "警告: 版权校验失败,当前版本可能被篡改。",
|
||||||
en: "Warning: Copyright integrity check failed; build may be tampered.",
|
en: "Warning: Copyright integrity check failed; build may be tampered.",
|
||||||
@@ -193,6 +202,10 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "Binance 深度: 买10 {buy} | 卖10 {sell} | 状态: {status}",
|
zh: "Binance 深度: 买10 {buy} | 卖10 {sell} | 状态: {status}",
|
||||||
en: "Binance depth: bid10 {buy} | ask10 {sell} | Status: {status}",
|
en: "Binance depth: bid10 {buy} | ask10 {sell} | Status: {status}",
|
||||||
},
|
},
|
||||||
|
"makerPoints.bandDepthLine": {
|
||||||
|
zh: "StandX 档位 {band}bps 深度: 买 {buy} | 卖 {sell}",
|
||||||
|
en: "StandX band {band}bps depth: buy {buy} | sell {sell}",
|
||||||
|
},
|
||||||
"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" },
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type {
|
||||||
|
NotificationLevel,
|
||||||
|
TradeNotification,
|
||||||
|
NotificationSender,
|
||||||
|
NotificationConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export {
|
||||||
|
TelegramNotifier,
|
||||||
|
createTelegramNotifier,
|
||||||
|
type TelegramConfig,
|
||||||
|
} from "./telegram";
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { NotificationSender, TradeNotification, NotificationConfig } from "./types";
|
||||||
|
|
||||||
|
export interface TelegramConfig extends NotificationConfig {
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_EMOJI: Record<string, string> = {
|
||||||
|
info: "ℹ️",
|
||||||
|
warn: "⚠️",
|
||||||
|
error: "🚨",
|
||||||
|
success: "✅",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_EMOJI: Record<string, string> = {
|
||||||
|
order_filled: "📝",
|
||||||
|
position_opened: "📈",
|
||||||
|
position_closed: "📉",
|
||||||
|
stop_loss: "🛑",
|
||||||
|
token_expired: "⏰",
|
||||||
|
custom: "📢",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOG_PREFIX = "[Telegram]";
|
||||||
|
|
||||||
|
function formatNotificationMessage(notification: TradeNotification, accountLabel?: string): string {
|
||||||
|
const levelEmoji = LEVEL_EMOJI[notification.level] ?? "";
|
||||||
|
const typeEmoji = TYPE_EMOJI[notification.type] ?? "";
|
||||||
|
const timestamp = notification.timestamp ?? Date.now();
|
||||||
|
const time = new Date(timestamp).toISOString().replace("T", " ").substring(0, 19);
|
||||||
|
|
||||||
|
const label = notification.accountLabel ?? accountLabel ?? notification.symbol;
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`${typeEmoji}${levelEmoji} [${label}] ${notification.title}`,
|
||||||
|
``,
|
||||||
|
`${notification.message}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (notification.details && Object.keys(notification.details).length > 0) {
|
||||||
|
lines.push(``);
|
||||||
|
for (const [key, value] of Object.entries(notification.details)) {
|
||||||
|
if (value != null) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
lines.push(`• ${key}: ${value.join(", ") || "[]"}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`• ${key}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(`🕐 ${time} UTC`);
|
||||||
|
lines.push(`📊 ${notification.symbol}`);
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramNotifier implements NotificationSender {
|
||||||
|
private readonly config: TelegramConfig;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private sendQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(config: Partial<TelegramConfig> = {}) {
|
||||||
|
this.config = {
|
||||||
|
enabled: Boolean(config.botToken && config.chatId),
|
||||||
|
botToken: config.botToken ?? "",
|
||||||
|
chatId: config.chatId ?? "",
|
||||||
|
accountLabel: config.accountLabel,
|
||||||
|
};
|
||||||
|
this.baseUrl = `https://api.telegram.org/bot${this.config.botToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(notification: TradeNotification): Promise<void> {
|
||||||
|
if (!this.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendQueue = this.sendQueue
|
||||||
|
.then(() => this.doSend(notification))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSend(notification: TradeNotification): Promise<void> {
|
||||||
|
const text = formatNotificationMessage(notification, this.config.accountLabel);
|
||||||
|
const url = `${this.baseUrl}/sendMessage`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: this.config.chatId,
|
||||||
|
text,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.info(`${LOG_PREFIX} Notification sent (status ${response.status}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorText = await response.text().catch(() => "unknown error");
|
||||||
|
console.error(`${LOG_PREFIX} Failed to send notification: ${response.status} ${errorText}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${LOG_PREFIX} Failed to send notification: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTelegramNotifier(): TelegramNotifier {
|
||||||
|
return new TelegramNotifier({
|
||||||
|
botToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||||
|
chatId: process.env.TELEGRAM_CHAT_ID,
|
||||||
|
accountLabel: process.env.TELEGRAM_ACCOUNT_LABEL,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type NotificationLevel = "info" | "warn" | "error" | "success";
|
||||||
|
|
||||||
|
export interface TradeNotification {
|
||||||
|
type: "order_filled" | "position_opened" | "position_closed" | "stop_loss" | "token_expired" | "custom";
|
||||||
|
level: NotificationLevel;
|
||||||
|
symbol: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
accountLabel?: string;
|
||||||
|
details?: Record<string, string | number | boolean | null | string[]>;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSender {
|
||||||
|
send(notification: TradeNotification): Promise<void>;
|
||||||
|
isEnabled(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
accountLabel?: string;
|
||||||
|
}
|
||||||
@@ -6,7 +6,24 @@ const WebSocketCtor: typeof globalThis.WebSocket =
|
|||||||
? globalThis.WebSocket
|
? globalThis.WebSocket
|
||||||
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
|
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = "wss://fstream.binance.com/ws";
|
const DEFAULT_BASE_URL = "wss://stream.binance.com:9443/ws";
|
||||||
|
|
||||||
|
// ========== Binance WebSocket 连接管理常量 ==========
|
||||||
|
// Binance 会发送 ping,若长时间无消息则认为连接异常
|
||||||
|
// 我们设置 5 分钟作为心跳超时阈值(保守值)
|
||||||
|
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
// 心跳检查间隔(每 30 秒检查一次)
|
||||||
|
const HEARTBEAT_CHECK_INTERVAL_MS = 30_000;
|
||||||
|
// Binance 连接最长有效期 24 小时,我们设置 23 小时主动重连
|
||||||
|
const MAX_CONNECTION_DURATION_MS = 23 * 60 * 60 * 1000;
|
||||||
|
// 数据过时阈值(毫秒)- 超过此时间未收到数据,标记为不可用
|
||||||
|
const DATA_STALE_THRESHOLD_MS = 5_000;
|
||||||
|
// 基础重连延迟
|
||||||
|
const RECONNECT_DELAY_BASE_MS = 3000;
|
||||||
|
// 最大重连延迟
|
||||||
|
const RECONNECT_DELAY_MAX_MS = 60_000;
|
||||||
|
|
||||||
|
export type BinanceConnectionState = "connected" | "disconnected" | "stale";
|
||||||
|
|
||||||
export interface BinanceDepthSnapshot {
|
export interface BinanceDepthSnapshot {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -18,13 +35,29 @@ export interface BinanceDepthSnapshot {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BinanceConnectionListener = (state: BinanceConnectionState) => void;
|
||||||
|
|
||||||
export class BinanceDepthTracker {
|
export class BinanceDepthTracker {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private reconnectDelayMs = 3000;
|
private reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
private stopped = false;
|
private stopped = false;
|
||||||
private snapshot: BinanceDepthSnapshot | null = null;
|
private snapshot: BinanceDepthSnapshot | null = null;
|
||||||
private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>();
|
private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>();
|
||||||
|
private connectionListeners = new Set<BinanceConnectionListener>();
|
||||||
|
|
||||||
|
// ========== 心跳与连接管理 ==========
|
||||||
|
// 上次收到消息的时间戳
|
||||||
|
private lastMessageTime = 0;
|
||||||
|
// 心跳检查定时器
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
// 连接建立时间(用于日志记录)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
private connectionStartTime = 0;
|
||||||
|
// 24 小时重连定时器
|
||||||
|
private maxDurationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// 当前连接状态
|
||||||
|
private connectionState: BinanceConnectionState = "disconnected";
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly symbol: string,
|
private readonly symbol: string,
|
||||||
@@ -32,6 +65,7 @@ export class BinanceDepthTracker {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
levels?: number;
|
levels?: number;
|
||||||
ratio?: number;
|
ratio?: number;
|
||||||
|
speedMs?: number;
|
||||||
logger?: (context: string, error: unknown) => void;
|
logger?: (context: string, error: unknown) => void;
|
||||||
}
|
}
|
||||||
) {}
|
) {}
|
||||||
@@ -43,18 +77,7 @@ export class BinanceDepthTracker {
|
|||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
if (this.reconnectTimer) {
|
this.cleanup();
|
||||||
clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
if (this.ws) {
|
|
||||||
try {
|
|
||||||
this.ws.close();
|
|
||||||
} catch {
|
|
||||||
// Ignore close errors
|
|
||||||
}
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
|
onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
|
||||||
@@ -65,37 +88,122 @@ export class BinanceDepthTracker {
|
|||||||
this.listeners.delete(handler);
|
this.listeners.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听连接状态变化
|
||||||
|
*/
|
||||||
|
onConnectionChange(handler: BinanceConnectionListener): void {
|
||||||
|
this.connectionListeners.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
offConnectionChange(handler: BinanceConnectionListener): void {
|
||||||
|
this.connectionListeners.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
getSnapshot(): BinanceDepthSnapshot | null {
|
getSnapshot(): BinanceDepthSnapshot | null {
|
||||||
return this.snapshot ? { ...this.snapshot } : null;
|
return this.snapshot ? { ...this.snapshot } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前连接状态
|
||||||
|
*/
|
||||||
|
getConnectionState(): BinanceConnectionState {
|
||||||
|
return this.connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查数据是否过时
|
||||||
|
*/
|
||||||
|
isDataStale(): boolean {
|
||||||
|
if (!this.snapshot) return true;
|
||||||
|
return Date.now() - this.snapshot.updatedAt > DATA_STALE_THRESHOLD_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
// 停止心跳监控
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
// 停止 24 小时重连定时器
|
||||||
|
if (this.maxDurationTimer) {
|
||||||
|
clearTimeout(this.maxDurationTimer);
|
||||||
|
this.maxDurationTimer = null;
|
||||||
|
}
|
||||||
|
// 停止重连定时器
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
// 关闭 WebSocket
|
||||||
|
if (this.ws) {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private connect(): void {
|
private connect(): void {
|
||||||
if (this.ws || this.stopped) return;
|
if (this.ws || this.stopped) return;
|
||||||
const url = this.buildUrl();
|
const url = this.buildUrl();
|
||||||
this.ws = new WebSocketCtor(url);
|
this.ws = new WebSocketCtor(url);
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
this.reconnectDelayMs = 3000;
|
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
|
this.connectionStartTime = Date.now();
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
this.updateConnectionState("connected");
|
||||||
|
|
||||||
|
// 启动心跳监控
|
||||||
|
this.startHeartbeatMonitor();
|
||||||
|
// 启动 24 小时自动重连定时器
|
||||||
|
this.startMaxDurationTimer();
|
||||||
|
|
||||||
|
this.options?.logger?.("binanceDepth", "WebSocket connected");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopMaxDurationTimer();
|
||||||
|
this.updateConnectionState("disconnected");
|
||||||
|
|
||||||
if (!this.stopped) {
|
if (!this.stopped) {
|
||||||
|
this.options?.logger?.("binanceDepth", "WebSocket closed, scheduling reconnect");
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
this.options?.logger?.("binanceDepth", error);
|
this.options?.logger?.("binanceDepth", error);
|
||||||
|
// 如果连接从未成功建立,需要清理并重连
|
||||||
|
if (this.ws && this.connectionState === "disconnected") {
|
||||||
|
this.ws = null;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMessage = (event: { data: unknown }) => {
|
const handleMessage = (event: { data: unknown }) => {
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
|
// 如果之前是 stale 状态,恢复为 connected
|
||||||
|
if (this.connectionState === "stale") {
|
||||||
|
this.updateConnectionState("connected");
|
||||||
|
}
|
||||||
this.handlePayload(event.data);
|
this.handlePayload(event.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理 Binance 服务器的 ping 帧
|
||||||
|
// 根据文档:必须尽快回复 pong,payload 为 ping 的 payload 副本
|
||||||
const handlePing = (data: unknown) => {
|
const handlePing = (data: unknown) => {
|
||||||
|
this.lastMessageTime = Date.now();
|
||||||
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
|
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
|
||||||
this.ws.pong(data as any);
|
try {
|
||||||
|
this.ws.pong(data as any);
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.("binanceDepth pong", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,7 +230,9 @@ export class BinanceDepthTracker {
|
|||||||
|
|
||||||
private buildUrl(): string {
|
private buildUrl(): string {
|
||||||
const base = this.options?.baseUrl ?? DEFAULT_BASE_URL;
|
const base = this.options?.baseUrl ?? DEFAULT_BASE_URL;
|
||||||
const stream = `${this.symbol.toLowerCase()}@depth10@100ms`;
|
const levels = this.options?.levels ?? 10;
|
||||||
|
const speed = this.options?.speedMs ?? 100;
|
||||||
|
const stream = `${this.symbol.toLowerCase()}@depth${levels}@${speed}ms`;
|
||||||
return `${base}/${stream}`;
|
return `${base}/${stream}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,18 +240,108 @@ export class BinanceDepthTracker {
|
|||||||
if (this.reconnectTimer || this.stopped) return;
|
if (this.reconnectTimer || this.stopped) return;
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 60_000);
|
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, RECONNECT_DELAY_MAX_MS);
|
||||||
this.connect();
|
this.connect();
|
||||||
}, this.reconnectDelayMs);
|
}, this.reconnectDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动心跳监控
|
||||||
|
* 根据 Binance 文档:长时间无 pong 会断连
|
||||||
|
* 我们设置 5 分钟作为心跳超时阈值
|
||||||
|
*/
|
||||||
|
private startHeartbeatMonitor(): void {
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - this.lastMessageTime;
|
||||||
|
|
||||||
|
// 检查数据是否过时(5 秒无数据)
|
||||||
|
if (elapsed > DATA_STALE_THRESHOLD_MS && this.connectionState === "connected") {
|
||||||
|
this.updateConnectionState("stale");
|
||||||
|
this.options?.logger?.("binanceDepth", `Data stale: ${elapsed}ms since last message`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查心跳超时(5 分钟无消息)
|
||||||
|
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
|
||||||
|
this.options?.logger?.("binanceDepth", `Heartbeat timeout: ${elapsed}ms, forcing reconnect`);
|
||||||
|
this.forceReconnect("heartbeat_timeout");
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeatMonitor(): void {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 24 小时自动重连定时器
|
||||||
|
* 根据 Binance 文档:连接最长有效期 24 小时
|
||||||
|
* 我们设置 23 小时主动重连,避免被服务器断开
|
||||||
|
*/
|
||||||
|
private startMaxDurationTimer(): void {
|
||||||
|
this.stopMaxDurationTimer();
|
||||||
|
this.maxDurationTimer = setTimeout(() => {
|
||||||
|
this.options?.logger?.("binanceDepth", "Max connection duration reached (23h), reconnecting");
|
||||||
|
this.forceReconnect("max_duration");
|
||||||
|
}, MAX_CONNECTION_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopMaxDurationTimer(): void {
|
||||||
|
if (this.maxDurationTimer) {
|
||||||
|
clearTimeout(this.maxDurationTimer);
|
||||||
|
this.maxDurationTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制重连
|
||||||
|
*/
|
||||||
|
private forceReconnect(reason: string): void {
|
||||||
|
this.options?.logger?.("binanceDepth", `Force reconnect: ${reason}`);
|
||||||
|
this.stopHeartbeatMonitor();
|
||||||
|
this.stopMaxDurationTimer();
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateConnectionState("disconnected");
|
||||||
|
// 立即重连(不使用指数退避)
|
||||||
|
this.reconnectDelayMs = RECONNECT_DELAY_BASE_MS;
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新连接状态并通知监听器
|
||||||
|
*/
|
||||||
|
private updateConnectionState(state: BinanceConnectionState): void {
|
||||||
|
if (this.connectionState === state) return;
|
||||||
|
this.connectionState = state;
|
||||||
|
for (const listener of this.connectionListeners) {
|
||||||
|
try {
|
||||||
|
listener(state);
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.("binanceDepth connectionListener", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handlePayload(data: unknown): void {
|
private handlePayload(data: unknown): void {
|
||||||
const payload = this.parsePayload(data);
|
const payload = this.parsePayload(data);
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
const bids = Array.isArray(payload.b) ? payload.b : [];
|
const bids = Array.isArray(payload.b) ? payload.b : Array.isArray(payload.bids) ? payload.bids : [];
|
||||||
const asks = Array.isArray(payload.a) ? payload.a : [];
|
const asks = Array.isArray(payload.a) ? payload.a : Array.isArray(payload.asks) ? payload.asks : [];
|
||||||
const depth = {
|
const depth = {
|
||||||
lastUpdateId: Number(payload.u ?? Date.now()),
|
lastUpdateId: Number(payload.lastUpdateId ?? payload.u ?? Date.now()),
|
||||||
bids,
|
bids,
|
||||||
asks,
|
asks,
|
||||||
};
|
};
|
||||||
@@ -158,20 +358,32 @@ export class BinanceDepthTracker {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
listener({ ...this.snapshot });
|
try {
|
||||||
|
listener({ ...this.snapshot });
|
||||||
|
} catch (error) {
|
||||||
|
this.options?.logger?.("binanceDepth listener", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePayload(data: unknown): { b?: [string, string][]; a?: [string, string][]; u?: number } | null {
|
private parsePayload(
|
||||||
|
data: unknown
|
||||||
|
): { b?: [string, string][]; a?: [string, string][]; bids?: [string, string][]; asks?: [string, string][]; u?: number; lastUpdateId?: number } | null {
|
||||||
try {
|
try {
|
||||||
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
|
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
if (!parsed || typeof parsed !== "object") return null;
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
return parsed as { b?: [string, string][]; a?: [string, string][]; u?: number };
|
return parsed as {
|
||||||
|
b?: [string, string][];
|
||||||
|
a?: [string, string][];
|
||||||
|
bids?: [string, string][];
|
||||||
|
asks?: [string, string][];
|
||||||
|
u?: number;
|
||||||
|
lastUpdateId?: number;
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ import { isOrderActiveStatus } from "../utils/order-status";
|
|||||||
import { getPosition } from "../utils/strategy";
|
import { getPosition } from "../utils/strategy";
|
||||||
import type { PositionSnapshot } from "../utils/strategy";
|
import type { PositionSnapshot } from "../utils/strategy";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
import { getTopPrices, getMidOrLast } from "../utils/price";
|
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
|
||||||
import { shouldStopLoss } from "../utils/risk";
|
import { shouldStopLoss } from "../utils/risk";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
@@ -305,10 +305,18 @@ export class MakerEngine {
|
|||||||
|
|
||||||
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
||||||
const priceDecimals = this.getPriceDecimals();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
|
// 平仓价格始终使用买1/卖1
|
||||||
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
||||||
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
||||||
const bidPrice = formatPriceToString(topBid - this.config.bidOffset, priceDecimals);
|
|
||||||
const askPrice = formatPriceToString(topAsk + this.config.askOffset, priceDecimals);
|
// 开仓价格根据 entryDepthLevel 使用指定档位
|
||||||
|
const entryLevel = this.config.entryDepthLevel ?? 1;
|
||||||
|
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(depth, entryLevel);
|
||||||
|
const entryBidBase = entryBid ?? topBid;
|
||||||
|
const entryAskBase = entryAsk ?? topAsk;
|
||||||
|
|
||||||
|
const bidPrice = formatPriceToString(entryBidBase - this.config.bidOffset, priceDecimals);
|
||||||
|
const askPrice = formatPriceToString(entryAskBase + this.config.askOffset, priceDecimals);
|
||||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
const absPosition = Math.abs(position.positionAmt);
|
const absPosition = Math.abs(position.positionAmt);
|
||||||
const desired: DesiredOrder[] = [];
|
const desired: DesiredOrder[] = [];
|
||||||
|
|||||||
+1200
-65
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ import { getPosition, parseSymbolParts } from "../utils/strategy";
|
|||||||
import type { PositionSnapshot } from "../utils/strategy";
|
import type { PositionSnapshot } from "../utils/strategy";
|
||||||
import { computeDepthStats } from "../utils/depth";
|
import { computeDepthStats } from "../utils/depth";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
import { getTopPrices, getMidOrLast } from "../utils/price";
|
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
|
||||||
import { shouldStopLoss } from "../utils/risk";
|
import { shouldStopLoss } from "../utils/risk";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
@@ -356,10 +356,18 @@ export class OffsetMakerEngine {
|
|||||||
|
|
||||||
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
||||||
const priceDecimals = this.getPriceDecimals();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
|
// 平仓价格始终使用买1/卖1
|
||||||
const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
|
const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
|
||||||
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
|
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
|
||||||
const rawBidPrice = finalBid - this.config.bidOffset;
|
|
||||||
const rawAskPrice = finalAsk + this.config.askOffset;
|
// 开仓价格根据 entryDepthLevel 使用指定档位
|
||||||
|
const entryLevel = this.config.entryDepthLevel ?? 1;
|
||||||
|
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(latestDepth, entryLevel);
|
||||||
|
const entryBidBase = entryBid ?? finalBid;
|
||||||
|
const entryAskBase = entryAsk ?? finalAsk;
|
||||||
|
|
||||||
|
const rawBidPrice = entryBidBase - this.config.bidOffset;
|
||||||
|
const rawAskPrice = entryAskBase + this.config.askOffset;
|
||||||
const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk);
|
const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk);
|
||||||
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
|
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
|
||||||
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
|
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
|
||||||
|
|||||||
+8
-1
@@ -5,6 +5,7 @@ import { GuardianApp } from "./GuardianApp";
|
|||||||
import { MakerApp } from "./MakerApp";
|
import { MakerApp } from "./MakerApp";
|
||||||
import { MakerPointsApp } from "./MakerPointsApp";
|
import { MakerPointsApp } from "./MakerPointsApp";
|
||||||
import { OffsetMakerApp } from "./OffsetMakerApp";
|
import { OffsetMakerApp } from "./OffsetMakerApp";
|
||||||
|
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 { isBasisStrategyEnabled } from "../config";
|
||||||
@@ -13,7 +14,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface StrategyOption {
|
interface StrategyOption {
|
||||||
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
|
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
component: React.ComponentType<{ onExit: () => void }>;
|
component: React.ComponentType<{ onExit: () => void }>;
|
||||||
@@ -50,6 +51,12 @@ const BASE_STRATEGIES: StrategyOption[] = [
|
|||||||
description: t("app.strategy.offset.desc"),
|
description: t("app.strategy.offset.desc"),
|
||||||
component: OffsetMakerApp,
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
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 { formatNumber } from "../utils/format";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
interface LiquidityMakerAppProps {
|
||||||
|
onExit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
|
export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) {
|
||||||
|
const [snapshot, setSnapshot] = useState<LiquidityMakerEngineSnapshot | null>(null);
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Text color="red">{t("common.startFailed", { message: error.message })}</Text>
|
||||||
|
<Text color="gray">{t("common.checkEnv")}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return (
|
||||||
|
<Box padding={1}>
|
||||||
|
<Text>{t("liquidityMaker.initializing")}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topBid = snapshot.topBid;
|
||||||
|
const topAsk = snapshot.topAsk;
|
||||||
|
const priceDigits = snapshot.priceDecimals ?? 2;
|
||||||
|
const spreadDigits = Math.max(priceDigits + 1, 4);
|
||||||
|
const spreadDisplay =
|
||||||
|
snapshot.spread != null ? `${formatNumber(snapshot.spread, spreadDigits)} USDT` : "-";
|
||||||
|
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
|
||||||
|
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
|
||||||
|
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
|
||||||
|
);
|
||||||
|
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||||
|
id: order.orderId,
|
||||||
|
side: order.side,
|
||||||
|
price: order.price,
|
||||||
|
qty: order.origQty,
|
||||||
|
filled: order.executedQty,
|
||||||
|
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||||
|
status: order.status,
|
||||||
|
}));
|
||||||
|
const openOrderColumns: TableColumn[] = [
|
||||||
|
{ key: "id", header: "ID", align: "right", minWidth: 6 },
|
||||||
|
{ key: "side", header: "Side", minWidth: 4 },
|
||||||
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
|
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
|
||||||
|
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
|
||||||
|
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||||
|
{ key: "status", header: "Status", minWidth: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const desiredRows = snapshot.desiredOrders.map((order, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
side: order.side,
|
||||||
|
price: order.price,
|
||||||
|
amount: order.amount,
|
||||||
|
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||||
|
}));
|
||||||
|
const desiredColumns: TableColumn[] = [
|
||||||
|
{ key: "index", header: "#", align: "right", minWidth: 2 },
|
||||||
|
{ key: "side", header: "Side", minWidth: 4 },
|
||||||
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
|
{ key: "amount", header: "Qty", align: "right", minWidth: 8 },
|
||||||
|
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||||
|
const imbalanceLabel =
|
||||||
|
snapshot.depthImbalance === "balanced"
|
||||||
|
? t("offset.imbalance.balanced")
|
||||||
|
: snapshot.depthImbalance === "buy_dominant"
|
||||||
|
? t("offset.imbalance.buy")
|
||||||
|
: t("offset.imbalance.sell");
|
||||||
|
const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData");
|
||||||
|
|
||||||
|
// 显示最近成交信息
|
||||||
|
const lastFillInfo = snapshot.lastFill
|
||||||
|
? `${snapshot.lastFill.side} ${formatNumber(snapshot.lastFill.amount, 6)} @ ${formatNumber(snapshot.lastFill.price, priceDigits)}`
|
||||||
|
: t("liquidityMaker.noFill");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="cyanBright">{t("liquidityMaker.title")}</Text>
|
||||||
|
<Text>
|
||||||
|
{t("offset.headerLine", {
|
||||||
|
exchange: exchangeName,
|
||||||
|
symbol: snapshot.symbol,
|
||||||
|
bid: formatNumber(topBid, priceDigits),
|
||||||
|
ask: formatNumber(topAsk, priceDigits),
|
||||||
|
spread: spreadDisplay,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("offset.depthLine", {
|
||||||
|
buy: formatNumber(snapshot.buyDepthSum10, 4),
|
||||||
|
sell: formatNumber(snapshot.sellDepthSum10, 4),
|
||||||
|
status: imbalanceLabel,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">
|
||||||
|
{t("offset.strategyStatus", {
|
||||||
|
buyStatus: snapshot.skipBuySide ? t("common.disabled") : t("common.enabled"),
|
||||||
|
sellStatus: snapshot.skipSellSide ? t("common.disabled") : t("common.enabled"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">{t("liquidityMaker.lastFill", { info: lastFillInfo })}</Text>
|
||||||
|
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Box flexDirection="column" marginRight={4}>
|
||||||
|
<Text color="greenBright">{t("common.section.position")}</Text>
|
||||||
|
{hasPosition ? (
|
||||||
|
<>
|
||||||
|
<Text>
|
||||||
|
{t("maker.positionLine", {
|
||||||
|
direction:
|
||||||
|
snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
||||||
|
qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4),
|
||||||
|
entry: formatNumber(snapshot.position.entryPrice, priceDigits),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("maker.pnlLine", {
|
||||||
|
pnl: formatNumber(snapshot.pnl, 4),
|
||||||
|
accountPnl: formatNumber(snapshot.accountUnrealized, 4),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noPosition")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="greenBright">{t("maker.targetOrders")}</Text>
|
||||||
|
{desiredRows.length > 0 ? (
|
||||||
|
<DataTable columns={desiredColumns} rows={desiredRows} />
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("maker.noTargetOrders")}</Text>
|
||||||
|
)}
|
||||||
|
<Text>
|
||||||
|
{t("trend.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="yellow">{t("common.section.orders")}</Text>
|
||||||
|
{openOrderRows.length > 0 ? (
|
||||||
|
<DataTable columns={openOrderColumns} rows={openOrderRows} />
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noOrders")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="yellow">{t("common.section.recent")}</Text>
|
||||||
|
{lastLogs.length > 0 ? (
|
||||||
|
lastLogs.map((item, index) => (
|
||||||
|
<Text key={`${item.time}-${index}`}>
|
||||||
|
[{item.time}] [{item.type}] {item.detail}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noLogs")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -135,6 +135,7 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
? t("offset.imbalance.sell")
|
? t("offset.imbalance.sell")
|
||||||
: 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));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
@@ -164,6 +165,15 @@ export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
|||||||
status: imbalanceLabel,
|
status: imbalanceLabel,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
{snapshot.bandDepths.map((band) => (
|
||||||
|
<Text key={band.band} color={band.enabled ? undefined : "gray"}>
|
||||||
|
{t("makerPoints.bandDepthLine", {
|
||||||
|
band: band.band,
|
||||||
|
buy: formatDepth(band.buyDepth),
|
||||||
|
sell: formatDepth(band.sellDepth),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
<Text>
|
<Text>
|
||||||
{t("maker.dataStatus")}
|
{t("maker.dataStatus")}
|
||||||
{feedEntries.map((entry, index) => (
|
{feedEntries.map((entry, index) => (
|
||||||
|
|||||||
@@ -61,3 +61,27 @@ export function isInsufficientBalanceError(error: unknown): boolean {
|
|||||||
message.includes("NOT ENOUGH")
|
message.includes("NOT ENOUGH")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPrecisionError(error: unknown): boolean {
|
||||||
|
const message = extractMessage(error).toUpperCase();
|
||||||
|
return (
|
||||||
|
message.includes("PRECISION") ||
|
||||||
|
message.includes("TICK_SIZE") ||
|
||||||
|
message.includes("TICKSIZE") ||
|
||||||
|
message.includes("STEP_SIZE") ||
|
||||||
|
message.includes("STEPSIZE") ||
|
||||||
|
message.includes("LOT_SIZE") ||
|
||||||
|
message.includes("LOTSIZE") ||
|
||||||
|
message.includes("INVALID_QUANTITY") ||
|
||||||
|
message.includes("INVALID QUANTITY") ||
|
||||||
|
message.includes("QUANTITY_INVALID") ||
|
||||||
|
message.includes("INVALID_PRICE") ||
|
||||||
|
message.includes("INVALID PRICE") ||
|
||||||
|
message.includes("PRICE_INVALID") ||
|
||||||
|
message.includes("QTY_STEP") ||
|
||||||
|
message.includes("PRICE_TICK") ||
|
||||||
|
message.includes("DECIMAL") ||
|
||||||
|
message.includes("FILTER_FAILURE") ||
|
||||||
|
message.includes("NOTIONAL")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,4 +13,24 @@ export function computePositionPnl(
|
|||||||
: (position.entryPrice - (priceForPnl as number)) * absAmt;
|
: (position.entryPrice - (priceForPnl as number)) * absAmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function computeStopLossPnl(
|
||||||
|
position: PositionSnapshot,
|
||||||
|
bestBid?: number | null,
|
||||||
|
bestAsk?: number | null
|
||||||
|
): number | null {
|
||||||
|
const absAmt = Math.abs(position.positionAmt);
|
||||||
|
if (!Number.isFinite(absAmt) || absAmt <= 0) return 0;
|
||||||
|
|
||||||
|
// If entry price is missing, prefer the exchange-provided unrealized PnL.
|
||||||
|
if (!Number.isFinite(position.entryPrice) || position.entryPrice <= 0) {
|
||||||
|
return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceForPnl = position.positionAmt > 0 ? bestBid : bestAsk;
|
||||||
|
if (!Number.isFinite(priceForPnl as number) || (priceForPnl as number) <= 0) {
|
||||||
|
return Number.isFinite(position.unrealizedProfit) ? position.unrealizedProfit : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return computePositionPnl(position, bestBid, bestAsk);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,46 @@ export function getTopPrices(depth?: AsterDepth | null): { topBid: number | null
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定档位的盘口价格
|
||||||
|
* @param depth 深度数据
|
||||||
|
* @param level 档位(1=买1/卖1,2=买2/卖2,以此类推)
|
||||||
|
* @returns 指定档位的买卖价格,如果该档位不存在则回退到最近的有效档位
|
||||||
|
*/
|
||||||
|
export function getPricesAtLevel(
|
||||||
|
depth?: AsterDepth | null,
|
||||||
|
level: number = 1
|
||||||
|
): { bidAtLevel: number | null; askAtLevel: number | null } {
|
||||||
|
const index = Math.max(0, level - 1);
|
||||||
|
|
||||||
|
// 尝试获取指定档位,如果不存在则回退到最近的有效档位
|
||||||
|
const bids = depth?.bids ?? [];
|
||||||
|
const asks = depth?.asks ?? [];
|
||||||
|
|
||||||
|
let bidAtLevel: number | null = null;
|
||||||
|
let askAtLevel: number | null = null;
|
||||||
|
|
||||||
|
// 从指定档位向前查找第一个有效的买价
|
||||||
|
for (let i = Math.min(index, bids.length - 1); i >= 0; i--) {
|
||||||
|
const bid = Number(bids[i]?.[0]);
|
||||||
|
if (Number.isFinite(bid)) {
|
||||||
|
bidAtLevel = bid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从指定档位向前查找第一个有效的卖价
|
||||||
|
for (let i = Math.min(index, asks.length - 1); i >= 0; i--) {
|
||||||
|
const ask = Number(asks[i]?.[0]);
|
||||||
|
if (Number.isFinite(ask)) {
|
||||||
|
askAtLevel = ask;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bidAtLevel, askAtLevel };
|
||||||
|
}
|
||||||
|
|
||||||
export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | null): number | null {
|
export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | null): number | null {
|
||||||
const { topBid, topAsk } = getTopPrices(depth);
|
const { topBid, topAsk } = getTopPrices(depth);
|
||||||
if (topBid != null && topAsk != null) return (topBid + topAsk) / 2;
|
if (topBid != null && topAsk != null) return (topBid + topAsk) / 2;
|
||||||
@@ -16,4 +56,58 @@ export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | n
|
|||||||
return Number.isFinite(last) ? last : null;
|
return Number.isFinite(last) ? last : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算从盘口一档到目标价格之间的挂单总量
|
||||||
|
* @param depth 深度数据
|
||||||
|
* @param side 挂单方向: BUY 检查 bids, SELL 检查 asks
|
||||||
|
* @param targetPrice 目标挂单价格
|
||||||
|
* @returns 从一档到目标价格之间的挂单总量 (不包含目标价格本身)
|
||||||
|
*/
|
||||||
|
export function getDepthBetweenPrices(
|
||||||
|
depth: AsterDepth | null | undefined,
|
||||||
|
side: "BUY" | "SELL",
|
||||||
|
targetPrice: number
|
||||||
|
): number {
|
||||||
|
if (!depth) return 0;
|
||||||
|
if (!Number.isFinite(targetPrice) || targetPrice <= 0) return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (side === "BUY") {
|
||||||
|
// BUY 订单挂在 bid 侧,检查从 bid1 到目标价格之间的所有 bids
|
||||||
|
// bids 按价格从高到低排序,目标价格 < bid1
|
||||||
|
const bids = depth.bids ?? [];
|
||||||
|
for (const level of bids) {
|
||||||
|
const price = Number(level[0]);
|
||||||
|
const qty = Number(level[1]);
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(qty)) continue;
|
||||||
|
// 只计算价格 > 目标价格的档位 (目标价格以上的挂单)
|
||||||
|
if (price > targetPrice) {
|
||||||
|
total += qty;
|
||||||
|
} else {
|
||||||
|
// bids 是从高到低排序,一旦 price <= targetPrice 就停止
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// SELL 订单挂在 ask 侧,检查从 ask1 到目标价格之间的所有 asks
|
||||||
|
// asks 按价格从低到高排序,目标价格 > ask1
|
||||||
|
const asks = depth.asks ?? [];
|
||||||
|
for (const level of asks) {
|
||||||
|
const price = Number(level[0]);
|
||||||
|
const qty = Number(level[1]);
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(qty)) continue;
|
||||||
|
// 只计算价格 < 目标价格的档位 (目标价格以下的挂单)
|
||||||
|
if (price < targetPrice) {
|
||||||
|
total += qty;
|
||||||
|
} else {
|
||||||
|
// asks 是从低到高排序,一旦 price >= targetPrice 就停止
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
|
||||||
|
|
||||||
|
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
|
||||||
|
|
||||||
|
export interface TokenExpiryStatus {
|
||||||
|
state: TokenExpiryState;
|
||||||
|
expired: boolean;
|
||||||
|
expiryTimestamp: number | null;
|
||||||
|
remainingMs: number | null;
|
||||||
|
hasPosition: boolean;
|
||||||
|
hasOpenOrders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenExpiryCheckParams {
|
||||||
|
positionAmt: number;
|
||||||
|
openOrderCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkStandxTokenExpiry(params: TokenExpiryCheckParams): TokenExpiryStatus {
|
||||||
|
const info = getStandxTokenExpiryInfo();
|
||||||
|
const hasPosition = Math.abs(params.positionAmt) > 1e-8;
|
||||||
|
const hasOpenOrders = params.openOrderCount > 0;
|
||||||
|
|
||||||
|
if (!info.expired) {
|
||||||
|
return {
|
||||||
|
state: "active",
|
||||||
|
expired: false,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: info.remainingMs,
|
||||||
|
hasPosition,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPosition) {
|
||||||
|
return {
|
||||||
|
state: "expired_with_position",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition: true,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasOpenOrders) {
|
||||||
|
return {
|
||||||
|
state: "silent",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition: false,
|
||||||
|
hasOpenOrders: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "expired",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | null {
|
||||||
|
if (!status.expired) {
|
||||||
|
if (status.remainingMs != null && status.remainingMs < 3600_000) {
|
||||||
|
const mins = Math.ceil(status.remainingMs / 60_000);
|
||||||
|
return `StandX Token 将在 ${mins} 分钟后过期`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status.state) {
|
||||||
|
case "expired":
|
||||||
|
return "StandX Token 已过期,正在取消所有挂单";
|
||||||
|
case "expired_with_position":
|
||||||
|
return "StandX Token 已过期,仅保留平仓/止损逻辑";
|
||||||
|
case "silent":
|
||||||
|
return "StandX Token 已过期,进入静默数据接收模式";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenExpiryConfigured(): boolean {
|
||||||
|
return standxTokenConfig.expiryTimestamp != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isStandxTokenExpired, getStandxTokenExpiryInfo };
|
||||||
@@ -47,6 +47,47 @@ export function getPosition(snapshot: AsterAccountSnapshot | null, symbol: strin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateAccountSnapshotForSymbol(
|
||||||
|
snapshot: AsterAccountSnapshot | null,
|
||||||
|
symbol: string
|
||||||
|
): { ok: true } | { ok: false; issues: string[] } {
|
||||||
|
if (!snapshot) return { ok: true };
|
||||||
|
const positions = snapshot.positions?.filter((p) => p.symbol === symbol) ?? [];
|
||||||
|
if (positions.length === 0) return { ok: true };
|
||||||
|
|
||||||
|
const NON_ZERO_EPS = 1e-8;
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
for (const position of positions) {
|
||||||
|
const amt = Number(position.positionAmt);
|
||||||
|
if (!Number.isFinite(amt)) {
|
||||||
|
issues.push("invalid_positionAmt");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Math.abs(amt) <= NON_ZERO_EPS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPrice = Number(position.entryPrice);
|
||||||
|
if (!Number.isFinite(entryPrice) || entryPrice <= 0) {
|
||||||
|
issues.push("invalid_entryPrice");
|
||||||
|
}
|
||||||
|
|
||||||
|
const unrealizedProfit = Number(position.unrealizedProfit);
|
||||||
|
if (!Number.isFinite(unrealizedProfit)) {
|
||||||
|
issues.push("invalid_unrealizedProfit");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMark = Number(position.markPrice);
|
||||||
|
if (position.markPrice != null && position.markPrice !== "" && (!Number.isFinite(rawMark) || rawMark <= 0)) {
|
||||||
|
issues.push("invalid_markPrice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) return { ok: true };
|
||||||
|
return { ok: false, issues: Array.from(new Set(issues)) };
|
||||||
|
}
|
||||||
|
|
||||||
export function getSMA(values: AsterKline[], length: number): number | null {
|
export function getSMA(values: AsterKline[], length: number): number | null {
|
||||||
if (!Array.isArray(values) || values.length < length) return null;
|
if (!Array.isArray(values) || values.length < length) return null;
|
||||||
const window = values.slice(-length);
|
const window = values.slice(-length);
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AsterAccountSnapshot } from "../src/exchanges/types";
|
||||||
|
import { validateAccountSnapshotForSymbol } from "../src/utils/strategy";
|
||||||
|
|
||||||
|
function baseSnapshot(positions: AsterAccountSnapshot["positions"]): AsterAccountSnapshot {
|
||||||
|
return {
|
||||||
|
canTrade: true,
|
||||||
|
canDeposit: true,
|
||||||
|
canWithdraw: true,
|
||||||
|
updateTime: Date.now(),
|
||||||
|
totalWalletBalance: "0",
|
||||||
|
totalUnrealizedProfit: "0",
|
||||||
|
positions,
|
||||||
|
assets: [],
|
||||||
|
marketType: "perp",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateAccountSnapshotForSymbol", () => {
|
||||||
|
it("accepts empty positions", () => {
|
||||||
|
const result = validateAccountSnapshotForSymbol(baseSnapshot([]), "BTC-USD");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts zero-sized positions even if entry price is zero", () => {
|
||||||
|
const result = validateAccountSnapshotForSymbol(
|
||||||
|
baseSnapshot([
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
positionAmt: "0",
|
||||||
|
entryPrice: "0",
|
||||||
|
unrealizedProfit: "0",
|
||||||
|
positionSide: "BOTH",
|
||||||
|
updateTime: Date.now(),
|
||||||
|
markPrice: "0",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"BTC-USD"
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags invalid numeric fields for non-zero positions", () => {
|
||||||
|
const result = validateAccountSnapshotForSymbol(
|
||||||
|
baseSnapshot([
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
positionAmt: "1",
|
||||||
|
entryPrice: "NaN",
|
||||||
|
unrealizedProfit: "oops",
|
||||||
|
positionSide: "BOTH",
|
||||||
|
updateTime: Date.now(),
|
||||||
|
markPrice: "-1",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"BTC-USD"
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.issues).toEqual(
|
||||||
|
expect.arrayContaining(["invalid_entryPrice", "invalid_unrealizedProfit", "invalid_markPrice"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags invalid positionAmt", () => {
|
||||||
|
const result = validateAccountSnapshotForSymbol(
|
||||||
|
baseSnapshot([
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
positionAmt: "abc",
|
||||||
|
entryPrice: "100",
|
||||||
|
unrealizedProfit: "0",
|
||||||
|
positionSide: "BOTH",
|
||||||
|
updateTime: Date.now(),
|
||||||
|
markPrice: "101",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
"BTC-USD"
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.issues).toContain("invalid_positionAmt");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...ORIGINAL_ENV };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
vi.resetModules();
|
||||||
|
return await import("../src/config");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("config env parsing", () => {
|
||||||
|
it("strips shell-style inline comments from symbol values", async () => {
|
||||||
|
process.env.EXCHANGE = "standx";
|
||||||
|
process.env.STANDX_SYMBOL = "BTC-USD # comment";
|
||||||
|
|
||||||
|
const { resolveSymbolFromEnv } = await loadConfig();
|
||||||
|
expect(resolveSymbolFromEnv()).toBe("BTC-USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses numeric maker-points env values with inline comments", async () => {
|
||||||
|
process.env.EXCHANGE = "standx";
|
||||||
|
process.env.MAKER_POINTS_STOP_LOSS_USD = "1 # comment";
|
||||||
|
process.env.MAKER_POINTS_CLOSE_THRESHOLD = "2 ; comment";
|
||||||
|
|
||||||
|
const { makerPointsConfig } = await loadConfig();
|
||||||
|
expect(makerPointsConfig.stopLossUsd).toBe(1);
|
||||||
|
expect(makerPointsConfig.closeThreshold).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses boolean maker-points env values with inline comments", async () => {
|
||||||
|
process.env.EXCHANGE = "standx";
|
||||||
|
process.env.MAKER_POINTS_BAND_10_30 = "false # comment";
|
||||||
|
|
||||||
|
const { makerPointsConfig } = await loadConfig();
|
||||||
|
expect(makerPointsConfig.enableBand10To30).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||||
|
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
|
||||||
|
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
|
||||||
|
|
||||||
|
class StubAdapter implements ExchangeAdapter {
|
||||||
|
id = "standx";
|
||||||
|
accountSnapshot: AsterAccountSnapshot | null = null;
|
||||||
|
|
||||||
|
supportsTrailingStops(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
|
||||||
|
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
|
||||||
|
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
|
||||||
|
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
|
||||||
|
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
|
||||||
|
|
||||||
|
async createOrder(): Promise<AsterOrder> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelOrder(): Promise<void> {}
|
||||||
|
async cancelOrders(): Promise<void> {}
|
||||||
|
async cancelAllOrders(): Promise<void> {}
|
||||||
|
|
||||||
|
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
|
return this.accountSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MakerPointsEngine defense-mode account staleness", () => {
|
||||||
|
it("does not enter defense mode for ~21s StandX account gap (REST probe succeeds)", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z"));
|
||||||
|
const adapter = new StubAdapter();
|
||||||
|
adapter.accountSnapshot = {
|
||||||
|
canTrade: true,
|
||||||
|
canDeposit: true,
|
||||||
|
canWithdraw: true,
|
||||||
|
updateTime: Date.now(),
|
||||||
|
totalWalletBalance: "0",
|
||||||
|
totalUnrealizedProfit: "0",
|
||||||
|
positions: [],
|
||||||
|
assets: [],
|
||||||
|
marketType: "perp",
|
||||||
|
};
|
||||||
|
const engine = new MakerPointsEngine(
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
perOrderAmount: 0.01,
|
||||||
|
closeThreshold: 0,
|
||||||
|
stopLossUsd: 1,
|
||||||
|
refreshIntervalMs: 500,
|
||||||
|
maxLogEntries: 10,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
(engine as any).lastStandxDepthTime = now;
|
||||||
|
(engine as any).lastBinanceDepthTime = now;
|
||||||
|
(engine as any).lastStandxAccountTime = now - 21_000;
|
||||||
|
|
||||||
|
(engine as any).checkDataStaleAndDefense();
|
||||||
|
expect((engine as any).defenseMode).toBe(false);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters defense mode if StandX account REST probe fails", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-24T15:20:00.000Z"));
|
||||||
|
const adapter = new StubAdapter();
|
||||||
|
adapter.accountSnapshot = null;
|
||||||
|
const engine = new MakerPointsEngine(
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
perOrderAmount: 0.01,
|
||||||
|
closeThreshold: 0,
|
||||||
|
stopLossUsd: 1,
|
||||||
|
refreshIntervalMs: 500,
|
||||||
|
maxLogEntries: 10,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
(engine as any).lastStandxDepthTime = now;
|
||||||
|
(engine as any).lastBinanceDepthTime = now;
|
||||||
|
(engine as any).lastStandxAccountTime = now - 121_000;
|
||||||
|
|
||||||
|
(engine as any).checkDataStaleAndDefense();
|
||||||
|
expect((engine as any).defenseMode).toBe(false);
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
(engine as any).checkDataStaleAndDefense();
|
||||||
|
expect((engine as any).defenseMode).toBe(true);
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||||
|
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
|
||||||
|
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
|
||||||
|
|
||||||
|
class StubAdapter implements ExchangeAdapter {
|
||||||
|
id = "standx";
|
||||||
|
cancelAllCount = 0;
|
||||||
|
openOrders: AsterOrder[] | Error = [];
|
||||||
|
accountSnapshot: AsterAccountSnapshot | null = null;
|
||||||
|
|
||||||
|
supportsTrailingStops(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
|
||||||
|
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
|
||||||
|
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
|
||||||
|
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
|
||||||
|
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
|
||||||
|
|
||||||
|
async createOrder(): Promise<AsterOrder> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelOrder(): Promise<void> {}
|
||||||
|
async cancelOrders(): Promise<void> {}
|
||||||
|
|
||||||
|
async cancelAllOrders(): Promise<void> {
|
||||||
|
this.cancelAllCount += 1;
|
||||||
|
this.openOrders = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryOpenOrders(): Promise<AsterOrder[]> {
|
||||||
|
if (this.openOrders instanceof Error) throw this.openOrders;
|
||||||
|
return this.openOrders;
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
|
return this.accountSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MakerPointsEngine defense-mode REST polling", () => {
|
||||||
|
it("keeps trying to fetch open orders and cancel all when open orders exist", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const adapter = new StubAdapter();
|
||||||
|
adapter.openOrders = [
|
||||||
|
{
|
||||||
|
orderId: "1",
|
||||||
|
clientOrderId: "c1",
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
side: "BUY",
|
||||||
|
type: "LIMIT",
|
||||||
|
status: "NEW",
|
||||||
|
price: "100",
|
||||||
|
origQty: "1",
|
||||||
|
executedQty: "0",
|
||||||
|
time: Date.now(),
|
||||||
|
updateTime: Date.now(),
|
||||||
|
reduceOnly: "false",
|
||||||
|
closePosition: "false",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const engine = new MakerPointsEngine(
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
perOrderAmount: 0.01,
|
||||||
|
closeThreshold: 0,
|
||||||
|
stopLossUsd: 1,
|
||||||
|
refreshIntervalMs: 500,
|
||||||
|
maxLogEntries: 10,
|
||||||
|
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).enterDefenseMode({
|
||||||
|
standxDepthStale: true,
|
||||||
|
binanceStale: false,
|
||||||
|
standxAccountStale: false,
|
||||||
|
accountInvalid: false,
|
||||||
|
standxRestUnhealthy: false,
|
||||||
|
standxRestConsecutiveErrors: 0,
|
||||||
|
standxRestLastError: null,
|
||||||
|
marginModeNotIsolated: false,
|
||||||
|
marginMode: "isolated",
|
||||||
|
standxDepthAge: 6000,
|
||||||
|
binanceAge: 0,
|
||||||
|
standxAccountAge: 0,
|
||||||
|
accountIssues: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attempts cancel-all even if open-order query fails", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const adapter = new StubAdapter();
|
||||||
|
adapter.openOrders = new Error("boom");
|
||||||
|
|
||||||
|
const engine = new MakerPointsEngine(
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
perOrderAmount: 0.01,
|
||||||
|
closeThreshold: 0,
|
||||||
|
stopLossUsd: 1,
|
||||||
|
refreshIntervalMs: 500,
|
||||||
|
maxLogEntries: 10,
|
||||||
|
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).enterDefenseMode({
|
||||||
|
standxDepthStale: true,
|
||||||
|
binanceStale: false,
|
||||||
|
standxAccountStale: false,
|
||||||
|
accountInvalid: false,
|
||||||
|
standxRestUnhealthy: false,
|
||||||
|
standxRestConsecutiveErrors: 0,
|
||||||
|
standxRestLastError: null,
|
||||||
|
marginModeNotIsolated: false,
|
||||||
|
marginMode: "isolated",
|
||||||
|
standxDepthAge: 6000,
|
||||||
|
binanceAge: 0,
|
||||||
|
standxAccountAge: 0,
|
||||||
|
accountIssues: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||||
|
import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types";
|
||||||
|
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
|
||||||
|
|
||||||
|
class StandxStubAdapter implements ExchangeAdapter {
|
||||||
|
id = "standx";
|
||||||
|
marginMode: "cross" | "isolated" = "cross";
|
||||||
|
changeCalls: Array<{ symbol: string; marginMode: "isolated" | "cross" }> = [];
|
||||||
|
|
||||||
|
supportsTrailingStops(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {}
|
||||||
|
watchOrders(_cb: (orders: AsterOrder[]) => void): void {}
|
||||||
|
watchDepth(_symbol: string, _cb: (depth: AsterDepth) => void): void {}
|
||||||
|
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {}
|
||||||
|
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {}
|
||||||
|
|
||||||
|
async createOrder(): Promise<AsterOrder> {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
}
|
||||||
|
async cancelOrder(): Promise<void> {}
|
||||||
|
async cancelOrders(): Promise<void> {}
|
||||||
|
async cancelAllOrders(): Promise<void> {}
|
||||||
|
|
||||||
|
async queryAccountSnapshot(): Promise<AsterAccountSnapshot | null> {
|
||||||
|
return {
|
||||||
|
canTrade: true,
|
||||||
|
canDeposit: true,
|
||||||
|
canWithdraw: true,
|
||||||
|
updateTime: Date.now(),
|
||||||
|
totalWalletBalance: "0",
|
||||||
|
totalUnrealizedProfit: "0",
|
||||||
|
marketType: "perp",
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
positionAmt: "0",
|
||||||
|
entryPrice: "0",
|
||||||
|
unrealizedProfit: "0",
|
||||||
|
positionSide: "BOTH",
|
||||||
|
updateTime: Date.now(),
|
||||||
|
marginType: this.marginMode,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
assets: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeMarginMode(params: { symbol: string; marginMode: "isolated" | "cross" }): Promise<void> {
|
||||||
|
this.changeCalls.push(params);
|
||||||
|
this.marginMode = params.marginMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MakerPointsEngine StandX isolated margin guard", () => {
|
||||||
|
it("switches to isolated before placing orders", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const adapter = new StandxStubAdapter();
|
||||||
|
|
||||||
|
const engine = new MakerPointsEngine(
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
perOrderAmount: 0.01,
|
||||||
|
closeThreshold: 0,
|
||||||
|
stopLossUsd: 1,
|
||||||
|
refreshIntervalMs: 500,
|
||||||
|
maxLogEntries: 10,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed engine state to pass readiness checks without WS.
|
||||||
|
(engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true };
|
||||||
|
(engine as any).initialOrderSnapshotReady = true;
|
||||||
|
(engine as any).accountSnapshot = await adapter.queryAccountSnapshot();
|
||||||
|
(engine as any).depthSnapshot = {
|
||||||
|
lastUpdateId: 1,
|
||||||
|
bids: [["100", "1"]],
|
||||||
|
asks: [["101", "1"]],
|
||||||
|
eventTime: Date.now(),
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
} as AsterDepth;
|
||||||
|
(engine as any).tickerSnapshot = {
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
lastPrice: "100",
|
||||||
|
openPrice: "0",
|
||||||
|
highPrice: "0",
|
||||||
|
lowPrice: "0",
|
||||||
|
volume: "0",
|
||||||
|
quoteVolume: "0",
|
||||||
|
eventTime: Date.now(),
|
||||||
|
} as AsterTicker;
|
||||||
|
|
||||||
|
const syncSpy = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(engine as any).syncOrders = syncSpy;
|
||||||
|
|
||||||
|
// First tick should force margin mode to isolated and then proceed to sync orders.
|
||||||
|
await (engine as any).tick();
|
||||||
|
|
||||||
|
expect(adapter.changeCalls).toEqual([{ symbol: "BTC-USD", marginMode: "isolated" }]);
|
||||||
|
expect(syncSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enters defense mode if it cannot switch to isolated", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const adapter = new StandxStubAdapter();
|
||||||
|
adapter.changeMarginMode = vi.fn(async () => {
|
||||||
|
throw new Error("change failed");
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const engine = new MakerPointsEngine(
|
||||||
|
{
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
perOrderAmount: 0.01,
|
||||||
|
closeThreshold: 0,
|
||||||
|
stopLossUsd: 1,
|
||||||
|
refreshIntervalMs: 500,
|
||||||
|
maxLogEntries: 10,
|
||||||
|
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).accountSnapshot = await adapter.queryAccountSnapshot();
|
||||||
|
(engine as any).depthSnapshot = {
|
||||||
|
lastUpdateId: 1,
|
||||||
|
bids: [["100", "1"]],
|
||||||
|
asks: [["101", "1"]],
|
||||||
|
eventTime: Date.now(),
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
} as AsterDepth;
|
||||||
|
(engine as any).tickerSnapshot = {
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
lastPrice: "100",
|
||||||
|
openPrice: "0",
|
||||||
|
highPrice: "0",
|
||||||
|
lowPrice: "0",
|
||||||
|
volume: "0",
|
||||||
|
quoteVolume: "0",
|
||||||
|
eventTime: Date.now(),
|
||||||
|
} as AsterTicker;
|
||||||
|
|
||||||
|
const syncSpy = vi.fn().mockResolvedValue(undefined);
|
||||||
|
(engine as any).syncOrders = syncSpy;
|
||||||
|
|
||||||
|
await (engine as any).tick();
|
||||||
|
|
||||||
|
expect(syncSpy).not.toHaveBeenCalled();
|
||||||
|
expect((engine as any).defenseMode).toBe(true);
|
||||||
|
|
||||||
|
engine.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { computeStopLossPnl } from "../src/utils/pnl";
|
||||||
|
|
||||||
|
describe("computeStopLossPnl", () => {
|
||||||
|
it("falls back to exchange-provided unrealized PnL when entryPrice is missing", () => {
|
||||||
|
const pnl = computeStopLossPnl(
|
||||||
|
{ positionAmt: 1, entryPrice: 0, unrealizedProfit: -2000, markPrice: null },
|
||||||
|
40000,
|
||||||
|
40010
|
||||||
|
);
|
||||||
|
expect(pnl).toBe(-2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses best bid/ask when entryPrice is available", () => {
|
||||||
|
const longPnl = computeStopLossPnl(
|
||||||
|
{ positionAmt: 1, entryPrice: 100, unrealizedProfit: -5, markPrice: null },
|
||||||
|
90,
|
||||||
|
91
|
||||||
|
);
|
||||||
|
expect(longPnl).toBe(-10);
|
||||||
|
|
||||||
|
const shortPnl = computeStopLossPnl(
|
||||||
|
{ positionAmt: -2, entryPrice: 100, unrealizedProfit: -5, markPrice: null },
|
||||||
|
95,
|
||||||
|
105
|
||||||
|
);
|
||||||
|
expect(shortPnl).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to exchange-provided unrealized PnL when best prices are unavailable", () => {
|
||||||
|
const pnl = computeStopLossPnl(
|
||||||
|
{ positionAmt: 1, entryPrice: 100, unrealizedProfit: -123, markPrice: null },
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
expect(pnl).toBe(-123);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { StandxGateway } from "../src/exchanges/standx/gateway";
|
||||||
|
|
||||||
|
const ORIGINAL_FETCH = globalThis.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = ORIGINAL_FETCH;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StandxGateway REST health", () => {
|
||||||
|
it("emits unhealthy after 3 consecutive REST failures, then healthy after a success", async () => {
|
||||||
|
const gateway = new StandxGateway({
|
||||||
|
token: "test-token",
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
wsUrl: "wss://example.com/ws",
|
||||||
|
logger: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: Array<{ state: string; consecutiveErrors: number }> = [];
|
||||||
|
gateway.onRestHealthEvent((state, info) => {
|
||||||
|
events.push({ state, consecutiveErrors: info.consecutiveErrors });
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn(async () => {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: async () => "server error",
|
||||||
|
} as any;
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
|
||||||
|
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
|
||||||
|
await expect(gateway.queryOpenOrders("BTC-USD")).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(events).toEqual([{ state: "unhealthy", consecutiveErrors: 3 }]);
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn(async () => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => "[]",
|
||||||
|
} as any;
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
await expect(gateway.queryOpenOrders("BTC-USD")).resolves.toEqual([]);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ state: "unhealthy", consecutiveErrors: 3 },
|
||||||
|
{ state: "healthy", consecutiveErrors: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user