mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37476ea4bc | ||
|
|
ffd62fd481 | ||
|
|
511cf8669d | ||
|
|
968b585688 | ||
|
|
59ccd1fea8 | ||
|
|
a9fa7f2e19 | ||
|
|
7529de334f | ||
|
|
1bfe4d58c0 | ||
|
|
4ce2b07e21 | ||
|
|
6bb0994cae | ||
|
|
486b911bed | ||
|
|
f48371ccd8 | ||
|
|
9ca35584b5 | ||
|
|
01e5ab9997 | ||
|
|
28bc1d5210 | ||
|
|
870fe2b8d7 | ||
|
|
e7f6341961 | ||
|
|
1baee3a207 | ||
|
|
e94cf1bda2 | ||
|
|
274e2f3d75 | ||
|
|
a91e87534b | ||
|
|
d0d1afa0ea | ||
|
|
0f70c6b6aa | ||
|
|
078b201d15 | ||
|
|
1832c4c13e | ||
|
|
73a88a8a0f | ||
|
|
265cb6df50 | ||
|
|
5bba8169b5 | ||
|
|
295c6a47b7 | ||
|
|
e7dedbba2c | ||
|
|
6196912cf4 | ||
|
|
f1ebfacaa2 | ||
|
|
ea5da20311 | ||
|
|
fabc8af679 | ||
|
|
189fed0f6e | ||
|
|
09ff4dc296 | ||
|
|
2627fa9db8 | ||
|
|
8b20e60753 | ||
|
|
f991215d86 | ||
|
|
6a3b842a3b | ||
|
|
b36c1dce0f | ||
|
|
dbe8df6934 | ||
|
|
400c7ad4b7 | ||
|
|
e96cbb6ff4 | ||
|
|
aa3d5e8c6c | ||
|
|
96cf4ea47b | ||
|
|
45061ed748 | ||
|
|
da3dacb549 | ||
|
|
51e0ce2bea | ||
|
|
8c13d20cb7 | ||
|
|
3d959ae579 | ||
|
|
4240cc6901 | ||
|
|
8d0e9578c4 | ||
|
|
95f07145a9 | ||
|
|
e3411c6791 | ||
|
|
640f1bfb4e | ||
|
|
f973fe433d | ||
|
|
730a75df93 | ||
|
|
de8a793bd5 | ||
|
|
bb072b2d1d | ||
|
|
95426e222b | ||
|
|
4365d73562 | ||
|
|
6067f759a0 | ||
|
|
b31b98f816 | ||
|
|
801543488e | ||
|
|
20c73eba21 | ||
|
|
ed49dca2a6 | ||
|
|
ca559d0f82 | ||
|
|
fef7604490 | ||
|
|
e524c225f2 | ||
|
|
39a97110dd | ||
|
|
8ad19b007b | ||
|
|
2e2990369b | ||
|
|
24f93fa8a7 | ||
|
|
d778e20c23 | ||
|
|
58f5de4a48 | ||
|
|
ad46635e20 | ||
|
|
6dde78f799 | ||
|
|
6b70aa0936 | ||
|
|
66e1b3e6f5 | ||
|
|
c6f279c51b | ||
|
|
9bad7f180e | ||
|
|
88292c9c49 | ||
|
|
5878a3dc0b | ||
|
|
ccb23ecf44 | ||
|
|
b6a6515677 | ||
|
|
5e65c7025d | ||
|
|
d7a95ceb36 | ||
|
|
1bd3e7edc6 | ||
|
|
28ba0613ca | ||
|
|
1dce3dfcfb | ||
|
|
d829e451cd |
+17
-3
@@ -37,6 +37,22 @@ MAKER_REFRESH_INTERVAL_MS=500 # Maker refresh cadence (ms)
|
||||
MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks to MAX_CLOSE_SLIPPAGE_PCT)
|
||||
MAKER_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK)
|
||||
|
||||
# Grid strategy defaults
|
||||
GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency)
|
||||
GRID_UPPER_PRICE=35000 # Grid upper bound price
|
||||
GRID_LEVELS=10 # Number of grid levels between bounds (>=2)
|
||||
GRID_ORDER_SIZE=0.001 # Quantity per grid order (base asset units)
|
||||
GRID_MAX_POSITION_SIZE=0.01 # Max inventory the grid may hold (base units)
|
||||
GRID_REFRESH_INTERVAL_MS=1000 # Grid evaluation cadence (ms)
|
||||
GRID_MAX_LOG_ENTRIES=200 # Grid trade log length (defaults to MAX_LOG_ENTRIES when unset)
|
||||
GRID_DIRECTION=both # Order direction: both | long | short
|
||||
GRID_STOP_LOSS_PCT=0.01 # Stop loss trigger percentage beyond bounds (0.01 => 1%)
|
||||
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside bounds
|
||||
GRID_AUTO_RESTART_ENABLED=true # Automatically resume grid when price re-enters range
|
||||
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Close-order slippage guard relative to mark price
|
||||
# GRID_PRICE_TICK=0.1 # Optional override for grid price tick (falls back to PRICE_TICK)
|
||||
# GRID_QTY_STEP=0.001 # Optional override for grid quantity step (falls back to QTY_STEP)
|
||||
|
||||
# GRVT authentication (set when EXCHANGE=grvt)
|
||||
GRVT_API_KEY=
|
||||
GRVT_API_SECRET=
|
||||
@@ -83,12 +99,10 @@ BACKPACK_DEBUG=false
|
||||
# Provide the EVM private key & wallet address for onboarded accounts.
|
||||
# When EXCHANGE=paradex these values are used automatically.
|
||||
|
||||
PARADEX_SYMBOL=BTC-USD-PERP
|
||||
PARADEX_PRIVATE_KEY=
|
||||
PARADEX_WALLET_ADDRESS=
|
||||
|
||||
# Symbol defaults to TRADE_SYMBOL if omitted. Use ccxt unified format like BTC-USD-PERP.
|
||||
# PARADEX_SYMBOL=BTC-USD-PERP
|
||||
|
||||
# Enable testnet endpoints by setting to "true"; defaults to false (mainnet).
|
||||
# PARADEX_SANDBOX=false
|
||||
|
||||
|
||||
@@ -1,42 +1,61 @@
|
||||
# ritmex-bot
|
||||
|
||||
基于 Bun 的 Aster 永续合约量化终端,内置趋势跟随(SMA30)与做市策略,支持快速恢复、实时行情订阅与日志追踪。
|
||||
基于 Bun 的多交易所永续合约量化终端,内置趋势跟随(SMA30)、Guardian 防守与做市策略,支持快速恢复、实时行情订阅、日志追踪与 CLI 仪表盘。
|
||||
|
||||
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
|
||||
|
||||
* [Aster 30% 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
|
||||
* [Binance 手续费优惠注册链接](https://www.binance.com/join?ref=KNKCA9XC)
|
||||
* [GRVT 手续费优惠注册链接](https://grvt.io/exchange/sign-up?ref=sea)
|
||||
* [Backpack 手续费优惠注册链接](https://backpack.exchange/join/ritmex)
|
||||
* [edgex 手续费优惠注册链接](https://pro.edgex.exchange/referral/BULL)
|
||||
* [Paradex 手续费优惠注册链接](https://paradex.io/ref/xingxingjun)
|
||||
* [Apex 手续费优惠注册链接](https://join.omni.apex.exchange/SEA)
|
||||
|
||||
## 文档索引
|
||||
- [English README](README_en.md)
|
||||
- [简明上手指南(零基础)](simple-readme.md)
|
||||
- [基础网格策略使用教程](grid-trading.md)
|
||||
|
||||
## 项目亮点
|
||||
- **实时行情与风控**:Websocket + REST 自动同步账户、挂单与仓位。
|
||||
## 核心特性
|
||||
- **实时行情与风控**:Websocket + REST 自动同步账户、挂单与仓位,断线后自动恢复。
|
||||
- **趋势策略**:SMA30 穿越入场,内置止损、移动止盈、布林带带宽过滤与步进锁盈。
|
||||
- **做市策略**:支持双边追价、风险阈值与订单自愈。
|
||||
- **模块化设计**:适配器、策略引擎与 CLI 解耦,方便扩展新交易所或策略。
|
||||
- **Guardian 策略**:不主动开单,实时监听账户仓位并强制补挂/移动止损与动态止盈,防止裸奔。
|
||||
- **做市策略**:支持双边追价、风险阈值控制与订单自愈。
|
||||
- **模块化架构**:策略引擎、交易所适配器与 Ink CLI 相互解耦,新增交易所或策略更容易。
|
||||
|
||||
## 环境要求
|
||||
- Bun ≥ 1.2(含 `bun`、`bunx` 命令)
|
||||
- macOS、Linux 或 Windows (WSL 推荐)
|
||||
- Node.js 仅在某些安装路径需要,可选
|
||||
## 支持的交易所
|
||||
| 交易所 | 合约类型 | 必填环境变量 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| Aster | USDT 永续 | `ASTER_API_KEY`, `ASTER_API_SECRET` | 默认交易所;兼容脚本引导
|
||||
| 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`
|
||||
| Backpack | USDC 永续 | `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, `BACKPACK_PASSWORD` | `BACKPACK_SANDBOX=true` 启用沙盒
|
||||
| Paradex | StarkEx 永续 | `PARADEX_PRIVATE_KEY`, `PARADEX_WALLET_ADDRESS` | `PARADEX_SANDBOX=true` 使用测试网
|
||||
|
||||
## 快速启动脚本(macOS / Linux / WSL)
|
||||
## 系统要求
|
||||
- Bun ≥ 1.2(需同时包含 `bun`、`bunx` 命令)
|
||||
- macOS、Linux 或 Windows (推荐 WSL)
|
||||
- Node.js 仅在部分工具链场景需要,可选
|
||||
|
||||
## 快速上手
|
||||
### 一键脚本(macOS / Linux / WSL)
|
||||
```bash
|
||||
curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | bash
|
||||
```
|
||||
脚本会安装 Bun、依赖,收集 Aster API Key/Secret,生成 `.env` 并启动 CLI。运行前请准备好 API 凭证。
|
||||
脚本会安装 Bun、项目依赖,收集 Aster API 凭证,生成 `.env` 并启动 CLI。运行前请准备好对应交易所的 API Key/Secret。
|
||||
|
||||
## 手动安装步骤
|
||||
### 手动安装
|
||||
1. **获取代码**
|
||||
```bash
|
||||
git clone https://github.com/discountry/ritmex-bot.git
|
||||
cd ritmex-bot
|
||||
```
|
||||
不方便使用 Git 时,可在仓库页面下载 ZIP 并手动解压。
|
||||
不便使用 Git 时,可在仓库页面下载 ZIP 后手动解压。
|
||||
2. **安装 Bun**
|
||||
- macOS / Linux:`curl -fsSL https://bun.sh/install | bash`
|
||||
- Windows PowerShell:`powershell -c "irm bun.sh/install.ps1 | iex"`
|
||||
安装后重新打开终端,确认 `bun -v` 正常输出版本号。
|
||||
安装完成后重新打开终端,确认 `bun -v` 正常输出版本号。
|
||||
3. **安装依赖**
|
||||
```bash
|
||||
bun install
|
||||
@@ -45,67 +64,87 @@ curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
按下文说明修改 `.env`,至少需要正确配置 Aster 或 GRVT 的 API。
|
||||
按下文指南修改 `.env`,至少需要正确配置一个交易所的凭证。
|
||||
5. **运行 CLI**
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
方向键选择策略,回车启动;`Esc` 返回菜单,`Ctrl+C` 退出。
|
||||
方向键选择策略并回车启动;`Esc` 返回菜单,`Ctrl+C` 退出。
|
||||
|
||||
## 环境变量配置指南
|
||||
核心变量在 `.env.example` 中给出默认值:
|
||||
## 通用环境变量
|
||||
`.env.example` 提供了所有默认键值,下表概括最常用参数:
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `ASTER_API_KEY` / `ASTER_API_SECRET` | Aster API 凭证,运行策略必填 |
|
||||
| `EXCHANGE` | 选择交易所(`aster`/`grvt`/`lighter`/`backpack`/`paradex`) |
|
||||
| `TRADE_SYMBOL` | 交易对(默认 `BTCUSDT`) |
|
||||
| `TRADE_AMOUNT` | 单笔下单数量(标的资产计) |
|
||||
| `LOSS_LIMIT` | 单笔最大亏损触发的强平额度(USDT) |
|
||||
| `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | 动态止盈触发值(USDT)与回撤百分比 |
|
||||
| `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | 浮盈超过阈值后上调止损的触发金额与偏移 |
|
||||
| `BOLLINGER_LENGTH` / `BOLLINGER_STD_MULTIPLIER` | 布林带宽度判定的窗口长度与标准差倍数 |
|
||||
| `MIN_BOLLINGER_BANDWIDTH` | 仅当带宽 ≥ 此比例时才触发入场信号 |
|
||||
| `BOLLINGER_*` | 趋势策略布林带过滤参数 |
|
||||
| `PRICE_TICK` / `QTY_STEP` | 交易所要求的最小报价与数量精度 |
|
||||
| `POLL_INTERVAL_MS` | 趋势策略循环间隔(毫秒) |
|
||||
| `MAX_CLOSE_SLIPPAGE_PCT` | 平仓时相对标记价允许的最大偏差 |
|
||||
| `MAKER_*` 系列 | 做市策略独有参数(追价阈值、报价偏移、刷新频率等) |
|
||||
| `MAKER_*` | 做市策略专属参数(追价阈值、报价偏移、刷新频率等) |
|
||||
|
||||
切换到 GRVT 时,将 `EXCHANGE=grvt` 并补齐 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID` 等变量;详情见 `.env.example`。
|
||||
|
||||
> 提示:你也可以通过命令行参数临时指定交易所(优先级高于环境变量):
|
||||
> 可通过命令行临时覆盖交易所与策略(优先级高于 `.env`):
|
||||
> ```bash
|
||||
> bun run index.ts --exchange grvt
|
||||
> bun run index.ts -e lighter
|
||||
> bun run index.ts --exchange grvt --strategy maker
|
||||
> bun run index.ts -e lighter -s offset-maker --silent
|
||||
> ```
|
||||
|
||||
## 常用命令
|
||||
## 交易所配置指南
|
||||
### Aster
|
||||
1. 将 `EXCHANGE` 保持为 `aster`(默认值)。
|
||||
2. 填写 `ASTER_API_KEY` 与 `ASTER_API_SECRET`。
|
||||
3. 根据交易对调整 `TRADE_SYMBOL`、`PRICE_TICK`、`QTY_STEP` 等精度参数。
|
||||
4. 一键脚本会自动写入这些变量,手动部署时需自行维护。
|
||||
|
||||
### GRVT
|
||||
1. 在 `.env` 中设置 `EXCHANGE=grvt`。
|
||||
2. 填写 `GRVT_API_KEY`、`GRVT_API_SECRET`、`GRVT_SUB_ACCOUNT_ID`。
|
||||
3. 若使用测试网,可将 `GRVT_ENV=testnet` 并调整 `GRVT_INSTRUMENT`/`GRVT_SYMBOL`。
|
||||
4. 可选:提供 `GRVT_COOKIE` 或自定义 `GRVT_SIGNER_PATH` 以复用已有登录态。
|
||||
|
||||
### Lighter
|
||||
1. 设置 `EXCHANGE=lighter`。
|
||||
2. 填写 `LIGHTER_ACCOUNT_INDEX` 与 `LIGHTER_API_PRIVATE_KEY`(40 字节十六进制私钥),其中`LIGHTER_ACCOUNT_INDEX`是你的账户索引,需要你在官网按F12观察接口请求获取,`LIGHTER_API_PRIVATE_KEY`是你的API私钥。
|
||||
3. 如需切换环境,将 `LIGHTER_ENV` 改为 `mainnet`/`staging`/`dev`;必要时指定 `LIGHTER_BASE_URL`。
|
||||
4. 交易对默认为 `LIGHTER_SYMBOL=BTCUSDT`,也可按需重写价格与数量小数位。
|
||||
|
||||
### Backpack
|
||||
1. 设置 `EXCHANGE=backpack`。
|
||||
2. 填写 `BACKPACK_API_KEY`、`BACKPACK_API_SECRET`、`BACKPACK_PASSWORD`;如有分账户,补充 `BACKPACK_SUBACCOUNT`,默认填写主账户ID。
|
||||
3. 使用测试环境时将 `BACKPACK_SANDBOX=true`,并确认 `BACKPACK_SYMBOL` 与实际符号一致(默认 `BTC_USD_PERP`)。
|
||||
4. 可通过 `BACKPACK_DEBUG=true` 观察适配器详细日志。
|
||||
|
||||
### Paradex
|
||||
1. 设置 `EXCHANGE=paradex`。
|
||||
2. 提供 `PARADEX_PRIVATE_KEY`(EVM 私钥)与 `PARADEX_WALLET_ADDRESS` 注意这是你EVM钱包的地址和私钥,建议创建全新钱包,不要放置无关资产。
|
||||
3. 默认连接主网,若需测试网,将 `PARADEX_SANDBOX=true` 并根据需要调整 `PARADEX_SYMBOL`。
|
||||
4. 复杂环境可额外设置 `PARADEX_USE_PRO`、`PARADEX_RECONNECT_DELAY_MS` 或调试开关。
|
||||
|
||||
## 命令速查
|
||||
```bash
|
||||
bun run index.ts # 启动 CLI(默认)
|
||||
bun run start # 同上
|
||||
bun run dev # 调试模式,等价于运行 index.ts
|
||||
bun x vitest run # 执行单元测试
|
||||
bun run index.ts # 启动 CLI(默认入口)
|
||||
bun run start # 等价于运行 index.ts
|
||||
bun run dev # 调试模式
|
||||
bun x vitest run # 执行全部测试
|
||||
```
|
||||
|
||||
## 静默启动与后台运行
|
||||
### 直接静默启动
|
||||
无需进入 Ink 菜单,可用命令行直接拉起指定策略:
|
||||
|
||||
```bash
|
||||
bun run index.ts --strategy trend --silent # 启动趋势策略
|
||||
bun run index.ts --strategy maker --silent # 启动做市策略
|
||||
bun run index.ts --strategy offset-maker --silent # 启动偏移做市策略
|
||||
```
|
||||
|
||||
如需同时指定交易所,可叠加 `--exchange/-e`(将覆盖 `.env` 中的 `EXCHANGE`/`TRADE_EXCHANGE`):
|
||||
|
||||
```bash
|
||||
bun run index.ts --exchange grvt --strategy maker --silent
|
||||
bun run index.ts -e lighter -s offset-maker --silent
|
||||
bun run index.ts --strategy trend --silent
|
||||
bun run index.ts --strategy maker --silent
|
||||
bun run index.ts --strategy offset-maker --silent
|
||||
```
|
||||
如需同时指定交易所,可叠加 `--exchange/-e` 参数。
|
||||
|
||||
### 项目内置脚本
|
||||
`package.json` 提供了便捷脚本:
|
||||
|
||||
```bash
|
||||
bun run start:trend:silent
|
||||
bun run start:maker:silent
|
||||
@@ -113,43 +152,38 @@ bun run start:offset:silent
|
||||
```
|
||||
|
||||
### 使用 pm2 守护并自动重启
|
||||
将 `pm2` 安装到项目中(示例:`bun add -d pm2`),之后即可在不安装全局 pm2 的情况下运行:
|
||||
|
||||
安装 `pm2`(示例:`bun add -d pm2`)后,可在项目内直接运行:
|
||||
```bash
|
||||
bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent
|
||||
```
|
||||
|
||||
亦可直接调用脚本:
|
||||
|
||||
或调用预置脚本:
|
||||
```bash
|
||||
bun run pm2:start:trend
|
||||
bun run pm2:start:maker
|
||||
bun run pm2:start:offset
|
||||
```
|
||||
|
||||
根据需要调整 `--name`、`--cwd`、`--restart-delay` 等参数,完成后可执行 `pm2 save` 持久化进程列表。
|
||||
完成配置后可执行 `pm2 save` 持久化进程列表。
|
||||
|
||||
## 测试
|
||||
项目使用 Vitest:
|
||||
```bash
|
||||
bun run test # 运行全部测试
|
||||
bun run test
|
||||
bun x vitest --watch
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
- 你需要至少 50-100 USDT 的资金才能运行策略
|
||||
- 请在交易所自行设置 50 倍左右的杠杆,本策略不包含杠杆设置
|
||||
- 请确保你电脑/服务器的时间是准确的真实世界时间
|
||||
- 持仓方式需要保持单向持仓
|
||||
- 至少准备 50–100 USDT 资金以覆盖策略运行需求。
|
||||
- 杠杆需在交易所提前设置(建议 ~50 倍),程序不会自动调整。
|
||||
- 请确保服务器/电脑时间同步真实世界时间,避免签名过期。
|
||||
- 账户需保持单向持仓模式。
|
||||
- `.env` 未读取:确认文件位于项目根目录且变量名无误。
|
||||
- API 拒绝访问:检查交易所后台权限,确保开启合约读写。
|
||||
- 精度错误:同步交易对的最小价格与数量步长。
|
||||
更多排查步骤可参考 [简明上手指南](simple-readme.md)。
|
||||
更多排查细节可参见 [简明上手指南](simple-readme.md)。
|
||||
|
||||
## 社区与支持
|
||||
- Telegram 交流群:[https://t.me/+4fdo0quY87o4Mjhh](https://t.me/+4fdo0quY87o4Mjhh)
|
||||
- 反馈或新特性建议请提交 Issue 或 PR
|
||||
- 欢迎通过 Issue 或 PR 提交反馈、特性建议
|
||||
|
||||
## 风险提示
|
||||
量化交易具备风险。建议在仿真或小额账户中验证策略表现,妥善保管 API 密钥,仅开启必要权限。
|
||||
量化交易具备风险。请先在仿真或小额账户中验证策略表现,妥善保管 API 密钥,仅开启必要权限。
|
||||
|
||||
+101
-66
@@ -1,39 +1,58 @@
|
||||
# ritmex-bot
|
||||
|
||||
A Bun-powered trading workstation for Aster perpetual contracts that ships two production-ready agents: an SMA30 trend follower and a dual-sided market maker. The CLI is built with Ink, synchronises risk state from the exchange, and automatically recovers from restarts or disconnects.
|
||||
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.
|
||||
|
||||
* [Aster referral (30% fee discount)](https://www.asterdex.com/en/referral/4665f3)
|
||||
* [Binance referral link](https://www.binance.com/join?ref=KNKCA9XC)
|
||||
* [GRVT referral link](https://grvt.io/exchange/sign-up?ref=sea)
|
||||
* [Backpack referral link](https://backpack.exchange/join/ritmex)
|
||||
* [edgex referral link](https://pro.edgex.exchange/referral/BULL)
|
||||
* [Paradex referral link](https://paradex.io/ref/xingxingjun)
|
||||
* [Apex referral link](https://join.omni.apex.exchange/RITHMEX)
|
||||
|
||||
## Documentation Map
|
||||
- [中文 README](README.md)
|
||||
- [Beginner-friendly Quick Start](simple-readme.md)
|
||||
|
||||
## Highlights
|
||||
- **Live market data & risk sync** via websocket feeds with REST fallbacks, full reconciliation on restart.
|
||||
- **Trend engine** featuring SMA30 entries, fixed stop loss, trailing stop, Bollinger bandwidth gate, and profit-lock stepping.
|
||||
- **Market-making loop** with adaptive quote chasing, loss caps, and automatic order healing.
|
||||
- **Extensible architecture** decoupling exchange adapters, engines, and the Ink CLI for easy venue or strategy additions.
|
||||
- **Live data & risk sync** via websockets with REST fallbacks and full reconciliation on restart.
|
||||
- **Trend strategy** featuring SMA30 entries, fixed stop loss, trailing stop, Bollinger bandwidth gate, and profit-lock stepping.
|
||||
- **Guardian strategy** that never opens trades but mirrors your live exposure, ensuring every position has a synced stop loss and trailing stop.
|
||||
- **Market-making loop** with dual-sided quote chasing, loss caps, and automatic order healing.
|
||||
- **Modular architecture** decoupling engines, exchange adapters, and the Ink CLI for easy venue or strategy extensions.
|
||||
|
||||
## Supported Exchanges
|
||||
| Exchange | Contract Type | Required Environment Variables | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Aster | USDT perpetuals | `ASTER_API_KEY`, `ASTER_API_SECRET` | Default venue; works with the bootstrap script |
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
||||
## Requirements
|
||||
- Bun ≥ 1.2 (`bun`, `bunx` available on PATH)
|
||||
- Bun ≥ 1.2 (both `bun` and `bunx` on PATH)
|
||||
- macOS, Linux, or Windows via WSL (native Windows works but WSL is recommended)
|
||||
- Node.js is optional unless your environment requires it for tooling
|
||||
- Node.js is optional unless your tooling requires it
|
||||
|
||||
## One-Line Bootstrap (macOS / Linux / WSL)
|
||||
## Quick Start
|
||||
### One-line bootstrap (macOS / Linux / WSL)
|
||||
```bash
|
||||
curl -fsSL https://github.com/discountry/ritmex-bot/raw/refs/heads/main/setup.sh | bash
|
||||
```
|
||||
The script installs Bun, project dependencies, collects Aster API credentials, generates `.env`, and launches the CLI. Prepare your API Key/Secret before running.
|
||||
The script installs Bun, project dependencies, collects Aster API credentials, generates `.env`, and launches the CLI. Prepare the relevant exchange API keys before running it.
|
||||
|
||||
## Manual Installation
|
||||
### Manual installation
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/discountry/ritmex-bot.git
|
||||
cd ritmex-bot
|
||||
```
|
||||
Alternatively download the ZIP from GitHub and extract it manually.
|
||||
Alternatively, download the ZIP from GitHub and extract it manually.
|
||||
2. **Install Bun**
|
||||
- macOS / Linux: `curl -fsSL https://bun.sh/install | bash`
|
||||
- Windows PowerShell: `powershell -c "irm bun.sh/install.ps1 | iex"`
|
||||
Re-open the terminal and confirm `bun -v` prints a version.
|
||||
Re-open the terminal and verify `bun -v` prints a version.
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
bun install
|
||||
@@ -42,67 +61,87 @@ The script installs Bun, project dependencies, collects Aster API credentials, g
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Edit `.env` with your exchange credentials and overrides.
|
||||
Edit `.env` with the exchange credentials and overrides you plan to use.
|
||||
5. **Launch the CLI**
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
Use the arrow keys to pick a strategy, `Enter` to start, `Esc` to return to the menu, and `Ctrl+C` to exit.
|
||||
Use the arrow keys to pick a strategy, `Enter` to start, `Esc` to go back, and `Ctrl+C` to exit.
|
||||
|
||||
## Environment Variables
|
||||
The most important settings shipped in `.env.example` are summarised below:
|
||||
## Shared Configuration
|
||||
`.env.example` captures all defaults; the most common settings are summarised below.
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `ASTER_API_KEY` / `ASTER_API_SECRET` | Required Aster exchange credentials |
|
||||
| `TRADE_SYMBOL` | Contract symbol, defaults to `BTCUSDT` |
|
||||
| `EXCHANGE` | Choose the venue (`aster` / `grvt` / `lighter` / `backpack` / `paradex`) |
|
||||
| `TRADE_SYMBOL` | Contract symbol (defaults to `BTCUSDT`) |
|
||||
| `TRADE_AMOUNT` | Order size in base asset units |
|
||||
| `LOSS_LIMIT` | Max per-trade loss (USDT) before forced close |
|
||||
| `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | Trailing stop trigger amount (USDT) and pullback percentage |
|
||||
| `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | Move the base stop once unrealised PnL exceeds this trigger |
|
||||
| `BOLLINGER_LENGTH` / `BOLLINGER_STD_MULTIPLIER` | Window size and std-dev multiplier for bandwidth filtering |
|
||||
| `MIN_BOLLINGER_BANDWIDTH` | Minimum bandwidth ratio required before opening a new position |
|
||||
| `LOSS_LIMIT` | Max per-trade loss in USDT before forced close |
|
||||
| `TRAILING_PROFIT` / `TRAILING_CALLBACK_RATE` | Trailing stop trigger (USDT) and pullback percentage |
|
||||
| `PROFIT_LOCK_TRIGGER_USD` / `PROFIT_LOCK_OFFSET_USD` | Profit lock trigger and offset thresholds |
|
||||
| `BOLLINGER_*` | Bollinger bandwidth filters for the trend engine |
|
||||
| `PRICE_TICK` / `QTY_STEP` | Exchange precision filters for price and quantity |
|
||||
| `POLL_INTERVAL_MS` | Trend engine polling cadence in milliseconds |
|
||||
| `MAX_CLOSE_SLIPPAGE_PCT` | Allowed deviation vs mark price when closing |
|
||||
| `MAKER_*` | Maker strategy knobs: chase threshold, quote offsets, refresh cadence, etc. |
|
||||
| `MAKER_*` | Maker-specific knobs (quote offsets, refresh cadence, slippage guard, etc.) |
|
||||
|
||||
To trade on GRVT, set `EXCHANGE=grvt` and populate `GRVT_API_KEY`, `GRVT_API_SECRET`, `GRVT_SUB_ACCOUNT_ID`, plus any optional overrides documented in `.env.example`.
|
||||
|
||||
> Tip: you can temporarily override the exchange via CLI flags (takes precedence over environment):
|
||||
> CLI flags override environment variables at runtime:
|
||||
> ```bash
|
||||
> bun run index.ts --exchange grvt
|
||||
> bun run index.ts -e lighter
|
||||
> bun run index.ts --exchange grvt --strategy maker
|
||||
> bun run index.ts -e lighter -s offset-maker --silent
|
||||
> ```
|
||||
|
||||
## Common Commands
|
||||
## Exchange Setup Guides
|
||||
### Aster
|
||||
1. Keep `EXCHANGE=aster` (default value).
|
||||
2. Supply `ASTER_API_KEY` and `ASTER_API_SECRET`.
|
||||
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.
|
||||
|
||||
### GRVT
|
||||
1. Set `EXCHANGE=grvt` inside `.env`.
|
||||
2. Fill `GRVT_API_KEY`, `GRVT_API_SECRET`, and `GRVT_SUB_ACCOUNT_ID`.
|
||||
3. Use `GRVT_ENV=testnet` when targeting the test environment, and align `GRVT_INSTRUMENT` / `GRVT_SYMBOL`.
|
||||
4. Optional: provide `GRVT_COOKIE` or a custom `GRVT_SIGNER_PATH` when reusing an existing session.
|
||||
|
||||
### Lighter
|
||||
1. Set `EXCHANGE=lighter`.
|
||||
2. Provide `LIGHTER_ACCOUNT_INDEX` and `LIGHTER_API_PRIVATE_KEY` (40-byte hex private key).
|
||||
3. Switch `LIGHTER_ENV` to `mainnet`, `staging`, or `dev` when necessary; override `LIGHTER_BASE_URL` if endpoints differ.
|
||||
4. `LIGHTER_SYMBOL` defaults to `BTCUSDT`; override price/size decimals when markets differ.
|
||||
|
||||
### Backpack
|
||||
1. Set `EXCHANGE=backpack`.
|
||||
2. Populate `BACKPACK_API_KEY`, `BACKPACK_API_SECRET`, and `BACKPACK_PASSWORD`; add `BACKPACK_SUBACCOUNT` if you trade from a subaccount.
|
||||
3. Toggle `BACKPACK_SANDBOX=true` for the sandbox environment and verify `BACKPACK_SYMBOL` matches the contract (defaults to `BTC_USD_PERP`).
|
||||
4. Enable `BACKPACK_DEBUG=true` for verbose adapter logging.
|
||||
|
||||
### Paradex
|
||||
1. Set `EXCHANGE=paradex`.
|
||||
2. Provide `PARADEX_PRIVATE_KEY` (EVM private key) and `PARADEX_WALLET_ADDRESS`.
|
||||
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.
|
||||
|
||||
## Command Cheatsheet
|
||||
```bash
|
||||
bun run index.ts # Launch the CLI
|
||||
bun run start # Same as above
|
||||
bun run dev # Development entry point
|
||||
bun x vitest run # Execute the Vitest suite
|
||||
bun run index.ts # Launch the CLI (default entrypoint)
|
||||
bun run start # Alias for bun run index.ts
|
||||
bun run dev # Development entrypoint
|
||||
bun x vitest run # Execute the full Vitest suite
|
||||
```
|
||||
|
||||
## Silent & Background Execution
|
||||
### Direct silent launch
|
||||
Skip the Ink menu and start a strategy straight from the CLI:
|
||||
|
||||
Skip the Ink menu and start a strategy directly:
|
||||
```bash
|
||||
bun run index.ts --strategy trend --silent # Trend engine
|
||||
bun run index.ts --strategy maker --silent # Maker engine
|
||||
bun run index.ts --strategy offset-maker --silent # Offset maker engine
|
||||
```
|
||||
|
||||
Combine with `--exchange/-e` to explicitly choose the venue (overrides `EXCHANGE`/`TRADE_EXCHANGE` from `.env`):
|
||||
|
||||
```bash
|
||||
bun run index.ts --exchange grvt --strategy maker --silent
|
||||
bun run index.ts -e lighter -s offset-maker --silent
|
||||
bun run index.ts --strategy trend --silent
|
||||
bun run index.ts --strategy maker --silent
|
||||
bun run index.ts --strategy offset-maker --silent
|
||||
```
|
||||
Combine with `--exchange/-e` to pin the venue for that run.
|
||||
|
||||
### Package scripts
|
||||
Convenience aliases are exposed in `package.json`:
|
||||
|
||||
Convenience aliases exposed via `package.json`:
|
||||
```bash
|
||||
bun run start:trend:silent
|
||||
bun run start:maker:silent
|
||||
@@ -110,42 +149,38 @@ bun run start:offset:silent
|
||||
```
|
||||
|
||||
### Daemonising with pm2
|
||||
Install `pm2` locally (e.g. `bun add -d pm2`) and launch without a global install:
|
||||
|
||||
Install `pm2` locally (e.g. `bun add -d pm2`) and launch the process:
|
||||
```bash
|
||||
bunx pm2 start bun --name ritmex-trend --cwd . --restart-delay 5000 -- run index.ts --strategy trend --silent
|
||||
```
|
||||
|
||||
You can also reuse the bundled scripts:
|
||||
|
||||
You can also call the bundled scripts:
|
||||
```bash
|
||||
bun run pm2:start:trend
|
||||
bun run pm2:start:maker
|
||||
bun run pm2:start:offset
|
||||
```
|
||||
|
||||
Adjust `--name`, `--cwd`, or `--restart-delay` to suit your environment and run `pm2 save` if you want the process to auto-start after reboot.
|
||||
Run `pm2 save` afterwards if you want the process list to survive reboots.
|
||||
|
||||
## Testing
|
||||
Vitest powers the unit tests:
|
||||
Powered by Vitest:
|
||||
```bash
|
||||
bun run test
|
||||
bun x vitest --watch
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- You need at least 50–100 USDT of capital before deploying a live strategy.
|
||||
- Set leverage on the exchange beforehand (around 50x is recommended); the bot does not change it for you.
|
||||
- Keep server/desktop time in sync with real-world time to avoid signature errors.
|
||||
- Make sure the exchange account is in one-way position mode.
|
||||
- **Env not loading**: ensure `.env` resides in the repository root and variable names are spelled correctly.
|
||||
- **Order rejected for precision**: align `PRICE_TICK`, `QTY_STEP`, and `TRADE_SYMBOL` with the exchange filters.
|
||||
- **Permission or auth errors**: double-check exchange API scopes.
|
||||
More step-by-step guidance is available in [simple-readme.md](simple-readme.md).
|
||||
- Keep at least 50–100 USDT in the account before deploying a live strategy.
|
||||
- Configure leverage on the exchange manually (~50x is recommended); the bot will not change it.
|
||||
- Ensure your server or workstation clock is in sync to avoid signature errors.
|
||||
- Accounts must run in one-way position mode.
|
||||
- **Env not loading**: make sure `.env` lives in the repo root and variable names are spelled correctly.
|
||||
- **Permission rejected**: confirm the API key has perpetual trading scopes enabled.
|
||||
- **Precision errors**: align `PRICE_TICK`, `QTY_STEP`, and `TRADE_SYMBOL` with the exchange filters.
|
||||
See [simple-readme.md](simple-readme.md) for more detailed walkthroughs.
|
||||
|
||||
## Community & Support
|
||||
- Telegram: [https://t.me/+4fdo0quY87o4Mjhh](https://t.me/+4fdo0quY87o4Mjhh)
|
||||
- Issues and PRs are welcome for bug reports and feature ideas
|
||||
- Issues and PRs are welcome for bug reports and feature requests
|
||||
|
||||
## Disclaimer
|
||||
Algorithmic trading carries risk. Validate strategies with paper accounts or small capital first, safeguard your API keys, and only grant the minimum required permissions.
|
||||
Algorithmic trading carries risk. Validate strategies with paper trading or small capital first, safeguard your API keys, and only grant the minimum required permissions.
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
"name": "ritmex-bot",
|
||||
"dependencies": {
|
||||
"@grvt/client": "^1.6.4",
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"axios": "^1.12.2",
|
||||
"ccxt": "^4.5.5",
|
||||
"ccxt": "^4.5.12",
|
||||
"dotenv": "^17.2.2",
|
||||
"ethereum-cryptography": "^2.1.3",
|
||||
"ink": "^6.3.1",
|
||||
@@ -83,6 +84,8 @@
|
||||
|
||||
"@noble/curves": ["@noble/curves@1.4.2", "", { "dependencies": { "@noble/hashes": "1.4.0" } }, "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw=="],
|
||||
|
||||
"@noble/ed25519": ["@noble/ed25519@3.0.0", "", {}, "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A=="],
|
||||
@@ -185,7 +188,7 @@
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"ccxt": ["ccxt@4.5.5", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-AyhwTFLkx4sO985ImIOfumEBox7AHD/iqk5tPGICObUSZG6wTXg0aRzU8Hjz974aCMG4msFwLk3A/iXPKAU4wA=="],
|
||||
"ccxt": ["ccxt@4.5.12", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-2lfL2TKjq4vBkQUQWJfDqFywhvYCZmk9r0SWC8GqA4AHZ6qozKVUJowxQTvdRsLX9jBwYSE0nc7JVurBrQ6SHg=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+253
-158
@@ -1,18 +1,16 @@
|
||||
WebSocket
|
||||
This page will help you get started with zkLighter WebSocket server.
|
||||
|
||||
Connection
|
||||
URL: wss://mainnet.zklighter.elliot.ai/stream
|
||||
# WebSocket
|
||||
URL: `wss://mainnet.zklighter.elliot.ai/stream`
|
||||
|
||||
You can directly connect to the WebSocket server using wscat:
|
||||
|
||||
|
||||
```
|
||||
wscat -c 'wss://mainnet.zklighter.elliot.ai/stream'
|
||||
Send Tx
|
||||
```
|
||||
|
||||
|
||||
You can send transactions using the websocket as follows:
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "jsonapi/sendtx",
|
||||
"data": {
|
||||
@@ -20,32 +18,33 @@ JSON
|
||||
"tx_info": ...
|
||||
}
|
||||
}
|
||||
The tx_type options can be found in the SignerClient file, while tx_info can be generated using the sign methods in the SignerClient.
|
||||
```
|
||||
|
||||
Example: ws_send_tx.py
|
||||
|
||||
Send Batch Tx
|
||||
The _tx\_type_ options can be found in the [SignerClient](https://github.com/elliottech/lighter-python/blob/main/lighter/signer_client.py) file, while _tx\_info_ can be generated using the sign methods in the SignerClient.
|
||||
|
||||
Example: [ws\_send\_tx.py](https://github.com/elliottech/lighter-python/blob/main/examples/ws_send_tx.py)
|
||||
|
||||
You can send batch transactions to execute up to 50 transactions in a single message.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "jsonapi/sendtxbatch",
|
||||
"data": {
|
||||
"tx_types": [INTEGER],
|
||||
"tx_infos": [tx_info]
|
||||
"tx_types": "[INTEGER]",
|
||||
"tx_infos": "[tx_info]"
|
||||
}
|
||||
}
|
||||
The tx_type options can be found in the SignerClient file, while tx_info can be generated using the sign methods in the SignerClient.
|
||||
```
|
||||
|
||||
Example: ws_send_batch_tx.py
|
||||
|
||||
Types
|
||||
The _tx\_type_ options can be found in the [SignerClient](https://github.com/elliottech/lighter-python/blob/main/lighter/signer_client.py) file, while _tx\_info_ can be generated using the sign methods in the SignerClient.
|
||||
|
||||
Example: [ws\_send\_batch\_tx.py](https://github.com/elliottech/lighter-python/blob/main/examples/ws_send_batch_tx.py)
|
||||
|
||||
We first need to define some types that appear often in the JSONs.
|
||||
|
||||
Transaction JSON
|
||||
JSON
|
||||
|
||||
```
|
||||
Transaction = {
|
||||
"hash": STRING,
|
||||
"type": INTEGER,
|
||||
@@ -63,10 +62,12 @@ Transaction = {
|
||||
"sequence_index": INTEGER,
|
||||
"parent_hash": STRING
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"hash": "0xabc123456789def",
|
||||
"type": 15,
|
||||
@@ -84,11 +85,12 @@ JSON
|
||||
"sequence_index": 5678,
|
||||
"parent_hash": "0xparenthash123456"
|
||||
}
|
||||
Used in: Transaction, Executed Transaction, Account Tx.
|
||||
```
|
||||
|
||||
Order JSON
|
||||
JSON
|
||||
|
||||
Used in: [Transaction](https://apibetadocs.lighter.xyz/docs/websocket-reference#transaction), [Executed Transaction](https://apibetadocs.lighter.xyz/docs/websocket-reference#executed-transaction), [Account Tx](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-tx).
|
||||
|
||||
```
|
||||
Order = {
|
||||
"order_index": INTEGER,
|
||||
"client_order_index": INTEGER,
|
||||
@@ -122,11 +124,12 @@ Order = {
|
||||
"block_height": INTEGER,
|
||||
"timestamp": INTEGER,
|
||||
}
|
||||
Used in: Account Market, Account All Orders, Account Orders.
|
||||
```
|
||||
|
||||
Trade JSON
|
||||
JSON
|
||||
|
||||
Used in: [Account Market](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-market), [Account All Orders](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all-orders), [Account Orders](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-orders).
|
||||
|
||||
```
|
||||
Trade = {
|
||||
"trade_id": INTEGER,
|
||||
"tx_hash": STRING,
|
||||
@@ -153,10 +156,12 @@ Trade = {
|
||||
"maker_initial_margin_fraction_before": INTEGER (omitted when zero),
|
||||
"maker_position_sign_changed": BOOL (omitted when false),
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"trade_id": 401,
|
||||
"tx_hash": "0xabc123456789",
|
||||
@@ -179,11 +184,12 @@ JSON
|
||||
"maker_entry_quote_before":"3075.396750",
|
||||
"maker_initial_margin_fraction_before":400
|
||||
}
|
||||
Used in: Trade, Account All, Account Market, Account All Trades.
|
||||
```
|
||||
|
||||
Position JSON
|
||||
JSON
|
||||
|
||||
Used in: [Trade](https://apibetadocs.lighter.xyz/docs/websocket-reference#trade), [Account All](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all), [Account Market](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-market), [Account All Trades](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all-trades).
|
||||
|
||||
```
|
||||
Position = {
|
||||
"market_id": INTEGER,
|
||||
"symbol": STRING,
|
||||
@@ -202,10 +208,12 @@ Position = {
|
||||
"margin_mode": INT,
|
||||
"allocated_margin": STRING,
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"market_id": 101,
|
||||
"symbol": "BTC-USD",
|
||||
@@ -224,49 +232,56 @@ JSON
|
||||
"margin_mode": 1,
|
||||
"allocated_margin": "46342",
|
||||
}
|
||||
Used in: Account All, Account Market, Account All Positions.
|
||||
```
|
||||
|
||||
PoolShares JSON
|
||||
JSON
|
||||
|
||||
Used in: [Account All](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all), [Account Market](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-market), [Account All Positions](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all-positions).
|
||||
|
||||
```
|
||||
PoolShares = {
|
||||
"public_pool_index": INTEGER,
|
||||
"shares_amount": INTEGER,
|
||||
"entry_usdc": STRING
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"public_pool_index": 1,
|
||||
"shares_amount": 100,
|
||||
"entry_usdc": "1000.00"
|
||||
}
|
||||
Used in: Account All, Account All Positions.
|
||||
```
|
||||
|
||||
|
||||
Used in: [Account All](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all), [Account All Positions](https://apibetadocs.lighter.xyz/docs/websocket-reference#account-all-positions).
|
||||
|
||||
Channels
|
||||
Order Book
|
||||
The order book channel sends the new ask and bid orders for the given market.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "order_book/{MARKET_INDEX}"
|
||||
}
|
||||
Example Subscription
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Subscription**
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "order_book/0"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "order_book:{MARKET_INDEX}",
|
||||
"offset": INTEGER,
|
||||
@@ -288,10 +303,12 @@ JSON
|
||||
},
|
||||
"type": "update/order_book"
|
||||
}
|
||||
Example Response
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Response**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "order_book:0",
|
||||
"offset": 41692864,
|
||||
@@ -313,35 +330,42 @@ JSON
|
||||
},
|
||||
"type": "update/order_book"
|
||||
}
|
||||
Market Stats
|
||||
```
|
||||
|
||||
|
||||
The market stats channel sends the market stat data for the given market.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "market_stats/{MARKET_INDEX}"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
or
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "market_stats/all"
|
||||
}
|
||||
Example Subscription
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Subscription**
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "market_stats/0"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "market_stats:{MARKET_INDEX}",
|
||||
"market_stats": {
|
||||
@@ -361,10 +385,12 @@ JSON
|
||||
},
|
||||
"type": "update/market_stats"
|
||||
}
|
||||
Example Response
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Response**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "market_stats:0",
|
||||
"market_stats": {
|
||||
@@ -384,37 +410,44 @@ JSON
|
||||
},
|
||||
"type": "update/market_stats"
|
||||
}
|
||||
Trade
|
||||
```
|
||||
|
||||
|
||||
The trade channel sends the new trade data for the given market.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "trade/{MARKET_INDEX}"
|
||||
}
|
||||
Example Subscription
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Subscription**
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "trade/0"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "trade:{MARKET_INDEX}",
|
||||
"trades": [Trade]
|
||||
],
|
||||
"type": "update/trade"
|
||||
}
|
||||
Example Response
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Response**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "trade:0",
|
||||
"trades": [
|
||||
@@ -437,27 +470,32 @@ JSON
|
||||
],
|
||||
"type": "update/trade"
|
||||
}
|
||||
Account All
|
||||
```
|
||||
|
||||
|
||||
The account all channel sends specific account market data for all markets.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_all/{ACCOUNT_ID}"
|
||||
}
|
||||
Example Subscription
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Subscription**
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_all/1"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"account": INTEGER,
|
||||
"channel": "account_all:{ACCOUNT_ID}",
|
||||
@@ -491,10 +529,12 @@ JSON
|
||||
},
|
||||
"type": "update/account_all"
|
||||
}
|
||||
Example Response
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Response**
|
||||
|
||||
```
|
||||
{
|
||||
"account": 10,
|
||||
"channel": "account_all:10",
|
||||
@@ -574,29 +614,34 @@ JSON
|
||||
},
|
||||
"type": "update/account"
|
||||
}
|
||||
Account Market
|
||||
```
|
||||
|
||||
|
||||
The account market channel sends specific account market data for a market.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_market/{MARKET_ID}/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Example Subscription
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Subscription**
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_market/0/40",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"account": INTEGER,
|
||||
"channel": "account_all/{MARKET_ID}/{ACCOUNT_ID}",
|
||||
@@ -614,27 +659,32 @@ JSON
|
||||
"trades": [Trade],
|
||||
"type": "update/account_market"
|
||||
}
|
||||
Account Stats
|
||||
```
|
||||
|
||||
|
||||
The account stats channel sends account stats data for the specific account.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "user_stats/{ACCOUNT_ID}"
|
||||
}
|
||||
Example Subscription
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Subscription**
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "user_stats/0"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "user_stats:{ACCOUNT_ID}",
|
||||
"stats": {
|
||||
@@ -664,10 +714,12 @@ JSON
|
||||
},
|
||||
"type": "update/user_stats"
|
||||
}
|
||||
Example Response
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example Response**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "user_stats:10",
|
||||
"stats": {
|
||||
@@ -696,57 +748,65 @@ JSON
|
||||
},
|
||||
"type": "update/user_stats"
|
||||
}
|
||||
Transaction
|
||||
```
|
||||
|
||||
|
||||
The transaction channel sends all new transactions.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "transaction"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "transaction",
|
||||
"txs": [Transaction],
|
||||
"type": "update/transaction"
|
||||
}
|
||||
Executed Transaction
|
||||
The structure is the same as with Transaction channel. But this channel sends only executed transactions.
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
The structure is the same as with [Transaction](#transaction) channel. But this channel sends only executed transactions.
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "executed_transaction"
|
||||
}
|
||||
Account Tx
|
||||
The structure is the same as with Transaction channel. But this channel sends only transactions related to a specific account.
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
The structure is the same as with [Transaction](#transaction) channel. But this channel sends only transactions related to a specific account.
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_tx/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Account All Orders
|
||||
```
|
||||
|
||||
|
||||
The account all orders channel sends data about all the orders of an account.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_all_orders/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "account_all_orders:{ACCOUNT_ID}",
|
||||
"orders": {
|
||||
@@ -754,38 +814,44 @@ JSON
|
||||
},
|
||||
"type": "update/account_all_orders"
|
||||
}
|
||||
Height
|
||||
```
|
||||
|
||||
|
||||
Blockchain height updates
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "height",
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "height",
|
||||
"height": INTEGER,
|
||||
"type": "update/height"
|
||||
}
|
||||
Pool data
|
||||
```
|
||||
|
||||
|
||||
Provides data about pool activities: trades, orders, positions, shares and funding histories.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "pool_data/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "pool_data:{ACCOUNT_ID}",
|
||||
"account": INTEGER,
|
||||
@@ -804,20 +870,23 @@ JSON
|
||||
},
|
||||
"type": "subscribed/pool_data"
|
||||
}
|
||||
Pool info
|
||||
```
|
||||
|
||||
|
||||
Provides information about pools.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "pool_info/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "pool_info:{ACCOUNT_ID}",
|
||||
"pool_info": {
|
||||
@@ -842,20 +911,23 @@ JSON
|
||||
},
|
||||
"type": "subscribed/pool_info"
|
||||
}
|
||||
Notification
|
||||
```
|
||||
|
||||
|
||||
Provides notifications received by an account. Notifications can be of three kinds: liquidation, deleverage, or announcement. Each kind has a different content structure.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "notification/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "notification:{ACCOUNT_ID}",
|
||||
"notifs": [
|
||||
@@ -872,10 +944,12 @@ JSON
|
||||
],
|
||||
"type": "subscribed/notification"
|
||||
}
|
||||
Liquidation Notification Content
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Liquidation Notification Content**
|
||||
|
||||
```
|
||||
{
|
||||
"id": STRING,
|
||||
"is_ask": BOOL,
|
||||
@@ -886,10 +960,12 @@ JSON
|
||||
"timestamp": INTEGER,
|
||||
"avg_price": STRING
|
||||
}
|
||||
Deleverage Notification Content
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Deleverage Notification Content**
|
||||
|
||||
```
|
||||
{
|
||||
"id": STRING,
|
||||
"usdc_amount": STRING,
|
||||
@@ -898,19 +974,23 @@ JSON
|
||||
"settlement_price": STRING,
|
||||
"timestamp": INTEGER
|
||||
}
|
||||
Announcement Notification Content
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Announcement Notification Content**
|
||||
|
||||
```
|
||||
{
|
||||
"title": STRING,
|
||||
"content": STRING,
|
||||
"created_at": INTEGER
|
||||
}
|
||||
Example response
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Example response**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "notification:12345",
|
||||
"notifs": [
|
||||
@@ -953,20 +1033,23 @@ JSON
|
||||
],
|
||||
"type": "update/notification"
|
||||
}
|
||||
Account Orders
|
||||
```
|
||||
|
||||
|
||||
The account all orders channel sends data about the orders of an account on a certain market.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_orders/{MARKET_INDEX}/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"account": {ACCOUNT_INDEX},
|
||||
"channel": "account_orders:{MARKET_INDEX}",
|
||||
@@ -976,20 +1059,23 @@ JSON
|
||||
},
|
||||
"type": "update/account_orders"
|
||||
}
|
||||
Account All Trades
|
||||
```
|
||||
|
||||
|
||||
The account all trades channel sends data about all the trades of an account.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_all_trades/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "account_all_trades:{ACCOUNT_ID}",
|
||||
"trades": {
|
||||
@@ -1001,20 +1087,23 @@ JSON
|
||||
"daily_volume": FLOAT,
|
||||
"type": "update/account_all_trades"
|
||||
}
|
||||
Account All Positions
|
||||
```
|
||||
|
||||
|
||||
The account all orders channel sends data about all the order of an account.
|
||||
|
||||
JSON
|
||||
|
||||
```
|
||||
{
|
||||
"type": "subscribe",
|
||||
"channel": "account_all_positions/{ACCOUNT_ID}",
|
||||
"auth": "{AUTH_TOKEN}"
|
||||
}
|
||||
Response Structure
|
||||
```
|
||||
|
||||
JSON
|
||||
|
||||
**Response Structure**
|
||||
|
||||
```
|
||||
{
|
||||
"channel": "account_all_positions:{ACCOUNT_ID}",
|
||||
"positions": {
|
||||
@@ -1022,4 +1111,10 @@ JSON
|
||||
},
|
||||
"shares": [PoolShares],
|
||||
"type": "update/account_all_positions"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Updated 30 days ago
|
||||
|
||||
* * *
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# 网格交易策略使用教程
|
||||
|
||||
本文介绍如何在 Ritmex Bot 中使用全新的网格交易策略。我们将以 ASTERUSDT 永续合约为例,演示从环境配置到运行监控的完整流程,并对关键参数、风控机制、常见问题做出说明。
|
||||
|
||||
## 环境配置
|
||||
|
||||
1. 复制 `.env.example` 到 `.env`
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
2. 配置 Aster 交易所 API:
|
||||
```env
|
||||
EXCHANGE=aster
|
||||
ASTER_API_KEY=你的API密钥
|
||||
ASTER_API_SECRET=你的API密钥
|
||||
TRADE_SYMBOL=ASTERUSDT
|
||||
```
|
||||
3. 设置基础精度与网格参数(示例使用 1.50 ~ 2.50 区间,20 条网格,单笔 5 手,最大仓位 50 手):
|
||||
```env
|
||||
PRICE_TICK=0.0001
|
||||
QTY_STEP=0.01
|
||||
|
||||
GRID_LOWER_PRICE=1.50
|
||||
GRID_UPPER_PRICE=2.50
|
||||
GRID_LEVELS=20
|
||||
GRID_ORDER_SIZE=5
|
||||
GRID_MAX_POSITION_SIZE=50
|
||||
GRID_REFRESH_INTERVAL_MS=1000
|
||||
GRID_MAX_LOG_ENTRIES=200
|
||||
GRID_DIRECTION=both
|
||||
GRID_STOP_LOSS_PCT=0.02
|
||||
GRID_RESTART_TRIGGER_PCT=0.02
|
||||
GRID_AUTO_RESTART_ENABLED=true
|
||||
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05
|
||||
```
|
||||
|
||||
- `GRID_ORDER_SIZE` 与 `GRID_MAX_POSITION_SIZE` 需遵循「最大仓位 ÷ 单笔数量 ≥ 网格数」的原则,这样策略才能补齐全部挂单。本例 50 ÷ 5 = 10,但网格数为 20,意味着策略只会在离现价最近的上下各 10 个位置挂单,与仓位上限保持一致。
|
||||
|
||||
## 网格机制概览
|
||||
|
||||
- **几何等比网格**:所有网格价格基于上下边界按等比方式分布。
|
||||
- **基于现价的挂单排序**:重启或行情驱动时,会优先在现价附近补挂,避免远端挂单未成交。
|
||||
- **双向模式**:`GRID_DIRECTION=both` 表示买卖两侧都开仓;设置为 `long` 或 `short` 则只在对应方向发起新仓,反方向挂单会自动带上 `reduceOnly`。
|
||||
- **风控**:
|
||||
- 跌破下界 * (1 - STOP_LOSS_PCT) 或突破上界 * (1 + STOP_LOSS_PCT) 时,策略撤销所有限价单并用市价平仓。
|
||||
- 若 `GRID_AUTO_RESTART_ENABLED=true`,当价格回到边界内 `RESTART_TRIGGER_PCT` 范围时会自动重启网格。
|
||||
- **持仓限制**:`GRID_MAX_POSITION_SIZE` 是总持仓上限,用于控制网格在极端走势中不会累积过量仓位。
|
||||
|
||||
## 运行命令
|
||||
|
||||
安装依赖后,使用 CLI 直接启动网格策略:
|
||||
```bash
|
||||
bun install
|
||||
bun run index.ts --strategy grid --exchange aster
|
||||
```
|
||||
|
||||
若要在 Ink Dashboard 中运行并交互,直接执行:
|
||||
```bash
|
||||
bun start
|
||||
```
|
||||
然后在菜单中选择 “基础网格策略”。
|
||||
|
||||
## 监控与调优
|
||||
|
||||
界面主要包括:
|
||||
- 当前买一/卖一、开仓方向、挂单/持仓概况。
|
||||
- 最近日志(订单状态、风控触发等)。
|
||||
- 触发止损后会清空网格并记录原因。
|
||||
|
||||
调参建议:
|
||||
1. **缩短区间**:想拉高单格盈利,可缩小上下边界并减少网格数。
|
||||
2. **更精细挂单**:适当提高 `GRID_LEVELS` 并降低 `GRID_ORDER_SIZE`,但同时记得调大 `GRID_MAX_POSITION_SIZE`。
|
||||
3. **调节平仓容忍度**:`GRID_MAX_CLOSE_SLIPPAGE_PCT` 控制平仓单相对标记价的最大偏移,确保 reduce-only 订单不会被交易所拒绝。
|
||||
4. **只做单边**:若只想高抛低吸不反手,可设 `GRID_DIRECTION=long`,卖单会变成 `reduceOnly`。
|
||||
|
||||
## 中断恢复行为
|
||||
|
||||
策略重启后会:
|
||||
- 重新订阅账户、订单、深度、ticker;
|
||||
- 基于当前持仓和开放订单重新计算网格,只补挂缺失部分;
|
||||
- 在仓位额度允许的情况下持续追踪价位。
|
||||
|
||||
因此就算进程断掉,只要交易所回放的账号/订单快照完整,网格会从中断前的状态继续运行。若停机前手动撤过单,新启动时系统会把不在网格计划中的挂单一并清理。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么只有靠近现价的几个网格有订单?
|
||||
A: 每笔网格单都会占用一定仓位上限。当 `GRID_MAX_POSITION_SIZE / GRID_ORDER_SIZE < GRID_LEVELS` 时,只会展示足以满足仓位限制的那几条网格。调整任一参数即可扩大覆盖面。
|
||||
|
||||
### Q: 价格突破上界后为何立即平仓?
|
||||
A: 这是止损保护触发,避免庄外行情继续拉扯,默认 2% 触发后网格会全部撤单,并用市价平掉现有仓位。
|
||||
|
||||
### Q: 想要手动调仓怎么办?
|
||||
A: 暂停策略(Ctrl+C 或 dashboard 退出)后手动操作,完成后再启动,策略会以新的仓位/挂单为基准重新布网。
|
||||
|
||||
## 小结
|
||||
|
||||
通过上述配置,你就可以在 ASTERUSDT 合约上运行一个自动化的等比网格策略。请务必先在沙盒或小仓位测试,确保参数适应当前波动性和手续费结构,再逐步提升资金规模。
|
||||
|
||||
祝交易顺利!
|
||||
+2
-1
@@ -24,8 +24,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@grvt/client": "^1.6.4",
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"axios": "^1.12.2",
|
||||
"ccxt": "^4.5.5",
|
||||
"ccxt": "^4.5.12",
|
||||
"dotenv": "^17.2.2",
|
||||
"ethereum-cryptography": "^2.1.3",
|
||||
"ink": "^6.3.1",
|
||||
|
||||
+10
-3
@@ -1,4 +1,4 @@
|
||||
export type StrategyId = "trend" | "maker" | "offset-maker";
|
||||
export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
|
||||
|
||||
export interface CliOptions {
|
||||
strategy?: StrategyId;
|
||||
@@ -7,7 +7,14 @@ export interface CliOptions {
|
||||
exchange?: "aster" | "grvt" | "lighter" | "backpack";
|
||||
}
|
||||
|
||||
const STRATEGY_VALUES = new Set<StrategyId>(["trend", "maker", "offset-maker"]);
|
||||
const STRATEGY_VALUES = new Set<StrategyId>([
|
||||
"trend",
|
||||
"guardian",
|
||||
"maker",
|
||||
"offset-maker",
|
||||
"basis",
|
||||
"grid",
|
||||
]);
|
||||
|
||||
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
|
||||
const options: CliOptions = { silent: false, help: false };
|
||||
@@ -77,7 +84,7 @@ function assignExchange(options: CliOptions, raw: string): void {
|
||||
|
||||
export function printCliHelp(): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Usage: bun run index.ts [--strategy <trend|maker|offset-maker>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
|
||||
console.log(`Usage: bun run index.ts [--strategy <trend|guardian|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
|
||||
`Options:\n` +
|
||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { makerConfig, tradingConfig } from "../config";
|
||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config";
|
||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
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 { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
||||
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
|
||||
import { extractMessage } from "../utils/errors";
|
||||
import type { StrategyId } from "./args";
|
||||
|
||||
@@ -19,8 +19,11 @@ type StrategyRunner = (options: RunnerOptions) => Promise<void>;
|
||||
|
||||
export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
||||
trend: "Trend Following",
|
||||
guardian: "Guardian",
|
||||
maker: "Maker",
|
||||
"offset-maker": "Offset Maker",
|
||||
basis: "Basis Arbitrage",
|
||||
grid: "Grid",
|
||||
};
|
||||
|
||||
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
|
||||
@@ -45,6 +48,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
guardian: async (opts) => {
|
||||
const config = tradingConfig;
|
||||
const adapter = createAdapterOrThrow(config.symbol);
|
||||
const engine = new GuardianEngine(config, adapter);
|
||||
await runEngine({
|
||||
engine,
|
||||
strategy: "guardian",
|
||||
silent: opts.silent,
|
||||
getSnapshot: () => engine.getSnapshot(),
|
||||
onUpdate: (emitter) => engine.on("update", emitter),
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
maker: async (opts) => {
|
||||
const config = makerConfig;
|
||||
const adapter = createAdapterOrThrow(config.symbol);
|
||||
@@ -71,6 +87,38 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
basis: async (opts) => {
|
||||
if (!isBasisStrategyEnabled()) {
|
||||
throw new Error("Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.");
|
||||
}
|
||||
const exchangeId = resolveExchangeId();
|
||||
if (exchangeId !== "aster") {
|
||||
throw new Error("Basis arbitrage strategy currently only supports the Aster exchange");
|
||||
}
|
||||
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol);
|
||||
const engine = new BasisArbEngine(basisConfig, adapter);
|
||||
await runEngine({
|
||||
engine,
|
||||
strategy: "basis",
|
||||
silent: opts.silent,
|
||||
getSnapshot: () => engine.getSnapshot(),
|
||||
onUpdate: (emitter) => engine.on("update", emitter),
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
grid: async (opts) => {
|
||||
const config = gridConfig;
|
||||
const adapter = createAdapterOrThrow(config.symbol);
|
||||
const engine = new GridEngine(config, adapter);
|
||||
await runEngine({
|
||||
engine,
|
||||
strategy: "grid",
|
||||
silent: opts.silent,
|
||||
getSnapshot: () => engine.getSnapshot(),
|
||||
onUpdate: (emitter) => engine.on("update", emitter),
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
interface EngineHarness<TSnapshot> {
|
||||
@@ -82,7 +130,15 @@ interface EngineHarness<TSnapshot> {
|
||||
offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
|
||||
}
|
||||
|
||||
async function runEngine<TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot>(
|
||||
async function runEngine<
|
||||
TSnapshot extends
|
||||
| TrendEngineSnapshot
|
||||
| GuardianEngineSnapshot
|
||||
| MakerEngineSnapshot
|
||||
| OffsetMakerEngineSnapshot
|
||||
| BasisArbSnapshot
|
||||
| GridEngineSnapshot
|
||||
>(
|
||||
harness: EngineHarness<TSnapshot>
|
||||
): Promise<void> {
|
||||
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
|
||||
|
||||
+85
-20
@@ -1,26 +1,6 @@
|
||||
/**
|
||||
* Trading Configuration
|
||||
*
|
||||
* Environment Variables for Backpack Exchange:
|
||||
* - BACKPACK_API_KEY: Required API key for Backpack
|
||||
* - BACKPACK_API_SECRET: Required API secret for Backpack
|
||||
* - BACKPACK_PASSWORD: Optional password for Backpack (if required)
|
||||
* - BACKPACK_SUBACCOUNT: Optional subaccount name
|
||||
* - BACKPACK_SYMBOL: Override symbol (defaults to TRADE_SYMBOL)
|
||||
* - BACKPACK_SANDBOX: Set to "true" for sandbox mode
|
||||
* - BACKPACK_DEBUG: Set to "true" for debug logging
|
||||
*
|
||||
* Environment Variables for Paradex Exchange:
|
||||
* - PARADEX_PRIVATE_KEY: Required EVM private key for REST/WS authentication
|
||||
* - PARADEX_WALLET_ADDRESS: Required wallet address matching the private key
|
||||
* - PARADEX_SYMBOL: Override symbol (defaults to TRADE_SYMBOL)
|
||||
* - PARADEX_SANDBOX: Set to "true" to use testnet endpoints
|
||||
* - PARADEX_USE_PRO: Set to "false" to disable ccxt.pro websocket feeds
|
||||
* - PARADEX_RECONNECT_DELAY_MS: Optional websocket reconnect delay in ms (default 2000)
|
||||
* - PARADEX_DEBUG: Set to "true" for verbose Paradex adapter logging
|
||||
*
|
||||
* Usage: Set EXCHANGE=backpack to use Backpack exchange
|
||||
* Set EXCHANGE=paradex to use Paradex exchange
|
||||
*/
|
||||
|
||||
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
|
||||
@@ -72,6 +52,15 @@ function parseNumber(value: string | undefined, fallback: number): number {
|
||||
return Number.isFinite(next) ? next : fallback;
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||
if (!value) return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return fallback;
|
||||
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true;
|
||||
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export const tradingConfig: TradingConfig = {
|
||||
symbol: resolveSymbolFromEnv(),
|
||||
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||
@@ -117,3 +106,79 @@ export const makerConfig: MakerConfig = {
|
||||
),
|
||||
priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||
};
|
||||
|
||||
export interface BasisArbConfig {
|
||||
futuresSymbol: string;
|
||||
spotSymbol: string;
|
||||
refreshIntervalMs: number;
|
||||
maxLogEntries: number;
|
||||
takerFeeRate: number;
|
||||
arbAmount: number; // base asset amount to arb (e.g., ASTER amount when ASTERUSDT)
|
||||
}
|
||||
|
||||
export interface GridConfig {
|
||||
symbol: string;
|
||||
tradeAmount: number;
|
||||
refreshIntervalMs: number;
|
||||
priceTick: number;
|
||||
qtyStep: number;
|
||||
levelsPerSide: number;
|
||||
spacingPct: number;
|
||||
stopLossBufferPct: number;
|
||||
maxLogEntries: number;
|
||||
maxPositionSize: number;
|
||||
}
|
||||
|
||||
const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
|
||||
for (const key of envKeys) {
|
||||
const value = process.env[key];
|
||||
if (value && value.trim()) {
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
}
|
||||
return fallback.toUpperCase();
|
||||
};
|
||||
|
||||
export const basisConfig: BasisArbConfig = {
|
||||
futuresSymbol: resolveBasisSymbol(
|
||||
["BASIS_FUTURES_SYMBOL", "ASTER_FUTURES_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
|
||||
"ASTERUSDT"
|
||||
),
|
||||
spotSymbol: resolveBasisSymbol(
|
||||
["BASIS_SPOT_SYMBOL", "ASTER_SPOT_SYMBOL", "ASTER_SYMBOL", "TRADE_SYMBOL"],
|
||||
"ASTERUSDT"
|
||||
),
|
||||
refreshIntervalMs: parseNumber(process.env.BASIS_REFRESH_INTERVAL_MS, 1000),
|
||||
maxLogEntries: parseNumber(process.env.BASIS_MAX_LOG_ENTRIES, 200),
|
||||
takerFeeRate: parseNumber(process.env.BASIS_TAKER_FEE_RATE, 0.0004),
|
||||
arbAmount: parseNumber(process.env.ARB_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0)),
|
||||
};
|
||||
|
||||
const resolveGridMaxPosition = (tradeAmount: number): number => {
|
||||
const fallback = Math.max(tradeAmount * 10, tradeAmount);
|
||||
const raw = process.env.GRID_MAX_POSITION_SIZE ?? process.env.GRID_MAX_POSITION ?? process.env.GRID_POSITION_CAP;
|
||||
const parsed = parseNumber(raw, fallback);
|
||||
return parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
export const gridConfig: GridConfig = {
|
||||
symbol: resolveSymbolFromEnv(),
|
||||
tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001),
|
||||
refreshIntervalMs: parseNumber(process.env.GRID_REFRESH_INTERVAL_MS, 800),
|
||||
priceTick: parseNumber(process.env.GRID_PRICE_TICK ?? process.env.PRICE_TICK, 0.1),
|
||||
qtyStep: parseNumber(process.env.GRID_QTY_STEP ?? process.env.QTY_STEP, 0.001),
|
||||
levelsPerSide: Math.max(5, Math.floor(parseNumber(process.env.GRID_LEVELS_PER_SIDE, 15))),
|
||||
spacingPct: Math.max(0.0001, parseNumber(process.env.GRID_SPACING_PCT, 0.00025)),
|
||||
stopLossBufferPct: Math.max(0.001, parseNumber(process.env.GRID_STOP_BUFFER_PCT, 0.003)),
|
||||
maxLogEntries: parseNumber(process.env.GRID_MAX_LOG_ENTRIES, 200),
|
||||
maxPositionSize: 0, // placeholder updated below
|
||||
};
|
||||
|
||||
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.tradeAmount);
|
||||
|
||||
export function isBasisStrategyEnabled(): boolean {
|
||||
const raw = process.env.ENABLE_BASIS_STRATEGY;
|
||||
if (!raw) return false;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
@@ -18,11 +18,18 @@ export function makeOrderPlan(
|
||||
const orderPrice = String(order.price);
|
||||
const reduceOnly = order.reduceOnly === true;
|
||||
const matchedIndex = targets.findIndex((target, index) => {
|
||||
const targetPrice = String(target.price);
|
||||
const orderPriceValue = Number(orderPrice);
|
||||
const targetPriceValue = Number(targetPrice);
|
||||
const priceMatches =
|
||||
Number.isFinite(orderPriceValue) && Number.isFinite(targetPriceValue)
|
||||
? Math.abs(orderPriceValue - targetPriceValue) <= 1e-8
|
||||
: orderPrice === targetPrice;
|
||||
return (
|
||||
unmatched.has(index) &&
|
||||
target.side === order.side &&
|
||||
target.reduceOnly === reduceOnly &&
|
||||
orderPrice === target.price // 直接使用字符串比较
|
||||
priceMatches
|
||||
);
|
||||
});
|
||||
if (matchedIndex >= 0) {
|
||||
@@ -39,4 +46,3 @@ export function makeOrderPlan(
|
||||
return { toCancel, toPlace };
|
||||
}
|
||||
|
||||
|
||||
|
||||
+132
-62
@@ -1,6 +1,13 @@
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type { AsterOrder, CreateOrderParams } from "../exchanges/types";
|
||||
import { roundDownToTick, roundQtyDownToStep, formatPriceToString } from "../utils/math";
|
||||
import type { AsterOrder } from "../exchanges/types";
|
||||
import {
|
||||
routeCloseOrder,
|
||||
routeLimitOrder,
|
||||
routeMarketOrder,
|
||||
routeStopOrder,
|
||||
routeTrailingStopOrder,
|
||||
} from "../exchanges/order-router";
|
||||
import { roundDownToTick, roundQtyDownToStep } from "../utils/math";
|
||||
import { isUnknownOrderError } from "../utils/errors";
|
||||
import { isOrderPriceAllowedByMark } from "../utils/strategy";
|
||||
|
||||
@@ -89,7 +96,14 @@ export async function deduplicateOrders(
|
||||
side: string,
|
||||
log: LogHandler
|
||||
): Promise<void> {
|
||||
const sameTypeOrders = openOrders.filter((o) => o.type === type && o.side === side);
|
||||
// Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated.
|
||||
const sameTypeOrders = openOrders.filter((o) => {
|
||||
const normalizedType = String(o.type).toUpperCase();
|
||||
const isStopLike = Number.isFinite(Number(o.stopPrice)) && Number(o.stopPrice) > 0;
|
||||
const matchesStop = type === "STOP_MARKET" && isStopLike && o.side === side;
|
||||
const exactMatch = normalizedType === type && o.side === side;
|
||||
return exactMatch || matchesStop;
|
||||
});
|
||||
if (sameTypeOrders.length <= 1) return;
|
||||
sameTypeOrders.sort((a, b) => {
|
||||
const ta = b.updateTime || b.time || 0;
|
||||
@@ -114,6 +128,12 @@ export async function deduplicateOrders(
|
||||
}
|
||||
}
|
||||
|
||||
type PlaceOrderOptions = {
|
||||
priceTick: number;
|
||||
qtyStep: number;
|
||||
skipDedupe?: boolean;
|
||||
};
|
||||
|
||||
export async function placeOrder(
|
||||
adapter: ExchangeAdapter,
|
||||
symbol: string,
|
||||
@@ -127,29 +147,38 @@ export async function placeOrder(
|
||||
log: LogHandler,
|
||||
reduceOnly = false,
|
||||
guard?: OrderGuardOptions,
|
||||
opts?: { priceTick: number; qtyStep: number }
|
||||
opts?: PlaceOrderOptions
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "LIMIT";
|
||||
if (isOperating(locks, type)) return;
|
||||
const priceNum = Number(price);
|
||||
if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return;
|
||||
const priceTick = opts?.priceTick ?? 0.1;
|
||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: roundQtyDownToStep(amount, qtyStep),
|
||||
price: priceNum, // 直接使用字符串转换的数字,不再格式化
|
||||
timeInForce: "GTX",
|
||||
};
|
||||
if (reduceOnly) params.reduceOnly = "true";
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
const rawQuantity = Math.abs(amount);
|
||||
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
||||
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
||||
if (quantity <= 0) {
|
||||
log("error", "限价单数量无效,跳过下单");
|
||||
return;
|
||||
}
|
||||
if (!opts?.skipDedupe) {
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
}
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
const closePosition = reduceOnly ? true : undefined;
|
||||
const order = await routeLimitOrder({
|
||||
adapter,
|
||||
symbol,
|
||||
side,
|
||||
quantity,
|
||||
price: priceNum,
|
||||
timeInForce: reduceOnly ? "GTC" : "GTX",
|
||||
reduceOnly: reduceOnly ? true : undefined,
|
||||
closePosition,
|
||||
});
|
||||
pendings[type] = String(order.orderId);
|
||||
log("order", `挂限价单: ${side} @ ${params.price} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
|
||||
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
@@ -179,19 +208,27 @@ export async function placeMarketOrder(
|
||||
if (isOperating(locks, type)) return;
|
||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
|
||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: roundQtyDownToStep(amount, qtyStep),
|
||||
};
|
||||
if (reduceOnly) params.reduceOnly = "true";
|
||||
const rawQuantity = Math.abs(amount);
|
||||
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
||||
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
||||
if (quantity <= 0) {
|
||||
log("error", "市价单数量无效,跳过下单");
|
||||
return;
|
||||
}
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
const closePosition = reduceOnly ? true : undefined;
|
||||
const order = await routeMarketOrder({
|
||||
adapter,
|
||||
symbol,
|
||||
side,
|
||||
quantity,
|
||||
reduceOnly: reduceOnly ? true : undefined,
|
||||
closePosition,
|
||||
});
|
||||
pendings[type] = String(order.orderId);
|
||||
log("order", `市价单: ${side} 数量 ${params.quantity} reduceOnly=${reduceOnly}`);
|
||||
log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
@@ -233,25 +270,32 @@ export async function placeStopLossOrder(
|
||||
}
|
||||
const priceTick = opts?.priceTick ?? 0.1;
|
||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
stopPrice: roundDownToTick(stopPrice, priceTick),
|
||||
reduceOnly: "true",
|
||||
closePosition: "true",
|
||||
timeInForce: "GTC",
|
||||
quantity: roundQtyDownToStep(quantity, qtyStep),
|
||||
triggerType: "STOP_LOSS",
|
||||
};
|
||||
// 部分交易所(例如 Paradex)要求 STOP_MARKET 同时提供 price 字段
|
||||
params.price = params.stopPrice;
|
||||
const normalizedStop = roundDownToTick(stopPrice, priceTick);
|
||||
const rawQuantity = Math.abs(quantity);
|
||||
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
||||
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
||||
if (normalizedQty <= 0) {
|
||||
log("error", "止损单数量无效,跳过下单");
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
const order = await routeStopOrder({
|
||||
adapter,
|
||||
symbol,
|
||||
side,
|
||||
quantity: normalizedQty,
|
||||
stopPrice: normalizedStop,
|
||||
timeInForce: "GTC",
|
||||
reduceOnly: true,
|
||||
closePosition: true,
|
||||
triggerType: side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS",
|
||||
});
|
||||
pendings[type] = String(order.orderId);
|
||||
log("stop", `挂止损单: ${side} STOP_MARKET @ ${params.stopPrice}`);
|
||||
log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
|
||||
return order;
|
||||
} catch (err) {
|
||||
unlockOperating(locks, timers, pendings, type);
|
||||
@@ -280,27 +324,38 @@ export async function placeTrailingStopOrder(
|
||||
): Promise<AsterOrder | undefined> {
|
||||
const type = "TRAILING_STOP_MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (!adapter.supportsTrailingStops()) {
|
||||
log("error", "当前交易所不支持动态止盈单");
|
||||
return;
|
||||
}
|
||||
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
|
||||
const priceTick = opts?.priceTick ?? 0.1;
|
||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: roundQtyDownToStep(quantity, qtyStep),
|
||||
reduceOnly: "true",
|
||||
activationPrice: roundDownToTick(activationPrice, priceTick),
|
||||
callbackRate,
|
||||
timeInForce: "GTC",
|
||||
};
|
||||
const normalizedActivation = roundDownToTick(activationPrice, priceTick);
|
||||
const rawQuantity = Math.abs(quantity);
|
||||
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
||||
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
||||
if (normalizedQty <= 0) {
|
||||
log("error", "动态止盈单数量无效,跳过下单");
|
||||
return;
|
||||
}
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
const order = await routeTrailingStopOrder({
|
||||
adapter,
|
||||
symbol,
|
||||
side,
|
||||
quantity: normalizedQty,
|
||||
activationPrice: normalizedActivation,
|
||||
callbackRate,
|
||||
timeInForce: "GTC",
|
||||
reduceOnly: true,
|
||||
});
|
||||
pendings[type] = String(order.orderId);
|
||||
log(
|
||||
"order",
|
||||
`挂动态止盈单: ${side} activation=${params.activationPrice} callbackRate=${callbackRate}`
|
||||
`挂动态止盈单: ${side} activation=${normalizedActivation} callbackRate=${callbackRate}`
|
||||
);
|
||||
return order;
|
||||
} catch (err) {
|
||||
@@ -329,18 +384,33 @@ export async function marketClose(
|
||||
const type = "MARKET";
|
||||
if (isOperating(locks, type)) return;
|
||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
|
||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
||||
const params: CreateOrderParams = {
|
||||
symbol,
|
||||
side,
|
||||
type,
|
||||
quantity: roundQtyDownToStep(quantity, qtyStep),
|
||||
reduceOnly: "true",
|
||||
};
|
||||
|
||||
const qtyStep = opts?.qtyStep;
|
||||
const rawQuantity = Math.abs(quantity);
|
||||
const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity;
|
||||
let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity;
|
||||
if (qtyStep != null) {
|
||||
const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
|
||||
if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
|
||||
normalizedQty = rawQuantity;
|
||||
}
|
||||
}
|
||||
if (normalizedQty <= 0) {
|
||||
log("error", "市价平仓数量无效,跳过下单");
|
||||
return;
|
||||
}
|
||||
|
||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
||||
lockOperating(locks, timers, pendings, type, log);
|
||||
try {
|
||||
const order = await adapter.createOrder(params);
|
||||
const order = await routeCloseOrder({
|
||||
adapter,
|
||||
symbol,
|
||||
side,
|
||||
quantity: normalizedQty,
|
||||
reduceOnly: true,
|
||||
closePosition: true,
|
||||
});
|
||||
pendings[type] = String(order.orderId);
|
||||
log("close", `市价平仓: ${side}`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -27,6 +27,14 @@ export interface KlineListener {
|
||||
(klines: AsterKline[]): void;
|
||||
}
|
||||
|
||||
export interface ExchangePrecision {
|
||||
priceTick: number;
|
||||
qtyStep: number;
|
||||
priceDecimals?: number;
|
||||
sizeDecimals?: number;
|
||||
marketId?: number;
|
||||
}
|
||||
|
||||
export interface ExchangeAdapter {
|
||||
readonly id: string;
|
||||
supportsTrailingStops(): boolean;
|
||||
@@ -39,4 +47,5 @@ export interface ExchangeAdapter {
|
||||
cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void>;
|
||||
cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void>;
|
||||
cancelAllOrders(params: { symbol: string }): Promise<void>;
|
||||
getPrecision?(): Promise<ExchangePrecision | null>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AccountListener,
|
||||
DepthListener,
|
||||
ExchangeAdapter,
|
||||
ExchangePrecision,
|
||||
KlineListener,
|
||||
OrderListener,
|
||||
TickerListener,
|
||||
@@ -148,4 +149,20 @@ export class AsterExchangeAdapter implements ExchangeAdapter {
|
||||
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,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[AsterExchangeAdapter] getPrecision failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+780
-13
@@ -6,14 +6,36 @@ import type {
|
||||
AsterDepth,
|
||||
AsterKline,
|
||||
AsterOrder,
|
||||
AsterSpotAccount,
|
||||
AsterSpotAggTrade,
|
||||
AsterSpotBookTicker,
|
||||
AsterSpotCommissionRate,
|
||||
AsterSpotDepth,
|
||||
AsterSpotExchangeInfo,
|
||||
AsterSpotHistoricalTrade,
|
||||
AsterSpotKline,
|
||||
AsterSpotPriceTicker,
|
||||
AsterSpotTicker24h,
|
||||
AsterSpotTrade,
|
||||
AsterSpotUserTrade,
|
||||
AsterTicker,
|
||||
AsterFuturesExchangeInfo,
|
||||
AsterFuturesSymbolInfo,
|
||||
CancelSpotOrderParams,
|
||||
CreateOrderParams,
|
||||
CreateSpotOrderParams,
|
||||
PositionSide,
|
||||
QuerySpotOrderParams,
|
||||
SpotAllOrdersParams,
|
||||
SpotOpenOrdersParams,
|
||||
SpotUserTradesParams,
|
||||
} from "../types";
|
||||
import { decimalsOf } from "../../utils/math";
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
|
||||
|
||||
const REST_BASE = "https://fapi.asterdex.com";
|
||||
const FUTURES_REST_BASE = "https://fapi.asterdex.com";
|
||||
const SPOT_REST_BASE = "https://sapi.asterdex.com";
|
||||
const WS_PUBLIC_URL = "wss://fstream.asterdex.com/ws";
|
||||
const WS_LISTEN_KEY_URL = "wss://fstream.asterdex.com/ws/";
|
||||
|
||||
@@ -25,6 +47,7 @@ const KLINE_REFRESH_INTERVAL_MS = 60_000;
|
||||
const LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1000;
|
||||
const RECONNECT_DELAY_MS = 2000;
|
||||
const POSITION_SYNC_INTERVAL_MS = 5000;
|
||||
const EXCHANGE_INFO_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
function requireEnv(value: string | undefined, key: string): string {
|
||||
if (!value) {
|
||||
@@ -33,6 +56,499 @@ function requireEnv(value: string | undefined, key: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function serialize(params: Record<string, unknown>): string {
|
||||
return Object.keys(params)
|
||||
.filter((key) => params[key] !== undefined && params[key] !== null)
|
||||
.sort()
|
||||
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
export class AsterSpotRestClient {
|
||||
private readonly apiKey?: string;
|
||||
private readonly apiSecret?: string;
|
||||
|
||||
constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
|
||||
this.apiKey = options.apiKey ?? process.env.ASTER_API_KEY;
|
||||
this.apiSecret = options.apiSecret ?? process.env.ASTER_API_SECRET;
|
||||
}
|
||||
|
||||
async ping(): Promise<void> {
|
||||
await this.request<void>({ path: "/api/v1/ping", method: "GET" });
|
||||
}
|
||||
|
||||
async getServerTime(): Promise<{ serverTime: number }> {
|
||||
return this.request<{ serverTime: number }>({ path: "/api/v1/time", method: "GET" });
|
||||
}
|
||||
|
||||
async getExchangeInfo(): Promise<AsterSpotExchangeInfo> {
|
||||
return this.request<AsterSpotExchangeInfo>({ path: "/api/v1/exchangeInfo", method: "GET" });
|
||||
}
|
||||
|
||||
async getDepth(symbol: string, limit?: number): Promise<AsterSpotDepth> {
|
||||
const payload = await this.request<AsterSpotDepth>({
|
||||
path: "/api/v1/depth",
|
||||
method: "GET",
|
||||
params: { symbol: symbol.toUpperCase(), limit },
|
||||
});
|
||||
return {
|
||||
lastUpdateId: Number(payload.lastUpdateId),
|
||||
E: payload.E,
|
||||
T: payload.T,
|
||||
bids: (payload.bids ?? []).map(([price, qty]) => [String(price), String(qty)]) as AsterSpotDepth["bids"],
|
||||
asks: (payload.asks ?? []).map(([price, qty]) => [String(price), String(qty)]) as AsterSpotDepth["asks"],
|
||||
};
|
||||
}
|
||||
|
||||
async getTrades(symbol: string, limit?: number): Promise<AsterSpotTrade[]> {
|
||||
const payload = await this.request<any[]>({
|
||||
path: "/api/v1/trades",
|
||||
method: "GET",
|
||||
params: { symbol: symbol.toUpperCase(), limit },
|
||||
});
|
||||
return payload.map((item) => ({
|
||||
id: Number(item.id),
|
||||
price: String(item.price),
|
||||
qty: String(item.qty),
|
||||
baseQty: item.baseQty !== undefined ? String(item.baseQty) : undefined,
|
||||
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
|
||||
time: Number(item.time ?? Date.now()),
|
||||
isBuyerMaker: Boolean(item.isBuyerMaker),
|
||||
}));
|
||||
}
|
||||
|
||||
async getHistoricalTrades(params: { symbol: string; limit?: number; fromId?: number }): Promise<AsterSpotHistoricalTrade[]> {
|
||||
const payload = await this.request<any[]>({
|
||||
path: "/api/v1/historicalTrades",
|
||||
method: "GET",
|
||||
params: {
|
||||
symbol: params.symbol.toUpperCase(),
|
||||
limit: params.limit,
|
||||
fromId: params.fromId,
|
||||
},
|
||||
requiresApiKey: true,
|
||||
});
|
||||
return payload.map((item) => ({
|
||||
id: Number(item.id),
|
||||
price: String(item.price),
|
||||
qty: String(item.qty),
|
||||
baseQty: item.baseQty !== undefined ? String(item.baseQty) : undefined,
|
||||
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
|
||||
time: Number(item.time ?? Date.now()),
|
||||
isBuyerMaker: Boolean(item.isBuyerMaker),
|
||||
isBestMatch: item.isBestMatch !== undefined ? Boolean(item.isBestMatch) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async getAggTrades(params: {
|
||||
symbol: string;
|
||||
fromId?: number;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
limit?: number;
|
||||
}): Promise<AsterSpotAggTrade[]> {
|
||||
const payload = await this.request<any[]>({
|
||||
path: "/api/v1/aggTrades",
|
||||
method: "GET",
|
||||
params: {
|
||||
symbol: params.symbol.toUpperCase(),
|
||||
fromId: params.fromId,
|
||||
startTime: params.startTime,
|
||||
endTime: params.endTime,
|
||||
limit: params.limit,
|
||||
},
|
||||
});
|
||||
return payload.map((item) => ({
|
||||
a: Number(item.a),
|
||||
p: String(item.p),
|
||||
q: String(item.q),
|
||||
f: Number(item.f),
|
||||
l: Number(item.l),
|
||||
T: Number(item.T),
|
||||
m: Boolean(item.m),
|
||||
M: item.M !== undefined ? Boolean(item.M) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async getKlines(params: {
|
||||
symbol: string;
|
||||
interval: string;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
limit?: number;
|
||||
}): Promise<AsterSpotKline[]> {
|
||||
const payload = await this.request<any[]>({
|
||||
path: "/api/v1/klines",
|
||||
method: "GET",
|
||||
params: {
|
||||
symbol: params.symbol.toUpperCase(),
|
||||
interval: params.interval,
|
||||
startTime: params.startTime,
|
||||
endTime: params.endTime,
|
||||
limit: params.limit,
|
||||
},
|
||||
});
|
||||
return payload.map((entry) => ({
|
||||
openTime: Number(entry[0]),
|
||||
open: String(entry[1]),
|
||||
high: String(entry[2]),
|
||||
low: String(entry[3]),
|
||||
close: String(entry[4]),
|
||||
volume: String(entry[5]),
|
||||
closeTime: Number(entry[6]),
|
||||
quoteAssetVolume: String(entry[7]),
|
||||
numberOfTrades: Number(entry[8] ?? 0),
|
||||
takerBuyBaseAssetVolume: String(entry[9] ?? "0"),
|
||||
takerBuyQuoteAssetVolume: String(entry[10] ?? "0"),
|
||||
}));
|
||||
}
|
||||
|
||||
async getTicker24h(symbol?: string): Promise<AsterSpotTicker24h | AsterSpotTicker24h[]> {
|
||||
const payload = await this.request<any>({
|
||||
path: "/api/v1/ticker/24hr",
|
||||
method: "GET",
|
||||
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
|
||||
});
|
||||
return this.normalizeTicker24h(payload);
|
||||
}
|
||||
|
||||
async getTickerPrice(symbol?: string): Promise<AsterSpotPriceTicker | AsterSpotPriceTicker[]> {
|
||||
const payload = await this.request<any>({
|
||||
path: "/api/v1/ticker/price",
|
||||
method: "GET",
|
||||
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
|
||||
});
|
||||
return Array.isArray(payload) ? payload.map((item) => this.normalizePriceTicker(item)) : this.normalizePriceTicker(payload);
|
||||
}
|
||||
|
||||
async getBookTicker(symbol?: string): Promise<AsterSpotBookTicker | AsterSpotBookTicker[]> {
|
||||
const payload = await this.request<any>({
|
||||
path: "/api/v1/ticker/bookTicker",
|
||||
method: "GET",
|
||||
params: symbol ? { symbol: symbol.toUpperCase() } : undefined,
|
||||
});
|
||||
return Array.isArray(payload) ? payload.map((item) => this.normalizeBookTicker(item)) : this.normalizeBookTicker(payload);
|
||||
}
|
||||
|
||||
async getCommissionRate(symbol: string, params: { recvWindow?: number } = {}): Promise<AsterSpotCommissionRate> {
|
||||
const payload = await this.request<AsterSpotCommissionRate>({
|
||||
path: "/api/v1/commissionRate",
|
||||
method: "GET",
|
||||
params: { symbol: symbol.toUpperCase(), recvWindow: params.recvWindow },
|
||||
signed: true,
|
||||
});
|
||||
return {
|
||||
symbol: payload.symbol,
|
||||
makerCommissionRate: String(payload.makerCommissionRate),
|
||||
takerCommissionRate: String(payload.takerCommissionRate),
|
||||
};
|
||||
}
|
||||
|
||||
async createOrder(params: CreateSpotOrderParams): Promise<AsterOrder> {
|
||||
const response = await this.request<any>({
|
||||
path: "/api/v1/order",
|
||||
method: "POST",
|
||||
params: this.normalizeSpotOrderParams(params),
|
||||
signed: true,
|
||||
sendInBody: true,
|
||||
});
|
||||
return toOrderFromRest(response);
|
||||
}
|
||||
|
||||
async cancelOrder(params: CancelSpotOrderParams): Promise<AsterOrder> {
|
||||
const response = await this.request<any>({
|
||||
path: "/api/v1/order",
|
||||
method: "DELETE",
|
||||
params: {
|
||||
symbol: params.symbol.toUpperCase(),
|
||||
orderId: params.orderId,
|
||||
origClientOrderId: params.origClientOrderId,
|
||||
recvWindow: params.recvWindow,
|
||||
},
|
||||
signed: true,
|
||||
});
|
||||
return toOrderFromRest(response);
|
||||
}
|
||||
|
||||
async getOrder(params: QuerySpotOrderParams): Promise<AsterOrder> {
|
||||
const response = await this.request<any>({
|
||||
path: "/api/v1/order",
|
||||
method: "GET",
|
||||
params: {
|
||||
symbol: params.symbol.toUpperCase(),
|
||||
orderId: params.orderId,
|
||||
origClientOrderId: params.origClientOrderId,
|
||||
recvWindow: params.recvWindow,
|
||||
},
|
||||
signed: true,
|
||||
});
|
||||
return toOrderFromRest(response);
|
||||
}
|
||||
|
||||
async getOpenOrders(params: SpotOpenOrdersParams = {}): Promise<AsterOrder[]> {
|
||||
const response = await this.request<any[]>({
|
||||
path: "/api/v1/openOrders",
|
||||
method: "GET",
|
||||
params: {
|
||||
symbol: params.symbol ? params.symbol.toUpperCase() : undefined,
|
||||
recvWindow: params.recvWindow,
|
||||
},
|
||||
signed: true,
|
||||
});
|
||||
return response.map(toOrderFromRest);
|
||||
}
|
||||
|
||||
async cancelAllOpenOrders(params: SpotOpenOrdersParams & { symbol: string }): Promise<{ code: number; msg: string }> {
|
||||
const payload: Record<string, unknown> = {
|
||||
symbol: params.symbol.toUpperCase(),
|
||||
recvWindow: params.recvWindow,
|
||||
};
|
||||
if (params.orderIdList && params.orderIdList.length) {
|
||||
payload.orderIdList = `[${params.orderIdList
|
||||
.map((id) => (typeof id === "string" ? id.trim() : String(id)))
|
||||
.join(",")}]`;
|
||||
}
|
||||
if (params.origClientOrderIdList && params.origClientOrderIdList.length) {
|
||||
payload.origClientOrderIdList = JSON.stringify(params.origClientOrderIdList);
|
||||
}
|
||||
return this.request<{ code: number; msg: string }>({
|
||||
path: "/api/v1/allOpenOrders",
|
||||
method: "DELETE",
|
||||
params: payload,
|
||||
signed: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getAllOrders(params: SpotAllOrdersParams): Promise<AsterOrder[]> {
|
||||
const response = await this.request<any[]>({
|
||||
path: "/api/v1/allOrders",
|
||||
method: "GET",
|
||||
params: {
|
||||
symbol: params.symbol.toUpperCase(),
|
||||
orderId: params.orderId,
|
||||
startTime: params.startTime,
|
||||
endTime: params.endTime,
|
||||
limit: params.limit,
|
||||
recvWindow: params.recvWindow,
|
||||
},
|
||||
signed: true,
|
||||
});
|
||||
return response.map(toOrderFromRest);
|
||||
}
|
||||
|
||||
async getAccount(params: { recvWindow?: number } = {}): Promise<AsterSpotAccount> {
|
||||
const payload = await this.request<AsterSpotAccount>({
|
||||
path: "/api/v1/account",
|
||||
method: "GET",
|
||||
params: { recvWindow: params.recvWindow },
|
||||
signed: true,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
balances: (payload.balances ?? []).map((balance) => ({
|
||||
asset: balance.asset,
|
||||
free: String(balance.free ?? "0"),
|
||||
locked: String(balance.locked ?? "0"),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getUserTrades(params: SpotUserTradesParams = {}): Promise<AsterSpotUserTrade[]> {
|
||||
const response = await this.request<any[]>({
|
||||
path: "/api/v1/userTrades",
|
||||
method: "GET",
|
||||
params: {
|
||||
symbol: params.symbol ? params.symbol.toUpperCase() : undefined,
|
||||
orderId: params.orderId,
|
||||
startTime: params.startTime,
|
||||
endTime: params.endTime,
|
||||
fromId: params.fromId,
|
||||
limit: params.limit,
|
||||
recvWindow: params.recvWindow,
|
||||
},
|
||||
signed: true,
|
||||
});
|
||||
return response.map((item) => ({
|
||||
symbol: item.symbol,
|
||||
id: Number(item.id),
|
||||
orderId: Number(item.orderId),
|
||||
side: item.side,
|
||||
price: String(item.price),
|
||||
qty: String(item.qty),
|
||||
quoteQty: item.quoteQty !== undefined ? String(item.quoteQty) : undefined,
|
||||
commission: String(item.commission ?? "0"),
|
||||
commissionAsset: String(item.commissionAsset ?? ""),
|
||||
time: Number(item.time ?? Date.now()),
|
||||
counterpartyId: item.counterpartyId !== undefined ? Number(item.counterpartyId) : undefined,
|
||||
maker: Boolean(item.maker),
|
||||
buyer: Boolean(item.buyer),
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeTicker24h(payload: any): AsterSpotTicker24h | AsterSpotTicker24h[] {
|
||||
const mapOne = (entry: any): AsterSpotTicker24h => ({
|
||||
symbol: entry.symbol,
|
||||
priceChange: String(entry.priceChange),
|
||||
priceChangePercent: String(entry.priceChangePercent),
|
||||
weightedAvgPrice: String(entry.weightedAvgPrice),
|
||||
prevClosePrice: String(entry.prevClosePrice),
|
||||
lastPrice: String(entry.lastPrice),
|
||||
lastQty: String(entry.lastQty),
|
||||
bidPrice: String(entry.bidPrice),
|
||||
bidQty: String(entry.bidQty),
|
||||
askPrice: String(entry.askPrice),
|
||||
askQty: String(entry.askQty),
|
||||
openPrice: String(entry.openPrice),
|
||||
highPrice: String(entry.highPrice),
|
||||
lowPrice: String(entry.lowPrice),
|
||||
volume: String(entry.volume),
|
||||
quoteVolume: String(entry.quoteVolume),
|
||||
openTime: Number(entry.openTime ?? 0),
|
||||
closeTime: Number(entry.closeTime ?? 0),
|
||||
firstId: Number(entry.firstId ?? 0),
|
||||
lastId: Number(entry.lastId ?? 0),
|
||||
count: Number(entry.count ?? 0),
|
||||
baseAsset: entry.baseAsset,
|
||||
quoteAsset: entry.quoteAsset,
|
||||
});
|
||||
return Array.isArray(payload) ? payload.map((entry) => mapOne(entry)) : mapOne(payload);
|
||||
}
|
||||
|
||||
private normalizePriceTicker(entry: any): AsterSpotPriceTicker {
|
||||
return {
|
||||
symbol: entry.symbol,
|
||||
price: String(entry.price),
|
||||
time: entry.time !== undefined ? Number(entry.time) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeBookTicker(entry: any): AsterSpotBookTicker {
|
||||
return {
|
||||
symbol: entry.symbol,
|
||||
bidPrice: String(entry.bidPrice),
|
||||
bidQty: String(entry.bidQty),
|
||||
askPrice: String(entry.askPrice),
|
||||
askQty: String(entry.askQty),
|
||||
time: entry.time !== undefined ? Number(entry.time) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSpotOrderParams(params: CreateSpotOrderParams): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
symbol: params.symbol.toUpperCase(),
|
||||
side: params.side,
|
||||
type: params.type,
|
||||
timeInForce: params.timeInForce,
|
||||
quantity: params.quantity !== undefined ? params.quantity : undefined,
|
||||
quoteOrderQty: params.quoteOrderQty !== undefined ? params.quoteOrderQty : undefined,
|
||||
price: params.price !== undefined ? params.price : undefined,
|
||||
newClientOrderId: params.newClientOrderId,
|
||||
stopPrice: params.stopPrice !== undefined ? params.stopPrice : undefined,
|
||||
recvWindow: params.recvWindow,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
private ensureApiKey(): string {
|
||||
if (!this.apiKey) {
|
||||
throw new Error("[AsterSpotRestClient] Missing API key");
|
||||
}
|
||||
return this.apiKey;
|
||||
}
|
||||
|
||||
private ensureCredentials(): { apiKey: string; apiSecret: string } {
|
||||
const apiKey = this.ensureApiKey();
|
||||
const apiSecret = this.apiSecret;
|
||||
if (!apiSecret) {
|
||||
throw new Error("[AsterSpotRestClient] Missing API secret");
|
||||
}
|
||||
return { apiKey, apiSecret };
|
||||
}
|
||||
|
||||
private cleanParams(params: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
const source = params ?? {};
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(source)) {
|
||||
const value = (source as Record<string, unknown>)[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
cleaned[key] = value;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private async request<T>({
|
||||
path,
|
||||
method,
|
||||
params,
|
||||
signed = false,
|
||||
sendInBody,
|
||||
requiresApiKey = false,
|
||||
}: {
|
||||
path: string;
|
||||
method: "GET" | "POST" | "DELETE" | "PUT";
|
||||
params?: Record<string, unknown>;
|
||||
signed?: boolean;
|
||||
sendInBody?: boolean;
|
||||
requiresApiKey?: boolean;
|
||||
}): Promise<T> {
|
||||
const cleaned = this.cleanParams(params);
|
||||
const headers: Record<string, string> = {};
|
||||
let url = `${SPOT_REST_BASE}${path}`;
|
||||
const useBody = sendInBody ?? (method !== "GET" && method !== "DELETE");
|
||||
let body: string | undefined;
|
||||
if (requiresApiKey || signed) {
|
||||
headers["X-MBX-APIKEY"] = this.ensureApiKey();
|
||||
}
|
||||
if (signed) {
|
||||
if (cleaned.timestamp === undefined) cleaned.timestamp = Date.now();
|
||||
if (cleaned.recvWindow === undefined) cleaned.recvWindow = 5000;
|
||||
const { apiSecret } = this.ensureCredentials();
|
||||
const serialized = serialize(cleaned);
|
||||
const signature = crypto.createHmac("sha256", apiSecret).update(serialized).digest("hex");
|
||||
if (useBody) {
|
||||
body = serialized ? `${serialized}&signature=${signature}` : `signature=${signature}`;
|
||||
} else {
|
||||
const query = serialized ? `${serialized}&signature=${signature}` : `signature=${signature}`;
|
||||
url += url.includes("?") ? `&${query}` : `?${query}`;
|
||||
}
|
||||
} else {
|
||||
const query = serialize(cleaned);
|
||||
if (query) {
|
||||
if (useBody) {
|
||||
body = query;
|
||||
} else {
|
||||
url += url.includes("?") ? `&${query}` : `?${query}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = { method, headers };
|
||||
if (useBody) {
|
||||
init.body = body ?? "";
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch (error) {
|
||||
throw new Error(`[AsterSpotRestClient] 请求失败 ${String(error)}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${text}`);
|
||||
}
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch (error) {
|
||||
throw new Error(`[AsterSpotRestClient] 无法解析响应: ${text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toDepth(streamSymbol: string, data: any): AsterDepth {
|
||||
return {
|
||||
eventType: data.e,
|
||||
@@ -265,8 +781,51 @@ export class AsterRestClient {
|
||||
return raw.map(toPositionFromRisk);
|
||||
}
|
||||
|
||||
async getExchangeInfo(): Promise<AsterFuturesExchangeInfo> {
|
||||
const url = `${FUTURES_REST_BASE}/fapi/v1/exchangeInfo`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch (error) {
|
||||
throw new Error(`[AsterRestClient] 获取交易规则失败 ${String(error)}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${text}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as AsterFuturesExchangeInfo;
|
||||
} catch (error) {
|
||||
throw new Error(`[AsterRestClient] 无法解析交易规则响应: ${text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||
const payload: Record<string, unknown> = { ...params };
|
||||
// Sanitize and normalize params for Aster futures API. Paradex-specific flags
|
||||
// like reduceOnly/closePosition on STOP/TRAILING should not leak here.
|
||||
const payload: Record<string, unknown> = {};
|
||||
payload.symbol = String(params.symbol).toUpperCase();
|
||||
payload.side = params.side;
|
||||
payload.type = params.type;
|
||||
if (params.timeInForce !== undefined) payload.timeInForce = params.timeInForce;
|
||||
if (params.price !== undefined) payload.price = params.price;
|
||||
if (params.stopPrice !== undefined) payload.stopPrice = params.stopPrice;
|
||||
if (params.activationPrice !== undefined) payload.activationPrice = params.activationPrice;
|
||||
if (params.callbackRate !== undefined) payload.callbackRate = params.callbackRate;
|
||||
if (params.quantity !== undefined) payload.quantity = Math.abs(params.quantity);
|
||||
|
||||
// Aster rejects reduceOnly/closePosition for certain order types (e.g. STOP/TRAILING).
|
||||
// Keep the behavior exchange-specific by stripping them here for Aster.
|
||||
const type = String(params.type).toUpperCase();
|
||||
const isStopOrTrailing = type === "STOP_MARKET" || type === "TRAILING_STOP_MARKET";
|
||||
const supportsClosePosition = type === "STOP_MARKET" || type === "TAKE_PROFIT_MARKET";
|
||||
if (!isStopOrTrailing) {
|
||||
if (params.reduceOnly !== undefined) payload.reduceOnly = params.reduceOnly;
|
||||
}
|
||||
if (supportsClosePosition) {
|
||||
if (params.closePosition !== undefined) payload.closePosition = params.closePosition;
|
||||
}
|
||||
|
||||
const response = await this.signedRequest<any>({ path: "/fapi/v1/order", method: "POST", params: payload });
|
||||
return toOrderFromRest(response);
|
||||
}
|
||||
@@ -296,7 +855,7 @@ export class AsterRestClient {
|
||||
|
||||
async getKlines(symbol: string, interval: string, limit = DEFAULT_KLINE_LIMIT): Promise<AsterKline[]> {
|
||||
const upper = symbol.toUpperCase();
|
||||
const url = `${REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
|
||||
const url = `${FUTURES_REST_BASE}/fapi/v1/continuousKlines?pair=${upper}&contractType=PERPETUAL&interval=${encodeURIComponent(interval)}&limit=${limit}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
@@ -315,6 +874,36 @@ export class AsterRestClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getPremiumIndex(symbol: string): Promise<{
|
||||
symbol: string;
|
||||
markPrice?: string;
|
||||
indexPrice?: string;
|
||||
lastFundingRate?: string;
|
||||
fundingRate?: string;
|
||||
nextFundingTime?: number;
|
||||
time?: number;
|
||||
}> {
|
||||
const upper = symbol.toUpperCase();
|
||||
const url = `${FUTURES_REST_BASE}/fapi/v1/premiumIndex?symbol=${encodeURIComponent(upper)}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch (error) {
|
||||
throw new Error(`[AsterRestClient] 获取资金费率失败 ${String(error)}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} ${text}`);
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(text) as any;
|
||||
// The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time }
|
||||
return payload;
|
||||
} catch (error) {
|
||||
throw new Error(`[AsterRestClient] 无法解析资金费率响应: ${text.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getListenKey(): Promise<string> {
|
||||
const response = await this.signedRequest<ListenKeyResponse>({ path: "/fapi/v1/listenKey", method: "POST", params: {} });
|
||||
return response.listenKey;
|
||||
@@ -331,9 +920,9 @@ export class AsterRestClient {
|
||||
private async signedRequest<T>({ path, method, params }: { path: string; method: string; params: Record<string, unknown> }): Promise<T> {
|
||||
const timestamp = Date.now();
|
||||
const payload = { ...params, timestamp, recvWindow: 5000 };
|
||||
const query = this.serialize(payload);
|
||||
const query = serialize(payload);
|
||||
const signature = crypto.createHmac("sha256", this.apiSecret).update(query).digest("hex");
|
||||
const url = `${REST_BASE}${path}?${query}&signature=${signature}`;
|
||||
const url = `${FUTURES_REST_BASE}${path}?${query}&signature=${signature}`;
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
@@ -358,13 +947,6 @@ export class AsterRestClient {
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(params: Record<string, unknown>): string {
|
||||
return Object.keys(params)
|
||||
.filter((key) => params[key] !== undefined && params[key] !== null)
|
||||
.sort()
|
||||
.map((key) => `${key}=${encodeURIComponent(String(params[key]))}`)
|
||||
.join("&");
|
||||
}
|
||||
}
|
||||
|
||||
type DepthHandler = (depth: AsterDepth) => void;
|
||||
@@ -745,6 +1327,18 @@ export class AsterGateway {
|
||||
private readonly klineInitialFetches = new Map<string, Promise<void>>();
|
||||
private initialized = false;
|
||||
private initializing: Promise<void> | null = null;
|
||||
private readonly precisionCache = new Map<
|
||||
string,
|
||||
{
|
||||
priceTick: number;
|
||||
qtyStep: number;
|
||||
priceDecimals?: number;
|
||||
sizeDecimals?: number;
|
||||
}
|
||||
>();
|
||||
private exchangeInfo: AsterFuturesExchangeInfo | null = null;
|
||||
private exchangeInfoFetchedAt = 0;
|
||||
private exchangeInfoPromise: Promise<AsterFuturesExchangeInfo> | null = null;
|
||||
|
||||
constructor(options: { apiKey?: string; apiSecret?: string } = {}) {
|
||||
this.rest = new AsterRestClient(options);
|
||||
@@ -991,12 +1585,42 @@ export class AsterGateway {
|
||||
}
|
||||
|
||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||
const order = await this.rest.createOrder(params);
|
||||
const normalized = await this.normalizeOrderParams(params);
|
||||
const order = await this.rest.createOrder(normalized);
|
||||
mergeOrderSnapshot(this.openOrders, order);
|
||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||
return order;
|
||||
}
|
||||
|
||||
async getPrecision(symbol: string): Promise<{
|
||||
priceTick: number;
|
||||
qtyStep: number;
|
||||
priceDecimals?: number;
|
||||
sizeDecimals?: number;
|
||||
} | null> {
|
||||
const upper = String(symbol).toUpperCase();
|
||||
const cached = this.precisionCache.get(upper);
|
||||
if (cached) return cached;
|
||||
let exchangeInfo: AsterFuturesExchangeInfo;
|
||||
try {
|
||||
exchangeInfo = await this.loadExchangeInfo();
|
||||
} catch (error) {
|
||||
console.error("[AsterGateway] 获取交易规则失败", error);
|
||||
return null;
|
||||
}
|
||||
const symbols = exchangeInfo?.symbols ?? [];
|
||||
const match = symbols.find((item) => {
|
||||
if (!item) return false;
|
||||
const symbolName = typeof item.symbol === "string" ? item.symbol.toUpperCase() : "";
|
||||
const pairName = typeof item.pair === "string" ? item.pair.toUpperCase() : "";
|
||||
return symbolName === upper || pairName === upper;
|
||||
});
|
||||
if (!match) return null;
|
||||
const precision = this.extractSymbolPrecision(match);
|
||||
this.precisionCache.set(upper, precision);
|
||||
return precision;
|
||||
}
|
||||
|
||||
async cancelOrder(params: { symbol: string; orderId?: number; origClientOrderId?: string }): Promise<void> {
|
||||
const result = await this.rest.cancelOrder(params);
|
||||
mergeOrderSnapshot(this.openOrders, result);
|
||||
@@ -1018,4 +1642,147 @@ export class AsterGateway {
|
||||
}
|
||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||
}
|
||||
|
||||
private async normalizeOrderParams(params: CreateOrderParams): Promise<CreateOrderParams> {
|
||||
const symbol = String(params.symbol).toUpperCase();
|
||||
const precision = await this.getPrecision(symbol);
|
||||
if (!precision) {
|
||||
return { ...params, symbol };
|
||||
}
|
||||
const { priceTick, qtyStep, priceDecimals, sizeDecimals } = precision;
|
||||
const normalized: CreateOrderParams = { ...params, symbol };
|
||||
if (normalized.price !== undefined) {
|
||||
normalized.price = this.quantizePrice(normalized.price, priceTick, priceDecimals);
|
||||
}
|
||||
if (normalized.stopPrice !== undefined) {
|
||||
normalized.stopPrice = this.quantizePrice(normalized.stopPrice, priceTick, priceDecimals);
|
||||
}
|
||||
if (normalized.activationPrice !== undefined) {
|
||||
normalized.activationPrice = this.quantizePrice(normalized.activationPrice, priceTick, priceDecimals);
|
||||
}
|
||||
if (normalized.quantity !== undefined) {
|
||||
normalized.quantity = this.quantizeQuantity(Math.abs(normalized.quantity), qtyStep, sizeDecimals);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async loadExchangeInfo(): Promise<AsterFuturesExchangeInfo> {
|
||||
const now = Date.now();
|
||||
if (this.exchangeInfo && now - this.exchangeInfoFetchedAt <= EXCHANGE_INFO_CACHE_TTL_MS) {
|
||||
return this.exchangeInfo;
|
||||
}
|
||||
if (this.exchangeInfoPromise) {
|
||||
return this.exchangeInfoPromise;
|
||||
}
|
||||
this.exchangeInfoPromise = this.rest
|
||||
.getExchangeInfo()
|
||||
.then((info) => {
|
||||
this.exchangeInfo = info;
|
||||
this.exchangeInfoFetchedAt = Date.now();
|
||||
this.exchangeInfoPromise = null;
|
||||
return info;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.exchangeInfoPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return this.exchangeInfoPromise;
|
||||
}
|
||||
|
||||
private extractSymbolPrecision(symbolInfo: AsterFuturesSymbolInfo): {
|
||||
priceTick: number;
|
||||
qtyStep: number;
|
||||
priceDecimals?: number;
|
||||
sizeDecimals?: number;
|
||||
} {
|
||||
const filters = symbolInfo.filters ?? [];
|
||||
const normalizeFilterType = (type: string) =>
|
||||
filters.find((item) => typeof item.filterType === "string" && item.filterType.toUpperCase() === type);
|
||||
const parseNumber = (value: unknown): number | undefined => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const priceFilter = normalizeFilterType("PRICE_FILTER");
|
||||
const lotFilter = normalizeFilterType("LOT_SIZE");
|
||||
const marketLotFilter = normalizeFilterType("MARKET_LOT_SIZE");
|
||||
const tickSize = parseNumber(priceFilter?.tickSize);
|
||||
const stepSize = parseNumber(lotFilter?.stepSize ?? marketLotFilter?.stepSize);
|
||||
const priceDecimals =
|
||||
typeof symbolInfo.pricePrecision === "number" && Number.isFinite(symbolInfo.pricePrecision)
|
||||
? symbolInfo.pricePrecision
|
||||
: typeof symbolInfo.quotePrecision === "number" && Number.isFinite(symbolInfo.quotePrecision)
|
||||
? symbolInfo.quotePrecision
|
||||
: undefined;
|
||||
const sizeDecimals =
|
||||
typeof symbolInfo.quantityPrecision === "number" && Number.isFinite(symbolInfo.quantityPrecision)
|
||||
? symbolInfo.quantityPrecision
|
||||
: typeof symbolInfo.baseAssetPrecision === "number" && Number.isFinite(symbolInfo.baseAssetPrecision)
|
||||
? symbolInfo.baseAssetPrecision
|
||||
: undefined;
|
||||
return {
|
||||
priceTick: this.ensurePositivePrecision(tickSize, priceDecimals),
|
||||
qtyStep: this.ensurePositivePrecision(stepSize, sizeDecimals),
|
||||
priceDecimals,
|
||||
sizeDecimals,
|
||||
};
|
||||
}
|
||||
|
||||
private ensurePositivePrecision(value: number | undefined, decimals?: number): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
const digits = Math.max(0, decimals ?? decimalsOf(value));
|
||||
return Number(value.toFixed(digits));
|
||||
}
|
||||
if (typeof decimals === "number" && decimals >= 0) {
|
||||
const fallback = Math.pow(10, -decimals);
|
||||
const digits = Math.max(0, decimals);
|
||||
return Number(fallback.toFixed(digits));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private quantizePrice(value: number, tick: number, decimals?: number): number {
|
||||
if (!Number.isFinite(value)) return value;
|
||||
let result = value;
|
||||
if (Number.isFinite(tick) && tick > 0) {
|
||||
const ratio = value / tick;
|
||||
const rounded = Math.round(ratio);
|
||||
const quantized = rounded * tick;
|
||||
const digits = Math.max(0, decimals ?? decimalsOf(tick));
|
||||
result = Number(quantized.toFixed(digits));
|
||||
} else if (typeof decimals === "number" && decimals >= 0) {
|
||||
result = Number(value.toFixed(decimals));
|
||||
}
|
||||
if (typeof decimals === "number" && decimals >= 0) {
|
||||
result = Number(result.toFixed(decimals));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private quantizeQuantity(value: number, step: number, decimals?: number): number {
|
||||
if (!Number.isFinite(value)) return value;
|
||||
const absValue = Math.abs(value);
|
||||
let result = absValue;
|
||||
if (Number.isFinite(step) && step > 0) {
|
||||
const ratio = absValue / step;
|
||||
const floored = Math.floor(ratio + 1e-12) * step;
|
||||
const digits = Math.max(0, decimals ?? decimalsOf(step));
|
||||
result = Number(floored.toFixed(digits));
|
||||
if (result <= 0 && absValue > 0) {
|
||||
const fallback = Number(step.toFixed(digits));
|
||||
if (fallback > 0) {
|
||||
result = fallback;
|
||||
}
|
||||
}
|
||||
} else if (typeof decimals === "number" && decimals >= 0) {
|
||||
result = Number(absValue.toFixed(decimals));
|
||||
}
|
||||
if (typeof decimals === "number" && decimals >= 0) {
|
||||
result = Number(result.toFixed(decimals));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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",
|
||||
},
|
||||
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,
|
||||
},
|
||||
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",
|
||||
triggerType: intent.triggerType,
|
||||
},
|
||||
intent
|
||||
);
|
||||
return intent.adapter.createOrder(params);
|
||||
}
|
||||
|
||||
export async function createTrailingStopOrder(intent: TrailingStopOrderIntent): Promise<AsterOrder> {
|
||||
const params: CreateOrderParams = applyCommonFields(
|
||||
{
|
||||
symbol: intent.symbol,
|
||||
side: intent.side,
|
||||
type: "TRAILING_STOP_MARKET",
|
||||
quantity: intent.quantity,
|
||||
activationPrice: intent.activationPrice,
|
||||
callbackRate: intent.callbackRate,
|
||||
timeInForce: intent.timeInForce ?? "GTC",
|
||||
},
|
||||
intent
|
||||
);
|
||||
return intent.adapter.createOrder(params);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
intent
|
||||
);
|
||||
return intent.adapter.createOrder(params);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,13 @@ export class BackpackExchangeAdapter implements ExchangeAdapter {
|
||||
this.initPromise = this.gateway
|
||||
.ensureInitialized(this.symbol)
|
||||
.then((value) => {
|
||||
this.clearRetry();
|
||||
if (process.env.BACKPACK_DEBUG === "1") {
|
||||
console.error(`[BackpackExchangeAdapter] initialize succeeded`);
|
||||
}
|
||||
if (process.env.BACKPACK_DEBUG === "1") {
|
||||
console.error(`[BackpackExchangeAdapter] initialize succeeded`);
|
||||
}
|
||||
this.clearRetry();
|
||||
return value;
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
+875
-266
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
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);
|
||||
}
|
||||
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",
|
||||
},
|
||||
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,
|
||||
},
|
||||
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",
|
||||
},
|
||||
intent
|
||||
);
|
||||
return intent.adapter.createOrder(params);
|
||||
}
|
||||
|
||||
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
|
||||
throw new Error("Backpack 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",
|
||||
},
|
||||
intent
|
||||
);
|
||||
return intent.adapter.createOrder(params);
|
||||
}
|
||||
@@ -1162,7 +1162,7 @@ function mapTicker(response: IApiTickerResponse, symbol: string): AsterTicker |
|
||||
}
|
||||
|
||||
function mapKlines(response: IApiCandlestickResponse, symbol: string): AsterKline[] {
|
||||
return (response.result ?? []).map((entry) => ({
|
||||
return (response.result ?? []).reverse().map((entry) => ({
|
||||
openTime: nsToMs(entry.open_time ?? Date.now() * ONE_SECOND_IN_NANOSECONDS),
|
||||
closeTime: nsToMs(entry.close_time ?? Date.now() * ONE_SECOND_IN_NANOSECONDS),
|
||||
open: entry.open ?? "0",
|
||||
@@ -1201,6 +1201,7 @@ function toApiOrderPayload(order: GrvtSignedOrder): IOrder {
|
||||
const metadata = order.metadata ? toApiOrderMetadata(order.metadata) : undefined;
|
||||
return {
|
||||
...order,
|
||||
client_order_id: order.metadata?.client_order_id,
|
||||
time_in_force: toApiTimeInForce(order.time_in_force),
|
||||
metadata,
|
||||
};
|
||||
@@ -1300,7 +1301,7 @@ function buildUnsignedOrder(params: {
|
||||
|
||||
const trigger = buildTriggerMetadata(orderParams);
|
||||
const metadata = {
|
||||
client_order_id: generateClientOrderId(),
|
||||
client_order_id: orderParams.clientOrderId ?? generateClientOrderId(),
|
||||
...(trigger ? { trigger } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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);
|
||||
}
|
||||
if (intent.clientOrderId) {
|
||||
params.clientOrderId = intent.clientOrderId;
|
||||
}
|
||||
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",
|
||||
clientOrderId: intent.clientOrderId,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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",
|
||||
triggerType: intent.triggerType ?? (intent.side === "BUY" ? "TAKE_PROFIT" : "STOP_LOSS"),
|
||||
closePosition: toStringBoolean(intent.closePosition ?? true),
|
||||
reduceOnly: toStringBoolean(intent.reduceOnly ?? true),
|
||||
},
|
||||
intent
|
||||
);
|
||||
return intent.adapter.createOrder(params);
|
||||
}
|
||||
|
||||
export async function createTrailingStopOrder(_intent: TrailingStopOrderIntent): Promise<AsterOrder> {
|
||||
throw new Error("GRVT 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),
|
||||
},
|
||||
intent
|
||||
);
|
||||
return intent.adapter.createOrder(params);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AccountListener,
|
||||
DepthListener,
|
||||
ExchangeAdapter,
|
||||
ExchangePrecision,
|
||||
KlineListener,
|
||||
OrderListener,
|
||||
TickerListener,
|
||||
@@ -108,13 +109,14 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
|
||||
|
||||
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
|
||||
await this.ensureInitialized("cancelOrder");
|
||||
await this.gateway.cancelOrder({ orderId: params.orderId });
|
||||
// Accept both clientOrderId and order_index as strings; forward as-is to preserve precision
|
||||
await this.gateway.cancelOrder({ orderId: String(params.orderId) });
|
||||
}
|
||||
|
||||
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
|
||||
await this.ensureInitialized("cancelOrders");
|
||||
for (const orderId of params.orderIdList) {
|
||||
await this.gateway.cancelOrder({ orderId });
|
||||
await this.gateway.cancelOrder({ orderId: String(orderId) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +125,22 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
|
||||
await this.gateway.cancelAllOrders();
|
||||
}
|
||||
|
||||
async getPrecision(): Promise<ExchangePrecision | null> {
|
||||
try {
|
||||
const precision = await this.gateway.getPrecision();
|
||||
return {
|
||||
priceTick: precision.priceTick,
|
||||
qtyStep: precision.qtyStep,
|
||||
priceDecimals: precision.priceDecimals,
|
||||
sizeDecimals: precision.sizeDecimals,
|
||||
marketId: precision.marketId ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logError("precision", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureInitialized(context?: string): Promise<void> {
|
||||
if (!this.initPromise) {
|
||||
this.initContexts.clear();
|
||||
@@ -140,12 +158,22 @@ export class LighterExchangeAdapter implements ExchangeAdapter {
|
||||
}
|
||||
|
||||
private logError(context: string, error: unknown): void {
|
||||
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
|
||||
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`);
|
||||
if (process.env.LIGHTER_DEBUG !== "1" && process.env.LIGHTER_DEBUG !== "true") {
|
||||
return;
|
||||
}
|
||||
if (isSuccessfulResponse(error)) {
|
||||
return; // success responses are noisy; ignore unless non-200
|
||||
}
|
||||
console.error(`[LighterExchangeAdapter] ${context} failed: ${extractMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isSuccessfulResponse(value: unknown): value is { code?: number } {
|
||||
if (typeof value !== "object" || value == null) return false;
|
||||
const code = (value as { code?: unknown }).code;
|
||||
return typeof code === "number" && code === 200;
|
||||
}
|
||||
|
||||
function resolveApiKeys(credentials: LighterCredentials): Record<number, string> {
|
||||
if (credentials.apiKeys && Object.keys(credentials.apiKeys).length) {
|
||||
return credentials.apiKeys;
|
||||
|
||||
@@ -46,6 +46,18 @@ export function decimalToScaled(value: number | string | bigint, decimals: numbe
|
||||
return sign === -1 ? -result : result;
|
||||
}
|
||||
|
||||
export function scaleQuantityWithMinimum(value: number | string | bigint, decimals: number): bigint {
|
||||
const scaled = decimalToScaled(value, decimals);
|
||||
if (scaled !== 0n) {
|
||||
return scaled;
|
||||
}
|
||||
const numeric = typeof value === "bigint" ? Number(value) : Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric === 0) {
|
||||
return scaled;
|
||||
}
|
||||
return numeric < 0 ? -1n : 1n;
|
||||
}
|
||||
|
||||
export function scaledToDecimalString(value: bigint | number | string, decimals: number): string {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new Error(`Invalid scaled number: ${value}`);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const TRUE_VALUES = new Set(["1", "true", "yes", "y", "on"]);
|
||||
const FALSE_VALUES = new Set(["0", "false", "no", "n", "off"]);
|
||||
|
||||
export function normalizeBooleanFlag(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
if (TRUE_VALUES.has(normalized)) return true;
|
||||
if (FALSE_VALUES.has(normalized)) return false;
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
if (value === 1n) return true;
|
||||
if (value === 0n) return false;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function coerceBooleanFlag(value: unknown, fallback = false): boolean {
|
||||
const normalized = normalizeBooleanFlag(value);
|
||||
if (normalized == null) return fallback;
|
||||
return normalized;
|
||||
}
|
||||
@@ -33,8 +33,10 @@ import {
|
||||
IMMEDIATE_OR_CANCEL_EXPIRY_PLACEHOLDER,
|
||||
type LighterEnvironment,
|
||||
} from "./constants";
|
||||
import { decimalToScaled, scaledToDecimalString } from "./decimal";
|
||||
import { decimalToScaled, scaledToDecimalString, scaleQuantityWithMinimum } from "./decimal";
|
||||
import { lighterOrderToAster, toAccountSnapshot, toDepth, toKlines, toOrders, toTicker } from "./mappers";
|
||||
import { normalizeOrderIdentity, orderIdentityEquals } from "./order-identity";
|
||||
import { shouldResetMarketOrders } from "./order-feed";
|
||||
|
||||
interface SimpleEvent<T> {
|
||||
add(handler: (value: T) => void): void;
|
||||
@@ -116,6 +118,14 @@ interface Pollers {
|
||||
const KLINE_DEFAULT_COUNT = 120;
|
||||
const DEFAULT_TICKER_POLL_MS = 3000;
|
||||
const DEFAULT_KLINE_POLL_MS = 15000;
|
||||
const WS_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||
const CLIENT_PING_INTERVAL_MS = 2_000;
|
||||
const WS_STALE_TIMEOUT_MS = 20_000;
|
||||
const FEED_STALE_TIMEOUT_MS = 8_000;
|
||||
const STALE_CHECK_INTERVAL_MS = 2_000;
|
||||
const POSITION_HTTP_MAX_STALE_MS = 60_000;
|
||||
const ACCOUNT_POLL_INTERVAL_MS = 5_000;
|
||||
const POSITION_EPSILON = 1e-12;
|
||||
|
||||
const RESOLUTION_MS: Record<string, number> = {
|
||||
"1m": 60_000,
|
||||
@@ -126,6 +136,15 @@ const RESOLUTION_MS: Record<string, number> = {
|
||||
"1d": 86_400_000,
|
||||
};
|
||||
|
||||
const TERMINAL_ORDER_STATUSES = new Set([
|
||||
"filled",
|
||||
"canceled",
|
||||
"cancelled",
|
||||
"expired",
|
||||
"canceled-post-only",
|
||||
"canceled-reduce-only",
|
||||
]);
|
||||
|
||||
export interface LighterGatewayOptions {
|
||||
symbol: string; // display symbol used by strategy logging
|
||||
marketSymbol?: string; // actual Lighter order book symbol (e.g., BTC)
|
||||
@@ -154,6 +173,8 @@ export class LighterGateway {
|
||||
private readonly apiKeyIndices: number[];
|
||||
private readonly environment: keyof typeof LIGHTER_HOSTS;
|
||||
private readonly pollers: Pollers = { ticker: undefined, klines: new Map() };
|
||||
private accountPoller: ReturnType<typeof setInterval> | null = null;
|
||||
private accountPollInFlight = false;
|
||||
private readonly klineCache = new Map<string, AsterKline[]>();
|
||||
private readonly accountEvent = createEvent<AsterAccountSnapshot>();
|
||||
private readonly ordersEvent = createEvent<AsterOrder[]>();
|
||||
@@ -163,15 +184,23 @@ export class LighterGateway {
|
||||
private readonly auth = { token: null as string | null, expiresAt: 0 };
|
||||
private readonly l1Address: string | null;
|
||||
private loggedCreateOrderPayload = false;
|
||||
private readonly logTxInfo: boolean;
|
||||
private lastWsPositionUpdateAt = 0;
|
||||
private readonly lastWsPositionByMarket = new Map<number, number>();
|
||||
private httpPositionsEmptyLogged = false;
|
||||
|
||||
private marketId: number | null = null;
|
||||
private priceDecimals: number | null = null;
|
||||
private sizeDecimals: number | null = null;
|
||||
private readonly orderIndexByClientId = new Map<string, string>();
|
||||
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly wsUrl: string;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private lastMessageAt = 0;
|
||||
|
||||
private accountDetails: LighterAccountDetails | null = null;
|
||||
private positions: LighterPosition[] = [];
|
||||
@@ -183,6 +212,12 @@ export class LighterGateway {
|
||||
|
||||
private readonly tickerPollMs: number;
|
||||
private readonly klinePollMs: number;
|
||||
private lastDepthUpdateAt = Date.now();
|
||||
private lastOrdersUpdateAt = Date.now();
|
||||
private lastAccountUpdateAt = Date.now();
|
||||
private lastTickerUpdateAt = Date.now();
|
||||
private staleReason: string | null = null;
|
||||
private staleMonitor: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Track last applied order book sequence to drop stale WS messages
|
||||
private lastOrderBookOffset: number = 0;
|
||||
@@ -227,6 +262,12 @@ export class LighterGateway {
|
||||
this.tickerPollMs = options.tickerPollMs ?? DEFAULT_TICKER_POLL_MS;
|
||||
this.klinePollMs = options.klinePollMs ?? DEFAULT_KLINE_POLL_MS;
|
||||
this.l1Address = options.l1Address ?? null;
|
||||
this.logTxInfo = process.env.LIGHTER_LOG_TX === "1" || process.env.LIGHTER_LOG_TX === "true";
|
||||
const now = Date.now();
|
||||
this.lastDepthUpdateAt = now;
|
||||
this.lastOrdersUpdateAt = now;
|
||||
this.lastAccountUpdateAt = now;
|
||||
this.lastTickerUpdateAt = now;
|
||||
}
|
||||
|
||||
async ensureInitialized(): Promise<void> {
|
||||
@@ -272,10 +313,9 @@ export class LighterGateway {
|
||||
apiKeyIndex,
|
||||
nonce,
|
||||
});
|
||||
if (!this.loggedCreateOrderPayload) {
|
||||
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
|
||||
this.logger("createOrder.txInfo", signed.txInfo);
|
||||
}
|
||||
const debugEnabled = process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true";
|
||||
if (this.logTxInfo && !this.loggedCreateOrderPayload) {
|
||||
this.logger("createOrder.txInfo", signed.txInfo);
|
||||
this.loggedCreateOrderPayload = true;
|
||||
}
|
||||
const auth = await this.ensureAuthToken();
|
||||
@@ -283,12 +323,15 @@ export class LighterGateway {
|
||||
authToken: auth,
|
||||
priceProtection: false,
|
||||
});
|
||||
if (process.env.LIGHTER_DEBUG === "1" || process.env.LIGHTER_DEBUG === "true") {
|
||||
if (debugEnabled && response.code !== 200) {
|
||||
this.logger("createOrder.sendTx.response", response);
|
||||
}
|
||||
const clientOrderIndexStr = signParams.clientOrderIndex.toString();
|
||||
return lighterOrderToAster(this.displaySymbol, {
|
||||
order_index: Number(signParams.clientOrderIndex % 1_000_000_000n),
|
||||
client_order_index: Number(signParams.clientOrderIndex),
|
||||
order_index: clientOrderIndexStr,
|
||||
client_order_index: clientOrderIndexStr,
|
||||
order_id: clientOrderIndexStr,
|
||||
client_order_id: clientOrderIndexStr,
|
||||
market_index: signParams.marketIndex,
|
||||
initial_base_amount: baseAmountScaledString,
|
||||
remaining_base_amount: baseAmountScaledString,
|
||||
@@ -312,7 +355,8 @@ export class LighterGateway {
|
||||
await this.ensureInitialized();
|
||||
const marketIndex = params.marketIndex ?? this.marketId;
|
||||
if (marketIndex == null) throw new Error("Market index unknown");
|
||||
const indexValue = BigInt(typeof params.orderId === "string" ? Number(params.orderId) : params.orderId);
|
||||
const resolvedOrderId = this.resolveOrderIndex(String(params.orderId));
|
||||
const indexValue = BigInt(resolvedOrderId);
|
||||
const { apiKeyIndex, nonce } = this.nonceManager.next();
|
||||
try {
|
||||
const signed = await this.signer.signCancelOrder({
|
||||
@@ -323,6 +367,8 @@ export class LighterGateway {
|
||||
});
|
||||
const auth = await this.ensureAuthToken();
|
||||
await this.http.sendTransaction(signed.txType, signed.txInfo, { authToken: auth });
|
||||
// Optimistically remove the order locally to avoid stale duplicates until WS confirms
|
||||
this.removeOrderLocally(String(params.orderId));
|
||||
} catch (error) {
|
||||
this.nonceManager.acknowledgeFailure(apiKeyIndex);
|
||||
throw error;
|
||||
@@ -359,6 +405,7 @@ export class LighterGateway {
|
||||
// orders until there is activity.
|
||||
this.emitOrders();
|
||||
this.startPolling();
|
||||
this.startStaleMonitor();
|
||||
}
|
||||
|
||||
private async loadMetadata(): Promise<void> {
|
||||
@@ -400,11 +447,8 @@ export class LighterGateway {
|
||||
value: Number(this.signer.accountIndex),
|
||||
});
|
||||
}
|
||||
if (details) {
|
||||
this.accountDetails = details;
|
||||
this.emitAccount();
|
||||
} else {
|
||||
// Fallback: emit an empty account snapshot so strategies can proceed
|
||||
if (!details) {
|
||||
if (!this.accountDetails) {
|
||||
this.accountDetails = {
|
||||
account_index: Number(this.signer.accountIndex),
|
||||
status: 1,
|
||||
@@ -414,11 +458,67 @@ export class LighterGateway {
|
||||
this.positions = [];
|
||||
this.emitAccount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.accountDetails = details;
|
||||
this.applyHttpPositions(details);
|
||||
this.emitAccount();
|
||||
} catch (error) {
|
||||
this.logger("refreshAccount", error);
|
||||
}
|
||||
}
|
||||
|
||||
private applyHttpPositions(details: LighterAccountDetails): void {
|
||||
if (!Object.prototype.hasOwnProperty.call(details, "positions")) {
|
||||
return;
|
||||
}
|
||||
const normalized = this.normalizePositions(details.positions);
|
||||
if (normalized.length) {
|
||||
this.replacePositions(normalized);
|
||||
this.httpPositionsEmptyLogged = false;
|
||||
return;
|
||||
}
|
||||
if (this.isEmptyPositionsPayload(details.positions)) {
|
||||
if (this.positions.length && !this.httpPositionsEmptyLogged) {
|
||||
this.logger("accountPoll", "HTTP positions payload empty, retaining existing positions until WS confirms");
|
||||
this.httpPositionsEmptyLogged = true;
|
||||
}
|
||||
this.pruneStalePositionsFromHttp();
|
||||
}
|
||||
}
|
||||
|
||||
private recordWsPositionUpdate(): void {
|
||||
this.lastWsPositionUpdateAt = Date.now();
|
||||
this.httpPositionsEmptyLogged = false;
|
||||
}
|
||||
|
||||
private markWsPositionForMarket(marketId: number): void {
|
||||
if (!Number.isFinite(marketId)) return;
|
||||
this.lastWsPositionByMarket.set(marketId, Date.now());
|
||||
}
|
||||
|
||||
private pruneStalePositionsFromHttp(): void {
|
||||
if (!this.positions.length) return;
|
||||
const now = Date.now();
|
||||
const remaining: LighterPosition[] = [];
|
||||
let removed = false;
|
||||
for (const pos of this.positions) {
|
||||
const marketId = Number(pos.market_id);
|
||||
const lastWs = this.lastWsPositionByMarket.get(marketId) ?? 0;
|
||||
if (Number.isFinite(marketId) && lastWs && now - lastWs > POSITION_HTTP_MAX_STALE_MS) {
|
||||
this.lastWsPositionByMarket.delete(marketId);
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
remaining.push(pos);
|
||||
}
|
||||
if (removed) {
|
||||
this.logger("accountPoll", "Pruned stale positions based on HTTP inactivity");
|
||||
this.positions = remaining;
|
||||
this.recordWsPositionUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private async openWebSocket(): Promise<void> {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
@@ -426,25 +526,61 @@ export class LighterGateway {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(this.wsUrl);
|
||||
this.ws = ws;
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
ws.removeAllListeners();
|
||||
this.stopHeartbeat();
|
||||
this.stopClientPing();
|
||||
if (this.ws === ws) {
|
||||
this.ws = null;
|
||||
}
|
||||
};
|
||||
const fail = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
this.lastMessageAt = Date.now();
|
||||
this.startHeartbeat();
|
||||
this.startClientPing();
|
||||
await this.subscribeChannels();
|
||||
this.startStaleMonitor();
|
||||
settled = true;
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
cleanup();
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
});
|
||||
ws.on("message", (data) => this.handleMessage(data));
|
||||
ws.on("message", (data) => {
|
||||
this.lastMessageAt = Date.now();
|
||||
this.handleMessage(data);
|
||||
});
|
||||
ws.on("pong", () => {
|
||||
this.lastMessageAt = Date.now();
|
||||
});
|
||||
ws.on("close", (code, reason) => {
|
||||
cleanup();
|
||||
const normalizedReason = Buffer.isBuffer(reason) && reason.length > 0 ? reason.toString("utf8") : undefined;
|
||||
if (!settled) {
|
||||
fail(new Error(`WebSocket closed before ready (code=${code}${normalizedReason ? `, reason=${normalizedReason}` : ""})`));
|
||||
return;
|
||||
}
|
||||
this.stopStaleMonitor();
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
ws.on("error", (error) => {
|
||||
cleanup();
|
||||
this.logger("ws:error", error);
|
||||
cleanup();
|
||||
if (!settled) {
|
||||
fail(error);
|
||||
return;
|
||||
}
|
||||
this.stopStaleMonitor();
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -494,6 +630,77 @@ export class LighterGateway {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private forceReconnect(reason: string): void {
|
||||
const now = Date.now();
|
||||
if (this.staleReason && now - this.lastDepthUpdateAt < FEED_STALE_TIMEOUT_MS / 2) {
|
||||
this.staleReason = null;
|
||||
}
|
||||
if (this.staleReason) return;
|
||||
this.staleReason = reason;
|
||||
this.logger("ws:stale", reason);
|
||||
try {
|
||||
this.ws?.terminate();
|
||||
} catch (error) {
|
||||
this.logger("ws:terminate", error);
|
||||
}
|
||||
this.stopHeartbeat();
|
||||
this.stopClientPing();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
if (this.heartbeatTimer) return;
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const now = Date.now();
|
||||
if (now - this.lastMessageAt > WS_STALE_TIMEOUT_MS) {
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch (error) {
|
||||
this.logger("ws:terminate", error);
|
||||
} finally {
|
||||
this.stopHeartbeat();
|
||||
this.stopClientPing();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws.ping();
|
||||
} catch (error) {
|
||||
this.logger("ws:ping", error);
|
||||
}
|
||||
}, WS_HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startClientPing(): void {
|
||||
if (this.pingTimer) return;
|
||||
this.pingTimer = setInterval(() => {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
} catch (error) {
|
||||
this.logger("ws:clientPing", error);
|
||||
}
|
||||
}, CLIENT_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopClientPing(): void {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: WebSocket.RawData): void {
|
||||
try {
|
||||
const text = typeof data === "string" ? data : data.toString("utf8");
|
||||
@@ -502,6 +709,9 @@ export class LighterGateway {
|
||||
switch (type) {
|
||||
case "connected":
|
||||
break;
|
||||
case "ping":
|
||||
this.handlePing(message);
|
||||
break;
|
||||
case "subscribed/order_book":
|
||||
this.handleOrderBookSnapshot(message);
|
||||
break;
|
||||
@@ -528,6 +738,28 @@ export class LighterGateway {
|
||||
}
|
||||
}
|
||||
|
||||
private handlePing(message: Record<string, unknown> | null | undefined): void {
|
||||
const extraPayload: Record<string, unknown> = {};
|
||||
if (message && typeof message === "object") {
|
||||
for (const [key, value] of Object.entries(message)) {
|
||||
if (key === "type") continue;
|
||||
extraPayload[key] = value;
|
||||
}
|
||||
}
|
||||
this.sendPong(extraPayload);
|
||||
}
|
||||
|
||||
private sendPong(extra: Record<string, unknown> = {}): void {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = Object.keys(extra).length ? { ...extra, type: "pong" } : { type: "pong" };
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
this.logger("ws:pong", error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleOrderBookSnapshot(message: any): void {
|
||||
if (!message?.order_book) return;
|
||||
const incomingOffset = Number(message.offset ?? message.order_book?.offset ?? 0);
|
||||
@@ -548,6 +780,7 @@ export class LighterGateway {
|
||||
this.lastOrderBookOffset = snapshot.offset ?? incomingOffset ?? this.lastOrderBookOffset;
|
||||
this.lastOrderBookTimestamp = incomingTs || Date.now();
|
||||
this.emitDepth();
|
||||
this.markDepthUpdate();
|
||||
}
|
||||
|
||||
private handleOrderBookUpdate(message: any): void {
|
||||
@@ -574,81 +807,277 @@ export class LighterGateway {
|
||||
this.lastOrderBookOffset = Number(this.orderBook.offset ?? incomingOffset ?? this.lastOrderBookOffset);
|
||||
this.lastOrderBookTimestamp = incomingTs || Date.now();
|
||||
this.emitDepth();
|
||||
this.markDepthUpdate();
|
||||
}
|
||||
|
||||
private handleAccountAll(message: any): void {
|
||||
if (!message) return;
|
||||
// account_all may be partial; merge provided markets into existing positions
|
||||
if (Object.prototype.hasOwnProperty.call(message, "positions")) {
|
||||
const positionsObject = message.positions ?? {};
|
||||
const incoming: LighterPosition[] = (Array.isArray(positionsObject)
|
||||
? (positionsObject as LighterPosition[])
|
||||
: (Object.values(positionsObject) as LighterPosition[])) as LighterPosition[];
|
||||
|
||||
const byMarket = new Map<number, LighterPosition>();
|
||||
for (const p of this.positions ?? []) {
|
||||
const mid = Number(p.market_id);
|
||||
if (Number.isFinite(mid)) byMarket.set(mid, p);
|
||||
const incoming = this.normalizePositions(positionsObject);
|
||||
if (incoming.length) {
|
||||
this.mergePositions(incoming);
|
||||
this.recordWsPositionUpdate();
|
||||
}
|
||||
for (const p of incoming) {
|
||||
const mid = Number(p.market_id);
|
||||
if (!Number.isFinite(mid)) continue;
|
||||
const sign = Number(p.sign ?? 0);
|
||||
const size = Number(p.position ?? 0);
|
||||
if (sign === 0 || Math.abs(size) < 1e-12) {
|
||||
byMarket.delete(mid);
|
||||
} else {
|
||||
byMarket.set(mid, p);
|
||||
}
|
||||
}
|
||||
this.positions = Array.from(byMarket.values());
|
||||
}
|
||||
this.emitAccount();
|
||||
}
|
||||
|
||||
private handleAccountMarket(message: any): void {
|
||||
if (!message) return;
|
||||
const type = typeof message.type === "string" ? message.type : "";
|
||||
const position: LighterPosition | undefined = message.position as LighterPosition | undefined;
|
||||
if (!position || !Number.isFinite(Number(position.market_id))) return;
|
||||
const marketId = Number(position.market_id);
|
||||
const sign = Number(position.sign ?? 0);
|
||||
const size = Number(position.position ?? 0);
|
||||
const shouldRemove = sign === 0 || Math.abs(size) < 1e-12;
|
||||
if (shouldRemove) {
|
||||
this.positions = (this.positions ?? []).filter((p) => Number(p.market_id) !== marketId);
|
||||
} else {
|
||||
let updated = false;
|
||||
this.positions = (this.positions ?? []).map((p) => {
|
||||
if (Number(p.market_id) === marketId) {
|
||||
updated = true;
|
||||
return position;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
if (!updated) this.positions.push(position);
|
||||
const channelMarketId = this.extractMarketIdFromChannel(message.channel);
|
||||
if (position && Number.isFinite(Number(position.market_id))) {
|
||||
this.mergePositions([position]);
|
||||
this.markWsPositionForMarket(Number(position.market_id));
|
||||
this.recordWsPositionUpdate();
|
||||
}
|
||||
if (Array.isArray(message.orders) && message.orders.length) {
|
||||
const marketId = Number(position?.market_id ?? channelMarketId ?? this.marketId ?? NaN);
|
||||
this.applyOrderList(message.orders, Number.isFinite(marketId) ? Number(marketId) : null, type === "subscribed/account_market");
|
||||
} else if (type === "subscribed/account_market" && channelMarketId != null) {
|
||||
this.clearOrdersForMarket(channelMarketId);
|
||||
this.emitOrders();
|
||||
}
|
||||
if (position && this.shouldRemovePosition(position)) {
|
||||
const target = Number(position.market_id ?? channelMarketId);
|
||||
if (Number.isFinite(target)) {
|
||||
this.positions = this.positions.filter((entry) => Number(entry.market_id) !== target);
|
||||
this.lastWsPositionByMarket.delete(target);
|
||||
this.recordWsPositionUpdate();
|
||||
}
|
||||
}
|
||||
this.emitAccount();
|
||||
}
|
||||
|
||||
private handleAccountOrders(message: any): void {
|
||||
if (!message) return;
|
||||
const snapshot = message.type === "subscribed/account_all_orders";
|
||||
const ordersObject = message.orders ?? {};
|
||||
const buckets = Object.values(ordersObject) as unknown[];
|
||||
const allOrders: LighterOrder[] = buckets.flatMap((entry) => Array.isArray(entry) ? (entry as LighterOrder[]) : []);
|
||||
const terminalStatuses = new Set(["filled", "canceled", "cancelled", "expired"]);
|
||||
for (const order of allOrders) {
|
||||
const key = String(order.order_index ?? order.order_id ?? order.client_order_index ?? "");
|
||||
const status = (order.status ?? "").toLowerCase();
|
||||
if (!key) continue;
|
||||
if (terminalStatuses.has(status)) {
|
||||
this.orderMap.delete(key);
|
||||
this.applyOrderBuckets(ordersObject, snapshot);
|
||||
}
|
||||
|
||||
private normalizePositions(source: unknown): LighterPosition[] {
|
||||
if (!source) return [];
|
||||
if (Array.isArray(source)) {
|
||||
return source.filter((entry): entry is LighterPosition => this.isPosition(entry));
|
||||
}
|
||||
if (isPlainObject(source)) {
|
||||
return Object.values(source).filter((entry): entry is LighterPosition => this.isPosition(entry));
|
||||
}
|
||||
if (this.isPosition(source)) return [source];
|
||||
return [];
|
||||
}
|
||||
|
||||
private isPosition(value: unknown): value is LighterPosition {
|
||||
return typeof value === "object" && value != null && Number.isFinite(Number((value as LighterPosition).market_id));
|
||||
}
|
||||
|
||||
private mergePositions(updates: LighterPosition[]): void {
|
||||
if (!updates.length) return;
|
||||
const byMarket = new Map<number, LighterPosition>();
|
||||
for (const existing of this.positions ?? []) {
|
||||
const mid = Number(existing.market_id);
|
||||
if (Number.isFinite(mid)) {
|
||||
byMarket.set(mid, existing);
|
||||
}
|
||||
}
|
||||
for (const update of updates) {
|
||||
const marketId = Number(update.market_id);
|
||||
if (!Number.isFinite(marketId)) continue;
|
||||
if (this.shouldRemovePosition(update)) {
|
||||
byMarket.delete(marketId);
|
||||
this.lastWsPositionByMarket.delete(marketId);
|
||||
} else {
|
||||
this.orderMap.set(key, order);
|
||||
byMarket.set(marketId, update);
|
||||
this.markWsPositionForMarket(marketId);
|
||||
}
|
||||
}
|
||||
this.positions = Array.from(byMarket.values());
|
||||
}
|
||||
|
||||
private replacePositions(positions: LighterPosition[]): void {
|
||||
if (!positions.length) {
|
||||
this.positions = [];
|
||||
this.lastWsPositionByMarket.clear();
|
||||
return;
|
||||
}
|
||||
const filtered = this.filterPositions(positions);
|
||||
this.positions = filtered;
|
||||
const now = Date.now();
|
||||
this.lastWsPositionByMarket.clear();
|
||||
for (const pos of filtered) {
|
||||
const marketId = Number(pos.market_id);
|
||||
if (Number.isFinite(marketId)) {
|
||||
this.lastWsPositionByMarket.set(marketId, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private filterPositions(positions: LighterPosition[]): LighterPosition[] {
|
||||
const byMarket = new Map<number, LighterPosition>();
|
||||
for (const entry of positions) {
|
||||
const marketId = Number(entry.market_id);
|
||||
if (!Number.isFinite(marketId)) continue;
|
||||
if (this.shouldRemovePosition(entry)) {
|
||||
byMarket.delete(marketId);
|
||||
} else {
|
||||
byMarket.set(marketId, entry);
|
||||
}
|
||||
}
|
||||
return Array.from(byMarket.values());
|
||||
}
|
||||
|
||||
private shouldRemovePosition(position: LighterPosition): boolean {
|
||||
const size = Number(position.position ?? 0);
|
||||
return !Number.isFinite(size) || Math.abs(size) < POSITION_EPSILON;
|
||||
}
|
||||
|
||||
private removePositionsForMarkets(markets: number[]): void {
|
||||
if (!markets.length) return;
|
||||
const targets = new Set(markets.filter((value) => Number.isFinite(value)).map((value) => Number(value)));
|
||||
if (!targets.size) return;
|
||||
this.positions = (this.positions ?? []).filter((position) => !targets.has(Number(position.market_id)));
|
||||
}
|
||||
|
||||
private applyOrderBuckets(rawOrders: unknown, snapshot: boolean): void {
|
||||
const ordersObject = isPlainObject(rawOrders) ? (rawOrders as Record<string, unknown>) : {};
|
||||
const marketKeys = Object.keys(ordersObject);
|
||||
if (snapshot && marketKeys.length === 0) {
|
||||
this.orderMap.clear();
|
||||
this.orderIndexByClientId.clear();
|
||||
this.orders = [];
|
||||
this.emitOrders();
|
||||
return;
|
||||
}
|
||||
if (snapshot) {
|
||||
this.orderMap.clear();
|
||||
this.orderIndexByClientId.clear();
|
||||
}
|
||||
for (const [market, bucket] of Object.entries(ordersObject)) {
|
||||
const marketId = Number(market);
|
||||
const shouldReset = shouldResetMarketOrders(bucket, snapshot);
|
||||
if (shouldReset && Number.isFinite(marketId)) {
|
||||
this.clearOrdersForMarket(marketId);
|
||||
}
|
||||
const normalized = this.normalizeOrders(bucket);
|
||||
if (!normalized.length) continue;
|
||||
for (const order of normalized) {
|
||||
this.applyOrderUpdate(order);
|
||||
}
|
||||
}
|
||||
this.orders = Array.from(this.orderMap.values());
|
||||
const mapped = toOrders(this.displaySymbol, this.orders);
|
||||
this.ordersEvent.emit(mapped);
|
||||
this.emitOrders();
|
||||
}
|
||||
|
||||
private normalizeOrders(source: unknown): LighterOrder[] {
|
||||
if (!source) return [];
|
||||
if (Array.isArray(source)) {
|
||||
return (source as unknown[]).filter((entry): entry is LighterOrder => this.isOrder(entry));
|
||||
}
|
||||
if (isPlainObject(source) && this.isOrder(source)) {
|
||||
return [source];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private isOrder(value: unknown): value is LighterOrder {
|
||||
return typeof value === "object" && value != null;
|
||||
}
|
||||
|
||||
private applyOrderList(rawOrders: unknown, marketId: number | null, snapshot: boolean): void {
|
||||
const orders = this.normalizeOrders(rawOrders);
|
||||
if (snapshot) {
|
||||
if (marketId != null) {
|
||||
this.clearOrdersForMarket(marketId);
|
||||
} else {
|
||||
this.orderMap.clear();
|
||||
this.orderIndexByClientId.clear();
|
||||
}
|
||||
}
|
||||
for (const order of orders) {
|
||||
this.applyOrderUpdate(order);
|
||||
}
|
||||
this.orders = Array.from(this.orderMap.values());
|
||||
this.emitOrders();
|
||||
}
|
||||
|
||||
private applyOrderUpdate(order: LighterOrder): void {
|
||||
const orderIndex = this.extractOrderIndex(order);
|
||||
const clientIndex = this.extractClientIndex(order);
|
||||
if (orderIndex && clientIndex) {
|
||||
this.orderIndexByClientId.set(clientIndex, orderIndex);
|
||||
}
|
||||
if (orderIndex) {
|
||||
this.orderIndexByClientId.set(orderIndex, orderIndex);
|
||||
}
|
||||
const key = orderIndex ?? clientIndex;
|
||||
if (!key) return;
|
||||
const status = String(order.status ?? "").toLowerCase();
|
||||
if (TERMINAL_ORDER_STATUSES.has(status)) {
|
||||
const existing = this.orderMap.get(key);
|
||||
this.orderMap.delete(key);
|
||||
if (existing) {
|
||||
this.forgetOrderIdentity(existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
order.client_order_index != null ||
|
||||
order.order_index != null ||
|
||||
order.client_order_id != null ||
|
||||
order.order_id != null
|
||||
) {
|
||||
for (const [existingKey, existingOrder] of Array.from(this.orderMap.entries())) {
|
||||
if (existingKey === key) continue;
|
||||
const sameOrderIndex =
|
||||
orderIdentityEquals(order.order_index, existingOrder.order_index) ||
|
||||
orderIdentityEquals(order.order_id, existingOrder.order_id);
|
||||
const sameClientIndex =
|
||||
orderIdentityEquals(order.client_order_index, existingOrder.client_order_index) ||
|
||||
orderIdentityEquals(order.client_order_id, existingOrder.client_order_id);
|
||||
if (sameOrderIndex || sameClientIndex) {
|
||||
const removed = this.orderMap.get(existingKey);
|
||||
this.orderMap.delete(existingKey);
|
||||
if (removed) {
|
||||
this.forgetOrderIdentity(removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.orderMap.set(key, order);
|
||||
}
|
||||
|
||||
private clearOrdersForMarket(marketId: number): void {
|
||||
const normalized = Number(marketId);
|
||||
if (!Number.isFinite(normalized)) return;
|
||||
for (const [key, existing] of Array.from(this.orderMap.entries())) {
|
||||
const existingMarket =
|
||||
(existing as { market_index?: number | string; market_id?: number | string }).market_index ??
|
||||
(existing as { market_id?: number | string }).market_id;
|
||||
if (Number(existingMarket) === normalized) {
|
||||
this.orderMap.delete(key);
|
||||
this.forgetOrderIdentity(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractMarketIdFromChannel(channel: unknown): number | null {
|
||||
if (typeof channel !== "string") return null;
|
||||
const match = channel.match(/account_market:(\d+)/);
|
||||
if (match && match[1]) {
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private isEmptyPositionsPayload(value: unknown): boolean {
|
||||
if (value == null) return true;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (isPlainObject(value)) return Object.keys(value).length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private emitDepth(): void {
|
||||
@@ -658,6 +1087,14 @@ export class LighterGateway {
|
||||
this.emitSyntheticTicker();
|
||||
}
|
||||
|
||||
private markDepthUpdate(): void {
|
||||
this.lastDepthUpdateAt = Date.now();
|
||||
if (this.staleReason && this.staleReason.startsWith("depth")) {
|
||||
this.logger("ws:stale:recovered", this.staleReason);
|
||||
this.staleReason = null;
|
||||
}
|
||||
}
|
||||
|
||||
private emitAccount(): void {
|
||||
if (!this.accountDetails) return;
|
||||
const snapshot = toAccountSnapshot(
|
||||
@@ -668,11 +1105,67 @@ export class LighterGateway {
|
||||
{ marketSymbol: this.marketSymbol, marketId: this.marketId }
|
||||
);
|
||||
this.accountEvent.emit(snapshot);
|
||||
this.lastAccountUpdateAt = Date.now();
|
||||
if (this.staleReason && this.staleReason.startsWith("account")) {
|
||||
this.staleReason = null;
|
||||
}
|
||||
}
|
||||
|
||||
private emitOrders(): void {
|
||||
const mapped = toOrders(this.displaySymbol, this.orders ?? []);
|
||||
this.ordersEvent.emit(mapped);
|
||||
this.lastOrdersUpdateAt = Date.now();
|
||||
if (this.staleReason && this.staleReason.startsWith("orders")) {
|
||||
this.staleReason = null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveOrderIndex(orderId: string): string {
|
||||
const normalized = normalizeOrderIdentity(orderId);
|
||||
if (!normalized) {
|
||||
throw new Error(`Invalid order id: ${orderId}`);
|
||||
}
|
||||
return this.orderIndexByClientId.get(normalized) ?? normalized;
|
||||
}
|
||||
|
||||
private removeOrderLocally(orderId: string): void {
|
||||
const key = normalizeOrderIdentity(orderId);
|
||||
if (!key) return;
|
||||
const existing = this.orderMap.get(key);
|
||||
this.orderMap.delete(key);
|
||||
this.orderIndexByClientId.delete(key);
|
||||
if (existing) {
|
||||
this.forgetOrderIdentity(existing);
|
||||
}
|
||||
this.orders = Array.from(this.orderMap.values());
|
||||
this.emitOrders();
|
||||
}
|
||||
|
||||
private extractOrderIndex(order: LighterOrder): string | null {
|
||||
return (
|
||||
normalizeOrderIdentity(order.order_id) ??
|
||||
normalizeOrderIdentity(order.order_index) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private extractClientIndex(order: LighterOrder): string | null {
|
||||
return (
|
||||
normalizeOrderIdentity(order.client_order_id) ??
|
||||
normalizeOrderIdentity(order.client_order_index) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private forgetOrderIdentity(order: LighterOrder): void {
|
||||
const orderIndex = this.extractOrderIndex(order);
|
||||
const clientIndex = this.extractClientIndex(order);
|
||||
if (orderIndex) {
|
||||
this.orderIndexByClientId.delete(orderIndex);
|
||||
}
|
||||
if (clientIndex) {
|
||||
this.orderIndexByClientId.delete(clientIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
@@ -682,6 +1175,39 @@ export class LighterGateway {
|
||||
}, this.tickerPollMs);
|
||||
void this.refreshTicker();
|
||||
}
|
||||
|
||||
if (!this.accountPoller) {
|
||||
const pollAccount = () => {
|
||||
if (this.accountPollInFlight) return;
|
||||
this.accountPollInFlight = true;
|
||||
this.refreshAccountSnapshot()
|
||||
.catch((error) => this.logger("accountPoll", error))
|
||||
.finally(() => {
|
||||
this.accountPollInFlight = false;
|
||||
});
|
||||
};
|
||||
this.accountPoller = setInterval(pollAccount, ACCOUNT_POLL_INTERVAL_MS);
|
||||
pollAccount();
|
||||
}
|
||||
}
|
||||
|
||||
private startStaleMonitor(): void {
|
||||
if (this.staleMonitor) return;
|
||||
this.staleMonitor = setInterval(() => this.checkFeedStaleness(), STALE_CHECK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopStaleMonitor(): void {
|
||||
if (!this.staleMonitor) return;
|
||||
clearInterval(this.staleMonitor);
|
||||
this.staleMonitor = null;
|
||||
}
|
||||
|
||||
private checkFeedStaleness(): void {
|
||||
if (this.staleReason) return;
|
||||
const now = Date.now();
|
||||
if (now - this.lastDepthUpdateAt > FEED_STALE_TIMEOUT_MS) {
|
||||
this.forceReconnect("depth stale");
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshTicker(): Promise<void> {
|
||||
@@ -696,6 +1222,10 @@ export class LighterGateway {
|
||||
const ticker = toTicker(this.displaySymbol, match);
|
||||
this.tickerEvent.emit(ticker);
|
||||
this.loggedCreateOrderPayload = false;
|
||||
this.lastTickerUpdateAt = Date.now();
|
||||
if (this.staleReason && this.staleReason.startsWith("ticker")) {
|
||||
this.staleReason = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger("refreshTicker", error);
|
||||
}
|
||||
@@ -760,7 +1290,10 @@ export class LighterGateway {
|
||||
lowPrice: (bestBid ?? last).toString(),
|
||||
volume: "0",
|
||||
quoteVolume: "0",
|
||||
priceChange: undefined,
|
||||
bidPrice: bestBid != null ? bestBid.toString() : undefined,
|
||||
askPrice: bestAsk != null ? bestAsk.toString() : undefined,
|
||||
priceChange: bestBid != null && bestAsk != null ? (bestAsk - bestBid).toString() : undefined,
|
||||
markPrice: last.toString(),
|
||||
priceChangePercent: undefined,
|
||||
weightedAvgPrice: undefined,
|
||||
lastQty: undefined,
|
||||
@@ -773,6 +1306,28 @@ export class LighterGateway {
|
||||
this.tickerEvent.emit(ticker);
|
||||
}
|
||||
|
||||
async getPrecision(): Promise<{
|
||||
priceTick: number;
|
||||
qtyStep: number;
|
||||
priceDecimals: number;
|
||||
sizeDecimals: number;
|
||||
marketId: number | null;
|
||||
}> {
|
||||
await this.loadMetadata();
|
||||
if (this.priceDecimals == null || this.sizeDecimals == null) {
|
||||
throw new Error("Lighter market metadata not initialized");
|
||||
}
|
||||
const priceTick = decimalsToStep(this.priceDecimals);
|
||||
const qtyStep = decimalsToStep(this.sizeDecimals);
|
||||
return {
|
||||
priceTick,
|
||||
qtyStep,
|
||||
priceDecimals: this.priceDecimals,
|
||||
sizeDecimals: this.sizeDecimals,
|
||||
marketId: this.marketId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapCreateOrderParams(params: CreateOrderParams): Omit<CreateOrderSignParams, "nonce"> & {
|
||||
baseAmountScaledString: string;
|
||||
priceScaledString: string;
|
||||
@@ -787,7 +1342,7 @@ export class LighterGateway {
|
||||
}
|
||||
const side = params.side;
|
||||
const isAsk = side === "SELL" ? 1 : 0;
|
||||
const baseAmount = decimalToScaled(params.quantity, this.sizeDecimals);
|
||||
const baseAmount = scaleQuantityWithMinimum(params.quantity, this.sizeDecimals);
|
||||
const baseAmountScaledString = scaledToDecimalString(baseAmount, this.sizeDecimals);
|
||||
const clientOrderIndex = BigInt(Date.now() % Number.MAX_SAFE_INTEGER);
|
||||
let priceScaled = params.price != null ? decimalToScaled(params.price, this.priceDecimals) : null;
|
||||
@@ -855,13 +1410,15 @@ export class LighterGateway {
|
||||
function mergeLevels(existing: LighterOrderBookLevel[], updates: LighterOrderBookLevel[]): LighterOrderBookLevel[] {
|
||||
const map = new Map<string, string>();
|
||||
for (const level of existing) {
|
||||
map.set(level.price, level.size);
|
||||
const key = normalizePriceKey(level.price);
|
||||
map.set(key, normalizeSizeValue(level.size));
|
||||
}
|
||||
for (const update of updates) {
|
||||
const key = normalizePriceKey(update.price);
|
||||
if (Number(update.size) <= 0) {
|
||||
map.delete(update.price);
|
||||
map.delete(key);
|
||||
} else {
|
||||
map.set(update.price, update.size);
|
||||
map.set(key, normalizeSizeValue(update.size));
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries()).map(([price, size]) => ({ price, size } as LighterOrderBookLevel));
|
||||
@@ -888,12 +1445,15 @@ function normalizeLevels(raw: Array<LighterOrderBookLevel | [string | number, st
|
||||
return raw
|
||||
.map((entry) => {
|
||||
if (Array.isArray(entry)) {
|
||||
const price = String(entry[0]);
|
||||
const size = String(entry[1]);
|
||||
const price = normalizePriceKey(entry[0] as string | number);
|
||||
const size = normalizeSizeValue(entry[1]);
|
||||
return { price, size } as LighterOrderBookLevel;
|
||||
}
|
||||
const obj = entry as LighterOrderBookLevel;
|
||||
return { price: String(obj.price), size: String(obj.size) } as LighterOrderBookLevel;
|
||||
return {
|
||||
price: normalizePriceKey(obj.price),
|
||||
size: normalizeSizeValue(obj.size),
|
||||
} as LighterOrderBookLevel;
|
||||
})
|
||||
.filter((lvl) => lvl.price != null && lvl.size != null);
|
||||
}
|
||||
@@ -914,6 +1474,26 @@ function sortAndTrimLevels(
|
||||
return list.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
function normalizePriceKey(value: string | number | undefined): string {
|
||||
if (value == null) return "0";
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return String(value).trim();
|
||||
}
|
||||
const fixed = num.toFixed(12);
|
||||
return fixed.replace(/\.?0+$/, "") || "0";
|
||||
}
|
||||
|
||||
function normalizeSizeValue(value: string | number | undefined): string {
|
||||
if (value == null) return "0";
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (Math.abs(num) < 1e-12) return "0";
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
function mapOrderType(type: OrderType): number {
|
||||
switch (type) {
|
||||
case "MARKET":
|
||||
@@ -941,3 +1521,15 @@ function mapTimeInForce(timeInForce: string | undefined, type: OrderType): numbe
|
||||
return LIGHTER_TIME_IN_FORCE.GOOD_TILL_TIME;
|
||||
}
|
||||
}
|
||||
|
||||
function decimalsToStep(decimals: number): number {
|
||||
if (!Number.isFinite(decimals) || decimals <= 0) {
|
||||
return 1;
|
||||
}
|
||||
const step = Number(`1e-${decimals}`);
|
||||
return Number.isFinite(step) ? step : Math.pow(10, -decimals);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value != null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ import type {
|
||||
LighterOrderBookSnapshot,
|
||||
LighterPosition,
|
||||
} from "./types";
|
||||
import { coerceBooleanFlag, normalizeBooleanFlag } from "./flags";
|
||||
import { normalizeOrderIdentity } from "./order-identity";
|
||||
import { normalizeOrderStatus } from "./status";
|
||||
|
||||
export function toDepth(symbol: string, snapshot: LighterOrderBookSnapshot): AsterDepth {
|
||||
const toLevels = (levels: LighterOrderBookLevel[]): AsterDepthLevel[] =>
|
||||
@@ -74,24 +77,43 @@ export function toOrders(symbol: string, orders: LighterOrder[]): AsterOrder[] {
|
||||
}
|
||||
|
||||
export function lighterOrderToAster(symbol: string, order: LighterOrder): AsterOrder {
|
||||
const side: OrderSide = order.is_ask || order.side?.toLowerCase() === "sell" || order.side?.toLowerCase() === "ask"
|
||||
? "SELL"
|
||||
: "BUY";
|
||||
const booleanIsAsk = normalizeBooleanFlag(order.is_ask);
|
||||
const normalizedSide = order.side?.toLowerCase();
|
||||
const side: OrderSide =
|
||||
booleanIsAsk != null
|
||||
? booleanIsAsk
|
||||
? "SELL"
|
||||
: "BUY"
|
||||
: normalizedSide === "sell" || normalizedSide === "ask"
|
||||
? "SELL"
|
||||
: "BUY";
|
||||
const reduceOnly = coerceBooleanFlag(order.reduce_only, false);
|
||||
const orderIndex =
|
||||
normalizeOrderIdentity(order.order_id) ??
|
||||
normalizeOrderIdentity(order.order_index) ??
|
||||
normalizeOrderIdentity(order.client_order_index) ??
|
||||
normalizeOrderIdentity(order.client_order_id) ??
|
||||
"";
|
||||
const clientIndex =
|
||||
normalizeOrderIdentity(order.client_order_id) ??
|
||||
normalizeOrderIdentity(order.client_order_index) ??
|
||||
"";
|
||||
return {
|
||||
orderId: order.order_index,
|
||||
clientOrderId: String(order.client_order_index ?? order.order_index ?? ""),
|
||||
// Use string order id to avoid precision loss; prefer on-chain order_index for cancellation
|
||||
orderId: orderIndex,
|
||||
clientOrderId: clientIndex || orderIndex,
|
||||
symbol,
|
||||
side,
|
||||
type: mapOrderType(order.type),
|
||||
status: order.status ?? order.trigger_status ?? "UNKNOWN",
|
||||
status: normalizeOrderStatus(order.status ?? order.trigger_status ?? "UNKNOWN"),
|
||||
price: order.price ?? "0",
|
||||
origQty: order.initial_base_amount ?? "0",
|
||||
executedQty: computeExecutedQty(order),
|
||||
stopPrice: order.trigger_price ?? "0",
|
||||
time: order.created_at ?? Date.now(),
|
||||
updateTime: order.updated_at ?? Date.now(),
|
||||
reduceOnly: Boolean(order.reduce_only),
|
||||
closePosition: Boolean(order.reduce_only ?? order.owner_account_index === undefined ? false : order.is_ask),
|
||||
reduceOnly,
|
||||
closePosition: reduceOnly,
|
||||
workingType: "MARK_PRICE",
|
||||
activationPrice: order.trigger_price,
|
||||
};
|
||||
@@ -143,16 +165,23 @@ export function toAccountSnapshot(
|
||||
assets: AsterAccountAsset[] = [],
|
||||
options?: { marketSymbol?: string | null; marketId?: number | null }
|
||||
): AsterAccountSnapshot {
|
||||
const targetSymbol = options?.marketSymbol?.toUpperCase();
|
||||
const targetMarketId = options?.marketId;
|
||||
const targetSymbol = options?.marketSymbol ?? null;
|
||||
const targetMarketId =
|
||||
options?.marketId != null && Number.isFinite(Number(options.marketId))
|
||||
? Number(options.marketId)
|
||||
: null;
|
||||
const filteredPositions = positions.filter((position) => {
|
||||
const marketMatches =
|
||||
targetMarketId == null ||
|
||||
(Number.isFinite(Number(position.market_id)) && Number(position.market_id) === Number(targetMarketId));
|
||||
const symbolMatches =
|
||||
!targetSymbol ||
|
||||
(typeof position.symbol === "string" && position.symbol.toUpperCase() === targetSymbol);
|
||||
return marketMatches && symbolMatches;
|
||||
if (targetMarketId != null) {
|
||||
const positionMarketId = Number(position.market_id);
|
||||
if (Number.isFinite(positionMarketId)) {
|
||||
return positionMarketId === targetMarketId;
|
||||
}
|
||||
return targetSymbol ? symbolsMatch(position.symbol, targetSymbol) : false;
|
||||
}
|
||||
if (targetSymbol) {
|
||||
return symbolsMatch(position.symbol, targetSymbol);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const transformedPositions = filteredPositions.map((position) => lighterPositionToAster(symbol, position));
|
||||
const aggregateUnrealized = transformedPositions.reduce((acc, pos) => acc + Number(pos.unrealizedProfit ?? 0), 0);
|
||||
@@ -200,3 +229,23 @@ function lighterPositionToAster(symbol: string, position: LighterPosition): Aste
|
||||
markPrice: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function symbolsMatch(source: string | null | undefined, target: string | null | undefined): boolean {
|
||||
if (!source || !target) return false;
|
||||
const sourceForms = normalizeSymbolForms(source);
|
||||
const targetForms = normalizeSymbolForms(target);
|
||||
if (!sourceForms.length || !targetForms.length) return false;
|
||||
return sourceForms.some((value) => targetForms.includes(value));
|
||||
}
|
||||
|
||||
function normalizeSymbolForms(value: string): string[] {
|
||||
const upper = value.toUpperCase();
|
||||
const sanitized = upper.replace(/[^A-Z0-9]/g, "");
|
||||
const parts = upper.split(/[-:/]/).filter(Boolean);
|
||||
const base = parts.length ? parts[0] : "";
|
||||
const forms = new Set<string>();
|
||||
if (upper) forms.add(upper);
|
||||
if (sanitized) forms.add(sanitized);
|
||||
if (base) forms.add(base);
|
||||
return Array.from(forms);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value != null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a per-market websocket payload should reset the cached
|
||||
* orders before applying its contents.
|
||||
*/
|
||||
export function shouldResetMarketOrders(bucket: unknown, snapshot: boolean): boolean {
|
||||
if (snapshot) return true;
|
||||
if (bucket == null) return false;
|
||||
if (Array.isArray(bucket)) {
|
||||
return bucket.length === 0;
|
||||
}
|
||||
if (isPlainObject(bucket)) {
|
||||
return Object.keys(bucket).length === 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Helpers for working with Lighter order identifiers without losing precision.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes any order identifier into a trimmed string representation.
|
||||
* Accepts string, number, or bigint inputs; returns null when the value
|
||||
* cannot be represented as a meaningful identifier.
|
||||
*/
|
||||
export function normalizeOrderIdentity(value: unknown): string | null {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(value).toString(10);
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString(10);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two identifier-like values without casting through Number(),
|
||||
* which would drop precision for large (>2^53) indices.
|
||||
*/
|
||||
export function orderIdentityEquals(a: unknown, b: unknown): boolean {
|
||||
const left = normalizeOrderIdentity(a);
|
||||
const right = normalizeOrderIdentity(b);
|
||||
return left != null && right != null && left === right;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 ?? "GTC",
|
||||
},
|
||||
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("Lighter 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,14 @@
|
||||
const TERMINAL_STATUS_MAP: Record<string, string> = {
|
||||
filled: "FILLED",
|
||||
canceled: "CANCELED",
|
||||
cancelled: "CANCELED",
|
||||
expired: "EXPIRED",
|
||||
"canceled-post-only": "CANCELED",
|
||||
"canceled-reduce-only": "CANCELED",
|
||||
};
|
||||
|
||||
export function normalizeOrderStatus(raw: string): string {
|
||||
if (!raw) return "UNKNOWN";
|
||||
const normalized = raw.toLowerCase();
|
||||
return TERMINAL_STATUS_MAP[normalized] ?? raw.toUpperCase();
|
||||
}
|
||||
@@ -8,12 +8,14 @@ export type LighterOrderType =
|
||||
| "take_profit_limit"
|
||||
| string;
|
||||
|
||||
type StrOrNum = string | number;
|
||||
|
||||
export interface LighterOrder {
|
||||
order_index: number;
|
||||
client_order_index: number;
|
||||
order_id?: string;
|
||||
client_order_id?: string;
|
||||
market_index: number;
|
||||
order_index: StrOrNum;
|
||||
client_order_index: StrOrNum;
|
||||
order_id?: string | null;
|
||||
client_order_id?: string | null;
|
||||
market_index: StrOrNum;
|
||||
owner_account_index?: number;
|
||||
initial_base_amount: string;
|
||||
remaining_base_amount: string;
|
||||
@@ -27,8 +29,8 @@ export interface LighterOrder {
|
||||
time_in_force?: string;
|
||||
trigger_price?: string;
|
||||
reduce_only?: boolean;
|
||||
status?: string;
|
||||
trigger_status?: string;
|
||||
status?: string | number;
|
||||
trigger_status?: string | number;
|
||||
trigger_time?: number;
|
||||
updated_at?: number;
|
||||
created_at?: number;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ExchangeAdapter } from "./adapter";
|
||||
import type { AsterOrder } from "./types";
|
||||
import type {
|
||||
BaseOrderIntent,
|
||||
ClosePositionIntent,
|
||||
LimitOrderIntent,
|
||||
MarketOrderIntent,
|
||||
StopOrderIntent,
|
||||
TrailingStopOrderIntent,
|
||||
} from "./order-schema";
|
||||
import * as asterOrders from "./aster/order";
|
||||
import * as backpackOrders from "./backpack/order";
|
||||
import * as grvtOrders from "./grvt/order";
|
||||
import * as lighterOrders from "./lighter/order";
|
||||
import * as paradexOrders from "./paradex/order";
|
||||
|
||||
type ExchangeKey = "aster" | "backpack" | "grvt" | "lighter" | "paradex";
|
||||
|
||||
interface ExchangeOrderHandlers {
|
||||
limit(intent: LimitOrderIntent): Promise<AsterOrder>;
|
||||
market(intent: MarketOrderIntent): Promise<AsterOrder>;
|
||||
stop(intent: StopOrderIntent): Promise<AsterOrder>;
|
||||
trailingStop?: (intent: TrailingStopOrderIntent) => Promise<AsterOrder>;
|
||||
close(intent: ClosePositionIntent): Promise<AsterOrder>;
|
||||
}
|
||||
|
||||
const handlerMap: Record<ExchangeKey, ExchangeOrderHandlers> = {
|
||||
aster: {
|
||||
limit: asterOrders.createLimitOrder,
|
||||
market: asterOrders.createMarketOrder,
|
||||
stop: asterOrders.createStopOrder,
|
||||
trailingStop: asterOrders.createTrailingStopOrder,
|
||||
close: asterOrders.createClosePositionOrder,
|
||||
},
|
||||
backpack: {
|
||||
limit: backpackOrders.createLimitOrder,
|
||||
market: backpackOrders.createMarketOrder,
|
||||
stop: backpackOrders.createStopOrder,
|
||||
trailingStop: backpackOrders.createTrailingStopOrder,
|
||||
close: backpackOrders.createClosePositionOrder,
|
||||
},
|
||||
grvt: {
|
||||
limit: grvtOrders.createLimitOrder,
|
||||
market: grvtOrders.createMarketOrder,
|
||||
stop: grvtOrders.createStopOrder,
|
||||
trailingStop: grvtOrders.createTrailingStopOrder,
|
||||
close: grvtOrders.createClosePositionOrder,
|
||||
},
|
||||
lighter: {
|
||||
limit: lighterOrders.createLimitOrder,
|
||||
market: lighterOrders.createMarketOrder,
|
||||
stop: lighterOrders.createStopOrder,
|
||||
trailingStop: lighterOrders.createTrailingStopOrder,
|
||||
close: lighterOrders.createClosePositionOrder,
|
||||
},
|
||||
paradex: {
|
||||
limit: paradexOrders.createLimitOrder,
|
||||
market: paradexOrders.createMarketOrder,
|
||||
stop: paradexOrders.createStopOrder,
|
||||
trailingStop: paradexOrders.createTrailingStopOrder,
|
||||
close: paradexOrders.createClosePositionOrder,
|
||||
},
|
||||
};
|
||||
|
||||
const knownExchanges: ExchangeKey[] = ["aster", "backpack", "grvt", "lighter", "paradex"];
|
||||
|
||||
function normalizeExchangeId(value: string | undefined | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveExchangeKey(adapter: ExchangeAdapter): ExchangeKey {
|
||||
const fromEnv = normalizeExchangeId(process.env.TRADE_EXCHANGE ?? process.env.EXCHANGE);
|
||||
const candidates = [fromEnv, normalizeExchangeId(adapter.id)];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
if ((knownExchanges as string[]).includes(candidate)) {
|
||||
return candidate as ExchangeKey;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported exchange for order routing: ${candidates.filter(Boolean).join(", ") || "unknown"}`
|
||||
);
|
||||
}
|
||||
|
||||
function getHandlers(intent: BaseOrderIntent): ExchangeOrderHandlers {
|
||||
const exchangeKey = resolveExchangeKey(intent.adapter);
|
||||
const handlers = handlerMap[exchangeKey];
|
||||
if (!handlers) {
|
||||
throw new Error(`Order handlers not implemented for exchange: ${exchangeKey}`);
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
|
||||
export function routeLimitOrder(intent: LimitOrderIntent): Promise<AsterOrder> {
|
||||
return getHandlers(intent).limit(intent);
|
||||
}
|
||||
|
||||
export function routeMarketOrder(intent: MarketOrderIntent): Promise<AsterOrder> {
|
||||
return getHandlers(intent).market(intent);
|
||||
}
|
||||
|
||||
export function routeStopOrder(intent: StopOrderIntent): Promise<AsterOrder> {
|
||||
return getHandlers(intent).stop(intent);
|
||||
}
|
||||
|
||||
export function routeTrailingStopOrder(intent: TrailingStopOrderIntent): Promise<AsterOrder> {
|
||||
const handlers = getHandlers(intent);
|
||||
if (!handlers.trailingStop) {
|
||||
throw new Error("Trailing stop orders are not supported by the current exchange");
|
||||
}
|
||||
return handlers.trailingStop(intent);
|
||||
}
|
||||
|
||||
export function routeCloseOrder(intent: ClosePositionIntent): Promise<AsterOrder> {
|
||||
return getHandlers(intent).close(intent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ExchangeAdapter } from "./adapter";
|
||||
import type { OrderSide, TimeInForce } from "./types";
|
||||
|
||||
export interface BaseOrderIntent {
|
||||
adapter: ExchangeAdapter;
|
||||
symbol: string;
|
||||
side: OrderSide;
|
||||
quantity: number;
|
||||
reduceOnly?: boolean;
|
||||
closePosition?: boolean;
|
||||
timeInForce?: TimeInForce | "GTX";
|
||||
clientOrderId?: string;
|
||||
}
|
||||
|
||||
export interface LimitOrderIntent extends BaseOrderIntent {
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface MarketOrderIntent extends BaseOrderIntent {
|
||||
expectedPrice?: number | null;
|
||||
}
|
||||
|
||||
export interface StopOrderIntent extends BaseOrderIntent {
|
||||
stopPrice: number;
|
||||
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
||||
}
|
||||
|
||||
export interface TrailingStopOrderIntent extends BaseOrderIntent {
|
||||
activationPrice: number;
|
||||
callbackRate: number;
|
||||
}
|
||||
|
||||
export interface ClosePositionIntent extends BaseOrderIntent {
|
||||
expectedPrice?: number | null;
|
||||
}
|
||||
|
||||
export type ExchangeOrderType = "limit" | "market" | "stop" | "trailingStop" | "close";
|
||||
|
||||
export function toStringBoolean(value: boolean | undefined): "true" | "false" | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
@@ -551,7 +551,7 @@ export class ParadexGateway {
|
||||
const symbol = this.marketSymbol;
|
||||
const type = this.mapOrderTypeToCcxt(params.type);
|
||||
const side = params.side.toLowerCase();
|
||||
const amount = params.quantity;
|
||||
let amount = params.quantity;
|
||||
const price = params.price;
|
||||
|
||||
const extraParams: Record<string, unknown> = {};
|
||||
@@ -560,13 +560,56 @@ export class ParadexGateway {
|
||||
if (params.reduceOnly !== undefined) {
|
||||
extraParams.reduceOnly = params.reduceOnly === "true";
|
||||
}
|
||||
if (params.closePosition !== undefined) {
|
||||
// propagate closePosition flag to the exchange params when provided
|
||||
(extraParams as any).closePosition = params.closePosition === "true";
|
||||
}
|
||||
|
||||
// Normalize amount for Paradex according to market precision/limits.
|
||||
// For STOP_MARKET closePosition orders, prefer using the current position size.
|
||||
try {
|
||||
const market = typeof (this.exchange as any).market === "function"
|
||||
? (this.exchange as any).market(symbol)
|
||||
: (this.exchange.markets ?? {})[symbol];
|
||||
const precisionDigits = Number((market?.precision?.amount ?? market?.amountPrecision));
|
||||
const limitMin = Number(market?.limits?.amount?.min);
|
||||
// Only trust explicit exchange min limit; do NOT infer 1 from precision=0
|
||||
const minAmount = Number.isFinite(limitMin) && limitMin > 0 ? limitMin : undefined;
|
||||
|
||||
// If closePosition is requested and amount is missing or too small, prefer using current position size
|
||||
const isClosePosition = (extraParams as any).closePosition === true;
|
||||
if (isClosePosition) {
|
||||
const posAbs = this.getCurrentPositionAbs();
|
||||
if (Number.isFinite(posAbs) && posAbs > 0) {
|
||||
amount = posAbs;
|
||||
}
|
||||
const current = Number(amount);
|
||||
if (!Number.isFinite(current) || current <= 0 || (minAmount !== undefined && current < minAmount)) {
|
||||
amount = (minAmount as number) ?? 1e-5; // fallback if market data is missing
|
||||
}
|
||||
}
|
||||
|
||||
// Quantize to exchange precision if helper is available (safe for STOP orders)
|
||||
if (typeof (this.exchange as any).amountToPrecision === "function" && Number.isFinite(Number(amount))) {
|
||||
amount = Number((this.exchange as any).amountToPrecision(symbol, amount));
|
||||
}
|
||||
} catch (_normalizeError) {
|
||||
// Swallow precision normalization errors and let exchange validation surface if any
|
||||
}
|
||||
|
||||
try {
|
||||
const isClosePosition = (extraParams as any).closePosition === true;
|
||||
// Paradex requires explicit size for close-position MARKET orders; always send amount
|
||||
const shouldOmitAmount = false;
|
||||
const amountArg: any = shouldOmitAmount ? undefined : amount;
|
||||
if (!shouldOmitAmount && amountArg != null && extraParams.size === undefined) {
|
||||
extraParams.size = amountArg.toString();
|
||||
}
|
||||
const order = (await this.exchange.createOrder(
|
||||
symbol,
|
||||
type,
|
||||
side,
|
||||
amount,
|
||||
amountArg,
|
||||
price,
|
||||
extraParams
|
||||
)) as CcxtOrder;
|
||||
@@ -578,6 +621,16 @@ export class ParadexGateway {
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentPositionAbs(): number | undefined {
|
||||
const snapshot = this.lastBalanceSnapshot;
|
||||
if (!snapshot) return undefined;
|
||||
const pos = (snapshot.positions || []).find((p) => p.symbol === this.displaySymbol);
|
||||
if (!pos) return undefined;
|
||||
const amt = Number(pos.positionAmt);
|
||||
if (!Number.isFinite(amt)) return undefined;
|
||||
return Math.abs(amt);
|
||||
}
|
||||
|
||||
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
|
||||
await this.ensureInitialized(params.symbol);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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 ?? "GTC",
|
||||
},
|
||||
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,
|
||||
},
|
||||
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,
|
||||
price: 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("Paradex 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,
|
||||
},
|
||||
intent
|
||||
);
|
||||
return intent.adapter.createOrder(params);
|
||||
}
|
||||
@@ -4,7 +4,10 @@ export type OrderSide = "BUY" | "SELL";
|
||||
export type OrderType =
|
||||
| "LIMIT"
|
||||
| "MARKET"
|
||||
| "STOP"
|
||||
| "STOP_MARKET"
|
||||
| "TAKE_PROFIT"
|
||||
| "TAKE_PROFIT_MARKET"
|
||||
| "TRAILING_STOP_MARKET";
|
||||
export type PositionSide = "BOTH" | "LONG" | "SHORT";
|
||||
export type TimeInForce = "GTC" | "IOC" | "FOK" | "GTX";
|
||||
@@ -22,6 +25,7 @@ export interface CreateOrderParams {
|
||||
reduceOnly?: StringBoolean;
|
||||
closePosition?: StringBoolean;
|
||||
triggerType?: "UNSPECIFIED" | "TAKE_PROFIT" | "STOP_LOSS";
|
||||
clientOrderId?: string;
|
||||
}
|
||||
|
||||
export interface AsterAccountPosition {
|
||||
@@ -313,6 +317,9 @@ export interface AsterTicker {
|
||||
priceChange?: string;
|
||||
priceChangePercent?: string;
|
||||
weightedAvgPrice?: string;
|
||||
bidPrice?: string;
|
||||
askPrice?: string;
|
||||
markPrice?: string;
|
||||
lastQty?: string;
|
||||
openTime?: number;
|
||||
closeTime?: number;
|
||||
@@ -321,6 +328,253 @@ export interface AsterTicker {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface AsterSpotRateLimit {
|
||||
rateLimitType: string;
|
||||
interval: string;
|
||||
intervalNum: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface AsterSpotExchangeFilter {
|
||||
filterType: string;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface AsterFuturesSymbolFilter {
|
||||
filterType: string;
|
||||
tickSize?: string;
|
||||
stepSize?: string;
|
||||
minPrice?: string;
|
||||
maxPrice?: string;
|
||||
minQty?: string;
|
||||
maxQty?: string;
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
}
|
||||
|
||||
export interface AsterFuturesSymbolInfo {
|
||||
symbol: string;
|
||||
pair?: string;
|
||||
contractType?: string;
|
||||
pricePrecision?: number;
|
||||
quantityPrecision?: number;
|
||||
baseAssetPrecision?: number;
|
||||
quotePrecision?: number;
|
||||
underlyingType?: string;
|
||||
filters?: AsterFuturesSymbolFilter[];
|
||||
}
|
||||
|
||||
export interface AsterFuturesExchangeInfo {
|
||||
timezone?: string;
|
||||
serverTime?: number;
|
||||
symbols?: AsterFuturesSymbolInfo[];
|
||||
}
|
||||
|
||||
export interface AsterSpotAssetInfo {
|
||||
asset: string;
|
||||
}
|
||||
|
||||
export interface AsterSpotSymbolInfo {
|
||||
symbol: string;
|
||||
status: string;
|
||||
baseAsset: string;
|
||||
quoteAsset: string;
|
||||
baseAssetPrecision?: number;
|
||||
quotePrecision?: number;
|
||||
pricePrecision?: number;
|
||||
quantityPrecision?: number;
|
||||
orderTypes: string[];
|
||||
timeInForce: string[];
|
||||
ocoAllowed: boolean;
|
||||
filters: AsterSpotExchangeFilter[];
|
||||
}
|
||||
|
||||
export interface AsterSpotExchangeInfo {
|
||||
timezone: string;
|
||||
serverTime: number;
|
||||
rateLimits: AsterSpotRateLimit[];
|
||||
exchangeFilters: AsterSpotExchangeFilter[];
|
||||
assets?: AsterSpotAssetInfo[];
|
||||
symbols: AsterSpotSymbolInfo[];
|
||||
}
|
||||
|
||||
export interface AsterSpotDepth {
|
||||
lastUpdateId: number;
|
||||
E?: number;
|
||||
T?: number;
|
||||
bids: AsterDepthLevel[];
|
||||
asks: AsterDepthLevel[];
|
||||
}
|
||||
|
||||
export interface AsterSpotTrade {
|
||||
id: number;
|
||||
price: string;
|
||||
qty: string;
|
||||
baseQty?: string;
|
||||
quoteQty?: string;
|
||||
time: number;
|
||||
isBuyerMaker: boolean;
|
||||
}
|
||||
|
||||
export interface AsterSpotHistoricalTrade extends AsterSpotTrade {
|
||||
isBestMatch?: boolean;
|
||||
}
|
||||
|
||||
export interface AsterSpotAggTrade {
|
||||
a: number;
|
||||
p: string;
|
||||
q: string;
|
||||
f: number;
|
||||
l: number;
|
||||
T: number;
|
||||
m: boolean;
|
||||
M?: boolean;
|
||||
}
|
||||
|
||||
export interface AsterSpotKline {
|
||||
openTime: number;
|
||||
open: string;
|
||||
high: string;
|
||||
low: string;
|
||||
close: string;
|
||||
volume: string;
|
||||
closeTime: number;
|
||||
quoteAssetVolume: string;
|
||||
numberOfTrades: number;
|
||||
takerBuyBaseAssetVolume: string;
|
||||
takerBuyQuoteAssetVolume: string;
|
||||
}
|
||||
|
||||
export interface AsterSpotTicker24h {
|
||||
symbol: string;
|
||||
priceChange: string;
|
||||
priceChangePercent: string;
|
||||
weightedAvgPrice: string;
|
||||
prevClosePrice: string;
|
||||
lastPrice: string;
|
||||
lastQty: string;
|
||||
bidPrice: string;
|
||||
bidQty: string;
|
||||
askPrice: string;
|
||||
askQty: string;
|
||||
openPrice: string;
|
||||
highPrice: string;
|
||||
lowPrice: string;
|
||||
volume: string;
|
||||
quoteVolume: string;
|
||||
openTime: number;
|
||||
closeTime: number;
|
||||
firstId: number;
|
||||
lastId: number;
|
||||
count: number;
|
||||
baseAsset?: string;
|
||||
quoteAsset?: string;
|
||||
}
|
||||
|
||||
export interface AsterSpotPriceTicker {
|
||||
symbol: string;
|
||||
price: string;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
export interface AsterSpotBookTicker {
|
||||
symbol: string;
|
||||
bidPrice: string;
|
||||
bidQty: string;
|
||||
askPrice: string;
|
||||
askQty: string;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
export interface AsterSpotCommissionRate {
|
||||
symbol: string;
|
||||
makerCommissionRate: string;
|
||||
takerCommissionRate: string;
|
||||
}
|
||||
|
||||
export interface CreateSpotOrderParams {
|
||||
symbol: string;
|
||||
side: OrderSide;
|
||||
type: OrderType;
|
||||
timeInForce?: TimeInForce;
|
||||
quantity?: number | string;
|
||||
quoteOrderQty?: number | string;
|
||||
price?: number | string;
|
||||
newClientOrderId?: string;
|
||||
stopPrice?: number | string;
|
||||
recvWindow?: number;
|
||||
}
|
||||
|
||||
export interface CancelSpotOrderParams {
|
||||
symbol: string;
|
||||
orderId?: number | string;
|
||||
origClientOrderId?: string;
|
||||
recvWindow?: number;
|
||||
}
|
||||
|
||||
export interface QuerySpotOrderParams extends CancelSpotOrderParams {}
|
||||
|
||||
export interface SpotOpenOrdersParams {
|
||||
symbol?: string;
|
||||
recvWindow?: number;
|
||||
orderIdList?: Array<number | string>;
|
||||
origClientOrderIdList?: string[];
|
||||
}
|
||||
|
||||
export interface SpotAllOrdersParams {
|
||||
symbol: string;
|
||||
orderId?: number;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
limit?: number;
|
||||
recvWindow?: number;
|
||||
}
|
||||
|
||||
export interface AsterSpotAccountBalance {
|
||||
asset: string;
|
||||
free: string;
|
||||
locked: string;
|
||||
}
|
||||
|
||||
export interface AsterSpotAccount {
|
||||
feeTier: number;
|
||||
canTrade: boolean;
|
||||
canDeposit: boolean;
|
||||
canWithdraw: boolean;
|
||||
canBurnAsset?: boolean;
|
||||
updateTime: number;
|
||||
makerCommission?: string;
|
||||
takerCommission?: string;
|
||||
buyerCommission?: string;
|
||||
sellerCommission?: string;
|
||||
balances: AsterSpotAccountBalance[];
|
||||
}
|
||||
|
||||
export interface SpotUserTradesParams {
|
||||
symbol?: string;
|
||||
orderId?: number;
|
||||
startTime?: number;
|
||||
endTime?: number;
|
||||
fromId?: number;
|
||||
limit?: number;
|
||||
recvWindow?: number;
|
||||
}
|
||||
|
||||
export interface AsterSpotUserTrade {
|
||||
symbol: string;
|
||||
id: number;
|
||||
orderId: number;
|
||||
side: OrderSide;
|
||||
price: string;
|
||||
qty: string;
|
||||
quoteQty?: string;
|
||||
commission: string;
|
||||
commissionAsset: string;
|
||||
time: number;
|
||||
counterpartyId?: number;
|
||||
maker: boolean;
|
||||
buyer: boolean;
|
||||
}
|
||||
|
||||
export interface AsterKline {
|
||||
eventType?: string;
|
||||
eventTime?: number;
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import type { BasisArbConfig } from "../config";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type { AsterDepth, AsterSpotBookTicker } from "../exchanges/types";
|
||||
import { AsterSpotRestClient, AsterRestClient } from "../exchanges/aster/client";
|
||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||
|
||||
export interface BasisArbSnapshot {
|
||||
ready: boolean;
|
||||
futuresSymbol: string;
|
||||
spotSymbol: string;
|
||||
futuresBid: number | null;
|
||||
futuresAsk: number | null;
|
||||
spotBid: number | null;
|
||||
spotAsk: number | null;
|
||||
futuresLastUpdate: number | null;
|
||||
spotLastUpdate: number | null;
|
||||
fundingRate: number | null;
|
||||
nextFundingTime: number | null;
|
||||
fundingLastUpdate: number | null;
|
||||
fundingIncomePerFunding: number | null; // USDT per funding event
|
||||
fundingIncomePerDay: number | null; // USDT per day (assuming 3 fundings/day)
|
||||
takerFeesPerRoundTrip: number | null; // USDT cost to open both legs
|
||||
fundingCountToBreakeven: number | null; // number of fundings to cover fees
|
||||
spread: number | null;
|
||||
spreadBps: number | null;
|
||||
netSpread: number | null;
|
||||
netSpreadBps: number | null;
|
||||
lastUpdated: number | null;
|
||||
tradeLog: TradeLogEntry[];
|
||||
feedStatus: {
|
||||
futures: boolean;
|
||||
spot: boolean;
|
||||
funding: boolean;
|
||||
};
|
||||
spotBalances: Array<{ asset: string; free: number; locked: number }>;
|
||||
futuresBalances: Array<{ asset: string; wallet: number; available: number }>;
|
||||
opportunity: boolean;
|
||||
}
|
||||
|
||||
type BasisArbEvent = "update";
|
||||
type BasisArbListener = (snapshot: BasisArbSnapshot) => void;
|
||||
|
||||
interface BasisArbDependencies {
|
||||
spotClient?: Pick<AsterSpotRestClient, "getBookTicker">;
|
||||
futuresClient?: Pick<AsterRestClient, "getPremiumIndex">;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
interface DepthState {
|
||||
bid: number | null;
|
||||
ask: number | null;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
interface SpotState {
|
||||
bid: number | null;
|
||||
ask: number | null;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
interface FundingState {
|
||||
rate: number | null;
|
||||
nextFundingTime: number | null;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
interface SpotBalanceStateEntry {
|
||||
asset: string;
|
||||
free: number;
|
||||
locked: number;
|
||||
}
|
||||
|
||||
interface FuturesBalanceStateEntry {
|
||||
asset: string;
|
||||
wallet: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
export class BasisArbEngine {
|
||||
private readonly events = new StrategyEventEmitter<BasisArbEvent, BasisArbSnapshot>();
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly spotClient: Pick<AsterSpotRestClient, "getBookTicker">;
|
||||
private readonly futuresClient: Pick<AsterRestClient, "getPremiumIndex">;
|
||||
private readonly now: () => number;
|
||||
private readonly config: BasisArbConfig;
|
||||
private readonly exchange: ExchangeAdapter;
|
||||
|
||||
private readonly futures: DepthState = { bid: null, ask: null, updatedAt: null };
|
||||
private readonly spot: SpotState = { bid: null, ask: null, updatedAt: null };
|
||||
private readonly funding: FundingState = { rate: null, nextFundingTime: null, updatedAt: null };
|
||||
private spotBalances: SpotBalanceStateEntry[] = [];
|
||||
private futuresBalances: FuturesBalanceStateEntry[] = [];
|
||||
|
||||
private readonly feedReady = { futures: false, spot: false, funding: false };
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private spotInFlight = false;
|
||||
private fundingInFlight = false;
|
||||
private spotAccountInFlight = false;
|
||||
private futuresAccountInFlight = false;
|
||||
private stopped = false;
|
||||
private lastEntrySignalAt = 0;
|
||||
private lastExitSignalAt = 0;
|
||||
private marketReadyAt: number | null = null;
|
||||
|
||||
constructor(config: BasisArbConfig, exchange: ExchangeAdapter, deps: BasisArbDependencies = {}) {
|
||||
this.config = config;
|
||||
this.exchange = exchange;
|
||||
this.spotClient = deps.spotClient ?? new AsterSpotRestClient();
|
||||
this.futuresClient = deps.futuresClient ?? new AsterRestClient();
|
||||
this.now = deps.now ?? (() => Date.now());
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
this.bootstrap();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => {
|
||||
void this.pollSpot();
|
||||
void this.pollFunding();
|
||||
void this.pollSpotAccount();
|
||||
void this.pollFuturesAccount();
|
||||
}, Math.max(this.config.refreshIntervalMs, 200));
|
||||
void this.pollSpot();
|
||||
void this.pollFunding();
|
||||
void this.pollSpotAccount();
|
||||
void this.pollFuturesAccount();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
on(event: BasisArbEvent, handler: BasisArbListener): void {
|
||||
this.events.on(event, handler);
|
||||
}
|
||||
|
||||
off(event: BasisArbEvent, handler: BasisArbListener): void {
|
||||
this.events.off(event, handler);
|
||||
}
|
||||
|
||||
getSnapshot(): BasisArbSnapshot {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
private bootstrap(): void {
|
||||
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
|
||||
|
||||
safeSubscribe<AsterDepth>(
|
||||
this.exchange.watchDepth.bind(this.exchange, this.config.futuresSymbol),
|
||||
(depth) => {
|
||||
this.applyFuturesDepth(depth);
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅期货深度失败: ${String(error)}`,
|
||||
processFail: (error) => `处理期货深度异常: ${String(error)}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private applyFuturesDepth(depth: AsterDepth): void {
|
||||
if (!depth?.bids?.length || !depth?.asks?.length) {
|
||||
return;
|
||||
}
|
||||
const topBid = Number(depth.bids[0]?.[0]);
|
||||
const topAsk = Number(depth.asks[0]?.[0]);
|
||||
if (!Number.isFinite(topBid) || !Number.isFinite(topAsk)) {
|
||||
return;
|
||||
}
|
||||
this.futures.bid = topBid;
|
||||
this.futures.ask = topAsk;
|
||||
this.futures.updatedAt = depth.eventTime ?? depth.tradeTime ?? this.now();
|
||||
if (!this.feedReady.futures) {
|
||||
this.feedReady.futures = true;
|
||||
this.tradeLog.push("info", `期货深度已就绪 (${this.config.futuresSymbol})`);
|
||||
}
|
||||
if (this.feedReady.futures && this.feedReady.spot && this.marketReadyAt == null) {
|
||||
this.marketReadyAt = this.now();
|
||||
}
|
||||
this.emitUpdate();
|
||||
}
|
||||
|
||||
private async pollSpot(): Promise<void> {
|
||||
if (this.spotInFlight || this.stopped) return;
|
||||
this.spotInFlight = true;
|
||||
try {
|
||||
const result = await this.spotClient.getBookTicker(this.config.spotSymbol);
|
||||
const ticker = Array.isArray(result) ? result[0] : result;
|
||||
if (!ticker) return;
|
||||
this.applySpotTicker(ticker);
|
||||
} catch (error) {
|
||||
this.feedReady.spot = false;
|
||||
this.tradeLog.push("error", `获取现货盘口失败: ${String(error instanceof Error ? error.message : error)}`);
|
||||
} finally {
|
||||
this.spotInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollFunding(): Promise<void> {
|
||||
if (this.fundingInFlight || this.stopped) return;
|
||||
this.fundingInFlight = true;
|
||||
try {
|
||||
const data = await this.futuresClient.getPremiumIndex(this.config.futuresSymbol);
|
||||
const rateRaw = (data.lastFundingRate ?? data.fundingRate) as string | undefined;
|
||||
const rate = rateRaw !== undefined ? Number(rateRaw) : NaN;
|
||||
const ts = (data.time ?? data.nextFundingTime ?? this.now()) as number | undefined;
|
||||
if (Number.isFinite(rate)) {
|
||||
this.funding.rate = Number(rateRaw);
|
||||
this.funding.nextFundingTime = typeof data.nextFundingTime === "number" ? data.nextFundingTime : null;
|
||||
this.funding.updatedAt = typeof ts === "number" ? ts : this.now();
|
||||
if (!this.feedReady.funding) {
|
||||
this.feedReady.funding = true;
|
||||
this.tradeLog.push("info", `资金费率已就绪 (${this.config.futuresSymbol})`);
|
||||
}
|
||||
this.emitUpdate();
|
||||
}
|
||||
} catch (error) {
|
||||
this.feedReady.funding = false;
|
||||
this.tradeLog.push("error", `获取资金费率失败: ${String(error instanceof Error ? error.message : error)}`);
|
||||
} finally {
|
||||
this.fundingInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollSpotAccount(): Promise<void> {
|
||||
if (this.spotAccountInFlight || this.stopped) return;
|
||||
this.spotAccountInFlight = true;
|
||||
try {
|
||||
// Spot balances via spot REST
|
||||
const account: any = await (this.spotClient as any).getAccount?.();
|
||||
const balances = Array.isArray(account?.balances) ? account.balances : [];
|
||||
const next: SpotBalanceStateEntry[] = [];
|
||||
for (const b of balances) {
|
||||
const asset = String(b.asset ?? "");
|
||||
const free = Number(b.free ?? 0);
|
||||
const locked = Number(b.locked ?? 0);
|
||||
if (!asset) continue;
|
||||
if (Math.abs(free) > 0 || Math.abs(locked) > 0) {
|
||||
next.push({ asset, free, locked });
|
||||
}
|
||||
}
|
||||
next.sort((a, b) => a.asset.localeCompare(b.asset));
|
||||
this.spotBalances = next;
|
||||
this.emitUpdate();
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `获取现货余额失败: ${String(error instanceof Error ? error.message : error)}`);
|
||||
} finally {
|
||||
this.spotAccountInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollFuturesAccount(): Promise<void> {
|
||||
if (this.futuresAccountInFlight || this.stopped) return;
|
||||
this.futuresAccountInFlight = true;
|
||||
try {
|
||||
// Futures balances via futures REST
|
||||
const rest = new AsterRestClient();
|
||||
const account: any = await rest.getAccount();
|
||||
const assets = Array.isArray(account?.assets) ? account.assets : [];
|
||||
const next: FuturesBalanceStateEntry[] = [];
|
||||
for (const a of assets) {
|
||||
const asset = String(a.asset ?? "");
|
||||
const wallet = Number(a.walletBalance ?? a.wb ?? 0);
|
||||
const available = Number(a.availableBalance ?? a.bc ?? 0);
|
||||
if (!asset) continue;
|
||||
if (Math.abs(wallet) > 0 || Math.abs(available) > 0) {
|
||||
next.push({ asset, wallet, available });
|
||||
}
|
||||
}
|
||||
next.sort((a, b) => a.asset.localeCompare(b.asset));
|
||||
this.futuresBalances = next;
|
||||
this.emitUpdate();
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `获取合约余额失败: ${String(error instanceof Error ? error.message : error)}`);
|
||||
} finally {
|
||||
this.futuresAccountInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private applySpotTicker(ticker: AsterSpotBookTicker): void {
|
||||
const bid = Number(ticker.bidPrice);
|
||||
const ask = Number(ticker.askPrice);
|
||||
if (!Number.isFinite(bid) || !Number.isFinite(ask)) {
|
||||
return;
|
||||
}
|
||||
this.spot.bid = bid;
|
||||
this.spot.ask = ask;
|
||||
this.spot.updatedAt = ticker.time ?? this.now();
|
||||
if (!this.feedReady.spot) {
|
||||
this.feedReady.spot = true;
|
||||
this.tradeLog.push("info", `现货盘口已就绪 (${this.config.spotSymbol})`);
|
||||
}
|
||||
if (this.feedReady.futures && this.feedReady.spot && this.marketReadyAt == null) {
|
||||
this.marketReadyAt = this.now();
|
||||
}
|
||||
this.emitUpdate();
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
// Build a single snapshot, evaluate signals against EXACTLY the same data, then emit that snapshot
|
||||
const snapshot = this.buildSnapshot();
|
||||
this.evaluateSignals(snapshot);
|
||||
this.events.emit("update", snapshot, (error) => {
|
||||
this.tradeLog.push("error", `推送订阅失败: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
private buildSnapshot(): BasisArbSnapshot {
|
||||
const futuresBid = this.futures.bid;
|
||||
const futuresAsk = this.futures.ask;
|
||||
const spotBid = this.spot.bid;
|
||||
const spotAsk = this.spot.ask;
|
||||
const fundingRate = this.funding.rate;
|
||||
const nextFundingTime = this.funding.nextFundingTime;
|
||||
const spread = this.computeSpread(futuresBid, spotAsk);
|
||||
const spreadBps = this.computeSpreadBps(spread, spotAsk);
|
||||
const netSpread = this.computeNetSpread(futuresBid, spotAsk);
|
||||
const netSpreadBps = this.computeSpreadBps(netSpread, spotAsk);
|
||||
const perFundingIncome = this.computeFundingIncomeUSDT(fundingRate, spotAsk);
|
||||
const perDayIncome = perFundingIncome != null ? perFundingIncome * 3 : null; // 3 times/day typical
|
||||
const takerFeesPerRoundTrip = this.computeRoundTripFeesUSDT(spotAsk);
|
||||
const fundingCountToBreakeven = perFundingIncome && perFundingIncome > 0 && takerFeesPerRoundTrip != null
|
||||
? takerFeesPerRoundTrip / perFundingIncome
|
||||
: null;
|
||||
const opportunity = netSpread != null && netSpread >= 0;
|
||||
const lastUpdated = Math.max(
|
||||
futuresBid != null && this.futures.updatedAt ? this.futures.updatedAt : 0,
|
||||
spotBid != null && this.spot.updatedAt ? this.spot.updatedAt : 0,
|
||||
fundingRate != null && this.funding.updatedAt ? this.funding.updatedAt : 0
|
||||
);
|
||||
|
||||
return {
|
||||
ready: this.feedReady.futures && this.feedReady.spot,
|
||||
futuresSymbol: this.config.futuresSymbol,
|
||||
spotSymbol: this.config.spotSymbol,
|
||||
futuresBid,
|
||||
futuresAsk,
|
||||
spotBid,
|
||||
spotAsk,
|
||||
futuresLastUpdate: this.futures.updatedAt,
|
||||
spotLastUpdate: this.spot.updatedAt,
|
||||
fundingRate,
|
||||
nextFundingTime,
|
||||
fundingLastUpdate: this.funding.updatedAt,
|
||||
fundingIncomePerFunding: perFundingIncome,
|
||||
fundingIncomePerDay: perDayIncome,
|
||||
takerFeesPerRoundTrip,
|
||||
fundingCountToBreakeven,
|
||||
spread,
|
||||
spreadBps,
|
||||
netSpread,
|
||||
netSpreadBps,
|
||||
lastUpdated: lastUpdated > 0 ? lastUpdated : null,
|
||||
tradeLog: this.tradeLog.all(),
|
||||
feedStatus: { ...this.feedReady },
|
||||
spotBalances: [...this.spotBalances],
|
||||
futuresBalances: [...this.futuresBalances],
|
||||
opportunity,
|
||||
};
|
||||
}
|
||||
|
||||
private computeSpread(futuresPrice: number | null, spotPrice: number | null): number | null {
|
||||
if (!Number.isFinite(futuresPrice ?? NaN) || !Number.isFinite(spotPrice ?? NaN)) return null;
|
||||
return Number(futuresPrice) - Number(spotPrice);
|
||||
}
|
||||
|
||||
private computeSpreadBps(spread: number | null, spotAsk: number | null): number | null {
|
||||
if (!Number.isFinite(spread ?? NaN) || !Number.isFinite(spotAsk ?? NaN)) return null;
|
||||
if (!spotAsk) return null;
|
||||
return (Number(spread) / Number(spotAsk)) * 10_000;
|
||||
}
|
||||
|
||||
private computeNetSpread(futuresBid: number | null, spotAsk: number | null): number | null {
|
||||
if (!Number.isFinite(futuresBid ?? NaN) || !Number.isFinite(spotAsk ?? NaN)) {
|
||||
return null;
|
||||
}
|
||||
const perSideFee = this.config.takerFeeRate ?? 0;
|
||||
const effectiveFee = perSideFee * 2;
|
||||
const sellFuturesNet = Number(futuresBid) * (1 - effectiveFee);
|
||||
const buySpotNet = Number(spotAsk) * (1 + effectiveFee);
|
||||
return sellFuturesNet - buySpotNet;
|
||||
}
|
||||
|
||||
private evaluateSignals(snapshot: BasisArbSnapshot): void {
|
||||
// Require futures, spot, and funding feeds ready
|
||||
if (!snapshot.feedStatus.futures || !snapshot.feedStatus.spot || !snapshot.feedStatus.funding) return;
|
||||
// Require at least one refresh of both futures and spot AFTER initial readiness to avoid startup triggers
|
||||
const readyAt = this.marketReadyAt;
|
||||
if (readyAt == null) return;
|
||||
const futTs = snapshot.futuresLastUpdate ?? 0;
|
||||
const spotTs = snapshot.spotLastUpdate ?? 0;
|
||||
if (futTs <= readyAt || spotTs <= readyAt) return;
|
||||
const now = this.now();
|
||||
// Use net spread after taker fees to match UI's "扣除 taker 手续费" bp
|
||||
const spreadBps = snapshot.netSpreadBps;
|
||||
const fundingRate = snapshot.fundingRate;
|
||||
const nextFundingTime = snapshot.nextFundingTime;
|
||||
const msUntilFunding = typeof nextFundingTime === "number" ? nextFundingTime - now : null;
|
||||
|
||||
// Entry signal: positive bp and next funding >= 10 minutes away
|
||||
if (Number.isFinite(spreadBps ?? NaN) && (spreadBps as number) > 0 && Number.isFinite(msUntilFunding ?? NaN) && (msUntilFunding as number) >= 10 * 60 * 1000) {
|
||||
if (now - this.lastEntrySignalAt >= 60 * 1000) { // debounce 60s
|
||||
this.lastEntrySignalAt = now;
|
||||
const bpTxt = (spreadBps as number).toFixed(2);
|
||||
const minutes = Math.floor(((msUntilFunding as number) / 60000));
|
||||
this.tradeLog.push("entry", `入场机会: 扣费后价差 ${bpTxt} bp | 距下次资金费约 ${minutes} 分钟`);
|
||||
}
|
||||
}
|
||||
|
||||
// Exit signal: funding rate negative and within 10 minutes before collection
|
||||
if (Number.isFinite(fundingRate ?? NaN) && (fundingRate as number) < 0 && Number.isFinite(msUntilFunding ?? NaN) && (msUntilFunding as number) > 0 && (msUntilFunding as number) <= 10 * 60 * 1000) {
|
||||
if (now - this.lastExitSignalAt >= 60 * 1000) { // debounce 60s
|
||||
this.lastExitSignalAt = now;
|
||||
const minutes = Math.max(0, Math.floor(((msUntilFunding as number) / 60000)));
|
||||
this.tradeLog.push("exit", `出场机会: 资金费率为负 | 距收取约 ${minutes} 分钟`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private computeFundingIncomeUSDT(fundingRate: number | null, spotAsk: number | null): number | null {
|
||||
if (!Number.isFinite(fundingRate ?? NaN)) return null;
|
||||
const price = Number.isFinite(spotAsk ?? NaN) ? Number(spotAsk) : null;
|
||||
const amount = Number.isFinite(this.config.arbAmount ?? NaN) ? Number(this.config.arbAmount) : null;
|
||||
if (price == null || amount == null) return null;
|
||||
// Funding income per event for a delta-neutral hedge ~ rate * notional
|
||||
// Notional in USDT = amount * price
|
||||
const notional = amount * price;
|
||||
const rate = Number(fundingRate);
|
||||
return notional * rate;
|
||||
}
|
||||
|
||||
private computeRoundTripFeesUSDT(spotAsk: number | null): number | null {
|
||||
const price = Number.isFinite(spotAsk ?? NaN) ? Number(spotAsk) : null;
|
||||
const amount = Number.isFinite(this.config.arbAmount ?? NaN) ? Number(this.config.arbAmount) : null;
|
||||
if (price == null || amount == null) return null;
|
||||
const notional = amount * price;
|
||||
// Two taker trades (sell futures, buy spot) → fees on both legs
|
||||
const perSide = (this.config.takerFeeRate ?? 0) * notional;
|
||||
return perSide * 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
import type { GridConfig } from "../config";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type { AsterAccountSnapshot, AsterDepth, AsterOrder, AsterTicker } from "../exchanges/types";
|
||||
import { routeLimitOrder, routeMarketOrder } from "../exchanges/order-router";
|
||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||
import { decimalsOf, formatPriceToString, roundDownToTick, roundQtyDownToStep } from "../utils/math";
|
||||
import { extractMessage } from "../utils/errors";
|
||||
import { getMidOrLast } from "../utils/price";
|
||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||
|
||||
interface GridLevelState {
|
||||
index: number;
|
||||
price: number;
|
||||
side: "BUY" | "SELL";
|
||||
status: "idle" | "entry-working" | "position-open" | "exit-working";
|
||||
entryOrderId?: string;
|
||||
exitOrderId?: string;
|
||||
blockedUntil?: number;
|
||||
entryClientId?: string;
|
||||
exitClientId?: string;
|
||||
}
|
||||
|
||||
interface DesiredGridOrder {
|
||||
level: number;
|
||||
side: "BUY" | "SELL";
|
||||
price: string;
|
||||
amount: number;
|
||||
intent: "ENTRY" | "EXIT";
|
||||
}
|
||||
|
||||
interface GridLineSnapshot {
|
||||
level: number;
|
||||
price: number;
|
||||
side: "BUY" | "SELL";
|
||||
active: boolean;
|
||||
hasOrder: boolean;
|
||||
}
|
||||
|
||||
export interface GridEngineSnapshot {
|
||||
ready: boolean;
|
||||
symbol: string;
|
||||
centerPrice: number | null;
|
||||
lowerPrice: number | null;
|
||||
upperPrice: number | null;
|
||||
lastPrice: number | null;
|
||||
gridLines: GridLineSnapshot[];
|
||||
desiredOrders: DesiredGridOrder[];
|
||||
openOrders: AsterOrder[];
|
||||
position: PositionSnapshot;
|
||||
running: boolean;
|
||||
stopReason: string | null;
|
||||
tradeLog: TradeLogEntry[];
|
||||
feedStatus: {
|
||||
account: boolean;
|
||||
orders: boolean;
|
||||
depth: boolean;
|
||||
ticker: boolean;
|
||||
};
|
||||
lastUpdated: number | null;
|
||||
}
|
||||
|
||||
type GridEvent = "update";
|
||||
type GridListener = (snapshot: GridEngineSnapshot) => void;
|
||||
|
||||
interface EngineOptions {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const FINAL_STATUSES = new Set(["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"]);
|
||||
const EPSILON = 1e-8;
|
||||
|
||||
export class GridEngine {
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly events = new StrategyEventEmitter<GridEvent, GridEngineSnapshot>();
|
||||
private readonly priceDecimals: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||
private depthSnapshot: AsterDepth | null = null;
|
||||
private tickerSnapshot: AsterTicker | null = null;
|
||||
private openOrders: AsterOrder[] = [];
|
||||
|
||||
private position: PositionSnapshot = { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0, markPrice: null };
|
||||
private desiredOrders: DesiredGridOrder[] = [];
|
||||
private levels: GridLevelState[] = [];
|
||||
private readonly orderIntentById = new Map<string, { level: number; intent: "ENTRY" | "EXIT"; side: "BUY" | "SELL"; price: string; clientId?: string }>();
|
||||
private readonly orderIntentByClientId = new Map<string, { level: number; intent: "ENTRY" | "EXIT"; side: "BUY" | "SELL"; price: string; clientId?: string }>();
|
||||
private readonly pendingCancels = new Set<string>();
|
||||
|
||||
private gridReady = false;
|
||||
private running = true;
|
||||
private stopReason: string | null = null;
|
||||
private centerPrice: number | null = null;
|
||||
private lowerPrice: number | null = null;
|
||||
private upperPrice: number | null = null;
|
||||
private gridSpacing: number | null = null;
|
||||
private lastPrice: number | null = null;
|
||||
private lastUpdated: number | null = null;
|
||||
|
||||
private feedStatus = {
|
||||
account: false,
|
||||
orders: false,
|
||||
depth: false,
|
||||
ticker: false,
|
||||
};
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
private maxOpenOrderHitUntil: number | null = null;
|
||||
|
||||
constructor(private readonly config: GridConfig, private readonly exchange: ExchangeAdapter, options: EngineOptions = {}) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
this.priceDecimals = Math.max(0, decimalsOf(this.config.priceTick));
|
||||
this.now = options.now ?? Date.now;
|
||||
this.bootstrap();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => void this.tick(), this.config.refreshIntervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
on(event: GridEvent, listener: GridListener): void {
|
||||
this.events.on(event, listener);
|
||||
}
|
||||
|
||||
off(event: GridEvent, listener: GridListener): void {
|
||||
this.events.off(event, listener);
|
||||
}
|
||||
|
||||
getSnapshot(): GridEngineSnapshot {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
private bootstrap(): void {
|
||||
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
|
||||
|
||||
safeSubscribe<AsterAccountSnapshot>(
|
||||
this.exchange.watchAccount.bind(this.exchange),
|
||||
(snapshot) => {
|
||||
this.accountSnapshot = snapshot;
|
||||
this.position = getPosition(snapshot, this.config.symbol);
|
||||
if (!this.feedStatus.account) {
|
||||
this.feedStatus.account = true;
|
||||
log("info", "账户快照已同步");
|
||||
}
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅账户失败: ${extractMessage(error)}`,
|
||||
processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`,
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterOrder[]>(
|
||||
this.exchange.watchOrders.bind(this.exchange),
|
||||
(orders) => {
|
||||
this.syncOrdersFromFeed(orders);
|
||||
if (!this.feedStatus.orders) {
|
||||
this.feedStatus.orders = true;
|
||||
log("info", "订单快照已同步");
|
||||
}
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅订单失败: ${extractMessage(error)}`,
|
||||
processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`,
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterDepth>(
|
||||
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
|
||||
(depth) => {
|
||||
this.depthSnapshot = depth;
|
||||
if (!this.feedStatus.depth) {
|
||||
this.feedStatus.depth = true;
|
||||
log("info", "盘口深度已同步");
|
||||
}
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅深度失败: ${extractMessage(error)}`,
|
||||
processFail: (error) => `深度推送处理异常: ${extractMessage(error)}`,
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterTicker>(
|
||||
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
||||
(ticker) => {
|
||||
this.tickerSnapshot = ticker;
|
||||
this.lastPrice = this.getReferencePrice();
|
||||
if (!this.feedStatus.ticker) {
|
||||
this.feedStatus.ticker = true;
|
||||
log("info", "行情推送已同步");
|
||||
}
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅行情失败: ${extractMessage(error)}`,
|
||||
processFail: (error) => `行情推送处理异常: ${extractMessage(error)}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private syncOrdersFromFeed(orders: AsterOrder[] | null | undefined): void {
|
||||
const list = Array.isArray(orders) ? orders.filter((o) => o.symbol === this.config.symbol) : [];
|
||||
this.openOrders = list;
|
||||
const currentIds = new Set(list.map((o) => String(o.orderId)));
|
||||
|
||||
for (const order of list) {
|
||||
const status = String(order.status ?? "").toUpperCase();
|
||||
if (FINAL_STATUSES.has(status)) {
|
||||
this.handleOrderResolution(String(order.orderId), status, order);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [orderId, meta] of [...this.orderIntentById.entries()]) {
|
||||
if (currentIds.has(orderId)) continue;
|
||||
const assumedStatus = this.pendingCancels.has(orderId) ? "CANCELED" : "FILLED";
|
||||
this.handleOrderResolution(orderId, assumedStatus, undefined, meta);
|
||||
}
|
||||
|
||||
for (const [clientId, meta] of [...this.orderIntentByClientId.entries()]) {
|
||||
const exists = list.some((o) => o.clientOrderId && String(o.clientOrderId) === clientId);
|
||||
if (exists) continue;
|
||||
const assumedStatus = this.pendingCancels.has(clientId) ? "CANCELED" : "FILLED";
|
||||
this.handleOrderResolution(clientId, assumedStatus, undefined, meta);
|
||||
}
|
||||
|
||||
this.rebuildLevelAssignmentsFromOrders();
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
try {
|
||||
if (!this.running) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
if (!this.isReady()) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
const price = this.getReferencePrice();
|
||||
this.lastPrice = price;
|
||||
if (!Number.isFinite(price) || price === null) {
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
if (!this.gridReady) {
|
||||
this.buildGrid(price);
|
||||
}
|
||||
|
||||
if (this.lowerPrice != null && this.upperPrice != null && this.shouldStop(price)) {
|
||||
await this.stopAndFlatten(price);
|
||||
this.emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
this.desiredOrders = this.buildDesiredOrders();
|
||||
await this.syncOpenOrders();
|
||||
this.lastUpdated = this.now();
|
||||
this.emitUpdate();
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `网格轮询异常: ${extractMessage(error)}`);
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private isReady(): boolean {
|
||||
return this.feedStatus.account && this.feedStatus.orders && this.feedStatus.ticker;
|
||||
}
|
||||
|
||||
private getReferencePrice(): number | null {
|
||||
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
|
||||
}
|
||||
|
||||
private buildGrid(referencePrice: number): void {
|
||||
const spacingRaw = Math.max(this.config.priceTick, referencePrice * this.config.spacingPct);
|
||||
const spacing = Math.max(this.config.priceTick, Number(formatPriceToString(spacingRaw, this.priceDecimals)));
|
||||
const center = Number(formatPriceToString(referencePrice, this.priceDecimals));
|
||||
const lower = Number(formatPriceToString(center - spacing * this.config.levelsPerSide, this.priceDecimals));
|
||||
const upper = Number(formatPriceToString(center + spacing * this.config.levelsPerSide, this.priceDecimals));
|
||||
|
||||
this.gridSpacing = spacing;
|
||||
this.centerPrice = center;
|
||||
this.lowerPrice = lower;
|
||||
this.upperPrice = upper;
|
||||
this.levels = [];
|
||||
|
||||
for (let i = -this.config.levelsPerSide; i <= this.config.levelsPerSide; i += 1) {
|
||||
if (i === 0) continue; // skip center to avoid immediate self-cross
|
||||
const price = this.clampPrice(center + spacing * i);
|
||||
const side: "BUY" | "SELL" = i < 0 ? "BUY" : "SELL";
|
||||
this.levels.push({ index: i, price, side, status: "idle" });
|
||||
}
|
||||
|
||||
this.gridReady = true;
|
||||
this.tradeLog.push("info", `网格已基于 ${center} 初始化,步长 ${spacing},每侧 ${this.config.levelsPerSide} 格`);
|
||||
|
||||
// 生成完网格后,让现有挂单映射到网格,避免重复补单
|
||||
this.rebuildLevelAssignmentsFromOrders();
|
||||
}
|
||||
|
||||
private clampPrice(value: number): number {
|
||||
const rounded = roundDownToTick(value, this.config.priceTick);
|
||||
return Number(formatPriceToString(rounded, this.priceDecimals));
|
||||
}
|
||||
|
||||
private shouldStop(price: number): boolean {
|
||||
if (this.lowerPrice == null || this.upperPrice == null) return false;
|
||||
const lowerGuard = this.lowerPrice * (1 - this.config.stopLossBufferPct);
|
||||
const upperGuard = this.upperPrice * (1 + this.config.stopLossBufferPct);
|
||||
if (price <= lowerGuard) {
|
||||
this.stopReason = `价格跌破网格下界 ${(100 * (1 - price / this.lowerPrice)).toFixed(2)}%`;
|
||||
return true;
|
||||
}
|
||||
if (price >= upperGuard) {
|
||||
this.stopReason = `价格突破网格上界 ${(100 * (price / this.upperPrice - 1)).toFixed(2)}%`;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async stopAndFlatten(_price: number): Promise<void> {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
const reason = this.stopReason ?? "触发止损";
|
||||
this.tradeLog.push("warn", `${reason},开始撤单并平仓`);
|
||||
try {
|
||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||||
this.tradeLog.push("order", "已撤销全部网格挂单");
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `撤销挂单失败: ${extractMessage(error)}`);
|
||||
}
|
||||
await this.closePosition();
|
||||
this.orderIntentById.clear();
|
||||
this.pendingCancels.clear();
|
||||
for (const level of this.levels) {
|
||||
level.entryOrderId = undefined;
|
||||
level.exitOrderId = undefined;
|
||||
level.status = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
private async closePosition(): Promise<void> {
|
||||
const qty = this.position.positionAmt;
|
||||
if (!Number.isFinite(qty) || Math.abs(qty) < EPSILON) return;
|
||||
const side: "BUY" | "SELL" = qty > 0 ? "SELL" : "BUY";
|
||||
const quantity = roundQtyDownToStep(Math.abs(qty), this.config.qtyStep);
|
||||
if (quantity <= 0) return;
|
||||
try {
|
||||
await routeMarketOrder({
|
||||
adapter: this.exchange,
|
||||
symbol: this.config.symbol,
|
||||
side,
|
||||
quantity,
|
||||
reduceOnly: true,
|
||||
closePosition: true,
|
||||
});
|
||||
this.tradeLog.push("order", `市价止损平仓 ${side} ${quantity}`);
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `平仓失败: ${extractMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildDesiredOrders(): DesiredGridOrder[] {
|
||||
if (!this.gridReady || this.gridSpacing == null) return [];
|
||||
const desired: DesiredGridOrder[] = [];
|
||||
const absPos = Math.abs(this.position.positionAmt);
|
||||
for (const level of this.levels) {
|
||||
if (level.blockedUntil && this.now() < level.blockedUntil) {
|
||||
continue;
|
||||
}
|
||||
if (level.status === "position-open" || level.status === "exit-working") {
|
||||
const exitPrice = this.computeExitPrice(level);
|
||||
const priceStr = formatPriceToString(exitPrice, this.priceDecimals);
|
||||
desired.push({
|
||||
level: level.index,
|
||||
side: level.side === "BUY" ? "SELL" : "BUY",
|
||||
price: priceStr,
|
||||
amount: this.config.tradeAmount,
|
||||
intent: "EXIT",
|
||||
});
|
||||
} else {
|
||||
const entryPrice = formatPriceToString(level.price, this.priceDecimals);
|
||||
desired.push({
|
||||
level: level.index,
|
||||
side: level.side,
|
||||
price: entryPrice,
|
||||
amount: this.config.tradeAmount,
|
||||
intent: "ENTRY",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Apply max position guard: if net仓位已达上限,跳过会增大仓位的开仓单
|
||||
if (this.config.maxPositionSize > 0) {
|
||||
return desired.filter((order) => {
|
||||
if (order.intent !== "ENTRY") return true;
|
||||
// opening in same direction as current position increases abs position
|
||||
if (this.position.positionAmt >= 0 && order.side === "BUY") {
|
||||
return absPos + order.amount <= this.config.maxPositionSize + EPSILON;
|
||||
}
|
||||
if (this.position.positionAmt <= 0 && order.side === "SELL") {
|
||||
return absPos + order.amount <= this.config.maxPositionSize + EPSILON;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return desired;
|
||||
}
|
||||
|
||||
private computeExitPrice(level: GridLevelState): number {
|
||||
if (this.gridSpacing == null) return level.price;
|
||||
const delta = Math.max(this.config.priceTick, this.gridSpacing);
|
||||
const raw = level.side === "BUY" ? level.price + delta : level.price - delta;
|
||||
const bumped = level.side === "BUY" ? Math.max(raw, level.price + this.config.priceTick) : Math.min(raw, level.price - this.config.priceTick);
|
||||
return this.clampPrice(bumped);
|
||||
}
|
||||
|
||||
private async syncOpenOrders(): Promise<void> {
|
||||
const nowTs = this.now();
|
||||
if (this.maxOpenOrderHitUntil && nowTs < this.maxOpenOrderHitUntil) {
|
||||
this.tradeLog.push("info", "命中交易所挂单上限冷却,暂不补单");
|
||||
return;
|
||||
}
|
||||
const desiredKeys = new Map<string, DesiredGridOrder>();
|
||||
for (const order of this.desiredOrders) {
|
||||
const normalizedPrice = this.clampPrice(Number(order.price));
|
||||
const normalizedOrder = { ...order, price: formatPriceToString(normalizedPrice, this.priceDecimals) };
|
||||
desiredKeys.set(this.orderKey(normalizedOrder), normalizedOrder);
|
||||
}
|
||||
|
||||
const activeKeys = new Set<string>();
|
||||
for (const order of this.openOrders) {
|
||||
if (order.symbol !== this.config.symbol) continue;
|
||||
const status = String(order.status ?? "").toUpperCase();
|
||||
if (FINAL_STATUSES.has(status)) continue;
|
||||
const priceStr = formatPriceToString(this.clampPrice(Number(order.price ?? 0)), this.priceDecimals);
|
||||
const meta = this.orderIntentById.get(String(order.orderId))
|
||||
?? (order.clientOrderId ? this.orderIntentByClientId.get(String(order.clientOrderId)) : undefined);
|
||||
let intent = meta?.intent;
|
||||
let levelIdx = meta?.level;
|
||||
if (!intent || levelIdx == null) {
|
||||
const parsedFromClient = order.clientOrderId ? this.parseClientOrderId(String(order.clientOrderId)) : null;
|
||||
if (parsedFromClient) {
|
||||
intent = parsedFromClient.intent;
|
||||
levelIdx = parsedFromClient.level;
|
||||
}
|
||||
const match = this.matchLevelForOrder(Number(order.price ?? 0), order.side === "SELL" ? "SELL" : "BUY", (a, b) => Math.abs(a - b) <= Math.max(this.config.priceTick * 0.5, 1e-9));
|
||||
if (match) {
|
||||
intent = match.intent;
|
||||
levelIdx = match.level.index;
|
||||
}
|
||||
}
|
||||
const side = order.side === "SELL" ? "SELL" : "BUY";
|
||||
const key = this.orderKey({ intent: intent ?? "ENTRY", price: priceStr, side, level: levelIdx ?? 0, amount: 0 });
|
||||
if (desiredKeys.has(key)) {
|
||||
activeKeys.add(key);
|
||||
} else {
|
||||
await this.cancelOrder(order);
|
||||
}
|
||||
}
|
||||
|
||||
for (const order of this.desiredOrders) {
|
||||
const normalizedPrice = this.clampPrice(Number(order.price));
|
||||
const normalizedOrder = { ...order, price: formatPriceToString(normalizedPrice, this.priceDecimals) };
|
||||
const key = this.orderKey(normalizedOrder);
|
||||
if (activeKeys.has(key)) continue;
|
||||
await this.placeGridOrder(normalizedOrder);
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelOrder(order: AsterOrder): Promise<void> {
|
||||
if (!order || order.orderId == null) return;
|
||||
const orderId = String(order.orderId);
|
||||
if (this.pendingCancels.has(orderId)) return;
|
||||
try {
|
||||
this.pendingCancels.add(orderId);
|
||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId });
|
||||
this.tradeLog.push("order", `撤销多余挂单 #${orderId}`);
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `撤单失败 #${orderId}: ${extractMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async placeGridOrder(target: DesiredGridOrder): Promise<void> {
|
||||
const quantity = roundQtyDownToStep(Math.abs(target.amount), this.config.qtyStep);
|
||||
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||
this.tradeLog.push("error", "下单数量无效,跳过网格单");
|
||||
return;
|
||||
}
|
||||
const priceNum = Number(target.price);
|
||||
if (!Number.isFinite(priceNum) || priceNum <= 0) return;
|
||||
const clientId = this.buildClientOrderId(target);
|
||||
try {
|
||||
const placed = await routeLimitOrder({
|
||||
adapter: this.exchange,
|
||||
symbol: this.config.symbol,
|
||||
side: target.side,
|
||||
price: priceNum,
|
||||
quantity,
|
||||
timeInForce: "GTX",
|
||||
clientOrderId: clientId,
|
||||
});
|
||||
if (placed && placed.orderId != null) {
|
||||
const orderId = String(placed.orderId);
|
||||
this.orderIntentById.set(orderId, {
|
||||
level: target.level,
|
||||
intent: target.intent,
|
||||
side: target.side,
|
||||
price: target.price,
|
||||
clientId,
|
||||
});
|
||||
const returnedClientId = (placed as any).clientOrderId || (placed as any).client_order_id;
|
||||
if (returnedClientId) {
|
||||
this.orderIntentByClientId.set(String(returnedClientId), {
|
||||
level: target.level,
|
||||
intent: target.intent,
|
||||
side: target.side,
|
||||
price: target.price,
|
||||
clientId: String(returnedClientId),
|
||||
});
|
||||
}
|
||||
const level = this.levels.find((lv) => lv.index === target.level);
|
||||
if (level) {
|
||||
if (target.intent === "ENTRY") {
|
||||
level.status = "entry-working";
|
||||
level.entryOrderId = orderId;
|
||||
level.entryClientId = String(returnedClientId ?? clientId);
|
||||
} else {
|
||||
level.status = "exit-working";
|
||||
level.exitOrderId = orderId;
|
||||
level.exitClientId = String(returnedClientId ?? clientId);
|
||||
}
|
||||
}
|
||||
this.tradeLog.push("order", `${target.intent === "ENTRY" ? "挂开仓" : "挂平仓"} ${target.side} @ ${target.price}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `挂单失败 (${target.side} @ ${target.price}): ${extractMessage(error)}`);
|
||||
const level = this.levels.find((lv) => lv.index === target.level);
|
||||
const message = String(error ?? "").toLowerCase();
|
||||
const isOpenLimit = message.includes("max open order") || message.includes("open orders exceeded");
|
||||
const backoff = isOpenLimit ? 30_000 : 5_000;
|
||||
if (level) {
|
||||
level.blockedUntil = this.now() + backoff;
|
||||
}
|
||||
if (isOpenLimit) {
|
||||
this.maxOpenOrderHitUntil = this.now() + backoff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleOrderResolution(orderId: string, status: string, order?: AsterOrder, fallbackMeta?: {
|
||||
level: number;
|
||||
intent: "ENTRY" | "EXIT";
|
||||
side: "BUY" | "SELL";
|
||||
price: string;
|
||||
}): void {
|
||||
const meta = fallbackMeta ?? this.orderIntentById.get(orderId) ?? this.orderIntentByClientId.get(orderId);
|
||||
if (!meta) return;
|
||||
const level = this.levels.find((lv) => lv.index === meta.level);
|
||||
if (!level) return;
|
||||
const priceStr = meta.price ?? formatPriceToString(Number(order?.price ?? 0), this.priceDecimals);
|
||||
if (meta.intent === "ENTRY") {
|
||||
level.entryOrderId = undefined;
|
||||
if (level.entryClientId === orderId) level.entryClientId = undefined;
|
||||
if (status === "FILLED") {
|
||||
level.status = "position-open";
|
||||
this.tradeLog.push("fill", `网格开仓成交 ${meta.side} @ ${priceStr} (#${meta.level})`);
|
||||
} else {
|
||||
level.status = "idle";
|
||||
}
|
||||
} else {
|
||||
level.exitOrderId = undefined;
|
||||
if (level.exitClientId === orderId) level.exitClientId = undefined;
|
||||
if (status === "FILLED") {
|
||||
level.status = "idle";
|
||||
this.tradeLog.push("fill", `网格平仓成交 ${meta.side} @ ${priceStr} (#${meta.level})`);
|
||||
} else {
|
||||
level.status = "position-open";
|
||||
}
|
||||
}
|
||||
this.orderIntentById.delete(orderId);
|
||||
this.orderIntentByClientId.delete(orderId);
|
||||
if (meta.clientId) {
|
||||
this.orderIntentByClientId.delete(meta.clientId);
|
||||
this.orderIntentById.delete(meta.clientId);
|
||||
}
|
||||
this.pendingCancels.delete(orderId);
|
||||
}
|
||||
|
||||
private orderKey(order: Pick<DesiredGridOrder, "intent" | "side" | "price" | "level">): string {
|
||||
return `${order.intent}:${order.side}:${order.price}:${order.level}`;
|
||||
}
|
||||
|
||||
private rebuildLevelAssignmentsFromOrders(): void {
|
||||
if (!this.gridReady || !this.levels.length) return;
|
||||
// Reset derived state
|
||||
for (const level of this.levels) {
|
||||
level.entryOrderId = undefined;
|
||||
level.exitOrderId = undefined;
|
||||
level.entryClientId = undefined;
|
||||
level.exitClientId = undefined;
|
||||
if (level.status === "entry-working" || level.status === "exit-working") {
|
||||
level.status = "idle";
|
||||
}
|
||||
}
|
||||
|
||||
const priceMatch = (a: number, b: number): boolean => {
|
||||
const tolerance = Math.max(this.config.priceTick * 0.55, 1e-8);
|
||||
return Math.abs(a - b) <= tolerance;
|
||||
};
|
||||
|
||||
let unmatched = 0;
|
||||
for (const order of this.openOrders) {
|
||||
const status = String(order.status ?? "").toUpperCase();
|
||||
if (FINAL_STATUSES.has(status)) continue;
|
||||
const price = Number(order.price ?? 0);
|
||||
if (!Number.isFinite(price)) {
|
||||
unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
const orderSide = order.side === "SELL" ? "SELL" : "BUY";
|
||||
const match = this.matchLevelForOrder(price, orderSide, priceMatch);
|
||||
if (!match) {
|
||||
unmatched += 1;
|
||||
continue;
|
||||
}
|
||||
const { level, intent } = match;
|
||||
const orderId = String(order.orderId ?? "");
|
||||
const clientId = order.clientOrderId ? String(order.clientOrderId) : undefined;
|
||||
const keyMeta = {
|
||||
level: level.index,
|
||||
intent,
|
||||
side: order.side === "SELL" ? "SELL" : "BUY",
|
||||
price: formatPriceToString(price, this.priceDecimals),
|
||||
clientId,
|
||||
};
|
||||
this.orderIntentById.set(orderId, keyMeta);
|
||||
if (clientId) {
|
||||
this.orderIntentByClientId.set(clientId, keyMeta);
|
||||
}
|
||||
if (intent === "ENTRY") {
|
||||
level.status = "entry-working";
|
||||
level.entryOrderId = orderId;
|
||||
if (clientId) level.entryClientId = clientId;
|
||||
} else {
|
||||
level.status = "exit-working";
|
||||
level.exitOrderId = orderId;
|
||||
if (clientId) level.exitClientId = clientId;
|
||||
}
|
||||
}
|
||||
|
||||
if (unmatched > 0) {
|
||||
this.tradeLog.push("info", `存在 ${unmatched} 笔挂单未能映射到网格价位,已跳过重复补单`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildClientOrderId(order: DesiredGridOrder): string {
|
||||
const intentFlag = order.intent === "ENTRY" ? "1" : "2";
|
||||
const sideFlag = order.side === "BUY" ? "1" : "2";
|
||||
const signFlag = order.level < 0 ? "0" : "1";
|
||||
const levelCode = Math.abs(order.level).toString().padStart(3, "0");
|
||||
const ts = Date.now().toString().slice(-8); // tail 8 digits for brevity
|
||||
// Digits-only clientId to satisfy exchanges that reject non-numeric IDs
|
||||
return `${intentFlag}${sideFlag}${signFlag}${levelCode}${ts}`;
|
||||
}
|
||||
|
||||
private parseClientOrderId(clientId: string): { intent: "ENTRY" | "EXIT"; level: number } | null {
|
||||
// Expect digits-only string: [intent][side][sign][level(3)][ts...]
|
||||
if (!/^[0-9]{5,}$/.test(clientId)) return null;
|
||||
const intentBit = clientId[0];
|
||||
const signBit = clientId[2];
|
||||
const levelCode = clientId.slice(3, 6);
|
||||
const intent = intentBit === "1" ? "ENTRY" : intentBit === "2" ? "EXIT" : null;
|
||||
const levelAbs = Number(levelCode);
|
||||
if (!intent || !Number.isFinite(levelAbs)) return null;
|
||||
const level = signBit === "0" ? -levelAbs : levelAbs;
|
||||
return { intent, level };
|
||||
}
|
||||
|
||||
private matchLevelForOrder(
|
||||
price: number,
|
||||
side: "BUY" | "SELL",
|
||||
matcher: (a: number, b: number) => boolean
|
||||
): { level: GridLevelState; intent: "ENTRY" | "EXIT" } | null {
|
||||
const normalizedPrice = this.clampPrice(price);
|
||||
// Quick exact match by index map to avoid O(n^2) under high density
|
||||
const primary = this.levels.find((lv) => matcher(this.clampPrice(lv.price), normalizedPrice));
|
||||
if (primary && primary.side === side) {
|
||||
return { level: primary, intent: "ENTRY" };
|
||||
}
|
||||
if (primary && primary.side !== side && matcher(this.clampPrice(this.computeExitPrice(primary)), normalizedPrice)) {
|
||||
return { level: primary, intent: "EXIT" };
|
||||
}
|
||||
for (const level of this.levels) {
|
||||
// entry-side match: same side, price == level price
|
||||
const levelPrice = this.clampPrice(level.price);
|
||||
if (side === level.side && matcher(levelPrice, normalizedPrice)) {
|
||||
return { level, intent: "ENTRY" };
|
||||
}
|
||||
// exit-side match: opposite side, price == computed exit price
|
||||
if (side !== level.side) {
|
||||
const exitPrice = this.clampPrice(this.computeExitPrice(level));
|
||||
if (matcher(exitPrice, normalizedPrice)) {
|
||||
return { level, intent: "EXIT" };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildSnapshot(): GridEngineSnapshot {
|
||||
const gridLines: GridLineSnapshot[] = this.levels.map((level) => ({
|
||||
level: level.index,
|
||||
price: level.price,
|
||||
side: level.side,
|
||||
active: this.running,
|
||||
hasOrder: level.status === "entry-working" || level.status === "exit-working",
|
||||
}));
|
||||
|
||||
return {
|
||||
ready: this.gridReady && this.isReady(),
|
||||
symbol: this.config.symbol,
|
||||
centerPrice: this.centerPrice,
|
||||
lowerPrice: this.lowerPrice,
|
||||
upperPrice: this.upperPrice,
|
||||
lastPrice: this.lastPrice,
|
||||
gridLines,
|
||||
desiredOrders: this.desiredOrders,
|
||||
openOrders: this.openOrders,
|
||||
position: this.position,
|
||||
running: this.running,
|
||||
stopReason: this.stopReason,
|
||||
tradeLog: this.tradeLog.all(),
|
||||
feedStatus: { ...this.feedStatus },
|
||||
lastUpdated: this.lastUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
this.events.emit("update", this.buildSnapshot());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
import type { TradingConfig } from "../config";
|
||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||
import type { AsterAccountSnapshot, AsterOrder, AsterTicker } from "../exchanges/types";
|
||||
import {
|
||||
calcStopLossPrice,
|
||||
calcTrailingActivationPrice,
|
||||
getPosition,
|
||||
type PositionSnapshot,
|
||||
} from "../utils/strategy";
|
||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||
import {
|
||||
placeStopLossOrder,
|
||||
placeTrailingStopOrder,
|
||||
unlockOperating,
|
||||
} from "../core/order-coordinator";
|
||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
||||
import { formatPriceToString } from "../utils/math";
|
||||
import { computePositionPnl } from "../utils/pnl";
|
||||
|
||||
export interface GuardianEngineSnapshot {
|
||||
ready: boolean;
|
||||
symbol: string;
|
||||
lastPrice: number | null;
|
||||
position: PositionSnapshot;
|
||||
pnl: number;
|
||||
unrealized: number;
|
||||
targetStopPrice: number | null;
|
||||
trailingActivationPrice: number | null;
|
||||
stopOrder: AsterOrder | null;
|
||||
trailingOrder: AsterOrder | null;
|
||||
requiresStop: boolean;
|
||||
tradeLog: TradeLogEntry[];
|
||||
openOrders: AsterOrder[];
|
||||
lastUpdated: number | null;
|
||||
guardStatus: "idle" | "protecting" | "pending";
|
||||
}
|
||||
|
||||
type GuardianEngineEvent = "update";
|
||||
type GuardianEngineListener = (snapshot: GuardianEngineSnapshot) => void;
|
||||
|
||||
export class GuardianEngine {
|
||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||
private openOrders: AsterOrder[] = [];
|
||||
private tickerSnapshot: AsterTicker | null = null;
|
||||
|
||||
private readonly locks: OrderLockMap = {};
|
||||
private readonly timers: OrderTimerMap = {};
|
||||
private readonly pending: OrderPendingMap = {};
|
||||
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly events = new StrategyEventEmitter<GuardianEngineEvent, GuardianEngineSnapshot>();
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
private ordersSnapshotReady = false;
|
||||
private entryPricePendingLogged = false;
|
||||
private priceUnavailableLogged = false;
|
||||
private readonly lastStopAttempt: { side: "BUY" | "SELL" | null; price: number | null; at: number } = {
|
||||
side: null,
|
||||
price: null,
|
||||
at: 0,
|
||||
};
|
||||
private precisionSync: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
this.syncPrecision();
|
||||
this.bootstrap();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => {
|
||||
void this.tick();
|
||||
}, this.config.pollIntervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
||||
this.events.on(event, handler);
|
||||
}
|
||||
|
||||
off(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
||||
this.events.off(event, handler);
|
||||
}
|
||||
|
||||
getSnapshot(): GuardianEngineSnapshot {
|
||||
return this.buildSnapshot();
|
||||
}
|
||||
|
||||
private bootstrap(): void {
|
||||
const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail);
|
||||
|
||||
safeSubscribe<AsterAccountSnapshot>(
|
||||
this.exchange.watchAccount.bind(this.exchange),
|
||||
(snapshot) => {
|
||||
this.accountSnapshot = snapshot;
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
|
||||
processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`,
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterOrder[]>(
|
||||
this.exchange.watchOrders.bind(this.exchange),
|
||||
(orders) => {
|
||||
this.synchronizeLocks(orders);
|
||||
const isActive = (status: string | undefined) => {
|
||||
if (!status) return true;
|
||||
const normalized = status.toLowerCase();
|
||||
return normalized !== "filled" && normalized !== "canceled" && normalized !== "cancelled";
|
||||
};
|
||||
this.openOrders = Array.isArray(orders)
|
||||
? orders.filter(
|
||||
(order) =>
|
||||
order.symbol === this.config.symbol &&
|
||||
order.type !== "MARKET" &&
|
||||
isActive(order.status)
|
||||
)
|
||||
: [];
|
||||
this.ordersSnapshotReady = true;
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅订单失败: ${String(error)}`,
|
||||
processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`,
|
||||
}
|
||||
);
|
||||
|
||||
safeSubscribe<AsterTicker>(
|
||||
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
||||
(ticker) => {
|
||||
this.tickerSnapshot = ticker;
|
||||
this.emitUpdate();
|
||||
},
|
||||
log,
|
||||
{
|
||||
subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`,
|
||||
processFail: (error) => `价格推送处理异常: ${extractMessage(error)}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private synchronizeLocks(orders: AsterOrder[] | null | undefined): void {
|
||||
const list = Array.isArray(orders) ? orders : [];
|
||||
Object.keys(this.pending).forEach((type) => {
|
||||
const pendingId = this.pending[type];
|
||||
if (!pendingId) return;
|
||||
const match = list.find((order) => String(order.orderId) === pendingId);
|
||||
if (!match || (match.status && match.status !== "NEW")) {
|
||||
unlockOperating(this.locks, this.timers, this.pending, type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isReady(): boolean {
|
||||
return Boolean(this.accountSnapshot && this.tickerSnapshot);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
try {
|
||||
if (!this.ordersSnapshotReady || !this.isReady()) {
|
||||
return;
|
||||
}
|
||||
await this.ensureProtection();
|
||||
} catch (error) {
|
||||
this.tradeLog.push("error", `Guardian 执行异常: ${extractMessage(error)}`);
|
||||
} finally {
|
||||
this.processing = false;
|
||||
this.emitUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureProtection(): Promise<void> {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const qtyAbs = Math.abs(position.positionAmt);
|
||||
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 10 : 1e-8;
|
||||
if (qtyAbs <= minQty) {
|
||||
this.entryPricePendingLogged = false;
|
||||
this.priceUnavailableLogged = false;
|
||||
await this.cancelProtectiveOrders();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||
if (!hasEntryPrice) {
|
||||
if (!this.entryPricePendingLogged) {
|
||||
this.tradeLog.push("info", "持仓均价尚未同步,等待交易所账户快照更新后再补挂止损");
|
||||
this.entryPricePendingLogged = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.entryPricePendingLogged = false;
|
||||
|
||||
const price = this.getLastPrice();
|
||||
if (!Number.isFinite(price)) {
|
||||
if (!this.priceUnavailableLogged) {
|
||||
this.tradeLog.push("info", "行情尚未就绪,等待最新价格以同步止损");
|
||||
this.priceUnavailableLogged = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.priceUnavailableLogged = false;
|
||||
|
||||
const direction = position.positionAmt > 0 ? "long" : "short";
|
||||
const stopSide = direction === "long" ? "SELL" : "BUY";
|
||||
const stopPriceRaw = calcStopLossPrice(
|
||||
position.entryPrice,
|
||||
qtyAbs,
|
||||
direction,
|
||||
this.config.lossLimit
|
||||
);
|
||||
const activationPriceRaw = calcTrailingActivationPrice(
|
||||
position.entryPrice,
|
||||
qtyAbs,
|
||||
direction,
|
||||
this.config.trailingProfit
|
||||
);
|
||||
if (!Number.isFinite(stopPriceRaw) || !Number.isFinite(activationPriceRaw)) {
|
||||
return;
|
||||
}
|
||||
const decimals = this.resolvePriceDecimals();
|
||||
const stopPrice = Number(formatPriceToString(stopPriceRaw, decimals));
|
||||
const activationPrice = Number(formatPriceToString(activationPriceRaw, decimals));
|
||||
|
||||
const currentStop = this.findStopOrder(stopSide);
|
||||
const currentTrailing = this.findTrailingOrder(stopSide);
|
||||
|
||||
await this.maintainProtection({
|
||||
position,
|
||||
direction,
|
||||
stopSide,
|
||||
price: Number(price),
|
||||
stopPrice,
|
||||
activationPrice,
|
||||
currentStop: currentStop ?? undefined,
|
||||
currentTrailing: currentTrailing ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async maintainProtection(params: {
|
||||
position: PositionSnapshot;
|
||||
direction: "long" | "short";
|
||||
stopSide: "BUY" | "SELL";
|
||||
price: number;
|
||||
stopPrice: number;
|
||||
activationPrice: number;
|
||||
currentStop?: AsterOrder;
|
||||
currentTrailing?: AsterOrder;
|
||||
}): Promise<void> {
|
||||
const { position, direction, stopSide, price, stopPrice, activationPrice, currentStop, currentTrailing } = params;
|
||||
|
||||
const qtyAbs = Math.abs(position.positionAmt);
|
||||
const depthPrice = price;
|
||||
const pnl = qtyAbs > 0
|
||||
? (direction === "long" ? depthPrice - position.entryPrice : position.entryPrice - depthPrice) * qtyAbs
|
||||
: 0;
|
||||
const unrealized = Number.isFinite(position.unrealizedProfit)
|
||||
? position.unrealizedProfit
|
||||
: pnl;
|
||||
|
||||
{
|
||||
const tick = Math.max(1e-9, this.config.priceTick);
|
||||
const stepUsd = Math.max(0, this.config.profitLockOffsetUsd);
|
||||
const triggerUsd = Math.max(0, this.config.profitLockTriggerUsd);
|
||||
const trailingActivateFromOrderRaw = currentTrailing?.activatePrice ?? (currentTrailing as any)?.activationPrice;
|
||||
const trailingActivateFromOrder = Number(trailingActivateFromOrderRaw);
|
||||
const trailingActivate = Number.isFinite(trailingActivateFromOrder)
|
||||
? trailingActivateFromOrder
|
||||
: activationPrice;
|
||||
|
||||
const trailingActivated =
|
||||
direction === "long"
|
||||
? Number.isFinite(trailingActivate) && price >= trailingActivate - tick
|
||||
: Number.isFinite(trailingActivate) && price <= trailingActivate + tick;
|
||||
|
||||
if (!trailingActivated && qtyAbs > 0 && stepUsd > 0) {
|
||||
const basisProfit = Number.isFinite(unrealized ?? pnl) ? Math.max(pnl, unrealized ?? pnl) : pnl;
|
||||
if (basisProfit >= triggerUsd) {
|
||||
const over = basisProfit - triggerUsd;
|
||||
const steps = 1 + Math.floor(over / stepUsd);
|
||||
const stepPx = stepUsd / qtyAbs;
|
||||
const rawTarget = direction === "long"
|
||||
? position.entryPrice + steps * stepPx
|
||||
: position.entryPrice - steps * stepPx;
|
||||
let targetStop = Number(formatPriceToString(rawTarget, this.resolvePriceDecimals()));
|
||||
|
||||
if (Number.isFinite(trailingActivate)) {
|
||||
if (stopSide === "SELL" && targetStop >= trailingActivate - tick) {
|
||||
targetStop = Math.min(targetStop, trailingActivate - tick);
|
||||
const existingRaw = Number(currentStop?.stopPrice);
|
||||
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
|
||||
const canImprove =
|
||||
!Number.isFinite(existingPrice) ||
|
||||
(stopSide === "SELL" && targetStop >= existingPrice + tick);
|
||||
if (!canImprove) {
|
||||
// no-op
|
||||
} else if (currentStop) {
|
||||
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
|
||||
} else {
|
||||
await this.tryPlaceStopLoss(stopSide, targetStop, price);
|
||||
}
|
||||
} else if (stopSide === "BUY" && targetStop <= trailingActivate + tick) {
|
||||
targetStop = Math.max(targetStop, trailingActivate + tick);
|
||||
const existingRaw = Number(currentStop?.stopPrice);
|
||||
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
|
||||
const canImprove =
|
||||
!Number.isFinite(existingPrice) ||
|
||||
(stopSide === "BUY" && targetStop <= existingPrice - tick);
|
||||
if (!canImprove) {
|
||||
// no-op
|
||||
} else if (currentStop) {
|
||||
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
|
||||
} else {
|
||||
await this.tryPlaceStopLoss(stopSide, targetStop, price);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const existingRaw = Number(currentStop?.stopPrice);
|
||||
const existingPrice = Number.isFinite(existingRaw) ? existingRaw : NaN;
|
||||
const canImprove =
|
||||
!Number.isFinite(existingPrice) ||
|
||||
(stopSide === "SELL" && targetStop >= existingPrice + tick) ||
|
||||
(stopSide === "BUY" && targetStop <= existingPrice - tick);
|
||||
if (!canImprove) {
|
||||
// no-op
|
||||
} else if (currentStop) {
|
||||
await this.tryReplaceStop(stopSide, currentStop, targetStop, price);
|
||||
} else {
|
||||
await this.tryPlaceStopLoss(stopSide, targetStop, price);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentStop) {
|
||||
await this.tryPlaceStopLoss(
|
||||
stopSide,
|
||||
Number(formatPriceToString(stopPrice, this.resolvePriceDecimals())),
|
||||
price
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentTrailing && this.exchange.supportsTrailingStops()) {
|
||||
await this.tryPlaceTrailingStop(
|
||||
stopSide,
|
||||
Number(formatPriceToString(activationPrice, this.resolvePriceDecimals())),
|
||||
Math.abs(position.positionAmt)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async tryPlaceStopLoss(side: "BUY" | "SELL", stopPrice: number, lastPrice: number): Promise<void> {
|
||||
const tick = Math.max(1e-9, this.config.priceTick);
|
||||
const now = Date.now();
|
||||
if (
|
||||
this.lastStopAttempt.side === side &&
|
||||
this.lastStopAttempt.price != null &&
|
||||
Math.abs(stopPrice - Number(this.lastStopAttempt.price)) < tick &&
|
||||
now - this.lastStopAttempt.at < 5000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const quantity = Math.abs(position.positionAmt);
|
||||
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
||||
if (quantity <= minQty) {
|
||||
return;
|
||||
}
|
||||
await placeStopLossOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
stopPrice,
|
||||
quantity,
|
||||
lastPrice,
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
{
|
||||
markPrice: position.markPrice,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||
);
|
||||
this.lastStopAttempt.side = side;
|
||||
this.lastStopAttempt.price = stopPrice;
|
||||
this.lastStopAttempt.at = now;
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `挂止损单失败: ${String(err)}`);
|
||||
this.lastStopAttempt.side = side;
|
||||
this.lastStopAttempt.price = stopPrice;
|
||||
this.lastStopAttempt.at = now;
|
||||
}
|
||||
}
|
||||
|
||||
private async tryReplaceStop(
|
||||
side: "BUY" | "SELL",
|
||||
currentOrder: AsterOrder,
|
||||
nextStopPrice: number,
|
||||
lastPrice: number
|
||||
): Promise<void> {
|
||||
const invalidForSide =
|
||||
(side === "SELL" && nextStopPrice >= lastPrice) ||
|
||||
(side === "BUY" && nextStopPrice <= lastPrice);
|
||||
if (invalidForSide) {
|
||||
return;
|
||||
}
|
||||
const existingStopPrice = Number(currentOrder.stopPrice);
|
||||
try {
|
||||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: currentOrder.orderId });
|
||||
} catch (err) {
|
||||
if (isUnknownOrderError(err)) {
|
||||
this.tradeLog.push("order", "原止损单已不存在,跳过撤销");
|
||||
this.openOrders = this.openOrders.filter((o) => o.orderId !== currentOrder.orderId);
|
||||
} else {
|
||||
this.tradeLog.push("error", `取消原止损单失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const quantity = Math.abs(position.positionAmt);
|
||||
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
||||
if (quantity <= minQty) {
|
||||
return;
|
||||
}
|
||||
const order = await placeStopLossOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
nextStopPrice,
|
||||
quantity,
|
||||
lastPrice,
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
{
|
||||
markPrice: position.markPrice,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||
);
|
||||
if (order) {
|
||||
this.tradeLog.push("stop", `移动止损到 ${formatPriceToString(nextStopPrice, this.resolvePriceDecimals())}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `移动止损失败: ${String(err)}`);
|
||||
try {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const quantity = Math.abs(position.positionAmt);
|
||||
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
||||
if (quantity <= minQty) {
|
||||
return;
|
||||
}
|
||||
const restored = await placeStopLossOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
|
||||
quantity,
|
||||
lastPrice,
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
{
|
||||
markPrice: position.markPrice,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||
);
|
||||
if (restored && Number.isFinite(existingStopPrice)) {
|
||||
this.tradeLog.push(
|
||||
"order",
|
||||
`恢复原止损 @ ${formatPriceToString(existingStopPrice, this.resolvePriceDecimals())}`
|
||||
);
|
||||
}
|
||||
} catch (recoverErr) {
|
||||
this.tradeLog.push("error", `恢复原止损失败: ${String(recoverErr)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async tryPlaceTrailingStop(
|
||||
side: "BUY" | "SELL",
|
||||
activationPrice: number,
|
||||
quantity: number
|
||||
): Promise<void> {
|
||||
if (!this.exchange.supportsTrailingStops()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await placeTrailingStopOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
side,
|
||||
activationPrice,
|
||||
quantity,
|
||||
this.config.trailingCallbackRate,
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
{
|
||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||||
);
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `挂动态止盈失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelProtectiveOrders(): Promise<void> {
|
||||
const protectiveOrders = this.openOrders.filter((order) => this.isProtectiveOrder(order));
|
||||
if (!protectiveOrders.length) return;
|
||||
const orderIdList = protectiveOrders.map((order) => order.orderId);
|
||||
try {
|
||||
await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList });
|
||||
this.tradeLog.push("order", `清理遗留保护单: ${orderIdList.join(",")}`);
|
||||
} catch (err) {
|
||||
if (isUnknownOrderError(err)) {
|
||||
this.tradeLog.push("order", "保护单已不存在,跳过清理");
|
||||
} else {
|
||||
this.tradeLog.push("error", `清理保护单失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isProtectiveOrder(order: AsterOrder): boolean {
|
||||
if (order.symbol !== this.config.symbol) {
|
||||
return false;
|
||||
}
|
||||
const type = String(order.type ?? "").toUpperCase();
|
||||
const hasStopPrice = Number.isFinite(Number(order.stopPrice)) && Number(order.stopPrice) > 0;
|
||||
if (type === "TRAILING_STOP_MARKET") {
|
||||
return true;
|
||||
}
|
||||
return type === "STOP_MARKET" || hasStopPrice;
|
||||
}
|
||||
|
||||
private findStopOrder(side: "BUY" | "SELL"): AsterOrder | undefined {
|
||||
return this.openOrders.find((order) => {
|
||||
const hasStopPrice = Number.isFinite(Number(order.stopPrice)) && Number(order.stopPrice) > 0;
|
||||
return order.side === side && (order.type === "STOP_MARKET" || hasStopPrice);
|
||||
});
|
||||
}
|
||||
|
||||
private findTrailingOrder(side: "BUY" | "SELL"): AsterOrder | undefined {
|
||||
return this.openOrders.find((order) => order.type === "TRAILING_STOP_MARKET" && order.side === side);
|
||||
}
|
||||
|
||||
private getLastPrice(): number | null {
|
||||
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
||||
return Number.isFinite(price) ? (price as number) : null;
|
||||
}
|
||||
|
||||
private buildSnapshot(): GuardianEngineSnapshot {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const price = this.getLastPrice();
|
||||
const stopSide = position.positionAmt > 0 ? "SELL" : "BUY";
|
||||
const stopOrder = Math.abs(position.positionAmt) > 1e-8 ? this.findStopOrder(stopSide) ?? null : null;
|
||||
const trailingOrder = Math.abs(position.positionAmt) > 1e-8 ? this.findTrailingOrder(stopSide) ?? null : null;
|
||||
const qtyAbs = Math.abs(position.positionAmt);
|
||||
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 10 : 1e-8;
|
||||
const hasPosition = qtyAbs > minQty;
|
||||
const targetStopPrice = hasPosition && Number.isFinite(position.entryPrice)
|
||||
? calcStopLossPrice(position.entryPrice, qtyAbs, position.positionAmt > 0 ? "long" : "short", this.config.lossLimit)
|
||||
: null;
|
||||
const trailingActivationPrice = hasPosition && Number.isFinite(position.entryPrice)
|
||||
? calcTrailingActivationPrice(position.entryPrice, qtyAbs, position.positionAmt > 0 ? "long" : "short", this.config.trailingProfit)
|
||||
: null;
|
||||
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
|
||||
const requiresStop = hasPosition && !stopOrder;
|
||||
const guardStatus: GuardianEngineSnapshot["guardStatus"] = hasPosition
|
||||
? requiresStop
|
||||
? "pending"
|
||||
: "protecting"
|
||||
: "idle";
|
||||
|
||||
return {
|
||||
ready: this.isReady() && this.ordersSnapshotReady,
|
||||
symbol: this.config.symbol,
|
||||
lastPrice: price,
|
||||
position,
|
||||
pnl,
|
||||
unrealized: position.unrealizedProfit,
|
||||
targetStopPrice,
|
||||
trailingActivationPrice,
|
||||
stopOrder,
|
||||
trailingOrder,
|
||||
requiresStop,
|
||||
tradeLog: this.tradeLog.all(),
|
||||
openOrders: this.openOrders,
|
||||
lastUpdated: Date.now(),
|
||||
guardStatus,
|
||||
};
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
try {
|
||||
const snapshot = this.buildSnapshot();
|
||||
this.events.emit("update", snapshot, (error) => {
|
||||
this.tradeLog.push("error", `更新分发异常: ${String(error)}`);
|
||||
});
|
||||
} catch (err) {
|
||||
this.tradeLog.push("error", `构建快照失败: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePriceDecimals(): number {
|
||||
if (!Number.isFinite(this.config.priceTick) || this.config.priceTick <= 0) {
|
||||
return 4;
|
||||
}
|
||||
const digits = Math.log10(1 / this.config.priceTick);
|
||||
if (!Number.isFinite(digits)) {
|
||||
return 4;
|
||||
}
|
||||
return Math.max(0, Math.min(12, Math.floor(digits)));
|
||||
}
|
||||
|
||||
private syncPrecision(): void {
|
||||
if (this.precisionSync) return;
|
||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||
if (!getPrecision) return;
|
||||
this.precisionSync = getPrecision()
|
||||
.then((precision) => {
|
||||
if (!precision) return;
|
||||
let updated = false;
|
||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||
const delta = Math.abs(precision.priceTick - this.config.priceTick);
|
||||
if (delta > 1e-12) {
|
||||
this.config.priceTick = precision.priceTick;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
|
||||
if (delta > 1e-12) {
|
||||
this.config.qtyStep = precision.qtyStep;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.tradeLog.push(
|
||||
"info",
|
||||
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`);
|
||||
this.precisionSync = null;
|
||||
setTimeout(() => this.syncPrecision(), 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export interface MakerEngineSnapshot {
|
||||
topBid: number | null;
|
||||
topAsk: number | null;
|
||||
spread: number | null;
|
||||
priceDecimals: number;
|
||||
position: PositionSnapshot;
|
||||
pnl: number;
|
||||
accountUnrealized: number;
|
||||
@@ -77,6 +78,9 @@ export class MakerEngine {
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
|
||||
private readonly sessionVolume = new SessionVolumeTracker();
|
||||
private priceTick: number = 0.1;
|
||||
private qtyStep: number = 0.001;
|
||||
private precisionSync: Promise<void> | null = null;
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
@@ -114,6 +118,9 @@ export class MakerEngine {
|
||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||
this.tradeLog.push(type, detail)
|
||||
);
|
||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
||||
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
||||
this.syncPrecision();
|
||||
this.bootstrap();
|
||||
}
|
||||
|
||||
@@ -290,7 +297,7 @@ export class MakerEngine {
|
||||
}
|
||||
|
||||
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
||||
const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick)));
|
||||
const priceDecimals = this.getPriceDecimals();
|
||||
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
||||
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
||||
const bidPrice = formatPriceToString(topBid - this.config.bidOffset, priceDecimals);
|
||||
@@ -340,7 +347,7 @@ export class MakerEngine {
|
||||
if (Math.abs(position.positionAmt) < EPS) return;
|
||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||
if (topBid == null || topAsk == null) return;
|
||||
const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick)));
|
||||
const priceDecimals = this.getPriceDecimals();
|
||||
const closeBidPrice = formatPriceToString(topBid, priceDecimals);
|
||||
const closeAskPrice = formatPriceToString(topAsk, priceDecimals);
|
||||
await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice));
|
||||
@@ -431,8 +438,8 @@ export class MakerEngine {
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{
|
||||
priceTick: this.config.priceTick,
|
||||
qtyStep: 0.001, // 默认数量步长
|
||||
priceTick: this.priceTick,
|
||||
qtyStep: this.qtyStep,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -489,7 +496,8 @@ export class MakerEngine {
|
||||
markPrice: position.markPrice,
|
||||
expectedPrice: Number(closeSidePrice) || null,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
}
|
||||
},
|
||||
{ qtyStep: this.qtyStep }
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnknownOrderError(error)) {
|
||||
@@ -527,6 +535,48 @@ export class MakerEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private syncPrecision(): void {
|
||||
if (this.precisionSync) return;
|
||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||
if (!getPrecision) return;
|
||||
this.precisionSync = getPrecision()
|
||||
.then((precision) => {
|
||||
if (!precision) return;
|
||||
let updated = false;
|
||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
||||
this.priceTick = precision.priceTick;
|
||||
this.config.priceTick = precision.priceTick;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
||||
this.qtyStep = precision.qtyStep;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.tradeLog.push(
|
||||
"info",
|
||||
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`);
|
||||
this.precisionSync = null;
|
||||
setTimeout(() => this.syncPrecision(), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
private getPriceDecimals(): number {
|
||||
const tick = Math.max(1e-9, this.priceTick);
|
||||
const raw = Math.log10(1 / tick);
|
||||
if (!Number.isFinite(raw)) return 0;
|
||||
return Math.max(0, Math.floor(raw + 1e-9));
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
try {
|
||||
const snapshot = this.buildSnapshot();
|
||||
@@ -550,6 +600,7 @@ export class MakerEngine {
|
||||
topBid: topBid,
|
||||
topAsk: topAsk,
|
||||
spread,
|
||||
priceDecimals: this.getPriceDecimals(),
|
||||
position,
|
||||
pnl,
|
||||
accountUnrealized: this.accountUnrealized,
|
||||
|
||||
@@ -64,6 +64,9 @@ export class OffsetMakerEngine {
|
||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
|
||||
private readonly sessionVolume = new SessionVolumeTracker();
|
||||
private priceTick: number = 0.1;
|
||||
private qtyStep: number = 0.001;
|
||||
private precisionSync: Promise<void> | null = null;
|
||||
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private processing = false;
|
||||
@@ -79,6 +82,8 @@ export class OffsetMakerEngine {
|
||||
private lastSkipBuy = false;
|
||||
private lastSkipSell = false;
|
||||
private lastImbalance: "balanced" | "buy_dominant" | "sell_dominant" = "balanced";
|
||||
private lastBuyPriceViable = true;
|
||||
private lastSellPriceViable = true;
|
||||
|
||||
// Reprice suppression for fast-ticking Lighter order book
|
||||
private readonly repriceDwellMs: number;
|
||||
@@ -93,6 +98,9 @@ export class OffsetMakerEngine {
|
||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||
this.tradeLog.push(type, detail)
|
||||
);
|
||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
||||
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
||||
this.syncPrecision();
|
||||
// Debounce window defaults to 3x refresh interval, min 1s
|
||||
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
||||
this.bootstrap();
|
||||
@@ -275,11 +283,15 @@ export class OffsetMakerEngine {
|
||||
const finalAsk = latestAsk ?? topAsk!;
|
||||
|
||||
// 直接使用orderbook价格,格式化为字符串避免精度问题
|
||||
const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick)));
|
||||
const priceDecimals = this.getPriceDecimals();
|
||||
const closeBidPrice = formatPriceToString(finalBid, priceDecimals);
|
||||
const closeAskPrice = formatPriceToString(finalAsk, priceDecimals);
|
||||
const bidPrice = formatPriceToString(finalBid - this.config.bidOffset, priceDecimals);
|
||||
const askPrice = formatPriceToString(finalAsk + this.config.askOffset, priceDecimals);
|
||||
const rawBidPrice = finalBid - this.config.bidOffset;
|
||||
const rawAskPrice = finalAsk + this.config.askOffset;
|
||||
const safeBid = this.ensureMakerPrice("BUY", rawBidPrice, finalBid, finalAsk);
|
||||
const safeAsk = this.ensureMakerPrice("SELL", rawAskPrice, finalBid, finalAsk);
|
||||
const bidPrice = safeBid != null ? formatPriceToString(safeBid, priceDecimals) : null;
|
||||
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
||||
const absPosition = Math.abs(position.positionAmt);
|
||||
const desired: DesiredOrder[] = [];
|
||||
const canEnter = !this.rateLimit.shouldBlockEntries();
|
||||
@@ -287,10 +299,22 @@ export class OffsetMakerEngine {
|
||||
if (absPosition < EPS) {
|
||||
this.entryPricePendingLogged = false;
|
||||
if (!skipBuySide && canEnter) {
|
||||
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||
if (bidPrice != null) {
|
||||
this.lastBuyPriceViable = true;
|
||||
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||
} else if (this.lastBuyPriceViable) {
|
||||
this.lastBuyPriceViable = false;
|
||||
this.tradeLog.push("info", "跳过买单:价差不足以构造maker价格");
|
||||
}
|
||||
}
|
||||
if (!skipSellSide && canEnter) {
|
||||
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||
if (askPrice != null) {
|
||||
this.lastSellPriceViable = true;
|
||||
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||
} else if (this.lastSellPriceViable) {
|
||||
this.lastSellPriceViable = false;
|
||||
this.tradeLog.push("info", "跳过卖单:价差不足以构造maker价格");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
||||
@@ -326,7 +350,7 @@ export class OffsetMakerEngine {
|
||||
const absPosition = Math.abs(position.positionAmt);
|
||||
const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||
const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick)));
|
||||
const priceDecimals = this.getPriceDecimals();
|
||||
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
||||
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
||||
try {
|
||||
@@ -347,7 +371,8 @@ export class OffsetMakerEngine {
|
||||
? (closeAskPrice != null ? Number(closeAskPrice) : null)
|
||||
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
}
|
||||
},
|
||||
{ qtyStep: this.qtyStep }
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnknownOrderError(error)) {
|
||||
@@ -435,7 +460,8 @@ export class OffsetMakerEngine {
|
||||
markPrice: position.markPrice,
|
||||
expectedPrice: Number(closeSidePrice) || null,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
}
|
||||
},
|
||||
{ qtyStep: this.qtyStep }
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnknownOrderError(error)) {
|
||||
@@ -464,7 +490,7 @@ export class OffsetMakerEngine {
|
||||
const newPrice = Number(t.price);
|
||||
const oldPrice = Number(existing.price);
|
||||
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
||||
const ticksDiff = Math.abs(newPrice - oldPrice) / this.config.priceTick;
|
||||
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
|
||||
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
||||
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
||||
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
||||
@@ -529,8 +555,8 @@ export class OffsetMakerEngine {
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{
|
||||
priceTick: this.config.priceTick,
|
||||
qtyStep: 0.001, // 默认数量步长
|
||||
priceTick: this.priceTick,
|
||||
qtyStep: this.qtyStep,
|
||||
}
|
||||
);
|
||||
// Record last placed entry order timing and price
|
||||
@@ -538,6 +564,19 @@ export class OffsetMakerEngine {
|
||||
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRateLimitError(error)) {
|
||||
throw error;
|
||||
}
|
||||
let dustClosed = false;
|
||||
try {
|
||||
dustClosed = await this.tryDustMarketClose(target, error);
|
||||
} catch (dustError) {
|
||||
if (isRateLimitError(dustError)) {
|
||||
throw dustError;
|
||||
}
|
||||
this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`);
|
||||
}
|
||||
if (dustClosed) continue;
|
||||
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -581,7 +620,8 @@ export class OffsetMakerEngine {
|
||||
markPrice: position.markPrice,
|
||||
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
}
|
||||
},
|
||||
{ qtyStep: this.qtyStep }
|
||||
);
|
||||
} catch (error) {
|
||||
if (isUnknownOrderError(error)) {
|
||||
@@ -620,6 +660,48 @@ export class OffsetMakerEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private syncPrecision(): void {
|
||||
if (this.precisionSync) return;
|
||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||
if (!getPrecision) return;
|
||||
this.precisionSync = getPrecision()
|
||||
.then((precision) => {
|
||||
if (!precision) return;
|
||||
let updated = false;
|
||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||
if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) {
|
||||
this.priceTick = precision.priceTick;
|
||||
this.config.priceTick = precision.priceTick;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||
if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) {
|
||||
this.qtyStep = precision.qtyStep;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.tradeLog.push(
|
||||
"info",
|
||||
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.tradeLog.push("error", `同步精度失败: ${String(error)}`);
|
||||
this.precisionSync = null;
|
||||
setTimeout(() => this.syncPrecision(), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
private getPriceDecimals(): number {
|
||||
const tick = Math.max(1e-9, this.priceTick);
|
||||
const raw = Math.log10(1 / tick);
|
||||
if (!Number.isFinite(raw)) return 0;
|
||||
return Math.max(0, Math.floor(raw + 1e-9));
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
try {
|
||||
const snapshot = this.buildSnapshot();
|
||||
@@ -643,6 +725,7 @@ export class OffsetMakerEngine {
|
||||
topBid: topBid,
|
||||
topAsk: topAsk,
|
||||
spread,
|
||||
priceDecimals: this.getPriceDecimals(),
|
||||
position,
|
||||
pnl,
|
||||
accountUnrealized: this.accountUnrealized,
|
||||
@@ -662,4 +745,80 @@ export class OffsetMakerEngine {
|
||||
private getReferencePrice(): number | null {
|
||||
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
|
||||
}
|
||||
|
||||
private ensureMakerPrice(
|
||||
side: "BUY" | "SELL",
|
||||
rawPrice: number,
|
||||
topBid: number | null,
|
||||
topAsk: number | null
|
||||
): number | null {
|
||||
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
||||
const tick = Math.max(this.priceTick, 1e-9);
|
||||
if (side === "BUY") {
|
||||
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
||||
const maxPrice = Number(topAsk) - tick;
|
||||
if (!Number.isFinite(maxPrice) || maxPrice <= 0) return null;
|
||||
const adjusted = Math.min(rawPrice, maxPrice);
|
||||
return adjusted > 0 ? adjusted : null;
|
||||
}
|
||||
if (side === "SELL") {
|
||||
if (topBid == null || !Number.isFinite(topBid)) return rawPrice;
|
||||
const minPrice = Number(topBid) + tick;
|
||||
if (!Number.isFinite(minPrice) || minPrice <= 0) return null;
|
||||
const adjusted = Math.max(rawPrice, minPrice);
|
||||
return adjusted > 0 ? adjusted : null;
|
||||
}
|
||||
return rawPrice;
|
||||
}
|
||||
|
||||
private isInvalidAmountError(error: unknown): boolean {
|
||||
const message =
|
||||
typeof error === "string"
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: JSON.stringify(error);
|
||||
if (!message) return false;
|
||||
if (message.includes("\"code\":21706")) return true;
|
||||
return message.toLowerCase().includes("invalid order base or quote amount");
|
||||
}
|
||||
|
||||
private async tryDustMarketClose(target: DesiredOrder, error: unknown): Promise<boolean> {
|
||||
if (!target.reduceOnly) return false;
|
||||
if (!this.isInvalidAmountError(error)) return false;
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const absQty = Math.abs(target.amount);
|
||||
if (absQty < EPS) return false;
|
||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||
try {
|
||||
await marketClose(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
this.openOrders,
|
||||
this.locks,
|
||||
this.timers,
|
||||
this.pending,
|
||||
target.side,
|
||||
absQty,
|
||||
(type, detail) => this.tradeLog.push(type, detail),
|
||||
{
|
||||
markPrice: position.markPrice,
|
||||
expectedPrice:
|
||||
target.side === "SELL"
|
||||
? (topBid != null ? Number(topBid) : null)
|
||||
: (topAsk != null ? Number(topAsk) : null),
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{ qtyStep: this.qtyStep }
|
||||
);
|
||||
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
||||
return true;
|
||||
} catch (closeError) {
|
||||
if (isRateLimitError(closeError)) {
|
||||
throw closeError;
|
||||
}
|
||||
this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +123,14 @@ export class TrendEngine {
|
||||
.digest("hex");
|
||||
|
||||
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
||||
private precisionSync: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
||||
this.tradeLog.push(type, detail)
|
||||
);
|
||||
this.syncPrecision();
|
||||
this.bootstrap();
|
||||
}
|
||||
|
||||
@@ -741,8 +743,8 @@ export class TrendEngine {
|
||||
: this.depthSnapshot?.asks?.[0]?.[0]
|
||||
) || null,
|
||||
maxPct: this.config.maxCloseSlippagePct,
|
||||
},
|
||||
{ qtyStep: this.config.qtyStep }
|
||||
},
|
||||
{ qtyStep: this.config.qtyStep }
|
||||
);
|
||||
result.closed = true;
|
||||
this.tradeLog.push("close", `止损平仓: ${direction === "long" ? "SELL" : "BUY"}`);
|
||||
@@ -781,7 +783,11 @@ export class TrendEngine {
|
||||
}
|
||||
try {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount;
|
||||
const quantity = Math.abs(position.positionAmt);
|
||||
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
||||
if (quantity <= minQty) {
|
||||
return;
|
||||
}
|
||||
await placeStopLossOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
@@ -837,7 +843,11 @@ export class TrendEngine {
|
||||
// 仅在成功创建新止损单后记录“移动止损”日志
|
||||
try {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount;
|
||||
const quantity = Math.abs(position.positionAmt);
|
||||
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
||||
if (quantity <= minQty) {
|
||||
return;
|
||||
}
|
||||
const order = await placeStopLossOrder(
|
||||
this.exchange,
|
||||
this.config.symbol,
|
||||
@@ -864,7 +874,11 @@ export class TrendEngine {
|
||||
// 回滚策略:尝试用原价恢复止损,以避免出现短时间内无止损保护
|
||||
try {
|
||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||
const quantity = Math.abs(position.positionAmt) || this.config.tradeAmount;
|
||||
const quantity = Math.abs(position.positionAmt);
|
||||
const minQty = this.config.qtyStep > 0 ? this.config.qtyStep / 2 : 1e-12;
|
||||
if (quantity <= minQty) {
|
||||
return;
|
||||
}
|
||||
const restoreInvalid =
|
||||
(side === "SELL" && existingStopPrice >= lastPrice) ||
|
||||
(side === "BUY" && existingStopPrice <= lastPrice);
|
||||
@@ -929,6 +943,42 @@ export class TrendEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private syncPrecision(): void {
|
||||
if (this.precisionSync) return;
|
||||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||
if (!getPrecision) return;
|
||||
this.precisionSync = getPrecision()
|
||||
.then((precision) => {
|
||||
if (!precision) return;
|
||||
let updated = false;
|
||||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||||
const delta = Math.abs(precision.priceTick - this.config.priceTick);
|
||||
if (delta > 1e-12) {
|
||||
this.config.priceTick = precision.priceTick;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||||
const delta = Math.abs(precision.qtyStep - this.config.qtyStep);
|
||||
if (delta > 1e-12) {
|
||||
this.config.qtyStep = precision.qtyStep;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
this.tradeLog.push(
|
||||
"info",
|
||||
`已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`);
|
||||
this.precisionSync = null;
|
||||
setTimeout(() => this.syncPrecision(), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
private emitUpdate(): void {
|
||||
try {
|
||||
const snapshot = this.buildSnapshot();
|
||||
|
||||
+32
-3
@@ -1,31 +1,47 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { TrendApp } from "./TrendApp";
|
||||
import { GuardianApp } from "./GuardianApp";
|
||||
import { MakerApp } from "./MakerApp";
|
||||
import { OffsetMakerApp } from "./OffsetMakerApp";
|
||||
import { GridApp } from "./GridApp";
|
||||
import { BasisApp } from "./BasisApp";
|
||||
import { isBasisStrategyEnabled } from "../config";
|
||||
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
|
||||
import { resolveExchangeId } from "../exchanges/create-adapter";
|
||||
|
||||
interface StrategyOption {
|
||||
id: "trend" | "maker" | "offset-maker";
|
||||
id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid";
|
||||
label: string;
|
||||
description: string;
|
||||
component: React.ComponentType<{ onExit: () => void }>;
|
||||
}
|
||||
|
||||
const STRATEGIES: StrategyOption[] = [
|
||||
const BASE_STRATEGIES: StrategyOption[] = [
|
||||
{
|
||||
id: "trend",
|
||||
label: "趋势跟随策略 (SMA30)",
|
||||
description: "监控均线信号,自动进出场并维护止损/止盈",
|
||||
component: TrendApp,
|
||||
},
|
||||
{
|
||||
id: "guardian",
|
||||
label: "Guardian 防守策略",
|
||||
description: "不主动开仓,只为现有仓位补挂/移动止损,防止裸奔",
|
||||
component: GuardianApp,
|
||||
},
|
||||
{
|
||||
id: "maker",
|
||||
label: "做市刷单策略",
|
||||
description: "双边挂单提供流动性,自动追价与风控止损",
|
||||
component: MakerApp,
|
||||
},
|
||||
{
|
||||
id: "grid",
|
||||
label: "基础网格策略",
|
||||
description: "在上下边界之间布设等比网格,自动加仓与减仓",
|
||||
component: GridApp,
|
||||
},
|
||||
{
|
||||
id: "offset-maker",
|
||||
label: "偏移做市策略",
|
||||
@@ -42,7 +58,20 @@ export function App() {
|
||||
const copyright = useMemo(() => loadCopyrightFragments(), []);
|
||||
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||
const strategies = useMemo(() => STRATEGIES, []);
|
||||
const strategies = useMemo(() => {
|
||||
if (!isBasisStrategyEnabled()) {
|
||||
return BASE_STRATEGIES;
|
||||
}
|
||||
return [
|
||||
...BASE_STRATEGIES,
|
||||
{
|
||||
id: "basis" as const,
|
||||
label: "期现套利策略",
|
||||
description: "监控期货与现货盘口差价,辅助发现套利机会",
|
||||
component: BasisApp,
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { basisConfig } from "../config";
|
||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||
import { formatNumber } from "../utils/format";
|
||||
|
||||
interface BasisAppProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||
|
||||
export function BasisApp({ onExit }: BasisAppProps) {
|
||||
const [snapshot, setSnapshot] = useState<BasisArbSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<BasisArbEngine | null>(null);
|
||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
engineRef.current?.stop();
|
||||
onExit();
|
||||
}
|
||||
},
|
||||
{ isActive: inputSupported }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (exchangeId !== "aster") {
|
||||
setError(new Error("期现套利策略目前仅支持 Aster 交易所。请设置 EXCHANGE=aster 后重试。"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const adapter = buildAdapterFromEnv({ exchangeId, symbol: basisConfig.futuresSymbol });
|
||||
const engine = new BasisArbEngine(basisConfig, adapter);
|
||||
engineRef.current = engine;
|
||||
setSnapshot(engine.getSnapshot());
|
||||
const handler = (next: BasisArbSnapshot) => {
|
||||
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||
};
|
||||
engine.on("update", handler);
|
||||
engine.start();
|
||||
return () => {
|
||||
engine.off("update", handler);
|
||||
engine.stop();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}, [exchangeId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">无法启动期现套利策略: {error.message}</Text>
|
||||
<Text color="gray">按 Esc 返回菜单。</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>正在初始化期现套利监控…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const futuresBid = formatNumber(snapshot.futuresBid, 4);
|
||||
const futuresAsk = formatNumber(snapshot.futuresAsk, 4);
|
||||
const spotBid = formatNumber(snapshot.spotBid, 4);
|
||||
const spotAsk = formatNumber(snapshot.spotAsk, 4);
|
||||
const spread = formatNumber(snapshot.spread, 4);
|
||||
const spreadBps = formatNumber(snapshot.spreadBps, 2);
|
||||
const netSpread = formatNumber(snapshot.netSpread, 4);
|
||||
const netSpreadBps = formatNumber(snapshot.netSpreadBps, 2);
|
||||
const lastUpdated = snapshot.lastUpdated ? new Date(snapshot.lastUpdated).toLocaleTimeString() : "-";
|
||||
const futuresUpdated = snapshot.futuresLastUpdate ? new Date(snapshot.futuresLastUpdate).toLocaleTimeString() : "-";
|
||||
const spotUpdated = snapshot.spotLastUpdate ? new Date(snapshot.spotLastUpdate).toLocaleTimeString() : "-";
|
||||
const fundingRatePct = snapshot.fundingRate != null ? `${(snapshot.fundingRate * 100).toFixed(4)}%` : "-";
|
||||
const fundingUpdated = snapshot.fundingLastUpdate ? new Date(snapshot.fundingLastUpdate).toLocaleTimeString() : "-";
|
||||
const nextFundingTime = snapshot.nextFundingTime ? new Date(snapshot.nextFundingTime).toLocaleTimeString() : "-";
|
||||
const fundingIncomePerFunding = snapshot.fundingIncomePerFunding != null ? `${formatNumber(snapshot.fundingIncomePerFunding, 4)} USDT` : "-";
|
||||
const fundingIncomePerDay = snapshot.fundingIncomePerDay != null ? `${formatNumber(snapshot.fundingIncomePerDay, 4)} USDT` : "-";
|
||||
const takerFeesPerRoundTrip = snapshot.takerFeesPerRoundTrip != null ? `${formatNumber(snapshot.takerFeesPerRoundTrip, 4)} USDT` : "-";
|
||||
const fundingCountToBreakeven = snapshot.fundingCountToBreakeven != null ? `${formatNumber(snapshot.fundingCountToBreakeven, 2)} 次` : "-";
|
||||
const feedStatus = snapshot.feedStatus;
|
||||
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||
const spotBalances = (snapshot.spotBalances ?? []).filter((b) => Math.abs(b.free) > 0 || Math.abs(b.locked) > 0);
|
||||
const futuresBalances = (snapshot.futuresBalances ?? []).filter((b) => Math.abs(b.wallet) > 0);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Basis Arbitrage Dashboard</Text>
|
||||
<Text>
|
||||
交易所: {exchangeName} | 期货合约: {snapshot.futuresSymbol} | 现货交易对: {snapshot.spotSymbol}
|
||||
</Text>
|
||||
<Text color="gray">按 Esc 返回策略选择 | 数据状态: 期货({feedStatus.futures ? "OK" : "--"}) 现货({feedStatus.spot ? "OK" : "--"}) 资金费率({feedStatus.funding ? "OK" : "--"})</Text>
|
||||
<Text color="gray">最近更新时间: {lastUpdated}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="greenBright">期货盘口</Text>
|
||||
<Text>买一: {futuresBid} | 卖一: {futuresAsk}</Text>
|
||||
<Text color="gray">更新时间: {futuresUpdated}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="greenBright">现货盘口</Text>
|
||||
<Text>买一: {spotBid} | 卖一: {spotAsk}</Text>
|
||||
<Text color="gray">更新时间: {spotUpdated}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">资金费率</Text>
|
||||
<Text>当前资金费率: {fundingRatePct}</Text>
|
||||
<Text color="gray">资金费率更新时间: {fundingUpdated} | 下次结算时间: {nextFundingTime}</Text>
|
||||
<Text>单次资金费率收益(估): {fundingIncomePerFunding} | 日收益(估): {fundingIncomePerDay}</Text>
|
||||
<Text>双边吃单手续费(估): {takerFeesPerRoundTrip} | 回本所需资金费率次数: {fundingCountToBreakeven}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="cyan">现货账户余额(非0)</Text>
|
||||
{spotBalances.length ? (
|
||||
spotBalances.map((b) => (
|
||||
<Text key={`spot-${b.asset}`}>
|
||||
{b.asset}: 可用 {formatNumber(b.free, 8)} | 冻结 {formatNumber(b.locked, 8)}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">无</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="cyan">合约账户余额(非0)</Text>
|
||||
{futuresBalances.length ? (
|
||||
futuresBalances.map((b) => (
|
||||
<Text key={`fut-${b.asset}`}>
|
||||
{b.asset}: 钱包 {formatNumber(b.wallet, 8)} | 可用 {formatNumber(b.available, 8)}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">无</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={snapshot.opportunity ? "greenBright" : "redBright"}>套利差价(卖期货 / 买现货)</Text>
|
||||
<Text color={snapshot.opportunity ? "green" : undefined}>毛价差: {spread} USDT | {spreadBps} bp</Text>
|
||||
<Text color={snapshot.opportunity ? "green" : "red"}>
|
||||
扣除 taker 手续费 ({(basisConfig.takerFeeRate * 100).toFixed(4)}% × 双边): {netSpread} USDT | {netSpreadBps} bp
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">最近事件</Text>
|
||||
{lastLogs.length ? (
|
||||
lastLogs.map((entry, index) => {
|
||||
const color = entry.type === "entry" ? "green" : entry.type === "exit" ? "red" : undefined;
|
||||
return (
|
||||
<Text key={`${entry.time}-${index}`} color={color}>
|
||||
[{entry.time}] [{entry.type}] {entry.detail}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text color="gray">暂无日志</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { gridConfig } from "../config";
|
||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
|
||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||
import { formatNumber } from "../utils/format";
|
||||
|
||||
interface GridAppProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||
|
||||
export function GridApp({ onExit }: GridAppProps) {
|
||||
const [snapshot, setSnapshot] = useState<GridEngineSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<GridEngine | 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: gridConfig.symbol });
|
||||
const engine = new GridEngine(gridConfig, adapter);
|
||||
engineRef.current = engine;
|
||||
setSnapshot(engine.getSnapshot());
|
||||
const handler = (next: GridEngineSnapshot) => {
|
||||
setSnapshot({
|
||||
...next,
|
||||
desiredOrders: [...next.desiredOrders],
|
||||
gridLines: [...next.gridLines],
|
||||
tradeLog: [...next.tradeLog],
|
||||
});
|
||||
};
|
||||
engine.on("update", handler);
|
||||
engine.start();
|
||||
return () => {
|
||||
engine.off("update", handler);
|
||||
engine.stop();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}, [exchangeId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">启动失败: {error.message}</Text>
|
||||
<Text color="gray">请检查环境变量和网络连通性。</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>正在初始化网格策略…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const feedStatus = snapshot.feedStatus;
|
||||
const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [
|
||||
{ key: "account", label: "账户" },
|
||||
{ key: "orders", label: "订单" },
|
||||
{ key: "depth", label: "深度" },
|
||||
{ key: "ticker", label: "行情" },
|
||||
];
|
||||
const stopReason = snapshot.running ? null : snapshot.stopReason;
|
||||
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||
const position = snapshot.position;
|
||||
const hasPosition = Math.abs(position.positionAmt) > 1e-5;
|
||||
|
||||
const gridColumns: TableColumn[] = [
|
||||
{ key: "level", header: "#", align: "right", minWidth: 3 },
|
||||
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||
{ key: "side", header: "Side", minWidth: 4 },
|
||||
{ key: "active", header: "Active", minWidth: 6 },
|
||||
{ key: "hasOrder", header: "Order", minWidth: 5 },
|
||||
];
|
||||
const gridRows = snapshot.gridLines.map((line) => ({
|
||||
level: line.level,
|
||||
price: formatNumber(line.price, 4),
|
||||
side: line.side,
|
||||
active: line.active ? "yes" : "no",
|
||||
hasOrder: line.hasOrder ? "yes" : "no",
|
||||
}));
|
||||
|
||||
const desiredColumns: TableColumn[] = [
|
||||
{ key: "level", header: "#", align: "right", minWidth: 3 },
|
||||
{ key: "side", header: "Side", minWidth: 4 },
|
||||
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||
{ key: "amount", header: "Qty", align: "right", minWidth: 8 },
|
||||
];
|
||||
const desiredRows = snapshot.desiredOrders.map((order) => ({
|
||||
level: order.level,
|
||||
side: order.side,
|
||||
price: order.price,
|
||||
amount: formatNumber(order.amount, 4),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Grid Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 状态: {snapshot.running ? "运行中" : "暂停"}
|
||||
</Text>
|
||||
<Text>
|
||||
实时价格: {formatNumber(snapshot.lastPrice, 4)} | 中心价: {formatNumber(snapshot.centerPrice, 4)} | 下界: {formatNumber(snapshot.lowerPrice, 4)} | 上界: {formatNumber(snapshot.upperPrice, 4)} | 网格数量: {snapshot.gridLines.length}
|
||||
</Text>
|
||||
<Text color="gray">数据状态:
|
||||
{feedEntries.map((entry, index) => (
|
||||
<Text key={entry.key} color={feedStatus[entry.key] ? "green" : "red"}>
|
||||
{index === 0 ? " " : " "}
|
||||
{entry.label}
|
||||
</Text>
|
||||
))}
|
||||
| 按 Esc 返回策略选择
|
||||
</Text>
|
||||
{stopReason ? <Text color="yellow">暂停原因: {stopReason}</Text> : null}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="greenBright">网格配置</Text>
|
||||
<Text>
|
||||
单笔数量: {formatNumber(gridConfig.tradeAmount, 6)} | 每侧格子数: {gridConfig.levelsPerSide} | 最大仓位: {formatNumber(gridConfig.maxPositionSize, 6)}
|
||||
</Text>
|
||||
<Text>
|
||||
网格步长: {(gridConfig.spacingPct * 100).toFixed(3)}% | 止损缓冲: {(gridConfig.stopLossBufferPct * 100).toFixed(3)}%
|
||||
</Text>
|
||||
<Text>
|
||||
刷新间隔: {gridConfig.refreshIntervalMs} ms
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="greenBright">持仓</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
当前持仓: {position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(position.positionAmt), 6)} | 均价: {formatNumber(position.entryPrice, 4)}
|
||||
</Text>
|
||||
<Text>
|
||||
未实现盈亏: {formatNumber(position.unrealizedProfit, 4)} | 标记价: {formatNumber(position.markPrice, 4)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">当前无持仓</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">网格线</Text>
|
||||
{gridRows.length > 0 ? <DataTable columns={gridColumns} rows={gridRows} /> : <Text color="gray">暂无网格线</Text>}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">目标挂单</Text>
|
||||
{desiredRows.length > 0 ? <DataTable columns={desiredColumns} rows={desiredRows} /> : <Text color="gray">暂无目标挂单</Text>}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">最近事件</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">暂无日志</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { tradingConfig } from "../config";
|
||||
import { resolveExchangeId, getExchangeDisplayName } from "../exchanges/create-adapter";
|
||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
||||
import { formatNumber } from "../utils/format";
|
||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||
|
||||
interface GuardianAppProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const READY_MESSAGE = "正在等待行情/账户推送…";
|
||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||
|
||||
export function GuardianApp({ onExit }: GuardianAppProps) {
|
||||
const [snapshot, setSnapshot] = useState<GuardianEngineSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<GuardianEngine | null>(null);
|
||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
engineRef.current?.stop();
|
||||
onExit();
|
||||
}
|
||||
},
|
||||
{ isActive: inputSupported }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const adapter = buildAdapterFromEnv({ exchangeId, symbol: tradingConfig.symbol });
|
||||
const engine = new GuardianEngine(tradingConfig, adapter);
|
||||
engineRef.current = engine;
|
||||
setSnapshot(engine.getSnapshot());
|
||||
const handler = (next: GuardianEngineSnapshot) => {
|
||||
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
||||
};
|
||||
engine.on("update", handler);
|
||||
engine.start();
|
||||
return () => {
|
||||
engine.off("update", handler);
|
||||
engine.stop();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}, [exchangeId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">Guardian 策略启动失败: {error.message}</Text>
|
||||
<Text color="gray">请检查环境变量和网络连通性。</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>正在初始化 Guardian 策略…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { position, stopOrder, trailingOrder, tradeLog, ready, guardStatus } = snapshot;
|
||||
const hasPosition = Math.abs(position.positionAmt) > 1e-8;
|
||||
const stopOrderPrice = stopOrder ? Number(stopOrder.stopPrice ?? stopOrder.price) : null;
|
||||
const trailingActivate = trailingOrder ? Number(trailingOrder.activatePrice ?? (trailingOrder as any).activationPrice) : null;
|
||||
const lastLogs = tradeLog.slice(-6);
|
||||
const orderColumns: TableColumn[] = [
|
||||
{ key: "id", header: "ID", align: "right", minWidth: 6 },
|
||||
{ key: "side", header: "Side", minWidth: 4 },
|
||||
{ key: "type", header: "Type", minWidth: 12 },
|
||||
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||
{ key: "qty", header: "Qty", align: "right", minWidth: 8 },
|
||||
{ key: "status", header: "Status", minWidth: 10 },
|
||||
];
|
||||
const orderRows = [...snapshot.openOrders]
|
||||
.sort((a, b) => (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId))
|
||||
.slice(0, 8)
|
||||
.map((order) => ({
|
||||
id: order.orderId,
|
||||
side: order.side,
|
||||
type: order.type,
|
||||
price: order.price ?? order.stopPrice,
|
||||
qty: order.origQty,
|
||||
status: order.status,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={0}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Guardian Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 最近价格: {formatNumber(snapshot.lastPrice, 2)} | 状态: {ready ? "实时运行" : READY_MESSAGE}
|
||||
</Text>
|
||||
<Text color="gray">策略只会维护止损/止盈,不会主动开仓。按 Esc 返回菜单。</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="greenBright">当前仓位与风控</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
方向: {position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(position.positionAmt), 4)} | 开仓价: {formatNumber(position.entryPrice, 2)} | 浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT
|
||||
</Text>
|
||||
<Text>
|
||||
目标止损价: {formatNumber(snapshot.targetStopPrice, 2)} | 当前止损单: {formatNumber(stopOrderPrice, 2)} | 动态止盈触发: {formatNumber(snapshot.trailingActivationPrice, 2)} | 动态止盈单: {formatNumber(trailingActivate, 2)}
|
||||
</Text>
|
||||
<Text color={snapshot.requiresStop ? "yellow" : "gray"}>
|
||||
Guardian 状态: {guardStatus === "protecting" ? "已挂止损" : guardStatus === "pending" ? "缺少止损,正在同步" : "监听中"}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">当前无持仓,Guardian 正在监听新的仓位变化。</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">当前挂单</Text>
|
||||
{orderRows.length > 0 ? (
|
||||
<DataTable columns={orderColumns} rows={orderRows} />
|
||||
) : (
|
||||
<Text color="gray">暂无保护类挂单</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">最近事件</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">暂无日志</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+6
-3
@@ -70,7 +70,10 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
||||
|
||||
const topBid = snapshot.topBid;
|
||||
const topAsk = snapshot.topAsk;
|
||||
const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} USDT` : "-";
|
||||
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) => ({
|
||||
@@ -121,7 +124,7 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Maker Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, 2)} | 卖一价: {formatNumber(topAsk, 2)} | 点差: {spreadDisplay}
|
||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, priceDigits)} | 卖一价: {formatNumber(topAsk, priceDigits)} | 点差: {spreadDisplay}
|
||||
</Text>
|
||||
<Text color="gray">状态: {snapshot.ready ? "实时运行" : "等待市场数据"} | 按 Esc 返回策略选择</Text>
|
||||
<Text>
|
||||
@@ -141,7 +144,7 @@ export function MakerApp({ onExit }: MakerAppProps) {
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
方向: {snapshot.position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} | 开仓价: {formatNumber(snapshot.position.entryPrice, 2)}
|
||||
方向: {snapshot.position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} | 开仓价: {formatNumber(snapshot.position.entryPrice, priceDigits)}
|
||||
</Text>
|
||||
<Text>
|
||||
浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT | 账户未实现盈亏: {formatNumber(snapshot.accountUnrealized, 4)} USDT
|
||||
|
||||
@@ -70,7 +70,10 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
|
||||
|
||||
const topBid = snapshot.topBid;
|
||||
const topAsk = snapshot.topAsk;
|
||||
const spreadDisplay = snapshot.spread != null ? `${snapshot.spread.toFixed(4)} USDT` : "-";
|
||||
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)
|
||||
@@ -121,7 +124,7 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Offset Maker Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, 2)} | 卖一价: {formatNumber(topAsk, 2)} | 点差: {spreadDisplay}
|
||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 买一价: {formatNumber(topBid, priceDigits)} | 卖一价: {formatNumber(topAsk, priceDigits)} | 点差: {spreadDisplay}
|
||||
</Text>
|
||||
<Text>
|
||||
买10档累计: {formatNumber(snapshot.buyDepthSum10, 4)} | 卖10档累计: {formatNumber(snapshot.sellDepthSum10, 4)} | 状态: {imbalanceLabel}
|
||||
@@ -138,7 +141,7 @@ export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
方向: {snapshot.position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} | 开仓价: {formatNumber(snapshot.position.entryPrice, 2)}
|
||||
方向: {snapshot.position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(snapshot.position.positionAmt), 4)} | 开仓价: {formatNumber(snapshot.position.entryPrice, priceDigits)}
|
||||
</Text>
|
||||
<Text>
|
||||
浮动盈亏: {formatNumber(snapshot.pnl, 4)} USDT | 账户未实现盈亏: {formatNumber(snapshot.accountUnrealized, 4)} USDT
|
||||
|
||||
+42
-9
@@ -1,21 +1,54 @@
|
||||
const MAX_SCALE_DECIMALS = 12;
|
||||
const STEP_TOLERANCE = 1e-9;
|
||||
|
||||
function resolveStepScale(step: number): { scale: number; stepInt: number; decimals: number } {
|
||||
if (!Number.isFinite(step) || step <= 0) {
|
||||
return { scale: 1, stepInt: 1, decimals: 0 };
|
||||
}
|
||||
let decimals = Math.min(MAX_SCALE_DECIMALS, Math.max(0, decimalsOf(step)));
|
||||
let scale = Math.pow(10, decimals);
|
||||
let scaledStep = step * scale;
|
||||
while (decimals < MAX_SCALE_DECIMALS && Math.abs(Math.round(scaledStep) - scaledStep) > STEP_TOLERANCE) {
|
||||
decimals += 1;
|
||||
scale *= 10;
|
||||
scaledStep = step * scale;
|
||||
}
|
||||
const stepInt = Math.max(1, Math.round(scaledStep));
|
||||
return { scale, stepInt, decimals };
|
||||
}
|
||||
|
||||
export function roundDownToTick(value: number, tick: number): number {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(tick) || tick <= 0) return value;
|
||||
const scaled = Math.floor(value / tick) * tick;
|
||||
// Avoid floating residuals
|
||||
return Number(scaled.toFixed(Math.max(0, decimalsOf(tick))));
|
||||
const sign = value < 0 ? -1 : 1;
|
||||
const absValue = Math.abs(value);
|
||||
const { scale, stepInt, decimals } = resolveStepScale(tick);
|
||||
const scaledValue = Math.floor(absValue * scale + STEP_TOLERANCE);
|
||||
const resultInt = Math.floor(scaledValue / stepInt) * stepInt;
|
||||
const rounded = resultInt / scale;
|
||||
return sign * Number(rounded.toFixed(decimals));
|
||||
}
|
||||
|
||||
export function roundQtyDownToStep(value: number, step: number): number {
|
||||
if (!Number.isFinite(value) || !Number.isFinite(step) || step <= 0) return value;
|
||||
const scaled = Math.floor(value / step) * step;
|
||||
return Number(scaled.toFixed(Math.max(0, decimalsOf(step))));
|
||||
const sign = value < 0 ? -1 : 1;
|
||||
const absValue = Math.abs(value);
|
||||
const { scale, stepInt, decimals } = resolveStepScale(step);
|
||||
const scaledValue = Math.floor(absValue * scale + STEP_TOLERANCE);
|
||||
const resultInt = Math.floor(scaledValue / stepInt) * stepInt;
|
||||
const rounded = resultInt / scale;
|
||||
return sign * Number(rounded.toFixed(decimals));
|
||||
}
|
||||
|
||||
export function decimalsOf(step: number): number {
|
||||
const s = step.toString();
|
||||
if (!s.includes(".")) return 0;
|
||||
const fraction = s.split(".")[1];
|
||||
return fraction ? fraction.length : 0;
|
||||
if (!Number.isFinite(step)) return 0;
|
||||
if (Number.isInteger(step)) return 0;
|
||||
let decimals = 0;
|
||||
let scaled = step;
|
||||
while (decimals < MAX_SCALE_DECIMALS && Math.abs(Math.round(scaled) - scaled) > STEP_TOLERANCE) {
|
||||
scaled *= 10;
|
||||
decimals += 1;
|
||||
}
|
||||
return decimals;
|
||||
}
|
||||
|
||||
export function isNearlyZero(value: number, epsilon = 1e-5): boolean {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import crypto from "crypto";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AsterSpotRestClient } from "../src/exchanges/aster/client";
|
||||
|
||||
describe("AsterSpotRestClient", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock = vi.fn();
|
||||
// @ts-expect-error override for tests
|
||||
globalThis.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls ping without credentials", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
|
||||
|
||||
await client.ping();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://sapi.asterdex.com/api/v1/ping");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers).toEqual({});
|
||||
});
|
||||
|
||||
it("signs market order requests", async () => {
|
||||
const orderResponse = {
|
||||
orderId: 1,
|
||||
clientOrderId: "abc",
|
||||
symbol: "BTCUSDT",
|
||||
side: "BUY",
|
||||
type: "MARKET",
|
||||
status: "FILLED",
|
||||
price: "0",
|
||||
origQty: "1",
|
||||
executedQty: "1",
|
||||
stopPrice: "0",
|
||||
time: 1000,
|
||||
updateTime: 1000,
|
||||
reduceOnly: false,
|
||||
closePosition: false,
|
||||
};
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(orderResponse), { status: 200 }));
|
||||
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
|
||||
vi.spyOn(Date, "now").mockReturnValue(1000);
|
||||
|
||||
await client.createOrder({ symbol: "BTCUSDT", side: "BUY", type: "MARKET", quoteOrderQty: "100" });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://sapi.asterdex.com/api/v1/order");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toEqual({
|
||||
"X-MBX-APIKEY": "key",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
});
|
||||
const payload = "quoteOrderQty=100&recvWindow=5000&side=BUY&symbol=BTCUSDT×tamp=1000&type=MARKET";
|
||||
const expectedSignature = crypto.createHmac("sha256", "secret").update(payload).digest("hex");
|
||||
expect(init.body).toBe(`${payload}&signature=${expectedSignature}`);
|
||||
});
|
||||
|
||||
it("attaches api key for historical trades without signing", async () => {
|
||||
const trades = [
|
||||
{ id: 1, price: "1", qty: "1", time: 1000, isBuyerMaker: false },
|
||||
];
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(trades), { status: 200 }));
|
||||
const client = new AsterSpotRestClient({ apiKey: "key", apiSecret: "secret" });
|
||||
|
||||
await client.getHistoricalTrades({ symbol: "BTCUSDT" });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://sapi.asterdex.com/api/v1/historicalTrades?symbol=BTCUSDT");
|
||||
expect(init.method).toBe("GET");
|
||||
expect(init.headers).toEqual({ "X-MBX-APIKEY": "key" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BackpackGateway } from "../src/exchanges/backpack/gateway";
|
||||
|
||||
describe("BackpackGateway account snapshots", () => {
|
||||
const createGateway = () =>
|
||||
new BackpackGateway({
|
||||
apiKey: "key",
|
||||
apiSecret: "secret",
|
||||
symbol: "BTCUSDC",
|
||||
logger: () => {},
|
||||
}) as any;
|
||||
|
||||
it("maps spot balances without positions", () => {
|
||||
const gateway = createGateway();
|
||||
gateway.isContractMarket = false;
|
||||
|
||||
const balance = {
|
||||
info: {},
|
||||
free: { USDC: "10" },
|
||||
used: { USDC: "5" },
|
||||
total: { USDC: "15" },
|
||||
USDC: { free: "10", used: "5", total: "15" },
|
||||
} as any;
|
||||
|
||||
const snapshot = gateway.mapBalanceToAccountSnapshotWithPositions(balance, []);
|
||||
|
||||
expect(snapshot.positions).toEqual([]);
|
||||
expect(snapshot.totalWalletBalance).toBe("15");
|
||||
expect(snapshot.totalUnrealizedProfit).toBe("0");
|
||||
expect(snapshot.availableBalance).toBe("10");
|
||||
expect(snapshot.maxWithdrawAmount).toBe("10");
|
||||
expect(snapshot.totalMarginBalance).toBeUndefined();
|
||||
expect(snapshot.assets).toHaveLength(1);
|
||||
expect(snapshot.assets[0]).toMatchObject({
|
||||
asset: "USDC",
|
||||
walletBalance: "15",
|
||||
availableBalance: "10",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes derivative positions when present", () => {
|
||||
const gateway = createGateway();
|
||||
gateway.isContractMarket = true;
|
||||
gateway.marketSymbol = "BTC/USDC:USDC";
|
||||
|
||||
const balance = {
|
||||
info: {},
|
||||
free: { USDC: "80" },
|
||||
used: { USDC: "20" },
|
||||
total: { USDC: "100" },
|
||||
USDC: { free: "80", used: "20", total: "100" },
|
||||
} as any;
|
||||
|
||||
const positions = [
|
||||
{
|
||||
symbol: "BTC/USDC:USDC",
|
||||
contracts: "2",
|
||||
side: "long",
|
||||
entryPrice: "25000",
|
||||
markPrice: "25200",
|
||||
unrealizedPnl: "400",
|
||||
info: {
|
||||
estLiquidationPrice: "15000",
|
||||
},
|
||||
},
|
||||
{
|
||||
info: {
|
||||
symbol: "ETH/USDC:USDC",
|
||||
netExposureQuantity: "0.5",
|
||||
netCost: "-100",
|
||||
entryPrice: "3000",
|
||||
pnlUnrealized: "-10",
|
||||
markPrice: "2900",
|
||||
estLiquidationPrice: "1000",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const snapshot = gateway.mapBalanceToAccountSnapshotWithPositions(balance, positions);
|
||||
|
||||
expect(snapshot.positions).toHaveLength(2);
|
||||
expect(snapshot.positions[0]).toMatchObject({
|
||||
symbol: "BTC/USDC:USDC",
|
||||
positionAmt: "2",
|
||||
positionSide: "LONG",
|
||||
entryPrice: "25000",
|
||||
unrealizedProfit: "400",
|
||||
markPrice: "25200",
|
||||
liquidationPrice: "15000",
|
||||
});
|
||||
expect(snapshot.positions[1]).toMatchObject({
|
||||
symbol: "ETH/USDC:USDC",
|
||||
positionAmt: "-0.5",
|
||||
positionSide: "SHORT",
|
||||
entryPrice: "3000",
|
||||
unrealizedProfit: "-10",
|
||||
markPrice: "2900",
|
||||
liquidationPrice: "1000",
|
||||
});
|
||||
|
||||
expect(snapshot.totalWalletBalance).toBe("100");
|
||||
expect(snapshot.totalUnrealizedProfit).toBe("390");
|
||||
expect(snapshot.totalMarginBalance).toBe("490");
|
||||
expect(snapshot.totalCrossWalletBalance).toBe("100");
|
||||
expect(snapshot.totalCrossUnPnl).toBe("390");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterDepth,
|
||||
AsterKline,
|
||||
AsterOrder,
|
||||
AsterTicker,
|
||||
} from "../src/exchanges/types";
|
||||
import { BasisArbEngine } from "../src/strategy/basis-arb-engine";
|
||||
|
||||
class StubAdapter implements ExchangeAdapter {
|
||||
id = "aster";
|
||||
private depthHandler: ((depth: AsterDepth) => void) | null = null;
|
||||
|
||||
supportsTrailingStops(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {
|
||||
// not required for this test
|
||||
}
|
||||
|
||||
watchOrders(_cb: (orders: AsterOrder[]) => void): void {
|
||||
// not required for this test
|
||||
}
|
||||
|
||||
watchDepth(_symbol: string, cb: (depth: AsterDepth) => void): void {
|
||||
this.depthHandler = cb;
|
||||
}
|
||||
|
||||
emitDepth(depth: AsterDepth): void {
|
||||
this.depthHandler?.(depth);
|
||||
}
|
||||
|
||||
watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {
|
||||
// not required for this test
|
||||
}
|
||||
|
||||
watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {
|
||||
// not required for this test
|
||||
}
|
||||
|
||||
createOrder(): Promise<AsterOrder> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
cancelOrder(_params: { symbol: string; orderId: number | string }): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
cancelOrders(_params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
cancelAllOrders(_params: { symbol: string }): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("BasisArbEngine", () => {
|
||||
it("computes spreads after receiving futures depth and spot quotes", async () => {
|
||||
const adapter = new StubAdapter();
|
||||
const spotClient = {
|
||||
getBookTicker: vi.fn().mockResolvedValue({
|
||||
symbol: "ASTERUSDT",
|
||||
bidPrice: "1.0000",
|
||||
bidQty: "1",
|
||||
askPrice: "1.0500",
|
||||
askQty: "1",
|
||||
time: 2_000,
|
||||
}),
|
||||
};
|
||||
|
||||
const engine = new BasisArbEngine(
|
||||
{
|
||||
futuresSymbol: "ASTERUSDT",
|
||||
spotSymbol: "ASTERUSDT",
|
||||
refreshIntervalMs: 1_000,
|
||||
maxLogEntries: 10,
|
||||
takerFeeRate: 0.0004,
|
||||
},
|
||||
adapter,
|
||||
{
|
||||
spotClient,
|
||||
now: () => 1_000,
|
||||
}
|
||||
);
|
||||
|
||||
engine.start();
|
||||
|
||||
adapter.emitDepth({
|
||||
lastUpdateId: 1,
|
||||
bids: [["1.0400", "1"]],
|
||||
asks: [["1.0600", "1"]],
|
||||
eventTime: 1_500,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spotClient.getBookTicker).toHaveBeenCalled();
|
||||
const snap = engine.getSnapshot();
|
||||
expect(snap.spotBid).not.toBeNull();
|
||||
expect(snap.futuresBid).not.toBeNull();
|
||||
});
|
||||
|
||||
const snapshot = engine.getSnapshot();
|
||||
expect(snapshot.spread).toBeCloseTo(1.04 - 1.05, 6);
|
||||
expect(snapshot.spreadBps).toBeCloseTo(((1.04 - 1.05) / 1.05) * 10_000, 6);
|
||||
const fee = 0.0004;
|
||||
const effectiveFee = fee * 2;
|
||||
const expectedNet = 1.04 * (1 - effectiveFee) - 1.05 * (1 + effectiveFee);
|
||||
expect(snapshot.netSpread).toBeCloseTo(expectedNet, 6);
|
||||
expect(snapshot.netSpreadBps).toBeCloseTo((expectedNet / 1.05) * 10_000, 6);
|
||||
expect(snapshot.feedStatus).toEqual({ futures: true, spot: true });
|
||||
expect(snapshot.opportunity).toBe(expectedNet >= 0);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||
import type {
|
||||
AsterAccountSnapshot,
|
||||
AsterDepth,
|
||||
AsterOrder,
|
||||
AsterTicker,
|
||||
CreateOrderParams,
|
||||
} from "../src/exchanges/types";
|
||||
import type { GridConfig } from "../src/config";
|
||||
import { GridEngine } from "../src/strategy/grid-engine";
|
||||
|
||||
class StubAdapter implements ExchangeAdapter {
|
||||
id = "aster";
|
||||
|
||||
private accountHandler: ((snapshot: AsterAccountSnapshot) => void) | null = null;
|
||||
private orderHandler: ((orders: AsterOrder[]) => void) | null = null;
|
||||
private depthHandler: ((depth: AsterDepth) => void) | null = null;
|
||||
private tickerHandler: ((ticker: AsterTicker) => void) | null = null;
|
||||
private currentOrders: AsterOrder[] = [];
|
||||
|
||||
public createdOrders: CreateOrderParams[] = [];
|
||||
public marketOrders: CreateOrderParams[] = [];
|
||||
public cancelAllCount = 0;
|
||||
public cancelledOrders: Array<number | string> = [];
|
||||
|
||||
supportsTrailingStops(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
watchAccount(cb: (snapshot: AsterAccountSnapshot) => void): void {
|
||||
this.accountHandler = cb;
|
||||
}
|
||||
|
||||
watchOrders(cb: (orders: AsterOrder[]) => void): void {
|
||||
this.orderHandler = cb;
|
||||
}
|
||||
|
||||
watchDepth(_symbol: string, cb: (depth: AsterDepth) => void): void {
|
||||
this.depthHandler = cb;
|
||||
}
|
||||
|
||||
watchTicker(_symbol: string, cb: (ticker: AsterTicker) => void): void {
|
||||
this.tickerHandler = cb;
|
||||
}
|
||||
|
||||
watchKlines(): void {
|
||||
// not used in tests
|
||||
}
|
||||
|
||||
emitAccount(snapshot: AsterAccountSnapshot): void {
|
||||
this.accountHandler?.(snapshot);
|
||||
}
|
||||
|
||||
emitOrders(orders: AsterOrder[]): void {
|
||||
this.orderHandler?.(orders);
|
||||
}
|
||||
|
||||
emitDepth(depth: AsterDepth): void {
|
||||
this.depthHandler?.(depth);
|
||||
}
|
||||
|
||||
emitTicker(ticker: AsterTicker): void {
|
||||
this.tickerHandler?.(ticker);
|
||||
}
|
||||
|
||||
async createOrder(params: CreateOrderParams): Promise<AsterOrder> {
|
||||
const order: AsterOrder = {
|
||||
orderId: `${Date.now()}-${Math.random()}`,
|
||||
clientOrderId: "test",
|
||||
symbol: params.symbol,
|
||||
side: params.side,
|
||||
type: params.type,
|
||||
status: params.type === "MARKET" ? "FILLED" : "NEW",
|
||||
price: Number(params.price ?? 0).toString(),
|
||||
origQty: Number(params.quantity ?? 0).toString(),
|
||||
executedQty: "0",
|
||||
stopPrice: "0",
|
||||
time: Date.now(),
|
||||
updateTime: Date.now(),
|
||||
reduceOnly: params.reduceOnly === "true",
|
||||
closePosition: false,
|
||||
};
|
||||
this.createdOrders.push(params);
|
||||
if (params.type === "MARKET") {
|
||||
this.marketOrders.push(params);
|
||||
this.orderHandler?.([]);
|
||||
} else {
|
||||
this.currentOrders = [order];
|
||||
this.orderHandler?.(this.currentOrders);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
async cancelOrder(params: { symbol: string; orderId: number | string }): Promise<void> {
|
||||
this.cancelledOrders.push(params.orderId);
|
||||
}
|
||||
|
||||
async cancelOrders(params: { symbol: string; orderIdList: Array<number | string> }): Promise<void> {
|
||||
this.cancelledOrders.push(...params.orderIdList);
|
||||
}
|
||||
|
||||
async cancelAllOrders(): Promise<void> {
|
||||
this.cancelAllCount += 1;
|
||||
this.currentOrders = [];
|
||||
this.orderHandler?.([]);
|
||||
}
|
||||
}
|
||||
|
||||
function createAccountSnapshot(symbol: string, positionAmt: number): AsterAccountSnapshot {
|
||||
return {
|
||||
canTrade: true,
|
||||
canDeposit: true,
|
||||
canWithdraw: true,
|
||||
updateTime: Date.now(),
|
||||
totalWalletBalance: "0",
|
||||
totalUnrealizedProfit: "0",
|
||||
positions: [
|
||||
{
|
||||
symbol,
|
||||
positionAmt: positionAmt.toString(),
|
||||
entryPrice: "150",
|
||||
unrealizedProfit: "0",
|
||||
positionSide: "BOTH",
|
||||
updateTime: Date.now(),
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
} as unknown as AsterAccountSnapshot;
|
||||
}
|
||||
|
||||
describe("GridEngine", () => {
|
||||
const baseConfig: GridConfig = {
|
||||
symbol: "BTCUSDT",
|
||||
lowerPrice: 100,
|
||||
upperPrice: 200,
|
||||
gridLevels: 3,
|
||||
orderSize: 0.1,
|
||||
maxPositionSize: 0.2,
|
||||
refreshIntervalMs: 10,
|
||||
maxLogEntries: 50,
|
||||
priceTick: 0.1,
|
||||
qtyStep: 0.01,
|
||||
direction: "both",
|
||||
stopLossPct: 0.01,
|
||||
restartTriggerPct: 0.01,
|
||||
autoRestart: true,
|
||||
gridMode: "geometric",
|
||||
maxCloseSlippagePct: 0.05,
|
||||
};
|
||||
|
||||
it("creates geometric desired orders when running in both directions", async () => {
|
||||
const adapter = new StubAdapter();
|
||||
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
|
||||
adapter.emitOrders([]);
|
||||
adapter.emitTicker({
|
||||
symbol: baseConfig.symbol,
|
||||
lastPrice: "150",
|
||||
openPrice: "150",
|
||||
highPrice: "150",
|
||||
lowPrice: "150",
|
||||
volume: "0",
|
||||
quoteVolume: "0",
|
||||
});
|
||||
|
||||
// use internal syncGrid to generate orders without waiting for timers
|
||||
const desired = (engine as any).computeDesiredOrders(150) as Array<{ side: string; price: string }>;
|
||||
expect(desired).toHaveLength(3);
|
||||
const buyOrders = desired.filter((order) => order.side === "BUY");
|
||||
const sellOrders = desired.filter((order) => order.side === "SELL");
|
||||
expect(buyOrders).toHaveLength(2);
|
||||
expect(sellOrders).toHaveLength(1);
|
||||
expect(Number(buyOrders[0]?.price)).toBeCloseTo(141.4, 1);
|
||||
expect(Number(buyOrders[1]?.price)).toBeCloseTo(100, 6);
|
||||
expect(Number(sellOrders[0]?.price)).toBeCloseTo(200, 6);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
it("limits sell orders for long-only direction when no position is available", () => {
|
||||
const adapter = new StubAdapter();
|
||||
const engine = new GridEngine({ ...baseConfig, direction: "long" }, adapter, { now: () => 0 });
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
|
||||
adapter.emitOrders([]);
|
||||
|
||||
const desired = (engine as any).computeDesiredOrders(150) as Array<{ side: string; reduceOnly: boolean }>;
|
||||
const sells = desired.filter((order) => order.side === "SELL");
|
||||
const buys = desired.filter((order) => order.side === "BUY");
|
||||
|
||||
expect(buys.length).toBeGreaterThan(0);
|
||||
expect(sells).toHaveLength(0);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
it("does not repopulate the same buy level until exposure is released", () => {
|
||||
const adapter = new StubAdapter();
|
||||
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
|
||||
adapter.emitOrders([]);
|
||||
|
||||
const desiredInitial = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>;
|
||||
const nearestBuy = desiredInitial.find((order) => order.side === "BUY");
|
||||
expect(nearestBuy).toBeTruthy();
|
||||
const targetLevel = nearestBuy!.level;
|
||||
|
||||
(engine as any).longExposure.set(targetLevel, baseConfig.orderSize);
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize));
|
||||
|
||||
const desiredAfterFill = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>;
|
||||
expect(desiredAfterFill.some((order) => order.level === targetLevel && order.side === "BUY")).toBe(false);
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
|
||||
const desiredAfterExit = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>;
|
||||
expect(desiredAfterExit.some((order) => order.level === targetLevel && order.side === "BUY")).toBe(true);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
it("keeps level side assignments stable regardless of price", () => {
|
||||
const adapter = new StubAdapter();
|
||||
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
|
||||
adapter.emitOrders([]);
|
||||
|
||||
const desiredHigh = (engine as any).computeDesiredOrders(2.45) as Array<{ level: number; side: string }>;
|
||||
expect(desiredHigh.every((order) => {
|
||||
const isBuyLevel = order.level <= Math.floor((baseConfig.gridLevels - 1) / 2);
|
||||
return isBuyLevel ? order.side === "BUY" : order.side === "SELL";
|
||||
})).toBe(true);
|
||||
|
||||
const desiredLow = (engine as any).computeDesiredOrders(1.55) as Array<{ level: number; side: string }>;
|
||||
expect(desiredLow.every((order) => {
|
||||
const isBuyLevel = order.level <= Math.floor((baseConfig.gridLevels - 1) / 2);
|
||||
return isBuyLevel ? order.side === "BUY" : order.side === "SELL";
|
||||
})).toBe(true);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
it("limits active sell orders by remaining short headroom", () => {
|
||||
const adapter = new StubAdapter();
|
||||
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0));
|
||||
adapter.emitOrders([]);
|
||||
|
||||
const desiredFull = (engine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>;
|
||||
const sellCountFull = desiredFull.filter((order) => order.side === "SELL").length;
|
||||
expect(sellCountFull).toBeGreaterThan(0);
|
||||
|
||||
const limitedHeadroomConfig = { ...baseConfig, maxPositionSize: baseConfig.orderSize * 2 };
|
||||
const limitedEngine = new GridEngine(limitedHeadroomConfig, adapter as any, { now: () => 0 });
|
||||
(limitedEngine as any).shortExposure.set(12, baseConfig.orderSize * 2);
|
||||
|
||||
const desiredLimited = (limitedEngine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>;
|
||||
const sellCountLimited = desiredLimited.filter((order) => order.side === "SELL").length;
|
||||
expect(sellCountLimited).toBeLessThanOrEqual(1);
|
||||
|
||||
engine.stop();
|
||||
limitedEngine.stop();
|
||||
});
|
||||
|
||||
it("places reduce-only orders to close existing exposures", () => {
|
||||
const adapter = new StubAdapter();
|
||||
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize));
|
||||
adapter.emitOrders([]);
|
||||
|
||||
const buyLevel = (engine as any).buyLevelIndices.slice(-1)[0];
|
||||
(engine as any).longExposure.set(buyLevel, baseConfig.orderSize);
|
||||
|
||||
const desired = (engine as any).computeDesiredOrders(2.05) as Array<{
|
||||
level: number;
|
||||
side: string;
|
||||
reduceOnly: boolean;
|
||||
amount: number;
|
||||
}>;
|
||||
|
||||
const closeOrder = desired.find((order) => order.reduceOnly && order.side === "SELL");
|
||||
expect(closeOrder).toBeTruthy();
|
||||
expect(closeOrder!.amount).toBeCloseTo(baseConfig.orderSize);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
it("restores exposures from existing reduce-only orders on restart", async () => {
|
||||
const adapter = new StubAdapter();
|
||||
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize * 2));
|
||||
|
||||
const reduceOrder: AsterOrder = {
|
||||
orderId: "existing-reduce",
|
||||
clientOrderId: "existing-reduce",
|
||||
symbol: baseConfig.symbol,
|
||||
side: "SELL",
|
||||
type: "LIMIT",
|
||||
status: "NEW",
|
||||
price: baseConfig.upperPrice.toFixed(1),
|
||||
origQty: (baseConfig.orderSize * 2).toString(),
|
||||
executedQty: "0",
|
||||
stopPrice: "0",
|
||||
time: Date.now(),
|
||||
updateTime: Date.now(),
|
||||
reduceOnly: true,
|
||||
closePosition: false,
|
||||
};
|
||||
|
||||
adapter.emitOrders([reduceOrder]);
|
||||
adapter.emitTicker({
|
||||
symbol: baseConfig.symbol,
|
||||
lastPrice: "150",
|
||||
openPrice: "150",
|
||||
highPrice: "150",
|
||||
lowPrice: "150",
|
||||
volume: "0",
|
||||
quoteVolume: "0",
|
||||
});
|
||||
|
||||
await (engine as any).syncGrid(150);
|
||||
|
||||
const longExposure: Map<number, number> = (engine as any).longExposure;
|
||||
const buyIndices: number[] = (engine as any).buyLevelIndices;
|
||||
|
||||
const totalExposure = [...longExposure.values()].reduce((acc, qty) => acc + qty, 0);
|
||||
expect(totalExposure).toBeCloseTo(baseConfig.orderSize * 2, 6);
|
||||
expect(longExposure.get(buyIndices.slice(-1)[0]!)).toBeCloseTo(baseConfig.orderSize, 6);
|
||||
expect(longExposure.get(buyIndices[0]!)).toBeCloseTo(baseConfig.orderSize, 6);
|
||||
|
||||
const snapshot = engine.getSnapshot();
|
||||
const reduceDesired = snapshot.desiredOrders.find(
|
||||
(order) => order.reduceOnly && order.side === "SELL"
|
||||
);
|
||||
expect(reduceDesired).toBeTruthy();
|
||||
expect(reduceDesired!.amount).toBeCloseTo(baseConfig.orderSize * 2, 6);
|
||||
expect(Number(reduceDesired!.price)).toBeCloseTo(baseConfig.upperPrice, 6);
|
||||
expect(adapter.cancelledOrders).toHaveLength(0);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
|
||||
it("halts the grid and closes positions when stop loss triggers", async () => {
|
||||
const adapter = new StubAdapter();
|
||||
const engine = new GridEngine(baseConfig, adapter, { now: () => 0 });
|
||||
|
||||
adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0.2));
|
||||
adapter.emitOrders([]);
|
||||
adapter.emitTicker({
|
||||
symbol: baseConfig.symbol,
|
||||
lastPrice: "150",
|
||||
openPrice: "150",
|
||||
highPrice: "150",
|
||||
lowPrice: "150",
|
||||
volume: "0",
|
||||
quoteVolume: "0",
|
||||
});
|
||||
|
||||
(engine as any).stopReason = "test stop";
|
||||
await (engine as any).haltGrid(90);
|
||||
|
||||
expect(adapter.cancelAllCount).toBe(1);
|
||||
expect(adapter.marketOrders).toHaveLength(1);
|
||||
expect(engine.getSnapshot().running).toBe(false);
|
||||
|
||||
engine.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { scaleQuantityWithMinimum, scaledToDecimalString, decimalToScaled } from "../../src/exchanges/lighter/decimal";
|
||||
|
||||
describe("scaleQuantityWithMinimum", () => {
|
||||
it("raises tiny positive quantities to the minimum step", () => {
|
||||
const scaled = scaleQuantityWithMinimum(0.00001, 4);
|
||||
expect(scaled).toBe(1n);
|
||||
expect(scaledToDecimalString(scaled, 4)).toBe("0.0001");
|
||||
});
|
||||
|
||||
it("raises tiny negative quantities to the negative minimum step", () => {
|
||||
const scaled = scaleQuantityWithMinimum(-0.00001, 4);
|
||||
expect(scaled).toBe(-1n);
|
||||
expect(scaledToDecimalString(scaled, 4)).toBe("-0.0001");
|
||||
});
|
||||
|
||||
it("keeps zero quantities at zero", () => {
|
||||
const scaled = scaleQuantityWithMinimum(0, 4);
|
||||
expect(scaled).toBe(0n);
|
||||
});
|
||||
|
||||
it("preserves values already aligned to the step", () => {
|
||||
const aligned = scaleQuantityWithMinimum(0.0002, 4);
|
||||
expect(aligned).toBe(decimalToScaled(0.0002, 4));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toAccountSnapshot } from "../../src/exchanges/lighter/mappers";
|
||||
import type { LighterAccountDetails, LighterPosition } from "../../src/exchanges/lighter/types";
|
||||
|
||||
const baseDetails: LighterAccountDetails = {
|
||||
account_index: 1,
|
||||
collateral: "1000",
|
||||
};
|
||||
|
||||
function createPosition(overrides: Partial<LighterPosition> = {}): LighterPosition {
|
||||
return {
|
||||
market_id: 101,
|
||||
symbol: "BTC/USDC:USDC",
|
||||
sign: 1,
|
||||
position: "0.5",
|
||||
avg_entry_price: "100",
|
||||
position_value: "50",
|
||||
unrealized_pnl: "0",
|
||||
realized_pnl: "0",
|
||||
...overrides,
|
||||
} as LighterPosition;
|
||||
}
|
||||
|
||||
describe("toAccountSnapshot", () => {
|
||||
it("includes positions that match the configured market id regardless of reported symbol format", () => {
|
||||
const snapshot = toAccountSnapshot(
|
||||
"BTC",
|
||||
baseDetails,
|
||||
[
|
||||
createPosition({ market_id: 101, symbol: "BTC/USDC:USDC" }),
|
||||
createPosition({ market_id: 202, symbol: "ETH/USDC:USDC", sign: -1 }),
|
||||
],
|
||||
[],
|
||||
{ marketSymbol: "BTC", marketId: 101 }
|
||||
);
|
||||
|
||||
expect(snapshot.positions).toHaveLength(1);
|
||||
expect(snapshot.positions[0]).toMatchObject({ symbol: "BTC", positionAmt: "0.5" });
|
||||
});
|
||||
|
||||
it("falls back to fuzzy symbol matching when market id is unavailable", () => {
|
||||
const snapshot = toAccountSnapshot(
|
||||
"BTC",
|
||||
baseDetails,
|
||||
[
|
||||
createPosition({ market_id: Number.NaN as number, symbol: "BTC/USDC:USDC" }),
|
||||
createPosition({ market_id: Number.NaN as number, symbol: "ETH/USDC:USDC" }),
|
||||
],
|
||||
[],
|
||||
{ marketSymbol: "BTC" }
|
||||
);
|
||||
|
||||
expect(snapshot.positions).toHaveLength(1);
|
||||
expect(snapshot.positions[0]).toMatchObject({ symbol: "BTC", positionAmt: "0.5" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldResetMarketOrders } from "../../src/exchanges/lighter/order-feed";
|
||||
|
||||
describe("shouldResetMarketOrders", () => {
|
||||
it("always resets on snapshots", () => {
|
||||
expect(shouldResetMarketOrders([{ id: 1 }], true)).toBe(true);
|
||||
expect(shouldResetMarketOrders([], true)).toBe(true);
|
||||
expect(shouldResetMarketOrders(null, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("resets when array bucket is empty", () => {
|
||||
expect(shouldResetMarketOrders([], false)).toBe(true);
|
||||
expect(shouldResetMarketOrders([{}], false)).toBe(false);
|
||||
});
|
||||
|
||||
it("resets when object bucket has no keys", () => {
|
||||
expect(shouldResetMarketOrders({}, false)).toBe(true);
|
||||
expect(shouldResetMarketOrders({ a: 1 }, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not reset for non-empty updates", () => {
|
||||
expect(shouldResetMarketOrders([{ order_index: "1" }], false)).toBe(false);
|
||||
expect(shouldResetMarketOrders(null, false)).toBe(false);
|
||||
expect(shouldResetMarketOrders(undefined, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeOrderIdentity, orderIdentityEquals } from "../../src/exchanges/lighter/order-identity";
|
||||
|
||||
describe("order identity helpers", () => {
|
||||
it("treats large numeric strings as distinct values", () => {
|
||||
const first = "27584547724798440";
|
||||
const second = "27584547724798442";
|
||||
expect(orderIdentityEquals(first, second)).toBe(false);
|
||||
expect(orderIdentityEquals(first, first)).toBe(true);
|
||||
});
|
||||
|
||||
it("considers numeric inputs equal to their string counterparts", () => {
|
||||
expect(orderIdentityEquals(123456789, "123456789")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes whitespace-only identifiers to null", () => {
|
||||
expect(normalizeOrderIdentity(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to truncated integers for floating inputs", () => {
|
||||
expect(normalizeOrderIdentity(42.9)).toBe("42");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { lighterOrderToAster } from "../../src/exchanges/lighter/mappers";
|
||||
import type { LighterOrder } from "../../src/exchanges/lighter/types";
|
||||
|
||||
function createOrder(overrides: Partial<LighterOrder> = {}): LighterOrder {
|
||||
return {
|
||||
order_index: 1,
|
||||
client_order_index: 1,
|
||||
market_index: 99,
|
||||
initial_base_amount: "0.1",
|
||||
remaining_base_amount: "0.1",
|
||||
price: "154.86",
|
||||
type: "limit",
|
||||
reduce_only: "No",
|
||||
side: "buy",
|
||||
...overrides,
|
||||
} as LighterOrder;
|
||||
}
|
||||
|
||||
describe("lighterOrderToAster", () => {
|
||||
it("treats textual reduce_only flags correctly", () => {
|
||||
const nonReduce = lighterOrderToAster("USDJPY", createOrder({ reduce_only: "No" }));
|
||||
expect(nonReduce.reduceOnly).toBe(false);
|
||||
|
||||
const reduce = lighterOrderToAster("USDJPY", createOrder({ reduce_only: "Yes" }));
|
||||
expect(reduce.reduceOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("uses numeric is_ask flag for side inference", () => {
|
||||
const sell = lighterOrderToAster("USDJPY", createOrder({ is_ask: 1 }));
|
||||
expect(sell.side).toBe("SELL");
|
||||
|
||||
const buy = lighterOrderToAster("USDJPY", createOrder({ is_ask: 0 }));
|
||||
expect(buy.side).toBe("BUY");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
|
||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||
import type { AsterOrder } from "../src/exchanges/types";
|
||||
import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator";
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
unlockOperating,
|
||||
} from "../src/core/order-coordinator";
|
||||
|
||||
const originalTradeExchange = process.env.TRADE_EXCHANGE;
|
||||
const originalExchange = process.env.EXCHANGE;
|
||||
|
||||
const baseOrder: AsterOrder = {
|
||||
orderId: 1,
|
||||
clientOrderId: "client",
|
||||
@@ -47,6 +50,16 @@ function createMockExchange(overrides: Partial<ExchangeAdapter> = {}): ExchangeA
|
||||
}
|
||||
|
||||
describe("order-coordinator", () => {
|
||||
beforeEach(() => {
|
||||
process.env.TRADE_EXCHANGE = "aster";
|
||||
process.env.EXCHANGE = undefined;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.TRADE_EXCHANGE = originalTradeExchange;
|
||||
process.env.EXCHANGE = originalExchange;
|
||||
});
|
||||
|
||||
it("deduplicates orders by type and side", async () => {
|
||||
const adapter = createMockExchange();
|
||||
const locks: OrderLockMap = {};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { roundQtyDownToStep, roundDownToTick, decimalsOf } from "../../src/utils/math";
|
||||
|
||||
describe("utils/math precision", () => {
|
||||
it("keeps quantity intact when it matches the step exactly", () => {
|
||||
expect(roundQtyDownToStep(0.01, 0.00001)).toBe(0.01);
|
||||
expect(roundQtyDownToStep(0.01, 0.001)).toBe(0.01);
|
||||
});
|
||||
|
||||
it("floors quantity to the nearest valid step without precision loss", () => {
|
||||
expect(roundQtyDownToStep(1.23456789, 0.001)).toBe(1.234);
|
||||
expect(roundQtyDownToStep(0.00009, 0.00005)).toBe(0.00005);
|
||||
});
|
||||
|
||||
it("rounds prices down respecting tick size", () => {
|
||||
expect(roundDownToTick(20345.123456, 0.001)).toBe(20345.123);
|
||||
expect(roundDownToTick(1.00000009, 0.00001)).toBe(1);
|
||||
});
|
||||
|
||||
it("detects decimal places for powers of ten", () => {
|
||||
expect(decimalsOf(0.00000001)).toBe(8);
|
||||
expect(decimalsOf(0.25)).toBe(2);
|
||||
expect(decimalsOf(1)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user