mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
Compare commits
8
Commits
feat/mm
...
445e634aa1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
445e634aa1 | ||
|
|
4bb1fee995 | ||
|
|
aad14395e0 | ||
|
|
598f2a0eb6 | ||
|
|
cb1cef6f1b | ||
|
|
fd034d493f | ||
|
|
2551670874 | ||
|
|
3ec7e9b8d6 |
@@ -16,6 +16,10 @@ STANDX_SYMBOL=BTC-USD
|
||||
# STANDX_SESSION_ID=
|
||||
# Optional: request signing key (ed25519 private key, hex or base64)
|
||||
# STANDX_REQUEST_PRIVATE_KEY=
|
||||
# Token expiry timestamp (Unix seconds). When expired: cancels all orders, stops new orders.
|
||||
# If position exists, continues close/stop-loss logic. If no position/orders, enters silent mode.
|
||||
# Example: STANDX_TOKEN_EXPIRY=1737092800
|
||||
# STANDX_TOKEN_EXPIRY=
|
||||
|
||||
# Core trading symbol and sizing
|
||||
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
||||
@@ -154,3 +158,8 @@ NADO_MIN_SIZE_POLICY=adjust
|
||||
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
|
||||
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
|
||||
# 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")
|
||||
|
||||
@@ -120,7 +120,7 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
|
||||
1. 打开 https://standx.ritmex.one/
|
||||
2. 连接钱包
|
||||
3. 点击“登录”
|
||||
4. 导出登录信息里面会包含 token和代理钱包私钥,代理钱包仅用于交易签名,有效保证资产钱包安全
|
||||
4. 导出登录信息里面会包含 token(STANDX_TOKEN) 和代理钱包私钥(STANDX_REQUEST_PRIVATE_KEY),代理钱包仅用于交易签名,有效保证资产钱包安全
|
||||
|
||||
请妥善保存,不要分享给他人
|
||||
|
||||
|
||||
+29
-11
@@ -1,7 +1,11 @@
|
||||
# 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.
|
||||
|
||||
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)
|
||||
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
||||
* [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)
|
||||
* [edgex referral link](https://pro.edgex.exchange/referral/BULL)
|
||||
* [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
|
||||
- [中文 README](README.md)
|
||||
- [Beginner-friendly Quick Start](simple-readme.md)
|
||||
- [Grid Trading Strategy Guide](grid-trading.md)
|
||||
|
||||
## Highlights
|
||||
- **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
|
||||
@@ -35,7 +39,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
||||
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
|
||||
|
||||
## 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)
|
||||
- Node.js is optional unless your tooling requires it
|
||||
|
||||
@@ -96,6 +100,7 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
||||
> ```
|
||||
|
||||
## Exchange Setup Guides
|
||||
|
||||
### Aster
|
||||
1. Keep `EXCHANGE=aster` (default value).
|
||||
2. Supply `ASTER_API_KEY` and `ASTER_API_SECRET`.
|
||||
@@ -103,11 +108,24 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
||||
4. The bootstrap script auto-populates these variables; manual installs must maintain them.
|
||||
|
||||
### StandX
|
||||
|
||||
* [StandX Maker Points Strategy Guide](docs/standx/maker-points-guide.md)
|
||||
|
||||
The strategy requires a StandX login token to place orders.
|
||||
|
||||
How to obtain the token:
|
||||
1. Open https://standx.ritmex.one/
|
||||
2. Connect your wallet
|
||||
3. Click "Login"
|
||||
4. Export your login credentials, which will include the token (`STANDX_TOKEN`) and proxy wallet private key (`STANDX_REQUEST_PRIVATE_KEY`). The proxy wallet is only used for trade signatures, keeping your main wallet secure.
|
||||
|
||||
Please keep these credentials safe and do not share them with anyone.
|
||||
|
||||
1. Set `EXCHANGE=standx`.
|
||||
2. Provide `STANDX_TOKEN` (JWT token for perps API).
|
||||
3. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`.
|
||||
4. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints.
|
||||
5. Optional: `STANDX_REQUEST_PRIVATE_KEY` if the API requires body signatures.
|
||||
3. Provide `STANDX_REQUEST_PRIVATE_KEY` (proxy wallet private key).
|
||||
4. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`.
|
||||
5. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints.
|
||||
|
||||
### GRVT
|
||||
1. Set `EXCHANGE=grvt` inside `.env`.
|
||||
@@ -117,25 +135,25 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
||||
|
||||
### 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.
|
||||
4. `LIGHTER_SYMBOL` defaults to `BTCUSDT`; override price/size decimals when markets differ.
|
||||
|
||||
### 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`).
|
||||
4. Enable `BACKPACK_DEBUG=true` for verbose adapter logging.
|
||||
|
||||
### 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.
|
||||
4. Advanced tuning: use `PARADEX_USE_PRO`, `PARADEX_RECONNECT_DELAY_MS`, or debug flags as needed.
|
||||
|
||||
### 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`).
|
||||
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`).
|
||||
@@ -187,7 +205,7 @@ bun x vitest --watch
|
||||
```
|
||||
|
||||
## 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.
|
||||
- Ensure your server or workstation clock is in sync to avoid signature errors.
|
||||
- Accounts must run in one-way position mode.
|
||||
|
||||
@@ -1,112 +1,355 @@
|
||||
# StandX 做市积分策略使用教程(新手版)
|
||||
# StandX 做市积分策略使用教程(超详细新手版)
|
||||
|
||||
本教程用于帮助你快速上手 StandX 做市积分策略(Maker Points)。无需交易经验,照着步骤做即可。
|
||||
本教程会 **手把手** 教你如何运行 StandX 做市积分策略。每一步都有详细说明,按顺序操作即可。
|
||||
|
||||
---
|
||||
|
||||
## 1. 准备工作
|
||||
## 第一步:安装 Bun(运行环境)
|
||||
|
||||
### 1.1 安装 Bun
|
||||
本项目使用 Bun 运行。
|
||||
本项目需要 Bun 才能运行。
|
||||
|
||||
如果你还没有安装 Bun,请先参考官方文档完成安装:
|
||||
- https://bun.sh/
|
||||
|
||||
安装完成后,进入项目目录执行:
|
||||
### macOS / Linux 用户:
|
||||
打开终端,复制粘贴以下命令后按回车:
|
||||
```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
|
||||
```
|
||||
|
||||
### 1.2 获取 StandX 登录凭证(Token)
|
||||
策略需要 StandX 的登录 token 才能下单。
|
||||
---
|
||||
|
||||
获取方式:
|
||||
1. 打开 https://standx.ritmex.one/
|
||||
2. 连接钱包
|
||||
3. 点击“登录”
|
||||
4. 导出登录信息(里面会包含 token)
|
||||
## 第三步:获取 StandX 登录凭证(最重要的一步)
|
||||
|
||||
请妥善保存,不要分享给他人。
|
||||
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||
>
|
||||
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||
>
|
||||
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||
|
||||
策略需要两样东西才能帮你下单:
|
||||
1. **TOKEN**(登录令牌)
|
||||
2. **代理钱包私钥**(用于签名交易)
|
||||
|
||||
### 获取步骤(图文说明):
|
||||
|
||||
#### 3.1 打开专用登录页面
|
||||
|
||||
在浏览器打开这个网址:
|
||||
```
|
||||
https://standx.ritmex.one/
|
||||
```
|
||||
|
||||
> **注意:不是 standx.com!是 standx.ritmex.one!**
|
||||
>
|
||||
> **注意:不是 standx.com!是 standx.ritmex.one!**
|
||||
>
|
||||
> **注意:不是 standx.com!是 standx.ritmex.one!**
|
||||
|
||||
#### 3.2 连接你的钱包
|
||||
|
||||
点击页面上的 **"连接钱包"** 按钮,使用 MetaMask 或其他钱包连接。
|
||||
|
||||
#### 3.3 点击登录
|
||||
|
||||
连接钱包后,点击 **"登录"** 按钮。钱包会弹出签名请求,确认签名。
|
||||
|
||||
#### 3.4 导出登录信息(关键!)
|
||||
|
||||
登录成功后,页面上会出现 **"导出登录信息"** 按钮,**点击它**!
|
||||
|
||||
你会看到类似这样的内容:
|
||||
|
||||
```
|
||||
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx...(很长一串)
|
||||
|
||||
代理钱包私钥: 0x1234567890abcdef...(64位十六进制字符)
|
||||
```
|
||||
|
||||
> 🔴 **请把这两个值复制保存下来!**
|
||||
>
|
||||
> 🔴 **请把这两个值复制保存下来!**
|
||||
>
|
||||
> 🔴 **请把这两个值复制保存下来!**
|
||||
|
||||
### 什么是代理钱包?
|
||||
|
||||
- 代理钱包是系统 **自动为你生成** 的一个临时钱包
|
||||
- 它 **只用于签名交易**,不存放你的资金
|
||||
- 你的资产仍然在你自己的钱包里,非常安全
|
||||
- **你不需要手动创建**,登录时系统会自动生成
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置环境变量
|
||||
## 第四步:配置环境变量
|
||||
|
||||
在项目根目录新建 `.env` 文件(或修改已有 `.env`),填入以下配置:
|
||||
在项目根目录创建一个 `.env` 文件(如果已存在就修改它)。
|
||||
|
||||
### 4.1 创建/编辑 .env 文件
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
用记事本打开项目文件夹,新建一个文本文件,命名为 `.env`(注意前面有个点)
|
||||
|
||||
### 4.2 填入以下内容
|
||||
|
||||
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||
>
|
||||
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||
>
|
||||
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||
|
||||
```bash
|
||||
# ===== 交易所设置 =====
|
||||
EXCHANGE=standx
|
||||
STANDX_REQUEST_PRIVATE_KEY=你的代理钱包私钥
|
||||
STANDX_TOKEN=你的token
|
||||
|
||||
# ===== 你的登录凭证(第三步获取的) =====
|
||||
# 把下面的 "你的TOKEN" 替换成你导出的 Token(很长一串以 eyJ 开头的)
|
||||
STANDX_TOKEN=你的TOKEN
|
||||
|
||||
# 把下面的 "你的私钥" 替换成你导出的代理钱包私钥(0x 开头的)
|
||||
STANDX_REQUEST_PRIVATE_KEY=你的私钥
|
||||
|
||||
# ===== 交易品种 =====
|
||||
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
|
||||
|
||||
# ===== Token 过期时间配置(可选) =====
|
||||
# 配置 Token 过期时间后,策略会在 Token 过期前提醒你,过期后自动进入安全模式
|
||||
# 格式1:时间戳(毫秒),例如:1735689600000
|
||||
# 格式2:时间戳(秒),例如:1735689600(会自动转换为毫秒)
|
||||
# 格式3:ISO 日期字符串,例如:2025-01-01T00:00:00Z
|
||||
# 如果不配置此项,策略不会检查 Token 过期时间
|
||||
# STANDX_TOKEN_EXPIRY=1735689600000
|
||||
|
||||
# ===== 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`)
|
||||
- `MAKER_POINTS_ORDER_AMOUNT`: 每一笔挂单数量
|
||||
- `MAKER_POINTS_CLOSE_THRESHOLD`: 持仓达到该数值进入平仓模式(0 表示不自动平仓)
|
||||
- `MAKER_POINTS_STOP_LOSS_USD`: 亏损超过该值时市价平仓(0 表示关闭)
|
||||
- `MAKER_POINTS_MIN_REPRICE_BPS`: 盘口变动达到多少 bps 才会重算并撤单(默认 3)
|
||||
- `MAKER_POINTS_BAND_0_10` / `10_30` / `30_100`: 三个档位开关
|
||||
### 正确填写示例
|
||||
|
||||
假设你导出的信息是:
|
||||
- Token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ`
|
||||
- 代理钱包私钥: `0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890`
|
||||
|
||||
那么你的 `.env` 应该这样写:
|
||||
|
||||
```bash
|
||||
EXCHANGE=standx
|
||||
STANDX_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
|
||||
STANDX_REQUEST_PRIVATE_KEY=0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
|
||||
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_EXPIRY=1735689600000
|
||||
# TELEGRAM_BOT_TOKEN=你的BotToken
|
||||
# TELEGRAM_CHAT_ID=你的ChatID
|
||||
```
|
||||
|
||||
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||
>
|
||||
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||
>
|
||||
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||
|
||||
---
|
||||
|
||||
## 3. 启动策略
|
||||
## 第五步:启动策略
|
||||
|
||||
### 3.1 普通启动
|
||||
### 普通启动(看实时仪表盘)
|
||||
```bash
|
||||
bun run index.ts --strategy maker-points --exchange standx
|
||||
```
|
||||
|
||||
### 3.2 使用 PM2 后台运行(推荐)
|
||||
### 后台运行(推荐长期挂机)
|
||||
```bash
|
||||
bun run pm2:start:maker-points
|
||||
```
|
||||
|
||||
PM2 会自动重启策略,适合长期运行。
|
||||
---
|
||||
|
||||
## 配置参数说明
|
||||
|
||||
| 参数 | 含义 | 新手建议 |
|
||||
|------|------|----------|
|
||||
| `STANDX_TOKEN` | 登录令牌 | 必填,从第三步获取 |
|
||||
| `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_EXPIRY` | Token 过期时间 | 可选,格式见下方说明 |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
|
||||
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
|
||||
| `TELEGRAM_ACCOUNT_LABEL` | Telegram 通知账户标签 | 可选,用于区分多个账户 |
|
||||
|
||||
### Token 过期时间配置详解
|
||||
|
||||
`STANDX_TOKEN_EXPIRY` 用于设置 Token 的过期时间。配置后,策略会:
|
||||
|
||||
1. **Token 过期前 1 小时**:在日志中提醒你 Token 即将过期
|
||||
2. **Token 过期后**:
|
||||
- 如果有持仓:进入**平仓模式**,只允许平仓和止损,不再开新仓
|
||||
- 如果无持仓但有挂单:**自动取消所有挂单**
|
||||
- 如果无持仓无挂单:进入**静默模式**,只接收数据,不下单
|
||||
|
||||
**支持的格式:**
|
||||
- **时间戳(毫秒)**:`1735689600000`
|
||||
- **时间戳(秒)**:`1735689600`(会自动转换为毫秒)
|
||||
- **ISO 日期字符串**:`2025-01-01T00:00:00Z` 或 `2025-01-01 00:00:00`
|
||||
|
||||
**如何获取 Token 过期时间?**
|
||||
|
||||
登录 standx.ritmex.one 时,系统会返回 Token 的有效期。你可以在导出登录信息时查看,或者根据登录时设置的过期时间计算。
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
# 方式1:使用时间戳(毫秒)
|
||||
STANDX_TOKEN_EXPIRY=1735689600000
|
||||
|
||||
# 方式2:使用时间戳(秒)
|
||||
STANDX_TOKEN_EXPIRY=1735689600
|
||||
|
||||
# 方式3:使用 ISO 日期字符串
|
||||
STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
|
||||
```
|
||||
|
||||
> 💡 **提示**:如果不配置此项,策略不会检查 Token 过期时间,但建议配置以便及时收到提醒。
|
||||
|
||||
### 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. 运行后你会看到什么
|
||||
## 常见问题
|
||||
|
||||
启动后会显示仪表盘,包含:
|
||||
- 当前交易对与盘口
|
||||
- 当前仓位与浮动盈亏
|
||||
- 目标挂单列表
|
||||
- 已挂单列表
|
||||
- Binance 深度失衡状态
|
||||
### Q:报错说 Token 无效怎么办?
|
||||
|
||||
重新去 https://standx.ritmex.one/ 登录,重新导出 Token。Token 可能过期了。
|
||||
|
||||
### Q:代理钱包私钥从哪来的?
|
||||
|
||||
登录 standx.ritmex.one 后点击"导出登录信息"就能看到。
|
||||
**你不需要自己创建钱包,系统会自动生成!**
|
||||
**你不需要自己创建钱包,系统会自动生成!**
|
||||
**你不需要自己创建钱包,系统会自动生成!**
|
||||
|
||||
### Q:.env 文件放在哪?
|
||||
|
||||
放在项目根目录,就是 `ritmex-bot` 文件夹里,和 `package.json` 同一个目录。
|
||||
|
||||
### Q:为什么策略没有下单?
|
||||
|
||||
1. 检查账户里有没有足够的保证金
|
||||
2. 检查 TOKEN 和私钥是否正确填写
|
||||
3. 检查 .env 文件是否保存成功
|
||||
|
||||
### Q:担心平掉我手动开的仓位?
|
||||
|
||||
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
|
||||
|
||||
### Q:如何知道 Token 什么时候过期?
|
||||
|
||||
配置 `STANDX_TOKEN_EXPIRY` 环境变量,策略会在 Token 过期前 1 小时提醒你。Token 过期后,如果有持仓会进入平仓模式,只允许平仓和止损。
|
||||
|
||||
### Q:Telegram 通知收不到怎么办?
|
||||
|
||||
1. 检查 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID` 是否正确填写
|
||||
2. 确保没有在 Bot Token 和 Chat ID 前后加引号或空格
|
||||
3. 在 Telegram 中先给机器人发送一条消息(任意内容),然后再启动策略
|
||||
4. 检查网络连接是否正常
|
||||
|
||||
---
|
||||
|
||||
## 5. 新手常见问题
|
||||
## 安全提示
|
||||
|
||||
### Q1:为什么会撤单重挂?
|
||||
策略只有在盘口变化超过 `MAKER_POINTS_MIN_REPRICE_BPS` 或 Binance 深度状态变化时才会重算挂单。
|
||||
1. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||
2. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||
3. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||
|
||||
### Q2:担心平掉手动持仓怎么办?
|
||||
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 0 或者设成大于你的持仓即可。
|
||||
|
||||
### Q3:我只想挂某个档位?
|
||||
把不需要的档位开关设为 `false` 即可。
|
||||
代理钱包只用于签名,你的资产始终在你自己的主钱包里。但如果泄露了 TOKEN,别人可以用你的账户交易。
|
||||
|
||||
---
|
||||
|
||||
## 6. 注意事项
|
||||
## 还是不会?
|
||||
|
||||
1. 请确保钱包里有足够的保证金/资金。
|
||||
2. 不要泄露 `STANDX_TOKEN`。
|
||||
3. 初次使用请先用小仓位测试。
|
||||
把你的报错信息截图发到 Telegram 群里,会有人帮你:
|
||||
|
||||
---
|
||||
|
||||
如果你需要我帮你定制参数或排查问题,直接把日志贴给我即可。
|
||||
Telegram 群:https://t.me/+4fdo0quY87o4Mjhh
|
||||
|
||||
@@ -6,6 +6,45 @@
|
||||
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
||||
import { language, type Language } from "./i18n";
|
||||
|
||||
export interface StandxTokenConfig {
|
||||
expiryTimestamp: number | null;
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | undefined): number | null {
|
||||
if (!value || !value.trim()) return null;
|
||||
const trimmed = value.trim();
|
||||
const asNumber = Number(trimmed);
|
||||
if (Number.isFinite(asNumber) && asNumber > 0) {
|
||||
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||
}
|
||||
const asDate = Date.parse(trimmed);
|
||||
if (Number.isFinite(asDate) && asDate > 0) {
|
||||
return asDate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const standxTokenConfig: StandxTokenConfig = {
|
||||
expiryTimestamp: parseTimestamp(process.env.STANDX_TOKEN_EXPIRY),
|
||||
};
|
||||
|
||||
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 {
|
||||
symbol: string;
|
||||
tradeAmount: number;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
NotificationLevel,
|
||||
TradeNotification,
|
||||
NotificationSender,
|
||||
NotificationConfig,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
TelegramNotifier,
|
||||
createTelegramNotifier,
|
||||
type TelegramConfig,
|
||||
} from "./telegram";
|
||||
@@ -0,0 +1,117 @@
|
||||
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) {
|
||||
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>;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface NotificationSender {
|
||||
send(notification: TradeNotification): Promise<void>;
|
||||
isEnabled(): boolean;
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
enabled: boolean;
|
||||
accountLabel?: string;
|
||||
}
|
||||
@@ -29,6 +29,17 @@ import { SessionVolumeTracker } from "./common/session-volume";
|
||||
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
|
||||
import { buildBpsTargets } from "./maker-points-logic";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
checkStandxTokenExpiry,
|
||||
formatTokenExpiryMessage,
|
||||
isTokenExpiryConfigured,
|
||||
type TokenExpiryState,
|
||||
} from "../utils/standx-token-expiry";
|
||||
import {
|
||||
createTelegramNotifier,
|
||||
type NotificationSender,
|
||||
type TradeNotification,
|
||||
} from "../notifications";
|
||||
|
||||
interface DesiredOrder {
|
||||
side: "BUY" | "SELL";
|
||||
@@ -90,6 +101,7 @@ export class MakerPointsEngine {
|
||||
private readonly sessionVolume = new SessionVolumeTracker();
|
||||
private readonly rateLimit: RateLimitController;
|
||||
private readonly binanceDepth: BinanceDepthTracker;
|
||||
private readonly notifier: NotificationSender;
|
||||
|
||||
private priceTick: number = 0.1;
|
||||
private qtyStep: number = 0.001;
|
||||
@@ -128,11 +140,21 @@ export class MakerPointsEngine {
|
||||
private insufficientBalanceNotified = false;
|
||||
private lastInsufficientMessage: string | null = null;
|
||||
|
||||
private tokenExpiryState: TokenExpiryState = "active";
|
||||
private tokenExpiryLogged = false;
|
||||
private tokenExpiryCancelDone = false;
|
||||
private tokenExpiredCloseOnlyMode = false;
|
||||
private tokenExpiryNotified = false;
|
||||
|
||||
private lastPositionAmt = 0;
|
||||
private lastPositionSide: "LONG" | "SHORT" | "FLAT" = "FLAT";
|
||||
|
||||
constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||
this.tradeLog.push(type, detail)
|
||||
);
|
||||
this.notifier = createTelegramNotifier();
|
||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
||||
this.qtyStep = Math.max(1e-9, this.config.qtyStep);
|
||||
this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), {
|
||||
@@ -201,6 +223,7 @@ export class MakerPointsEngine {
|
||||
}
|
||||
const position = getPosition(snapshot, this.config.symbol);
|
||||
this.sessionVolume.update(position, this.getReferencePrice());
|
||||
this.detectPositionChange(position);
|
||||
this.feedStatus.account = true;
|
||||
this.emitUpdate();
|
||||
},
|
||||
@@ -314,6 +337,14 @@ export class MakerPointsEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const absPosition = Math.abs(position.positionAmt);
|
||||
|
||||
if (await this.handleTokenExpiry(position, absPosition)) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const depth = this.depthSnapshot!;
|
||||
const { topBid, topAsk } = getTopPrices(depth);
|
||||
if (topBid == null || topAsk == null) {
|
||||
@@ -321,13 +352,12 @@ export class MakerPointsEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const absPosition = Math.abs(position.positionAmt);
|
||||
const closeThreshold = Number(this.config.closeThreshold);
|
||||
const closeOnly =
|
||||
Number.isFinite(closeThreshold) &&
|
||||
this.tokenExpiredCloseOnlyMode ||
|
||||
(Number.isFinite(closeThreshold) &&
|
||||
closeThreshold > 0 &&
|
||||
absPosition >= closeThreshold - EPS;
|
||||
absPosition >= closeThreshold - EPS);
|
||||
const prevCloseOnly = this.lastCloseOnly;
|
||||
if (closeOnly !== prevCloseOnly) {
|
||||
this.tradeLog.push("info", closeOnly ? "进入平仓模式,仅挂 reduce-only" : "退出平仓模式");
|
||||
@@ -615,6 +645,19 @@ export class MakerPointsEngine {
|
||||
"stop",
|
||||
`触发止损: 未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT`
|
||||
);
|
||||
this.notify({
|
||||
type: "stop_loss",
|
||||
level: "error",
|
||||
symbol: this.config.symbol,
|
||||
title: "止损触发",
|
||||
message: `未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT,强制平仓`,
|
||||
details: {
|
||||
side: position.positionAmt > 0 ? "LONG" : "SHORT",
|
||||
size: absPosition,
|
||||
unrealizedPnl: position.unrealizedProfit,
|
||||
lossLimit: -lossLimit,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await this.flushOrders();
|
||||
await marketClose(
|
||||
@@ -759,6 +802,10 @@ export class MakerPointsEngine {
|
||||
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
|
||||
}
|
||||
|
||||
private notify(notification: TradeNotification): void {
|
||||
this.notifier.send(notification);
|
||||
}
|
||||
|
||||
private logReadinessBlockers(): void {
|
||||
if (!this.feedStatus.account && !this.readinessLogged.account) {
|
||||
this.tradeLog.push("info", t("log.maker.waitAccount"));
|
||||
@@ -828,6 +875,180 @@ export class MakerPointsEngine {
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
private detectPositionChange(position: PositionSnapshot): void {
|
||||
const currentAmt = position.positionAmt;
|
||||
const currentSide: "LONG" | "SHORT" | "FLAT" =
|
||||
currentAmt > EPS ? "LONG" : currentAmt < -EPS ? "SHORT" : "FLAT";
|
||||
const prevAmt = this.lastPositionAmt;
|
||||
const prevSide = this.lastPositionSide;
|
||||
|
||||
if (Math.abs(currentAmt - prevAmt) < EPS && currentSide === prevSide) {
|
||||
return;
|
||||
}
|
||||
|
||||
const absChange = Math.abs(currentAmt - prevAmt);
|
||||
const reference = this.getReferencePrice() ?? 0;
|
||||
|
||||
if (prevSide === "FLAT" && currentSide !== "FLAT") {
|
||||
this.notify({
|
||||
type: "position_opened",
|
||||
level: "info",
|
||||
symbol: this.config.symbol,
|
||||
title: "开仓",
|
||||
message: `${currentSide === "LONG" ? "做多" : "做空"} ${Math.abs(currentAmt).toFixed(6)}`,
|
||||
details: {
|
||||
side: currentSide,
|
||||
size: Math.abs(currentAmt),
|
||||
price: reference > 0 ? reference : null,
|
||||
},
|
||||
});
|
||||
} else if (currentSide === "FLAT" && prevSide !== "FLAT") {
|
||||
const pnl = position.unrealizedProfit;
|
||||
const closeType = this.tokenExpiredCloseOnlyMode ? "Token过期平仓" : "平仓";
|
||||
this.notify({
|
||||
type: "position_closed",
|
||||
level: "success",
|
||||
symbol: this.config.symbol,
|
||||
title: closeType,
|
||||
message: `已平仓 ${Math.abs(prevAmt).toFixed(6)} (${prevSide === "LONG" ? "多" : "空"})`,
|
||||
details: {
|
||||
prevSide,
|
||||
closedSize: Math.abs(prevAmt),
|
||||
pnl: Number.isFinite(pnl) ? pnl : null,
|
||||
},
|
||||
});
|
||||
} else if (currentSide === prevSide && absChange > EPS) {
|
||||
const isIncrease = Math.abs(currentAmt) > Math.abs(prevAmt);
|
||||
if (isIncrease) {
|
||||
this.notify({
|
||||
type: "order_filled",
|
||||
level: "info",
|
||||
symbol: this.config.symbol,
|
||||
title: "加仓",
|
||||
message: `${currentSide === "LONG" ? "做多" : "做空"} +${absChange.toFixed(6)} → ${Math.abs(currentAmt).toFixed(6)}`,
|
||||
details: {
|
||||
side: currentSide,
|
||||
added: absChange,
|
||||
totalSize: Math.abs(currentAmt),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.notify({
|
||||
type: "order_filled",
|
||||
level: "info",
|
||||
symbol: this.config.symbol,
|
||||
title: "减仓",
|
||||
message: `${currentSide === "LONG" ? "多" : "空"} -${absChange.toFixed(6)} → ${Math.abs(currentAmt).toFixed(6)}`,
|
||||
details: {
|
||||
side: currentSide,
|
||||
reduced: absChange,
|
||||
totalSize: Math.abs(currentAmt),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (currentSide !== prevSide && currentSide !== "FLAT" && prevSide !== "FLAT") {
|
||||
this.notify({
|
||||
type: "position_opened",
|
||||
level: "info",
|
||||
symbol: this.config.symbol,
|
||||
title: "反向开仓",
|
||||
message: `${prevSide === "LONG" ? "多→空" : "空→多"} ${Math.abs(currentAmt).toFixed(6)}`,
|
||||
details: {
|
||||
prevSide,
|
||||
newSide: currentSide,
|
||||
size: Math.abs(currentAmt),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.lastPositionAmt = currentAmt;
|
||||
this.lastPositionSide = currentSide;
|
||||
}
|
||||
|
||||
private async handleTokenExpiry(position: PositionSnapshot, absPosition: number): Promise<boolean> {
|
||||
if (!isTokenExpiryConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiryStatus = checkStandxTokenExpiry({
|
||||
positionAmt: position.positionAmt,
|
||||
openOrderCount: this.openOrders.length,
|
||||
});
|
||||
|
||||
if (!expiryStatus.expired) {
|
||||
if (this.tokenExpiryState !== "active") {
|
||||
this.tokenExpiryState = "active";
|
||||
this.tokenExpiryLogged = false;
|
||||
this.tokenExpiryCancelDone = false;
|
||||
this.tokenExpiredCloseOnlyMode = false;
|
||||
this.tokenExpiryNotified = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevState = this.tokenExpiryState;
|
||||
this.tokenExpiryState = expiryStatus.state;
|
||||
|
||||
if (!this.tokenExpiryLogged) {
|
||||
const message = formatTokenExpiryMessage(expiryStatus);
|
||||
if (message) {
|
||||
this.tradeLog.push("warn", message);
|
||||
}
|
||||
this.tokenExpiryLogged = true;
|
||||
}
|
||||
|
||||
if (!this.tokenExpiryNotified) {
|
||||
this.notify({
|
||||
type: "token_expired",
|
||||
level: "warn",
|
||||
symbol: this.config.symbol,
|
||||
title: "Token 已过期",
|
||||
message: expiryStatus.hasPosition
|
||||
? "Token 已过期,进入平仓模式,不再开新仓"
|
||||
: "Token 已过期,策略进入静默模式",
|
||||
details: {
|
||||
hasPosition: expiryStatus.hasPosition,
|
||||
hasOpenOrders: expiryStatus.hasOpenOrders,
|
||||
state: expiryStatus.state,
|
||||
},
|
||||
});
|
||||
this.tokenExpiryNotified = true;
|
||||
}
|
||||
|
||||
if (!this.tokenExpiryCancelDone && this.openOrders.length > 0) {
|
||||
try {
|
||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||||
this.tradeLog.push("order", "Token 过期,已撤销所有挂单");
|
||||
this.openOrders = [];
|
||||
this.tokenExpiryCancelDone = true;
|
||||
} catch (error) {
|
||||
if (isUnknownOrderError(error)) {
|
||||
this.tradeLog.push("order", "Token 过期撤单时订单已不存在");
|
||||
this.tokenExpiryCancelDone = true;
|
||||
} else {
|
||||
this.tradeLog.push("error", `Token 过期撤单失败: ${extractMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expiryStatus.state === "expired_with_position") {
|
||||
if (!this.tokenExpiredCloseOnlyMode) {
|
||||
this.tokenExpiredCloseOnlyMode = true;
|
||||
this.tradeLog.push("info", "Token 过期,强制进入平仓模式,仅允许 reduce-only 订单");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expiryStatus.state === "silent") {
|
||||
if (prevState !== "silent") {
|
||||
this.tradeLog.push("info", "进入静默数据接收模式,不再进行任何交易操作");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBinanceSymbol(symbol: string): string {
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user