mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-11 17:28:08 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b935b7979 | ||
|
|
00388f9166 | ||
|
|
a32efa2ba0 | ||
|
|
76704b6bdd | ||
|
|
168d8cbb08 | ||
|
|
9629c22496 | ||
|
|
a34d06f9b4 | ||
|
|
2ba3e80ad9 | ||
|
|
12e8e3e064 | ||
|
|
aa24995d28 | ||
|
|
d493642935 | ||
|
|
86670486a6 | ||
|
|
6496011d8f | ||
|
|
792351ab8a | ||
|
|
4915dc574e | ||
|
|
9866e8068f | ||
|
|
099af3ce01 | ||
|
|
445e634aa1 | ||
|
|
4bb1fee995 | ||
|
|
aad14395e0 | ||
|
|
598f2a0eb6 | ||
|
|
cb1cef6f1b | ||
|
|
fd034d493f | ||
|
|
2551670874 | ||
|
|
3ec7e9b8d6 | ||
|
|
499ee692da | ||
|
|
de13142950 | ||
|
|
597e41f053 | ||
|
|
b0a33a58d1 | ||
|
|
65b9f21981 | ||
|
|
33b5407245 | ||
|
|
7aafc3b69d | ||
|
|
9a093459bc | ||
|
|
aa36b0cfdc | ||
|
|
93c6409688 |
+21
-1
@@ -2,12 +2,27 @@
|
|||||||
LANG=zh
|
LANG=zh
|
||||||
|
|
||||||
# Exchange selection
|
# Exchange selection
|
||||||
EXCHANGE=aster # Pick aster (default) or grvt/lighter/backpack/paradex/nado
|
EXCHANGE=aster # Pick aster (default) or standx/grvt/lighter/backpack/paradex/nado
|
||||||
|
|
||||||
# Aster API credentials
|
# Aster API credentials
|
||||||
ASTER_API_KEY=
|
ASTER_API_KEY=
|
||||||
ASTER_API_SECRET=
|
ASTER_API_SECRET=
|
||||||
|
|
||||||
|
# StandX authentication (set when EXCHANGE=standx)
|
||||||
|
STANDX_TOKEN=
|
||||||
|
STANDX_SYMBOL=BTC-USD
|
||||||
|
# STANDX_BASE_URL=https://perps.standx.com
|
||||||
|
# STANDX_WS_URL=wss://perps.standx.com/ws-stream/v1
|
||||||
|
# STANDX_SESSION_ID=
|
||||||
|
# Optional: request signing key (ed25519 private key, supports hex or base58 format)
|
||||||
|
# STANDX_REQUEST_PRIVATE_KEY=
|
||||||
|
# Token expiry configuration (recommended method: creation date + validity days)
|
||||||
|
# Get these values when generating API token at https://standx.com/user/session
|
||||||
|
# STANDX_TOKEN_CREATE_DATE=2026-01-15 # Token creation date (YYYY-MM-DD format)
|
||||||
|
# STANDX_TOKEN_VALIDITY_DAYS=30 # Token validity period in days
|
||||||
|
# Legacy method: direct expiry timestamp (Unix seconds)
|
||||||
|
# STANDX_TOKEN_EXPIRY=1737092800
|
||||||
|
|
||||||
# Core trading symbol and sizing
|
# Core trading symbol and sizing
|
||||||
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
|
||||||
TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC)
|
TRADE_AMOUNT=0.001 # Base order quantity (base asset, e.g. BTC)
|
||||||
@@ -145,3 +160,8 @@ NADO_MIN_SIZE_POLICY=adjust
|
|||||||
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
|
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
|
||||||
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
|
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
|
||||||
# NADO_DEBUG=false
|
# NADO_DEBUG=false
|
||||||
|
|
||||||
|
# Telegram notification configuration
|
||||||
|
# TELEGRAM_BOT_TOKEN= # Telegram bot token from @BotFather
|
||||||
|
# TELEGRAM_CHAT_ID= # Chat ID to receive notifications
|
||||||
|
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# RitMEX Bot - Claude Instructions
|
||||||
|
|
||||||
|
## Package Manager
|
||||||
|
|
||||||
|
**必须使用 Bun** - 这个项目使用 Bun 作为包管理器和运行时。所有能用 bun 执行的命令都必须使用 bun:
|
||||||
|
|
||||||
|
- 安装依赖: `bun install`
|
||||||
|
- 运行脚本: `bun run <script>`
|
||||||
|
- 执行测试: `bun test`
|
||||||
|
- 类型检查: `bun run typecheck`
|
||||||
|
|
||||||
|
**不要使用 npm、yarn 或 npx**
|
||||||
@@ -12,6 +12,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
|
|
||||||
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=111909FA)
|
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=111909FA)
|
||||||
* [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
|
* [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
|
||||||
|
* [StandX 手续费优惠注册链接](https://standx.com/referral?code=xingxingjun)
|
||||||
* [Binance 手续费优惠注册链接](https://www.binance.com/join?ref=KNKCA9XC)
|
* [Binance 手续费优惠注册链接](https://www.binance.com/join?ref=KNKCA9XC)
|
||||||
* [GRVT 手续费优惠注册链接](https://grvt.io/exchange/sign-up?ref=sea)
|
* [GRVT 手续费优惠注册链接](https://grvt.io/exchange/sign-up?ref=sea)
|
||||||
* [Nado 手续费优惠注册链接](https://app.nado.xyz?join=LKbIUs5)
|
* [Nado 手续费优惠注册链接](https://app.nado.xyz?join=LKbIUs5)
|
||||||
@@ -35,10 +36,12 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
| 交易所 | 合约类型 | 必填环境变量 | 备注 |
|
| 交易所 | 合约类型 | 必填环境变量 | 备注 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Aster | USDT 永续 | `ASTER_API_KEY`, `ASTER_API_SECRET` | 默认交易所;兼容脚本引导
|
| Aster | USDT 永续 | `ASTER_API_KEY`, `ASTER_API_SECRET` | 默认交易所;兼容脚本引导
|
||||||
|
| StandX | USD 永续 | `STANDX_TOKEN` | 使用 JWT Token 登录,优先走 WebSocket 推送
|
||||||
| GRVT | USDT 永续 | `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID` | `GRVT_ENV` 可切换 `prod`/`testnet`
|
| GRVT | USDT 永续 | `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID` | `GRVT_ENV` 可切换 `prod`/`testnet`
|
||||||
| Lighter | zkLighter 永续 | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | 默认 `LIGHTER_ENV=testnet`
|
| Lighter | zkLighter 永续 | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | 默认 `LIGHTER_ENV=testnet`
|
||||||
| Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒
|
| Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒
|
||||||
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
|
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
|
||||||
|
| Nado | USDC 永续 | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | `NADO_ENV` 可切换 `inkMainnet`/`inkTestnet`
|
||||||
|
|
||||||
## 系统要求
|
## 系统要求
|
||||||
- Bun ≥ 1.2(需同时包含 `bun`、`bunx` 命令)
|
- Bun ≥ 1.2(需同时包含 `bun`、`bunx` 命令)
|
||||||
@@ -83,7 +86,7 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
|
|||||||
|
|
||||||
| 变量 | 说明 |
|
| 变量 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `EXCHANGE` | 选择交易所(`aster`/`grvt`/`lighter`/`backpack`/`paradex`/`nado`) |
|
| `EXCHANGE` | 选择交易所(`aster`/`standx`/`grvt`/`lighter`/`backpack`/`paradex`/`nado`) |
|
||||||
| `TRADE_SYMBOL` | 交易对(默认 `BTCUSDT`) |
|
| `TRADE_SYMBOL` | 交易对(默认 `BTCUSDT`) |
|
||||||
| `TRADE_AMOUNT` | 单笔下单数量(标的资产计) |
|
| `TRADE_AMOUNT` | 单笔下单数量(标的资产计) |
|
||||||
| `LOSS_LIMIT` | 单笔最大亏损触发的强平额度(USDT) |
|
| `LOSS_LIMIT` | 单笔最大亏损触发的强平额度(USDT) |
|
||||||
@@ -108,6 +111,36 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
|
|||||||
3. 根据交易对调整 `TRADE_SYMBOL`、`PRICE_TICK`、`QTY_STEP` 等精度参数。
|
3. 根据交易对调整 `TRADE_SYMBOL`、`PRICE_TICK`、`QTY_STEP` 等精度参数。
|
||||||
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
|
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
|
||||||
|
|
||||||
|
### StandX
|
||||||
|
|
||||||
|
* [StandX 做市策略教程](docs/standx/maker-points-guide.md)
|
||||||
|
|
||||||
|
策略需要 StandX 的 API Token 和签名私钥才能下单。
|
||||||
|
|
||||||
|
**获取方式(使用 StandX 官方 API 生成功能):**
|
||||||
|
1. 打开 StandX 官方 API 创建页面:https://standx.com/user/session
|
||||||
|
2. 连接钱包并登录
|
||||||
|
3. 点击 **"Generate API Token"** 按钮
|
||||||
|
4. 页面会显示以下信息:
|
||||||
|
- **Token**(以 `eyJ` 开头的 JWT 字符串)→ 填入 `STANDX_TOKEN`
|
||||||
|
- **Ed25519 Private Key**(Base58 格式私钥,类似 `HdsyJD7oWgT...`)→ 填入 `STANDX_REQUEST_PRIVATE_KEY`
|
||||||
|
- **创建日期** 和 **有效期天数** → 用于配置 Token 过期提醒
|
||||||
|
|
||||||
|
> Ed25519 Private Key 是系统自动生成的签名私钥,仅用于交易请求签名,你的资产仍在主钱包中,非常安全。
|
||||||
|
|
||||||
|
请妥善保存这些凭证,不要分享给他人。
|
||||||
|
|
||||||
|
**配置步骤:**
|
||||||
|
1. 设置 `EXCHANGE=standx`。
|
||||||
|
2. 填写 `STANDX_TOKEN`(Perps API 的 JWT Token)。
|
||||||
|
3. 填写 `STANDX_REQUEST_PRIVATE_KEY`(Ed25519 签名私钥,Base58 格式)。
|
||||||
|
4. 设置 `STANDX_SYMBOL`(默认 `BTC-USD`),并校准 `PRICE_TICK` / `QTY_STEP`。
|
||||||
|
5. 推荐配置 Token 过期时间:
|
||||||
|
- `STANDX_TOKEN_CREATE_DATE`(创建日期,格式 `YYYY-MM-DD`)
|
||||||
|
- `STANDX_TOKEN_VALIDITY_DAYS`(有效期天数)
|
||||||
|
6. 可选:`STANDX_BASE_URL`、`STANDX_WS_URL`、`STANDX_SESSION_ID` 用于自定义环境。
|
||||||
|
|
||||||
|
|
||||||
### GRVT
|
### GRVT
|
||||||
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
|
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
|
||||||
2. 填写 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID`。
|
2. 填写 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID`。
|
||||||
|
|||||||
+46
-9
@@ -1,20 +1,25 @@
|
|||||||
# 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)
|
||||||
* [Binance referral link](https://www.binance.com/join?ref=KNKCA9XC)
|
* [Binance referral link](https://www.binance.com/join?ref=KNKCA9XC)
|
||||||
* [GRVT referral link](https://grvt.io/exchange/sign-up?ref=sea)
|
* [GRVT referral link](https://grvt.io/exchange/sign-up?ref=sea)
|
||||||
* [Nado referral link](https://app.nado.xyz?join=LKbIUs5)
|
* [Nado referral link](https://app.nado.xyz?join=LKbIUs5)
|
||||||
* [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.
|
||||||
@@ -27,13 +32,15 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
| Exchange | Contract Type | Required Environment Variables | Notes |
|
| Exchange | Contract Type | Required Environment Variables | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Aster | USDT perpetuals | `ASTER_API_KEY`, `ASTER_API_SECRET` | Default venue; works with the bootstrap script |
|
| Aster | USDT perpetuals | `ASTER_API_KEY`, `ASTER_API_SECRET` | Default venue; works with the bootstrap script |
|
||||||
|
| StandX | USD perpetuals | `STANDX_TOKEN` | Uses JWT token auth; prefer websocket streams |
|
||||||
| GRVT | USDT perpetuals | `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID` | Switch `GRVT_ENV` between `prod` and `testnet` |
|
| GRVT | USDT perpetuals | `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID` | Switch `GRVT_ENV` between `prod` and `testnet` |
|
||||||
| Lighter | zkLighter perpetuals | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | Defaults to `LIGHTER_ENV=testnet` |
|
| Lighter | zkLighter perpetuals | `LIGHTER_ACCOUNT_INDEX`, `LIGHTER_API_PRIVATE_KEY` | Defaults to `LIGHTER_ENV=testnet` |
|
||||||
| Backpack | USDC perpetuals | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | Set `BACKPACK_SANDBOX=true` for the sandbox |
|
| Backpack | USDC perpetuals | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | Set `BACKPACK_SANDBOX=true` for the sandbox |
|
||||||
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
|
| Paradex | StarkEx perpetuals | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | Toggle `PARADEX_SANDBOX=true` for the testnet |
|
||||||
|
| Nado | USDC perpetuals | `NADO_SIGNER_PRIVATE_KEY`, `NADO_SUBACCOUNT_OWNER` | Switch `NADO_ENV` between `inkMainnet` and `inkTestnet` |
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Bun ≥ 1.2 (both `bun` and `bunx` on PATH)
|
- Bun >= 1.2 (both `bun` and `bunx` on PATH)
|
||||||
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
|
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
|
||||||
- Node.js is optional unless your tooling requires it
|
- Node.js is optional unless your tooling requires it
|
||||||
|
|
||||||
@@ -75,7 +82,7 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
|||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `EXCHANGE` | Choose the venue (`aster` / `grvt` / `lighter` / `backpack` / `paradex` / `nado`) |
|
| `EXCHANGE` | Choose the venue (`aster` / `standx` / `grvt` / `lighter` / `backpack` / `paradex` / `nado`) |
|
||||||
| `TRADE_SYMBOL` | Contract symbol (defaults to `BTCUSDT`) |
|
| `TRADE_SYMBOL` | Contract symbol (defaults to `BTCUSDT`) |
|
||||||
| `TRADE_AMOUNT` | Order size in base asset units |
|
| `TRADE_AMOUNT` | Order size in base asset units |
|
||||||
| `LOSS_LIMIT` | Max per-trade loss in USDT before forced close |
|
| `LOSS_LIMIT` | Max per-trade loss in USDT before forced close |
|
||||||
@@ -94,12 +101,42 @@ 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`.
|
||||||
3. Adjust `TRADE_SYMBOL`, `PRICE_TICK`, and `QTY_STEP` to match the requested market.
|
3. Adjust `TRADE_SYMBOL`, `PRICE_TICK`, and `QTY_STEP` to match the requested market.
|
||||||
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 Maker Points Strategy Guide](docs/standx/maker-points-guide.md)
|
||||||
|
|
||||||
|
The strategy requires a StandX API Token and signing private key to place orders.
|
||||||
|
|
||||||
|
**How to obtain (using StandX's official API generation feature):**
|
||||||
|
1. Open the StandX official API creation page: https://standx.com/user/session
|
||||||
|
2. Connect your wallet and log in
|
||||||
|
3. Click the **"Generate API Token"** button
|
||||||
|
4. The page will display the following information:
|
||||||
|
- **Token** (JWT string starting with `eyJ`) → Fill in `STANDX_TOKEN`
|
||||||
|
- **Ed25519 Private Key** (Base58 format, like `HdsyJD7oWgT...`) → Fill in `STANDX_REQUEST_PRIVATE_KEY`
|
||||||
|
- **Creation date** and **Validity days** → Used to configure token expiry reminders
|
||||||
|
|
||||||
|
> The Ed25519 Private Key is an auto-generated signing key used only for trade request signatures. Your assets remain in your main wallet and are completely safe.
|
||||||
|
|
||||||
|
Please keep these credentials safe and do not share them with anyone.
|
||||||
|
|
||||||
|
**Configuration steps:**
|
||||||
|
1. Set `EXCHANGE=standx`.
|
||||||
|
2. Provide `STANDX_TOKEN` (JWT token for perps API).
|
||||||
|
3. Provide `STANDX_REQUEST_PRIVATE_KEY` (Ed25519 signing private key, Base58 format).
|
||||||
|
4. Set `STANDX_SYMBOL` (defaults to `BTC-USD`) and align `PRICE_TICK` / `QTY_STEP`.
|
||||||
|
5. Recommended: configure token expiry settings:
|
||||||
|
- `STANDX_TOKEN_CREATE_DATE` (creation date, format `YYYY-MM-DD`)
|
||||||
|
- `STANDX_TOKEN_VALIDITY_DAYS` (validity days)
|
||||||
|
6. Optional: `STANDX_BASE_URL`, `STANDX_WS_URL`, or `STANDX_SESSION_ID` for custom endpoints.
|
||||||
|
|
||||||
### GRVT
|
### GRVT
|
||||||
1. Set `EXCHANGE=grvt` inside `.env`.
|
1. Set `EXCHANGE=grvt` inside `.env`.
|
||||||
2. Fill `GRVT_API_KEY`, `GRVT_API_SECRET`, and `GRVT_SUB_ACCOUNT_ID`.
|
2. Fill `GRVT_API_KEY`, `GRVT_API_SECRET`, and `GRVT_SUB_ACCOUNT_ID`.
|
||||||
@@ -108,25 +145,25 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
|||||||
|
|
||||||
### Lighter
|
### Lighter
|
||||||
1. Set `EXCHANGE=lighter`.
|
1. Set `EXCHANGE=lighter`.
|
||||||
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key).
|
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key). `LIGHTER_ACCOUNT_INDEX` is your account index, which you can find by opening DevTools (F12) on the official website and observing API requests. `LIGHTER_API_PRIVATE_KEY` is your API private key.
|
||||||
3. Switch `LIGHTER_ENV` to `mainnet`, `staging`, or `dev` when necessary; override `LIGHTER_BASE_URL` if endpoints differ.
|
3. Switch `LIGHTER_ENV` to `mainnet`, `staging`, or `dev` when necessary; override `LIGHTER_BASE_URL` if endpoints differ.
|
||||||
4. `LIGHTER_SYMBOL` defaults to `BTCUSDT`; override price/size decimals when markets differ.
|
4. `LIGHTER_SYMBOL` defaults to `BTCUSDT`; override price/size decimals when markets differ.
|
||||||
|
|
||||||
### Backpack
|
### Backpack
|
||||||
1. Set `EXCHANGE=backpack`.
|
1. Set `EXCHANGE=backpack`.
|
||||||
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount.
|
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount (defaults to main account ID).
|
||||||
3. Toggle `BACKPACK_SANDBOX=true` for the sandbox environment and verify `BACKPACK_SYMBOL` matches the contract (defaults to `BTC_USD_PERP`).
|
3. Toggle `BACKPACK_SANDBOX=true` for the sandbox environment and verify `BACKPACK_SYMBOL` matches the contract (defaults to `BTC_USD_PERP`).
|
||||||
4. Enable `BACKPACK_DEBUG=true` for verbose adapter logging.
|
4. Enable `BACKPACK_DEBUG=true` for verbose adapter logging.
|
||||||
|
|
||||||
### Paradex
|
### Paradex
|
||||||
1. Set `EXCHANGE=paradex`.
|
1. Set `EXCHANGE=paradex`.
|
||||||
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`.
|
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`. Note: These are your EVM wallet address and private key. It is recommended to create a brand new wallet and avoid storing unrelated assets in it.
|
||||||
3. The adapter connects to mainnet by default; enable `PARADEX_SANDBOX=true` and adjust `PARADEX_SYMBOL` for testnet usage.
|
3. The adapter connects to mainnet by default; enable `PARADEX_SANDBOX=true` and adjust `PARADEX_SYMBOL` for testnet usage.
|
||||||
4. Advanced tuning: use `PARADEX_USE_PRO`, `PARADEX_RECONNECT_DELAY_MS`, or debug flags as needed.
|
4. Advanced tuning: use `PARADEX_USE_PRO`, `PARADEX_RECONNECT_DELAY_MS`, or debug flags as needed.
|
||||||
|
|
||||||
### Nado
|
### Nado
|
||||||
1. Set `EXCHANGE=nado`.
|
1. Set `EXCHANGE=nado`.
|
||||||
2. On the Nado web app, open DevTools → switch to the `Application` tab → `Local Storage`, locate `nado.userSettings`, then grab the `privateKey` field from its JSON value and paste it into `.env` as `NADO_SIGNER_PRIVATE_KEY`.
|
2. On the Nado web app (trading interface), open DevTools (F12) -> switch to the `Application` tab -> `Local Storage`, locate `nado.userSettings`, then grab the `privateKey` field from its JSON value and paste it into `.env` as `NADO_SIGNER_PRIVATE_KEY`.
|
||||||
3. Provide `NADO_SUBACCOUNT_OWNER` (or `NADO_EVM_ADDRESS`).
|
3. Provide `NADO_SUBACCOUNT_OWNER` (or `NADO_EVM_ADDRESS`).
|
||||||
4. Select network via `NADO_ENV=inkMainnet` (mainnet) or `inkTestnet` (testnet).
|
4. Select network via `NADO_ENV=inkMainnet` (mainnet) or `inkTestnet` (testnet).
|
||||||
5. Set `NADO_SYMBOL` using Nado product symbols like `BTC-PERP` (it also accepts `BTCUSDT0` and maps it to `BTC-PERP`).
|
5. Set `NADO_SYMBOL` using Nado product symbols like `BTC-PERP` (it also accepts `BTCUSDT0` and maps it to `BTC-PERP`).
|
||||||
@@ -178,7 +215,7 @@ bun x vitest --watch
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
- Keep at least 50–100 USDT in the account before deploying a live strategy.
|
- Keep at least 50-100 USDT in the account before deploying a live strategy.
|
||||||
- Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
|
- Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
|
||||||
- Ensure your server or workstation clock is in sync to avoid signature errors.
|
- Ensure your server or workstation clock is in sync to avoid signature errors.
|
||||||
- Accounts must run in one-way position mode.
|
- Accounts must run in one-way position mode.
|
||||||
|
|||||||
@@ -0,0 +1,717 @@
|
|||||||
|
## StandX Perps Authentication
|
||||||
|
|
||||||
|
官网创建的 API 提供了
|
||||||
|
|
||||||
|
API Token 以及 Ed25519 Private Key,用于签名交易。
|
||||||
|
|
||||||
|
⚠️ This document is under construction.
|
||||||
|
|
||||||
|
This document explains how to obtain JWT access tokens for the StandX Perps API through wallet signatures.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Valid wallet address and corresponding private key
|
||||||
|
- Development environment with `ed25519` algorithm support
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
### 1\. Prepare Wallet and Temporary ed25519 Key Pair
|
||||||
|
|
||||||
|
1. **Prepare Wallet**: Ensure you have a blockchain wallet with its address and private key.
|
||||||
|
2. **Generate Temporary ed25519 Key Pair and `requestId`**
|
||||||
|
|
||||||
|
### 2\. Get Signature Data
|
||||||
|
|
||||||
|
Request signature data from the server:
|
||||||
|
|
||||||
|
> **Note**: Code examples provided below are for reference purposes only and demonstrate the general implementation approach. Adapt them to your specific production environment.
|
||||||
|
|
||||||
|
#### Using curl
|
||||||
|
|
||||||
|
```
|
||||||
|
curl 'https://api.standx.com/v1/offchain/prepare-signin?chain=<chain>' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data-raw '{
|
||||||
|
"address": "<your_wallet_address>",
|
||||||
|
"requestId": "<base58_encoded_public_key>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TypeScript/ES6 Implementation Reference
|
||||||
|
|
||||||
|
#### Request Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| chain | string | Yes | Blockchain network: `bsc` or `solana` |
|
||||||
|
| address | string | Yes | Wallet address |
|
||||||
|
| requestId | string | Yes | Base58-encoded ed25519 public key from step 1 |
|
||||||
|
|
||||||
|
#### Success Response
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"signedData": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3\. Parse and Verify Signature Data
|
||||||
|
|
||||||
|
`signedData` is a JWT string that must be verified using StandX’s public key.
|
||||||
|
|
||||||
|
#### Get Verification Public Key
|
||||||
|
|
||||||
|
```
|
||||||
|
# Using curl
|
||||||
|
curl 'https://api.standx.com/v1/offchain/certs'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example signedData Payload
|
||||||
|
|
||||||
|
### 4\. Sign the Message
|
||||||
|
|
||||||
|
Sign `payload.message` with your wallet private key to generate the `signature`.
|
||||||
|
|
||||||
|
#### BSC (EVM) Implementation Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
import { ethers } from "ethers";
|
||||||
|
|
||||||
|
const provider = new ethers.JsonRpcProvider(
|
||||||
|
"https://bsc-dataseed.binance.org/"
|
||||||
|
);
|
||||||
|
const privateKey = "<your_wallet_private_key>"; // Keep secure; use environment variables
|
||||||
|
const wallet = new ethers.Wallet(privateKey, provider);
|
||||||
|
|
||||||
|
// Sign using the message from the parsed payload
|
||||||
|
const signature = await wallet.signMessage(payload.message);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solana Implementation Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
import bs58 from "bs58";
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { Keypair } from "@solana/web3.js";
|
||||||
|
|
||||||
|
const privateKey = "<your_base58_encoded_private_key>"; // Keep secure; use environment variables
|
||||||
|
const walletKeypair = Keypair.fromSecretKey(bs58.decode(privateKey));
|
||||||
|
|
||||||
|
// Sign using the message from the parsed payload
|
||||||
|
const messageBytes = new TextEncoder().encode(payload.message);
|
||||||
|
const signatureBytes = ed25519.sign(
|
||||||
|
messageBytes,
|
||||||
|
walletKeypair.secretKey.slice(0, 32) // First 32 bytes are the private key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solana requires a specific signature format
|
||||||
|
const signature = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
input: payload,
|
||||||
|
output: {
|
||||||
|
signedMessage: Array.from(messageBytes),
|
||||||
|
signature: Array.from(signatureBytes),
|
||||||
|
account: {
|
||||||
|
publicKey: Array.from(walletKeypair.publicKey.toBytes()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toString("base64");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5\. Get Access Token
|
||||||
|
|
||||||
|
Submit the `signature` and original `signedData` to the login endpoint.
|
||||||
|
|
||||||
|
**Optional Parameter:**
|
||||||
|
|
||||||
|
- `expiresSeconds` (number): Token expiration time in seconds. Defaults to `604800` (7 days) if not specified. This controls how long the JWT access token remains valid before requiring re-authentication.
|
||||||
|
|
||||||
|
> **Security Note**: For security best practices, avoid setting excessively long expiration times. Shorter token lifetimes reduce the risk of unauthorized access if a token is compromised. Consider your security requirements when configuring this value.
|
||||||
|
|
||||||
|
#### Using curl
|
||||||
|
|
||||||
|
#### TypeScript/ES6 Implementation Reference
|
||||||
|
|
||||||
|
#### Success Response
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"address": "0x...",
|
||||||
|
"alias": "user123",
|
||||||
|
"chain": "bsc",
|
||||||
|
"perpsAlpha": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6\. Use Access Token
|
||||||
|
|
||||||
|
Use the obtained `token` for subsequent API requests by adding `Authorization: Bearer <token>` to the request headers.
|
||||||
|
|
||||||
|
## Body Signature Flow
|
||||||
|
|
||||||
|
### Basic Flow
|
||||||
|
|
||||||
|
1. Prepare a key pair
|
||||||
|
2. Build message: `{version},{id},{timestamp},{payload}`
|
||||||
|
3. Sign with private key
|
||||||
|
4. Base64 encode signature
|
||||||
|
5. Attach signature to request headers
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
...
|
||||||
|
"authorization": "Bearer <token>",
|
||||||
|
"x-request-sign-version": "v1",
|
||||||
|
"x-request-id": "uuid",
|
||||||
|
"x-request-timestamp": "timestamp",
|
||||||
|
"x-request-signature": "signature",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code example (only for reference):
|
||||||
|
|
||||||
|
```
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { base58 } from "@scure/base";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign request and return Base64-encoded signature.
|
||||||
|
*/
|
||||||
|
function encodeRequestSignature(
|
||||||
|
xRequestVersion: string,
|
||||||
|
xRequestId: string,
|
||||||
|
xRequestTimestamp: number,
|
||||||
|
payload: string,
|
||||||
|
signingKey: Uint8Array
|
||||||
|
): string {
|
||||||
|
// Build message to sign: "{version},{id},{timestamp},{payload}"
|
||||||
|
const signMsg = \`${xRequestVersion},${xRequestId},${xRequestTimestamp},${payload}\`;
|
||||||
|
|
||||||
|
// Sign message with Ed25519 private key
|
||||||
|
const messageBytes = Buffer.from(signMsg, "utf-8");
|
||||||
|
const signature = ed25519.sign(messageBytes, signingKey);
|
||||||
|
|
||||||
|
// Base64 encode the signature
|
||||||
|
return Buffer.from(signature).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Example Usage ---
|
||||||
|
|
||||||
|
// Generate Ed25519 key pair
|
||||||
|
const privateKey = ed25519.utils.randomSecretKey();
|
||||||
|
const publicKey = ed25519.getPublicKey(privateKey);
|
||||||
|
|
||||||
|
// Generate requestId (base58-encoded public key)
|
||||||
|
const requestId = base58.encode(publicKey);
|
||||||
|
|
||||||
|
// Prepare request parameters
|
||||||
|
const xRequestVersion = "v1";
|
||||||
|
const xRequestId = uuidv4();
|
||||||
|
const xRequestTimestamp = Date.now();
|
||||||
|
|
||||||
|
const payloadDict = {
|
||||||
|
user_id: 12345,
|
||||||
|
data: "some important information",
|
||||||
|
};
|
||||||
|
const payloadStr = JSON.stringify(payloadDict);
|
||||||
|
|
||||||
|
// Generate signature
|
||||||
|
const signature = encodeRequestSignature(
|
||||||
|
xRequestVersion,
|
||||||
|
xRequestId,
|
||||||
|
xRequestTimestamp,
|
||||||
|
payloadStr,
|
||||||
|
privateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify signature (optional)
|
||||||
|
try {
|
||||||
|
const verifyMsg = \`v1,${xRequestId},${xRequestTimestamp},${payloadStr}\`;
|
||||||
|
const signatureBytes = Buffer.from(signature, "base64");
|
||||||
|
const messageBytes = Buffer.from(verifyMsg, "utf-8");
|
||||||
|
|
||||||
|
const isValid = ed25519.verify(signatureBytes, messageBytes, publicKey);
|
||||||
|
if (!isValid) throw new Error("Verification failed");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Signature verification error:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Request with Body Signature
|
||||||
|
fetch("/api/request_need_body_signature", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
authorization: \`Bearer ${token}\`,
|
||||||
|
"x-request-sign-version": "v1",
|
||||||
|
"x-request-id": xRequestId,
|
||||||
|
"x-request-timestamp": xRequestTimestamp.toString(),
|
||||||
|
"x-request-signature": signature,
|
||||||
|
},
|
||||||
|
body: payloadStr,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Authentication Examples
|
||||||
|
|
||||||
|
For complete, runnable implementations, see the chain-specific examples:
|
||||||
|
|
||||||
|
- [EVM (BSC) Example](https://docs.standx.com/standx-api/perps-auth-evm-example) - Authentication using ethers.js for BSC and other EVM-compatible chains
|
||||||
|
- [Solana (SVM) Example](https://docs.standx.com/standx-api/perps-auth-svm-example) - Authentication using @solana/web3.js for Solana
|
||||||
|
|
||||||
|
Last updated on
|
||||||
|
|
||||||
|
[About StandX API](https://docs.standx.com/standx-api/standx-api "About StandX API") [Perps Auth EVM Example](https://docs.standx.com/standx-api/perps-auth-evm-example "Perps Auth EVM Example")
|
||||||
|
|
||||||
|
## StandX Perps Authentication - EVM Example
|
||||||
|
|
||||||
|
This example demonstrates how to authenticate with the StandX Perps API using an EVM-compatible wallet (e.g., BSC).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js environment with TypeScript support
|
||||||
|
- EVM wallet with private key
|
||||||
|
- Required packages:
|
||||||
|
```
|
||||||
|
npm install @noble/curves @scure/base ethers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Implementation
|
||||||
|
|
||||||
|
```
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { base58 } from "@scure/base";
|
||||||
|
import { ethers } from "ethers";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type Chain = "bsc" | "solana";
|
||||||
|
|
||||||
|
export interface SignedData {
|
||||||
|
domain: string;
|
||||||
|
uri: string;
|
||||||
|
statement: string;
|
||||||
|
version: string;
|
||||||
|
chainId: number;
|
||||||
|
nonce: string;
|
||||||
|
address: string;
|
||||||
|
requestId: string;
|
||||||
|
issuedAt: string;
|
||||||
|
message: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
address: string;
|
||||||
|
alias: string;
|
||||||
|
chain: string;
|
||||||
|
perpsAlpha: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestSignatureHeaders {
|
||||||
|
"x-request-sign-version": string;
|
||||||
|
"x-request-id": string;
|
||||||
|
"x-request-timestamp": string;
|
||||||
|
"x-request-signature": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication Class
|
||||||
|
export class StandXAuth {
|
||||||
|
private ed25519PrivateKey: Uint8Array;
|
||||||
|
private ed25519PublicKey: Uint8Array;
|
||||||
|
private requestId: string;
|
||||||
|
private baseUrl = "https://api.standx.com";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const privateKey = ed25519.utils.randomSecretKey();
|
||||||
|
this.ed25519PrivateKey = privateKey;
|
||||||
|
this.ed25519PublicKey = ed25519.getPublicKey(privateKey);
|
||||||
|
this.requestId = base58.encode(this.ed25519PublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(
|
||||||
|
chain: Chain,
|
||||||
|
walletAddress: string,
|
||||||
|
signMessage: (msg: string) => Promise<string>
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const signedDataJwt = await this.prepareSignIn(chain, walletAddress);
|
||||||
|
const payload = this.parseJwt<SignedData>(signedDataJwt);
|
||||||
|
const signature = await signMessage(payload.message);
|
||||||
|
return this.login(chain, signature, signedDataJwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareSignIn(chain: Chain, address: string): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
\`${this.baseUrl}/v1/offchain/prepare-signin?chain=${chain}\`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ address, requestId: this.requestId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error("Failed to prepare sign-in");
|
||||||
|
return data.signedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(
|
||||||
|
chain: Chain,
|
||||||
|
signature: string,
|
||||||
|
signedData: string,
|
||||||
|
expiresSeconds: number = 604800 // default: 7 days
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const res = await fetch(
|
||||||
|
\`${this.baseUrl}/v1/offchain/login?chain=${chain}\`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ signature, signedData, expiresSeconds }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
signRequest(
|
||||||
|
payload: string,
|
||||||
|
requestId: string,
|
||||||
|
timestamp: number
|
||||||
|
): RequestSignatureHeaders {
|
||||||
|
const version = "v1";
|
||||||
|
const message = \`${version},${requestId},${timestamp},${payload}\`;
|
||||||
|
const signature = ed25519.sign(
|
||||||
|
Buffer.from(message, "utf-8"),
|
||||||
|
this.ed25519PrivateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"x-request-sign-version": version,
|
||||||
|
"x-request-id": requestId,
|
||||||
|
"x-request-timestamp": timestamp.toString(),
|
||||||
|
"x-request-signature": Buffer.from(signature).toString("base64"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseJwt<T>(token: string): T {
|
||||||
|
const base64Url = token.split(".")[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
return JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage Example
|
||||||
|
async function main() {
|
||||||
|
// Initialize auth
|
||||||
|
const auth = new StandXAuth();
|
||||||
|
|
||||||
|
// Setup wallet
|
||||||
|
const provider = new ethers.JsonRpcProvider(
|
||||||
|
"https://bsc-dataseed.binance.org/"
|
||||||
|
);
|
||||||
|
const privateKey = process.env.WALLET_PRIVATE_KEY!;
|
||||||
|
const wallet = new ethers.Wallet(privateKey, provider);
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
const loginResponse = await auth.authenticate(
|
||||||
|
"bsc",
|
||||||
|
wallet.address,
|
||||||
|
async (message) => wallet.signMessage(message)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Access Token:", loginResponse.token);
|
||||||
|
|
||||||
|
// Sign a request
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
side: "buy",
|
||||||
|
order_type: "limit",
|
||||||
|
qty: "0.1",
|
||||||
|
price: "50000",
|
||||||
|
time_in_force: "gtc",
|
||||||
|
reduce_only: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = auth.signRequest(payload, crypto.randomUUID(), Date.now());
|
||||||
|
|
||||||
|
// Make authenticated request
|
||||||
|
await fetch("https://perps.standx.com/api/new_order", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: \`Bearer ${loginResponse.token}\`,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
1. **Wallet Setup**: Uses `ethers.js` to create a wallet from a private key
|
||||||
|
2. **Message Signing**: EVM wallets sign the message directly using `wallet.signMessage()`
|
||||||
|
3. **Signature Format**: The signature is returned as-is from the wallet (hex string)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file with:
|
||||||
|
|
||||||
|
```
|
||||||
|
WALLET_PRIVATE_KEY=your_private_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Security Note**: Never commit private keys to version control. Use environment variables or secure key management solutions.
|
||||||
|
|
||||||
|
[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth") [Perps Auth SVM Example](https://docs.standx.com/standx-api/perps-auth-svm-example "Perps Auth SVM Example")
|
||||||
|
|
||||||
|
## StandX Perps Authentication - Solana (SVM) Example
|
||||||
|
|
||||||
|
This example demonstrates how to authenticate with the StandX Perps API using a Solana wallet.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js environment with TypeScript support
|
||||||
|
- Solana wallet with private key (base58-encoded)
|
||||||
|
- Required packages:
|
||||||
|
```
|
||||||
|
npm install @noble/curves @scure/base @solana/web3.js bs58
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Implementation
|
||||||
|
|
||||||
|
```
|
||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import { base58 } from "@scure/base";
|
||||||
|
import bs58 from "bs58";
|
||||||
|
import { Keypair } from "@solana/web3.js";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type Chain = "bsc" | "solana";
|
||||||
|
|
||||||
|
export interface SignedData {
|
||||||
|
domain: string;
|
||||||
|
uri: string;
|
||||||
|
statement: string;
|
||||||
|
version: string;
|
||||||
|
chainId: number;
|
||||||
|
nonce: string;
|
||||||
|
address: string;
|
||||||
|
requestId: string;
|
||||||
|
issuedAt: string;
|
||||||
|
message: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
address: string;
|
||||||
|
alias: string;
|
||||||
|
chain: string;
|
||||||
|
perpsAlpha: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestSignatureHeaders {
|
||||||
|
"x-request-sign-version": string;
|
||||||
|
"x-request-id": string;
|
||||||
|
"x-request-timestamp": string;
|
||||||
|
"x-request-signature": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication Class
|
||||||
|
export class StandXAuth {
|
||||||
|
private ed25519PrivateKey: Uint8Array;
|
||||||
|
private ed25519PublicKey: Uint8Array;
|
||||||
|
private requestId: string;
|
||||||
|
private baseUrl = "https://api.standx.com";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const privateKey = ed25519.utils.randomSecretKey();
|
||||||
|
this.ed25519PrivateKey = privateKey;
|
||||||
|
this.ed25519PublicKey = ed25519.getPublicKey(privateKey);
|
||||||
|
this.requestId = base58.encode(this.ed25519PublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async authenticate(
|
||||||
|
chain: Chain,
|
||||||
|
walletAddress: string,
|
||||||
|
signMessage: (msg: string, payload: SignedData) => Promise<string>
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const signedDataJwt = await this.prepareSignIn(chain, walletAddress);
|
||||||
|
const payload = this.parseJwt<SignedData>(signedDataJwt);
|
||||||
|
const signature = await signMessage(payload.message, payload);
|
||||||
|
return this.login(chain, signature, signedDataJwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareSignIn(chain: Chain, address: string): Promise<string> {
|
||||||
|
const res = await fetch(
|
||||||
|
\`${this.baseUrl}/v1/offchain/prepare-signin?chain=${chain}\`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ address, requestId: this.requestId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error("Failed to prepare sign-in");
|
||||||
|
return data.signedData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(
|
||||||
|
chain: Chain,
|
||||||
|
signature: string,
|
||||||
|
signedData: string,
|
||||||
|
expiresSeconds: number = 604800 // default: 7 days
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
const res = await fetch(
|
||||||
|
\`${this.baseUrl}/v1/offchain/login?chain=${chain}\`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ signature, signedData, expiresSeconds }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
signRequest(
|
||||||
|
payload: string,
|
||||||
|
requestId: string,
|
||||||
|
timestamp: number
|
||||||
|
): RequestSignatureHeaders {
|
||||||
|
const version = "v1";
|
||||||
|
const message = \`${version},${requestId},${timestamp},${payload}\`;
|
||||||
|
const signature = ed25519.sign(
|
||||||
|
Buffer.from(message, "utf-8"),
|
||||||
|
this.ed25519PrivateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"x-request-sign-version": version,
|
||||||
|
"x-request-id": requestId,
|
||||||
|
"x-request-timestamp": timestamp.toString(),
|
||||||
|
"x-request-signature": Buffer.from(signature).toString("base64"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseJwt<T>(token: string): T {
|
||||||
|
const base64Url = token.split(".")[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
return JSON.parse(Buffer.from(base64, "base64").toString("utf-8"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage Example
|
||||||
|
async function main() {
|
||||||
|
// Initialize auth
|
||||||
|
const auth = new StandXAuth();
|
||||||
|
|
||||||
|
// Setup wallet from base58-encoded private key
|
||||||
|
const privateKey = process.env.SOLANA_PRIVATE_KEY!;
|
||||||
|
const walletKeypair = Keypair.fromSecretKey(bs58.decode(privateKey));
|
||||||
|
const walletAddress = walletKeypair.publicKey.toBase58();
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
const loginResponse = await auth.authenticate(
|
||||||
|
"solana",
|
||||||
|
walletAddress,
|
||||||
|
async (message, payload) => {
|
||||||
|
const messageBytes = new TextEncoder().encode(message);
|
||||||
|
const signatureBytes = ed25519.sign(
|
||||||
|
messageBytes,
|
||||||
|
walletKeypair.secretKey.slice(0, 32) // First 32 bytes are the private key
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solana requires a specific signature format
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
input: payload,
|
||||||
|
output: {
|
||||||
|
signedMessage: Array.from(messageBytes),
|
||||||
|
signature: Array.from(signatureBytes),
|
||||||
|
account: {
|
||||||
|
publicKey: Array.from(walletKeypair.publicKey.toBytes()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toString("base64");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Access Token:", loginResponse.token);
|
||||||
|
|
||||||
|
// Sign a request
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
symbol: "BTC-USD",
|
||||||
|
side: "buy",
|
||||||
|
order_type: "limit",
|
||||||
|
qty: "0.1",
|
||||||
|
price: "50000",
|
||||||
|
time_in_force: "gtc",
|
||||||
|
reduce_only: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = auth.signRequest(payload, crypto.randomUUID(), Date.now());
|
||||||
|
|
||||||
|
// Make authenticated request
|
||||||
|
await fetch("https://perps.standx.com/api/new_order", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: \`Bearer ${loginResponse.token}\`,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Points
|
||||||
|
|
||||||
|
1. **Wallet Setup**: Uses `@solana/web3.js` Keypair with a base58-encoded private key
|
||||||
|
2. **Message Signing**: Uses `@noble/curves/ed25519` for Ed25519 signing with `walletKeypair.secretKey.slice(0, 32)` (first 32 bytes are the private key)
|
||||||
|
3. **Signature Format**: Solana requires a specific JSON structure containing:
|
||||||
|
- `input`: The original payload from the server
|
||||||
|
- `output.signedMessage`: The message bytes as an array
|
||||||
|
- `output.signature`: The signature bytes as an array
|
||||||
|
- `output.account.publicKey`: The wallet’s public key bytes as an array
|
||||||
|
This JSON is then base64-encoded before being sent to the server.
|
||||||
|
|
||||||
|
## Signature Format Explanation
|
||||||
|
|
||||||
|
Unlike EVM wallets that return a simple hex signature, Solana authentication requires a structured response:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
input: payload, // Original SignedData from server
|
||||||
|
output: {
|
||||||
|
signedMessage: [...], // Message bytes as number array
|
||||||
|
signature: [...], // Ed25519 signature bytes as number array
|
||||||
|
account: {
|
||||||
|
publicKey: [...] // Wallet public key bytes as number array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This format allows the server to verify both the signature and the signing account.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file with:
|
||||||
|
|
||||||
|
```
|
||||||
|
SOLANA_PRIVATE_KEY=your_base58_encoded_private_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Security Note**: Never commit private keys to version control. Use environment variables or secure key management solutions.
|
||||||
|
|
||||||
|
[Perps Auth EVM Example](https://docs.standx.com/standx-api/perps-auth-evm-example "Perps Auth EVM Example") [Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API")
|
||||||
@@ -0,0 +1,845 @@
|
|||||||
|
## StandX Perps HTTP API List
|
||||||
|
|
||||||
|
⚠️ This document is under construction.
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://perps.standx.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
All endpoints except **public endpoints** require JWT authentication. Include the JWT token in the `Authorization` header:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <your_jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token Validity**: 7 days
|
||||||
|
|
||||||
|
#### Body Signature
|
||||||
|
|
||||||
|
Some endpoints require body signature. Add the following headers to signed requests:
|
||||||
|
|
||||||
|
```
|
||||||
|
x-request-sign-version: v1
|
||||||
|
x-request-id: <random_string>
|
||||||
|
x-request-timestamp: <timestamp_in_milliseconds>
|
||||||
|
x-request-signature: <your_body_signature>
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Authentication Guide](https://docs.standx.com/standx-api/perps-auth) for implementation details.
|
||||||
|
|
||||||
|
#### Session ID
|
||||||
|
|
||||||
|
For `new_order` and `cancel_order` requests, you will want to know the results of these requests after actual matching. To obtain these results, you need to add the following information to the header in these interface requests:
|
||||||
|
|
||||||
|
```
|
||||||
|
x-session-id: <your_custom_session_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that this session\_id needs to be consistent with the session\_id used in your ws-client.
|
||||||
|
|
||||||
|
### Request Format
|
||||||
|
|
||||||
|
- **`int` parameters** (e.g., timestamp) are expected as JSON integers, not strings
|
||||||
|
- **`decimal` parameters** (e.g., price) are expected as JSON strings, not floats
|
||||||
|
|
||||||
|
## Trade Endpoints
|
||||||
|
|
||||||
|
### Create New Order
|
||||||
|
|
||||||
|
`POST /api/new_order`
|
||||||
|
|
||||||
|
**Note**: A successful response indicates the order was submitted, not necessarily executed. Some orders (e.g., ALO) may be rejected during matching if conditions are not met. Subscribe to [Order Response Stream](https://docs.standx.com/standx-api/perps-ws#order-response-stream) for real-time execution status.
|
||||||
|
|
||||||
|
To receive order updates via [Order Response Stream](https://docs.standx.com/standx-api/perps-ws#order-response-stream), add the `x-session-id` header to your request. This session\_id must be consistent with the session\_id used in your ws-client.
|
||||||
|
|
||||||
|
**Authentication Required** • **Body Signature Required**
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| side | enum | Order side (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| order\_type | enum | Order type (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| qty | decimal | Order quantity |
|
||||||
|
| time\_in\_force | enum | Time in force (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| reduce\_only | boolean | Only reduce position if `true` |
|
||||||
|
|
||||||
|
**Optional Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| price | decimal | Order price (required for limit orders) |
|
||||||
|
| cl\_ord\_id | string | Client order ID (auto-generated if omitted) |
|
||||||
|
| margin\_mode | enum | Margin mode (see [Reference](https://docs.standx.com/standx-api/perps-reference)). Must match position |
|
||||||
|
| leverage | int | Leverage value. Must match position |
|
||||||
|
|
||||||
|
**Request Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"side": "buy",
|
||||||
|
"order_type": "limit",
|
||||||
|
"qty": "0.1",
|
||||||
|
"price": "50000",
|
||||||
|
"time_in_force": "gtc",
|
||||||
|
"reduce_only": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "xxx-xxx-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancel Order
|
||||||
|
|
||||||
|
`POST /api/cancel_order`
|
||||||
|
|
||||||
|
To receive order updates via [Order Response Stream](https://docs.standx.com/standx-api/perps-ws#order-response-stream), add the `x-session-id` header to your request. This session\_id must be consistent with the session\_id used in your ws-client.
|
||||||
|
|
||||||
|
**Authentication Required** • **Body Signature Required**
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
> At least one of `order_id` or `cl_ord_id` is required.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| order\_id | int | Order ID to cancel |
|
||||||
|
| cl\_ord\_id | string | Client order ID to cancel |
|
||||||
|
|
||||||
|
**Request Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"order_id": 2424844
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "xxx-xxx-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancel Multiple Orders
|
||||||
|
|
||||||
|
`POST /api/cancel_orders`
|
||||||
|
|
||||||
|
**Authentication Required** • **Body Signature Required**
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
> At least one of `order_id_list` or `cl_ord_id_list` is required.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| order\_id\_list | int\[\] | Order IDs to cancel |
|
||||||
|
| cl\_ord\_id\_list | string\[\] | Client order IDs to cancel |
|
||||||
|
|
||||||
|
**Request Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"order_id_list": [2424844]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change Leverage
|
||||||
|
|
||||||
|
`POST /api/change_leverage`
|
||||||
|
|
||||||
|
**Authentication Required** • **Body Signature Required**
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| leverage | int | New leverage value |
|
||||||
|
|
||||||
|
**Request Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"leverage": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "xxx-xxx-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Change Margin Mode
|
||||||
|
|
||||||
|
`POST /api/change_margin_mode`
|
||||||
|
|
||||||
|
**Authentication Required** • **Body Signature Required**
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| margin\_mode | enum | Margin mode (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Request Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"margin_mode": "cross"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "xxx-xxx-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Endpoints
|
||||||
|
|
||||||
|
### Transfer Margin
|
||||||
|
|
||||||
|
`POST /api/transfer_margin`
|
||||||
|
|
||||||
|
**Authentication Required** • **Body Signature Required**
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| amount\_in | decimal | Amount to transfer |
|
||||||
|
|
||||||
|
**Request Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"amount_in": "1000.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "xxx-xxx-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Order
|
||||||
|
|
||||||
|
`GET /api/query_order`
|
||||||
|
|
||||||
|
**⚠️ NOTE**: Orders may be rejected due mis-qualification due async matching network structure. To receive the order updates in real-time, please check [Order Response Stream](https://docs.standx.com/standx-api/perps-ws#order-response-stream).
|
||||||
|
|
||||||
|
**Authentication Required**
|
||||||
|
|
||||||
|
**Query Parameters**
|
||||||
|
|
||||||
|
> At least one of `order_id` or `cl_ord_id` is required.
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| order\_id | int | Order ID to query |
|
||||||
|
| cl\_ord\_id | string | Client order ID to query |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"avail_locked": "3.071880000",
|
||||||
|
"cl_ord_id": "01K2BK4ZKQE0C308SRD39P8N9Z",
|
||||||
|
"closed_block": -1,
|
||||||
|
"created_at": "2025-08-11T03:35:25.559151Z",
|
||||||
|
"created_block": -1,
|
||||||
|
"fill_avg_price": "0",
|
||||||
|
"fill_qty": "0",
|
||||||
|
"id": 1820682,
|
||||||
|
"leverage": "10",
|
||||||
|
"liq_id": 0,
|
||||||
|
"margin": "0",
|
||||||
|
"order_type": "limit",
|
||||||
|
"payload": null,
|
||||||
|
"position_id": 15,
|
||||||
|
"price": "121900.00",
|
||||||
|
"qty": "0.060",
|
||||||
|
"reduce_only": false,
|
||||||
|
"remark": "",
|
||||||
|
"side": "sell",
|
||||||
|
"source": "user",
|
||||||
|
"status": "open",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time_in_force": "gtc",
|
||||||
|
"updated_at": "2025-08-11T03:35:25.559151Z",
|
||||||
|
"user": "bsc_0x..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query User Orders
|
||||||
|
|
||||||
|
`GET /api/query_orders`
|
||||||
|
|
||||||
|
**Authentication Required**
|
||||||
|
|
||||||
|
**Query Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| status | enum | Order status (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| order\_type | enum | Order type (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| start | string | Start time in ISO 8601 format |
|
||||||
|
| end | string | End time in ISO 8601 format |
|
||||||
|
| last\_id | number | Last order ID for pagination |
|
||||||
|
| limit | number | Results limit (default: 100, max: 500) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"page_size": 1,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"avail_locked": "3.071880000",
|
||||||
|
"cl_ord_id": "01K2BK4ZKQE0C308SRD39P8N9Z",
|
||||||
|
"closed_block": -1,
|
||||||
|
"created_at": "2025-08-11T03:35:25.559151Z",
|
||||||
|
"created_block": -1,
|
||||||
|
"fill_avg_price": "0",
|
||||||
|
"fill_qty": "0",
|
||||||
|
"id": 1820682,
|
||||||
|
"leverage": "10",
|
||||||
|
"liq_id": 0,
|
||||||
|
"margin": "0",
|
||||||
|
"order_type": "limit",
|
||||||
|
"payload": null,
|
||||||
|
"position_id": 15,
|
||||||
|
"price": "121900.00",
|
||||||
|
"qty": "0.060",
|
||||||
|
"reduce_only": false,
|
||||||
|
"remark": "",
|
||||||
|
"side": "sell",
|
||||||
|
"source": "user",
|
||||||
|
"status": "new",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time_in_force": "gtc",
|
||||||
|
"updated_at": "2025-08-11T03:35:25.559151Z",
|
||||||
|
"user": "bsc_0x..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query User All Open Orders
|
||||||
|
|
||||||
|
`GET /api/query_open_orders`
|
||||||
|
|
||||||
|
**Authentication Required**
|
||||||
|
|
||||||
|
**Query Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| limit | number | Results limit (default: 500, max: 1200) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"page_size": 1,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"avail_locked": "3.071880000",
|
||||||
|
"cl_ord_id": "01K2BK4ZKQE0C308SRD39P8N9Z",
|
||||||
|
"closed_block": -1,
|
||||||
|
"created_at": "2025-08-11T03:35:25.559151Z",
|
||||||
|
"created_block": -1,
|
||||||
|
"fill_avg_price": "0",
|
||||||
|
"fill_qty": "0",
|
||||||
|
"id": 1820682,
|
||||||
|
"leverage": "10",
|
||||||
|
"liq_id": 0,
|
||||||
|
"margin": "0",
|
||||||
|
"order_type": "limit",
|
||||||
|
"payload": null,
|
||||||
|
"position_id": 15,
|
||||||
|
"price": "121900.00",
|
||||||
|
"qty": "0.060",
|
||||||
|
"reduce_only": false,
|
||||||
|
"remark": "",
|
||||||
|
"side": "sell",
|
||||||
|
"source": "user",
|
||||||
|
"status": "new",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time_in_force": "gtc",
|
||||||
|
"updated_at": "2025-08-11T03:35:25.559151Z",
|
||||||
|
"user": "bsc_0x..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query User Trades
|
||||||
|
|
||||||
|
`GET /api/query_trades`
|
||||||
|
|
||||||
|
**Authentication Required**
|
||||||
|
|
||||||
|
**Query Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| last\_id | number | Last trade ID for pagination |
|
||||||
|
| side | string | Order side (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| start | string | Start time in ISO 8601 format |
|
||||||
|
| end | string | End time in ISO 8601 format |
|
||||||
|
| limit | number | Results limit (default: 100, max: 500) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"page_size": 1,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"created_at": "2025-08-11T03:36:19.352620Z",
|
||||||
|
"fee_asset": "DUSD",
|
||||||
|
"fee_qty": "0.121900",
|
||||||
|
"id": 409870,
|
||||||
|
"order_id": 1820682,
|
||||||
|
"pnl": "1.62040",
|
||||||
|
"price": "121900",
|
||||||
|
"qty": "0.01",
|
||||||
|
"side": "sell",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"updated_at": "2025-08-11T03:36:19.352620Z",
|
||||||
|
"user": "bsc_0x...",
|
||||||
|
"value": "1219.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Position Config
|
||||||
|
|
||||||
|
`GET /api/query_position_config`
|
||||||
|
|
||||||
|
**Authentication Required**
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"leverage": 10,
|
||||||
|
"margin_mode": "cross"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query User Positions
|
||||||
|
|
||||||
|
`GET /api/query_positions`
|
||||||
|
|
||||||
|
**Authentication Required**
|
||||||
|
|
||||||
|
**Query Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"bankruptcy_price": "109608.01",
|
||||||
|
"created_at": "2025-08-10T09:05:50.265265Z",
|
||||||
|
"entry_price": "121737.96",
|
||||||
|
"entry_value": "114433.68240",
|
||||||
|
"holding_margin": "11443.3682400",
|
||||||
|
"id": 15,
|
||||||
|
"initial_margin": "11443.36824",
|
||||||
|
"leverage": "10",
|
||||||
|
"liq_price": "112373.50",
|
||||||
|
"maint_margin": "2860.30367500",
|
||||||
|
"margin_asset": "DUSD",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"mark_price": "121715.05",
|
||||||
|
"mmr": "3.993223845366698695025800014",
|
||||||
|
"position_value": "114412.14700",
|
||||||
|
"qty": "0.940",
|
||||||
|
"realized_pnl": "31.61532",
|
||||||
|
"status": "open",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time": "2025-08-11T03:41:40.922818Z",
|
||||||
|
"updated_at": "2025-08-10T09:05:50.265265Z",
|
||||||
|
"upnl": "-21.53540",
|
||||||
|
"user": "bsc_0x..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query User Balances
|
||||||
|
|
||||||
|
- **Endpoint**: `/api/query_balance`
|
||||||
|
- **Method**: `GET`
|
||||||
|
- **Authentication**: Required
|
||||||
|
- **Description**: Unified balance snapshot.
|
||||||
|
- **Response Fields**:
|
||||||
|
| Name | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| isolated\_balance | decimal | Isolated wallet total |
|
||||||
|
| isolated\_upnl | decimal | Isolated unrealized PnL |
|
||||||
|
| cross\_balance | decimal | Cross wallet free balance |
|
||||||
|
| cross\_margin | decimal | Cross margin used (executed positions only) |
|
||||||
|
| cross\_upnl | decimal | Cross unrealized PnL |
|
||||||
|
| locked | decimal | Order lock (margin + fee), already includes safety factor b |
|
||||||
|
| cross\_available | decimal | cross\_balance - cross\_margin - locked + cross\_upnl |
|
||||||
|
| balance | decimal | Total account assets = cross\_balance + isolated\_balance |
|
||||||
|
| upnl | decimal | Total unrealized PnL = cross\_upnl + isolated\_upnl |
|
||||||
|
| equity | decimal | Account equity = balance + upnl |
|
||||||
|
| pnl\_freeze | decimal | 24h realized PnL (for display) |
|
||||||
|
- **Response Example**:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"isolated_balance": "11443.3682400",
|
||||||
|
"isolated_upnl": "-21.53540",
|
||||||
|
"cross_balance": "1088575.259316737",
|
||||||
|
"cross_margin": "2860.30367500",
|
||||||
|
"cross_upnl": "31.61532",
|
||||||
|
"locked": "0.000000000",
|
||||||
|
"cross_available": "1085746.571",
|
||||||
|
"balance": "1100018.627556737",
|
||||||
|
"upnl": "10.07992",
|
||||||
|
"equity": "1100028.707476657",
|
||||||
|
"pnl_freeze": "31.61532"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Notes:
|
||||||
|
>
|
||||||
|
> - `cross_available` may be negative depending on PnL and locks;
|
||||||
|
|
||||||
|
## Public Endpoints
|
||||||
|
|
||||||
|
### Query Symbol Info
|
||||||
|
|
||||||
|
`GET /api/query_symbol_info`
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"base_asset": "BTC",
|
||||||
|
"base_decimals": 9,
|
||||||
|
"created_at": "2025-07-10T05:15:32.089568Z",
|
||||||
|
"def_leverage": "10",
|
||||||
|
"depth_ticks": "0.01,0.1,1",
|
||||||
|
"enabled": true,
|
||||||
|
"maker_fee": "0.0001",
|
||||||
|
"max_leverage": "20",
|
||||||
|
"max_open_orders": "100",
|
||||||
|
"max_order_qty": "100",
|
||||||
|
"max_position_size": "1000",
|
||||||
|
"min_order_qty": "0.001",
|
||||||
|
"price_cap_ratio": "0.3",
|
||||||
|
"price_floor_ratio": "0.3",
|
||||||
|
"price_tick_decimals": 2,
|
||||||
|
"qty_tick_decimals": 3,
|
||||||
|
"quote_asset": "DUSD",
|
||||||
|
"quote_decimals": 9,
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"taker_fee": "0.0004",
|
||||||
|
"updated_at": "2025-07-10T05:15:32.089568Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Symbol Market
|
||||||
|
|
||||||
|
`GET /api/query_symbol_market`
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"base": "BTC",
|
||||||
|
"funding_rate": "0.00010000",
|
||||||
|
"high_price_24h": "122164.08",
|
||||||
|
"index_price": "121601.158461",
|
||||||
|
"last_price": "121599.94",
|
||||||
|
"low_price_24h": "114098.44",
|
||||||
|
"mark_price": "121602.43",
|
||||||
|
"mid_price": "121599.99",
|
||||||
|
"next_funding_time": "2025-08-11T08:00:00Z",
|
||||||
|
"open_interest": "15.948",
|
||||||
|
"quote": "DUSD",
|
||||||
|
"spread": ["121599.94", "121600.04"],
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time": "2025-08-11T03:44:40.922233Z",
|
||||||
|
"volume_24h": "9030.51800000000002509"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Symbol Price
|
||||||
|
|
||||||
|
`GET /api/query_symbol_price`
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"base": "BTC",
|
||||||
|
"index_price": "121601.158461",
|
||||||
|
"last_price": "121599.94",
|
||||||
|
"mark_price": "121602.43",
|
||||||
|
"mid_price": "121599.99",
|
||||||
|
"quote": "DUSD",
|
||||||
|
"spread_ask": "121600.04",
|
||||||
|
"spread_bid": "121599.94",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time": "2025-08-11T03:44:40.922233Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: `last_price`, `mid_price`, `spread_ask`, `spread_bid` may be null if no recent trades.
|
||||||
|
|
||||||
|
### Query Depth Book
|
||||||
|
|
||||||
|
`GET /api/query_depth_book`
|
||||||
|
|
||||||
|
**⚠️ Note: The sequence of price levels in the asks and bids arrays is not guaranteed. Please implement local sorting on the client side based on your specific requirements.**
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"asks": [
|
||||||
|
["121895.81", "0.843"],
|
||||||
|
["121896.11", "0.96"]
|
||||||
|
],
|
||||||
|
"bids": [
|
||||||
|
["121884.01", "0.001"],
|
||||||
|
["121884.31", "0.001"]
|
||||||
|
],
|
||||||
|
"symbol": "BTC-USD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/query_recent_trades`
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"is_buyer_taker": true,
|
||||||
|
"price": "121720.18",
|
||||||
|
"qty": "0.01",
|
||||||
|
"quote_qty": "1217.2018",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time": "2025-08-11T03:48:47.086505Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"is_buyer_taker": true,
|
||||||
|
"price": "121720.18",
|
||||||
|
"qty": "0.01",
|
||||||
|
"quote_qty": "1217.2018",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time": "2025-08-11T03:48:46.850415Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Funding Rates
|
||||||
|
|
||||||
|
`GET /api/query_funding_rates`
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| start\_time | int | Start time in milliseconds |
|
||||||
|
| end\_time | int | End time in milliseconds |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"funding_rate": "0.0001",
|
||||||
|
"index_price": "121601.158461",
|
||||||
|
"mark_price": "121602.43",
|
||||||
|
"premium": "0.0001",
|
||||||
|
"time": "2025-08-11T03:48:47.086505Z",
|
||||||
|
"created_at": "2025-08-11T03:48:47.086505Z",
|
||||||
|
"updated_at": "2025-08-11T03:48:47.086505Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kline Endpoints
|
||||||
|
|
||||||
|
### Get Server Time
|
||||||
|
|
||||||
|
`GET /api/kline/time`
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1620000000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Kline History
|
||||||
|
|
||||||
|
`GET /api/kline/history`
|
||||||
|
|
||||||
|
**Required Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| symbol | string | Trading pair (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
| from | u64 | Unix timestamp in seconds |
|
||||||
|
| to | u64 | Unix timestamp in seconds |
|
||||||
|
| resolution | enum | Resolution (see [Reference](https://docs.standx.com/standx-api/perps-reference)) |
|
||||||
|
|
||||||
|
**Optional Parameters**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| countback | u64 | The required amount of bars to load |
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"s": "ok",
|
||||||
|
"t": [1754897028, 1754897031],
|
||||||
|
"c": [121897.95, 121903.04],
|
||||||
|
"o": [121896.02, 121898.05],
|
||||||
|
"h": [121897.95, 121903.15],
|
||||||
|
"l": [121895.92, 121898.05],
|
||||||
|
"v": [0.09, 10.542]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Check
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
`GET /api/health`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
|
||||||
|
```
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Misc
|
||||||
|
|
||||||
|
### Region and Server Time
|
||||||
|
|
||||||
|
`GET https://geo.standx.com/v1/region`
|
||||||
|
|
||||||
|
**Response Example**:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"systemTime": 1761970177865,
|
||||||
|
"region": "jp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
For enums, constants, and error codes, see [API Reference](https://docs.standx.com/standx-api/perps-reference).
|
||||||
|
|
||||||
|
Last updated on
|
||||||
|
|
||||||
|
[Perps Auth SVM Example](https://docs.standx.com/standx-api/perps-auth-svm-example "Perps Auth SVM Example") [Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## About StandX API
|
||||||
|
|
||||||
|
StandX provides REST and WebSocket APIs for perpetual futures trading. Access real-time market data, manage positions, execute trades, and monitor your portfolio programmatically.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **[Authentication](https://docs.standx.com/standx-api/perps-auth)** - JWT authentication and request signing guide
|
||||||
|
- **[HTTP API](https://docs.standx.com/standx-api/perps-http)** - Complete REST endpoint reference for trading, user data, and market information
|
||||||
|
- **[WebSocket API](https://docs.standx.com/standx-api/perps-ws)** - Real-time data streams and event subscriptions
|
||||||
|
- **[API Reference](https://docs.standx.com/standx-api/perps-reference)** - Enums, constants, and error codes
|
||||||
|
|
||||||
|
Base URL: `https://perps.standx.com`
|
||||||
|
|
||||||
|
Get started by obtaining your JWT token through wallet signature authentication.
|
||||||
|
|
||||||
|
Last updated on
|
||||||
|
|
||||||
|
[Perps Auth](https://docs.standx.com/standx-api/perps-auth "Perps Auth")
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# 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 API Token(最重要的一步)
|
||||||
|
|
||||||
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
>
|
||||||
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
>
|
||||||
|
> ⚠️ **这一步是 90% 新手卡住的地方,请仔细阅读!**
|
||||||
|
|
||||||
|
策略需要两样东西才能帮你下单:
|
||||||
|
1. **TOKEN**(API 令牌)
|
||||||
|
2. **代理钱包私钥**(用于签名交易)
|
||||||
|
|
||||||
|
### 获取步骤(图文说明):
|
||||||
|
|
||||||
|
#### 3.1 打开 StandX 官方 API 创建页面
|
||||||
|
|
||||||
|
在浏览器打开这个网址:
|
||||||
|
```
|
||||||
|
https://standx.com/user/session
|
||||||
|
```
|
||||||
|
|
||||||
|
> **现在可以直接在 StandX 官网创建 API Token 了!**
|
||||||
|
|
||||||
|
#### 3.2 连接你的钱包并登录
|
||||||
|
|
||||||
|
如果还没登录,先连接钱包并登录你的 StandX 账户。
|
||||||
|
|
||||||
|
#### 3.3 生成 API Token
|
||||||
|
|
||||||
|
点击页面上的 **"Generate API Token"** 按钮。
|
||||||
|
|
||||||
|
你会看到类似这样的信息:
|
||||||
|
- **Token**(很长一串以 eyJ 开头的字符串)
|
||||||
|
- **Ed25519 Private Key**(Base58 格式的私钥,类似 `HdsyJD7oWgT756124j3taSPGv...`)
|
||||||
|
- **创建日期**(例如:2026-01-15)
|
||||||
|
- **有效期天数**(例如:30 天)
|
||||||
|
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
>
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
>
|
||||||
|
> 🔴 **请把这些值复制保存下来!**
|
||||||
|
|
||||||
|
### 什么是 Ed25519 Private Key?
|
||||||
|
|
||||||
|
- 这是系统 **自动为你生成** 的一个 Ed25519 签名私钥
|
||||||
|
- 它 **只用于签名交易请求**,不存放你的资金
|
||||||
|
- 你的资产仍然在你自己的钱包里,非常安全
|
||||||
|
- **你不需要手动创建**,生成 API Token 时系统会自动创建
|
||||||
|
- 格式为 Base58 编码(类似 `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第四步:配置环境变量
|
||||||
|
|
||||||
|
在项目根目录创建一个 `.env` 文件(如果已存在就修改它)。
|
||||||
|
|
||||||
|
### 4.1 创建/编辑 .env 文件
|
||||||
|
|
||||||
|
**macOS / Linux:**
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
用记事本打开项目文件夹,新建一个文本文件,命名为 `.env`(注意前面有个点)
|
||||||
|
|
||||||
|
### 4.2 填入以下内容
|
||||||
|
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
>
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
>
|
||||||
|
> ⚠️ **请务必把下面的示例值替换成你自己的!**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ===== 交易所设置 =====
|
||||||
|
EXCHANGE=standx
|
||||||
|
|
||||||
|
# ===== 你的 API 凭证(第三步获取的) =====
|
||||||
|
# 把下面的 "你的TOKEN" 替换成你生成的 Token(很长一串以 eyJ 开头的)
|
||||||
|
STANDX_TOKEN=你的TOKEN
|
||||||
|
|
||||||
|
# 把下面的 "你的私钥" 替换成页面中显示的代理钱包私钥(按页面原样粘贴即可)
|
||||||
|
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 过期时间配置(推荐配置) =====
|
||||||
|
# 填写你创建 API Token 时显示的创建日期和有效期天数
|
||||||
|
# 创建日期格式:YYYY-MM-DD(例如:2026-01-15)
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
# 有效期天数(例如:30)
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
|
||||||
|
# ===== Telegram 通知配置(可选) =====
|
||||||
|
# 配置后,策略会通过 Telegram 发送重要通知(订单成交、开仓、平仓、止损、Token过期等)
|
||||||
|
# 如何获取 Bot Token:在 Telegram 搜索 @BotFather,发送 /newbot 创建机器人,获取 Token
|
||||||
|
# 如何获取 Chat ID:在 Telegram 搜索 @userinfobot,发送任意消息即可看到你的 Chat ID
|
||||||
|
# TELEGRAM_BOT_TOKEN=你的BotToken
|
||||||
|
# TELEGRAM_CHAT_ID=你的ChatID
|
||||||
|
# TELEGRAM_ACCOUNT_LABEL=我的账户(可选,用于区分多个账户的通知)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 正确填写示例
|
||||||
|
|
||||||
|
假设你生成的 API Token 信息是:
|
||||||
|
- Token: `eyJhbGciOiJFUzI1NiIsImtpZCI6IlhnaEJQSVNuN0RQVHlMcWJtLUVHVkVhOU1lMFpwdU9iMk1Qc2gtbUFlencifQ...`
|
||||||
|
- Ed25519 Private Key: `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`
|
||||||
|
- 创建日期: `2026-01-15`
|
||||||
|
- 有效期: `30` 天
|
||||||
|
|
||||||
|
那么你的 `.env` 应该这样写:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EXCHANGE=standx
|
||||||
|
STANDX_TOKEN=eyJhbGciOiJFUzI1NiIsImtpZCI6IlhnaEJQSVNuN0RQVHlMcWJtLUVHVkVhOU1lMFpwdU9iMk1Qc2gtbUFlencifQ...
|
||||||
|
STANDX_REQUEST_PRIVATE_KEY=HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f
|
||||||
|
STANDX_SYMBOL=BTC-USD
|
||||||
|
MAKER_POINTS_ORDER_AMOUNT=0.01
|
||||||
|
MAKER_POINTS_CLOSE_THRESHOLD=0.1
|
||||||
|
MAKER_POINTS_STOP_LOSS_USD=0
|
||||||
|
MAKER_POINTS_MIN_REPRICE_BPS=3
|
||||||
|
MAKER_POINTS_BAND_0_10=true
|
||||||
|
MAKER_POINTS_BAND_10_30=true
|
||||||
|
MAKER_POINTS_BAND_30_100=true
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
# TELEGRAM_BOT_TOKEN=你的BotToken
|
||||||
|
# TELEGRAM_CHAT_ID=你的ChatID
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
>
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
>
|
||||||
|
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第五步:启动策略
|
||||||
|
|
||||||
|
### 普通启动(看实时仪表盘)
|
||||||
|
```bash
|
||||||
|
bun run index.ts --strategy maker-points --exchange standx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后台运行(推荐长期挂机)
|
||||||
|
```bash
|
||||||
|
bun run pm2:start:maker-points
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置参数说明
|
||||||
|
|
||||||
|
| 参数 | 含义 | 新手建议 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `STANDX_TOKEN` | API 令牌 | 必填,从第三步获取 |
|
||||||
|
| `STANDX_REQUEST_PRIVATE_KEY` | 代理钱包私钥 | 必填,从第三步获取 |
|
||||||
|
| `STANDX_SYMBOL` | 交易品种 | 默认 `BTC-USD` |
|
||||||
|
| `MAKER_POINTS_ORDER_AMOUNT` | 每笔挂单数量 | 建议 `0.01` 起步 |
|
||||||
|
| `MAKER_POINTS_CLOSE_THRESHOLD` | 持仓达到多少开始平仓 | 设为 `0` 表示不自动平仓 |
|
||||||
|
| `MAKER_POINTS_STOP_LOSS_USD` | 亏损多少美元强制平仓 | 设为 `0` 表示关闭止损 |
|
||||||
|
| `MAKER_POINTS_BAND_*` | 三个挂单档位的开关 | 全部 `true` 即可 |
|
||||||
|
| `STANDX_TOKEN_CREATE_DATE` | Token 创建日期 | 推荐配置,格式 YYYY-MM-DD |
|
||||||
|
| `STANDX_TOKEN_VALIDITY_DAYS` | Token 有效期天数 | 推荐配置,与创建日期配合使用 |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
|
||||||
|
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
|
||||||
|
| `TELEGRAM_ACCOUNT_LABEL` | Telegram 通知账户标签 | 可选,用于区分多个账户 |
|
||||||
|
|
||||||
|
### Token 过期时间配置详解
|
||||||
|
|
||||||
|
`STANDX_TOKEN_CREATE_DATE` 和 `STANDX_TOKEN_VALIDITY_DAYS` 用于设置 Token 的过期时间。配置后,策略会:
|
||||||
|
|
||||||
|
1. **Token 过期前 1 小时**:在日志中提醒你 Token 即将过期
|
||||||
|
2. **Token 过期后**:
|
||||||
|
- 如果有持仓:进入**平仓模式**,只允许平仓和止损,不再开新仓
|
||||||
|
- 如果无持仓但有挂单:**自动取消所有挂单**
|
||||||
|
- 如果无持仓无挂单:进入**静默模式**,只接收数据,不下单
|
||||||
|
|
||||||
|
**推荐配置方式(创建日期 + 有效期天数):**
|
||||||
|
|
||||||
|
在 StandX 官网生成 API Token 时,页面会显示创建日期和有效期天数,直接填入即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建日期(格式:YYYY-MM-DD)
|
||||||
|
STANDX_TOKEN_CREATE_DATE=2026-01-15
|
||||||
|
# 有效期天数
|
||||||
|
STANDX_TOKEN_VALIDITY_DAYS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例计算:**
|
||||||
|
- 创建日期:2026-01-15
|
||||||
|
- 有效期:30 天
|
||||||
|
- 过期时间:2025-02-14 00:00:00 UTC
|
||||||
|
|
||||||
|
**兼容旧版配置(直接指定过期时间戳):**
|
||||||
|
|
||||||
|
如果你之前使用的是 `STANDX_TOKEN_EXPIRY`,仍然可以继续使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式1:使用时间戳(秒)
|
||||||
|
STANDX_TOKEN_EXPIRY=1735689600
|
||||||
|
|
||||||
|
# 方式2:使用 ISO 日期字符串
|
||||||
|
STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **提示**:推荐使用新的创建日期 + 有效期天数方式,更直观易懂。
|
||||||
|
|
||||||
|
### Telegram 通知配置详解
|
||||||
|
|
||||||
|
配置 Telegram 通知后,策略会在以下情况发送通知:
|
||||||
|
|
||||||
|
- 📝 **订单成交**:挂单被成交时
|
||||||
|
- 📈 **开仓**:持仓从 0 变为非 0 时
|
||||||
|
- 📉 **平仓**:持仓从非 0 变为 0 时
|
||||||
|
- 🛑 **止损触发**:触发止损平仓时
|
||||||
|
- ⏰ **Token 过期**:Token 过期时
|
||||||
|
|
||||||
|
**配置步骤:**
|
||||||
|
|
||||||
|
1. **创建 Telegram 机器人**:
|
||||||
|
- 在 Telegram 搜索 `@BotFather`
|
||||||
|
- 发送 `/newbot` 命令
|
||||||
|
- 按提示设置机器人名称和用户名
|
||||||
|
- 获取 Bot Token(格式类似:`123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||||
|
|
||||||
|
2. **获取你的 Chat ID**:
|
||||||
|
- 在 Telegram 搜索 `@userinfobot`
|
||||||
|
- 发送任意消息
|
||||||
|
- 机器人会返回你的 Chat ID(一串数字,例如:`123456789`)
|
||||||
|
|
||||||
|
3. **配置环境变量**:
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_ACCOUNT_LABEL=我的账户(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **测试通知**:
|
||||||
|
- 启动策略后,如果配置正确,会在 Token 过期或重要事件时收到通知
|
||||||
|
- 如果收不到通知,检查 Bot Token 和 Chat ID 是否正确
|
||||||
|
|
||||||
|
> 💡 **提示**:`TELEGRAM_ACCOUNT_LABEL` 是可选的,如果你有多个账户在运行策略,可以用这个标签区分不同账户的通知。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q:报错说 Token 无效怎么办?
|
||||||
|
|
||||||
|
重新去 https://standx.com/user/session 生成新的 API Token。Token 可能过期了。
|
||||||
|
|
||||||
|
### Q:Ed25519 Private Key 从哪来的?
|
||||||
|
|
||||||
|
在 StandX 官网(https://standx.com/user/session)点击 "Generate API Token" 按钮时会显示。
|
||||||
|
私钥格式为 Base58 编码(类似 `HdsyJD7oWgT756124j3taSPGv17vo5u7FafDq3vrun4f`)。
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
**你不需要自己创建,系统会自动生成!**
|
||||||
|
|
||||||
|
### Q:.env 文件放在哪?
|
||||||
|
|
||||||
|
放在项目根目录,就是 `ritmex-bot` 文件夹里,和 `package.json` 同一个目录。
|
||||||
|
|
||||||
|
### Q:为什么策略没有下单?
|
||||||
|
|
||||||
|
1. 检查账户里有没有足够的保证金
|
||||||
|
2. 检查 TOKEN 和私钥是否正确填写
|
||||||
|
3. 检查 .env 文件是否保存成功
|
||||||
|
|
||||||
|
### Q:担心平掉我手动开的仓位?
|
||||||
|
|
||||||
|
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
|
||||||
|
|
||||||
|
### Q:如何知道 Token 什么时候过期?
|
||||||
|
|
||||||
|
配置 `STANDX_TOKEN_CREATE_DATE`(创建日期)和 `STANDX_TOKEN_VALIDITY_DAYS`(有效期天数),策略会在 Token 过期前 1 小时提醒你。这两个值在生成 API Token 时会显示。Token 过期后,如果有持仓会进入平仓模式,只允许平仓和止损。
|
||||||
|
|
||||||
|
### Q:Telegram 通知收不到怎么办?
|
||||||
|
|
||||||
|
1. 检查 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID` 是否正确填写
|
||||||
|
2. 确保没有在 Bot Token 和 Chat ID 前后加引号或空格
|
||||||
|
3. 在 Telegram 中先给机器人发送一条消息(任意内容),然后再启动策略
|
||||||
|
4. 检查网络连接是否正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全提示
|
||||||
|
|
||||||
|
1. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
2. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
3. **绝对不要把 TOKEN 和私钥分享给任何人!**
|
||||||
|
|
||||||
|
代理钱包只用于签名,你的资产始终在你自己的主钱包里。但如果泄露了 TOKEN,别人可以用你的账户交易。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 还是不会?
|
||||||
|
|
||||||
|
把你的报错信息截图发到 Telegram 群里,会有人帮你:
|
||||||
|
|
||||||
|
Telegram 群:https://t.me/+4fdo0quY87o4Mjhh
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
[API](https://docs.standx.com/standx-api/standx-api "API") Perps Reference
|
||||||
|
|
||||||
|
## StandX Perps API Reference
|
||||||
|
|
||||||
|
⚠️ This document is under construction.
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
|
||||||
|
### Symbol
|
||||||
|
|
||||||
|
Available symbols (Trading pairs):
|
||||||
|
|
||||||
|
- `BTC-USD`
|
||||||
|
|
||||||
|
### Margin Mode
|
||||||
|
|
||||||
|
- `cross`
|
||||||
|
- `isolated`
|
||||||
|
|
||||||
|
### Token
|
||||||
|
|
||||||
|
Available tokens:
|
||||||
|
|
||||||
|
- `DUSD`
|
||||||
|
|
||||||
|
### Order Side (side)
|
||||||
|
|
||||||
|
- `buy`
|
||||||
|
- `sell`
|
||||||
|
|
||||||
|
### Order Type (order\_type)
|
||||||
|
|
||||||
|
- `limit`
|
||||||
|
- `market`
|
||||||
|
|
||||||
|
### Order Status (status)
|
||||||
|
|
||||||
|
- `open`
|
||||||
|
- `canceled`
|
||||||
|
- `filled`
|
||||||
|
- `rejected`
|
||||||
|
- `untriggered`
|
||||||
|
|
||||||
|
### Time In Force (time\_in\_force)
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `gtc` | Good Til Canceled - Order remains active until canceled |
|
||||||
|
| `ioc` | Immediate Or Cancel - Fill as much as possible immediately, cancel the rest |
|
||||||
|
| `alo` | Add Liquidity Only - Order added to book without immediate execution; only executes as resting order |
|
||||||
|
|
||||||
|
### Resolution
|
||||||
|
|
||||||
|
Kline resolutions:
|
||||||
|
|
||||||
|
- `1T` - 1 tick
|
||||||
|
- `3S` - 3 seconds
|
||||||
|
- `1` - 1 minute
|
||||||
|
- `5` - 5 minutes
|
||||||
|
- `15` - 15 minutes
|
||||||
|
- `60` - 60 minutes (1 hour)
|
||||||
|
- `1D` - 1 day
|
||||||
|
- `1W` - 1 week
|
||||||
|
- `1M` - 1 month
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
### Common Error Codes
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| 400 | Bad Request - Invalid request parameters |
|
||||||
|
| 401 | Unauthorized - Authentication required or invalid token |
|
||||||
|
| 403 | Forbidden - Insufficient permissions |
|
||||||
|
| 404 | Not Found - Resource not found |
|
||||||
|
| 429 | Too Many Requests - Rate limit exceeded |
|
||||||
|
| 500 | Internal Server Error - Server error |
|
||||||
|
|
||||||
|
Last updated on
|
||||||
|
|
||||||
|
[Perps WebSocket API](https://docs.standx.com/standx-api/perps-ws "Perps WebSocket API")
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
## StandX Perps WebSocket API List
|
||||||
|
|
||||||
|
The WebSocket API provides two streams: **Market Stream** for market data and user account updates, and **Order Response Stream** for asynchronous order creation responses.
|
||||||
|
|
||||||
|
⚠️ This document is under construction.
|
||||||
|
|
||||||
|
## Connection Management
|
||||||
|
|
||||||
|
Both WebSocket streams implement the following connection management behavior:
|
||||||
|
|
||||||
|
### Connection Duration Limit
|
||||||
|
|
||||||
|
- **Maximum Duration**: A single WebSocket connection can be maintained for a maximum of **24 hours**
|
||||||
|
- After 24 hours, the connection will be automatically terminated
|
||||||
|
- Clients should implement reconnection logic to handle this gracefully
|
||||||
|
|
||||||
|
### Ping/Pong Mechanism
|
||||||
|
|
||||||
|
- **Server Ping Interval**: The server sends a WebSocket Ping frame every 10 seconds
|
||||||
|
- **Client Response**: Clients must respond with a Pong frame when receiving a Ping
|
||||||
|
- **Timeout**: If the server does not receive a Ping/Pong response within 5 minutes, the connection will be terminated with error:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"code": 408,
|
||||||
|
"message": "disconnecting due to not receive Pong within 5 minute period"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Most modern browsers and WebSocket libraries automatically handle ping/pong frames, so you might not need to implement this manually. However, if your environment doesn’t support automatic ping/pong handling, you can proactively send ping frames to the server. Example using the npm `ws` library:
|
||||||
|
|
||||||
|
```
|
||||||
|
import WebSocket from "ws";
|
||||||
|
// ...
|
||||||
|
private ws: WebSocket;
|
||||||
|
//...
|
||||||
|
ping(): void {
|
||||||
|
this.lastPingTime = Date.now();
|
||||||
|
this.ws.ping();
|
||||||
|
console.log(\`[${new Date().toISOString()}] Ping server\`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Market Stream
|
||||||
|
|
||||||
|
Base Endpoint: `wss://perps.standx.com/ws-stream/v1`
|
||||||
|
|
||||||
|
### Available Channels
|
||||||
|
|
||||||
|
```
|
||||||
|
[
|
||||||
|
// public channels
|
||||||
|
{ channel: "price", symbol: "<symbol>" },
|
||||||
|
{ channel: "depth_book", symbol: "<symbol>" },
|
||||||
|
{ channel: "public_trade", symbol: "<symbol>" },
|
||||||
|
// user-level authenticated channels
|
||||||
|
{ channel: "order" },
|
||||||
|
{ channel: "position" },
|
||||||
|
{ channel: "balance" },
|
||||||
|
{ channel: "trade" },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscribe to Depth Book
|
||||||
|
|
||||||
|
**⚠️ Note: The sequence of price levels in the asks and bids arrays is not guaranteed. Please implement local sorting on the client side based on your specific requirements.**
|
||||||
|
|
||||||
|
- Request:
|
||||||
|
- Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"seq": 3,
|
||||||
|
"channel": "depth_book",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"data": {
|
||||||
|
"asks": [
|
||||||
|
["121896.02", "0.839"],
|
||||||
|
["121896.32", "1.051"]
|
||||||
|
],
|
||||||
|
"bids": [
|
||||||
|
["121884.22", "0.001"],
|
||||||
|
["121884.52", "0.001"]
|
||||||
|
],
|
||||||
|
"symbol": "BTC-USD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscribe to Symbol Price
|
||||||
|
|
||||||
|
- Request:
|
||||||
|
- Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"seq": 13,
|
||||||
|
"channel": "price",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"data": {
|
||||||
|
"base": "BTC",
|
||||||
|
"index_price": "121890.651250",
|
||||||
|
"last_price": "121897.95",
|
||||||
|
"mark_price": "121897.56",
|
||||||
|
"mid_price": "121898.00",
|
||||||
|
"quote": "DUSD",
|
||||||
|
"spread": ["121897.95", "121898.05"],
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time": "2025-08-11T07:23:50.923602474Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentication Request
|
||||||
|
|
||||||
|
#### Log in with JWT
|
||||||
|
|
||||||
|
- Request:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"token": "<your_jwt_token>",
|
||||||
|
"streams": [{ "channel": "order" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `auth.streams` is **Optional**, which enables the user to subscribe to specific channels right after authentication.
|
||||||
|
|
||||||
|
- Response:
|
||||||
|
```
|
||||||
|
{ "seq": 1, "channel": "auth", "data": { "code": 200, "msg": "success" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### User Orders Subscription
|
||||||
|
|
||||||
|
- Request:
|
||||||
|
- Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"seq": 35,
|
||||||
|
"channel": "order",
|
||||||
|
"data": {
|
||||||
|
"avail_locked": "0",
|
||||||
|
"cl_ord_id": "01K2C9H93Y42RW8KD6RSVWVDVV",
|
||||||
|
"closed_block": -1,
|
||||||
|
"created_at": "2025-08-11T10:06:37.182464902Z",
|
||||||
|
"created_block": -1,
|
||||||
|
"fill_avg_price": "121245.21",
|
||||||
|
"fill_qty": "1.000",
|
||||||
|
"id": 2547027,
|
||||||
|
"leverage": "15",
|
||||||
|
"liq_id": 0,
|
||||||
|
"margin": "8083.013333334",
|
||||||
|
"order_type": "market",
|
||||||
|
"payload": null,
|
||||||
|
"position_id": 15,
|
||||||
|
"price": "121245.20",
|
||||||
|
"qty": "1.000",
|
||||||
|
"reduce_only": false,
|
||||||
|
"remark": "",
|
||||||
|
"side": "buy",
|
||||||
|
"source": "user",
|
||||||
|
"status": "filled",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"time_in_force": "ioc",
|
||||||
|
"updated_at": "2025-08-11T10:06:37.182465022Z",
|
||||||
|
"user": "bsc_0x..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### User Position Subscription
|
||||||
|
|
||||||
|
- Request:
|
||||||
|
- Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"seq": 36,
|
||||||
|
"channel": "position",
|
||||||
|
"data": {
|
||||||
|
"created_at": "2025-08-10T09:05:50.265265Z",
|
||||||
|
"entry_price": "121677.65",
|
||||||
|
"entry_value": "2879988.1154631481396099405228",
|
||||||
|
"id": 15,
|
||||||
|
"initial_margin": "191999.219856667",
|
||||||
|
"leverage": "15",
|
||||||
|
"margin_asset": "DUSD",
|
||||||
|
"margin_mode": "isolated",
|
||||||
|
"qty": "23.669",
|
||||||
|
"realized_pnl": "158.197103148",
|
||||||
|
"status": "open",
|
||||||
|
"symbol": "BTC-USD",
|
||||||
|
"updated_at": "2025-08-10T09:05:50.265265Z",
|
||||||
|
"user": "bsc_0x..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### User Balance Subscription
|
||||||
|
|
||||||
|
- Request:
|
||||||
|
- Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"seq": 37,
|
||||||
|
"channel": "balance",
|
||||||
|
"data": {
|
||||||
|
"account_type": "perps",
|
||||||
|
"created_at": "2025-08-09T09:36:54.504639Z",
|
||||||
|
"free": "906946.976225666",
|
||||||
|
"id": "bsc_0x...",
|
||||||
|
"inbound": "0",
|
||||||
|
"is_enabled": true,
|
||||||
|
"kind": "user",
|
||||||
|
"last_tx": "",
|
||||||
|
"last_tx_updated_at": 0,
|
||||||
|
"locked": "0.000000000",
|
||||||
|
"occupied": "0",
|
||||||
|
"outbound": "0",
|
||||||
|
"ref_id": 0,
|
||||||
|
"token": "DUSD",
|
||||||
|
"total": "923207.752500717",
|
||||||
|
"updated_at": "2025-08-09T09:36:54.504639Z",
|
||||||
|
"version": 0,
|
||||||
|
"wallet_id": "bsc_0x..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Order Response Stream
|
||||||
|
|
||||||
|
This WebSocket channel provides real-time order status updates for the `new order` API. Since order creation is asynchronous, this channel notifies clients about order responses, including ALO order rejections.
|
||||||
|
|
||||||
|
**Base Endpoint:**`wss://perps.standx.com/ws-api/v1`
|
||||||
|
|
||||||
|
### Request Structure
|
||||||
|
|
||||||
|
All WebSocket requests follow this structure:
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
- `session_id`: UUID that remains consistent throughout the session
|
||||||
|
- `request_id`: Unique UUID for each request
|
||||||
|
- `method`: Operation to perform (`auth:login`, `order:new`, `order:cancel`)
|
||||||
|
- `header`: Required for `order:new` and `order:cancel` methods (authentication headers)
|
||||||
|
- `params`: JSON-stringified parameters specific to the method
|
||||||
|
|
||||||
|
### Methods
|
||||||
|
|
||||||
|
#### auth:login
|
||||||
|
|
||||||
|
Authenticate using JWT token.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
```
|
||||||
|
{ "token": "<jwt>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
|
||||||
|
#### order:new
|
||||||
|
|
||||||
|
Create a new order. Parameters are the same as the HTTP API `new_order` payload.
|
||||||
|
|
||||||
|
#### order:cancel
|
||||||
|
|
||||||
|
Cancel an existing order. Parameters are the same as the HTTP API `cancel_order` payload.
|
||||||
|
|
||||||
|
### Order Response Format
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"request_id": "bccc2b23-03dc-4c2b-912f-4315ebbbb7e0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rejection Response:**
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "alo order rejected",
|
||||||
|
"request_id": "1187e114-1914-4111-8da1-2aaaa86bb1b9"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Last updated on
|
||||||
|
|
||||||
|
[Perps HTTP API](https://docs.standx.com/standx-api/perps-http "Perps HTTP API") [Perps Reference](https://docs.standx.com/standx-api/perps-reference "Perps Reference")
|
||||||
+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",
|
||||||
|
|||||||
+12
-4
@@ -1,17 +1,19 @@
|
|||||||
export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
|
export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
|
|
||||||
export interface CliOptions {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
help: boolean;
|
help: boolean;
|
||||||
exchange?: "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado";
|
exchange?: "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado" | "standx";
|
||||||
}
|
}
|
||||||
|
|
||||||
const STRATEGY_VALUES = new Set<StrategyId>([
|
const STRATEGY_VALUES = new Set<StrategyId>([
|
||||||
"trend",
|
"trend",
|
||||||
"guardian",
|
"guardian",
|
||||||
"maker",
|
"maker",
|
||||||
|
"maker-points",
|
||||||
"offset-maker",
|
"offset-maker",
|
||||||
|
"liquidity-maker",
|
||||||
"basis",
|
"basis",
|
||||||
"grid",
|
"grid",
|
||||||
]);
|
]);
|
||||||
@@ -69,6 +71,10 @@ 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";
|
||||||
|
} else if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity-maker" || normalized === "liquidity_maker") {
|
||||||
|
options.strategy = "liquidity-maker";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +87,8 @@ function assignExchange(options: CliOptions, raw: string): void {
|
|||||||
normalized === "lighter" ||
|
normalized === "lighter" ||
|
||||||
normalized === "backpack" ||
|
normalized === "backpack" ||
|
||||||
normalized === "paradex" ||
|
normalized === "paradex" ||
|
||||||
normalized === "nado"
|
normalized === "nado" ||
|
||||||
|
normalized === "standx"
|
||||||
) {
|
) {
|
||||||
options.exchange = normalized as CliOptions["exchange"];
|
options.exchange = normalized as CliOptions["exchange"];
|
||||||
} else if (normalized === "gravity" || normalized === "grav" || normalized === "grv") {
|
} else if (normalized === "gravity" || normalized === "grav" || normalized === "grv") {
|
||||||
@@ -91,10 +98,11 @@ function assignExchange(options: CliOptions, raw: string): void {
|
|||||||
|
|
||||||
export function printCliHelp(): void {
|
export function printCliHelp(): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado>] [--silent]\n\n` +
|
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack|paradex|nado|standx>] [--silent]\n\n` +
|
||||||
`Options:\n` +
|
`Options:\n` +
|
||||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||||
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
||||||
|
` Aliases: liquidity, liquidity-maker for the liquidity maker engine.\n` +
|
||||||
` --exchange, -e Choose exchange. Overrides EXCHANGE/TRADE_EXCHANGE environment variables.\n` +
|
` --exchange, -e Choose exchange. Overrides EXCHANGE/TRADE_EXCHANGE environment variables.\n` +
|
||||||
` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` +
|
` --silent, -q Reduce console output. When used with --strategy, runs in silent daemon mode.\n` +
|
||||||
` --help, -h Show this help message.\n`);
|
` --help, -h Show this help message.\n`);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
|
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, tradingConfig } from "../config";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
||||||
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
||||||
|
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||||
|
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
||||||
import { 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,7 +23,9 @@ 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",
|
||||||
|
"liquidity-maker": "Liquidity Maker",
|
||||||
basis: "Basis Arbitrage",
|
basis: "Basis Arbitrage",
|
||||||
grid: "Grid",
|
grid: "Grid",
|
||||||
};
|
};
|
||||||
@@ -74,6 +78,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);
|
||||||
@@ -87,13 +108,26 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
|||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
"liquidity-maker": async (opts) => {
|
||||||
|
const config = liquidityMakerConfig;
|
||||||
|
const adapter = createAdapterOrThrow(config.symbol);
|
||||||
|
const engine = new LiquidityMakerEngine(config, adapter);
|
||||||
|
await runEngine({
|
||||||
|
engine,
|
||||||
|
strategy: "liquidity-maker",
|
||||||
|
silent: opts.silent,
|
||||||
|
getSnapshot: () => engine.getSnapshot(),
|
||||||
|
onUpdate: (emitter) => engine.on("update", emitter),
|
||||||
|
offUpdate: (emitter) => engine.off("update", emitter),
|
||||||
|
});
|
||||||
|
},
|
||||||
basis: async (opts) => {
|
basis: async (opts) => {
|
||||||
if (!isBasisStrategyEnabled()) {
|
if (!isBasisStrategyEnabled()) {
|
||||||
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
||||||
}
|
}
|
||||||
const exchangeId = resolveExchangeId();
|
const exchangeId = resolveExchangeId();
|
||||||
if (exchangeId !== "aster" && exchangeId !== "nado") {
|
if (exchangeId !== "aster" && exchangeId !== "nado" && exchangeId !== "standx") {
|
||||||
throw new Error("Basis arbitrage strategy currently only supports the Aster and Nado exchanges");
|
throw new Error("Basis arbitrage strategy currently only supports the Aster, Nado, and StandX exchanges");
|
||||||
}
|
}
|
||||||
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol);
|
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol);
|
||||||
const engine = new BasisArbEngine(basisConfig, adapter);
|
const engine = new BasisArbEngine(basisConfig, adapter);
|
||||||
@@ -135,7 +169,9 @@ async function runEngine<
|
|||||||
| TrendEngineSnapshot
|
| TrendEngineSnapshot
|
||||||
| GuardianEngineSnapshot
|
| GuardianEngineSnapshot
|
||||||
| MakerEngineSnapshot
|
| MakerEngineSnapshot
|
||||||
|
| MakerPointsSnapshot
|
||||||
| OffsetMakerEngineSnapshot
|
| OffsetMakerEngineSnapshot
|
||||||
|
| LiquidityMakerEngineSnapshot
|
||||||
| BasisArbSnapshot
|
| BasisArbSnapshot
|
||||||
| GridEngineSnapshot
|
| GridEngineSnapshot
|
||||||
>(
|
>(
|
||||||
|
|||||||
+168
-2
@@ -6,6 +6,70 @@
|
|||||||
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
||||||
import { language, type Language } from "./i18n";
|
import { language, type Language } from "./i18n";
|
||||||
|
|
||||||
|
export interface StandxTokenConfig {
|
||||||
|
expiryTimestamp: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenExpiry(): number | null {
|
||||||
|
// Method 1: Use creation date + validity days (recommended for official API tokens)
|
||||||
|
const createDate = process.env.STANDX_TOKEN_CREATE_DATE?.trim();
|
||||||
|
const validityDays = process.env.STANDX_TOKEN_VALIDITY_DAYS?.trim();
|
||||||
|
|
||||||
|
if (createDate && validityDays) {
|
||||||
|
// Parse date in YYYY-MM-DD format
|
||||||
|
const dateMatch = createDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (dateMatch) {
|
||||||
|
const [, year, month, day] = dateMatch;
|
||||||
|
const createTimestamp = Date.UTC(
|
||||||
|
Number(year),
|
||||||
|
Number(month) - 1, // Month is 0-indexed
|
||||||
|
Number(day),
|
||||||
|
0, 0, 0, 0
|
||||||
|
);
|
||||||
|
const days = Number(validityDays);
|
||||||
|
if (Number.isFinite(createTimestamp) && Number.isFinite(days) && days > 0) {
|
||||||
|
return createTimestamp + days * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Use legacy STANDX_TOKEN_EXPIRY (timestamp or ISO date string)
|
||||||
|
const legacyExpiry = process.env.STANDX_TOKEN_EXPIRY?.trim();
|
||||||
|
if (legacyExpiry) {
|
||||||
|
const asNumber = Number(legacyExpiry);
|
||||||
|
if (Number.isFinite(asNumber) && asNumber > 0) {
|
||||||
|
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
|
||||||
|
}
|
||||||
|
const asDate = Date.parse(legacyExpiry);
|
||||||
|
if (Number.isFinite(asDate) && asDate > 0) {
|
||||||
|
return asDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const standxTokenConfig: StandxTokenConfig = {
|
||||||
|
expiryTimestamp: parseTokenExpiry(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isStandxTokenExpired(): boolean {
|
||||||
|
const expiry = standxTokenConfig.expiryTimestamp;
|
||||||
|
if (expiry == null) return false;
|
||||||
|
return Date.now() >= expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStandxTokenExpiryInfo(): { expired: boolean; expiryTimestamp: number | null; remainingMs: number | null } {
|
||||||
|
const expiry = standxTokenConfig.expiryTimestamp;
|
||||||
|
if (expiry == null) {
|
||||||
|
return { expired: false, expiryTimestamp: null, remainingMs: null };
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const expired = now >= expiry;
|
||||||
|
const remainingMs = expired ? 0 : expiry - now;
|
||||||
|
return { expired, expiryTimestamp: expiry, remainingMs };
|
||||||
|
}
|
||||||
|
|
||||||
export interface TradingConfig {
|
export interface TradingConfig {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
tradeAmount: number;
|
tradeAmount: number;
|
||||||
@@ -32,6 +96,7 @@ const SYMBOL_PRIORITY_BY_EXCHANGE: Record<SupportedExchangeId, { envKeys: string
|
|||||||
backpack: { envKeys: ["BACKPACK_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDC" },
|
backpack: { envKeys: ["BACKPACK_SYMBOL", "TRADE_SYMBOL"], fallback: "BTCUSDC" },
|
||||||
paradex: { envKeys: ["PARADEX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC/USDC" },
|
paradex: { envKeys: ["PARADEX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC/USDC" },
|
||||||
nado: { envKeys: ["NADO_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-PERP" },
|
nado: { envKeys: ["NADO_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-PERP" },
|
||||||
|
standx: { envKeys: ["STANDX_SYMBOL", "TRADE_SYMBOL"], fallback: "BTC-USD" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string {
|
export function resolveSymbolFromEnv(explicitExchangeId?: SupportedExchangeId | string | null): string {
|
||||||
@@ -92,6 +157,8 @@ export interface MakerConfig {
|
|||||||
maxLogEntries: number;
|
maxLogEntries: number;
|
||||||
maxCloseSlippagePct: number;
|
maxCloseSlippagePct: number;
|
||||||
priceTick: number;
|
priceTick: number;
|
||||||
|
/** 开仓挂单档位:1=买1/卖1,2=买2/卖2,以此类推。仅影响无仓位时的开仓挂单,平仓逻辑不受影响。默认1 */
|
||||||
|
entryDepthLevel: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makerConfig: MakerConfig = {
|
export const makerConfig: MakerConfig = {
|
||||||
@@ -107,6 +174,59 @@ export const makerConfig: MakerConfig = {
|
|||||||
0.05
|
0.05
|
||||||
),
|
),
|
||||||
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
|
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MakerPointsConfig {
|
||||||
|
symbol: string;
|
||||||
|
perOrderAmount: number;
|
||||||
|
closeThreshold: number;
|
||||||
|
stopLossUsd: number;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
maxLogEntries: number;
|
||||||
|
maxCloseSlippagePct: number;
|
||||||
|
priceTick: number;
|
||||||
|
qtyStep: number;
|
||||||
|
enableBand0To10: boolean;
|
||||||
|
enableBand10To30: boolean;
|
||||||
|
enableBand30To100: boolean;
|
||||||
|
/** 0-10 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band0To10Amount: number;
|
||||||
|
/** 10-30 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band10To30Amount: number;
|
||||||
|
/** 30-100 bps 档位挂单数量,未配置时使用 perOrderAmount */
|
||||||
|
band30To100Amount: number;
|
||||||
|
minRepriceBps: number;
|
||||||
|
/** 是否根据 Binance 盘口深度失衡自动取消单边挂单,默认 true */
|
||||||
|
enableBinanceDepthCancel: boolean;
|
||||||
|
/** 各档位最小深度阈值 (BTC),盘口到目标价之间的挂单量低于此值则跳过该档位,默认 1 */
|
||||||
|
filterMinDepth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultMakerPointsAmount = parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001));
|
||||||
|
|
||||||
|
export const makerPointsConfig: MakerPointsConfig = {
|
||||||
|
symbol: resolveSymbolFromEnv("standx"),
|
||||||
|
perOrderAmount: defaultMakerPointsAmount,
|
||||||
|
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),
|
||||||
|
band0To10Amount: parseNumber(process.env.MAKER_POINTS_BAND_0_10_AMOUNT, defaultMakerPointsAmount),
|
||||||
|
band10To30Amount: parseNumber(process.env.MAKER_POINTS_BAND_10_30_AMOUNT, defaultMakerPointsAmount),
|
||||||
|
band30To100Amount: parseNumber(process.env.MAKER_POINTS_BAND_30_100_AMOUNT, defaultMakerPointsAmount),
|
||||||
|
minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3),
|
||||||
|
enableBinanceDepthCancel: parseBoolean(process.env.MAKER_POINTS_BINANCE_DEPTH_CANCEL, true),
|
||||||
|
filterMinDepth: parseNumber(process.env.MAKER_POINTS_FILTER_MIN_DEPTH, 1),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BasisArbConfig {
|
export interface BasisArbConfig {
|
||||||
@@ -154,11 +274,21 @@ export const basisConfig: BasisArbConfig = {
|
|||||||
// Users can always override via BASIS_* env vars.
|
// Users can always override via BASIS_* env vars.
|
||||||
futuresSymbol: resolveBasisSymbol(
|
futuresSymbol: resolveBasisSymbol(
|
||||||
["BASIS_FUTURES_SYMBOL", "ASTER_FUTURES_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
|
["BASIS_FUTURES_SYMBOL", "ASTER_FUTURES_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
|
||||||
(process.env.EXCHANGE ?? "").trim().toLowerCase() === "nado" ? "BTC-PERP" : "ASTERUSDT"
|
(() => {
|
||||||
|
const exchange = (process.env.EXCHANGE ?? "").trim().toLowerCase();
|
||||||
|
if (exchange === "nado") return "BTC-PERP";
|
||||||
|
if (exchange === "standx") return "BTC-USD";
|
||||||
|
return "ASTERUSDT";
|
||||||
|
})()
|
||||||
),
|
),
|
||||||
spotSymbol: resolveBasisSymbol(
|
spotSymbol: resolveBasisSymbol(
|
||||||
["BASIS_SPOT_SYMBOL", "ASTER_SPOT_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
|
["BASIS_SPOT_SYMBOL", "ASTER_SPOT_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
|
||||||
(process.env.EXCHANGE ?? "").trim().toLowerCase() === "nado" ? "KBTC" : "ASTERUSDT"
|
(() => {
|
||||||
|
const exchange = (process.env.EXCHANGE ?? "").trim().toLowerCase();
|
||||||
|
if (exchange === "nado") return "KBTC";
|
||||||
|
if (exchange === "standx") return "BTC-USD";
|
||||||
|
return "ASTERUSDT";
|
||||||
|
})()
|
||||||
),
|
),
|
||||||
refreshIntervalMs: parseNumber(process.env.BASIS_REFRESH_INTERVAL_MS, 1000),
|
refreshIntervalMs: parseNumber(process.env.BASIS_REFRESH_INTERVAL_MS, 1000),
|
||||||
maxLogEntries: parseNumber(process.env.BASIS_MAX_LOG_ENTRIES, 200),
|
maxLogEntries: parseNumber(process.env.BASIS_MAX_LOG_ENTRIES, 200),
|
||||||
@@ -209,6 +339,42 @@ export const gridConfig: GridConfig = {
|
|||||||
|
|
||||||
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
|
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
|
||||||
|
|
||||||
|
export interface LiquidityMakerConfig {
|
||||||
|
symbol: string;
|
||||||
|
tradeAmount: number;
|
||||||
|
lossLimit: number;
|
||||||
|
bidOffset: number;
|
||||||
|
askOffset: number;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
maxLogEntries: number;
|
||||||
|
maxCloseSlippagePct: number;
|
||||||
|
priceTick: number;
|
||||||
|
/** 平仓挂单距成交价的档位数,默认1档 */
|
||||||
|
closeTickOffset: number;
|
||||||
|
/** 偏移判断阈值倍数,当一侧深度超出另一侧此倍数时取消薄端订单,默认2 */
|
||||||
|
depthImbalanceRatio: number;
|
||||||
|
/** 开仓挂单档位:1=买1/卖1,2=买2/卖2,以此类推。仅影响无仓位时的开仓挂单,平仓逻辑不受影响。默认1 */
|
||||||
|
entryDepthLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const liquidityMakerConfig: LiquidityMakerConfig = {
|
||||||
|
symbol: resolveSymbolFromEnv(),
|
||||||
|
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||||
|
lossLimit: parseNumber(process.env.LIQUIDITY_MAKER_LOSS_LIMIT, parseNumber(process.env.MAKER_LOSS_LIMIT, parseNumber(process.env.LOSS_LIMIT, 0.03))),
|
||||||
|
bidOffset: parseNumber(process.env.LIQUIDITY_MAKER_BID_OFFSET, parseNumber(process.env.MAKER_BID_OFFSET, 0)),
|
||||||
|
askOffset: parseNumber(process.env.LIQUIDITY_MAKER_ASK_OFFSET, parseNumber(process.env.MAKER_ASK_OFFSET, 0)),
|
||||||
|
refreshIntervalMs: parseNumber(process.env.LIQUIDITY_MAKER_REFRESH_INTERVAL_MS, parseNumber(process.env.MAKER_REFRESH_INTERVAL_MS, 500)),
|
||||||
|
maxLogEntries: parseNumber(process.env.LIQUIDITY_MAKER_MAX_LOG_ENTRIES, parseNumber(process.env.MAKER_MAX_LOG_ENTRIES, 200)),
|
||||||
|
maxCloseSlippagePct: parseNumber(
|
||||||
|
process.env.LIQUIDITY_MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAKER_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT,
|
||||||
|
0.05
|
||||||
|
),
|
||||||
|
priceTick: parseNumber(process.env.LIQUIDITY_MAKER_PRICE_TICK ?? process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||||
|
closeTickOffset: Math.max(1, Math.floor(parseNumber(process.env.LIQUIDITY_MAKER_CLOSE_TICK_OFFSET, 1))),
|
||||||
|
depthImbalanceRatio: Math.max(1.1, parseNumber(process.env.LIQUIDITY_MAKER_DEPTH_IMBALANCE_RATIO, 2)),
|
||||||
|
entryDepthLevel: Math.max(1, Math.floor(parseNumber(process.env.MAKER_ENTRY_DEPTH_LEVEL, 1))),
|
||||||
|
};
|
||||||
|
|
||||||
export function isBasisStrategyEnabled(): boolean {
|
export function isBasisStrategyEnabled(): boolean {
|
||||||
const raw = process.env.ENABLE_BASIS_STRATEGY;
|
const raw = process.env.ENABLE_BASIS_STRATEGY;
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ type PlaceOrderOptions = {
|
|||||||
priceTick: number;
|
priceTick: number;
|
||||||
qtyStep: number;
|
qtyStep: number;
|
||||||
skipDedupe?: boolean;
|
skipDedupe?: boolean;
|
||||||
|
slPrice?: number;
|
||||||
|
tpPrice?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function placeOrder(
|
export async function placeOrder(
|
||||||
@@ -176,9 +178,11 @@ export async function placeOrder(
|
|||||||
timeInForce: reduceOnly ? "GTC" : "GTX",
|
timeInForce: reduceOnly ? "GTC" : "GTX",
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
closePosition,
|
closePosition,
|
||||||
|
slPrice: opts?.slPrice,
|
||||||
|
tpPrice: opts?.tpPrice,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`);
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export interface ExchangePrecision {
|
|||||||
minQuoteAmount?: number;
|
minQuoteAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConnectionEventType = "disconnected" | "reconnected";
|
||||||
|
export interface ConnectionEventListener {
|
||||||
|
(event: ConnectionEventType, symbol: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExchangeAdapter {
|
export interface ExchangeAdapter {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
supportsTrailingStops(): boolean;
|
supportsTrailingStops(): boolean;
|
||||||
@@ -61,4 +66,9 @@ export interface ExchangeAdapter {
|
|||||||
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
|
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
|
||||||
cancelAllOrders(params: { symbol: string }): Promise<void>;
|
cancelAllOrders(params: { symbol: string }): Promise<void>;
|
||||||
getPrecision?(): Promise<ExchangePrecision | null>;
|
getPrecision?(): Promise<ExchangePrecision | null>;
|
||||||
|
// 连接保护相关方法(可选,仅 StandX 支持)
|
||||||
|
onConnectionEvent?(listener: ConnectionEventListener): void;
|
||||||
|
offConnectionEvent?(listener: ConnectionEventListener): void;
|
||||||
|
queryOpenOrders?(): Promise<AsterOrder[]>;
|
||||||
|
forceCancelAllOrders?(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LighterExchangeAdapter, type LighterCredentials } from "./lighter/adapt
|
|||||||
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
|
import { BackpackExchangeAdapter, type BackpackCredentials } from "./backpack/adapter";
|
||||||
import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter";
|
import { ParadexExchangeAdapter, type ParadexCredentials } from "./paradex/adapter";
|
||||||
import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter";
|
import { NadoExchangeAdapter, type NadoCredentials } from "./nado/adapter";
|
||||||
|
import { StandxExchangeAdapter, type StandxCredentials } from "./standx/adapter";
|
||||||
|
|
||||||
export interface ExchangeFactoryOptions {
|
export interface ExchangeFactoryOptions {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -15,9 +16,17 @@ export interface ExchangeFactoryOptions {
|
|||||||
backpack?: BackpackCredentials;
|
backpack?: BackpackCredentials;
|
||||||
paradex?: ParadexCredentials;
|
paradex?: ParadexCredentials;
|
||||||
nado?: NadoCredentials;
|
nado?: NadoCredentials;
|
||||||
|
standx?: StandxCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SupportedExchangeId = "aster" | "grvt" | "lighter" | "backpack" | "paradex" | "nado";
|
export type SupportedExchangeId =
|
||||||
|
| "aster"
|
||||||
|
| "grvt"
|
||||||
|
| "lighter"
|
||||||
|
| "backpack"
|
||||||
|
| "paradex"
|
||||||
|
| "nado"
|
||||||
|
| "standx";
|
||||||
|
|
||||||
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
|
export function resolveExchangeId(value?: string | null): SupportedExchangeId {
|
||||||
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
|
const fallback = (value ?? process.env.EXCHANGE ?? process.env.TRADE_EXCHANGE ?? "aster")
|
||||||
@@ -29,6 +38,7 @@ export function resolveExchangeId(value?: string | null): SupportedExchangeId {
|
|||||||
if (fallback === "backpack") return "backpack";
|
if (fallback === "backpack") return "backpack";
|
||||||
if (fallback === "paradex") return "paradex";
|
if (fallback === "paradex") return "paradex";
|
||||||
if (fallback === "nado") return "nado";
|
if (fallback === "nado") return "nado";
|
||||||
|
if (fallback === "standx") return "standx";
|
||||||
return "aster";
|
return "aster";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +48,7 @@ export function getExchangeDisplayName(id: SupportedExchangeId): string {
|
|||||||
if (id === "backpack") return "Backpack";
|
if (id === "backpack") return "Backpack";
|
||||||
if (id === "paradex") return "Paradex";
|
if (id === "paradex") return "Paradex";
|
||||||
if (id === "nado") return "Nado";
|
if (id === "nado") return "Nado";
|
||||||
|
if (id === "standx") return "StandX";
|
||||||
return "AsterDex";
|
return "AsterDex";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,5 +69,8 @@ export function createExchangeAdapter(options: ExchangeFactoryOptions): Exchange
|
|||||||
if (id === "nado") {
|
if (id === "nado") {
|
||||||
return new NadoExchangeAdapter({ ...options.nado, symbol: options.symbol });
|
return new NadoExchangeAdapter({ ...options.nado, symbol: options.symbol });
|
||||||
}
|
}
|
||||||
|
if (id === "standx") {
|
||||||
|
return new StandxExchangeAdapter({ ...options.standx, symbol: options.symbol });
|
||||||
|
}
|
||||||
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
|
return new AsterExchangeAdapter({ ...options.aster, symbol: options.symbol });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import * as grvtOrders from "./grvt/order";
|
|||||||
import * as lighterOrders from "./lighter/order";
|
import * as lighterOrders from "./lighter/order";
|
||||||
import * as paradexOrders from "./paradex/order";
|
import * as paradexOrders from "./paradex/order";
|
||||||
import * as nadoOrders from "./nado/order";
|
import * as nadoOrders from "./nado/order";
|
||||||
|
import * as standxOrders from "./standx/order";
|
||||||
|
|
||||||
type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex" | "nado";
|
type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex" | "nado" | "standx";
|
||||||
|
|
||||||
interface ExchangeOrderHandlers {
|
interface ExchangeOrderHandlers {
|
||||||
limit(intent: LimitOrderIntent): Promise<AsterOrder>;
|
limit(intent: LimitOrderIntent): Promise<AsterOrder>;
|
||||||
@@ -68,9 +69,24 @@ const handlerMap: Record<ExchangeKey, ExchangeOrderHandlers> = {
|
|||||||
trailingStop: nadoOrders.createTrailingStopOrder,
|
trailingStop: nadoOrders.createTrailingStopOrder,
|
||||||
close: nadoOrders.createClosePositionOrder,
|
close: nadoOrders.createClosePositionOrder,
|
||||||
},
|
},
|
||||||
|
standx: {
|
||||||
|
limit: standxOrders.createLimitOrder,
|
||||||
|
market: standxOrders.createMarketOrder,
|
||||||
|
stop: standxOrders.createStopOrder,
|
||||||
|
trailingStop: standxOrders.createTrailingStopOrder,
|
||||||
|
close: standxOrders.createClosePositionOrder,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const knownExchanges: ExchangeKey[] = ["aster", "backpack", "grvt", "lighter", "paradex", "nado"];
|
const knownExchanges: ExchangeKey[] = [
|
||||||
|
"aster",
|
||||||
|
"backpack",
|
||||||
|
"grvt",
|
||||||
|
"lighter",
|
||||||
|
"paradex",
|
||||||
|
"nado",
|
||||||
|
"standx",
|
||||||
|
];
|
||||||
|
|
||||||
function normalizeExchangeId(value: string | undefined | null): string | undefined {
|
function normalizeExchangeId(value: string | undefined | null): string | undefined {
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export interface BaseOrderIntent {
|
|||||||
|
|
||||||
export interface LimitOrderIntent extends BaseOrderIntent {
|
export interface LimitOrderIntent extends BaseOrderIntent {
|
||||||
price: number;
|
price: number;
|
||||||
|
// StandX TPSL 参数
|
||||||
|
slPrice?: number; // 止损价格
|
||||||
|
tpPrice?: number; // 止盈价格
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketOrderIntent extends BaseOrderIntent {
|
export interface MarketOrderIntent extends BaseOrderIntent {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { LighterCredentials } from "./lighter/adapter";
|
|||||||
import type { BackpackCredentials } from "./backpack/adapter";
|
import type { BackpackCredentials } from "./backpack/adapter";
|
||||||
import type { ParadexCredentials } from "./paradex/adapter";
|
import type { ParadexCredentials } from "./paradex/adapter";
|
||||||
import type { NadoCredentials } from "./nado/adapter";
|
import type { NadoCredentials } from "./nado/adapter";
|
||||||
|
import type { StandxCredentials } from "./standx/adapter";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import type { Address } from "viem";
|
import type { Address } from "viem";
|
||||||
|
|
||||||
@@ -42,6 +43,11 @@ export function buildAdapterFromEnv(options: BuildAdapterOptions): ExchangeAdapt
|
|||||||
return createExchangeAdapter({ exchange: id, symbol, nado: credentials });
|
return createExchangeAdapter({ exchange: id, symbol, nado: credentials });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (id === "standx") {
|
||||||
|
const credentials = resolveStandxCredentials(symbol);
|
||||||
|
return createExchangeAdapter({ exchange: id, symbol, standx: credentials });
|
||||||
|
}
|
||||||
|
|
||||||
return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } });
|
return createExchangeAdapter({ exchange: id, symbol, grvt: { symbol } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +159,21 @@ function resolveNadoCredentials(symbol: string): NadoCredentials {
|
|||||||
return credentials;
|
return credentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveStandxCredentials(symbol: string): StandxCredentials {
|
||||||
|
const token = process.env.STANDX_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(t("env.missingStandx"));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
symbol: process.env.STANDX_SYMBOL ?? symbol,
|
||||||
|
baseUrl: process.env.STANDX_BASE_URL ?? undefined,
|
||||||
|
wsUrl: process.env.STANDX_WS_URL ?? undefined,
|
||||||
|
sessionId: process.env.STANDX_SESSION_ID ?? undefined,
|
||||||
|
signingKey: process.env.STANDX_REQUEST_PRIVATE_KEY ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isHex32(value: string): boolean {
|
function isHex32(value: string): boolean {
|
||||||
return /^0x[0-9a-fA-F]{64}$/.test(value.trim());
|
return /^0x[0-9a-fA-F]{64}$/.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { setTimeout, clearTimeout } from "timers";
|
||||||
|
import type {
|
||||||
|
AccountListener,
|
||||||
|
DepthListener,
|
||||||
|
ExchangeAdapter,
|
||||||
|
ExchangePrecision,
|
||||||
|
FundingRateListener,
|
||||||
|
KlineListener,
|
||||||
|
OrderListener,
|
||||||
|
TickerListener,
|
||||||
|
} from "../adapter";
|
||||||
|
import type { AsterOrder, CreateOrderParams } from "../types";
|
||||||
|
import { extractMessage } from "../../utils/errors";
|
||||||
|
import { StandxGateway, type StandxGatewayOptions, type ConnectionEventListener, type ConnectionEventType } from "./gateway";
|
||||||
|
|
||||||
|
export type { ConnectionEventListener, ConnectionEventType };
|
||||||
|
|
||||||
|
export interface StandxCredentials {
|
||||||
|
token?: string;
|
||||||
|
symbol?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
wsUrl?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
signingKey?: string;
|
||||||
|
logger?: StandxGatewayOptions["logger"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StandxExchangeAdapter implements ExchangeAdapter {
|
||||||
|
readonly id = "standx";
|
||||||
|
|
||||||
|
private readonly gateway: StandxGateway;
|
||||||
|
private readonly symbol: string;
|
||||||
|
private initPromise: Promise<void> | null = null;
|
||||||
|
private readonly initContexts = new Set<string>();
|
||||||
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private retryDelayMs = 3000;
|
||||||
|
private lastInitErrorAt = 0;
|
||||||
|
|
||||||
|
constructor(credentials: StandxCredentials = {}) {
|
||||||
|
const token = credentials.token ?? process.env.STANDX_TOKEN;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Missing STANDX_TOKEN environment variable");
|
||||||
|
}
|
||||||
|
this.symbol = credentials.symbol ?? process.env.STANDX_SYMBOL ?? process.env.TRADE_SYMBOL ?? "BTC-USD";
|
||||||
|
this.gateway = new StandxGateway({
|
||||||
|
token,
|
||||||
|
symbol: this.symbol,
|
||||||
|
baseUrl: credentials.baseUrl,
|
||||||
|
wsUrl: credentials.wsUrl,
|
||||||
|
sessionId: credentials.sessionId,
|
||||||
|
signingKey: credentials.signingKey,
|
||||||
|
logger: credentials.logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
supportsTrailingStops(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watchAccount(cb: AccountListener): void {
|
||||||
|
void this.ensureInitialized("watchAccount");
|
||||||
|
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchOrders(cb: OrderListener): void {
|
||||||
|
void this.ensureInitialized("watchOrders");
|
||||||
|
this.gateway.onOrders(this.safeInvoke("watchOrders", cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDepth(symbol: string, cb: DepthListener): void {
|
||||||
|
void this.ensureInitialized("watchDepth");
|
||||||
|
this.gateway.onDepth(symbol, this.safeInvoke("watchDepth", cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchTicker(symbol: string, cb: TickerListener): void {
|
||||||
|
void this.ensureInitialized("watchTicker");
|
||||||
|
this.gateway.onTicker(symbol, this.safeInvoke("watchTicker", cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchKlines(symbol: string, interval: string, cb: KlineListener): void {
|
||||||
|
void this.ensureInitialized("watchKlines");
|
||||||
|
this.gateway.onKlines(symbol, interval, this.safeInvoke("watchKlines", cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
watchFundingRate(symbol: string, cb: FundingRateListener): void {
|
||||||
|
void this.ensureInitialized("watchFundingRate");
|
||||||
|
this.gateway.onFundingRate(symbol, this.safeInvoke("watchFundingRate", cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||||
|
await this.ensureInitialized("createOrder");
|
||||||
|
return this.gateway.createOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
|
||||||
|
await this.ensureInitialized("cancelOrder");
|
||||||
|
await this.gateway.cancelOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
|
||||||
|
await this.ensureInitialized("cancelOrders");
|
||||||
|
await this.gateway.cancelOrders(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelAllOrders(params: { symbol: string }): Promise<void> {
|
||||||
|
await this.ensureInitialized("cancelAllOrders");
|
||||||
|
await this.gateway.cancelAllOrders(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrecision(): Promise<ExchangePrecision | null> {
|
||||||
|
try {
|
||||||
|
const precision = await this.gateway.getPrecision(this.symbol);
|
||||||
|
if (!precision) return null;
|
||||||
|
return {
|
||||||
|
priceTick: precision.priceTick,
|
||||||
|
qtyStep: precision.qtyStep,
|
||||||
|
priceDecimals: precision.priceDecimals,
|
||||||
|
sizeDecimals: precision.sizeDecimals,
|
||||||
|
minBaseAmount: precision.minBaseAmount,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[StandxExchangeAdapter] getPrecision failed", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听连接事件(断连/重连)
|
||||||
|
*/
|
||||||
|
onConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.gateway.onConnectionEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消连接事件监听
|
||||||
|
*/
|
||||||
|
offConnectionEvent(listener: ConnectionEventListener): void {
|
||||||
|
this.gateway.offConnectionEvent(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前真实的挂单状态(通过 HTTP API)
|
||||||
|
* 用于验证实际挂单情况,防止取消请求丢失
|
||||||
|
*/
|
||||||
|
async queryOpenOrders(): Promise<AsterOrder[]> {
|
||||||
|
await this.ensureInitialized("queryOpenOrders");
|
||||||
|
return this.gateway.queryOpenOrders(this.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制取消所有挂单
|
||||||
|
* 会查询当前挂单然后取消,并验证取消成功
|
||||||
|
*/
|
||||||
|
async forceCancelAllOrders(): Promise<boolean> {
|
||||||
|
await this.ensureInitialized("forceCancelAllOrders");
|
||||||
|
return this.gateway.forceCancelAllOrders(this.symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeInvoke<T extends (...args: any[]) => void>(context: string, cb: T): T {
|
||||||
|
const wrapped = ((...args: any[]) => {
|
||||||
|
try {
|
||||||
|
cb(...args);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[StandxExchangeAdapter] ${context} handler failed: ${extractMessage(error)}`);
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureInitialized(context?: string): Promise<void> {
|
||||||
|
if (!this.initPromise) {
|
||||||
|
this.initContexts.clear();
|
||||||
|
this.initPromise = this.gateway
|
||||||
|
.ensureInitialized(this.symbol)
|
||||||
|
.then((value) => {
|
||||||
|
this.clearRetry();
|
||||||
|
return value;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.handleInitError("initialize", error);
|
||||||
|
this.initPromise = null;
|
||||||
|
this.scheduleRetry();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (context && !this.initContexts.has(context)) {
|
||||||
|
this.initContexts.add(context);
|
||||||
|
this.initPromise.catch((error) => {
|
||||||
|
this.handleInitError(context, error);
|
||||||
|
this.scheduleRetry();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleRetry(): void {
|
||||||
|
if (this.retryTimer) return;
|
||||||
|
this.retryTimer = setTimeout(() => {
|
||||||
|
this.retryTimer = null;
|
||||||
|
if (this.initPromise) return;
|
||||||
|
this.retryDelayMs = Math.min(this.retryDelayMs * 2, 60_000);
|
||||||
|
void this.ensureInitialized("retry");
|
||||||
|
}, this.retryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearRetry(): void {
|
||||||
|
if (this.retryTimer) {
|
||||||
|
clearTimeout(this.retryTimer);
|
||||||
|
this.retryTimer = null;
|
||||||
|
}
|
||||||
|
this.retryDelayMs = 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInitError(context: string, error: unknown): void {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastInitErrorAt < 5000) return;
|
||||||
|
this.lastInitErrorAt = now;
|
||||||
|
console.error(`[StandxExchangeAdapter] ${context} failed`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
import type { AsterOrder, CreateOrderParams } from "../types";
|
||||||
|
import type {
|
||||||
|
BaseOrderIntent,
|
||||||
|
ClosePositionIntent,
|
||||||
|
LimitOrderIntent,
|
||||||
|
MarketOrderIntent,
|
||||||
|
StopOrderIntent,
|
||||||
|
TrailingStopOrderIntent,
|
||||||
|
} from "../order-schema";
|
||||||
|
import { toStringBoolean } from "../order-schema";
|
||||||
|
|
||||||
|
function applyCommonFields(params: CreateOrderParams, intent: BaseOrderIntent): CreateOrderParams {
|
||||||
|
if (params.quantity === undefined) {
|
||||||
|
params.quantity = intent.quantity;
|
||||||
|
}
|
||||||
|
if (params.timeInForce === undefined && intent.timeInForce) {
|
||||||
|
params.timeInForce = intent.timeInForce;
|
||||||
|
}
|
||||||
|
if (intent.reduceOnly !== undefined) {
|
||||||
|
params.reduceOnly = toStringBoolean(intent.reduceOnly);
|
||||||
|
}
|
||||||
|
if (intent.closePosition !== undefined) {
|
||||||
|
params.closePosition = toStringBoolean(intent.closePosition);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
|
||||||
|
const params: CreateOrderParams = applyCommonFields(
|
||||||
|
{
|
||||||
|
symbol: intent.symbol,
|
||||||
|
side: intent.side,
|
||||||
|
type: "LIMIT",
|
||||||
|
quantity: intent.quantity,
|
||||||
|
price: intent.price,
|
||||||
|
timeInForce: intent.timeInForce ?? "GTX",
|
||||||
|
slPrice: intent.slPrice,
|
||||||
|
tpPrice: intent.tpPrice,
|
||||||
|
},
|
||||||
|
intent
|
||||||
|
);
|
||||||
|
return intent.adapter.createOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
|
||||||
|
const params: CreateOrderParams = applyCommonFields(
|
||||||
|
{
|
||||||
|
symbol: intent.symbol,
|
||||||
|
side: intent.side,
|
||||||
|
type: "MARKET",
|
||||||
|
quantity: intent.quantity,
|
||||||
|
timeInForce: intent.timeInForce ?? "IOC",
|
||||||
|
},
|
||||||
|
intent
|
||||||
|
);
|
||||||
|
return intent.adapter.createOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
|
||||||
|
const params: CreateOrderParams = applyCommonFields(
|
||||||
|
{
|
||||||
|
symbol: intent.symbol,
|
||||||
|
side: intent.side,
|
||||||
|
type: "STOP_MARKET",
|
||||||
|
quantity: intent.quantity,
|
||||||
|
stopPrice: intent.stopPrice,
|
||||||
|
timeInForce: intent.timeInForce ?? "GTC",
|
||||||
|
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
|
||||||
|
closePosition: toStringBoolean(intent.closePosition ?? true),
|
||||||
|
},
|
||||||
|
intent
|
||||||
|
);
|
||||||
|
return intent.adapter.createOrder(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
|
||||||
|
throw new Error("StandX exchange does not support trailing stop orders");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createClosePositionOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
|
||||||
|
const params: CreateOrderParams = applyCommonFields(
|
||||||
|
{
|
||||||
|
symbol: intent.symbol,
|
||||||
|
side: intent.side,
|
||||||
|
type: "MARKET",
|
||||||
|
quantity: intent.quantity,
|
||||||
|
reduceOnly: "true",
|
||||||
|
closePosition: toStringBoolean(intent.closePosition ?? true),
|
||||||
|
timeInForce: intent.timeInForce ?? "IOC",
|
||||||
|
},
|
||||||
|
intent
|
||||||
|
);
|
||||||
|
return intent.adapter.createOrder(params);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
export interface StandxOrder {
|
||||||
|
id?: number;
|
||||||
|
cl_ord_id?: string;
|
||||||
|
symbol: string;
|
||||||
|
side: string;
|
||||||
|
order_type: string;
|
||||||
|
qty: string;
|
||||||
|
price?: string;
|
||||||
|
fill_qty?: string;
|
||||||
|
fill_avg_price?: string;
|
||||||
|
reduce_only?: boolean;
|
||||||
|
time_in_force?: string;
|
||||||
|
status?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandxPosition {
|
||||||
|
symbol: string;
|
||||||
|
qty: string;
|
||||||
|
entry_price?: string;
|
||||||
|
mark_price?: string;
|
||||||
|
upnl?: string;
|
||||||
|
leverage?: string;
|
||||||
|
liq_price?: string;
|
||||||
|
margin_mode?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandxBalance {
|
||||||
|
token: string;
|
||||||
|
free?: string;
|
||||||
|
locked?: string;
|
||||||
|
total?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandxDepthBook {
|
||||||
|
symbol: string;
|
||||||
|
bids: [string, string][];
|
||||||
|
asks: [string, string][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandxPrice {
|
||||||
|
symbol: string;
|
||||||
|
last_price?: string;
|
||||||
|
mark_price?: string;
|
||||||
|
index_price?: string;
|
||||||
|
mid_price?: string;
|
||||||
|
spread_bid?: string;
|
||||||
|
spread_ask?: string;
|
||||||
|
spread?: [string, string];
|
||||||
|
time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandxSymbolInfo {
|
||||||
|
symbol: string;
|
||||||
|
price_tick_decimals?: number;
|
||||||
|
qty_tick_decimals?: number;
|
||||||
|
min_order_qty?: string;
|
||||||
|
max_order_qty?: string;
|
||||||
|
depth_ticks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandxSymbolMarket {
|
||||||
|
symbol: string;
|
||||||
|
funding_rate?: string;
|
||||||
|
next_funding_time?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandxBalanceSnapshot {
|
||||||
|
balance?: string;
|
||||||
|
upnl?: string;
|
||||||
|
cross_available?: string;
|
||||||
|
cross_balance?: string;
|
||||||
|
isolated_balance?: string;
|
||||||
|
cross_upnl?: string;
|
||||||
|
isolated_upnl?: string;
|
||||||
|
locked?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandxKlineHistory {
|
||||||
|
s?: string;
|
||||||
|
t?: number[];
|
||||||
|
o?: number[];
|
||||||
|
h?: number[];
|
||||||
|
l?: number[];
|
||||||
|
c?: number[];
|
||||||
|
v?: number[];
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ export interface CreateOrderParams {
|
|||||||
reduceOnly?: StringBoolean;
|
reduceOnly?: StringBoolean;
|
||||||
closePosition?: StringBoolean;
|
closePosition?: StringBoolean;
|
||||||
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
||||||
|
// StandX TPSL 参数
|
||||||
|
slPrice?: number; // 止损价格
|
||||||
|
tpPrice?: number; // 止盈价格
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AsterAccountPosition {
|
export interface AsterAccountPosition {
|
||||||
|
|||||||
+37
-2
@@ -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: "在上下边界之间布设等比网格,自动加仓与减仓",
|
||||||
@@ -50,6 +55,15 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "监控期货与现货盘口差价,辅助发现套利机会",
|
zh: "监控期货与现货盘口差价,辅助发现套利机会",
|
||||||
en: "Monitors futures/spot spread to surface arbitrage windows.",
|
en: "Monitors futures/spot spread to surface arbitrage windows.",
|
||||||
},
|
},
|
||||||
|
"app.strategy.liquidityMaker.label": { zh: "流动性做市商", en: "Liquidity Maker" },
|
||||||
|
"app.strategy.liquidityMaker.desc": {
|
||||||
|
zh: "成交后在更优价位挂单平仓,更敏感的深度偏移判断",
|
||||||
|
en: "Places close orders at better prices after fills, with sensitive depth imbalance detection.",
|
||||||
|
},
|
||||||
|
"liquidityMaker.title": { zh: "流动性做市商 (Liquidity Maker)", en: "Liquidity Maker" },
|
||||||
|
"liquidityMaker.initializing": { zh: "流动性做市商初始化中...", en: "Initializing Liquidity Maker..." },
|
||||||
|
"liquidityMaker.lastFill": { zh: "最近成交: {info}", en: "Last fill: {info}" },
|
||||||
|
"liquidityMaker.noFill": { zh: "无", en: "None" },
|
||||||
"app.integrity.warning": {
|
"app.integrity.warning": {
|
||||||
zh: "警告: 版权校验失败,当前版本可能被篡改。",
|
zh: "警告: 版权校验失败,当前版本可能被篡改。",
|
||||||
en: "Warning: Copyright integrity check failed; build may be tampered.",
|
en: "Warning: Copyright integrity check failed; build may be tampered.",
|
||||||
@@ -174,6 +188,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..." },
|
||||||
@@ -229,8 +260,8 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
"grid.direction.long": { zh: "多", en: "Long" },
|
"grid.direction.long": { zh: "多", en: "Long" },
|
||||||
"grid.direction.short": { zh: "空", en: "Short" },
|
"grid.direction.short": { zh: "空", en: "Short" },
|
||||||
"basis.onlyAster": {
|
"basis.onlyAster": {
|
||||||
zh: "期现套利策略目前仅支持 Aster / Nado 交易所。请设置 EXCHANGE=aster 或 EXCHANGE=nado 后重试。",
|
zh: "期现套利策略目前仅支持 Aster / Nado / StandX 交易所。请设置 EXCHANGE=aster 或 EXCHANGE=nado 或 EXCHANGE=standx 后重试。",
|
||||||
en: "Basis arbitrage currently supports only Aster and Nado. Set EXCHANGE=aster or EXCHANGE=nado and retry.",
|
en: "Basis arbitrage currently supports only Aster, Nado, and StandX. Set EXCHANGE=aster, EXCHANGE=nado, or EXCHANGE=standx and retry.",
|
||||||
},
|
},
|
||||||
"basis.startFailed": {
|
"basis.startFailed": {
|
||||||
zh: "无法启动期现套利策略: {message}",
|
zh: "无法启动期现套利策略: {message}",
|
||||||
@@ -338,6 +369,10 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "NADO_SUBACCOUNT_OWNER / NADO_EVM_ADDRESS 必须是有效的 0x 开头 40 字节十六进制地址",
|
zh: "NADO_SUBACCOUNT_OWNER / NADO_EVM_ADDRESS 必须是有效的 0x 开头 40 字节十六进制地址",
|
||||||
en: "NADO_SUBACCOUNT_OWNER / NADO_EVM_ADDRESS must be a valid 0x-prefixed 40-byte hex address",
|
en: "NADO_SUBACCOUNT_OWNER / NADO_EVM_ADDRESS must be a valid 0x-prefixed 40-byte hex address",
|
||||||
},
|
},
|
||||||
|
"env.missingStandx": {
|
||||||
|
zh: "StandX 需要配置 STANDX_TOKEN",
|
||||||
|
en: "StandX requires STANDX_TOKEN",
|
||||||
|
},
|
||||||
"log.subscribe.accountFail": {
|
"log.subscribe.accountFail": {
|
||||||
zh: "订阅账户失败: {error}",
|
zh: "订阅账户失败: {error}",
|
||||||
en: "Failed to subscribe account: {error}",
|
en: "Failed to subscribe account: {error}",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -167,7 +167,7 @@ export class BasisArbEngine {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.exchange.id === "nado") {
|
if (this.exchange.id === "nado" || this.exchange.id === "standx") {
|
||||||
safeSubscribe<AsterDepth>(
|
safeSubscribe<AsterDepth>(
|
||||||
this.exchange.watchDepth.bind(this.exchange, this.config.spotSymbol),
|
this.exchange.watchDepth.bind(this.exchange, this.config.spotSymbol),
|
||||||
(depth) => {
|
(depth) => {
|
||||||
|
|||||||
@@ -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
@@ -14,7 +14,7 @@ import { isOrderActiveStatus } from "../utils/order-status";
|
|||||||
import { getPosition } from "../utils/strategy";
|
import { getPosition } from "../utils/strategy";
|
||||||
import type { PositionSnapshot } from "../utils/strategy";
|
import type { PositionSnapshot } from "../utils/strategy";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
import { getTopPrices, getMidOrLast } from "../utils/price";
|
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
|
||||||
import { shouldStopLoss } from "../utils/risk";
|
import { shouldStopLoss } from "../utils/risk";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
@@ -305,10 +305,18 @@ export class MakerEngine {
|
|||||||
|
|
||||||
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
||||||
const priceDecimals = this.getPriceDecimals();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
|
// 平仓价格始终使用买1/卖1
|
||||||
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
||||||
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
||||||
const bidPrice = formatPriceToString(topBid - this.config.bidOffset, priceDecimals);
|
|
||||||
const askPrice = formatPriceToString(topAsk + this.config.askOffset, priceDecimals);
|
// 开仓价格根据 entryDepthLevel 使用指定档位
|
||||||
|
const entryLevel = this.config.entryDepthLevel ?? 1;
|
||||||
|
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(depth, entryLevel);
|
||||||
|
const entryBidBase = entryBid ?? topBid;
|
||||||
|
const entryAskBase = entryAsk ?? topAsk;
|
||||||
|
|
||||||
|
const bidPrice = formatPriceToString(entryBidBase - this.config.bidOffset, priceDecimals);
|
||||||
|
const askPrice = formatPriceToString(entryAskBase + this.config.askOffset, priceDecimals);
|
||||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
const absPosition = Math.abs(position.positionAmt);
|
const absPosition = Math.abs(position.positionAmt);
|
||||||
const desired: DesiredOrder[] = [];
|
const desired: DesiredOrder[] = [];
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { getPosition, parseSymbolParts } from "../utils/strategy";
|
|||||||
import type { PositionSnapshot } from "../utils/strategy";
|
import type { PositionSnapshot } from "../utils/strategy";
|
||||||
import { computeDepthStats } from "../utils/depth";
|
import { computeDepthStats } from "../utils/depth";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
import { getTopPrices, getMidOrLast } from "../utils/price";
|
import { getTopPrices, getPricesAtLevel, getMidOrLast } from "../utils/price";
|
||||||
import { shouldStopLoss } from "../utils/risk";
|
import { shouldStopLoss } from "../utils/risk";
|
||||||
import {
|
import {
|
||||||
marketClose,
|
marketClose,
|
||||||
@@ -356,10 +356,18 @@ export class OffsetMakerEngine {
|
|||||||
|
|
||||||
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
||||||
const priceDecimals = this.getPriceDecimals();
|
const priceDecimals = this.getPriceDecimals();
|
||||||
|
// 平仓价格始终使用买1/卖1
|
||||||
const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
|
const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
|
||||||
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
|
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
|
||||||
const rawBidPrice = finalBid - this.config.bidOffset;
|
|
||||||
const rawAskPrice = finalAsk + this.config.askOffset;
|
// 开仓价格根据 entryDepthLevel 使用指定档位
|
||||||
|
const entryLevel = this.config.entryDepthLevel ?? 1;
|
||||||
|
const { bidAtLevel: entryBid, askAtLevel: entryAsk } = getPricesAtLevel(latestDepth, entryLevel);
|
||||||
|
const entryBidBase = entryBid ?? finalBid;
|
||||||
|
const entryAskBase = entryAsk ?? finalAsk;
|
||||||
|
|
||||||
|
const rawBidPrice = entryBidBase - this.config.bidOffset;
|
||||||
|
const rawAskPrice = entryAskBase + this.config.askOffset;
|
||||||
const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk);
|
const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk);
|
||||||
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
|
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
|
||||||
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
|
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
|
||||||
|
|||||||
+23
-9
@@ -3,7 +3,9 @@ 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 { LiquidityMakerApp } from "./LiquidityMakerApp";
|
||||||
import { GridApp } from "./GridApp";
|
import { GridApp } from "./GridApp";
|
||||||
import { BasisApp } from "./BasisApp";
|
import { BasisApp } from "./BasisApp";
|
||||||
import { isBasisStrategyEnabled } from "../config";
|
import { isBasisStrategyEnabled } from "../config";
|
||||||
@@ -12,7 +14,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface StrategyOption {
|
interface StrategyOption {
|
||||||
id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
|
id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
component: React.ComponentType<{ onExit: () => void }>;
|
component: React.ComponentType<{ onExit: () => void }>;
|
||||||
@@ -49,6 +51,12 @@ const BASE_STRATEGIES: StrategyOption[] = [
|
|||||||
description: t("app.strategy.offset.desc"),
|
description: t("app.strategy.offset.desc"),
|
||||||
component: OffsetMakerApp,
|
component: OffsetMakerApp,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "liquidity-maker",
|
||||||
|
label: t("app.strategy.liquidityMaker.label"),
|
||||||
|
description: t("app.strategy.liquidityMaker.desc"),
|
||||||
|
component: LiquidityMakerApp,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
@@ -60,19 +68,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) => {
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ export function BasisApp({ onExit }: BasisAppProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exchangeId !== "aster" && exchangeId !== "nado") {
|
if (exchangeId !== "aster" && exchangeId !== "nado" && exchangeId !== "standx") {
|
||||||
setError(new Error(t("basis.onlyAster")));
|
setError(new Error(t("basis.onlyAster")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { liquidityMakerConfig } from "../config";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||||
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
interface LiquidityMakerAppProps {
|
||||||
|
onExit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
|
export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) {
|
||||||
|
const [snapshot, setSnapshot] = useState<LiquidityMakerEngineSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const engineRef = useRef<LiquidityMakerEngine | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: liquidityMakerConfig.symbol });
|
||||||
|
const engine = new LiquidityMakerEngine(liquidityMakerConfig, adapter);
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
const handler = (next: LiquidityMakerEngineSnapshot) => {
|
||||||
|
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}, [exchangeId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Text color="red">{t("common.startFailed", { message: error.message })}</Text>
|
||||||
|
<Text color="gray">{t("common.checkEnv")}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return (
|
||||||
|
<Box padding={1}>
|
||||||
|
<Text>{t("liquidityMaker.initializing")}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topBid = snapshot.topBid;
|
||||||
|
const topAsk = snapshot.topAsk;
|
||||||
|
const priceDigits = snapshot.priceDecimals ?? 2;
|
||||||
|
const spreadDigits = Math.max(priceDigits + 1, 4);
|
||||||
|
const spreadDisplay =
|
||||||
|
snapshot.spread != null ? `${formatNumber(snapshot.spread, spreadDigits)} USDT` : "-";
|
||||||
|
const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5;
|
||||||
|
const sortedOrders = [...snapshot.openOrders].sort((a, b) =>
|
||||||
|
(Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId)
|
||||||
|
);
|
||||||
|
const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({
|
||||||
|
id: order.orderId,
|
||||||
|
side: order.side,
|
||||||
|
price: order.price,
|
||||||
|
qty: order.origQty,
|
||||||
|
filled: order.executedQty,
|
||||||
|
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||||
|
status: order.status,
|
||||||
|
}));
|
||||||
|
const openOrderColumns: TableColumn[] = [
|
||||||
|
{ key: "id", header: "ID", align: "right", minWidth: 6 },
|
||||||
|
{ key: "side", header: "Side", minWidth: 4 },
|
||||||
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
|
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
|
||||||
|
{ key: "filled", header: "Filled", align: "right", minWidth: 8 },
|
||||||
|
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||||
|
{ key: "status", header: "Status", minWidth: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const desiredRows = snapshot.desiredOrders.map((order, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
side: order.side,
|
||||||
|
price: order.price,
|
||||||
|
amount: order.amount,
|
||||||
|
reduceOnly: order.reduceOnly ? "yes" : "no",
|
||||||
|
}));
|
||||||
|
const desiredColumns: TableColumn[] = [
|
||||||
|
{ key: "index", header: "#", align: "right", minWidth: 2 },
|
||||||
|
{ key: "side", header: "Side", minWidth: 4 },
|
||||||
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
|
{ key: "amount", header: "Qty", align: "right", minWidth: 8 },
|
||||||
|
{ key: "reduceOnly", header: "RO", minWidth: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||||
|
const imbalanceLabel =
|
||||||
|
snapshot.depthImbalance === "balanced"
|
||||||
|
? t("offset.imbalance.balanced")
|
||||||
|
: snapshot.depthImbalance === "buy_dominant"
|
||||||
|
? t("offset.imbalance.buy")
|
||||||
|
: t("offset.imbalance.sell");
|
||||||
|
const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData");
|
||||||
|
|
||||||
|
// 显示最近成交信息
|
||||||
|
const lastFillInfo = snapshot.lastFill
|
||||||
|
? `${snapshot.lastFill.side} ${formatNumber(snapshot.lastFill.amount, 6)} @ ${formatNumber(snapshot.lastFill.price, priceDigits)}`
|
||||||
|
: t("liquidityMaker.noFill");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={1}>
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="cyanBright">{t("liquidityMaker.title")}</Text>
|
||||||
|
<Text>
|
||||||
|
{t("offset.headerLine", {
|
||||||
|
exchange: exchangeName,
|
||||||
|
symbol: snapshot.symbol,
|
||||||
|
bid: formatNumber(topBid, priceDigits),
|
||||||
|
ask: formatNumber(topAsk, priceDigits),
|
||||||
|
spread: spreadDisplay,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("offset.depthLine", {
|
||||||
|
buy: formatNumber(snapshot.buyDepthSum10, 4),
|
||||||
|
sell: formatNumber(snapshot.sellDepthSum10, 4),
|
||||||
|
status: imbalanceLabel,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">
|
||||||
|
{t("offset.strategyStatus", {
|
||||||
|
buyStatus: snapshot.skipBuySide ? t("common.disabled") : t("common.enabled"),
|
||||||
|
sellStatus: snapshot.skipSellSide ? t("common.disabled") : t("common.enabled"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray">{t("liquidityMaker.lastFill", { info: lastFillInfo })}</Text>
|
||||||
|
<Text color="gray">{t("trend.statusLine", { status: readyStatus })}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Box flexDirection="column" marginRight={4}>
|
||||||
|
<Text color="greenBright">{t("common.section.position")}</Text>
|
||||||
|
{hasPosition ? (
|
||||||
|
<>
|
||||||
|
<Text>
|
||||||
|
{t("maker.positionLine", {
|
||||||
|
direction:
|
||||||
|
snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
||||||
|
qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4),
|
||||||
|
entry: formatNumber(snapshot.position.entryPrice, priceDigits),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("maker.pnlLine", {
|
||||||
|
pnl: formatNumber(snapshot.pnl, 4),
|
||||||
|
accountPnl: formatNumber(snapshot.accountUnrealized, 4),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noPosition")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="greenBright">{t("maker.targetOrders")}</Text>
|
||||||
|
{desiredRows.length > 0 ? (
|
||||||
|
<DataTable columns={desiredColumns} rows={desiredRows} />
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("maker.noTargetOrders")}</Text>
|
||||||
|
)}
|
||||||
|
<Text>
|
||||||
|
{t("trend.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color="yellow">{t("common.section.orders")}</Text>
|
||||||
|
{openOrderRows.length > 0 ? (
|
||||||
|
<DataTable columns={openOrderColumns} rows={openOrderRows} />
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noOrders")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color="yellow">{t("common.section.recent")}</Text>
|
||||||
|
{lastLogs.length > 0 ? (
|
||||||
|
lastLogs.map((item, index) => (
|
||||||
|
<Text key={`${item.time}-${index}`}>
|
||||||
|
[{item.time}] [{item.type}] {item.detail}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text color="gray">{t("common.noLogs")}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,3 +61,27 @@ export function isInsufficientBalanceError(error: unknown): boolean {
|
|||||||
message.includes("NOT ENOUGH")
|
message.includes("NOT ENOUGH")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPrecisionError(error: unknown): boolean {
|
||||||
|
const message = extractMessage(error).toUpperCase();
|
||||||
|
return (
|
||||||
|
message.includes("PRECISION") ||
|
||||||
|
message.includes("TICK_SIZE") ||
|
||||||
|
message.includes("TICKSIZE") ||
|
||||||
|
message.includes("STEP_SIZE") ||
|
||||||
|
message.includes("STEPSIZE") ||
|
||||||
|
message.includes("LOT_SIZE") ||
|
||||||
|
message.includes("LOTSIZE") ||
|
||||||
|
message.includes("INVALID_QUANTITY") ||
|
||||||
|
message.includes("INVALID QUANTITY") ||
|
||||||
|
message.includes("QUANTITY_INVALID") ||
|
||||||
|
message.includes("INVALID_PRICE") ||
|
||||||
|
message.includes("INVALID PRICE") ||
|
||||||
|
message.includes("PRICE_INVALID") ||
|
||||||
|
message.includes("QTY_STEP") ||
|
||||||
|
message.includes("PRICE_TICK") ||
|
||||||
|
message.includes("DECIMAL") ||
|
||||||
|
message.includes("FILTER_FAILURE") ||
|
||||||
|
message.includes("NOTIONAL")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,46 @@ export function getTopPrices(depth?: AsterDepth | null): { topBid: number | null
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定档位的盘口价格
|
||||||
|
* @param depth 深度数据
|
||||||
|
* @param level 档位(1=买1/卖1,2=买2/卖2,以此类推)
|
||||||
|
* @returns 指定档位的买卖价格,如果该档位不存在则回退到最近的有效档位
|
||||||
|
*/
|
||||||
|
export function getPricesAtLevel(
|
||||||
|
depth?: AsterDepth | null,
|
||||||
|
level: number = 1
|
||||||
|
): { bidAtLevel: number | null; askAtLevel: number | null } {
|
||||||
|
const index = Math.max(0, level - 1);
|
||||||
|
|
||||||
|
// 尝试获取指定档位,如果不存在则回退到最近的有效档位
|
||||||
|
const bids = depth?.bids ?? [];
|
||||||
|
const asks = depth?.asks ?? [];
|
||||||
|
|
||||||
|
let bidAtLevel: number | null = null;
|
||||||
|
let askAtLevel: number | null = null;
|
||||||
|
|
||||||
|
// 从指定档位向前查找第一个有效的买价
|
||||||
|
for (let i = Math.min(index, bids.length - 1); i >= 0; i--) {
|
||||||
|
const bid = Number(bids[i]?.[0]);
|
||||||
|
if (Number.isFinite(bid)) {
|
||||||
|
bidAtLevel = bid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从指定档位向前查找第一个有效的卖价
|
||||||
|
for (let i = Math.min(index, asks.length - 1); i >= 0; i--) {
|
||||||
|
const ask = Number(asks[i]?.[0]);
|
||||||
|
if (Number.isFinite(ask)) {
|
||||||
|
askAtLevel = ask;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bidAtLevel, askAtLevel };
|
||||||
|
}
|
||||||
|
|
||||||
export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | null): number | null {
|
export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | null): number | null {
|
||||||
const { topBid, topAsk } = getTopPrices(depth);
|
const { topBid, topAsk } = getTopPrices(depth);
|
||||||
if (topBid != null && topAsk != null) return (topBid + topAsk) / 2;
|
if (topBid != null && topAsk != null) return (topBid + topAsk) / 2;
|
||||||
@@ -16,4 +56,58 @@ export function getMidOrLast(depth?: AsterDepth | null, ticker?: AsterTicker | n
|
|||||||
return Number.isFinite(last) ? last : null;
|
return Number.isFinite(last) ? last : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算从盘口一档到目标价格之间的挂单总量
|
||||||
|
* @param depth 深度数据
|
||||||
|
* @param side 挂单方向: BUY 检查 bids, SELL 检查 asks
|
||||||
|
* @param targetPrice 目标挂单价格
|
||||||
|
* @returns 从一档到目标价格之间的挂单总量 (不包含目标价格本身)
|
||||||
|
*/
|
||||||
|
export function getDepthBetweenPrices(
|
||||||
|
depth: AsterDepth | null | undefined,
|
||||||
|
side: "BUY" | "SELL",
|
||||||
|
targetPrice: number
|
||||||
|
): number {
|
||||||
|
if (!depth) return 0;
|
||||||
|
if (!Number.isFinite(targetPrice) || targetPrice <= 0) return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (side === "BUY") {
|
||||||
|
// BUY 订单挂在 bid 侧,检查从 bid1 到目标价格之间的所有 bids
|
||||||
|
// bids 按价格从高到低排序,目标价格 < bid1
|
||||||
|
const bids = depth.bids ?? [];
|
||||||
|
for (const level of bids) {
|
||||||
|
const price = Number(level[0]);
|
||||||
|
const qty = Number(level[1]);
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(qty)) continue;
|
||||||
|
// 只计算价格 > 目标价格的档位 (目标价格以上的挂单)
|
||||||
|
if (price > targetPrice) {
|
||||||
|
total += qty;
|
||||||
|
} else {
|
||||||
|
// bids 是从高到低排序,一旦 price <= targetPrice 就停止
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// SELL 订单挂在 ask 侧,检查从 ask1 到目标价格之间的所有 asks
|
||||||
|
// asks 按价格从低到高排序,目标价格 > ask1
|
||||||
|
const asks = depth.asks ?? [];
|
||||||
|
for (const level of asks) {
|
||||||
|
const price = Number(level[0]);
|
||||||
|
const qty = Number(level[1]);
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(qty)) continue;
|
||||||
|
// 只计算价格 < 目标价格的档位 (目标价格以下的挂单)
|
||||||
|
if (price < targetPrice) {
|
||||||
|
total += qty;
|
||||||
|
} else {
|
||||||
|
// asks 是从低到高排序,一旦 price >= targetPrice 就停止
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
|
||||||
|
|
||||||
|
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
|
||||||
|
|
||||||
|
export interface TokenExpiryStatus {
|
||||||
|
state: TokenExpiryState;
|
||||||
|
expired: boolean;
|
||||||
|
expiryTimestamp: number | null;
|
||||||
|
remainingMs: number | null;
|
||||||
|
hasPosition: boolean;
|
||||||
|
hasOpenOrders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenExpiryCheckParams {
|
||||||
|
positionAmt: number;
|
||||||
|
openOrderCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkStandxTokenExpiry(params: TokenExpiryCheckParams): TokenExpiryStatus {
|
||||||
|
const info = getStandxTokenExpiryInfo();
|
||||||
|
const hasPosition = Math.abs(params.positionAmt) > 1e-8;
|
||||||
|
const hasOpenOrders = params.openOrderCount > 0;
|
||||||
|
|
||||||
|
if (!info.expired) {
|
||||||
|
return {
|
||||||
|
state: "active",
|
||||||
|
expired: false,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: info.remainingMs,
|
||||||
|
hasPosition,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPosition) {
|
||||||
|
return {
|
||||||
|
state: "expired_with_position",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition: true,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasOpenOrders) {
|
||||||
|
return {
|
||||||
|
state: "silent",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition: false,
|
||||||
|
hasOpenOrders: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "expired",
|
||||||
|
expired: true,
|
||||||
|
expiryTimestamp: info.expiryTimestamp,
|
||||||
|
remainingMs: 0,
|
||||||
|
hasPosition,
|
||||||
|
hasOpenOrders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | null {
|
||||||
|
if (!status.expired) {
|
||||||
|
if (status.remainingMs != null && status.remainingMs < 3600_000) {
|
||||||
|
const mins = Math.ceil(status.remainingMs / 60_000);
|
||||||
|
return `StandX Token 将在 ${mins} 分钟后过期`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status.state) {
|
||||||
|
case "expired":
|
||||||
|
return "StandX Token 已过期,正在取消所有挂单";
|
||||||
|
case "expired_with_position":
|
||||||
|
return "StandX Token 已过期,仅保留平仓/止损逻辑";
|
||||||
|
case "silent":
|
||||||
|
return "StandX Token 已过期,进入静默数据接收模式";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenExpiryConfigured(): boolean {
|
||||||
|
return standxTokenConfig.expiryTimestamp != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isStandxTokenExpired, getStandxTokenExpiryInfo };
|
||||||
@@ -42,5 +42,11 @@ describe("resolveSymbolFromEnv", () => {
|
|||||||
|
|
||||||
expect(resolveSymbolFromEnv("grvt")).toBe("ETHUSDT");
|
expect(resolveSymbolFromEnv("grvt")).toBe("ETHUSDT");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
it("supports standx symbol defaults when explicit exchange id is provided", () => {
|
||||||
|
delete process.env.EXCHANGE;
|
||||||
|
process.env.STANDX_SYMBOL = "ETH-USD";
|
||||||
|
|
||||||
|
expect(resolveSymbolFromEnv("standx")).toBe("ETH-USD");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AsterExchangeAdapter } from "../src/exchanges/aster-adapter";
|
|||||||
import { GrvtExchangeAdapter } from "../src/exchanges/grvt/adapter";
|
import { GrvtExchangeAdapter } from "../src/exchanges/grvt/adapter";
|
||||||
import { BackpackExchangeAdapter } from "../src/exchanges/backpack/adapter";
|
import { BackpackExchangeAdapter } from "../src/exchanges/backpack/adapter";
|
||||||
import { ParadexExchangeAdapter } from "../src/exchanges/paradex/adapter";
|
import { ParadexExchangeAdapter } from "../src/exchanges/paradex/adapter";
|
||||||
|
import { StandxExchangeAdapter } from "../src/exchanges/standx/adapter";
|
||||||
|
|
||||||
const ORIGINAL_ENV = { ...process.env };
|
const ORIGINAL_ENV = { ...process.env };
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ describe("exchange factory", () => {
|
|||||||
expect(resolveExchangeId("ASTER")).toBe("aster");
|
expect(resolveExchangeId("ASTER")).toBe("aster");
|
||||||
expect(resolveExchangeId("BACKPACK")).toBe("backpack");
|
expect(resolveExchangeId("BACKPACK")).toBe("backpack");
|
||||||
expect(resolveExchangeId("PaRaDeX")).toBe("paradex");
|
expect(resolveExchangeId("PaRaDeX")).toBe("paradex");
|
||||||
|
expect(resolveExchangeId("StandX")).toBe("standx");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates grvt adapter when EXCHANGE=grvt", () => {
|
it("creates grvt adapter when EXCHANGE=grvt", () => {
|
||||||
@@ -67,4 +69,14 @@ describe("exchange factory", () => {
|
|||||||
expect(adapter).toBeInstanceOf(ParadexExchangeAdapter);
|
expect(adapter).toBeInstanceOf(ParadexExchangeAdapter);
|
||||||
expect(adapter.id).toBe("paradex");
|
expect(adapter.id).toBe("paradex");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates standx adapter when EXCHANGE=standx", () => {
|
||||||
|
process.env.EXCHANGE = "standx";
|
||||||
|
process.env.STANDX_TOKEN = "token";
|
||||||
|
process.env.STANDX_SYMBOL = "BTC-USD";
|
||||||
|
|
||||||
|
const adapter = createExchangeAdapter({ symbol: "BTC-USD" });
|
||||||
|
expect(adapter).toBeInstanceOf(StandxExchangeAdapter);
|
||||||
|
expect(adapter.id).toBe("standx");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user