mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 09:18:08 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
445e634aa1 | ||
|
|
4bb1fee995 | ||
|
|
aad14395e0 | ||
|
|
598f2a0eb6 | ||
|
|
cb1cef6f1b | ||
|
|
fd034d493f | ||
|
|
2551670874 | ||
|
|
3ec7e9b8d6 | ||
|
|
499ee692da | ||
|
|
de13142950 | ||
|
|
597e41f053 | ||
|
|
b0a33a58d1 | ||
|
|
65b9f21981 | ||
|
|
33b5407245 | ||
|
|
7aafc3b69d |
@@ -16,6 +16,10 @@ STANDX_SYMBOL=BTC-USD
|
|||||||
# STANDX_SESSION_ID=
|
# STANDX_SESSION_ID=
|
||||||
# Optional: request signing key (ed25519 private key, hex or base64)
|
# Optional: request signing key (ed25519 private key, hex or base64)
|
||||||
# STANDX_REQUEST_PRIVATE_KEY=
|
# 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
|
# Core trading symbol and sizing
|
||||||
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
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_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")
|
||||||
|
|||||||
@@ -111,11 +111,25 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
|
|||||||
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
|
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
|
||||||
|
|
||||||
### StandX
|
### StandX
|
||||||
|
|
||||||
|
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
|
||||||
|
|
||||||
|
策略需要 StandX 的登录 token 才能下单
|
||||||
|
|
||||||
|
获取方式:
|
||||||
|
1. 打开 https://standx.ritmex.one/
|
||||||
|
2. 连接钱包
|
||||||
|
3. 点击“登录”
|
||||||
|
4. 导出登录信息里面会包含 token(STANDX_TOKEN) 和代理钱包私钥(STANDX_REQUEST_PRIVATE_KEY),代理钱包仅用于交易签名,有效保证资产钱包安全
|
||||||
|
|
||||||
|
请妥善保存,不要分享给他人
|
||||||
|
|
||||||
1. 设置 `EXCHANGE=standx`。
|
1. 设置 `EXCHANGE=standx`。
|
||||||
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
||||||
3. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(代理钱包私钥)。
|
||||||
4. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
||||||
5. 可选:如需请求签名,补充 `STANDX_REQUEST_PRIVATE_KEY`。
|
5. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
||||||
|
|
||||||
|
|
||||||
### GRVT
|
### GRVT
|
||||||
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
|
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
|
||||||
|
|||||||
+29
-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.
|
||||||
@@ -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 |
|
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
|
||||||
|
|
||||||
## 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 +100,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 +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.
|
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 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`.
|
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` (proxy wallet private key).
|
||||||
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. 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 +135,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 +205,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.
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# StandX 做市积分策略使用教程(超详细新手版)
|
||||||
|
|
||||||
|
本教程会 **手把手** 教你如何运行 StandX 做市积分策略。每一步都有详细说明,按顺序操作即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一步:安装 Bun(运行环境)
|
||||||
|
|
||||||
|
本项目需要 Bun 才能运行。
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三步:获取 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位十六进制字符)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🔴 **请把这两个值复制保存下来!**
|
||||||
|
>
|
||||||
|
> 🔴 **请把这两个值复制保存下来!**
|
||||||
|
>
|
||||||
|
> 🔴 **请把这两个值复制保存下来!**
|
||||||
|
|
||||||
|
### 什么是代理钱包?
|
||||||
|
|
||||||
|
- 代理钱包是系统 **自动为你生成** 的一个临时钱包
|
||||||
|
- 它 **只用于签名交易**,不存放你的资金
|
||||||
|
- 你的资产仍然在你自己的钱包里,非常安全
|
||||||
|
- **你不需要手动创建**,登录时系统会自动生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第四步:配置环境变量
|
||||||
|
|
||||||
|
在项目根目录创建一个 `.env` 文件(如果已存在就修改它)。
|
||||||
|
|
||||||
|
### 4.1 创建/编辑 .env 文件
|
||||||
|
|
||||||
|
**macOS / Linux:**
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
用记事本打开项目文件夹,新建一个文本文件,命名为 `.env`(注意前面有个点)
|
||||||
|
|
||||||
|
### 4.2 填入以下内容
|
||||||
|
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
>
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
>
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ===== 交易所设置 =====
|
||||||
|
EXCHANGE=standx
|
||||||
|
|
||||||
|
# ===== 你的登录凭证(第三步获取的) =====
|
||||||
|
# 把下面的 "你的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=我的账户(可选,用于区分多个账户的通知)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 正确填写示例
|
||||||
|
|
||||||
|
假设你导出的信息是:
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
>
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
>
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第五步:启动策略
|
||||||
|
|
||||||
|
### 普通启动(看实时仪表盘)
|
||||||
|
```bash
|
||||||
|
bun run index.ts --strategy maker-points --exchange standx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后台运行(推荐长期挂机)
|
||||||
|
```bash
|
||||||
|
bun run pm2:start:maker-points
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置参数说明
|
||||||
|
|
||||||
|
| 参数 | 含义 | 新手建议 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `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` 是可选的,如果你有多个账户在运行策略,可以用这个标签区分不同账户的通知。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 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. 检查网络连接是否正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
1. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
2. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
3. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
|
||||||
|
代理钱包只用于签名,你的资产始终在你自己的主钱包里。但如果泄露了 TOKEN,别人可以用你的账户交易。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 还是不会?
|
||||||
|
|
||||||
|
把你的报错信息截图发到 Telegram 群里,会有人帮你:
|
||||||
|
|
||||||
|
Telegram 群:https://t.me/+4fdo0quY87o4Mjhh
|
||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"start:offset:silent": "bun run index.ts --strategy offset-maker --silent",
|
"start:offset:silent": "bun run index.ts --strategy offset-maker --silent",
|
||||||
"pm2:start:trend": "pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent",
|
"pm2:start:trend": "pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent",
|
||||||
"pm2:start:maker": "pm2 start bun --name ritmex-maker --cwd . --restart-delay 5000 -- run index.ts --strategy maker --silent",
|
"pm2:start:maker": "pm2 start bun --name ritmex-maker --cwd . --restart-delay 5000 -- run index.ts --strategy maker --silent",
|
||||||
"pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent"
|
"pm2:start:offset": "pm2 start bun --name ritmex-offset --cwd . --restart-delay 5000 -- run index.ts --strategy offset-maker --silent",
|
||||||
|
"pm2:start:maker-points": "pm2 start bun --name ritmex-maker-points --cwd . --restart-delay 5000 -- run index.ts --strategy maker-points --exchange standx --silent"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
|||||||
+5
-2
@@ -1,4 +1,4 @@
|
|||||||
export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
|
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
|
||||||
|
|
||||||
export interface CliOptions {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
@@ -11,6 +11,7 @@ const STRATEGY_VALUES = new Set<StrategyId>([
|
|||||||
"trend",
|
"trend",
|
||||||
"guardian",
|
"guardian",
|
||||||
"maker",
|
"maker",
|
||||||
|
"maker-points",
|
||||||
"offset-maker",
|
"offset-maker",
|
||||||
"basis",
|
"basis",
|
||||||
"grid",
|
"grid",
|
||||||
@@ -69,6 +70,8 @@ function assignStrategy(options: CliOptions, raw: string): void {
|
|||||||
options.strategy = normalized as StrategyId;
|
options.strategy = normalized as StrategyId;
|
||||||
} else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") {
|
} else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") {
|
||||||
options.strategy = "offset-maker";
|
options.strategy = "offset-maker";
|
||||||
|
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
|
||||||
|
options.strategy = "maker-points";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ 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|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|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` +
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
|
import { basisConfig, gridConfig, isBasisStrategyEnabled, 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 { 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";
|
||||||
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||||
@@ -21,6 +22,7 @@ export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
|||||||
trend: "Trend Following",
|
trend: "Trend Following",
|
||||||
guardian: "Guardian",
|
guardian: "Guardian",
|
||||||
maker: "Maker",
|
maker: "Maker",
|
||||||
|
"maker-points": "Maker Points",
|
||||||
"offset-maker": "Offset Maker",
|
"offset-maker": "Offset Maker",
|
||||||
basis: "Basis Arbitrage",
|
basis: "Basis Arbitrage",
|
||||||
grid: "Grid",
|
grid: "Grid",
|
||||||
@@ -74,6 +76,23 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
|||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
"maker-points": async (opts) => {
|
||||||
|
const exchangeId = resolveExchangeId();
|
||||||
|
if (exchangeId !== "standx") {
|
||||||
|
throw new Error("Maker Points strategy only supports the StandX exchange.");
|
||||||
|
}
|
||||||
|
const config = makerPointsConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol);
|
||||||
|
const engine = new MakerPointsEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "maker-points",
|
||||||
|
silent: opts.silent,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
"offset-maker": async (opts) => {
|
"offset-maker": async (opts) => {
|
||||||
const config = makerConfig;
|
const config = makerConfig;
|
||||||
const adapter = createAdapterOrThrow(config.symbol);
|
const adapter = createAdapterOrThrow(config.symbol);
|
||||||
@@ -135,6 +154,7 @@ async function runEngine<
|
|||||||
| TrendEngineSnapshot
|
| TrendEngineSnapshot
|
||||||
| GuardianEngineSnapshot
|
| GuardianEngineSnapshot
|
||||||
| MakerEngineSnapshot
|
| MakerEngineSnapshot
|
||||||
|
| MakerPointsSnapshot
|
||||||
| OffsetMakerEngineSnapshot
|
| OffsetMakerEngineSnapshot
|
||||||
| BasisArbSnapshot
|
| BasisArbSnapshot
|
||||||
| GridEngineSnapshot
|
| GridEngineSnapshot
|
||||||
|
|||||||
@@ -6,6 +6,45 @@
|
|||||||
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 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 {
|
export interface TradingConfig {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
tradeAmount: number;
|
tradeAmount: number;
|
||||||
@@ -110,6 +149,41 @@ export const makerConfig: MakerConfig = {
|
|||||||
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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface MakerPointsConfig {
|
||||||
|
symbol: string;
|
||||||
|
perOrderAmount: number;
|
||||||
|
closeThreshold: number;
|
||||||
|
stopLossUsd: number;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
maxLogEntries: number;
|
||||||
|
maxCloseSlippagePct: number;
|
||||||
|
priceTick: number;
|
||||||
|
qtyStep: number;
|
||||||
|
enableBand0To10: boolean;
|
||||||
|
enableBand10To30: boolean;
|
||||||
|
enableBand30To100: boolean;
|
||||||
|
minRepriceBps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makerPointsConfig: MakerPointsConfig = {
|
||||||
|
symbol: resolveSymbolFromEnv("standx"),
|
||||||
|
perOrderAmount: parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001)),
|
||||||
|
closeThreshold: parseNumber(process.env.MAKER_POINTS_CLOSE_THRESHOLD, 0),
|
||||||
|
stopLossUsd: parseNumber(process.env.MAKER_POINTS_STOP_LOSS_USD, 0),
|
||||||
|
refreshIntervalMs: parseNumber(process.env.MAKER_POINTS_REFRESH_INTERVAL_MS, 500),
|
||||||
|
maxLogEntries: parseNumber(process.env.MAKER_POINTS_MAX_LOG_ENTRIES, 200),
|
||||||
|
maxCloseSlippagePct: parseNumber(
|
||||||
|
process.env.MAKER_POINTS_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
|
||||||
|
0.05
|
||||||
|
),
|
||||||
|
priceTick: parseNumber(process.env.MAKER_POINTS_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
|
qtyStep: parseNumber(process.env.MAKER_POINTS_QTY_STEP ?? process.env.QTY_STEP, 0.001),
|
||||||
|
enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true),
|
||||||
|
enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true),
|
||||||
|
enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true),
|
||||||
|
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
|
||||||
|
};
|
||||||
|
|
||||||
export interface BasisArbConfig {
|
export interface BasisArbConfig {
|
||||||
futuresSymbol: string;
|
futuresSymbol: string;
|
||||||
spotSymbol: string;
|
spotSymbol: string;
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "双边挂单提供流动性,自动追价与风控止损",
|
zh: "双边挂单提供流动性,自动追价与风控止损",
|
||||||
en: "Places two-sided quotes, auto-chases and risk-manages stops.",
|
en: "Places two-sided quotes, auto-chases and risk-manages stops.",
|
||||||
},
|
},
|
||||||
|
"app.strategy.makerPoints.label": { zh: "StandX 积分做市策略", en: "StandX Maker Points" },
|
||||||
|
"app.strategy.makerPoints.desc": {
|
||||||
|
zh: "基于标记价/盘口挂单赚取 StandX Maker Points",
|
||||||
|
en: "Quotes by mark-price bands to farm StandX maker points.",
|
||||||
|
},
|
||||||
"app.strategy.grid.label": { zh: "基础网格策略", en: "Grid Strategy" },
|
"app.strategy.grid.label": { zh: "基础网格策略", en: "Grid Strategy" },
|
||||||
"app.strategy.grid.desc": {
|
"app.strategy.grid.desc": {
|
||||||
zh: "在上下边界之间布设等比网格,自动加仓与减仓",
|
zh: "在上下边界之间布设等比网格,自动加仓与减仓",
|
||||||
@@ -174,6 +179,23 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
},
|
},
|
||||||
"maker.targetOrders": { zh: "目标挂单", en: "Target Orders" },
|
"maker.targetOrders": { zh: "目标挂单", en: "Target Orders" },
|
||||||
"maker.noTargetOrders": { zh: "暂无目标挂单", en: "No target orders" },
|
"maker.noTargetOrders": { zh: "暂无目标挂单", en: "No target orders" },
|
||||||
|
"makerPoints.title": { zh: "Maker Points 策略仪表盘", en: "Maker Points Dashboard" },
|
||||||
|
"makerPoints.initializing": { zh: "正在初始化 Maker Points 策略…", en: "Initializing Maker Points strategy..." },
|
||||||
|
"makerPoints.headerLine": {
|
||||||
|
zh: "交易所: {exchange} | 交易对: {symbol} | 买一价: {bid} | 卖一价: {ask} | 点差: {spread}",
|
||||||
|
en: "Exchange: {exchange} | Symbol: {symbol} | Best Bid: {bid} | Best Ask: {ask} | Spread: {spread}",
|
||||||
|
},
|
||||||
|
"makerPoints.quoteLine": {
|
||||||
|
zh: "挂单模式: {mode} | BUY {buy} | SELL {sell}",
|
||||||
|
en: "Quote mode: {mode} | BUY {buy} | SELL {sell}",
|
||||||
|
},
|
||||||
|
"makerPoints.binanceLine": {
|
||||||
|
zh: "Binance 深度: 买10 {buy} | 卖10 {sell} | 状态: {status}",
|
||||||
|
en: "Binance depth: bid10 {buy} | ask10 {sell} | Status: {status}",
|
||||||
|
},
|
||||||
|
"makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" },
|
||||||
|
"makerPoints.mode.normal": { zh: "正常", en: "Normal" },
|
||||||
|
"makerPoints.feed.binance": { zh: "Binance", en: "Binance" },
|
||||||
"offset.name": { zh: "偏移做市策略", en: "offset maker strategy" },
|
"offset.name": { zh: "偏移做市策略", en: "offset maker strategy" },
|
||||||
"offset.title": { zh: "偏移做市策略仪表盘", en: "Offset Maker Strategy Dashboard" },
|
"offset.title": { zh: "偏移做市策略仪表盘", en: "Offset Maker Strategy Dashboard" },
|
||||||
"offset.initializing": { zh: "正在初始化偏移做市策略…", en: "Initializing offset maker strategy..." },
|
"offset.initializing": { zh: "正在初始化偏移做市策略…", en: "Initializing offset maker strategy..." },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import NodeWebSocket from "ws";
|
||||||
|
import { computeDepthStats, type DepthImbalance } from "../../utils/depth";
|
||||||
|
|
||||||
|
const WebSocketCtor: typeof globalThis.WebSocket =
|
||||||
|
typeof globalThis.WebSocket !== "undefined"
|
||||||
|
? globalThis.WebSocket
|
||||||
|
: ((NodeWebSocket as unknown) as typeof globalThis.WebSocket);
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = "wss://fstream.binance.com/ws";
|
||||||
|
|
||||||
|
export interface BinanceDepthSnapshot {
|
||||||
|
symbol: string;
|
||||||
|
buySum: number;
|
||||||
|
sellSum: number;
|
||||||
|
skipBuySide: boolean;
|
||||||
|
skipSellSide: boolean;
|
||||||
|
imbalance: DepthImbalance;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BinanceDepthTracker {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private reconnectDelayMs = 3000;
|
||||||
|
private stopped = false;
|
||||||
|
private snapshot: BinanceDepthSnapshot | null = null;
|
||||||
|
private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly symbol: string,
|
||||||
|
private readonly options?: {
|
||||||
|
baseUrl?: string;
|
||||||
|
levels?: number;
|
||||||
|
ratio?: number;
|
||||||
|
logger?: (context: string, error: unknown) => void;
|
||||||
|
}
|
||||||
|
) {}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.stopped = false;
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
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 {
|
||||||
|
this.listeners.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
offUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void {
|
||||||
|
this.listeners.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSnapshot(): BinanceDepthSnapshot | null {
|
||||||
|
return this.snapshot ? { ...this.snapshot } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect(): void {
|
||||||
|
if (this.ws || this.stopped) return;
|
||||||
|
const url = this.buildUrl();
|
||||||
|
this.ws = new WebSocketCtor(url);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
this.reconnectDelayMs = 3000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
this.ws = null;
|
||||||
|
if (!this.stopped) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error: unknown) => {
|
||||||
|
this.options?.logger?.("binanceDepth", error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessage = (event: { data: unknown }) => {
|
||||||
|
this.handlePayload(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePing = (data: unknown) => {
|
||||||
|
if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") {
|
||||||
|
this.ws.pong(data as any);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") {
|
||||||
|
this.ws.addEventListener("open", handleOpen);
|
||||||
|
this.ws.addEventListener("message", handleMessage as any);
|
||||||
|
this.ws.addEventListener("close", handleClose);
|
||||||
|
this.ws.addEventListener("error", handleError as any);
|
||||||
|
this.ws.addEventListener("ping", handlePing as any);
|
||||||
|
} else if ("on" in this.ws && typeof (this.ws as any).on === "function") {
|
||||||
|
const nodeSocket = this.ws as any;
|
||||||
|
nodeSocket.on("open", handleOpen);
|
||||||
|
nodeSocket.on("message", (data: unknown) => handleMessage({ data }));
|
||||||
|
nodeSocket.on("close", handleClose);
|
||||||
|
nodeSocket.on("error", handleError);
|
||||||
|
nodeSocket.on("ping", handlePing);
|
||||||
|
} else {
|
||||||
|
(this.ws as any).onopen = handleOpen;
|
||||||
|
(this.ws as any).onmessage = handleMessage;
|
||||||
|
(this.ws as any).onclose = handleClose;
|
||||||
|
(this.ws as any).onerror = handleError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUrl(): string {
|
||||||
|
const base = this.options?.baseUrl ?? DEFAULT_BASE_URL;
|
||||||
|
const stream = `${this.symbol.toLowerCase()}@depth10@100ms`;
|
||||||
|
return `${base}/${stream}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimer || this.stopped) return;
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 60_000);
|
||||||
|
this.connect();
|
||||||
|
}, this.reconnectDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePayload(data: unknown): void {
|
||||||
|
const payload = this.parsePayload(data);
|
||||||
|
if (!payload) return;
|
||||||
|
const bids = Array.isArray(payload.b) ? payload.b : [];
|
||||||
|
const asks = Array.isArray(payload.a) ? payload.a : [];
|
||||||
|
const depth = {
|
||||||
|
lastUpdateId: Number(payload.u ?? Date.now()),
|
||||||
|
bids,
|
||||||
|
asks,
|
||||||
|
};
|
||||||
|
const levels = this.options?.levels ?? 10;
|
||||||
|
const ratio = this.options?.ratio ?? 3;
|
||||||
|
const stats = computeDepthStats(depth, levels, ratio);
|
||||||
|
this.snapshot = {
|
||||||
|
symbol: this.symbol,
|
||||||
|
buySum: stats.buySum,
|
||||||
|
sellSum: stats.sellSum,
|
||||||
|
skipBuySide: stats.skipBuySide,
|
||||||
|
skipSellSide: stats.skipSellSide,
|
||||||
|
imbalance: stats.imbalance,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener({ ...this.snapshot });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePayload(data: unknown): { b?: [string, string][]; a?: [string, string][]; u?: number } | null {
|
||||||
|
try {
|
||||||
|
const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null;
|
||||||
|
if (!text) return null;
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
return parsed as { b?: [string, string][]; a?: [string, string][]; u?: number };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildBpsTargets } from "./maker-points-logic";
|
||||||
|
|
||||||
|
describe("maker points target builder", () => {
|
||||||
|
it("builds fixed bps targets per enabled band", () => {
|
||||||
|
const targets = buildBpsTargets({
|
||||||
|
band0To10: true,
|
||||||
|
band10To30: true,
|
||||||
|
band30To100: true,
|
||||||
|
});
|
||||||
|
expect(targets).toEqual([9, 29, 99]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips disabled bands", () => {
|
||||||
|
const targets = buildBpsTargets({
|
||||||
|
band0To10: true,
|
||||||
|
band10To30: false,
|
||||||
|
band30To100: true,
|
||||||
|
});
|
||||||
|
expect(targets).toEqual([9, 99]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface MakerPointsBandConfig {
|
||||||
|
band0To10: boolean;
|
||||||
|
band10To30: boolean;
|
||||||
|
band30To100: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBpsTargets(config: MakerPointsBandConfig): number[] {
|
||||||
|
const targets: number[] = [];
|
||||||
|
if (config.band0To10) targets.push(9);
|
||||||
|
if (config.band10To30) targets.push(29);
|
||||||
|
if (config.band30To100) targets.push(99);
|
||||||
|
return targets.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
+16
-9
@@ -3,6 +3,7 @@ import { Box, Text, useInput } from "ink";
|
|||||||
import { TrendApp } from "./TrendApp";
|
import { TrendApp } from "./TrendApp";
|
||||||
import { GuardianApp } from "./GuardianApp";
|
import { GuardianApp } from "./GuardianApp";
|
||||||
import { MakerApp } from "./MakerApp";
|
import { MakerApp } from "./MakerApp";
|
||||||
|
import { MakerPointsApp } from "./MakerPointsApp";
|
||||||
import { OffsetMakerApp } from "./OffsetMakerApp";
|
import { OffsetMakerApp } from "./OffsetMakerApp";
|
||||||
import { GridApp } from "./GridApp";
|
import { GridApp } from "./GridApp";
|
||||||
import { BasisApp } from "./BasisApp";
|
import { BasisApp } from "./BasisApp";
|
||||||
@@ -12,7 +13,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface StrategyOption {
|
interface StrategyOption {
|
||||||
id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
|
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid";
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
component: React.ComponentType<{ onExit: () => void }>;
|
component: React.ComponentType<{ onExit: () => void }>;
|
||||||
@@ -60,19 +61,25 @@ export function App() {
|
|||||||
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
const strategies = useMemo(() => {
|
const strategies = useMemo(() => {
|
||||||
if (!isBasisStrategyEnabled()) {
|
const next: StrategyOption[] = [...BASE_STRATEGIES];
|
||||||
return BASE_STRATEGIES;
|
if (exchangeId === "standx") {
|
||||||
|
next.splice(3, 0, {
|
||||||
|
id: "maker-points" as const,
|
||||||
|
label: t("app.strategy.makerPoints.label"),
|
||||||
|
description: t("app.strategy.makerPoints.desc"),
|
||||||
|
component: MakerPointsApp,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return [
|
if (isBasisStrategyEnabled()) {
|
||||||
...BASE_STRATEGIES,
|
next.push({
|
||||||
{
|
|
||||||
id: "basis" as const,
|
id: "basis" as const,
|
||||||
label: t("app.strategy.basis.label"),
|
label: t("app.strategy.basis.label"),
|
||||||
description: t("app.strategy.basis.desc"),
|
description: t("app.strategy.basis.desc"),
|
||||||
component: BasisApp,
|
component: BasisApp,
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
}, []);
|
return next;
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(input, key) => {
|
(input, key) => {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { makerPointsConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
||||||
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
interface MakerPointsAppProps {
|
||||||
|
onExit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
|
export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
||||||
|
const [snapshot, setSnapshot] = useState<MakerPointsSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const engineRef = useRef<MakerPointsEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (exchangeId !== "standx") {
|
||||||
|
throw new Error("Maker Points strategy only supports the StandX exchange.");
|
||||||
|
}
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerPointsConfig.symbol });
|
||||||
|
const engine = new MakerPointsEngine(makerPointsConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: MakerPointsSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
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("makerPoints.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 feedStatus = snapshot.feedStatus;
|
||||||
|
const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [
|
||||||
|
{ key: "account", label: t("maker.feed.account") },
|
||||||
|
{ key: "orders", label: t("maker.feed.orders") },
|
||||||
|
{ key: "depth", label: t("maker.feed.depth") },
|
||||||
|
{ key: "ticker", label: t("maker.feed.ticker") },
|
||||||
|
{ key: "binance", label: t("makerPoints.feed.binance") },
|
||||||
|
];
|
||||||
|
const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData");
|
||||||
|
const imbalanceStatus = snapshot.binanceDepth?.imbalance ?? "balanced";
|
||||||
|
const imbalanceLabel =
|
||||||
|
imbalanceStatus === "buy_dominant"
|
||||||
|
? t("offset.imbalance.buy")
|
||||||
|
: imbalanceStatus === "sell_dominant"
|
||||||
|
? t("offset.imbalance.sell")
|
||||||
|
: t("offset.imbalance.balanced");
|
||||||
|
const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="cyanBright">{t("makerPoints.title")}</Text>
|
||||||
|
<Text>
|
||||||
|
{t("makerPoints.headerLine", {
|
||||||
|
exchange: exchangeName,
|
||||||
|
symbol: snapshot.symbol,
|
||||||
|
bid: formatNumber(topBid, priceDigits),
|
||||||
|
ask: formatNumber(topAsk, priceDigits),
|
||||||
|
spread: spreadDisplay,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
|
||||||
|
<Text>
|
||||||
|
{t("makerPoints.quoteLine", {
|
||||||
|
mode: quoteMode,
|
||||||
|
buy: snapshot.quoteStatus.skipBuy ? t("common.disabled") : t("common.enabled"),
|
||||||
|
sell: snapshot.quoteStatus.skipSell ? t("common.disabled") : t("common.enabled"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("makerPoints.binanceLine", {
|
||||||
|
buy: formatNumber(snapshot.binanceDepth?.buySum ?? 0, 4),
|
||||||
|
sell: formatNumber(snapshot.binanceDepth?.sellSum ?? 0, 4),
|
||||||
|
status: imbalanceLabel,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("maker.dataStatus")}
|
||||||
|
{feedEntries.map((entry, index) => (
|
||||||
|
<Text key={entry.key} color={feedStatus[entry.key] ? "green" : "red"}>
|
||||||
|
{index === 0 ? " " : " "}
|
||||||
|
{entry.label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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