mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-12 09:48:07 +00:00
Compare commits
12
Commits
feat/grid
...
0ea71503e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ea71503e3 | ||
|
|
22b9c5a39d | ||
|
|
b9331c516a | ||
|
|
448a2f2615 | ||
|
|
5aecaabb16 | ||
|
|
6f64dd5a0a | ||
|
|
2c98664412 | ||
|
|
9538ecf265 | ||
|
|
1e4f610c04 | ||
|
|
7acb3c3b82 | ||
|
|
9f3e50045a | ||
|
|
b7ace263b1 |
@@ -87,6 +87,14 @@ GRID_STOP_LOSS_PCT=0.01 # Stop loss trigger percentage beyond bo
|
|||||||
GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside bounds
|
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_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_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Close-order slippage guard relative to mark price
|
||||||
|
GRID_SHIFT_ENABLED=false # Smart-follow grid: shift the whole grid when price drifts from anchor
|
||||||
|
GRID_SHIFT_TRIGGER_PCT=0.05 # Shift trigger: |price/anchor - 1| threshold (0.05 => 5%)
|
||||||
|
GRID_SHIFT_RANGE_PCT=0.05 # New grid half-range around the new anchor after a shift
|
||||||
|
GRID_SHIFT_CONFIRM_MS=3000 # Deviation must persist this long before shifting (anti-wick)
|
||||||
|
GRID_USE_REDUCE_ONLY=false # Attach reduceOnly to EXIT orders (some venues reject it alongside entries)
|
||||||
|
GRID_EXCHANGE_STOP_ENABLED=true # Keep an exchange-side STOP_MARKET backstop (aster/binance/grvt/ondoperps)
|
||||||
|
GRID_RECONCILE_INTERVAL_MS=30000 # Periodic REST reconcile cadence when the venue supports order queries
|
||||||
|
GRID_UNCOVERED_GRACE_MS=5000 # Grace before the coverage audit acts on uncovered position
|
||||||
# GRID_PRICE_TICK=0.1 # Optional override for grid price tick (falls back to PRICE_TICK)
|
# 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)
|
# GRID_QTY_STEP=0.001 # Optional override for grid quantity step (falls back to QTY_STEP)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
|
|
||||||
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
|
如果您希望获取优惠并支持本项目,请考虑使用以下注册链接:
|
||||||
|
|
||||||
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=111909FA)
|
* [Lighter 手续费优惠注册链接](https://app.lighter.xyz/?referral=RITMEX)
|
||||||
* [Hyperliquid 邀请注册链接](https://app.hyperliquid.xyz/join/RITMEX)
|
* [Hyperliquid 邀请注册链接](https://app.hyperliquid.xyz/join/RITMEX)
|
||||||
* [Ondo Perps 邀请注册链接](https://app.ondoperps.xyz/?ref=4A3ACQ)
|
* [Ondo Perps 邀请注册链接](https://app.ondoperps.xyz/?ref=4A3ACQ)
|
||||||
* [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
|
* [Aster 手续费优惠注册链接](https://www.asterdex.com/zh-CN/referral/4665f3)
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ A Bun-powered multi-exchange perpetuals workstation that ships an SMA30 trend en
|
|||||||
|
|
||||||
If you'd like to support this project and get fee discounts, please consider using these referral links:
|
If you'd like to support this project and get fee discounts, please consider using these referral links:
|
||||||
|
|
||||||
* [Lighter referral link](https://app.lighter.xyz/?referral=111909FA)
|
* [Lighter referral link](https://app.lighter.xyz/?referral=RITMEX)
|
||||||
* [Hyperliquid referral link](https://app.hyperliquid.xyz/join/RITMEX)
|
* [Hyperliquid referral link](https://app.hyperliquid.xyz/join/RITMEX)
|
||||||
* [Ondo Perps referral link](https://app.ondoperps.xyz/?ref=4A3ACQ)
|
* [Ondo Perps referral link](https://app.ondoperps.xyz/?ref=4A3ACQ)
|
||||||
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
* [Aster referral link](https://www.asterdex.com/en/referral/4665f3)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.9",
|
"@types/bun": "^1.3.9",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"oxlint": "^1.54.0",
|
"oxlint": "^1.54.0",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
},
|
},
|
||||||
@@ -208,6 +210,10 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
|
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||||
|
|
||||||
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
|
||||||
|
|
||||||
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
|
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
|
||||||
@@ -260,6 +266,8 @@
|
|||||||
|
|
||||||
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
"convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||||
|
|||||||
+131
-54
@@ -1,21 +1,30 @@
|
|||||||
# 网格交易策略使用教程
|
# 网格交易策略使用教程
|
||||||
|
|
||||||
本文介绍如何在 Ritmex Bot 中使用全新的网格交易策略。我们将以 ASTERUSDT 永续合约为例,演示从环境配置到运行监控的完整流程,并对关键参数、风控机制、常见问题做出说明。
|
本文介绍如何在 Ritmex Bot 中使用网格交易策略。当前版本的网格引擎围绕「每线状态机 + 开/平仓意图自治 + 断点恢复」重新设计,支持三种交易模式、四层止损防护与智能跟随移格。本文覆盖从环境配置到运行监控的完整流程,并详细说明各项机制与参数。
|
||||||
|
|
||||||
## 环境配置
|
## 核心概念
|
||||||
|
|
||||||
1. 复制 `.env.example` 到 `.env`
|
在阅读参数前,先了解四个核心概念:
|
||||||
|
|
||||||
|
- **网格线(Level)**:策略在 `GRID_LOWER_PRICE` ~ `GRID_UPPER_PRICE` 之间按几何等比铺设 `GRID_LEVELS` 条价格线。每条线有独立的生命周期:`idle →(挂开仓单)entry_placed →(成交)holding →(挂平仓单)exit_placed →(平仓成交)idle`。
|
||||||
|
- **开仓单(ENTRY)与平仓单(EXIT)**:策略自身通过订单登记表 + clientOrderId 编码(`grid-{网格版本}-E-…` / `grid-{网格版本}-X-…`)+ 价档匹配三级机制区分每笔挂单的意图,不依赖交易所回传的 reduceOnly 标志。
|
||||||
|
- **相邻线配对**:每条线的平仓目标固定为相邻线(多单在上一条线卖出、空单在下一条线买回),每格利润恒等于一格间距。
|
||||||
|
- **锚定价(Anchor)**:中性模式启动时以首笔行情价为分界线,上半区挂空、下半区挂多。锚定价持久化到磁盘,重启后沿用,避免价格漂移导致半区与已有持仓错位。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. 复制 `.env.example` 到 `.env`:
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
2. 配置 Aster 交易所 API:
|
2. 配置交易所 API(以 Aster 为例):
|
||||||
```env
|
```env
|
||||||
EXCHANGE=aster
|
EXCHANGE=aster
|
||||||
ASTER_API_KEY=你的API密钥
|
ASTER_API_KEY=你的API密钥
|
||||||
ASTER_API_SECRET=你的API密钥
|
ASTER_API_SECRET=你的API密钥
|
||||||
TRADE_SYMBOL=ASTERUSDT
|
TRADE_SYMBOL=ASTERUSDT
|
||||||
```
|
```
|
||||||
3. 设置基础精度与网格参数(示例使用 1.50 ~ 2.50 区间,20 条网格,单笔 5 手,最大仓位 50 手):
|
3. 设置精度与网格参数(示例:1.50 ~ 2.50 区间、20 条网格、单笔 5 手、单侧最大 50 手):
|
||||||
```env
|
```env
|
||||||
PRICE_TICK=0.0001
|
PRICE_TICK=0.0001
|
||||||
QTY_STEP=0.01
|
QTY_STEP=0.01
|
||||||
@@ -25,76 +34,144 @@
|
|||||||
GRID_LEVELS=20
|
GRID_LEVELS=20
|
||||||
GRID_ORDER_SIZE=5
|
GRID_ORDER_SIZE=5
|
||||||
GRID_MAX_POSITION_SIZE=50
|
GRID_MAX_POSITION_SIZE=50
|
||||||
GRID_REFRESH_INTERVAL_MS=1000
|
|
||||||
GRID_MAX_LOG_ENTRIES=200
|
|
||||||
GRID_DIRECTION=both
|
GRID_DIRECTION=both
|
||||||
GRID_STOP_LOSS_PCT=0.02
|
GRID_STOP_LOSS_PCT=0.02
|
||||||
GRID_RESTART_TRIGGER_PCT=0.02
|
```
|
||||||
GRID_AUTO_RESTART_ENABLED=true
|
4. 启动:
|
||||||
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05
|
```bash
|
||||||
```
|
bun install
|
||||||
|
bun run index.ts --strategy grid --exchange aster
|
||||||
|
```
|
||||||
|
或运行 `bun start` 后在菜单选择「基础网格策略」。
|
||||||
|
|
||||||
- `GRID_ORDER_SIZE` 与 `GRID_MAX_POSITION_SIZE` 需遵循「最大仓位 ÷ 单笔数量 ≥ 网格数」的原则,这样策略才能补齐全部挂单。本例 50 ÷ 5 = 10,但网格数为 20,意味着策略只会在离现价最近的上下各 10 个位置挂单,与仓位上限保持一致。
|
建议先用 dry-run 模拟运行(连接真实行情但不真正下单,验证建格方向与参数):
|
||||||
|
```bash
|
||||||
|
ritmex-bot strategy run --strategy grid --dry-run
|
||||||
|
# 或未全局安装时:bunx ritmex-bot strategy run --strategy grid --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
## 网格机制概览
|
## 参数总表
|
||||||
|
|
||||||
- **几何等比网格**:所有网格价格基于上下边界按等比方式分布。
|
### 基础参数
|
||||||
- **基于现价的挂单排序**:重启或行情驱动时,会优先在现价附近补挂,避免远端挂单未成交。
|
|
||||||
- **双向模式**:`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` 是总持仓上限,用于控制网格在极端走势中不会累积过量仓位。
|
|
||||||
|
|
||||||
## 运行命令
|
| 环境变量 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GRID_LOWER_PRICE` / `GRID_UPPER_PRICE` | 必填 | 网格上下边界(计价货币) |
|
||||||
|
| `GRID_LEVELS` | 10 | 网格线数量(≥2),几何等比分布 |
|
||||||
|
| `GRID_ORDER_SIZE` | TRADE_AMOUNT | 每条线的下单数量(标的资产) |
|
||||||
|
| `GRID_MAX_POSITION_SIZE` | orderSize×(levels−1) | 单方向最大持仓(中性模式下多、空两侧分别约束) |
|
||||||
|
| `GRID_DIRECTION` | both | `long`(只做多)/ `short`(只做空)/ `both`(中性双向) |
|
||||||
|
| `GRID_REFRESH_INTERVAL_MS` | 1000 | 引擎轮询间隔,每轮最多新挂 1 笔限价单 |
|
||||||
|
| `GRID_PRICE_TICK` / `GRID_QTY_STEP` | PRICE_TICK / QTY_STEP | 价格与数量精度;交易所支持时会自动同步为交易所精度 |
|
||||||
|
|
||||||
安装依赖后,使用 CLI 直接启动网格策略:
|
### 风控参数
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
bun run index.ts --strategy grid --exchange aster
|
|
||||||
```
|
|
||||||
|
|
||||||
若要在 Ink Dashboard 中运行并交互,直接执行:
|
| 环境变量 | 默认值 | 说明 |
|
||||||
```bash
|
|---|---|---|
|
||||||
bun start
|
| `GRID_STOP_LOSS_PCT` | 0.01 | 价格越过边界该比例后触发止损(层①),同时决定兜底止损单触发价 |
|
||||||
```
|
| `GRID_MAX_CLOSE_SLIPPAGE_PCT` | 0.05 | 所有市价平仓路径的滑点守卫:盘口价偏离标记价超过该比例时暂缓平仓、下轮重试 |
|
||||||
然后在菜单中选择 “基础网格策略”。
|
| `GRID_UNCOVERED_GRACE_MS` | 5000 | 覆盖审计(层②)的宽限期:持仓未被平仓单覆盖持续超过该时长才处置 |
|
||||||
|
| `GRID_EXCHANGE_STOP_ENABLED` | true | 在支持触发单的交易所(aster / binance / grvt / ondoperps)额外挂交易所侧 STOP_MARKET 兜底单(层④) |
|
||||||
|
| `GRID_AUTO_RESTART_ENABLED` | true | 止损停机后价格回到区间内自动重启网格 |
|
||||||
|
| `GRID_RESTART_TRIGGER_PCT` | 0.01 | 自动重启要求价格回到边界内该比例的缓冲区 |
|
||||||
|
|
||||||
## 监控与调优
|
### 智能移格参数
|
||||||
|
|
||||||
界面主要包括:
|
| 环境变量 | 默认值 | 说明 |
|
||||||
- 当前买一/卖一、开仓方向、挂单/持仓概况。
|
|---|---|---|
|
||||||
- 最近日志(订单状态、风控触发等)。
|
| `GRID_SHIFT_ENABLED` | false | 开启后价格偏离锚定价超阈值时整体移格 |
|
||||||
- 触发止损后会清空网格并记录原因。
|
| `GRID_SHIFT_TRIGGER_PCT` | 0.05 | 移格触发阈值:\|现价/锚定价 − 1\| |
|
||||||
|
| `GRID_SHIFT_CONFIRM_MS` | 3000 | 偏离需持续该时长才触发(防插针) |
|
||||||
|
| `GRID_SHIFT_RANGE_PCT` | 0.05 | 移格后新区间 = 新锚定价 × (1 ± 该比例) |
|
||||||
|
|
||||||
调参建议:
|
### 高级参数
|
||||||
1. **缩短区间**:想拉高单格盈利,可缩小上下边界并减少网格数。
|
|
||||||
2. **更精细挂单**:适当提高 `GRID_LEVELS` 并降低 `GRID_ORDER_SIZE`,但同时记得调大 `GRID_MAX_POSITION_SIZE`。
|
|
||||||
3. **调节平仓容忍度**:`GRID_MAX_CLOSE_SLIPPAGE_PCT` 控制平仓单相对标记价的最大偏移,确保 reduce-only 订单不会被交易所拒绝。
|
|
||||||
4. **只做单边**:若只想高抛低吸不反手,可设 `GRID_DIRECTION=long`,卖单会变成 `reduceOnly`。
|
|
||||||
|
|
||||||
## 中断恢复行为
|
| 环境变量 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GRID_USE_REDUCE_ONLY` | false | 平仓单是否携带 reduceOnly。默认不带(部分交易所会拒绝与反向挂单共存的 reduce-only 限价单);策略靠意图登记自治区分开/平仓,无需此标志 |
|
||||||
|
| `GRID_RECONCILE_INTERVAL_MS` | 30000 | 支持 REST 查单的交易所的周期对账间隔 |
|
||||||
|
| `GRID_DATA_DIR` | ./data | 状态持久化目录(`grid-record.json`) |
|
||||||
|
|
||||||
策略重启后会:
|
## 三种交易模式
|
||||||
- 重新订阅账户、订单、深度、ticker;
|
|
||||||
- 基于当前持仓和开放订单重新计算网格,只补挂缺失部分;
|
|
||||||
- 在仓位额度允许的情况下持续追踪价位。
|
|
||||||
|
|
||||||
因此就算进程断掉,只要交易所回放的账号/订单快照完整,网格会从中断前的状态继续运行。若停机前手动撤过单,新启动时系统会把不在网格计划中的挂单一并清理。
|
`GRID_DIRECTION` 决定每条线的角色:
|
||||||
|
|
||||||
|
| 模式 | 开仓线 | 开仓方向 | 平仓目标 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `long` | 除最顶线外全部 | BUY | 上一条线(SELL) |
|
||||||
|
| `short` | 除最底线外全部 | SELL | 下一条线(BUY) |
|
||||||
|
| `both`(中性) | 锚定价下方 BUY / 上方 SELL | 按半区 | BUY→上一条线 / SELL→下一条线 |
|
||||||
|
|
||||||
|
- **long**:只在现价下方挂买单,买入成交后在相邻上方线挂卖单止盈。价格上行时逐格落袋,下行时逐格接多。
|
||||||
|
- **short**:镜像逻辑,只在现价上方挂卖单,成交后在相邻下方线买回。
|
||||||
|
- **both(中性)**:以启动时的锚定价分界。价格向上穿越上半区某条线时,会同时发生「下方多单的止盈卖出」与「该线自身的空头开仓」——两笔同价卖单并存是中性网格的正常形态。
|
||||||
|
|
||||||
|
## 挂单与仓位规则
|
||||||
|
|
||||||
|
- **每线一单**:只有 `idle` 状态的线才允许挂开仓单。线在 `holding` / `exit_placed` 期间,价格反复穿越也不会重复开仓,直到平仓单成交释放该线。
|
||||||
|
- **就近优先**:开仓单按与现价的距离排序,每轮只补挂 1 笔,逐步铺满。
|
||||||
|
- **仓位上限**:每次开仓前计算 `剩余额度 = GRID_MAX_POSITION_SIZE − |同方向净仓| − 同方向在途开仓挂单量`,额度不足时跳过该线。中性模式下多空两侧分别计算。
|
||||||
|
- **平仓优先**:每轮规划先补挂缺失的平仓单,再考虑开仓单。
|
||||||
|
|
||||||
|
## 持久化与中断恢复
|
||||||
|
|
||||||
|
策略状态实时落盘到 `data/grid-record.json`(schema v2,旧版 v1 文件自动迁移),内容包括:网格版本、锚定价、区间边界、每条线的状态与持仓量、每笔挂单的意图登记、移格进度、兜底止损单。
|
||||||
|
|
||||||
|
- **下单前写前日志(write-ahead)**:每笔限价单在发出前先落盘 inflight 槽位,交易所接单后立即登记订单号并再次落盘,消灭「交易所已接单、本地未记录」的崩溃窗口。
|
||||||
|
- **重启恢复**:启动时读取磁盘状态(要求配置指纹一致:方向 / 单笔数量 / 网格数 / 网格模式 / 交易对 / 交易所;**区间边界以磁盘为准**,移格后可能与 env 不同),然后执行三方对账:磁盘登记 ↔ 交易所挂单 ↔ 实际仓位。挂单按订单号 → clientOrderId → 价档三级匹配归位;无法归属的挂单中,平仓方向的收编为「孤儿平仓单」继续保护仓位,其余撤销;仓位差额归档到最近的线(每线不超过单笔数量),归不完的残余交给覆盖审计立即处置。
|
||||||
|
- **配置变更**:修改方向、网格数、单笔数量等指纹字段后重启会放弃旧状态、全新建格,并对现场执行孤儿扫描(撤掉旧挂单、按新网格归档仓位)。
|
||||||
|
- **断线重连**:支持连接事件的交易所(standx / ondoperps / binance)断连时冻结新下单,重连后用 REST 查单 + 查仓走同一套对账逻辑;支持 REST 查单的交易所另有周期对账兜底(`GRID_RECONCILE_INTERVAL_MS`)。其余交易所依赖网关自动重连 + 订单流差分判定,并有「下单后订单流长时间无反映则暂停新下单」的陈旧性守卫。
|
||||||
|
|
||||||
|
## 多重止损(四层防护)
|
||||||
|
|
||||||
|
1. **层① 价格越界**:现价 ≤ 下界×(1−stopLossPct) 或 ≥ 上界×(1+stopLossPct) 时,撤销全部挂单 → 市价平掉全部持仓(受滑点守卫保护,被拦截时下轮重试)→ 清空状态停机。开启移格时越界优先走移格,层①兜移格禁用或移格中再次越界的场景。
|
||||||
|
2. **层② 持仓覆盖审计**:每轮核对 `未覆盖仓位 = |净仓| − 活跃平仓挂单量 − 待挂平仓的线上持仓`。未覆盖持续超过 `GRID_UNCOVERED_GRACE_MS` 时:价格仍在区间内且浮亏未超限 → 在最近的可盈利线补挂平仓单;价格已出区间或浮亏超过 stopLossPct → 未覆盖部分直接市价平掉。
|
||||||
|
3. **层③ 恢复期孤儿扫描**:重启/重连对账后无法归档到任何线的残余仓位,跳过宽限期立即按层②处置;恢复完成前不开新仓。
|
||||||
|
4. **层④ 交易所侧兜底单**:在支持触发单的交易所(aster / binance / grvt / ondoperps),净多时挂 SELL STOP_MARKET @ 下界×(1−stopLossPct),净空时挂 BUY STOP_MARKET @ 上界×(1+stopLossPct)。即使机器人进程死亡,交易所也会在极端行情中兜底平仓。方向变化、触发价偏移或订单消失时自动重挂,仓位归零时自动撤销。
|
||||||
|
|
||||||
|
## 智能跟随网格(移格)
|
||||||
|
|
||||||
|
开启 `GRID_SHIFT_ENABLED=true` 后,价格偏离锚定价超过 `GRID_SHIFT_TRIGGER_PCT` 且持续 `GRID_SHIFT_CONFIRM_MS`,策略执行三阶段移格:
|
||||||
|
|
||||||
|
1. **cancelling**:撤销全部挂单(含兜底止损单);
|
||||||
|
2. **closing**:市价平掉全部持仓(受滑点守卫保护);
|
||||||
|
3. **rebuilding**:以当前价为新锚定价,新区间 = 锚定价 × (1 ± `GRID_SHIFT_RANGE_PCT`),网格版本 +1,全部线重置后重新铺网。
|
||||||
|
|
||||||
|
每个阶段进度都持久化,进程在任一阶段崩溃后重启会从记录的阶段续跑。移格期间冻结开仓,层①②止损照常生效。移格会实现当前浮动盈亏——趋势行情中这意味着接受每次移格的亏损换取网格持续贴近现价,请结合波动性谨慎开启。
|
||||||
|
|
||||||
|
## 监控界面
|
||||||
|
|
||||||
|
Ink 仪表盘除价格与区间外,新增以下信息:
|
||||||
|
|
||||||
|
- **锚定价 / 网格版本**:`v1` 起步,每次移格或重启重建 +1;
|
||||||
|
- **移格状态**:移格进行中显示当前阶段(cancelling / closing / rebuilding);
|
||||||
|
- **止损防护行**:实时显示未覆盖仓位数量与交易所兜底止损单(方向 @ 触发价);
|
||||||
|
- **网格线表**:每条线的价格、方向(BUY / SELL / `-` 表示不开仓线)、状态(idle / entry_placed / holding / exit_placed)、是否有活跃挂单、线上持仓量。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
### Q: 为什么只有靠近现价的几个网格有订单?
|
### Q: 为什么启动后不是一次性挂满所有网格单?
|
||||||
A: 每笔网格单都会占用一定仓位上限。当 `GRID_MAX_POSITION_SIZE / GRID_ORDER_SIZE < GRID_LEVELS` 时,只会展示足以满足仓位限制的那几条网格。调整任一参数即可扩大覆盖面。
|
A: 引擎每轮最多新挂 1 笔限价单(按离现价由近到远),既控制请求频率也便于逐单登记意图。以默认 1 秒轮询计,20 条网格约 1 分钟内铺满。
|
||||||
|
|
||||||
### Q: 价格突破上界后为何立即平仓?
|
### Q: 为什么同一价位出现两笔同向挂单?
|
||||||
A: 这是止损保护触发,避免庄外行情继续拉扯,默认 2% 触发后网格会全部撤单,并用市价平掉现有仓位。
|
A: 中性模式的正常形态:一笔是下方线的止盈平仓单,另一笔是该线自身的空头开仓单。价格穿越时两笔都成交,等于「平多 + 开空」。策略内部按意图分别登记,不会混淆。
|
||||||
|
|
||||||
|
### Q: 平仓单为什么不带 reduceOnly?
|
||||||
|
A: 部分交易所会拒绝与反向挂单共存的 reduce-only 限价单。策略通过自身的意图登记区分开/平仓,不需要该标志。若你的交易所支持且希望强制,只需设 `GRID_USE_REDUCE_ONLY=true`。
|
||||||
|
|
||||||
|
### Q: 修改了参数重启后旧挂单怎么办?
|
||||||
|
A: 若改的是配置指纹字段(方向 / 网格数 / 单笔数量等),策略会全新建格并撤销所有无法归属的旧挂单;平仓方向的旧挂单会被保留为孤儿平仓单继续保护仓位。若只是重启未改参数,挂单和线状态原样恢复,不重复挂单。
|
||||||
|
|
||||||
### Q: 想要手动调仓怎么办?
|
### Q: 想要手动调仓怎么办?
|
||||||
A: 暂停策略(Ctrl+C 或 dashboard 退出)后手动操作,完成后再启动,策略会以新的仓位/挂单为基准重新布网。
|
A: 停止策略后手动操作,再启动即可——对账机制会以新的仓位/挂单为基准归档:手动加的仓归到最近的线,手动挂的平仓方向订单被收编,其余手动挂单被撤销。若想彻底重来,删除 `data/grid-record.json` 后重启。
|
||||||
|
|
||||||
## 小结
|
### Q: 价格突破边界后发生了什么?
|
||||||
|
A: 依次发生:交易所侧兜底止损单先行触发(若启用且进程已死);进程存活时层①撤单并市价平仓后停机;若 `GRID_AUTO_RESTART_ENABLED=true`,价格回到边界内缓冲区后自动以当前价重新建格。开启移格时则优先整体平移网格而不是停机。
|
||||||
|
|
||||||
通过上述配置,你就可以在 ASTERUSDT 合约上运行一个自动化的等比网格策略。请务必先在沙盒或小仓位测试,确保参数适应当前波动性和手续费结构,再逐步提升资金规模。
|
## 风险提示
|
||||||
|
|
||||||
|
- 网格策略在震荡行情中赚取格差,在单边行情中会累积逆势仓位。`GRID_MAX_POSITION_SIZE` 与 `GRID_STOP_LOSS_PCT` 是最重要的两道闸门,务必按可承受亏损设置。
|
||||||
|
- 移格功能会在每次移格时实现浮亏,等于把「区间失效」的损失分期支付,并不消除趋势风险。
|
||||||
|
- 请先用 `ritmex-bot strategy run --strategy grid --dry-run` 或小仓位验证参数与手续费结构,再逐步放大资金规模。
|
||||||
|
|
||||||
祝交易顺利!
|
祝交易顺利!
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"start": "bun run index.ts",
|
"start": "bun run index.ts",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"lint:fix": "oxlint --fix",
|
"lint:fix": "oxlint --fix",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "bun x vitest run",
|
"test": "bun x vitest run",
|
||||||
"test:exchange-contract": "bun x vitest run tests/exchange-contract-suite.test.ts tests/exchange-factory.test.ts tests/config.test.ts",
|
"test:exchange-contract": "bun x vitest run tests/exchange-contract-suite.test.ts tests/exchange-factory.test.ts tests/config.test.ts",
|
||||||
"test:watch": "bun x vitest",
|
"test:watch": "bun x vitest",
|
||||||
@@ -29,6 +30,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.9",
|
"@types/bun": "^1.3.9",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"oxlint": "^1.54.0",
|
"oxlint": "^1.54.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr";
|
|
||||||
import { bytesToHex } from "../src/exchanges/lighter/bytes";
|
|
||||||
|
|
||||||
function derive(keyHex: string): string {
|
|
||||||
const normalized = keyHex.startsWith("0x") ? keyHex.slice(2) : keyHex;
|
|
||||||
const key = LighterPrivateKey.fromHex(normalized);
|
|
||||||
return bytesToHex(key.publicKey().toBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = process.argv[2];
|
|
||||||
if (!input) {
|
|
||||||
console.error("usage: bun run scripts/derive-public.ts <hex>");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(derive(input));
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import "dotenv/config";
|
|
||||||
import { LighterPrivateKey } from "../src/exchanges/lighter/crypto/schnorr";
|
|
||||||
import { bytesToHex } from "../src/exchanges/lighter/bytes";
|
|
||||||
|
|
||||||
function main(): void {
|
|
||||||
const raw = process.env.LIGHTER_API_PRIVATE_KEY;
|
|
||||||
if (!raw) {
|
|
||||||
throw new Error("LIGHTER_API_PRIVATE_KEY env var is required");
|
|
||||||
}
|
|
||||||
const normalized = raw.startsWith("0x") ? raw.slice(2) : raw;
|
|
||||||
const key = LighterPrivateKey.fromHex(normalized);
|
|
||||||
const publicKeyHex = bytesToHex(key.publicKey().toBytes());
|
|
||||||
const apiKeyIndex = process.env.LIGHTER_API_KEY_INDEX ?? "(not set)";
|
|
||||||
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
apiKeyIndex,
|
|
||||||
publicKeyHex,
|
|
||||||
}, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
+7
-24
@@ -1,6 +1,7 @@
|
|||||||
import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "../exchanges/create-adapter";
|
import { SUPPORTED_EXCHANGE_IDS, type SupportedExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { STRATEGY_IDS, parseStrategyId, type StrategyId } from "../strategy/strategy-ids";
|
||||||
|
|
||||||
export type StrategyId = "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
export type { StrategyId };
|
||||||
|
|
||||||
export interface CliOptions {
|
export interface CliOptions {
|
||||||
strategy?: StrategyId;
|
strategy?: StrategyId;
|
||||||
@@ -9,18 +10,6 @@ export interface CliOptions {
|
|||||||
exchange?: SupportedExchangeId;
|
exchange?: SupportedExchangeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STRATEGY_VALUES = new Set<StrategyId>([
|
|
||||||
"trend",
|
|
||||||
"swing",
|
|
||||||
"guardian",
|
|
||||||
"maker",
|
|
||||||
"maker-points",
|
|
||||||
"offset-maker",
|
|
||||||
"liquidity-maker",
|
|
||||||
"basis",
|
|
||||||
"grid",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
|
export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions {
|
||||||
const options: CliOptions = { silent: false, help: false };
|
const options: CliOptions = { silent: false, help: false };
|
||||||
|
|
||||||
@@ -68,16 +57,9 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assignStrategy(options: CliOptions, raw: string): void {
|
function assignStrategy(options: CliOptions, raw: string): void {
|
||||||
const normalized = raw.trim().toLowerCase();
|
const strategy = parseStrategyId(raw);
|
||||||
if (!normalized) return;
|
if (strategy) {
|
||||||
if (STRATEGY_VALUES.has(normalized as StrategyId)) {
|
options.strategy = strategy;
|
||||||
options.strategy = normalized as StrategyId;
|
|
||||||
} else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") {
|
|
||||||
options.strategy = "offset-maker";
|
|
||||||
} else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") {
|
|
||||||
options.strategy = "maker-points";
|
|
||||||
} else if (normalized === "liquidity" || normalized === "liquiditymaker" || normalized === "liquidity-maker" || normalized === "liquidity_maker") {
|
|
||||||
options.strategy = "liquidity-maker";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +77,9 @@ function assignExchange(options: CliOptions, raw: string): void {
|
|||||||
|
|
||||||
export function printCliHelp(): void {
|
export function printCliHelp(): void {
|
||||||
const exchangeList = SUPPORTED_EXCHANGE_IDS.join("|");
|
const exchangeList = SUPPORTED_EXCHANGE_IDS.join("|");
|
||||||
|
const strategyList = STRATEGY_IDS.join("|");
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Usage: bun run index.ts [--strategy <trend|swing|guardian|maker|maker-points|offset-maker|liquidity-maker|basis|grid>] [--exchange <${exchangeList}>] [--silent]\n\n` +
|
console.log(`Usage: bun run index.ts [--strategy <${strategyList}>] [--exchange <${exchangeList}>] [--silent]\n\n` +
|
||||||
`Options:\n` +
|
`Options:\n` +
|
||||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||||
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ function resolveEffectiveSymbol(explicit: string | undefined, exchange: Supporte
|
|||||||
function runtimeCapabilities(adapter: ExchangeAdapter): unknown {
|
function runtimeCapabilities(adapter: ExchangeAdapter): unknown {
|
||||||
return {
|
return {
|
||||||
trailingStops: adapter.supportsTrailingStops(),
|
trailingStops: adapter.supportsTrailingStops(),
|
||||||
|
triggerOrders: adapter.supportsTriggerOrders?.() ?? false,
|
||||||
fundingRate: typeof adapter.watchFundingRate === "function",
|
fundingRate: typeof adapter.watchFundingRate === "function",
|
||||||
precision: typeof adapter.getPrecision === "function",
|
precision: typeof adapter.getPrecision === "function",
|
||||||
queryOpenOrders: typeof adapter.queryOpenOrders === "function",
|
queryOpenOrders: typeof adapter.queryOpenOrders === "function",
|
||||||
|
|||||||
+55
-227
@@ -1,224 +1,58 @@
|
|||||||
import { basisConfig, gridConfig, isBasisStrategyEnabled, liquidityMakerConfig, makerConfig, makerPointsConfig, swingConfig, tradingConfig } from "../config";
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
import { DryRunExchangeAdapter } from "../exchanges/dry-run-adapter";
|
import { DryRunExchangeAdapter } from "../exchanges/dry-run-adapter";
|
||||||
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
import {
|
||||||
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
getStrategyDefinition,
|
||||||
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
strategyUnavailableReason,
|
||||||
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
STRATEGY_DEFINITIONS,
|
||||||
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
type StrategyEngine,
|
||||||
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
|
type StrategySnapshot,
|
||||||
import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
} from "../strategy/registry";
|
||||||
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
|
||||||
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
|
|
||||||
import { extractMessage } from "../utils/errors";
|
import { extractMessage } from "../utils/errors";
|
||||||
import type { StrategyId } from "./args";
|
import type { StrategyId } from "../strategy/strategy-ids";
|
||||||
|
|
||||||
interface RunnerOptions {
|
interface RunnerOptions {
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StrategyRunner = (options: RunnerOptions) => Promise<void>;
|
export const STRATEGY_LABELS = Object.fromEntries(
|
||||||
|
STRATEGY_DEFINITIONS.map((definition) => [definition.id, definition.consoleLabel])
|
||||||
export const STRATEGY_LABELS: Record<StrategyId, string> = {
|
) as Record<StrategyId, string>;
|
||||||
trend: "Trend Following",
|
|
||||||
swing: "Swing",
|
|
||||||
guardian: "Guardian",
|
|
||||||
maker: "Maker",
|
|
||||||
"maker-points": "Maker Points",
|
|
||||||
"offset-maker": "Offset Maker",
|
|
||||||
"liquidity-maker": "Liquidity Maker",
|
|
||||||
basis: "Basis Arbitrage",
|
|
||||||
grid: "Grid",
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
|
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
|
||||||
const runner = STRATEGY_FACTORIES[strategyId];
|
const definition = getStrategyDefinition(strategyId);
|
||||||
if (!runner) {
|
if (!definition) {
|
||||||
throw new Error(`Unsupported strategy: ${strategyId}`);
|
throw new Error(`Unsupported strategy: ${strategyId}`);
|
||||||
}
|
}
|
||||||
await runner(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
|
||||||
trend: async (opts) => {
|
|
||||||
const config = tradingConfig;
|
|
||||||
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
|
||||||
const engine = new TrendEngine(config, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "trend",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
getSnapshot: () => engine.getSnapshot(),
|
|
||||||
onUpdate: (emitter) => engine.on("update", emitter),
|
|
||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
swing: async (opts) => {
|
|
||||||
const config = swingConfig;
|
|
||||||
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
|
||||||
const engine = new SwingEngine(config, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "swing",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
getSnapshot: () => engine.getSnapshot(),
|
|
||||||
onUpdate: (emitter) => engine.on("update", emitter),
|
|
||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
guardian: async (opts) => {
|
|
||||||
const config = tradingConfig;
|
|
||||||
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
|
||||||
const engine = new GuardianEngine(config, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "guardian",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
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, opts.dryRun);
|
|
||||||
const engine = new MakerEngine(config, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "maker",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
getSnapshot: () => engine.getSnapshot(),
|
|
||||||
onUpdate: (emitter) => engine.on("update", emitter),
|
|
||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
"maker-points": async (opts) => {
|
|
||||||
const exchangeId = resolveExchangeId();
|
|
||||||
if (exchangeId !== "standx") {
|
|
||||||
throw new Error("Maker Points strategy only supports the StandX exchange.");
|
|
||||||
}
|
|
||||||
const config = makerPointsConfig;
|
|
||||||
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
|
||||||
const engine = new MakerPointsEngine(config, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "maker-points",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
getSnapshot: () => engine.getSnapshot(),
|
|
||||||
onUpdate: (emitter) => engine.on("update", emitter),
|
|
||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
"offset-maker": async (opts) => {
|
|
||||||
const config = makerConfig;
|
|
||||||
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
|
||||||
const engine = new OffsetMakerEngine(config, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "offset-maker",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
getSnapshot: () => engine.getSnapshot(),
|
|
||||||
onUpdate: (emitter) => engine.on("update", emitter),
|
|
||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
"liquidity-maker": async (opts) => {
|
|
||||||
const config = liquidityMakerConfig;
|
|
||||||
const adapter = createAdapterOrThrow(config.symbol, opts.dryRun);
|
|
||||||
const engine = new LiquidityMakerEngine(config, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "liquidity-maker",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
getSnapshot: () => engine.getSnapshot(),
|
|
||||||
onUpdate: (emitter) => engine.on("update", emitter),
|
|
||||||
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 (!isBasisSupportedExchangeId(exchangeId)) {
|
|
||||||
throw new Error("Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges");
|
|
||||||
}
|
|
||||||
const adapter = createAdapterOrThrow(basisConfig.futuresSymbol, opts.dryRun);
|
|
||||||
const engine = new BasisArbEngine(basisConfig, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "basis",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
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, opts.dryRun);
|
|
||||||
const engine = new GridEngine(config, adapter);
|
|
||||||
await runEngine({
|
|
||||||
engine,
|
|
||||||
strategy: "grid",
|
|
||||||
silent: opts.silent,
|
|
||||||
dryRun: opts.dryRun,
|
|
||||||
getSnapshot: () => engine.getSnapshot(),
|
|
||||||
onUpdate: (emitter) => engine.on("update", emitter),
|
|
||||||
offUpdate: (emitter) => engine.off("update", emitter),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EngineHarness<TSnapshot> {
|
|
||||||
engine: { start(): void; stop(): void };
|
|
||||||
strategy: StrategyId;
|
|
||||||
silent?: boolean;
|
|
||||||
dryRun?: boolean;
|
|
||||||
getSnapshot: () => TSnapshot;
|
|
||||||
onUpdate: (handler: (snapshot: TSnapshot) => void) => void;
|
|
||||||
offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runEngine<
|
|
||||||
TSnapshot extends
|
|
||||||
| TrendEngineSnapshot
|
|
||||||
| SwingEngineSnapshot
|
|
||||||
| GuardianEngineSnapshot
|
|
||||||
| MakerEngineSnapshot
|
|
||||||
| MakerPointsSnapshot
|
|
||||||
| OffsetMakerEngineSnapshot
|
|
||||||
| LiquidityMakerEngineSnapshot
|
|
||||||
| BasisArbSnapshot
|
|
||||||
| GridEngineSnapshot
|
|
||||||
>(
|
|
||||||
harness: EngineHarness<TSnapshot>
|
|
||||||
): Promise<void> {
|
|
||||||
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
|
|
||||||
const exchangeId = resolveExchangeId();
|
const exchangeId = resolveExchangeId();
|
||||||
const exchangeName = getExchangeDisplayName(exchangeId);
|
const blocked = strategyUnavailableReason(strategyId, exchangeId);
|
||||||
const label = STRATEGY_LABELS[strategy];
|
if (blocked) {
|
||||||
|
throw new Error(blocked);
|
||||||
const initial = getSnapshot();
|
|
||||||
let lastLogKey: string | undefined;
|
|
||||||
if (Array.isArray(initial.tradeLog) && initial.tradeLog.length > 0) {
|
|
||||||
const lastEntry = initial.tradeLog[initial.tradeLog.length - 1]!;
|
|
||||||
lastLogKey = createLogKey(lastEntry);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adapter = createAdapterOrThrow(definition.symbol(), options.dryRun);
|
||||||
|
const engine = definition.createEngine(adapter);
|
||||||
|
await runEngine(engine, definition.consoleLabel, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams an engine's trade log to the console until SIGINT/SIGTERM, then stops it.
|
||||||
|
* Depends only on the StrategyEngine contract, so a new strategy needs no change here.
|
||||||
|
*/
|
||||||
|
async function runEngine(
|
||||||
|
engine: StrategyEngine,
|
||||||
|
label: string,
|
||||||
|
options: RunnerOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const exchangeName = getExchangeDisplayName(resolveExchangeId());
|
||||||
|
|
||||||
|
const initial = engine.getSnapshot();
|
||||||
|
let lastLogKey = lastKeyOf(initial.tradeLog);
|
||||||
let readyLogged = initial.ready === true;
|
let readyLogged = initial.ready === true;
|
||||||
|
|
||||||
const emitter = (snapshot: TSnapshot) => {
|
const emitter = (snapshot: StrategySnapshot) => {
|
||||||
if (!Array.isArray(snapshot.tradeLog)) return;
|
if (!Array.isArray(snapshot.tradeLog)) return;
|
||||||
if (!readyLogged && snapshot.ready) {
|
if (!readyLogged && snapshot.ready) {
|
||||||
readyLogged = true;
|
readyLogged = true;
|
||||||
@@ -229,31 +63,24 @@ async function runEngine<
|
|||||||
for (const entry of pending) {
|
for (const entry of pending) {
|
||||||
console.info(`[${label}] [${entry.time}] [${entry.type}] ${entry.detail}`);
|
console.info(`[${label}] [${entry.time}] [${entry.type}] ${entry.detail}`);
|
||||||
}
|
}
|
||||||
const lastEntry = pending[pending.length - 1]!;
|
lastLogKey = lastKeyOf(pending) ?? lastLogKey;
|
||||||
if (lastEntry) {
|
|
||||||
lastLogKey = createLogKey(lastEntry);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onUpdate(emitter);
|
engine.on("update", emitter);
|
||||||
engine.start();
|
engine.start();
|
||||||
|
|
||||||
const modeLabel = `${silent ? "silent" : "interactive"}${harness.dryRun ? "+dry-run" : ""}`;
|
const modeLabel = `${options.silent ? "silent" : "interactive"}${options.dryRun ? "+dry-run" : ""}`;
|
||||||
console.info(`[${label}] Starting on ${exchangeName}. Mode: ${modeLabel}. Press Ctrl+C to exit.`);
|
console.info(`[${label}] Starting on ${exchangeName}. Mode: ${modeLabel}. Press Ctrl+C to exit.`);
|
||||||
|
|
||||||
const shutdown = (signal: NodeJS.Signals) => {
|
|
||||||
try {
|
|
||||||
console.info(`[${label}] Received ${signal}. Shutting down…`);
|
|
||||||
engine.stop();
|
|
||||||
offUpdate(emitter);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const wrapper = (signal: NodeJS.Signals) => {
|
const wrapper = (signal: NodeJS.Signals) => {
|
||||||
shutdown(signal);
|
try {
|
||||||
|
console.info(`[${label}] Received ${signal}. Shutting down…`);
|
||||||
|
engine.stop();
|
||||||
|
engine.off("update", emitter);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${label}] Error during shutdown: ${extractMessage(error)}`);
|
||||||
|
}
|
||||||
process.off("SIGINT", wrapper);
|
process.off("SIGINT", wrapper);
|
||||||
process.off("SIGTERM", wrapper);
|
process.off("SIGTERM", wrapper);
|
||||||
resolve();
|
resolve();
|
||||||
@@ -266,10 +93,7 @@ async function runEngine<
|
|||||||
|
|
||||||
function createAdapterOrThrow(symbol: string, dryRun?: boolean): ExchangeAdapter {
|
function createAdapterOrThrow(symbol: string, dryRun?: boolean): ExchangeAdapter {
|
||||||
const adapter = buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
|
const adapter = buildAdapterFromEnv({ exchangeId: resolveExchangeId(), symbol });
|
||||||
if (dryRun) {
|
return dryRun ? new DryRunExchangeAdapter(adapter) : adapter;
|
||||||
return new DryRunExchangeAdapter(adapter);
|
|
||||||
}
|
|
||||||
return adapter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TradeLogEntry = { time: string; type: string; detail: string };
|
type TradeLogEntry = { time: string; type: string; detail: string };
|
||||||
@@ -278,13 +102,17 @@ function diffTradeLog(tradeLog: TradeLogEntry[], lastKey: string | undefined): T
|
|||||||
if (!tradeLog.length) return [];
|
if (!tradeLog.length) return [];
|
||||||
if (!lastKey) return tradeLog;
|
if (!lastKey) return tradeLog;
|
||||||
const lastIndex = tradeLog.findIndex((entry) => createLogKey(entry) === lastKey);
|
const lastIndex = tradeLog.findIndex((entry) => createLogKey(entry) === lastKey);
|
||||||
if (lastIndex === -1) {
|
if (lastIndex === -1) return tradeLog;
|
||||||
return tradeLog;
|
|
||||||
}
|
|
||||||
if (lastIndex === tradeLog.length - 1) return [];
|
if (lastIndex === tradeLog.length - 1) return [];
|
||||||
return tradeLog.slice(lastIndex + 1);
|
return tradeLog.slice(lastIndex + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lastKeyOf(entries: TradeLogEntry[] | undefined): string | undefined {
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) return undefined;
|
||||||
|
const last = entries[entries.length - 1];
|
||||||
|
return last ? createLogKey(last) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function createLogKey(entry: TradeLogEntry): string {
|
function createLogKey(entry: TradeLogEntry): string {
|
||||||
return `${entry.time}|${entry.type}|${entry.detail}`;
|
return `${entry.time}|${entry.type}|${entry.detail}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,6 +293,14 @@ export interface GridConfig {
|
|||||||
autoRestart: boolean;
|
autoRestart: boolean;
|
||||||
gridMode: "geometric";
|
gridMode: "geometric";
|
||||||
maxCloseSlippagePct: number;
|
maxCloseSlippagePct: number;
|
||||||
|
gridShiftEnabled: boolean;
|
||||||
|
gridShiftTriggerPct: number;
|
||||||
|
gridShiftRangePct: number;
|
||||||
|
gridShiftConfirmMs: number;
|
||||||
|
useReduceOnlyForExit: boolean;
|
||||||
|
exchangeStopEnabled: boolean;
|
||||||
|
reconcileIntervalMs: number;
|
||||||
|
uncoveredGraceMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
|
const resolveBasisSymbol = (envKeys: string[], fallback: string): string => {
|
||||||
@@ -373,6 +381,14 @@ export const gridConfig: GridConfig = {
|
|||||||
0.05
|
0.05
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
gridShiftEnabled: parseBoolean(process.env.GRID_SHIFT_ENABLED, false),
|
||||||
|
gridShiftTriggerPct: Math.max(0, parseNumber(process.env.GRID_SHIFT_TRIGGER_PCT, 0.05)),
|
||||||
|
gridShiftRangePct: Math.max(0, parseNumber(process.env.GRID_SHIFT_RANGE_PCT, 0.05)),
|
||||||
|
gridShiftConfirmMs: Math.max(0, parseNumber(process.env.GRID_SHIFT_CONFIRM_MS, 3000)),
|
||||||
|
useReduceOnlyForExit: parseBoolean(process.env.GRID_USE_REDUCE_ONLY, false),
|
||||||
|
exchangeStopEnabled: parseBoolean(process.env.GRID_EXCHANGE_STOP_ENABLED, true),
|
||||||
|
reconcileIntervalMs: Math.max(1000, parseNumber(process.env.GRID_RECONCILE_INTERVAL_MS, 30_000)),
|
||||||
|
uncoveredGraceMs: Math.max(0, parseNumber(process.env.GRID_UNCOVERED_GRACE_MS, 5000)),
|
||||||
};
|
};
|
||||||
|
|
||||||
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
|
gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels);
|
||||||
|
|||||||
+170
-152
@@ -10,6 +10,7 @@ import {
|
|||||||
import { roundDownToTick, roundQtyDownToStep } from "../utils/math";
|
import { roundDownToTick, roundQtyDownToStep } from "../utils/math";
|
||||||
import { isUnknownOrderError } from "../utils/errors";
|
import { isUnknownOrderError } from "../utils/errors";
|
||||||
import { isOrderPriceAllowedByMark } from "../utils/strategy";
|
import { isOrderPriceAllowedByMark } from "../utils/strategy";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export type OrderLockMap = Record<string, boolean>;
|
export type OrderLockMap = Record<string, boolean>;
|
||||||
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
export type OrderTimerMap = Record<string, ReturnType<typeof setTimeout> | null>;
|
||||||
@@ -22,12 +23,75 @@ type OrderGuardOptions = {
|
|||||||
maxPct?: number;
|
maxPct?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything about *where* an order goes, fixed for an engine's lifetime.
|
||||||
|
* These six values always travelled together as the leading positional
|
||||||
|
* parameters of every order function; bundling them keeps call sites readable
|
||||||
|
* and makes an argument-order mistake impossible.
|
||||||
|
*/
|
||||||
|
export interface OrderContext {
|
||||||
|
adapter: ExchangeAdapter;
|
||||||
|
symbol: string;
|
||||||
|
locks: OrderLockMap;
|
||||||
|
timers: OrderTimerMap;
|
||||||
|
pendings: OrderPendingMap;
|
||||||
|
log: LogHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderRequestBase {
|
||||||
|
/** Live orders, used to cancel same-type duplicates before placing. */
|
||||||
|
openOrders: Order[];
|
||||||
|
side: "BUY" | "SELL";
|
||||||
|
/** Rejects the order when its price strays too far from the mark price. */
|
||||||
|
guard?: OrderGuardOptions;
|
||||||
|
qtyStep?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LimitOrderRequest extends OrderRequestBase {
|
||||||
|
/** String to preserve the exact tick the caller computed. */
|
||||||
|
price: string;
|
||||||
|
amount: number;
|
||||||
|
reduceOnly?: boolean;
|
||||||
|
skipDedupe?: boolean;
|
||||||
|
slPrice?: number;
|
||||||
|
tpPrice?: number;
|
||||||
|
clientOrderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketOrderRequest extends OrderRequestBase {
|
||||||
|
amount: number;
|
||||||
|
reduceOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StopLossOrderRequest extends OrderRequestBase {
|
||||||
|
stopPrice: number;
|
||||||
|
quantity: number;
|
||||||
|
/** Latest traded price; the stop is rejected when it is already through it. */
|
||||||
|
lastPrice: number | null;
|
||||||
|
priceTick?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrailingStopOrderRequest extends OrderRequestBase {
|
||||||
|
activationPrice: number;
|
||||||
|
quantity: number;
|
||||||
|
callbackRate: number;
|
||||||
|
priceTick?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketCloseRequest extends OrderRequestBase {
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step assumed when the caller does not know the venue's own. */
|
||||||
|
const DEFAULT_QTY_STEP = 0.001;
|
||||||
|
const DEFAULT_PRICE_TICK = 0.1;
|
||||||
|
|
||||||
function enforceMarkPriceGuard(
|
function enforceMarkPriceGuard(
|
||||||
side: "BUY" | "SELL",
|
side: "BUY" | "SELL",
|
||||||
toCheckPrice: number | null | undefined,
|
toCheckPrice: number | null | undefined,
|
||||||
guard: OrderGuardOptions | undefined,
|
guard: OrderGuardOptions | undefined,
|
||||||
log: LogHandler,
|
log: LogHandler,
|
||||||
context: string
|
kind: string
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!guard || guard.maxPct == null) return true;
|
if (!guard || guard.maxPct == null) return true;
|
||||||
const allowed = isOrderPriceAllowedByMark({
|
const allowed = isOrderPriceAllowedByMark({
|
||||||
@@ -41,13 +105,26 @@ function enforceMarkPriceGuard(
|
|||||||
const markStr = Number.isFinite(Number(guard.markPrice)) ? Number(guard.markPrice).toFixed(2) : String(guard.markPrice);
|
const markStr = Number.isFinite(Number(guard.markPrice)) ? Number(guard.markPrice).toFixed(2) : String(guard.markPrice);
|
||||||
log(
|
log(
|
||||||
"info",
|
"info",
|
||||||
`${context} 保护触发:side=${side} price=${priceStr} mark=${markStr} 超过 ${(guard.maxPct! * 100).toFixed(2)}%`
|
t("log.order.markGuardBlocked", {
|
||||||
|
kind,
|
||||||
|
side,
|
||||||
|
price: priceStr,
|
||||||
|
mark: markStr,
|
||||||
|
pct: (guard.maxPct! * 100).toFixed(2),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rounds down to the venue's step, but never to zero — a sub-step size is kept as-is. */
|
||||||
|
function normalizeQuantity(amount: number, qtyStep: number): number {
|
||||||
|
const raw = Math.abs(amount);
|
||||||
|
const rounded = roundQtyDownToStep(raw, qtyStep);
|
||||||
|
return rounded > 0 ? rounded : raw;
|
||||||
|
}
|
||||||
|
|
||||||
export function isOperating(locks: OrderLockMap, type: string): boolean {
|
export function isOperating(locks: OrderLockMap, type: string): boolean {
|
||||||
return Boolean(locks[type]);
|
return Boolean(locks[type]);
|
||||||
}
|
}
|
||||||
@@ -67,7 +144,7 @@ export function lockOperating(
|
|||||||
timers[type] = setTimeout(() => {
|
timers[type] = setTimeout(() => {
|
||||||
locks[type] = false;
|
locks[type] = false;
|
||||||
pendings[type] = null;
|
pendings[type] = null;
|
||||||
log("info", `${type} 操作超时自动解锁`);
|
log("info", t("log.order.lockTimeout", { type }));
|
||||||
}, timeout);
|
}, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,16 +163,12 @@ export function unlockOperating(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deduplicateOrders(
|
export async function deduplicateOrders(
|
||||||
adapter: ExchangeAdapter,
|
ctx: OrderContext,
|
||||||
symbol: string,
|
|
||||||
openOrders: Order[],
|
openOrders: Order[],
|
||||||
locks: OrderLockMap,
|
|
||||||
timers: OrderTimerMap,
|
|
||||||
pendings: OrderPendingMap,
|
|
||||||
type: string,
|
type: string,
|
||||||
side: string,
|
side: string
|
||||||
log: LogHandler
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const { adapter, symbol, locks, timers, pendings, log } = ctx;
|
||||||
// Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated.
|
// Treat STOP orders on some exchanges (e.g., Lighter) as LIMIT with stopPrice populated.
|
||||||
const sameTypeOrders = openOrders.filter((o) => {
|
const sameTypeOrders = openOrders.filter((o) => {
|
||||||
const normalizedType = String(o.type).toUpperCase();
|
const normalizedType = String(o.type).toUpperCase();
|
||||||
@@ -116,80 +189,68 @@ export async function deduplicateOrders(
|
|||||||
try {
|
try {
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
await adapter.cancelOrders({ symbol, orderIdList });
|
await adapter.cancelOrders({ symbol, orderIdList });
|
||||||
log("order", `去重撤销重复 ${type} 单: ${orderIdList.join(",")}`);
|
log("order", t("log.order.dedupeCancelled", { type, ids: orderIdList.join(",") }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", "去重时发现订单已不存在,跳过删除");
|
log("order", t("log.order.dedupeGone"));
|
||||||
} else {
|
} else {
|
||||||
log("error", `去重撤单失败: ${String(err)}`);
|
log("error", t("log.order.dedupeFailed", { error: String(err) }));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaceOrderOptions = {
|
|
||||||
priceTick: number;
|
|
||||||
qtyStep: number;
|
|
||||||
skipDedupe?: boolean;
|
|
||||||
slPrice?: number;
|
|
||||||
tpPrice?: number;
|
|
||||||
clientOrderId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function placeOrder(
|
export async function placeOrder(
|
||||||
adapter: ExchangeAdapter,
|
ctx: OrderContext,
|
||||||
symbol: string,
|
request: LimitOrderRequest
|
||||||
openOrders: Order[],
|
|
||||||
locks: OrderLockMap,
|
|
||||||
timers: OrderTimerMap,
|
|
||||||
pendings: OrderPendingMap,
|
|
||||||
side: "BUY" | "SELL",
|
|
||||||
price: string, // 改为字符串价格
|
|
||||||
amount: number,
|
|
||||||
log: LogHandler,
|
|
||||||
reduceOnly = false,
|
|
||||||
guard?: OrderGuardOptions,
|
|
||||||
opts?: PlaceOrderOptions
|
|
||||||
): Promise<Order | undefined> {
|
): Promise<Order | undefined> {
|
||||||
|
const { locks, timers, pendings, log } = ctx;
|
||||||
|
const { side, openOrders, guard, reduceOnly = false } = request;
|
||||||
const type = "LIMIT";
|
const type = "LIMIT";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
const priceNum = Number(price);
|
const priceNum = Number(request.price);
|
||||||
if (!enforceMarkPriceGuard(side, priceNum, guard, log, "限价单")) return;
|
if (!enforceMarkPriceGuard(side, priceNum, guard, log, t("order.kind.limit"))) return;
|
||||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP);
|
||||||
const rawQuantity = Math.abs(amount);
|
|
||||||
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
|
||||||
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
log("error", "限价单数量无效,跳过下单");
|
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.limit") }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!opts?.skipDedupe) {
|
if (!request.skipDedupe) {
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(ctx, openOrders, type, side);
|
||||||
}
|
}
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const closePosition = reduceOnly ? true : undefined;
|
const closePosition = reduceOnly ? true : undefined;
|
||||||
const order = await routeLimitOrder({
|
const order = await routeLimitOrder({
|
||||||
adapter,
|
adapter: ctx.adapter,
|
||||||
symbol,
|
symbol: ctx.symbol,
|
||||||
side,
|
side,
|
||||||
quantity,
|
quantity,
|
||||||
price: priceNum,
|
price: priceNum,
|
||||||
timeInForce: reduceOnly ? "GTC" : "GTX",
|
timeInForce: reduceOnly ? "GTC" : "GTX",
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
closePosition,
|
closePosition,
|
||||||
slPrice: opts?.slPrice,
|
slPrice: request.slPrice,
|
||||||
tpPrice: opts?.tpPrice,
|
tpPrice: request.tpPrice,
|
||||||
clientOrderId: opts?.clientOrderId,
|
clientOrderId: request.clientOrderId,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("order", `挂限价单: ${side} @ ${priceNum} 数量 ${quantity} reduceOnly=${reduceOnly}${opts?.slPrice ? ` sl=${opts.slPrice}` : ""}`);
|
log(
|
||||||
|
"order",
|
||||||
|
t("log.order.limitPlaced", {
|
||||||
|
side,
|
||||||
|
price: priceNum,
|
||||||
|
quantity,
|
||||||
|
reduceOnly,
|
||||||
|
sl: request.slPrice ? ` sl=${request.slPrice}` : "",
|
||||||
|
})
|
||||||
|
);
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", "订单已成交或被撤销,跳过新单");
|
log("order", t("log.order.limitGone"));
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -197,49 +258,38 @@ export async function placeOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function placeMarketOrder(
|
export async function placeMarketOrder(
|
||||||
adapter: ExchangeAdapter,
|
ctx: OrderContext,
|
||||||
symbol: string,
|
request: MarketOrderRequest
|
||||||
openOrders: Order[],
|
|
||||||
locks: OrderLockMap,
|
|
||||||
timers: OrderTimerMap,
|
|
||||||
pendings: OrderPendingMap,
|
|
||||||
side: "BUY" | "SELL",
|
|
||||||
amount: number,
|
|
||||||
log: LogHandler,
|
|
||||||
reduceOnly = false,
|
|
||||||
guard?: OrderGuardOptions,
|
|
||||||
opts?: { qtyStep: number }
|
|
||||||
): Promise<Order | undefined> {
|
): Promise<Order | undefined> {
|
||||||
|
const { locks, timers, pendings, log } = ctx;
|
||||||
|
const { side, openOrders, guard, reduceOnly = false } = request;
|
||||||
const type = "MARKET";
|
const type = "MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价单")) return;
|
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, t("order.kind.market"))) return;
|
||||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
const quantity = normalizeQuantity(request.amount, request.qtyStep ?? DEFAULT_QTY_STEP);
|
||||||
const rawQuantity = Math.abs(amount);
|
|
||||||
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
|
||||||
const quantity = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
|
||||||
if (quantity <= 0) {
|
if (quantity <= 0) {
|
||||||
log("error", "市价单数量无效,跳过下单");
|
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.market") }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(ctx, openOrders, type, side);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const closePosition = reduceOnly ? true : undefined;
|
const closePosition = reduceOnly ? true : undefined;
|
||||||
const order = await routeMarketOrder({
|
const order = await routeMarketOrder({
|
||||||
adapter,
|
adapter: ctx.adapter,
|
||||||
symbol,
|
symbol: ctx.symbol,
|
||||||
side,
|
side,
|
||||||
quantity,
|
quantity,
|
||||||
reduceOnly: reduceOnly ? true : undefined,
|
reduceOnly: reduceOnly ? true : undefined,
|
||||||
closePosition,
|
closePosition,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("order", `市价单: ${side} 数量 ${quantity} reduceOnly=${reduceOnly}`);
|
log("order", t("log.order.marketPlaced", { side, quantity, reduceOnly }));
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", "市价单失败但订单已不存在,忽略");
|
log("order", t("log.order.marketGone"));
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -247,51 +297,38 @@ export async function placeMarketOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function placeStopLossOrder(
|
export async function placeStopLossOrder(
|
||||||
adapter: ExchangeAdapter,
|
ctx: OrderContext,
|
||||||
symbol: string,
|
request: StopLossOrderRequest
|
||||||
openOrders: Order[],
|
|
||||||
locks: OrderLockMap,
|
|
||||||
timers: OrderTimerMap,
|
|
||||||
pendings: OrderPendingMap,
|
|
||||||
side: "BUY" | "SELL",
|
|
||||||
stopPrice: number,
|
|
||||||
quantity: number,
|
|
||||||
lastPrice: number | null,
|
|
||||||
log: LogHandler,
|
|
||||||
guard?: OrderGuardOptions,
|
|
||||||
opts?: { priceTick: number; qtyStep: number }
|
|
||||||
): Promise<Order | undefined> {
|
): Promise<Order | undefined> {
|
||||||
|
const { locks, timers, pendings, log } = ctx;
|
||||||
|
const { side, openOrders, guard, stopPrice, lastPrice } = request;
|
||||||
const type = "STOP_MARKET";
|
const type = "STOP_MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, stopPrice, guard, log, "止损单")) return;
|
if (!enforceMarkPriceGuard(side, stopPrice, guard, log, t("order.kind.stop"))) return;
|
||||||
if (lastPrice != null) {
|
if (lastPrice != null) {
|
||||||
if (side === "SELL" && stopPrice >= lastPrice) {
|
if (side === "SELL" && stopPrice >= lastPrice) {
|
||||||
log("error", `止损价 ${stopPrice} 高于或等于当前价 ${lastPrice},取消挂单`);
|
log("error", t("log.order.stopAboveLast", { stopPrice, lastPrice }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (side === "BUY" && stopPrice <= lastPrice) {
|
if (side === "BUY" && stopPrice <= lastPrice) {
|
||||||
log("error", `止损价 ${stopPrice} 低于或等于当前价 ${lastPrice},取消挂单`);
|
log("error", t("log.order.stopBelowLast", { stopPrice, lastPrice }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const priceTick = opts?.priceTick ?? 0.1;
|
const normalizedStop = roundDownToTick(stopPrice, request.priceTick ?? DEFAULT_PRICE_TICK);
|
||||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP);
|
||||||
const normalizedStop = roundDownToTick(stopPrice, priceTick);
|
|
||||||
const rawQuantity = Math.abs(quantity);
|
|
||||||
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
|
||||||
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
|
||||||
if (normalizedQty <= 0) {
|
if (normalizedQty <= 0) {
|
||||||
log("error", "止损单数量无效,跳过下单");
|
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.stop") }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways
|
// Avoid forcing price for STOP_MARKET globally; keep this exchange-specific in gateways
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(ctx, openOrders, type, side);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const order = await routeStopOrder({
|
const order = await routeStopOrder({
|
||||||
adapter,
|
adapter: ctx.adapter,
|
||||||
symbol,
|
symbol: ctx.symbol,
|
||||||
side,
|
side,
|
||||||
quantity: normalizedQty,
|
quantity: normalizedQty,
|
||||||
stopPrice: normalizedStop,
|
stopPrice: normalizedStop,
|
||||||
@@ -301,12 +338,12 @@ export async function placeStopLossOrder(
|
|||||||
triggerType: "STOP_LOSS",
|
triggerType: "STOP_LOSS",
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("stop", `挂止损单: ${side} STOP_MARKET @ ${normalizedStop}`);
|
log("stop", t("log.order.stopPlaced", { side, stopPrice: normalizedStop }));
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", "止损单已失效,跳过");
|
log("order", t("log.order.stopGone"));
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -314,43 +351,30 @@ export async function placeStopLossOrder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function placeTrailingStopOrder(
|
export async function placeTrailingStopOrder(
|
||||||
adapter: ExchangeAdapter,
|
ctx: OrderContext,
|
||||||
symbol: string,
|
request: TrailingStopOrderRequest
|
||||||
openOrders: Order[],
|
|
||||||
locks: OrderLockMap,
|
|
||||||
timers: OrderTimerMap,
|
|
||||||
pendings: OrderPendingMap,
|
|
||||||
side: "BUY" | "SELL",
|
|
||||||
activationPrice: number,
|
|
||||||
quantity: number,
|
|
||||||
callbackRate: number,
|
|
||||||
log: LogHandler,
|
|
||||||
guard?: OrderGuardOptions,
|
|
||||||
opts?: { priceTick: number; qtyStep: number }
|
|
||||||
): Promise<Order | undefined> {
|
): Promise<Order | undefined> {
|
||||||
|
const { adapter, locks, timers, pendings, log } = ctx;
|
||||||
|
const { side, openOrders, guard, activationPrice, callbackRate } = request;
|
||||||
const type = "TRAILING_STOP_MARKET";
|
const type = "TRAILING_STOP_MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!adapter.supportsTrailingStops()) {
|
if (!adapter.supportsTrailingStops()) {
|
||||||
log("error", "当前交易所不支持动态止盈单");
|
log("error", t("log.order.trailingUnsupported"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, "动态止盈单")) return;
|
if (!enforceMarkPriceGuard(side, activationPrice, guard, log, t("order.kind.trailing"))) return;
|
||||||
const priceTick = opts?.priceTick ?? 0.1;
|
const normalizedActivation = roundDownToTick(activationPrice, request.priceTick ?? DEFAULT_PRICE_TICK);
|
||||||
const qtyStep = opts?.qtyStep ?? 0.001;
|
const normalizedQty = normalizeQuantity(request.quantity, request.qtyStep ?? DEFAULT_QTY_STEP);
|
||||||
const normalizedActivation = roundDownToTick(activationPrice, priceTick);
|
|
||||||
const rawQuantity = Math.abs(quantity);
|
|
||||||
const roundedQuantity = roundQtyDownToStep(rawQuantity, qtyStep);
|
|
||||||
const normalizedQty = roundedQuantity > 0 ? roundedQuantity : rawQuantity;
|
|
||||||
if (normalizedQty <= 0) {
|
if (normalizedQty <= 0) {
|
||||||
log("error", "动态止盈单数量无效,跳过下单");
|
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.trailing") }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(ctx, openOrders, type, side);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const order = await routeTrailingStopOrder({
|
const order = await routeTrailingStopOrder({
|
||||||
adapter,
|
adapter,
|
||||||
symbol,
|
symbol: ctx.symbol,
|
||||||
side,
|
side,
|
||||||
quantity: normalizedQty,
|
quantity: normalizedQty,
|
||||||
activationPrice: normalizedActivation,
|
activationPrice: normalizedActivation,
|
||||||
@@ -361,68 +385,62 @@ export async function placeTrailingStopOrder(
|
|||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log(
|
log(
|
||||||
"order",
|
"order",
|
||||||
`挂动态止盈单: ${side} activation=${normalizedActivation} callbackRate=${callbackRate}`
|
t("log.order.trailingPlaced", {
|
||||||
|
side,
|
||||||
|
activation: normalizedActivation,
|
||||||
|
callbackRate,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
return order;
|
return order;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", "动态止盈单已失效,跳过");
|
log("order", t("log.order.trailingGone"));
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function marketClose(
|
export async function marketClose(ctx: OrderContext, request: MarketCloseRequest): Promise<void> {
|
||||||
adapter: ExchangeAdapter,
|
const { locks, timers, pendings, log } = ctx;
|
||||||
symbol: string,
|
const { side, openOrders, guard, qtyStep } = request;
|
||||||
openOrders: Order[],
|
|
||||||
locks: OrderLockMap,
|
|
||||||
timers: OrderTimerMap,
|
|
||||||
pendings: OrderPendingMap,
|
|
||||||
side: "BUY" | "SELL",
|
|
||||||
quantity: number,
|
|
||||||
log: LogHandler,
|
|
||||||
guard?: OrderGuardOptions,
|
|
||||||
opts?: { qtyStep: number }
|
|
||||||
): Promise<void> {
|
|
||||||
const type = "MARKET";
|
const type = "MARKET";
|
||||||
if (isOperating(locks, type)) return;
|
if (isOperating(locks, type)) return;
|
||||||
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, "市价平仓")) return;
|
if (!enforceMarkPriceGuard(side, guard?.expectedPrice ?? null, guard, log, t("order.kind.close"))) return;
|
||||||
|
|
||||||
const qtyStep = opts?.qtyStep;
|
const rawQuantity = Math.abs(request.quantity);
|
||||||
const rawQuantity = Math.abs(quantity);
|
let normalizedQty = qtyStep != null ? normalizeQuantity(rawQuantity, qtyStep) : rawQuantity;
|
||||||
const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity;
|
|
||||||
let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity;
|
|
||||||
if (qtyStep != null) {
|
if (qtyStep != null) {
|
||||||
|
// A step-rounded close that is within rounding noise of the real position
|
||||||
|
// would leave dust behind; close the exact amount instead.
|
||||||
const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
|
const epsilon = Math.max(qtyStep * 1e-4, 1e-10);
|
||||||
if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
|
if (Math.abs(rawQuantity - normalizedQty) <= epsilon) {
|
||||||
normalizedQty = rawQuantity;
|
normalizedQty = rawQuantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (normalizedQty <= 0) {
|
if (normalizedQty <= 0) {
|
||||||
log("error", "市价平仓数量无效,跳过下单");
|
log("error", t("log.order.invalidQuantity", { kind: t("order.kind.close") }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log);
|
await deduplicateOrders(ctx, openOrders, type, side);
|
||||||
lockOperating(locks, timers, pendings, type, log);
|
lockOperating(locks, timers, pendings, type, log);
|
||||||
try {
|
try {
|
||||||
const order = await routeCloseOrder({
|
const order = await routeCloseOrder({
|
||||||
adapter,
|
adapter: ctx.adapter,
|
||||||
symbol,
|
symbol: ctx.symbol,
|
||||||
side,
|
side,
|
||||||
quantity: normalizedQty,
|
quantity: normalizedQty,
|
||||||
reduceOnly: true,
|
reduceOnly: true,
|
||||||
closePosition: true,
|
closePosition: true,
|
||||||
});
|
});
|
||||||
pendings[type] = String(order.orderId);
|
pendings[type] = String(order.orderId);
|
||||||
log("close", `市价平仓: ${side}`);
|
log("close", t("log.order.closePlaced", { side }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
unlockOperating(locks, timers, pendings, type);
|
unlockOperating(locks, timers, pendings, type);
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
log("order", "市场平仓时订单已不存在");
|
log("order", t("log.order.closeGone"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface ConnectionEventListener {
|
|||||||
export interface ExchangeAdapter {
|
export interface ExchangeAdapter {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
supportsTrailingStops(): boolean;
|
supportsTrailingStops(): boolean;
|
||||||
|
/** 是否支持交易所侧触发单(STOP_MARKET 兜底止损),缺省视为 false */
|
||||||
|
supportsTriggerOrders?(): boolean;
|
||||||
watchAccount(cb: AccountListener): void;
|
watchAccount(cb: AccountListener): void;
|
||||||
watchOrders(cb: OrderListener): void;
|
watchOrders(cb: OrderListener): void;
|
||||||
watchDepth(symbol: string, cb: DepthListener): void;
|
watchDepth(symbol: string, cb: DepthListener): void;
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export class AsterExchangeAdapter implements ExchangeAdapter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportsTriggerOrders(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
watchAccount(cb: AccountListener): void {
|
watchAccount(cb: AccountListener): void {
|
||||||
void this.init.ensureInitialized("watchAccount");
|
void this.init.ensureInitialized("watchAccount");
|
||||||
this.gateway.onAccount(this.safeInvoke("watchAccount", (snapshot) => {
|
this.gateway.onAccount(this.safeInvoke("watchAccount", (snapshot) => {
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ export class AsterSpotRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url, init);
|
response = await fetch(url, init);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterSpotRestClient] 请求失败 ${String(error)}`);
|
throw new Error(`[AsterSpotRestClient] request failed: ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -546,7 +546,7 @@ export class AsterSpotRestClient {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text) as T;
|
return JSON.parse(text) as T;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterSpotRestClient] 无法解析响应: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterSpotRestClient] could not parse response: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -789,7 +789,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url);
|
response = await fetch(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterRestClient] 获取交易规则失败 ${String(error)}`);
|
throw new Error(`[AsterRestClient] failed to fetch exchange info: ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -798,7 +798,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text) as AsterFuturesExchangeInfo;
|
return JSON.parse(text) as AsterFuturesExchangeInfo;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterRestClient] 无法解析交易规则响应: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterRestClient] could not parse exchange info: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,7 +863,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url);
|
response = await fetch(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterRestClient] 获取K线失败 ${String(error)}`);
|
throw new Error(`[AsterRestClient] failed to fetch klines: ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -873,7 +873,7 @@ export class AsterRestClient {
|
|||||||
const payload = JSON.parse(text) as any[];
|
const payload = JSON.parse(text) as any[];
|
||||||
return payload.map((entry) => fromRestKline(entry, interval, upper));
|
return payload.map((entry) => fromRestKline(entry, interval, upper));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterRestClient] 无法解析K线响应: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterRestClient] could not parse klines: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,7 +892,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url);
|
response = await fetch(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterRestClient] 获取资金费率失败 ${String(error)}`);
|
throw new Error(`[AsterRestClient] failed to fetch funding rate: ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -903,7 +903,7 @@ export class AsterRestClient {
|
|||||||
// The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time }
|
// The response shape mirrors Binance: { symbol, markPrice, indexPrice, lastFundingRate, nextFundingTime, time }
|
||||||
return payload;
|
return payload;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterRestClient] 无法解析资金费率响应: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterRestClient] could not parse funding rate: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,7 +937,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url, init);
|
response = await fetch(url, init);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`[AsterRestClient] 请求失败 ${String(error)}`);
|
throw new Error(`[AsterRestClient] request failed: ${String(error)}`);
|
||||||
}
|
}
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -946,7 +946,7 @@ export class AsterRestClient {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(text) as T;
|
return JSON.parse(text) as T;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`[AsterRestClient] 无法解析响应: ${text.slice(0, 200)}`);
|
throw new Error(`[AsterRestClient] could not parse response: ${text.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,7 +1037,7 @@ export class AsterPublicStreams {
|
|||||||
try {
|
try {
|
||||||
payload = JSON.parse(event.data);
|
payload = JSON.parse(event.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterPublicStreams] 无法解析消息", error, event.data);
|
console.error("[AsterPublicStreams] could not parse message", error, event.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1198,7 +1198,7 @@ export class AsterUserStream {
|
|||||||
try {
|
try {
|
||||||
payload = JSON.parse(event.data);
|
payload = JSON.parse(event.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterUserStream] 无法解析消息", error, event.data);
|
console.error("[AsterUserStream] could not parse message", error, event.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1509,7 +1509,7 @@ export class AsterGateway {
|
|||||||
positions = latestPositions;
|
positions = latestPositions;
|
||||||
}
|
}
|
||||||
} catch (positionError) {
|
} catch (positionError) {
|
||||||
console.error("[AsterGateway] 刷新持仓失败", positionError);
|
console.error("[AsterGateway] failed to refresh positions", positionError);
|
||||||
}
|
}
|
||||||
const normalizedPositions = clonePositions(positions);
|
const normalizedPositions = clonePositions(positions);
|
||||||
const snapshot: AccountSnapshot = {
|
const snapshot: AccountSnapshot = {
|
||||||
@@ -1521,7 +1521,7 @@ export class AsterGateway {
|
|||||||
this.accountSnapshot = snapshot;
|
this.accountSnapshot = snapshot;
|
||||||
this.accountEvent.emit(snapshot);
|
this.accountEvent.emit(snapshot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] 刷新账户信息失败", error);
|
console.error("[AsterGateway] failed to refresh account", error);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const orders = await this.rest.getOpenOrders();
|
const orders = await this.rest.getOpenOrders();
|
||||||
@@ -1529,7 +1529,7 @@ export class AsterGateway {
|
|||||||
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
|
orders.forEach((order) => mergeOrderSnapshot(this.openOrders, order));
|
||||||
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
this.ordersEvent.emit(Array.from(this.openOrders.values()));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] 刷新挂单失败", error);
|
console.error("[AsterGateway] failed to refresh open orders", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1573,7 +1573,7 @@ export class AsterGateway {
|
|||||||
this.accountSnapshot = nextSnapshot;
|
this.accountSnapshot = nextSnapshot;
|
||||||
this.accountEvent.emit(nextSnapshot);
|
this.accountEvent.emit(nextSnapshot);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] 同步持仓失败", error);
|
console.error("[AsterGateway] failed to sync positions", error);
|
||||||
} finally {
|
} finally {
|
||||||
this.positionSyncInFlight = false;
|
this.positionSyncInFlight = false;
|
||||||
}
|
}
|
||||||
@@ -1608,7 +1608,7 @@ export class AsterGateway {
|
|||||||
try {
|
try {
|
||||||
exchangeInfo = await this.loadExchangeInfo();
|
exchangeInfo = await this.loadExchangeInfo();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AsterGateway] 获取交易规则失败", error);
|
console.error("[AsterGateway] failed to fetch exchange info", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const symbols = exchangeInfo?.symbols ?? [];
|
const symbols = exchangeInfo?.symbols ?? [];
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
TickerListener,
|
TickerListener,
|
||||||
KlineListener,
|
KlineListener,
|
||||||
} from "../adapter";
|
} from "../adapter";
|
||||||
|
import { ReconnectScheduler, fixedBackoff } from "../reconnect-scheduler";
|
||||||
|
|
||||||
const WebSocketCtor: typeof globalThis.WebSocket =
|
const WebSocketCtor: typeof globalThis.WebSocket =
|
||||||
typeof globalThis.WebSocket !== "undefined"
|
typeof globalThis.WebSocket !== "undefined"
|
||||||
@@ -99,7 +100,10 @@ export class BackpackGateway {
|
|||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private wsReady = false;
|
private wsReady = false;
|
||||||
private wsPingTimer: ReturnType<typeof setInterval> | null = null;
|
private wsPingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private readonly wsReconnect = new ReconnectScheduler({
|
||||||
|
connect: () => this.ensurePrivateSocket(),
|
||||||
|
backoff: fixedBackoff(WS_RECONNECT_DELAY),
|
||||||
|
});
|
||||||
private readonly wsTopics = new Set<string>();
|
private readonly wsTopics = new Set<string>();
|
||||||
private wsConnecting = false;
|
private wsConnecting = false;
|
||||||
private readonly wsWindow: string;
|
private readonly wsWindow: string;
|
||||||
@@ -782,7 +786,7 @@ export class BackpackGateway {
|
|||||||
this.wsReady = false;
|
this.wsReady = false;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.stopPing();
|
this.stopPing();
|
||||||
this.scheduleReconnect();
|
this.wsReconnect.schedule();
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleWsError = (_event: any): void => {
|
private handleWsError = (_event: any): void => {
|
||||||
@@ -945,14 +949,6 @@ export class BackpackGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
|
||||||
if (this.wsReconnectTimer) return;
|
|
||||||
this.wsReconnectTimer = setTimeout(() => {
|
|
||||||
this.wsReconnectTimer = null;
|
|
||||||
this.ensurePrivateSocket();
|
|
||||||
}, WS_RECONNECT_DELAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
private detachWebSocket(): void {
|
private detachWebSocket(): void {
|
||||||
if (this.wsCleanup) {
|
if (this.wsCleanup) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export class BinanceExchangeAdapter implements ExchangeAdapter {
|
|||||||
return this.marketType !== "spot";
|
return this.marketType !== "spot";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportsTriggerOrders(): boolean {
|
||||||
|
return this.marketType !== "spot";
|
||||||
|
}
|
||||||
|
|
||||||
watchAccount(cb: AccountListener): void {
|
watchAccount(cb: AccountListener): void {
|
||||||
const safe = this.safeInvoke("watchAccount", cb);
|
const safe = this.safeInvoke("watchAccount", cb);
|
||||||
void this.init.ensureInitialized("watchAccount")
|
void this.init.ensureInitialized("watchAccount")
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export class DryRunExchangeAdapter implements ExchangeAdapter {
|
|||||||
return this.inner.supportsTrailingStops();
|
return this.inner.supportsTrailingStops();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportsTriggerOrders(): boolean {
|
||||||
|
return this.inner.supportsTriggerOrders?.() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
watchAccount(cb: AccountListener): void {
|
watchAccount(cb: AccountListener): void {
|
||||||
this.inner.watchAccount(cb);
|
this.inner.watchAccount(cb);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export class GrvtExchangeAdapter implements ExchangeAdapter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportsTriggerOrders(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
watchAccount(cb: AccountListener): void {
|
watchAccount(cb: AccountListener): void {
|
||||||
void this.init.ensureInitialized("watchAccount");
|
void this.init.ensureInitialized("watchAccount");
|
||||||
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
|
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
|
||||||
@@ -173,7 +177,7 @@ function loadSignatureProviderFromEnv(
|
|||||||
return loaded.default as GrvtSignatureProvider;
|
return loaded.default as GrvtSignatureProvider;
|
||||||
}
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`[GrvtExchangeAdapter] 模块 ${resolved} 未导出签名函数 (function default export)`
|
`[GrvtExchangeAdapter] module ${resolved} does not export a signing function (function default export)`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const log = logger ?? ((ctx, err) => console.error(`[GrvtExchangeAdapter] ${ctx}`, err));
|
const log = logger ?? ((ctx, err) => console.error(`[GrvtExchangeAdapter] ${ctx}`, err));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
|
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
import { ReconnectScheduler, linearBackoff } from "../reconnect-scheduler";
|
||||||
import type {
|
import type {
|
||||||
AccountListener,
|
AccountListener,
|
||||||
DepthListener,
|
DepthListener,
|
||||||
@@ -122,9 +123,36 @@ interface Pollers {
|
|||||||
klines: Map<string, ReturnType<typeof setInterval>>;
|
klines: Map<string, ReturnType<typeof setInterval>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spot balance of a single asset. `effective` is what every balance guard compares
|
||||||
|
* against: an unknown or unparseable asset collapses to 0 so guards fail closed
|
||||||
|
* instead of waving an order through.
|
||||||
|
*/
|
||||||
|
interface SpotAssetBalance {
|
||||||
|
available: number | null;
|
||||||
|
wallet: number | null;
|
||||||
|
effective: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSpotAssetBalance(available: number | null, wallet: number | null): SpotAssetBalance {
|
||||||
|
return {
|
||||||
|
available,
|
||||||
|
wallet,
|
||||||
|
effective: Math.max(
|
||||||
|
available != null && Number.isFinite(available) ? available : 0,
|
||||||
|
wallet != null && Number.isFinite(wallet) ? wallet : 0
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tolerance that absorbs float drift when comparing a balance against an order size. */
|
||||||
|
const BALANCE_EPSILON = 1e-9;
|
||||||
|
|
||||||
const KLINE_DEFAULT_COUNT = 120;
|
const KLINE_DEFAULT_COUNT = 120;
|
||||||
const DEFAULT_TICKER_POLL_MS = 3000;
|
const DEFAULT_TICKER_POLL_MS = 3000;
|
||||||
const DEFAULT_KLINE_POLL_MS = 15000;
|
const DEFAULT_KLINE_POLL_MS = 15000;
|
||||||
|
const WS_RECONNECT_BASE_MS = 2_000;
|
||||||
|
const WS_RECONNECT_MAX_MS = 30_000;
|
||||||
const WS_HEARTBEAT_INTERVAL_MS = 5_000;
|
const WS_HEARTBEAT_INTERVAL_MS = 5_000;
|
||||||
const CLIENT_PING_INTERVAL_MS = 2_000;
|
const CLIENT_PING_INTERVAL_MS = 2_000;
|
||||||
const WS_STALE_TIMEOUT_MS = 20_000;
|
const WS_STALE_TIMEOUT_MS = 20_000;
|
||||||
@@ -221,8 +249,14 @@ export class LighterGateway {
|
|||||||
private readonly orderIndexByClientId = new Map<string, string>();
|
private readonly orderIndexByClientId = new Map<string, string>();
|
||||||
|
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private readonly reconnect: ReconnectScheduler = new ReconnectScheduler({
|
||||||
private reconnectAttempts = 0;
|
connect: async () => {
|
||||||
|
await this.openWebSocket();
|
||||||
|
this.reconnect.onConnected();
|
||||||
|
},
|
||||||
|
backoff: linearBackoff(WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS),
|
||||||
|
onError: (error) => this.logger("reconnect", error),
|
||||||
|
});
|
||||||
private readonly wsUrl: string;
|
private readonly wsUrl: string;
|
||||||
private connectPromise: Promise<void> | null = null;
|
private connectPromise: Promise<void> | null = null;
|
||||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -734,7 +768,7 @@ export class LighterGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.stopStaleMonitor();
|
this.stopStaleMonitor();
|
||||||
this.scheduleReconnect();
|
this.reconnect.schedule();
|
||||||
});
|
});
|
||||||
ws.on("error", (error) => {
|
ws.on("error", (error) => {
|
||||||
this.logger("ws:error", error);
|
this.logger("ws:error", error);
|
||||||
@@ -744,7 +778,7 @@ export class LighterGateway {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.stopStaleMonitor();
|
this.stopStaleMonitor();
|
||||||
this.scheduleReconnect();
|
this.reconnect.schedule();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -917,25 +951,7 @@ export class LighterGateway {
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return candidates[0];
|
return candidates[0] ?? null;
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
|
||||||
if (this.reconnectTimer) return;
|
|
||||||
const attempt = this.reconnectAttempts + 1;
|
|
||||||
const delay = Math.min(2000 * attempt, 30_000);
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
this.openWebSocket()
|
|
||||||
.then(() => {
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.logger("reconnect", error);
|
|
||||||
this.reconnectAttempts = attempt;
|
|
||||||
this.scheduleReconnect();
|
|
||||||
});
|
|
||||||
}, delay);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private forceReconnect(reason: string): void {
|
private forceReconnect(reason: string): void {
|
||||||
@@ -953,7 +969,7 @@ export class LighterGateway {
|
|||||||
}
|
}
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.stopClientPing();
|
this.stopClientPing();
|
||||||
this.scheduleReconnect();
|
this.reconnect.schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
@@ -970,7 +986,7 @@ export class LighterGateway {
|
|||||||
} finally {
|
} finally {
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.stopClientPing();
|
this.stopClientPing();
|
||||||
this.scheduleReconnect();
|
this.reconnect.schedule();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1756,8 +1772,9 @@ export class LighterGateway {
|
|||||||
(matchSymbol ?? asset.symbol ?? (asset.asset_id != null ? String(asset.asset_id) : "ASSET")).toUpperCase();
|
(matchSymbol ?? asset.symbol ?? (asset.asset_id != null ? String(asset.asset_id) : "ASSET")).toUpperCase();
|
||||||
list.push({
|
list.push({
|
||||||
asset: assetSymbol,
|
asset: assetSymbol,
|
||||||
walletBalance: asset.balance ?? "0",
|
walletBalance: String(asset.balance ?? "0"),
|
||||||
availableBalance: available != null && Number.isFinite(available) ? available.toString() : asset.balance ?? "0",
|
availableBalance:
|
||||||
|
available != null && Number.isFinite(available) ? available.toString() : String(asset.balance ?? "0"),
|
||||||
updateTime: now,
|
updateTime: now,
|
||||||
assetId: Number.isFinite(assetId) ? assetId : undefined,
|
assetId: Number.isFinite(assetId) ? assetId : undefined,
|
||||||
});
|
});
|
||||||
@@ -1796,7 +1813,7 @@ export class LighterGateway {
|
|||||||
return (hasBase && hasQuote) || idMatch;
|
return (hasBase && hasQuote) || idMatch;
|
||||||
});
|
});
|
||||||
if (!matches.length) return null;
|
if (!matches.length) return null;
|
||||||
return matches[0];
|
return matches[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrecision(): Promise<{
|
async getPrecision(): Promise<{
|
||||||
@@ -1846,8 +1863,7 @@ export class LighterGateway {
|
|||||||
return qty;
|
return qty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAvailableAssetAmount(assetId?: number | null, symbol?: string | null): { available: number | null; wallet: number | null } {
|
private getSpotAssetBalance(assetId?: number | null, symbol?: string | null): SpotAssetBalance {
|
||||||
if (!this.assets.size) return null;
|
|
||||||
const normalizedSymbol = symbol ? symbol.toUpperCase() : null;
|
const normalizedSymbol = symbol ? symbol.toUpperCase() : null;
|
||||||
for (const asset of this.assets.values()) {
|
for (const asset of this.assets.values()) {
|
||||||
const idMatches = assetId != null && Number.isFinite(Number(asset.asset_id)) && Number(asset.asset_id) === assetId;
|
const idMatches = assetId != null && Number.isFinite(Number(asset.asset_id)) && Number(asset.asset_id) === assetId;
|
||||||
@@ -1857,29 +1873,23 @@ export class LighterGateway {
|
|||||||
const locked = parseNumber(asset.locked_balance ?? 0);
|
const locked = parseNumber(asset.locked_balance ?? 0);
|
||||||
if (balance == null) continue;
|
if (balance == null) continue;
|
||||||
const available = locked != null ? balance - locked : balance;
|
const available = locked != null ? balance - locked : balance;
|
||||||
return {
|
return makeSpotAssetBalance(
|
||||||
available: Number.isFinite(available) ? available : null,
|
Number.isFinite(available) ? available : null,
|
||||||
wallet: Number.isFinite(balance) ? balance : null,
|
Number.isFinite(balance) ? balance : null
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
return { available: null, wallet: null };
|
return makeSpotAssetBalance(null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertSpotBalance(params: { isAsk: boolean; quantity: number | null | undefined; price: number | null }): void {
|
private assertSpotBalance(params: { isAsk: boolean; quantity: number | null | undefined; price: number | null }): void {
|
||||||
const qty = Number(params.quantity);
|
const qty = Number(params.quantity);
|
||||||
if (!Number.isFinite(qty) || qty <= 0) return;
|
if (!Number.isFinite(qty) || qty <= 0) return;
|
||||||
if (params.isAsk) {
|
if (params.isAsk) {
|
||||||
const baseAmounts = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
|
const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
|
||||||
const availableBase = baseAmounts?.available ?? null;
|
if (base.effective + BALANCE_EPSILON < qty) {
|
||||||
const walletBase = baseAmounts?.wallet ?? null;
|
|
||||||
const effective = Math.max(
|
|
||||||
availableBase != null && Number.isFinite(availableBase) ? availableBase : 0,
|
|
||||||
walletBase != null && Number.isFinite(walletBase) ? walletBase : 0
|
|
||||||
);
|
|
||||||
if (effective + 1e-9 < qty) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${availableBase ?? 0}${
|
`Insufficient base asset (${this.baseAssetSymbol ?? "BASE"} available ${base.available ?? 0}${
|
||||||
walletBase != null ? ` wallet ${walletBase}` : ""
|
base.wallet != null ? ` wallet ${base.wallet}` : ""
|
||||||
}) for spot sell ${qty}`
|
}) for spot sell ${qty}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1888,10 +1898,12 @@ export class LighterGateway {
|
|||||||
const price = Number(params.price);
|
const price = Number(params.price);
|
||||||
if (!Number.isFinite(price) || price <= 0) return;
|
if (!Number.isFinite(price) || price <= 0) return;
|
||||||
const requiredQuote = qty * price;
|
const requiredQuote = qty * price;
|
||||||
const availableQuote = this.getAvailableAssetAmount(this.quoteAssetId, this.quoteAssetSymbol);
|
const quote = this.getSpotAssetBalance(this.quoteAssetId, this.quoteAssetSymbol);
|
||||||
if (availableQuote != null && availableQuote + 1e-9 < requiredQuote) {
|
if (quote.effective + BALANCE_EPSILON < requiredQuote) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${availableQuote}) for spot buy requiring ${requiredQuote}`
|
`Insufficient quote asset (${this.quoteAssetSymbol ?? "QUOTE"} available ${
|
||||||
|
quote.available ?? 0
|
||||||
|
}) for spot buy requiring ${requiredQuote}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1932,10 +1944,10 @@ export class LighterGateway {
|
|||||||
const isAsk = side === "SELL" ? 1 : 0;
|
const isAsk = side === "SELL" ? 1 : 0;
|
||||||
const enforcedQty = this.enforceMinimums(params.quantity, params.price ?? null);
|
const enforcedQty = this.enforceMinimums(params.quantity, params.price ?? null);
|
||||||
if (this.isSpotMarket() && isAsk === 1) {
|
if (this.isSpotMarket() && isAsk === 1) {
|
||||||
const availableBase = this.getAvailableAssetAmount(this.baseAssetId, this.baseAssetSymbol);
|
const base = this.getSpotAssetBalance(this.baseAssetId, this.baseAssetSymbol);
|
||||||
if (availableBase != null && availableBase + 1e-9 < enforcedQty) {
|
if (base.effective + BALANCE_EPSILON < enforcedQty) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Spot sell quantity ${enforcedQty} exceeds available base ${availableBase} (min trade size may be higher than balance)`
|
`Spot sell quantity ${enforcedQty} exceeds available base ${base.effective} (min trade size may be higher than balance)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function lighterOrderToAster(symbol: string, order: LighterOrder): Order
|
|||||||
symbol,
|
symbol,
|
||||||
side,
|
side,
|
||||||
type: mapOrderType(order.type),
|
type: mapOrderType(order.type),
|
||||||
status: normalizeOrderStatus(order.status ?? order.trigger_status ?? "UNKNOWN"),
|
status: normalizeOrderStatus(String(order.status ?? order.trigger_status ?? "UNKNOWN")),
|
||||||
price: order.price ?? "0",
|
price: order.price ?? "0",
|
||||||
origQty: order.initial_base_amount ?? "0",
|
origQty: order.initial_base_amount ?? "0",
|
||||||
executedQty: computeExecutedQty(order),
|
executedQty: computeExecutedQty(order),
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export type LighterOrderType =
|
|||||||
|
|
||||||
type StrOrNum = string | number;
|
type StrOrNum = string | number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lighter reports boolean fields inconsistently across endpoints — `true`, `1`, `"Yes"`.
|
||||||
|
* `normalizeBooleanFlag` in ./flags is what turns any of these into a real boolean.
|
||||||
|
*/
|
||||||
|
type BooleanFlag = boolean | string | number | bigint;
|
||||||
|
|
||||||
export interface LighterOrder {
|
export interface LighterOrder {
|
||||||
order_index: StrOrNum;
|
order_index: StrOrNum;
|
||||||
client_order_index: StrOrNum;
|
client_order_index: StrOrNum;
|
||||||
@@ -23,12 +29,12 @@ export interface LighterOrder {
|
|||||||
filled_quote_amount?: string;
|
filled_quote_amount?: string;
|
||||||
price: string;
|
price: string;
|
||||||
nonce?: number;
|
nonce?: number;
|
||||||
is_ask?: boolean;
|
is_ask?: BooleanFlag;
|
||||||
side?: LighterSide;
|
side?: LighterSide;
|
||||||
type?: LighterOrderType;
|
type?: LighterOrderType;
|
||||||
time_in_force?: string;
|
time_in_force?: string;
|
||||||
trigger_price?: string;
|
trigger_price?: string;
|
||||||
reduce_only?: boolean;
|
reduce_only?: BooleanFlag;
|
||||||
status?: string | number;
|
status?: string | number;
|
||||||
trigger_status?: string | number;
|
trigger_status?: string | number;
|
||||||
trigger_time?: number;
|
trigger_time?: number;
|
||||||
@@ -68,7 +74,8 @@ export interface LighterAccountDetails {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LighterAccountAsset {
|
export interface LighterAccountAsset {
|
||||||
symbol: string;
|
/** Optional: the account endpoint omits it for assets it only knows by id. */
|
||||||
|
symbol?: string;
|
||||||
asset_id?: number;
|
asset_id?: number;
|
||||||
balance: string | number;
|
balance: string | number;
|
||||||
locked_balance?: string | number;
|
locked_balance?: string | number;
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export class OndoperpsExchangeAdapter implements ExchangeAdapter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
supportsTriggerOrders(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
watchAccount(cb: AccountListener): void {
|
watchAccount(cb: AccountListener): void {
|
||||||
void this.init.ensureInitialized("watchAccount");
|
void this.init.ensureInitialized("watchAccount");
|
||||||
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
|
this.gateway.onAccount(this.safeInvoke("watchAccount", cb));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createHmac } from "node:crypto";
|
import { createHmac } from "node:crypto";
|
||||||
|
import { ReconnectScheduler, exponentialBackoff } from "../reconnect-scheduler";
|
||||||
import type {
|
import type {
|
||||||
AccountListener,
|
AccountListener,
|
||||||
ConnectionEventListener,
|
ConnectionEventListener,
|
||||||
@@ -42,10 +43,10 @@ const DEFAULT_WS_URL = "wss://api.ondoperps.xyz/ws";
|
|||||||
const DEFAULT_SYMBOL = "BTC-USD.P";
|
const DEFAULT_SYMBOL = "BTC-USD.P";
|
||||||
const REQUEST_TIMEOUT_MS = 15_000;
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
const WS_HEARTBEAT_MS = 30_000;
|
const WS_HEARTBEAT_MS = 30_000;
|
||||||
|
const WS_RECONNECT_BASE_MS = 1_000;
|
||||||
const WS_RECONNECT_MAX_MS = 30_000;
|
const WS_RECONNECT_MAX_MS = 30_000;
|
||||||
|
|
||||||
type Timer = ReturnType<typeof setInterval>;
|
type Timer = ReturnType<typeof setInterval>;
|
||||||
type Timeout = ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
export interface OndoperpsGatewayOptions {
|
export interface OndoperpsGatewayOptions {
|
||||||
apiKeyId: string;
|
apiKeyId: string;
|
||||||
@@ -211,8 +212,11 @@ export class OndoperpsGateway {
|
|||||||
private wsLoginInFlight = false;
|
private wsLoginInFlight = false;
|
||||||
private wsLoginFallbackAttempted = false;
|
private wsLoginFallbackAttempted = false;
|
||||||
private wsEverOpened = false;
|
private wsEverOpened = false;
|
||||||
private wsReconnectDelayMs = 1_000;
|
private readonly wsReconnect = new ReconnectScheduler({
|
||||||
private wsReconnectTimer: Timeout | null = null;
|
connect: () => this.connectWebSocket(),
|
||||||
|
backoff: exponentialBackoff(WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS),
|
||||||
|
onError: (error) => this.logger("reconnect", error),
|
||||||
|
});
|
||||||
private heartbeatTimer: Timer | null = null;
|
private heartbeatTimer: Timer | null = null;
|
||||||
private readonly sentSubscriptions = new Set<string>();
|
private readonly sentSubscriptions = new Set<string>();
|
||||||
|
|
||||||
@@ -590,10 +594,7 @@ export class OndoperpsGateway {
|
|||||||
|
|
||||||
private connectWebSocket(): void {
|
private connectWebSocket(): void {
|
||||||
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
|
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
|
||||||
if (this.wsReconnectTimer) {
|
this.wsReconnect.cancel();
|
||||||
clearTimeout(this.wsReconnectTimer);
|
|
||||||
this.wsReconnectTimer = null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const ws = this.webSocketFactory(this.wsUrl);
|
const ws = this.webSocketFactory(this.wsUrl);
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
@@ -605,7 +606,7 @@ export class OndoperpsGateway {
|
|||||||
ws.addEventListener("error", (event) => this.logger("websocket", event));
|
ws.addEventListener("error", (event) => this.logger("websocket", event));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger("connectWebSocket", error);
|
this.logger("connectWebSocket", error);
|
||||||
this.scheduleReconnect();
|
this.wsReconnect.schedule();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,7 +614,7 @@ export class OndoperpsGateway {
|
|||||||
if (this.ws !== ws) return;
|
if (this.ws !== ws) return;
|
||||||
const reconnected = this.wsEverOpened;
|
const reconnected = this.wsEverOpened;
|
||||||
this.wsEverOpened = true;
|
this.wsEverOpened = true;
|
||||||
this.wsReconnectDelayMs = 1_000;
|
this.wsReconnect.onConnected();
|
||||||
this.wsAuthenticated = false;
|
this.wsAuthenticated = false;
|
||||||
this.wsLoginInFlight = false;
|
this.wsLoginInFlight = false;
|
||||||
this.wsLoginFallbackAttempted = false;
|
this.wsLoginFallbackAttempted = false;
|
||||||
@@ -630,7 +631,7 @@ export class OndoperpsGateway {
|
|||||||
this.wsLoginInFlight = false;
|
this.wsLoginInFlight = false;
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.emitConnection("disconnected");
|
this.emitConnection("disconnected");
|
||||||
this.scheduleReconnect();
|
this.wsReconnect.schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleWsMessage(raw: unknown): Promise<void> {
|
private async handleWsMessage(raw: unknown): Promise<void> {
|
||||||
@@ -772,16 +773,6 @@ export class OndoperpsGateway {
|
|||||||
this.heartbeatTimer = null;
|
this.heartbeatTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
|
||||||
if (this.wsReconnectTimer) return;
|
|
||||||
const delay = this.wsReconnectDelayMs;
|
|
||||||
this.wsReconnectDelayMs = Math.min(this.wsReconnectDelayMs * 2, WS_RECONNECT_MAX_MS);
|
|
||||||
this.wsReconnectTimer = setTimeout(() => {
|
|
||||||
this.wsReconnectTimer = null;
|
|
||||||
this.connectWebSocket();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startAccountPolling(): void {
|
private startAccountPolling(): void {
|
||||||
if (this.accountPollTimer) return;
|
if (this.accountPollTimer) return;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ export class ParadexGateway {
|
|||||||
const isClosePosition = (extraParams as any).closePosition === true;
|
const isClosePosition = (extraParams as any).closePosition === true;
|
||||||
if (isClosePosition) {
|
if (isClosePosition) {
|
||||||
const posAbs = this.getCurrentPositionAbs();
|
const posAbs = this.getCurrentPositionAbs();
|
||||||
if (Number.isFinite(posAbs) && posAbs > 0) {
|
if (posAbs != null && Number.isFinite(posAbs) && posAbs > 0) {
|
||||||
amount = posAbs;
|
amount = posAbs;
|
||||||
}
|
}
|
||||||
const current = Number(amount);
|
const current = Number(amount);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* How long to wait before the nth reconnect attempt (1-based).
|
||||||
|
* Return a fixed value for a constant delay, or grow it for backoff.
|
||||||
|
*/
|
||||||
|
export type BackoffPolicy = (attempt: number) => number;
|
||||||
|
|
||||||
|
export const fixedBackoff = (delayMs: number): BackoffPolicy => () => delayMs;
|
||||||
|
|
||||||
|
/** `base * 2^(attempt-1)`, capped at `maxMs`. */
|
||||||
|
export const exponentialBackoff = (baseMs: number, maxMs: number): BackoffPolicy => (attempt) =>
|
||||||
|
Math.min(baseMs * Math.pow(2, attempt - 1), maxMs);
|
||||||
|
|
||||||
|
/** `base * attempt`, capped at `maxMs`. */
|
||||||
|
export const linearBackoff = (baseMs: number, maxMs: number): BackoffPolicy => (attempt) =>
|
||||||
|
Math.min(baseMs * attempt, maxMs);
|
||||||
|
|
||||||
|
export interface ReconnectSchedulerOptions {
|
||||||
|
/** Reopens the socket. Rejections are reported and then retried. */
|
||||||
|
connect: () => void | Promise<void>;
|
||||||
|
backoff: BackoffPolicy;
|
||||||
|
/** Returns false to abandon reconnecting (e.g. the gateway was closed). */
|
||||||
|
shouldReconnect?: () => boolean;
|
||||||
|
onError?: (error: unknown, attempt: number) => void;
|
||||||
|
onSchedule?: (delayMs: number, attempt: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the reconnect timer for one socket.
|
||||||
|
*
|
||||||
|
* Every gateway hand-rolled the same three pieces — a "one pending attempt at a
|
||||||
|
* time" guard, an attempt counter feeding a backoff formula, and resetting that
|
||||||
|
* counter once the socket opens — each with its own field names and a slightly
|
||||||
|
* different formula. Forgetting the reset is the classic way backoff silently
|
||||||
|
* degrades into a 30-second stall after a transient blip, so the reset lives
|
||||||
|
* here next to the counter it guards.
|
||||||
|
*
|
||||||
|
* Deliberately narrow: connect/auth/subscribe/heartbeat differ per venue and
|
||||||
|
* stay in each gateway.
|
||||||
|
*/
|
||||||
|
export class ReconnectScheduler {
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private attempts = 0;
|
||||||
|
private stopped = false;
|
||||||
|
|
||||||
|
constructor(private readonly options: ReconnectSchedulerOptions) {}
|
||||||
|
|
||||||
|
/** Consecutive failed attempts since the last successful open. */
|
||||||
|
get attemptCount(): number {
|
||||||
|
return this.attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pending(): boolean {
|
||||||
|
return this.timer != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queues a reconnect. A no-op while one is already pending. */
|
||||||
|
schedule(): void {
|
||||||
|
if (this.stopped || this.timer) return;
|
||||||
|
if (this.options.shouldReconnect && !this.options.shouldReconnect()) return;
|
||||||
|
|
||||||
|
const attempt = this.attempts + 1;
|
||||||
|
const delay = this.options.backoff(attempt);
|
||||||
|
this.options.onSchedule?.(delay, attempt);
|
||||||
|
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.timer = null;
|
||||||
|
this.attempts = attempt;
|
||||||
|
if (this.stopped) return;
|
||||||
|
try {
|
||||||
|
const result = this.options.connect();
|
||||||
|
if (result && typeof result.then === "function") {
|
||||||
|
result.catch((error) => this.handleFailure(error, attempt));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleFailure(error, attempt);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call once the socket is open: clears backoff so the next blip retries fast. */
|
||||||
|
onConnected(): void {
|
||||||
|
this.attempts = 0;
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels a pending attempt without ending the scheduler. */
|
||||||
|
cancel(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently stops reconnecting; use when the gateway shuts down. */
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFailure(error: unknown, attempt: number): void {
|
||||||
|
this.options.onError?.(error, attempt);
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import NodeWebSocket from "ws";
|
import NodeWebSocket from "ws";
|
||||||
|
import { ReconnectScheduler, exponentialBackoff } from "../reconnect-scheduler";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { sign, utils as edUtils, hashes as edHashes } from "@noble/ed25519";
|
import { sign, utils as edUtils, hashes as edHashes } from "@noble/ed25519";
|
||||||
import { sha512 } from "@noble/hashes/sha512";
|
import { sha512 } from "@noble/hashes/sha512";
|
||||||
@@ -434,7 +435,6 @@ export class StandxGateway {
|
|||||||
private marketWsReady = false;
|
private marketWsReady = false;
|
||||||
private marketWsAuthed = false;
|
private marketWsAuthed = false;
|
||||||
private marketWsAuthRequested = false;
|
private marketWsAuthRequested = false;
|
||||||
private marketReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
private readonly subscriptions = new Set<string>();
|
private readonly subscriptions = new Set<string>();
|
||||||
|
|
||||||
// ========== 心跳与连接管理 ==========
|
// ========== 心跳与连接管理 ==========
|
||||||
@@ -443,7 +443,15 @@ export class StandxGateway {
|
|||||||
// 心跳检查定时器
|
// 心跳检查定时器
|
||||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
// 重连次数(用于指数退避)
|
// 重连次数(用于指数退避)
|
||||||
private reconnectAttempts = 0;
|
private readonly marketReconnect = new ReconnectScheduler({
|
||||||
|
connect: () => {
|
||||||
|
this.logDebug("attempting reconnect");
|
||||||
|
this.connectMarketWs();
|
||||||
|
},
|
||||||
|
backoff: exponentialBackoff(WS_RECONNECT_DELAY_BASE, WS_RECONNECT_DELAY_MAX),
|
||||||
|
onSchedule: (delay, attempt) =>
|
||||||
|
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${attempt})`),
|
||||||
|
});
|
||||||
|
|
||||||
// ========== 数据过时检测与 REST 备用 ==========
|
// ========== 数据过时检测与 REST 备用 ==========
|
||||||
// 上次收到行情数据(price/depth)的时间戳
|
// 上次收到行情数据(price/depth)的时间戳
|
||||||
@@ -893,7 +901,7 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private connectMarketWs(): void {
|
private connectMarketWs(): void {
|
||||||
if (this.marketWs || this.marketReconnectTimer) return;
|
if (this.marketWs || this.marketReconnect.pending) return;
|
||||||
this.marketWs = new WebSocketCtor(this.wsUrl);
|
this.marketWs = new WebSocketCtor(this.wsUrl);
|
||||||
this.marketWsReady = false;
|
this.marketWsReady = false;
|
||||||
this.marketWsAuthed = false;
|
this.marketWsAuthed = false;
|
||||||
@@ -902,7 +910,7 @@ export class StandxGateway {
|
|||||||
this.marketWsReady = true;
|
this.marketWsReady = true;
|
||||||
this.marketWsAuthed = false;
|
this.marketWsAuthed = false;
|
||||||
// 重置重连计数和时间戳
|
// 重置重连计数和时间戳
|
||||||
this.reconnectAttempts = 0;
|
this.marketReconnect.onConnected();
|
||||||
this.lastMessageTime = Date.now();
|
this.lastMessageTime = Date.now();
|
||||||
this.lastMarketDataTime = Date.now();
|
this.lastMarketDataTime = Date.now();
|
||||||
this.lastAccountDataTime = Date.now();
|
this.lastAccountDataTime = Date.now();
|
||||||
@@ -929,7 +937,7 @@ export class StandxGateway {
|
|||||||
if (wasReady) {
|
if (wasReady) {
|
||||||
this.onDisconnect();
|
this.onDisconnect();
|
||||||
}
|
}
|
||||||
this.scheduleReconnect();
|
this.marketReconnect.schedule();
|
||||||
};
|
};
|
||||||
const handleError = (error: unknown) => {
|
const handleError = (error: unknown) => {
|
||||||
this.logger("marketWs", error);
|
this.logger("marketWs", error);
|
||||||
@@ -937,7 +945,7 @@ export class StandxGateway {
|
|||||||
// 因为某些 WebSocket 实现在握手失败时可能不触发 close 事件
|
// 因为某些 WebSocket 实现在握手失败时可能不触发 close 事件
|
||||||
if (this.marketWs && !this.marketWsReady) {
|
if (this.marketWs && !this.marketWsReady) {
|
||||||
this.marketWs = null;
|
this.marketWs = null;
|
||||||
this.scheduleReconnect();
|
this.marketReconnect.schedule();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -960,22 +968,6 @@ export class StandxGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
|
||||||
if (this.marketReconnectTimer) return;
|
|
||||||
// 指数退避:delay = min(base * 2^attempts, max)
|
|
||||||
const delay = Math.min(
|
|
||||||
WS_RECONNECT_DELAY_BASE * Math.pow(2, this.reconnectAttempts),
|
|
||||||
WS_RECONNECT_DELAY_MAX
|
|
||||||
);
|
|
||||||
this.reconnectAttempts += 1;
|
|
||||||
this.logDebug(`scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
||||||
this.marketReconnectTimer = setTimeout(() => {
|
|
||||||
this.marketReconnectTimer = null;
|
|
||||||
this.logDebug("attempting reconnect");
|
|
||||||
this.connectMarketWs();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMarketMessage(event: { data: any }): void {
|
private handleMarketMessage(event: { data: any }): void {
|
||||||
// 更新最后收到消息的时间(心跳监控)
|
// 更新最后收到消息的时间(心跳监控)
|
||||||
this.lastMessageTime = Date.now();
|
this.lastMessageTime = Date.now();
|
||||||
@@ -1322,9 +1314,9 @@ export class StandxGateway {
|
|||||||
if (wasReady) {
|
if (wasReady) {
|
||||||
this.onDisconnect();
|
this.onDisconnect();
|
||||||
}
|
}
|
||||||
// 立即重连(不使用指数退避,因为是主动行为)
|
// Deliberate teardown, so reset the backoff and retry at the base delay.
|
||||||
this.reconnectAttempts = 0;
|
this.marketReconnect.onConnected();
|
||||||
this.scheduleReconnect();
|
this.marketReconnect.schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
private logDebug(context: string, detail?: unknown): void {
|
private logDebug(context: string, detail?: unknown): void {
|
||||||
|
|||||||
+664
-13
@@ -288,6 +288,16 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
en: "Last price: {lastPrice} | Lower: {lower} | Upper: {upper} | Grid count: {count}",
|
en: "Last price: {lastPrice} | Lower: {lower} | Upper: {upper} | Grid count: {count}",
|
||||||
},
|
},
|
||||||
"grid.dataStatus": { zh: "数据状态:", en: "Data status:" },
|
"grid.dataStatus": { zh: "数据状态:", en: "Data status:" },
|
||||||
|
"grid.anchorLine": {
|
||||||
|
zh: "锚定价: {anchor} | 网格版本: v{version}",
|
||||||
|
en: "Anchor: {anchor} | Grid version: v{version}",
|
||||||
|
},
|
||||||
|
"grid.shiftState": { zh: "移格进行中: {phase}", en: "Shifting: {phase}" },
|
||||||
|
"grid.stopProtection": {
|
||||||
|
zh: "止损防护: 未覆盖 {uncovered} | 兜底止损单: {stop}",
|
||||||
|
en: "Stop protection: uncovered {uncovered} | exchange stop: {stop}",
|
||||||
|
},
|
||||||
|
"grid.stopProtection.none": { zh: "无", en: "none" },
|
||||||
"grid.stopReason": { zh: "暂停原因: {reason}", en: "Pause reason: {reason}" },
|
"grid.stopReason": { zh: "暂停原因: {reason}", en: "Pause reason: {reason}" },
|
||||||
"grid.configTitle": { zh: "网格配置", en: "Grid Config" },
|
"grid.configTitle": { zh: "网格配置", en: "Grid Config" },
|
||||||
"grid.configSize": {
|
"grid.configSize": {
|
||||||
@@ -518,14 +528,6 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
zh: "构建快照失败: {error}",
|
zh: "构建快照失败: {error}",
|
||||||
en: "Failed to build snapshot: {error}",
|
en: "Failed to build snapshot: {error}",
|
||||||
},
|
},
|
||||||
"log.guardian.precisionSynced": {
|
|
||||||
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
|
|
||||||
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
|
|
||||||
},
|
|
||||||
"log.guardian.precisionFailed": {
|
|
||||||
zh: "同步精度失败: {error}",
|
|
||||||
en: "Failed to sync precision: {error}",
|
|
||||||
},
|
|
||||||
"log.basis.subscribeFuturesDepthFail": {
|
"log.basis.subscribeFuturesDepthFail": {
|
||||||
zh: "订阅期货深度失败: {error}",
|
zh: "订阅期货深度失败: {error}",
|
||||||
en: "Failed to subscribe futures depth: {error}",
|
en: "Failed to subscribe futures depth: {error}",
|
||||||
@@ -721,13 +723,662 @@ const translations: Record<string, TranslationEntry> = {
|
|||||||
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
|
"log.trend.restoreStop": { zh: "恢复原止损 @ {price}", en: "Restored original stop @ {price}" },
|
||||||
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" },
|
"log.trend.restoreStopFail": { zh: "恢复原止损失败: {error}", en: "Failed to restore original stop: {error}" },
|
||||||
"log.trend.trailingFail": { zh: "挂动态止盈失败: {error}", en: "Failed to place trailing stop: {error}" },
|
"log.trend.trailingFail": { zh: "挂动态止盈失败: {error}", en: "Failed to place trailing stop: {error}" },
|
||||||
"log.trend.precisionSynced": {
|
|
||||||
zh: "已同步交易精度: priceTick={priceTick} qtyStep={qtyStep}",
|
|
||||||
en: "Synced precision: priceTick={priceTick} qtyStep={qtyStep}",
|
|
||||||
},
|
|
||||||
"log.trend.precisionFailed": { zh: "同步精度失败: {error}", en: "Failed to sync precision: {error}" },
|
|
||||||
"log.trend.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
|
"log.trend.updateHandlerError": { zh: "更新回调处理异常: {error}", en: "Update handler error: {error}" },
|
||||||
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
|
"log.trend.snapshotDispatchError": { zh: "快照或更新分发异常: {error}", en: "Snapshot/update dispatch error: {error}" },
|
||||||
|
// --- core/order-coordinator ---
|
||||||
|
"order.kind.limit": { zh: "限价单", en: "Limit order" },
|
||||||
|
"order.kind.market": { zh: "市价单", en: "Market order" },
|
||||||
|
"order.kind.stop": { zh: "止损单", en: "Stop order" },
|
||||||
|
"order.kind.trailing": { zh: "动态止盈单", en: "Trailing stop order" },
|
||||||
|
"order.kind.close": { zh: "市价平仓", en: "Market close" },
|
||||||
|
"log.order.markGuardBlocked": {
|
||||||
|
zh: "{kind} 保护触发:side={side} price={price} mark={mark} 超过 {pct}%",
|
||||||
|
en: "{kind} blocked by mark-price guard: side={side} price={price} mark={mark} exceeds {pct}%",
|
||||||
|
},
|
||||||
|
"log.order.lockTimeout": {
|
||||||
|
zh: "{type} 操作超时自动解锁",
|
||||||
|
en: "{type} operation timed out; lock released",
|
||||||
|
},
|
||||||
|
"log.order.dedupeCancelled": {
|
||||||
|
zh: "去重撤销重复 {type} 单: {ids}",
|
||||||
|
en: "Cancelled duplicate {type} orders: {ids}",
|
||||||
|
},
|
||||||
|
"log.order.dedupeGone": {
|
||||||
|
zh: "去重时发现订单已不存在,跳过删除",
|
||||||
|
en: "Order already gone while deduplicating; skipping cancel",
|
||||||
|
},
|
||||||
|
"log.order.dedupeFailed": {
|
||||||
|
zh: "去重撤单失败: {error}",
|
||||||
|
en: "Failed to cancel duplicates: {error}",
|
||||||
|
},
|
||||||
|
"log.order.invalidQuantity": {
|
||||||
|
zh: "{kind}数量无效,跳过下单",
|
||||||
|
en: "{kind} quantity is invalid; skipping",
|
||||||
|
},
|
||||||
|
"log.order.limitPlaced": {
|
||||||
|
zh: "挂限价单: {side} @ {price} 数量 {quantity} reduceOnly={reduceOnly}{sl}",
|
||||||
|
en: "Placed limit order: {side} @ {price} qty {quantity} reduceOnly={reduceOnly}{sl}",
|
||||||
|
},
|
||||||
|
"log.order.limitGone": {
|
||||||
|
zh: "订单已成交或被撤销,跳过新单",
|
||||||
|
en: "Order already filled or cancelled; skipping new order",
|
||||||
|
},
|
||||||
|
"log.order.marketPlaced": {
|
||||||
|
zh: "市价单: {side} 数量 {quantity} reduceOnly={reduceOnly}",
|
||||||
|
en: "Market order: {side} qty {quantity} reduceOnly={reduceOnly}",
|
||||||
|
},
|
||||||
|
"log.order.marketGone": {
|
||||||
|
zh: "市价单失败但订单已不存在,忽略",
|
||||||
|
en: "Market order failed but the order is already gone; ignoring",
|
||||||
|
},
|
||||||
|
"log.order.stopAboveLast": {
|
||||||
|
zh: "止损价 {stopPrice} 高于或等于当前价 {lastPrice},取消挂单",
|
||||||
|
en: "Stop price {stopPrice} is at or above the last price {lastPrice}; not placing",
|
||||||
|
},
|
||||||
|
"log.order.stopBelowLast": {
|
||||||
|
zh: "止损价 {stopPrice} 低于或等于当前价 {lastPrice},取消挂单",
|
||||||
|
en: "Stop price {stopPrice} is at or below the last price {lastPrice}; not placing",
|
||||||
|
},
|
||||||
|
"log.order.stopPlaced": {
|
||||||
|
zh: "挂止损单: {side} STOP_MARKET @ {stopPrice}",
|
||||||
|
en: "Placed stop order: {side} STOP_MARKET @ {stopPrice}",
|
||||||
|
},
|
||||||
|
"log.order.stopGone": { zh: "止损单已失效,跳过", en: "Stop order no longer valid; skipping" },
|
||||||
|
"log.order.trailingUnsupported": {
|
||||||
|
zh: "当前交易所不支持动态止盈单",
|
||||||
|
en: "This exchange does not support trailing stop orders",
|
||||||
|
},
|
||||||
|
"log.order.trailingPlaced": {
|
||||||
|
zh: "挂动态止盈单: {side} activation={activation} callbackRate={callbackRate}",
|
||||||
|
en: "Placed trailing stop: {side} activation={activation} callbackRate={callbackRate}",
|
||||||
|
},
|
||||||
|
"log.order.trailingGone": {
|
||||||
|
zh: "动态止盈单已失效,跳过",
|
||||||
|
en: "Trailing stop no longer valid; skipping",
|
||||||
|
},
|
||||||
|
"log.order.closePlaced": { zh: "市价平仓: {side}", en: "Market close: {side}" },
|
||||||
|
"log.order.closeGone": {
|
||||||
|
zh: "市场平仓时订单已不存在",
|
||||||
|
en: "Order already gone while closing at market",
|
||||||
|
},
|
||||||
|
// --- utils/standx-token-expiry ---
|
||||||
|
"token.expiringSoon": {
|
||||||
|
zh: "StandX Token 将在 {minutes} 分钟后过期",
|
||||||
|
en: "StandX token expires in {minutes} minutes",
|
||||||
|
},
|
||||||
|
"token.expiredCancelling": {
|
||||||
|
zh: "StandX Token 已过期,正在取消所有挂单",
|
||||||
|
en: "StandX token expired; cancelling all open orders",
|
||||||
|
},
|
||||||
|
"token.expiredWithPosition": {
|
||||||
|
zh: "StandX Token 已过期,仅保留平仓/止损逻辑",
|
||||||
|
en: "StandX token expired; only close/stop logic remains active",
|
||||||
|
},
|
||||||
|
"token.expiredSilent": {
|
||||||
|
zh: "StandX Token 已过期,进入静默数据接收模式",
|
||||||
|
en: "StandX token expired; entering silent data-only mode",
|
||||||
|
},
|
||||||
|
"log.token.closeOnlyForced": {
|
||||||
|
zh: "Token 过期,强制进入平仓模式,仅允许 reduce-only 订单",
|
||||||
|
en: "Token expired; forcing close-only mode, reduce-only orders only",
|
||||||
|
},
|
||||||
|
"log.token.silentEntered": {
|
||||||
|
zh: "进入静默数据接收模式,不再进行任何交易操作",
|
||||||
|
en: "Entered silent data-only mode; no further trading actions",
|
||||||
|
},
|
||||||
|
"log.token.ordersCancelled": {
|
||||||
|
zh: "Token 过期,已撤销所有挂单",
|
||||||
|
en: "Token expired; cancelled all open orders",
|
||||||
|
},
|
||||||
|
"log.token.cancelOrderMissing": {
|
||||||
|
zh: "Token 过期撤单时订单已不存在",
|
||||||
|
en: "Order already gone while cancelling after token expiry",
|
||||||
|
},
|
||||||
|
"log.token.cancelFailed": {
|
||||||
|
zh: "Token 过期撤单失败: {error}",
|
||||||
|
en: "Failed to cancel orders after token expiry: {error}",
|
||||||
|
},
|
||||||
|
"notify.token.title": { zh: "Token 已过期", en: "Token expired" },
|
||||||
|
"notify.token.closeOnly": {
|
||||||
|
zh: "Token 已过期,进入平仓模式,不再开新仓",
|
||||||
|
en: "Token expired; entering close-only mode, no new positions",
|
||||||
|
},
|
||||||
|
"notify.token.silent": {
|
||||||
|
zh: "Token 已过期,策略进入静默模式",
|
||||||
|
en: "Token expired; strategy entering silent mode",
|
||||||
|
},
|
||||||
|
// --- strategy/common/isolated-margin-guard ---
|
||||||
|
"log.margin.switched": {
|
||||||
|
zh: "已切换为逐仓模式 (isolated),恢复策略运行",
|
||||||
|
en: "Switched to isolated margin; resuming strategy",
|
||||||
|
},
|
||||||
|
"log.margin.switchUnconfirmed": {
|
||||||
|
zh: "逐仓模式切换未确认,当前模式: {mode}",
|
||||||
|
en: "Isolated margin switch unconfirmed; current mode: {mode}",
|
||||||
|
},
|
||||||
|
"log.margin.switchFailed": {
|
||||||
|
zh: "切换逐仓模式失败: {error}",
|
||||||
|
en: "Failed to switch to isolated margin: {error}",
|
||||||
|
},
|
||||||
|
// --- strategy/grid-logic ---
|
||||||
|
"log.grid.entryFilled": {
|
||||||
|
zh: "ENTRY 成交: {side} @ {price} (线 {level})",
|
||||||
|
en: "ENTRY filled: {side} @ {price} (level {level})",
|
||||||
|
},
|
||||||
|
"log.grid.orphanExitFilled": {
|
||||||
|
zh: "孤儿 EXIT 成交: {side} @ {price}",
|
||||||
|
en: "Orphan EXIT filled: {side} @ {price}",
|
||||||
|
},
|
||||||
|
"log.grid.exitFilled": {
|
||||||
|
zh: "EXIT 成交: {side} @ {price} (释放线 {level})",
|
||||||
|
en: "EXIT filled: {side} @ {price} (level {level} released)",
|
||||||
|
},
|
||||||
|
"log.grid.entryCancelled": {
|
||||||
|
zh: "ENTRY 撤销: {side} @ {price} (线 {level})",
|
||||||
|
en: "ENTRY cancelled: {side} @ {price} (level {level})",
|
||||||
|
},
|
||||||
|
"log.grid.exitCancelled": {
|
||||||
|
zh: "EXIT 撤销: {side} @ {price} (线 {level})",
|
||||||
|
en: "EXIT cancelled: {side} @ {price} (level {level})",
|
||||||
|
},
|
||||||
|
"log.grid.orderVanished": {
|
||||||
|
zh: "订单消失待判定: {intent} {side} @ {price}",
|
||||||
|
en: "Order vanished, outcome unknown: {intent} {side} @ {price}",
|
||||||
|
},
|
||||||
|
"log.grid.belowLowerBound": {
|
||||||
|
zh: "价格跌破网格下边界 {pct}%",
|
||||||
|
en: "Price fell {pct}% below the grid's lower bound",
|
||||||
|
},
|
||||||
|
"log.grid.aboveUpperBound": {
|
||||||
|
zh: "价格突破网格上边界 {pct}%",
|
||||||
|
en: "Price rose {pct}% above the grid's upper bound",
|
||||||
|
},
|
||||||
|
"log.grid.coverageAuditClose": {
|
||||||
|
zh: "覆盖审计: 未覆盖 {qty} 且{cause},市价平仓",
|
||||||
|
en: "Coverage audit: {qty} uncovered and {cause}; closing at market",
|
||||||
|
},
|
||||||
|
"log.grid.causeOutOfRange": { zh: "价格已出区间", en: "price left the range" },
|
||||||
|
"log.grid.causeLossExceeded": { zh: "浮亏超限", en: "unrealised loss exceeded the limit" },
|
||||||
|
"log.grid.coverageAuditReason": { zh: "覆盖审计止损", en: "Coverage audit stop" },
|
||||||
|
"log.grid.coverageAuditRepost": {
|
||||||
|
zh: "覆盖审计: 未覆盖 {qty},补挂平仓单 @ {price}",
|
||||||
|
en: "Coverage audit: {qty} uncovered; reposting exit @ {price}",
|
||||||
|
},
|
||||||
|
"log.grid.shiftOutOfRange": {
|
||||||
|
zh: "价格越界,启动移格: {reason}",
|
||||||
|
en: "Price out of range; starting grid shift: {reason}",
|
||||||
|
},
|
||||||
|
"log.grid.shiftAnchorDrift": {
|
||||||
|
zh: "价格偏离锚定价超阈值,启动移格 (anchor={anchor} → {price})",
|
||||||
|
en: "Price drifted past the anchor threshold; starting grid shift (anchor={anchor} → {price})",
|
||||||
|
},
|
||||||
|
"log.grid.adoptOrphanExit": {
|
||||||
|
zh: "收编平仓方向挂单为孤儿 EXIT: {side} @ {price}",
|
||||||
|
en: "Adopted an unattributed exit-side order as orphan EXIT: {side} @ {price}",
|
||||||
|
},
|
||||||
|
"log.grid.cancelUnattributable": {
|
||||||
|
zh: "撤销无法归属的挂单: {side} @ {price}",
|
||||||
|
en: "Cancelling unattributable order: {side} @ {price}",
|
||||||
|
},
|
||||||
|
"log.grid.inflightMatched": {
|
||||||
|
zh: "inflight 归属确认: {intent} {side} @ {price}",
|
||||||
|
en: "In-flight order matched: {intent} {side} @ {price}",
|
||||||
|
},
|
||||||
|
"log.grid.cancelStaleVersion": {
|
||||||
|
zh: "撤销过期网格版本挂单: {clientOrderId}",
|
||||||
|
en: "Cancelling order from a stale grid version: {clientOrderId}",
|
||||||
|
},
|
||||||
|
"log.grid.orphanResidual": {
|
||||||
|
zh: "对账残余孤儿仓位: {qty}",
|
||||||
|
en: "Reconciliation left an orphan position: {qty}",
|
||||||
|
},
|
||||||
|
// --- strategy/grid-engine ---
|
||||||
|
"log.gridEngine.configInvalid": { zh: "配置无效,已暂停网格", en: "Invalid config; grid paused" },
|
||||||
|
"log.gridEngine.wsDisconnected": {
|
||||||
|
zh: "WebSocket 断连 ({symbol}),冻结网格下单",
|
||||||
|
en: "WebSocket disconnected ({symbol}); freezing grid orders",
|
||||||
|
},
|
||||||
|
"log.gridEngine.wsReconnected": {
|
||||||
|
zh: "WebSocket 重连成功 ({symbol}),下一轮执行对账",
|
||||||
|
en: "WebSocket reconnected ({symbol}); reconciling next tick",
|
||||||
|
},
|
||||||
|
"log.gridEngine.loadStateFailed": {
|
||||||
|
zh: "加载网格状态失败: {error}",
|
||||||
|
en: "Failed to load grid state: {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.stateRestored": {
|
||||||
|
zh: "已从磁盘恢复网格状态: gridVersion={gridVersion} anchor={anchor} 区间=[{lower}, {upper}]{shift}",
|
||||||
|
en: "Restored grid state from disk: gridVersion={gridVersion} anchor={anchor} range=[{lower}, {upper}]{shift}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.stateRestoredShift": {
|
||||||
|
zh: " 移格续跑({phase})",
|
||||||
|
en: " resuming shift ({phase})",
|
||||||
|
},
|
||||||
|
"log.gridEngine.fingerprintMismatch": {
|
||||||
|
zh: "磁盘网格状态与当前配置指纹不一致,全新建格并执行孤儿扫描",
|
||||||
|
en: "Stored grid state does not match the current config; rebuilding and scanning for orphans",
|
||||||
|
},
|
||||||
|
"log.gridEngine.gridCreated": {
|
||||||
|
zh: "以锚定价 {anchor} 建立网格 ({mode})",
|
||||||
|
en: "Grid created at anchor {anchor} ({mode})",
|
||||||
|
},
|
||||||
|
"log.gridEngine.initFailed": { zh: "网格初始化失败: {error}", en: "Grid init failed: {error}" },
|
||||||
|
"log.gridEngine.reconcileEvent": { zh: "[对账:{source}] {event}", en: "[reconcile:{source}] {event}" },
|
||||||
|
"log.gridEngine.reconcileCancelled": {
|
||||||
|
zh: "[对账:{source}] 撤销 {count} 个无法归属的挂单",
|
||||||
|
en: "[reconcile:{source}] cancelled {count} unattributable orders",
|
||||||
|
},
|
||||||
|
"log.gridEngine.reconcileCancelFailed": {
|
||||||
|
zh: "[对账:{source}] 撤单失败: {error}",
|
||||||
|
en: "[reconcile:{source}] cancel failed: {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.reconcileOrdersFailed": {
|
||||||
|
zh: "[对账:{source}] REST 查询挂单失败: {error}",
|
||||||
|
en: "[reconcile:{source}] REST open-order query failed: {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.reconcileAccountFailed": {
|
||||||
|
zh: "[对账:{source}] REST 查询账户失败: {error}",
|
||||||
|
en: "[reconcile:{source}] REST account query failed: {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.tickFailed": { zh: "网格轮询异常: {error}", en: "Grid tick failed: {error}" },
|
||||||
|
"log.gridEngine.shiftStarting": {
|
||||||
|
zh: "启动智能移格,目标锚定价 {anchor}",
|
||||||
|
en: "Starting grid shift to anchor {anchor}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.orderFeedStalled": {
|
||||||
|
zh: "订单流疑似停滞(下单后长时间未反映),暂停新下单",
|
||||||
|
en: "Order feed looks stalled (placements are not showing up); pausing new orders",
|
||||||
|
},
|
||||||
|
"log.gridEngine.placeFailed": {
|
||||||
|
zh: "挂单失败 ({side} @ {price}): {error}",
|
||||||
|
en: "Failed to place order ({side} @ {price}): {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.closeSlippageBlocked": {
|
||||||
|
zh: "市价平仓滑点守卫触发 ({reason}): close={close} mark={mark} 偏离 {pct}% > {limit}%,暂缓",
|
||||||
|
en: "Market close blocked by slippage guard ({reason}): close={close} mark={mark} deviates {pct}% > {limit}%; holding off",
|
||||||
|
},
|
||||||
|
"log.gridEngine.closed": { zh: "市价平仓 {side} {qty} ({reason})", en: "Market close {side} {qty} ({reason})" },
|
||||||
|
"log.gridEngine.closeFailed": {
|
||||||
|
zh: "市价平仓失败 ({reason}): {error}",
|
||||||
|
en: "Market close failed ({reason}): {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.shiftCancelRequested": {
|
||||||
|
zh: "移格: 已请求撤销全部挂单",
|
||||||
|
en: "Shift: requested cancellation of all orders",
|
||||||
|
},
|
||||||
|
"log.gridEngine.shiftCancelFailed": { zh: "移格撤单失败: {error}", en: "Shift cancel failed: {error}" },
|
||||||
|
"log.gridEngine.shiftCloseReason": { zh: "移格平仓", en: "Grid shift close" },
|
||||||
|
"log.gridEngine.shiftCloseDeferred": {
|
||||||
|
zh: "移格: 平仓被滑点守卫暂缓,下轮重试",
|
||||||
|
en: "Shift: close deferred by the slippage guard; retrying next tick",
|
||||||
|
},
|
||||||
|
"log.gridEngine.shiftDone": {
|
||||||
|
zh: "移格完成: 新锚定价 {anchor},区间 [{lower}, {upper}],gridVersion={gridVersion}",
|
||||||
|
en: "Shift complete: anchor {anchor}, range [{lower}, {upper}], gridVersion={gridVersion}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.stopCancelledFlat": {
|
||||||
|
zh: "已撤销交易所兜底止损单(仓位归零)",
|
||||||
|
en: "Cancelled the exchange stop order (position is flat)",
|
||||||
|
},
|
||||||
|
"log.gridEngine.stopCancelFailed": {
|
||||||
|
zh: "撤销兜底止损单失败: {error}",
|
||||||
|
en: "Failed to cancel the exchange stop: {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.stopCancelStaleFailed": {
|
||||||
|
zh: "撤销旧兜底止损单失败: {error}",
|
||||||
|
en: "Failed to cancel the previous exchange stop: {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.stopPlaceFailed": {
|
||||||
|
zh: "挂兜底止损单失败: {error}",
|
||||||
|
en: "Failed to place the exchange stop: {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.haltStarting": {
|
||||||
|
zh: "{reason},开始执行撤单与平仓",
|
||||||
|
en: "{reason}; cancelling orders and closing out",
|
||||||
|
},
|
||||||
|
"log.gridEngine.allCancelled": { zh: "已撤销全部网格挂单", en: "Cancelled all grid orders" },
|
||||||
|
"log.gridEngine.cancelAllFailed": {
|
||||||
|
zh: "撤销网格挂单失败: {error}",
|
||||||
|
en: "Failed to cancel grid orders: {error}",
|
||||||
|
},
|
||||||
|
"log.gridEngine.stopCloseDeferred": {
|
||||||
|
zh: "止损平仓被滑点守卫暂缓,下轮重试",
|
||||||
|
en: "Stop close deferred by the slippage guard; retrying next tick",
|
||||||
|
},
|
||||||
|
"log.gridEngine.resumed": {
|
||||||
|
zh: "价格重新回到网格区间,恢复网格运行 (gridVersion={gridVersion})",
|
||||||
|
en: "Price re-entered the grid range; resuming (gridVersion={gridVersion})",
|
||||||
|
},
|
||||||
|
"log.gridEngine.saveStateFailed": {
|
||||||
|
zh: "保存网格状态失败: {error}",
|
||||||
|
en: "Failed to save grid state: {error}",
|
||||||
|
},
|
||||||
|
// --- offset-maker / liquidity-maker (shared wording) ---
|
||||||
|
"log.subscribe.klineFail": { zh: "订阅K线失败: {error}", en: "Failed to subscribe klines: {error}" },
|
||||||
|
"log.process.klineError": { zh: "K线推送处理异常: {error}", en: "Kline update handler error: {error}" },
|
||||||
|
"log.spotMaker.belowMinSellHold": {
|
||||||
|
zh: "现货持仓低于最小卖单量,暂不挂卖单",
|
||||||
|
en: "Spot balance is below the minimum sell size; holding off on sell orders",
|
||||||
|
},
|
||||||
|
"log.spotMaker.belowMinSellSkip": {
|
||||||
|
zh: "现货持仓低于最小卖单量,跳过卖单",
|
||||||
|
en: "Spot balance is below the minimum sell size; skipping the sell order",
|
||||||
|
},
|
||||||
|
"log.spotMaker.buyOnlyOnGreenCandle": {
|
||||||
|
zh: "现货买入仅在1m阳线,当前跳过买单",
|
||||||
|
en: "Spot buys only on a green 1m candle; skipping the buy order",
|
||||||
|
},
|
||||||
|
"log.spotMaker.quoteBalanceShort": {
|
||||||
|
zh: "现货可用报价资产不足,跳过买单",
|
||||||
|
en: "Not enough quote asset available; skipping the buy order",
|
||||||
|
},
|
||||||
|
"log.spotMaker.baseBalanceShort": {
|
||||||
|
zh: "现货可用基础资产不足,跳过卖单",
|
||||||
|
en: "Not enough base asset available; skipping the sell order",
|
||||||
|
},
|
||||||
|
"log.spotMaker.spreadTooTightBuy": {
|
||||||
|
zh: "跳过买单:价差不足以构造maker价格",
|
||||||
|
en: "Skipping the buy order: the spread is too tight for a maker price",
|
||||||
|
},
|
||||||
|
"log.spotMaker.spreadTooTightSell": {
|
||||||
|
zh: "跳过卖单:价差不足以构造maker价格",
|
||||||
|
en: "Skipping the sell order: the spread is too tight for a maker price",
|
||||||
|
},
|
||||||
|
"log.spotMaker.sellBelowMinNotional": {
|
||||||
|
zh: "现货卖单低于最小成交量,跳过挂单等待累积",
|
||||||
|
en: "Sell size is below the venue minimum; waiting to accumulate",
|
||||||
|
},
|
||||||
|
"log.spotMaker.belowMinCloseSkipStop": {
|
||||||
|
zh: "现货持仓低于最小平仓数量,跳过止损检查",
|
||||||
|
en: "Spot position is below the minimum close size; skipping the stop check",
|
||||||
|
},
|
||||||
|
"log.spotMaker.rateLimitCloseMissing": {
|
||||||
|
zh: "限频强制平仓时订单已不存在",
|
||||||
|
en: "Order already gone during the rate-limit forced close",
|
||||||
|
},
|
||||||
|
"log.spotMaker.rateLimitCloseFailed": {
|
||||||
|
zh: "限频强制平仓失败: {error}",
|
||||||
|
en: "Rate-limit forced close failed: {error}",
|
||||||
|
},
|
||||||
|
"log.spotMaker.startupCleanup": { zh: "启动时清理历史挂单", en: "Cancelling stale orders on startup" },
|
||||||
|
"log.spotMaker.startupCleanupGone": {
|
||||||
|
zh: "历史挂单已消失,跳过启动清理",
|
||||||
|
en: "Stale orders already gone; skipping startup cleanup",
|
||||||
|
},
|
||||||
|
"log.spotMaker.startupCancelFailed": {
|
||||||
|
zh: "启动撤单失败: {error}",
|
||||||
|
en: "Startup cancel failed: {error}",
|
||||||
|
},
|
||||||
|
"log.spotMaker.cancelMismatched": {
|
||||||
|
zh: "撤销不匹配订单 {side} @ {price} reduceOnly={reduceOnly}",
|
||||||
|
en: "Cancelling mismatched order {side} @ {price} reduceOnly={reduceOnly}",
|
||||||
|
},
|
||||||
|
"log.spotMaker.cancelAlreadySettled": {
|
||||||
|
zh: "撤销时发现订单已被成交/取消,忽略",
|
||||||
|
en: "Order was already filled or cancelled; ignoring",
|
||||||
|
},
|
||||||
|
"log.spotMaker.cancelFailed": { zh: "撤销订单失败: {error}", en: "Failed to cancel order: {error}" },
|
||||||
|
"log.spotMaker.orderMissingOnCancel": {
|
||||||
|
zh: "订单已不存在,撤销跳过",
|
||||||
|
en: "Order no longer exists; skipping cancel",
|
||||||
|
},
|
||||||
|
"log.spotMaker.dustCloseFailed": {
|
||||||
|
zh: "小额市价平仓失败: {error}",
|
||||||
|
en: "Dust market close failed: {error}",
|
||||||
|
},
|
||||||
|
"log.spotMaker.dustClose": {
|
||||||
|
zh: "小额仓位使用市价平仓 {side} 数量 {qty}",
|
||||||
|
en: "Closing dust position at market: {side} qty {qty}",
|
||||||
|
},
|
||||||
|
"log.spotMaker.placeFailed": {
|
||||||
|
zh: "挂单失败({side} {price}): {error}",
|
||||||
|
en: "Failed to place order ({side} {price}): {error}",
|
||||||
|
},
|
||||||
|
"log.spotMaker.spotStop": {
|
||||||
|
zh: "现货止损,当前仓位={qty} PnL={pnl} USDT",
|
||||||
|
en: "Spot stop-loss: position={qty} PnL={pnl} USDT",
|
||||||
|
},
|
||||||
|
"log.spotMaker.spotStopFailed": { zh: "现货止损失败: {error}", en: "Spot stop-loss failed: {error}" },
|
||||||
|
"log.spotMaker.stopCloseMissing": {
|
||||||
|
zh: "止损平仓时订单已不存在",
|
||||||
|
en: "Order already gone while closing on stop",
|
||||||
|
},
|
||||||
|
"log.spotMaker.stopCloseFailed": { zh: "止损平仓失败: {error}", en: "Stop close failed: {error}" },
|
||||||
|
"log.spotMaker.entryPricePending": {
|
||||||
|
zh: "做市持仓均价未同步,等待账户快照刷新后再执行止损判断",
|
||||||
|
en: "Entry price not synced yet; waiting for an account refresh before evaluating the stop",
|
||||||
|
},
|
||||||
|
"log.spotMaker.stopTriggered": {
|
||||||
|
zh: "触发止损,方向={direction} 当前亏损={pnl} USDT",
|
||||||
|
en: "Stop-loss triggered: direction={direction} loss={pnl} USDT",
|
||||||
|
},
|
||||||
|
"log.spotMaker.updateHandlerError": {
|
||||||
|
zh: "更新回调处理异常: {error}",
|
||||||
|
en: "Update handler error: {error}",
|
||||||
|
},
|
||||||
|
"log.spotMaker.snapshotDispatchError": {
|
||||||
|
zh: "快照或更新分发异常: {error}",
|
||||||
|
en: "Snapshot/update dispatch error: {error}",
|
||||||
|
},
|
||||||
|
"log.offsetMaker.tickFailed": { zh: "偏移做市循环异常: {error}", en: "Offset maker tick failed: {error}" },
|
||||||
|
"log.offsetMaker.imbalanceClose": {
|
||||||
|
zh: "深度极端不平衡({buySum} vs {sellSum}), 市价平仓 {side}",
|
||||||
|
en: "Extreme depth imbalance ({buySum} vs {sellSum}); closing {side} at market",
|
||||||
|
},
|
||||||
|
"log.offsetMaker.imbalanceCloseMissing": {
|
||||||
|
zh: "深度不平衡平仓时订单已不存在",
|
||||||
|
en: "Order already gone during the imbalance close",
|
||||||
|
},
|
||||||
|
"log.offsetMaker.imbalanceCloseFailed": {
|
||||||
|
zh: "深度不平衡平仓失败: {error}",
|
||||||
|
en: "Imbalance close failed: {error}",
|
||||||
|
},
|
||||||
|
"log.liquidityMaker.tickFailed": {
|
||||||
|
zh: "流动性做市循环异常: {error}",
|
||||||
|
en: "Liquidity maker tick failed: {error}",
|
||||||
|
},
|
||||||
|
"log.liquidityMaker.fillDetected": {
|
||||||
|
zh: "检测到成交: {side} {qty} @ {price}",
|
||||||
|
en: "Fill detected: {side} {qty} @ {price}",
|
||||||
|
},
|
||||||
|
"log.liquidityMaker.exitRaisedToBreakeven": {
|
||||||
|
zh: "平仓价调整为入场价+1tick以确保不亏本: {price}",
|
||||||
|
en: "Exit raised to entry+1 tick to stay at or above breakeven: {price}",
|
||||||
|
},
|
||||||
|
"log.liquidityMaker.exitLoweredToBreakeven": {
|
||||||
|
zh: "平仓价调整为入场价-1tick以确保不亏本: {price}",
|
||||||
|
en: "Exit lowered to entry-1 tick to stay at or above breakeven: {price}",
|
||||||
|
},
|
||||||
|
// --- strategy/maker-points-engine ---
|
||||||
|
"log.mp.binanceError": { zh: "Binance {context} 异常: {error}", en: "Binance {context} error: {error}" },
|
||||||
|
"log.mp.binanceDisconnected": { zh: "Binance 深度连接断开", en: "Binance depth feed disconnected" },
|
||||||
|
"log.mp.binanceStale": { zh: "Binance 深度数据过时", en: "Binance depth data is stale" },
|
||||||
|
"log.mp.binanceRecovered": { zh: "Binance 深度连接恢复", en: "Binance depth feed recovered" },
|
||||||
|
"log.mp.wsDisconnected": {
|
||||||
|
zh: "WebSocket 断连 ({symbol}),启动断连保护",
|
||||||
|
en: "WebSocket disconnected ({symbol}); engaging disconnect protection",
|
||||||
|
},
|
||||||
|
"log.mp.wsReconnected": {
|
||||||
|
zh: "WebSocket 重连成功 ({symbol}),开始重连保护流程",
|
||||||
|
en: "WebSocket reconnected ({symbol}); running reconnect protection",
|
||||||
|
},
|
||||||
|
"log.mp.reconnectFoundOrders": {
|
||||||
|
zh: "重连后查询到 {count} 个挂单",
|
||||||
|
en: "Found {count} open orders after reconnecting",
|
||||||
|
},
|
||||||
|
"log.mp.reconnectCancelled": { zh: "重连保护:已取消所有挂单", en: "Reconnect protection: cancelled all orders" },
|
||||||
|
"log.mp.reconnectCancelPartial": {
|
||||||
|
zh: "重连保护:取消挂单未完全成功,将在下次循环重试",
|
||||||
|
en: "Reconnect protection: cancellation incomplete; retrying next tick",
|
||||||
|
},
|
||||||
|
"log.mp.reconnectFailed": { zh: "重连保护流程失败: {error}", en: "Reconnect protection failed: {error}" },
|
||||||
|
"log.mp.closeOnlyEntered": { zh: "进入平仓模式,仅挂 reduce-only", en: "Entered close-only mode; reduce-only quotes" },
|
||||||
|
"log.mp.closeOnlyExited": { zh: "退出平仓模式", en: "Left close-only mode" },
|
||||||
|
"log.mp.depthImbalancePause": {
|
||||||
|
zh: "Binance 深度失衡,暂停 {summary} 挂单",
|
||||||
|
en: "Binance depth imbalance; pausing {summary} quotes",
|
||||||
|
},
|
||||||
|
"log.mp.depthImbalanceResume": { zh: "Binance 深度恢复,继续挂单", en: "Binance depth recovered; resuming quotes" },
|
||||||
|
"log.mp.rateLimited": { zh: "限频触发,暂停挂单: {error}", en: "Rate limited; pausing quotes: {error}" },
|
||||||
|
"log.mp.tickFailed": { zh: "MakerPoints 主循环异常: {error}", en: "MakerPoints tick failed: {error}" },
|
||||||
|
"log.mp.precisionErrorResync": {
|
||||||
|
zh: "检测到精度错误,重新同步: {error}",
|
||||||
|
en: "Precision error detected; resyncing: {error}",
|
||||||
|
},
|
||||||
|
"log.mp.placeFailed": { zh: "挂单失败 {side} @ {price}: {error}", en: "Failed to place {side} @ {price}: {error}" },
|
||||||
|
"log.mp.unexpectedOrders": {
|
||||||
|
zh: "发现 {count} 个未预期挂单,执行强制取消",
|
||||||
|
en: "Found {count} unexpected orders; force-cancelling",
|
||||||
|
},
|
||||||
|
"log.mp.forceCancelled": {
|
||||||
|
zh: "已强制取消所有挂单,重置本地状态",
|
||||||
|
en: "Force-cancelled all orders and reset local state",
|
||||||
|
},
|
||||||
|
"log.mp.verifyOrdersFailed": { zh: "验证挂单状态失败: {error}", en: "Failed to verify order state: {error}" },
|
||||||
|
"log.mp.stopTriggered": {
|
||||||
|
zh: "触发止损: 实时未实现亏损 {pnl} USDT",
|
||||||
|
en: "Stop-loss triggered: live unrealised loss {pnl} USDT",
|
||||||
|
},
|
||||||
|
"log.mp.stopSucceeded": { zh: "止损成功: 仓位已清零", en: "Stop-loss done: position is flat" },
|
||||||
|
"log.mp.stopOrderMissing": {
|
||||||
|
zh: "止损平仓时订单已不存在,继续检查仓位",
|
||||||
|
en: "Order already gone during the stop close; rechecking the position",
|
||||||
|
},
|
||||||
|
"log.mp.stopPrecisionResync": {
|
||||||
|
zh: "止损平仓精度错误,重新同步: {error}",
|
||||||
|
en: "Precision error during the stop close; resyncing: {error}",
|
||||||
|
},
|
||||||
|
"log.mp.stopRetry": {
|
||||||
|
zh: "止损平仓失败 (重试 {attempt}/{max}): {error}",
|
||||||
|
en: "Stop close failed (retry {attempt}/{max}): {error}",
|
||||||
|
},
|
||||||
|
"log.mp.stopRetriesExhausted": {
|
||||||
|
zh: "止损重试已达上限 ({max} 次),请手动检查仓位",
|
||||||
|
en: "Stop retries exhausted ({max}); check the position manually",
|
||||||
|
},
|
||||||
|
"log.mp.updateHandlerError": { zh: "更新监听异常: {error}", en: "Update listener error: {error}" },
|
||||||
|
"log.mp.snapshotError": { zh: "快照生成异常: {error}", en: "Snapshot build error: {error}" },
|
||||||
|
"log.mp.noTargets": { zh: "暂无目标挂单", en: "No target orders" },
|
||||||
|
"log.mp.targets": { zh: "目标挂单: {summary}", en: "Target orders: {summary}" },
|
||||||
|
"log.mp.skipThinDepth": {
|
||||||
|
zh: "跳过 {side} {bps}bps 挂单: 深度 {depth} BTC < {min} BTC",
|
||||||
|
en: "Skipping {side} {bps}bps quote: depth {depth} BTC < {min} BTC",
|
||||||
|
},
|
||||||
|
"log.mp.depthRecovered": {
|
||||||
|
zh: "{side} {bps}bps 深度恢复,继续挂单",
|
||||||
|
en: "{side} {bps}bps depth recovered; resuming quotes",
|
||||||
|
},
|
||||||
|
"log.mp.insufficientBalance": {
|
||||||
|
zh: "余额不足,暂停挂单 {seconds}s: {detail}",
|
||||||
|
en: "Insufficient balance; pausing quotes for {seconds}s: {detail}",
|
||||||
|
},
|
||||||
|
"log.mp.balanceRecovered": { zh: "余额恢复,继续挂单", en: "Balance recovered; resuming quotes" },
|
||||||
|
"log.mp.defenseEntered": {
|
||||||
|
zh: "数据过时检测: {summary},进入防御模式",
|
||||||
|
en: "Stale-data check: {summary}; entering defense mode",
|
||||||
|
},
|
||||||
|
"log.mp.defenseExited": {
|
||||||
|
zh: "数据推送恢复正常,退出防御模式",
|
||||||
|
en: "Data feeds recovered; leaving defense mode",
|
||||||
|
},
|
||||||
|
"log.mp.defenseForceCancelled": {
|
||||||
|
zh: "防御模式: 已强制取消所有挂单",
|
||||||
|
en: "Defense mode: force-cancelled all orders",
|
||||||
|
},
|
||||||
|
"log.mp.defenseCancelPartial": {
|
||||||
|
zh: "防御模式: 取消挂单未完全成功,将继续重试",
|
||||||
|
en: "Defense mode: cancellation incomplete; will retry",
|
||||||
|
},
|
||||||
|
"log.mp.defenseCancelled": { zh: "防御模式: 已取消所有挂单", en: "Defense mode: cancelled all orders" },
|
||||||
|
"log.mp.defenseOrdersGone": { zh: "防御模式: 挂单已不存在", en: "Defense mode: orders already gone" },
|
||||||
|
"log.mp.defenseCancelFailed": {
|
||||||
|
zh: "防御模式取消挂单失败: {error}",
|
||||||
|
en: "Defense mode cancel failed: {error}",
|
||||||
|
},
|
||||||
|
"log.mp.defensePollStarted": {
|
||||||
|
zh: "防御模式: 启动 REST 数据轮询",
|
||||||
|
en: "Defense mode: started REST polling",
|
||||||
|
},
|
||||||
|
"log.mp.defensePollStopped": {
|
||||||
|
zh: "防御模式: 停止 REST 数据轮询",
|
||||||
|
en: "Defense mode: stopped REST polling",
|
||||||
|
},
|
||||||
|
"log.mp.defensePositionStillBad": {
|
||||||
|
zh: "防御模式: 仓位数据仍异常: {issues}",
|
||||||
|
en: "Defense mode: position data still invalid: {issues}",
|
||||||
|
},
|
||||||
|
"log.mp.defenseEmptySnapshot": {
|
||||||
|
zh: "防御模式: REST 获取账户快照为空",
|
||||||
|
en: "Defense mode: REST returned an empty account snapshot",
|
||||||
|
},
|
||||||
|
"log.mp.defenseFoundOrders": {
|
||||||
|
zh: "防御模式: 发现 {count} 个挂单,执行取消",
|
||||||
|
en: "Defense mode: found {count} open orders; cancelling",
|
||||||
|
},
|
||||||
|
"log.mp.defenseQueryFailed": {
|
||||||
|
zh: "防御模式查询挂单失败: {error}",
|
||||||
|
en: "Defense mode open-order query failed: {error}",
|
||||||
|
},
|
||||||
|
"log.mp.defensePollFailed": {
|
||||||
|
zh: "防御模式 REST 轮询失败: {error}",
|
||||||
|
en: "Defense mode REST poll failed: {error}",
|
||||||
|
},
|
||||||
|
"notify.mp.disconnectTitle": { zh: "连接断开", en: "Disconnected" },
|
||||||
|
"notify.mp.disconnectBody": {
|
||||||
|
zh: "WebSocket 断连,正在尝试取消所有挂单",
|
||||||
|
en: "WebSocket disconnected; cancelling all open orders",
|
||||||
|
},
|
||||||
|
"notify.mp.reconnectTitle": { zh: "重连完成", en: "Reconnected" },
|
||||||
|
"notify.mp.reconnectBody": {
|
||||||
|
zh: "WebSocket 重连成功,已清理挂单状态",
|
||||||
|
en: "WebSocket reconnected; order state cleaned up",
|
||||||
|
},
|
||||||
|
"notify.mp.stopTitle": { zh: "止损触发", en: "Stop-loss triggered" },
|
||||||
|
"notify.mp.stopBody": {
|
||||||
|
zh: "实时未实现亏损 {pnl} USDT,强制平仓",
|
||||||
|
en: "Live unrealised loss {pnl} USDT; forcing a close",
|
||||||
|
},
|
||||||
|
"notify.mp.defenseTitle": { zh: "防御模式", en: "Defense mode" },
|
||||||
|
"notify.mp.defenseBody": {
|
||||||
|
zh: "数据推送中断: {summary},已取消所有挂单",
|
||||||
|
en: "Data feed interrupted: {summary}; cancelled all open orders",
|
||||||
|
},
|
||||||
|
"notify.mp.defenseClearedTitle": { zh: "防御模式解除", en: "Defense mode cleared" },
|
||||||
|
"notify.mp.defenseClearedBody": {
|
||||||
|
zh: "数据推送恢复正常,恢复正常交易",
|
||||||
|
en: "Data feeds are healthy again; resuming normal trading",
|
||||||
|
},
|
||||||
|
"notify.mp.openTitle": { zh: "开仓", en: "Position opened" },
|
||||||
|
"notify.mp.closeTitle": { zh: "平仓", en: "Position closed" },
|
||||||
|
"notify.mp.closeTitleTokenExpired": { zh: "Token过期平仓", en: "Token-expiry close" },
|
||||||
|
"notify.mp.increaseTitle": { zh: "加仓", en: "Position increased" },
|
||||||
|
"notify.mp.reduceTitle": { zh: "减仓", en: "Position reduced" },
|
||||||
|
"notify.mp.reverseTitle": { zh: "反向开仓", en: "Position reversed" },
|
||||||
|
"notify.mp.openBody": { zh: "{direction} {qty}", en: "{direction} {qty}" },
|
||||||
|
"notify.mp.closeBody": { zh: "已平仓 {qty} ({direction})", en: "Closed {qty} ({direction})" },
|
||||||
|
"notify.mp.increaseBody": {
|
||||||
|
zh: "{direction} +{delta} → {qty}",
|
||||||
|
en: "{direction} +{delta} → {qty}",
|
||||||
|
},
|
||||||
|
"notify.mp.reduceBody": { zh: "{direction} -{delta} → {qty}", en: "{direction} -{delta} → {qty}" },
|
||||||
|
"notify.mp.reverseBody": { zh: "{transition} {qty}", en: "{transition} {qty}" },
|
||||||
|
"common.direction.longToShort": { zh: "多→空", en: "long → short" },
|
||||||
|
"common.direction.shortToLong": { zh: "空→多", en: "short → long" },
|
||||||
|
// --- strategy/maker-points-defense (stale-reason summary) ---
|
||||||
|
"defense.reason.depth": { zh: "StandX深度({seconds}s)", en: "StandX depth ({seconds}s)" },
|
||||||
|
"defense.reason.account": { zh: "StandX账户({seconds}s)", en: "StandX account ({seconds}s)" },
|
||||||
|
"defense.reason.accountInvalid": {
|
||||||
|
zh: "StandX仓位数据异常({issues})",
|
||||||
|
en: "StandX position data invalid ({issues})",
|
||||||
|
},
|
||||||
|
"defense.reason.rest": { zh: "StandX REST错误({count}次)", en: "StandX REST errors ({count})" },
|
||||||
|
"defense.reason.marginMode": { zh: "保证金模式({mode})", en: "Margin mode ({mode})" },
|
||||||
|
"defense.reason.binanceDepth": { zh: "Binance深度({seconds}s)", en: "Binance depth ({seconds}s)" },
|
||||||
|
"defense.reason.binanceBook": {
|
||||||
|
zh: "Binance簿记异常({reason})",
|
||||||
|
en: "Binance order book unhealthy ({reason})",
|
||||||
|
},
|
||||||
|
"defense.reason.unknown": { zh: "unknown", en: "unknown" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTemplate = (template: string, params: Record<string, unknown>): string => {
|
const formatTemplate = (template: string, params: Record<string, unknown>): string => {
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ export class BasisArbEngine {
|
|||||||
const spotTs = snapshot.spotLastUpdate ?? 0;
|
const spotTs = snapshot.spotLastUpdate ?? 0;
|
||||||
if (futTs <= readyAt || spotTs <= readyAt) return;
|
if (futTs <= readyAt || spotTs <= readyAt) return;
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
// Use net spread after taker fees to match UI's "扣除 taker 手续费" bp
|
// Use net spread after taker fees to match the bp figure the UI labels as taker-fee-adjusted
|
||||||
const spreadBps = snapshot.netSpreadBps;
|
const spreadBps = snapshot.netSpreadBps;
|
||||||
const fundingRate = snapshot.fundingRate;
|
const fundingRate = snapshot.fundingRate;
|
||||||
const nextFundingTime = snapshot.nextFundingTime;
|
const nextFundingTime = snapshot.nextFundingTime;
|
||||||
|
|||||||
@@ -1,41 +1,80 @@
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { GridDirection } from "../../config";
|
import type { LevelPhase, StoredGridStateV2, StoredLevelV2 } from "../grid-logic";
|
||||||
|
|
||||||
const DATA_DIR = process.env.GRID_DATA_DIR?.trim() || path.resolve("data");
|
export type { LevelPhase, StoredGridStateV2, StoredLevelV2 };
|
||||||
const GRID_FILE = path.resolve(DATA_DIR, "grid-record.json");
|
|
||||||
|
|
||||||
/** State of a single grid level */
|
// 惰性解析,测试可通过 GRID_DATA_DIR 切换目录
|
||||||
export type LevelState = "idle" | "filled" | "exit_placed";
|
function dataDir(): string {
|
||||||
|
return process.env.GRID_DATA_DIR?.trim() || path.resolve("data");
|
||||||
export interface StoredLevelInfo {
|
|
||||||
state: LevelState;
|
|
||||||
/** The grid level index where ENTRY was filled */
|
|
||||||
sourceLevel: number;
|
|
||||||
/** The grid level index where EXIT is targeted (closeTarget) */
|
|
||||||
targetLevel: number | null;
|
|
||||||
/** The orderId of the EXIT order on exchange (if exit_placed) */
|
|
||||||
exitOrderId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredGridState {
|
function gridFile(): string {
|
||||||
|
return path.resolve(dataDir(), "grid-record.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1 遗留格式(无 schemaVersion 字段) */
|
||||||
|
interface StoredGridStateV1 {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
lowerPrice: number;
|
lowerPrice: number;
|
||||||
upperPrice: number;
|
upperPrice: number;
|
||||||
gridLevels: number;
|
gridLevels: number;
|
||||||
orderSize: number;
|
orderSize: number;
|
||||||
maxPositionSize: number;
|
maxPositionSize: number;
|
||||||
direction: GridDirection;
|
direction: string;
|
||||||
/** Per-level state: key is level index string */
|
levels: Record<
|
||||||
levels: Record<string, StoredLevelInfo>;
|
string,
|
||||||
|
{ state: "idle" | "filled" | "exit_placed"; sourceLevel: number; targetLevel: number | null; exitOrderId?: string }
|
||||||
|
>;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GridStateMap = Record<string, StoredGridState>;
|
type StoredGridStateAny = StoredGridStateV1 | StoredGridStateV2;
|
||||||
|
type GridStateMap = Record<string, StoredGridStateAny>;
|
||||||
|
|
||||||
|
function isV2(entry: StoredGridStateAny): entry is StoredGridStateV2 {
|
||||||
|
return (entry as StoredGridStateV2).schemaVersion === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1 → v2:filled→holding、exit_placed→exit_placed,holdQty 取 orderSize,锚定价缺失由引擎补齐 */
|
||||||
|
export function migrateV1ToV2(v1: StoredGridStateV1): StoredGridStateV2 {
|
||||||
|
const levels: Record<string, StoredLevelV2> = {};
|
||||||
|
for (const [key, info] of Object.entries(v1.levels ?? {})) {
|
||||||
|
if (!info || info.state === "idle") continue;
|
||||||
|
const phase: LevelPhase = info.state === "filled" ? "holding" : "exit_placed";
|
||||||
|
const entry: StoredLevelV2 = {
|
||||||
|
phase,
|
||||||
|
exitTarget: info.targetLevel ?? null,
|
||||||
|
holdQty: Number.isFinite(v1.orderSize) ? v1.orderSize : 0,
|
||||||
|
};
|
||||||
|
if (info.exitOrderId) entry.exitOrderId = info.exitOrderId;
|
||||||
|
levels[key] = entry;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
symbol: v1.symbol,
|
||||||
|
exchangeId: "",
|
||||||
|
gridVersion: 1,
|
||||||
|
anchorPrice: null,
|
||||||
|
lowerPrice: v1.lowerPrice,
|
||||||
|
upperPrice: v1.upperPrice,
|
||||||
|
gridLevels: v1.gridLevels,
|
||||||
|
orderSize: v1.orderSize,
|
||||||
|
maxPositionSize: v1.maxPositionSize,
|
||||||
|
direction: v1.direction,
|
||||||
|
gridMode: "geometric",
|
||||||
|
levels,
|
||||||
|
intents: [],
|
||||||
|
inflight: null,
|
||||||
|
shift: null,
|
||||||
|
exchangeStop: null,
|
||||||
|
updatedAt: v1.updatedAt ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureDataDir(): Promise<void> {
|
async function ensureDataDir(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
await fs.mkdir(dataDir(), { recursive: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -43,7 +82,7 @@ async function ensureDataDir(): Promise<void> {
|
|||||||
|
|
||||||
async function readStateFile(): Promise<GridStateMap> {
|
async function readStateFile(): Promise<GridStateMap> {
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(GRID_FILE, "utf8");
|
const content = await fs.readFile(gridFile(), "utf8");
|
||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
if (parsed && typeof parsed === "object") {
|
if (parsed && typeof parsed === "object") {
|
||||||
return parsed as GridStateMap;
|
return parsed as GridStateMap;
|
||||||
@@ -57,17 +96,19 @@ async function readStateFile(): Promise<GridStateMap> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadGridState(symbol: string): Promise<StoredGridState | null> {
|
export async function loadGridState(symbol: string): Promise<StoredGridStateV2 | null> {
|
||||||
const map = await readStateFile();
|
const map = await readStateFile();
|
||||||
const snapshot = map[symbol];
|
const snapshot = map[symbol];
|
||||||
return snapshot ?? null;
|
if (!snapshot) return null;
|
||||||
|
if (isV2(snapshot)) return snapshot;
|
||||||
|
return migrateV1ToV2(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveGridState(snapshot: StoredGridState): Promise<void> {
|
export async function saveGridState(snapshot: StoredGridStateV2): Promise<void> {
|
||||||
await ensureDataDir();
|
await ensureDataDir();
|
||||||
const map = await readStateFile();
|
const map = await readStateFile();
|
||||||
map[snapshot.symbol] = snapshot;
|
map[snapshot.symbol] = snapshot;
|
||||||
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
|
await fs.writeFile(gridFile(), JSON.stringify(map, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearGridState(symbol: string): Promise<void> {
|
export async function clearGridState(symbol: string): Promise<void> {
|
||||||
@@ -79,7 +120,7 @@ export async function clearGridState(symbol: string): Promise<void> {
|
|||||||
const entries = Object.keys(map);
|
const entries = Object.keys(map);
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(GRID_FILE);
|
await fs.unlink(gridFile());
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
|
if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -88,5 +129,5 @@ export async function clearGridState(symbol: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ensureDataDir();
|
await ensureDataDir();
|
||||||
await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8");
|
await fs.writeFile(gridFile(), JSON.stringify(map, null, 2), "utf8");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { AccountSnapshot } from "../../exchanges/types";
|
||||||
|
import { extractMessage } from "../../utils/errors";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type { LogHandler } from "./subscriptions";
|
||||||
|
|
||||||
|
/** Snapshot refreshes to wait through before giving up on the switch (~5s total). */
|
||||||
|
const MAX_CONFIRM_ATTEMPTS = 10;
|
||||||
|
const CONFIRM_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
export interface IsolatedMarginGuardDeps {
|
||||||
|
symbol: string;
|
||||||
|
/** False on venues that do not expose a per-symbol margin mode; the guard is then inert. */
|
||||||
|
enabled: boolean;
|
||||||
|
log: LogHandler;
|
||||||
|
/** Latest account snapshot the engine holds. */
|
||||||
|
currentSnapshot: () => AccountSnapshot | null;
|
||||||
|
changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise<void>;
|
||||||
|
queryAccountSnapshot?: () => Promise<AccountSnapshot | null>;
|
||||||
|
/** Feeds a freshly polled snapshot back into the engine before re-reading the mode. */
|
||||||
|
applySnapshot: (snapshot: AccountSnapshot) => void;
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the traded symbol on isolated margin.
|
||||||
|
*
|
||||||
|
* The switch is asynchronous at the venue: the REST call returns before the
|
||||||
|
* account reflects it, so the guard polls until the new mode shows up. A single
|
||||||
|
* in-flight promise makes concurrent ticks share one attempt instead of firing
|
||||||
|
* the change repeatedly.
|
||||||
|
*/
|
||||||
|
export class IsolatedMarginGuard {
|
||||||
|
private ensuring: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly deps: IsolatedMarginGuardDeps) {}
|
||||||
|
|
||||||
|
/** The venue's margin mode for this symbol, lowercased, or null when unknown. */
|
||||||
|
currentMode(snapshot: AccountSnapshot | null = this.deps.currentSnapshot()): string | null {
|
||||||
|
if (!this.deps.enabled) return null;
|
||||||
|
const positions = snapshot?.positions ?? [];
|
||||||
|
const match = positions.find((pos) => pos.symbol === this.deps.symbol);
|
||||||
|
const raw = (match as { marginType?: unknown; margin_mode?: unknown } | undefined)?.marginType ??
|
||||||
|
(match as { margin_mode?: unknown } | undefined)?.margin_mode;
|
||||||
|
const mode = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
||||||
|
return mode ? mode : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns true when the symbol is on isolated margin. A false result means the
|
||||||
|
* caller should hold off trading — either a switch is under way or it failed.
|
||||||
|
*/
|
||||||
|
async ensureIsolated(): Promise<boolean> {
|
||||||
|
if (!this.deps.enabled) return true;
|
||||||
|
if (this.currentMode() === "isolated") return true;
|
||||||
|
|
||||||
|
const { changeMarginMode, queryAccountSnapshot } = this.deps;
|
||||||
|
if (!changeMarginMode || !queryAccountSnapshot) return false;
|
||||||
|
// Another tick is already switching; do not stack a second request.
|
||||||
|
if (this.ensuring) return false;
|
||||||
|
|
||||||
|
this.ensuring = this.performSwitch(changeMarginMode, queryAccountSnapshot);
|
||||||
|
return await this.ensuring;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performSwitch(
|
||||||
|
changeMarginMode: NonNullable<IsolatedMarginGuardDeps["changeMarginMode"]>,
|
||||||
|
queryAccountSnapshot: NonNullable<IsolatedMarginGuardDeps["queryAccountSnapshot"]>
|
||||||
|
): Promise<boolean> {
|
||||||
|
const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
||||||
|
try {
|
||||||
|
await changeMarginMode({ symbol: this.deps.symbol, marginMode: "isolated" });
|
||||||
|
for (let attempt = 0; attempt < MAX_CONFIRM_ATTEMPTS; attempt += 1) {
|
||||||
|
const next = await queryAccountSnapshot();
|
||||||
|
if (next) {
|
||||||
|
this.deps.applySnapshot(next);
|
||||||
|
}
|
||||||
|
if (this.currentMode() === "isolated") {
|
||||||
|
this.deps.log("info", t("log.margin.switched"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await sleep(CONFIRM_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
this.deps.log("warn", t("log.margin.switchUnconfirmed", { mode: this.currentMode() ?? "unknown" }));
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
this.deps.log("error", t("log.margin.switchFailed", { error: extractMessage(error) }));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.ensuring = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { ExchangeAdapter, ExchangePrecision } from "../../exchanges/adapter";
|
||||||
|
import { extractMessage } from "../../utils/errors";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type { LogHandler } from "./subscriptions";
|
||||||
|
|
||||||
|
/** Smallest tick/step an engine will accept; guards against a config of 0. */
|
||||||
|
const MIN_INCREMENT = 1e-9;
|
||||||
|
/** Two ticks that differ by less than this are the same tick. */
|
||||||
|
const INCREMENT_EPSILON = 1e-12;
|
||||||
|
const RETRY_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
export interface PrecisionSeed {
|
||||||
|
priceTick: number;
|
||||||
|
qtyStep: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config slice the syncer writes through to. Engines that read
|
||||||
|
* `config.priceTick` / `config.qtyStep` directly stay correct without change.
|
||||||
|
* `qtyStep` is optional: the maker-family configs carry only a price tick.
|
||||||
|
*/
|
||||||
|
export interface PrecisionConfigTarget {
|
||||||
|
priceTick: number;
|
||||||
|
qtyStep?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrecisionSyncerMessages {
|
||||||
|
synced: (precision: ExchangePrecision) => string;
|
||||||
|
failed: (error: unknown) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches trading precision from the exchange once, retrying until it lands, and
|
||||||
|
* exposes the live values every engine quotes against.
|
||||||
|
*
|
||||||
|
* Owns its retry timer so a stopped engine stops retrying — the eight hand-rolled
|
||||||
|
* copies of this logic leaked one retry loop each.
|
||||||
|
*/
|
||||||
|
export class PrecisionSyncer {
|
||||||
|
private priceTickValue: number;
|
||||||
|
private qtyStepValue: number;
|
||||||
|
private minBaseAmountValue: number | null = null;
|
||||||
|
private minQuoteAmountValue: number | null = null;
|
||||||
|
|
||||||
|
private inFlight: Promise<void> | null = null;
|
||||||
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private stopped = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly exchange: ExchangeAdapter,
|
||||||
|
private readonly config: PrecisionConfigTarget,
|
||||||
|
seed: PrecisionSeed,
|
||||||
|
private readonly log: LogHandler,
|
||||||
|
private readonly messages: PrecisionSyncerMessages
|
||||||
|
) {
|
||||||
|
this.priceTickValue = Math.max(MIN_INCREMENT, seed.priceTick);
|
||||||
|
this.qtyStepValue = Math.max(MIN_INCREMENT, seed.qtyStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
get priceTick(): number {
|
||||||
|
return this.priceTickValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
get qtyStep(): number {
|
||||||
|
return this.qtyStepValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
get minBaseAmount(): number | null {
|
||||||
|
return this.minBaseAmountValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
get minQuoteAmount(): number | null {
|
||||||
|
return this.minQuoteAmountValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent: a sync already in flight or already completed is not repeated. */
|
||||||
|
start(): void {
|
||||||
|
if (this.stopped || this.inFlight) return;
|
||||||
|
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||||||
|
if (!getPrecision) return;
|
||||||
|
|
||||||
|
this.inFlight = getPrecision()
|
||||||
|
.then((precision) => {
|
||||||
|
if (this.stopped || !precision) return;
|
||||||
|
if (this.apply(precision)) {
|
||||||
|
this.log("info", this.messages.synced(precision));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.inFlight = null;
|
||||||
|
if (this.stopped) return;
|
||||||
|
this.log("error", this.messages.failed(extractMessage(error)));
|
||||||
|
this.retryTimer = setTimeout(() => {
|
||||||
|
this.retryTimer = null;
|
||||||
|
this.start();
|
||||||
|
}, RETRY_DELAY_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discards the completed sync so the next start() refetches. */
|
||||||
|
refresh(): void {
|
||||||
|
this.inFlight = null;
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.retryTimer) {
|
||||||
|
clearTimeout(this.retryTimer);
|
||||||
|
this.retryTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns whether either increment actually moved. */
|
||||||
|
private apply(precision: ExchangePrecision): boolean {
|
||||||
|
let changed = false;
|
||||||
|
if (isUsableIncrement(precision.priceTick) && differs(precision.priceTick, this.priceTickValue)) {
|
||||||
|
this.priceTickValue = precision.priceTick;
|
||||||
|
this.config.priceTick = precision.priceTick;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (isUsableIncrement(precision.qtyStep) && differs(precision.qtyStep, this.qtyStepValue)) {
|
||||||
|
this.qtyStepValue = precision.qtyStep;
|
||||||
|
this.config.qtyStep = precision.qtyStep;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (precision.minBaseAmount != null && Number.isFinite(precision.minBaseAmount)) {
|
||||||
|
this.minBaseAmountValue = precision.minBaseAmount;
|
||||||
|
}
|
||||||
|
if (precision.minQuoteAmount != null && Number.isFinite(precision.minQuoteAmount)) {
|
||||||
|
this.minQuoteAmountValue = precision.minQuoteAmount;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every engine reports precision sync with the same wording, so they share one
|
||||||
|
* syncer built from `log.common.precision*`.
|
||||||
|
*
|
||||||
|
* @param seedQtyStep step used until the exchange reports one. Maker-family engines
|
||||||
|
* pass a fixed default; config-driven engines pass `config.qtyStep`.
|
||||||
|
*/
|
||||||
|
export function createPrecisionSyncer(
|
||||||
|
exchange: ExchangeAdapter,
|
||||||
|
config: PrecisionConfigTarget,
|
||||||
|
seedQtyStep: number,
|
||||||
|
log: LogHandler
|
||||||
|
): PrecisionSyncer {
|
||||||
|
return new PrecisionSyncer(
|
||||||
|
exchange,
|
||||||
|
config,
|
||||||
|
{ priceTick: config.priceTick, qtyStep: seedQtyStep },
|
||||||
|
log,
|
||||||
|
{
|
||||||
|
synced: (precision) =>
|
||||||
|
t("log.common.precisionSynced", {
|
||||||
|
priceTick: precision.priceTick,
|
||||||
|
qtyStep: precision.qtyStep,
|
||||||
|
}),
|
||||||
|
failed: (error) => t("log.common.precisionFailed", { error: String(error) }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsableIncrement(value: number | undefined): value is number {
|
||||||
|
return value != null && Number.isFinite(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function differs(next: number, current: number): boolean {
|
||||||
|
return Math.abs(next - current) > INCREMENT_EPSILON;
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { extractMessage, isUnknownOrderError } from "../../utils/errors";
|
||||||
|
import {
|
||||||
|
checkStandxTokenExpiry,
|
||||||
|
formatTokenExpiryMessage,
|
||||||
|
isTokenExpiryConfigured,
|
||||||
|
type TokenExpiryState,
|
||||||
|
type TokenExpiryStatus,
|
||||||
|
} from "../../utils/standx-token-expiry";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import type { LogHandler } from "./subscriptions";
|
||||||
|
|
||||||
|
/** What the engine should do for the rest of this tick. */
|
||||||
|
export type TokenExpiryDecision =
|
||||||
|
/** Token is live, or expired but a position still needs managing — keep ticking. */
|
||||||
|
| { halt: false; closeOnly: boolean }
|
||||||
|
/** Expired with nothing left to manage — skip the rest of the tick. */
|
||||||
|
| { halt: true; closeOnly: boolean };
|
||||||
|
|
||||||
|
export interface TokenExpiryGuardDeps {
|
||||||
|
log: LogHandler;
|
||||||
|
notify: (notification: {
|
||||||
|
hasPosition: boolean;
|
||||||
|
hasOpenOrders: boolean;
|
||||||
|
state: TokenExpiryState;
|
||||||
|
}) => void;
|
||||||
|
/** Cancels every resting order; resolves once the venue has accepted. */
|
||||||
|
cancelAllOrders: () => Promise<void>;
|
||||||
|
/** Called after a successful cancel so the engine can drop its local copy. */
|
||||||
|
onOrdersCancelled: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the StandX token-expiry episode.
|
||||||
|
*
|
||||||
|
* The venue's token expires on a wall clock, and each consequence — the warning
|
||||||
|
* log, the alert, the cancel-everything sweep — must happen exactly once per
|
||||||
|
* episode and reset when a fresh token arrives. That is five latches whose only
|
||||||
|
* correctness property is that they move together, so they live in one class
|
||||||
|
* rather than loose beside forty other engine fields.
|
||||||
|
*/
|
||||||
|
export class TokenExpiryGuard {
|
||||||
|
private state: TokenExpiryState = "active";
|
||||||
|
private logged = false;
|
||||||
|
private notified = false;
|
||||||
|
private cancelDone = false;
|
||||||
|
private closeOnly = false;
|
||||||
|
|
||||||
|
constructor(private readonly deps: TokenExpiryGuardDeps) {}
|
||||||
|
|
||||||
|
/** True once expiry has forced the engine into reduce-only quoting. */
|
||||||
|
get closeOnlyMode(): boolean {
|
||||||
|
return this.closeOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentState(): TokenExpiryState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(params: { positionAmt: number; openOrderCount: number }): Promise<TokenExpiryDecision> {
|
||||||
|
if (!isTokenExpiryConfigured()) {
|
||||||
|
return { halt: false, closeOnly: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = checkStandxTokenExpiry(params);
|
||||||
|
if (!status.expired) {
|
||||||
|
this.reset();
|
||||||
|
return { halt: false, closeOnly: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousState = this.state;
|
||||||
|
this.state = status.state;
|
||||||
|
|
||||||
|
this.logOnce(status);
|
||||||
|
this.notifyOnce(status);
|
||||||
|
await this.cancelOnce(params.openOrderCount);
|
||||||
|
|
||||||
|
if (status.state === "expired_with_position") {
|
||||||
|
if (!this.closeOnly) {
|
||||||
|
this.closeOnly = true;
|
||||||
|
this.deps.log("info", t("log.token.closeOnlyForced"));
|
||||||
|
}
|
||||||
|
return { halt: false, closeOnly: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.state === "silent" && previousState !== "silent") {
|
||||||
|
this.deps.log("info", t("log.token.silentEntered"));
|
||||||
|
}
|
||||||
|
return { halt: true, closeOnly: this.closeOnly };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh token clears every latch so the next episode reports itself again. */
|
||||||
|
private reset(): void {
|
||||||
|
if (this.state === "active") return;
|
||||||
|
this.state = "active";
|
||||||
|
this.logged = false;
|
||||||
|
this.notified = false;
|
||||||
|
this.cancelDone = false;
|
||||||
|
this.closeOnly = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private logOnce(status: TokenExpiryStatus): void {
|
||||||
|
if (this.logged) return;
|
||||||
|
const message = formatTokenExpiryMessage(status);
|
||||||
|
if (message) {
|
||||||
|
this.deps.log("warn", message);
|
||||||
|
}
|
||||||
|
this.logged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyOnce(status: TokenExpiryStatus): void {
|
||||||
|
if (this.notified) return;
|
||||||
|
this.deps.notify({
|
||||||
|
hasPosition: status.hasPosition,
|
||||||
|
hasOpenOrders: status.hasOpenOrders,
|
||||||
|
state: status.state,
|
||||||
|
});
|
||||||
|
this.notified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cancelOnce(openOrderCount: number): Promise<void> {
|
||||||
|
if (this.cancelDone || openOrderCount === 0) return;
|
||||||
|
try {
|
||||||
|
await this.deps.cancelAllOrders();
|
||||||
|
this.deps.log("order", t("log.token.ordersCancelled"));
|
||||||
|
this.deps.onOrdersCancelled();
|
||||||
|
this.cancelDone = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnknownOrderError(error)) {
|
||||||
|
// Nothing left to cancel is the outcome we wanted.
|
||||||
|
this.deps.log("order", t("log.token.cancelOrderMissing"));
|
||||||
|
this.cancelDone = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Leave cancelDone false so the next tick retries.
|
||||||
|
this.deps.log("error", t("log.token.cancelFailed", { error: extractMessage(error) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+796
-1334
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+61
-101
@@ -8,13 +8,14 @@ import {
|
|||||||
type PositionSnapshot,
|
type PositionSnapshot,
|
||||||
} from "../utils/strategy";
|
} from "../utils/strategy";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import {
|
import {
|
||||||
placeStopLossOrder,
|
placeStopLossOrder,
|
||||||
placeTrailingStopOrder,
|
placeTrailingStopOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||||
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
||||||
import { formatPriceToString } from "../utils/math";
|
import { formatPriceToString } from "../utils/math";
|
||||||
@@ -64,14 +65,30 @@ export class GuardianEngine {
|
|||||||
price: null,
|
price: null,
|
||||||
at: 0,
|
at: 0,
|
||||||
};
|
};
|
||||||
private precisionSync: Promise<void> | null = null;
|
private readonly precision: PrecisionSyncer;
|
||||||
|
|
||||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
this.syncPrecision();
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
||||||
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
||||||
|
private get orderContext(): OrderContext {
|
||||||
|
return (this.orderContextCache ??= {
|
||||||
|
adapter: this.exchange,
|
||||||
|
symbol: this.config.symbol,
|
||||||
|
locks: this.locks,
|
||||||
|
timers: this.timers,
|
||||||
|
pendings: this.pending,
|
||||||
|
log: (type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private orderContextCache: OrderContext | null = null;
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -84,6 +101,7 @@ export class GuardianEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
on(event: GuardianEngineEvent, handler: GuardianEngineListener): void {
|
||||||
@@ -385,24 +403,19 @@ export class GuardianEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await placeStopLossOrder(
|
await placeStopLossOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
stopPrice: stopPrice,
|
||||||
this.locks,
|
quantity: quantity,
|
||||||
this.timers,
|
lastPrice: lastPrice,
|
||||||
this.pending,
|
guard: {
|
||||||
side,
|
|
||||||
stopPrice,
|
|
||||||
quantity,
|
|
||||||
lastPrice,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
this.lastStopAttempt.side = side;
|
this.lastStopAttempt.side = side;
|
||||||
this.lastStopAttempt.price = stopPrice;
|
this.lastStopAttempt.price = stopPrice;
|
||||||
this.lastStopAttempt.at = now;
|
this.lastStopAttempt.at = now;
|
||||||
@@ -444,24 +457,19 @@ export class GuardianEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const order = await placeStopLossOrder(
|
const order = await placeStopLossOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
stopPrice: nextStopPrice,
|
||||||
this.locks,
|
quantity: quantity,
|
||||||
this.timers,
|
lastPrice: lastPrice,
|
||||||
this.pending,
|
guard: {
|
||||||
side,
|
|
||||||
nextStopPrice,
|
|
||||||
quantity,
|
|
||||||
lastPrice,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
if (order) {
|
if (order) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
@@ -479,24 +487,19 @@ export class GuardianEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const restored = await placeStopLossOrder(
|
const restored = await placeStopLossOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
stopPrice: Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
|
||||||
this.locks,
|
quantity: quantity,
|
||||||
this.timers,
|
lastPrice: lastPrice,
|
||||||
this.pending,
|
guard: {
|
||||||
side,
|
|
||||||
Number.isFinite(existingStopPrice) ? existingStopPrice : nextStopPrice,
|
|
||||||
quantity,
|
|
||||||
lastPrice,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
if (restored && Number.isFinite(existingStopPrice)) {
|
if (restored && Number.isFinite(existingStopPrice)) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
@@ -520,24 +523,19 @@ export class GuardianEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await placeTrailingStopOrder(
|
await placeTrailingStopOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
activationPrice: activationPrice,
|
||||||
this.locks,
|
quantity: quantity,
|
||||||
this.timers,
|
callbackRate: this.config.trailingCallbackRate,
|
||||||
this.pending,
|
guard: {
|
||||||
side,
|
|
||||||
activationPrice,
|
|
||||||
quantity,
|
|
||||||
this.config.trailingCallbackRate,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) }));
|
this.tradeLog.push("error", t("log.guardian.trailingFail", { error: String(err) }));
|
||||||
}
|
}
|
||||||
@@ -651,42 +649,4 @@ export class GuardianEngine {
|
|||||||
return Math.max(0, Math.min(12, Math.floor(digits)));
|
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",
|
|
||||||
t("log.guardian.precisionSynced", {
|
|
||||||
priceTick: precision.priceTick,
|
|
||||||
qtyStep: precision.qtyStep,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", t("log.guardian.precisionFailed", { error: extractMessage(error) }));
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,16 @@ import {
|
|||||||
placeOrder,
|
placeOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import type { MakerEngineSnapshot } from "./maker-engine";
|
import type { MakerEngineSnapshot } from "./maker-engine";
|
||||||
import { makeOrderPlan } from "../core/lib/order-plan";
|
import { makeOrderPlan } from "../core/lib/order-plan";
|
||||||
import { safeCancelOrder } from "../core/lib/orders";
|
import { safeCancelOrder } from "../core/lib/orders";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
|
import { t } from "../i18n";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
side: "BUY" | "SELL";
|
side: "BUY" | "SELL";
|
||||||
@@ -63,6 +65,8 @@ type MakerEvent = "update";
|
|||||||
type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void;
|
type MakerListener = (snapshot: LiquidityMakerEngineSnapshot) => void;
|
||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
|
/** Quantity step assumed until the exchange reports its own. */
|
||||||
|
const DEFAULT_QTY_STEP = 0.001;
|
||||||
|
|
||||||
export class LiquidityMakerEngine {
|
export class LiquidityMakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -80,11 +84,7 @@ export class LiquidityMakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, LiquidityMakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private priceTick: number = 0.1;
|
private readonly precision: PrecisionSyncer;
|
||||||
private qtyStep: number = 0.001;
|
|
||||||
private minBaseAmount: number | null = null;
|
|
||||||
private minQuoteAmount: number | null = null;
|
|
||||||
private precisionSync: Promise<void> | null = null;
|
|
||||||
private marketType: "perp" | "spot" = "perp";
|
private marketType: "perp" | "spot" = "perp";
|
||||||
private baseAsset: string | null = null;
|
private baseAsset: string | null = null;
|
||||||
private quoteAsset: string | null = null;
|
private quoteAsset: string | null = null;
|
||||||
@@ -140,17 +140,31 @@ export class LiquidityMakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
||||||
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
||||||
this.baseAsset = parsedSymbols.base ?? null;
|
this.baseAsset = parsedSymbols.base ?? null;
|
||||||
this.quoteAsset = parsedSymbols.quote ?? null;
|
this.quoteAsset = parsedSymbols.quote ?? null;
|
||||||
this.syncPrecision();
|
this.precision.start();
|
||||||
// Debounce window defaults to 3x refresh interval, min 1s
|
// Debounce window defaults to 3x refresh interval, min 1s
|
||||||
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
||||||
|
private get orderContext(): OrderContext {
|
||||||
|
return (this.orderContextCache ??= {
|
||||||
|
adapter: this.exchange,
|
||||||
|
symbol: this.config.symbol,
|
||||||
|
locks: this.locks,
|
||||||
|
timers: this.timers,
|
||||||
|
pendings: this.pending,
|
||||||
|
log: (type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private orderContextCache: OrderContext | null = null;
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -163,6 +177,7 @@ export class LiquidityMakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -219,8 +234,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
|
||||||
processFail: (error) => `账户推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.accountError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -263,8 +278,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅订单失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
|
||||||
processFail: (error) => `订单推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.orderError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -277,8 +292,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅深度失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
|
||||||
processFail: (error) => `深度推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.depthError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -291,8 +306,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
|
||||||
processFail: (error) => `价格推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -311,8 +326,8 @@ export class LiquidityMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅K线失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.klineFail", { error: String(error) }),
|
||||||
processFail: (error) => `K线推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.klineError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -350,7 +365,11 @@ export class LiquidityMakerEngine {
|
|||||||
|
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
`检测到成交: ${order.side} ${filledQty.toFixed(6)} @ ${avgPrice.toFixed(this.getPriceDecimals())}`
|
t("log.liquidityMaker.fillDetected", {
|
||||||
|
side: order.side,
|
||||||
|
qty: filledQty.toFixed(6),
|
||||||
|
price: avgPrice.toFixed(this.getPriceDecimals()),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,9 +467,9 @@ export class LiquidityMakerEngine {
|
|||||||
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
||||||
const rawAbsPosition = Math.abs(position.positionAmt);
|
const rawAbsPosition = Math.abs(position.positionAmt);
|
||||||
const minSell =
|
const minSell =
|
||||||
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
|
Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
|
||||||
? this.minBaseAmount!
|
? this.precision.minBaseAmount!
|
||||||
: Math.max(this.config.tradeAmount, this.qtyStep);
|
: Math.max(this.config.tradeAmount, this.precision.qtyStep);
|
||||||
let absPosition = rawAbsPosition;
|
let absPosition = rawAbsPosition;
|
||||||
const tinySpotPosition =
|
const tinySpotPosition =
|
||||||
isSpotMarket &&
|
isSpotMarket &&
|
||||||
@@ -473,13 +492,13 @@ export class LiquidityMakerEngine {
|
|||||||
// 无法卖出,跳过卖单,允许买单累计
|
// 无法卖出,跳过卖单,允许买单累计
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
if (!skipSellSide) {
|
if (!skipSellSide) {
|
||||||
this.tradeLog.push("info", "现货持仓低于最小卖单量,暂不挂卖单");
|
this.tradeLog.push("info", t("log.spotMaker.belowMinSellHold"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipBuySide && canEnter) {
|
if (!skipBuySide && canEnter) {
|
||||||
if (!allowSpotBuy) {
|
if (!allowSpotBuy) {
|
||||||
if (this.lastBuyPriceViable) {
|
if (this.lastBuyPriceViable) {
|
||||||
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
|
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
|
||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -496,8 +515,8 @@ export class LiquidityMakerEngine {
|
|||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
const reason =
|
const reason =
|
||||||
buyAmount < EPS && isSpotMarket
|
buyAmount < EPS && isSpotMarket
|
||||||
? "现货可用报价资产不足,跳过买单"
|
? t("log.spotMaker.quoteBalanceShort")
|
||||||
: "跳过买单:价差不足以构造maker价格";
|
: t("log.spotMaker.spreadTooTightBuy");
|
||||||
this.tradeLog.push("info", reason);
|
this.tradeLog.push("info", reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,7 +529,7 @@ export class LiquidityMakerEngine {
|
|||||||
// 持仓低于最小卖单量,跳过卖单,等待累积
|
// 持仓低于最小卖单量,跳过卖单,等待累积
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
|
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const desiredSellAmount =
|
const desiredSellAmount =
|
||||||
@@ -528,8 +547,8 @@ export class LiquidityMakerEngine {
|
|||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
const reason =
|
const reason =
|
||||||
sellAmount < EPS && isSpotMarket
|
sellAmount < EPS && isSpotMarket
|
||||||
? "现货可用基础资产不足,跳过卖单"
|
? t("log.spotMaker.baseBalanceShort")
|
||||||
: "跳过卖单:价差不足以构造maker价格";
|
: t("log.spotMaker.spreadTooTightSell");
|
||||||
this.tradeLog.push("info", reason);
|
this.tradeLog.push("info", reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,7 +559,7 @@ export class LiquidityMakerEngine {
|
|||||||
if (!skipBuySide && canEnter) {
|
if (!skipBuySide && canEnter) {
|
||||||
if (isSpotMarket && !allowSpotBuy) {
|
if (isSpotMarket && !allowSpotBuy) {
|
||||||
if (this.lastBuyPriceViable) {
|
if (this.lastBuyPriceViable) {
|
||||||
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
|
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
|
||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
}
|
}
|
||||||
} else if (bidPrice != null) {
|
} else if (bidPrice != null) {
|
||||||
@@ -548,12 +567,12 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipSellSide && canEnter) {
|
if (!skipSellSide && canEnter) {
|
||||||
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) {
|
if (isSpotMarket && minSell > 0 && this.precision.minBaseAmount != null) {
|
||||||
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
||||||
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
||||||
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
|
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
|
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (askPrice != null) {
|
if (askPrice != null) {
|
||||||
@@ -608,7 +627,7 @@ export class LiquidityMakerEngine {
|
|||||||
await this.enforceRateLimitStop();
|
await this.enforceRateLimitStop();
|
||||||
this.tradeLog.push("warn", `LiquidityMakerEngine 429: ${String(error)}`);
|
this.tradeLog.push("warn", `LiquidityMakerEngine 429: ${String(error)}`);
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `流动性做市循环异常: ${String(error)}`);
|
this.tradeLog.push("error", t("log.liquidityMaker.tickFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -630,7 +649,7 @@ export class LiquidityMakerEngine {
|
|||||||
topAsk: number,
|
topAsk: number,
|
||||||
priceDecimals: number
|
priceDecimals: number
|
||||||
): string | null {
|
): string | null {
|
||||||
const tickOffset = this.config.closeTickOffset * this.priceTick;
|
const tickOffset = this.config.closeTickOffset * this.precision.priceTick;
|
||||||
const entryPrice = position.entryPrice || this.positionEntryPrice;
|
const entryPrice = position.entryPrice || this.positionEntryPrice;
|
||||||
|
|
||||||
let targetPrice: number;
|
let targetPrice: number;
|
||||||
@@ -664,14 +683,14 @@ export class LiquidityMakerEngine {
|
|||||||
if (closeSide === "SELL") {
|
if (closeSide === "SELL") {
|
||||||
// 多头平仓:卖价必须 >= 入场价
|
// 多头平仓:卖价必须 >= 入场价
|
||||||
if (targetPrice < entryPrice) {
|
if (targetPrice < entryPrice) {
|
||||||
targetPrice = entryPrice + this.priceTick;
|
targetPrice = entryPrice + this.precision.priceTick;
|
||||||
this.tradeLog.push("info", `平仓价调整为入场价+1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
|
this.tradeLog.push("info", t("log.liquidityMaker.exitRaisedToBreakeven", { price: targetPrice.toFixed(priceDecimals) }));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 空头平仓:买价必须 <= 入场价
|
// 空头平仓:买价必须 <= 入场价
|
||||||
if (targetPrice > entryPrice) {
|
if (targetPrice > entryPrice) {
|
||||||
targetPrice = entryPrice - this.priceTick;
|
targetPrice = entryPrice - this.precision.priceTick;
|
||||||
this.tradeLog.push("info", `平仓价调整为入场价-1tick以确保不亏本: ${targetPrice.toFixed(priceDecimals)}`);
|
this.tradeLog.push("info", t("log.liquidityMaker.exitLoweredToBreakeven", { price: targetPrice.toFixed(priceDecimals) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -697,17 +716,11 @@ export class LiquidityMakerEngine {
|
|||||||
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
||||||
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
||||||
try {
|
try {
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
quantity: absPosition,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
side,
|
|
||||||
absPosition,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice:
|
expectedPrice:
|
||||||
side === "SELL"
|
side === "SELL"
|
||||||
@@ -715,13 +728,13 @@ export class LiquidityMakerEngine {
|
|||||||
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "限频强制平仓时订单已不存在");
|
this.tradeLog.push("order", t("log.spotMaker.rateLimitCloseMissing"));
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `限频强制平仓失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.rateLimitCloseFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -739,18 +752,18 @@ export class LiquidityMakerEngine {
|
|||||||
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
||||||
this.openOrders = [];
|
this.openOrders = [];
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
this.tradeLog.push("order", "启动时清理历史挂单");
|
this.tradeLog.push("order", t("log.spotMaker.startupCleanup"));
|
||||||
this.initialOrderResetDone = true;
|
this.initialOrderResetDone = true;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "历史挂单已消失,跳过启动清理");
|
this.tradeLog.push("order", t("log.spotMaker.startupCleanupGone"));
|
||||||
this.initialOrderResetDone = true;
|
this.initialOrderResetDone = true;
|
||||||
this.openOrders = [];
|
this.openOrders = [];
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", `启动撤单失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.startupCancelFailed", { error: String(error) }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -823,7 +836,7 @@ export class LiquidityMakerEngine {
|
|||||||
const newPrice = Number(t.price);
|
const newPrice = Number(t.price);
|
||||||
const oldPrice = Number(existing.price);
|
const oldPrice = Number(existing.price);
|
||||||
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
||||||
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
|
const ticksDiff = Math.abs(newPrice - oldPrice) / this.precision.priceTick;
|
||||||
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
||||||
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
||||||
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
||||||
@@ -849,17 +862,21 @@ export class LiquidityMakerEngine {
|
|||||||
() => {
|
() => {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
`撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`
|
t("log.spotMaker.cancelMismatched", {
|
||||||
|
side: order.side,
|
||||||
|
price: order.price,
|
||||||
|
reduceOnly: order.reduceOnly,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
|
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
|
this.tradeLog.push("order", t("log.spotMaker.cancelAlreadySettled"));
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
|
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
@@ -872,40 +889,31 @@ export class LiquidityMakerEngine {
|
|||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
if (
|
if (
|
||||||
this.marketType === "spot" &&
|
this.marketType === "spot" &&
|
||||||
this.minBaseAmount != null &&
|
this.precision.minBaseAmount != null &&
|
||||||
target.side === "SELL" &&
|
target.side === "SELL" &&
|
||||||
target.amount + EPS < this.minBaseAmount
|
target.amount + EPS < this.precision.minBaseAmount
|
||||||
) {
|
) {
|
||||||
// Skip placing sells that would be bumped by venue minimums
|
// Skip placing sells that would be bumped by venue minimums
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", "现货卖单低于最小成交量,跳过挂单等待累积");
|
this.tradeLog.push("info", t("log.spotMaker.sellBelowMinNotional"));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
|
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
|
||||||
await placeOrder(
|
await placeOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: target.side,
|
||||||
this.openOrders,
|
price: target.price,
|
||||||
this.locks,
|
amount: target.amount,
|
||||||
this.timers,
|
reduceOnly: reduceOnlyFlag,
|
||||||
this.pending,
|
guard: {
|
||||||
target.side,
|
|
||||||
target.price, // 已经是字符串价格
|
|
||||||
target.amount,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
reduceOnlyFlag,
|
|
||||||
{
|
|
||||||
markPrice: this.getPositionSnapshot().markPrice,
|
markPrice: this.getPositionSnapshot().markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{
|
qtyStep: this.precision.qtyStep
|
||||||
priceTick: this.priceTick,
|
});
|
||||||
qtyStep: this.qtyStep,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Record last placed entry order timing and price
|
// Record last placed entry order timing and price
|
||||||
if (!target.reduceOnly) {
|
if (!target.reduceOnly) {
|
||||||
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
||||||
@@ -921,10 +929,10 @@ export class LiquidityMakerEngine {
|
|||||||
if (isRateLimitError(dustError)) {
|
if (isRateLimitError(dustError)) {
|
||||||
throw dustError;
|
throw dustError;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`);
|
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(dustError) }));
|
||||||
}
|
}
|
||||||
if (dustClosed) continue;
|
if (dustClosed) continue;
|
||||||
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.placeFailed", { side: target.side, price: target.price, error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -937,10 +945,10 @@ export class LiquidityMakerEngine {
|
|||||||
this.lastSpotStopSkipped = false;
|
this.lastSpotStopSkipped = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null;
|
const minStopQty = Number.isFinite(this.precision.minBaseAmount) ? this.precision.minBaseAmount! : null;
|
||||||
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
||||||
if (!this.lastSpotStopSkipped) {
|
if (!this.lastSpotStopSkipped) {
|
||||||
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
|
this.tradeLog.push("info", t("log.spotMaker.belowMinCloseSkipStop"));
|
||||||
this.lastSpotStopSkipped = true;
|
this.lastSpotStopSkipped = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -949,34 +957,28 @@ export class LiquidityMakerEngine {
|
|||||||
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
||||||
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
||||||
if (!triggerStop) return;
|
if (!triggerStop) return;
|
||||||
this.tradeLog.push("stop", `现货止损,当前仓位=${absPosition.toFixed(6)} PnL=${pnl.toFixed(4)} USDT`);
|
this.tradeLog.push("stop", t("log.spotMaker.spotStop", { qty: absPosition.toFixed(6), pnl: pnl.toFixed(4) }));
|
||||||
try {
|
try {
|
||||||
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
|
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
|
||||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: "SELL",
|
||||||
this.openOrders,
|
quantity: absPosition,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
"SELL",
|
|
||||||
absPosition,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: bidPrice || null,
|
expectedPrice: bidPrice || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRateLimitError(error)) throw error;
|
if (isRateLimitError(error)) throw error;
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `现货止损失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.spotStopFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -987,7 +989,7 @@ export class LiquidityMakerEngine {
|
|||||||
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||||
if (!hasEntryPrice) {
|
if (!hasEntryPrice) {
|
||||||
if (!this.entryPricePendingLogged) {
|
if (!this.entryPricePendingLogged) {
|
||||||
this.tradeLog.push("info", "做市持仓均价未同步,等待账户快照刷新后再执行止损判断");
|
this.tradeLog.push("info", t("log.spotMaker.entryPricePending"));
|
||||||
this.entryPricePendingLogged = true;
|
this.entryPricePendingLogged = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1000,32 +1002,29 @@ export class LiquidityMakerEngine {
|
|||||||
if (triggerStop) {
|
if (triggerStop) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
|
t("log.spotMaker.stopTriggered", {
|
||||||
|
direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
||||||
|
pnl: pnl.toFixed(4),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: position.positionAmt > 0 ? "SELL" : "BUY",
|
||||||
this.openOrders,
|
quantity: absPosition,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
position.positionAmt > 0 ? "SELL" : "BUY",
|
|
||||||
absPosition,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.stopCloseFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1044,12 +1043,12 @@ export class LiquidityMakerEngine {
|
|||||||
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
|
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.tradeLog.push("order", "订单已不存在,撤销跳过");
|
this.tradeLog.push("order", t("log.spotMaker.orderMissingOnCancel"));
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
|
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
@@ -1058,49 +1057,8 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (Number.isFinite(precision.minBaseAmount)) {
|
|
||||||
this.minBaseAmount = precision.minBaseAmount!;
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.minQuoteAmount)) {
|
|
||||||
this.minQuoteAmount = precision.minQuoteAmount!;
|
|
||||||
}
|
|
||||||
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 {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.priceTick);
|
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
@@ -1110,10 +1068,10 @@ export class LiquidityMakerEngine {
|
|||||||
try {
|
try {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
this.events.emit("update", snapshot, (error) => {
|
this.events.emit("update", snapshot, (error) => {
|
||||||
this.tradeLog.push("error", `更新回调处理异常: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.updateHandlerError", { error: String(error) }));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", `快照或更新分发异常: ${String(err)}`);
|
this.tradeLog.push("error", t("log.spotMaker.snapshotDispatchError", { error: String(err) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1231,7 +1189,7 @@ export class LiquidityMakerEngine {
|
|||||||
if (!params.balances) return desired;
|
if (!params.balances) return desired;
|
||||||
if (params.side === "SELL") {
|
if (params.side === "SELL") {
|
||||||
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
||||||
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
|
if (this.precision.minBaseAmount != null && cap + EPS < this.precision.minBaseAmount) {
|
||||||
return 0; // below venue min trade size; skip sell until enough balance
|
return 0; // below venue min trade size; skip sell until enough balance
|
||||||
}
|
}
|
||||||
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
||||||
@@ -1244,7 +1202,7 @@ export class LiquidityMakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private roundToStep(amount: number): number {
|
private roundToStep(amount: number): number {
|
||||||
const step = Math.max(1e-9, this.qtyStep);
|
const step = Math.max(1e-9, this.precision.qtyStep);
|
||||||
return Math.floor(amount / step) * step;
|
return Math.floor(amount / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1255,7 +1213,7 @@ export class LiquidityMakerEngine {
|
|||||||
topAsk: number | null
|
topAsk: number | null
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
||||||
const tick = Math.max(this.priceTick, 1e-9);
|
const tick = Math.max(this.precision.priceTick, 1e-9);
|
||||||
if (side === "BUY") {
|
if (side === "BUY") {
|
||||||
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
||||||
const maxPrice = Number(topAsk) - tick;
|
const maxPrice = Number(topAsk) - tick;
|
||||||
@@ -1293,17 +1251,11 @@ export class LiquidityMakerEngine {
|
|||||||
if (absQty < EPS) return false;
|
if (absQty < EPS) return false;
|
||||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||||
try {
|
try {
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: target.side,
|
||||||
this.openOrders,
|
quantity: absQty,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
target.side,
|
|
||||||
absQty,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice:
|
expectedPrice:
|
||||||
target.side === "SELL"
|
target.side === "SELL"
|
||||||
@@ -1311,15 +1263,15 @@ export class LiquidityMakerEngine {
|
|||||||
: (topAsk != null ? Number(topAsk) : null),
|
: (topAsk != null ? Number(topAsk) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
this.tradeLog.push("order", t("log.spotMaker.dustClose", { side: target.side, qty: absQty.toFixed(6) }));
|
||||||
return true;
|
return true;
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
if (isRateLimitError(closeError)) {
|
if (isRateLimitError(closeError)) {
|
||||||
throw closeError;
|
throw closeError;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`);
|
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(closeError) }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,14 @@ import {
|
|||||||
placeOrder,
|
placeOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import { makeOrderPlan } from "../core/lib/order-plan";
|
import { makeOrderPlan } from "../core/lib/order-plan";
|
||||||
import { safeCancelOrder } from "../core/lib/orders";
|
import { safeCancelOrder } from "../core/lib/orders";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
@@ -64,6 +65,8 @@ type MakerListener = (snapshot: MakerEngineSnapshot) => void;
|
|||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
|
const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000;
|
||||||
|
/** Quantity step assumed until the exchange reports its own. */
|
||||||
|
const DEFAULT_QTY_STEP = 0.001;
|
||||||
|
|
||||||
export class MakerEngine {
|
export class MakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -79,9 +82,7 @@ export class MakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, MakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private priceTick: number = 0.1;
|
private readonly precision: PrecisionSyncer;
|
||||||
private qtyStep: number = 0.001;
|
|
||||||
private precisionSync: Promise<void> | null = null;
|
|
||||||
|
|
||||||
private timer: ReturnType<typeof setInterval> | null = null;
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
private processing = false;
|
private processing = false;
|
||||||
@@ -119,12 +120,26 @@ export class MakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
||||||
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
this.tradeLog.push(type, detail)
|
||||||
this.syncPrecision();
|
);
|
||||||
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
||||||
|
private get orderContext(): OrderContext {
|
||||||
|
return (this.orderContextCache ??= {
|
||||||
|
adapter: this.exchange,
|
||||||
|
symbol: this.config.symbol,
|
||||||
|
locks: this.locks,
|
||||||
|
timers: this.timers,
|
||||||
|
pendings: this.pending,
|
||||||
|
log: (type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private orderContextCache: OrderContext | null = null;
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -137,6 +152,7 @@ export class MakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -436,27 +452,18 @@ export class MakerEngine {
|
|||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
try {
|
try {
|
||||||
await placeOrder(
|
await placeOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: target.side,
|
||||||
this.openOrders,
|
price: target.price,
|
||||||
this.locks,
|
amount: target.amount,
|
||||||
this.timers,
|
reduceOnly: target.reduceOnly,
|
||||||
this.pending,
|
guard: {
|
||||||
target.side,
|
|
||||||
target.price, // 已经是字符串价格
|
|
||||||
target.amount,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
target.reduceOnly,
|
|
||||||
{
|
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{
|
qtyStep: this.precision.qtyStep
|
||||||
priceTick: this.priceTick,
|
});
|
||||||
qtyStep: this.qtyStep,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isInsufficientBalanceError(error)) {
|
if (isInsufficientBalanceError(error)) {
|
||||||
this.registerInsufficientBalance(error);
|
this.registerInsufficientBalance(error);
|
||||||
@@ -504,23 +511,17 @@ export class MakerEngine {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: position.positionAmt > 0 ? "SELL" : "BUY",
|
||||||
this.openOrders,
|
quantity: absPosition,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
position.positionAmt > 0 ? "SELL" : "BUY",
|
|
||||||
absPosition,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(closeSidePrice) || null,
|
expectedPrice: Number(closeSidePrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", t("log.maker.stopOrderMissing"));
|
this.tradeLog.push("order", t("log.maker.stopOrderMissing"));
|
||||||
@@ -557,46 +558,8 @@ 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",
|
|
||||||
t("log.common.precisionSynced", {
|
|
||||||
priceTick: precision.priceTick,
|
|
||||||
qtyStep: precision.qtyStep,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", t("log.common.precisionFailed", { error: extractMessage(error) }));
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPriceDecimals(): number {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.priceTick);
|
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ACCOUNT_DATA_STALE_THRESHOLD_MS,
|
||||||
|
DATA_STALE_THRESHOLD_MS,
|
||||||
|
REST_ERROR_DEFENSE_THRESHOLD,
|
||||||
|
defenseReasonsFor,
|
||||||
|
describeDefenseReasons,
|
||||||
|
evaluateDefense,
|
||||||
|
type DefenseInputs,
|
||||||
|
} from "./maker-points-defense";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
const NOW = 1_700_000_000_000;
|
||||||
|
|
||||||
|
function inputs(overrides: Partial<DefenseInputs> = {}): DefenseInputs {
|
||||||
|
return {
|
||||||
|
now: NOW,
|
||||||
|
lastDepthTime: NOW,
|
||||||
|
lastAccountTime: NOW,
|
||||||
|
lastBinanceDepthTime: NOW,
|
||||||
|
binanceHealth: { healthy: true },
|
||||||
|
accountHealth: { ok: true },
|
||||||
|
hasAccountSnapshot: true,
|
||||||
|
accountProbeFailures: 0,
|
||||||
|
accountProbeInFlight: false,
|
||||||
|
restUnhealthy: false,
|
||||||
|
restConsecutiveErrors: 0,
|
||||||
|
restLastError: null,
|
||||||
|
marginMode: "isolated",
|
||||||
|
enforceIsolatedMargin: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("evaluateDefense", () => {
|
||||||
|
it("stays out of defense when every feed is fresh", () => {
|
||||||
|
expect(evaluateDefense(inputs()).shouldDefend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a feed that has never reported as fresh, not stale", () => {
|
||||||
|
// Startup: age 0 must not be read as "infinitely old".
|
||||||
|
const verdict = evaluateDefense(
|
||||||
|
inputs({ lastDepthTime: 0, lastAccountTime: 0, lastBinanceDepthTime: 0 })
|
||||||
|
);
|
||||||
|
expect(verdict.shouldDefend).toBe(false);
|
||||||
|
expect(verdict.reasons.depthAge).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defends on a stale venue depth feed", () => {
|
||||||
|
const verdict = evaluateDefense(
|
||||||
|
inputs({ lastDepthTime: NOW - DATA_STALE_THRESHOLD_MS - 1 })
|
||||||
|
);
|
||||||
|
expect(verdict.shouldDefend).toBe(true);
|
||||||
|
expect(verdict.reasons.depthStale).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not defend exactly at the staleness threshold", () => {
|
||||||
|
expect(evaluateDefense(inputs({ lastDepthTime: NOW - DATA_STALE_THRESHOLD_MS })).shouldDefend).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defends on a stale Binance depth feed or an unhealthy book", () => {
|
||||||
|
expect(
|
||||||
|
evaluateDefense(inputs({ lastBinanceDepthTime: NOW - DATA_STALE_THRESHOLD_MS - 1 })).shouldDefend
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
evaluateDefense(inputs({ binanceHealth: { healthy: false, reason: "gap" } })).shouldDefend
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes but does not defend when the account feed first goes quiet", () => {
|
||||||
|
const verdict = evaluateDefense(
|
||||||
|
inputs({ lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1 })
|
||||||
|
);
|
||||||
|
expect(verdict.needsAccountProbe).toBe(true);
|
||||||
|
expect(verdict.shouldDefend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds off while the account REST probe is still in flight", () => {
|
||||||
|
const verdict = evaluateDefense(
|
||||||
|
inputs({
|
||||||
|
lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1,
|
||||||
|
accountProbeFailures: 2,
|
||||||
|
accountProbeInFlight: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(verdict.shouldDefend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defends once the account REST probe has failed", () => {
|
||||||
|
const verdict = evaluateDefense(
|
||||||
|
inputs({
|
||||||
|
lastAccountTime: NOW - ACCOUNT_DATA_STALE_THRESHOLD_MS - 1,
|
||||||
|
accountProbeFailures: 1,
|
||||||
|
accountProbeInFlight: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(verdict.shouldDefend).toBe(true);
|
||||||
|
expect(verdict.reasons.accountStale).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defends on an invalid account snapshot and carries its issues", () => {
|
||||||
|
const verdict = evaluateDefense(
|
||||||
|
inputs({ accountHealth: { ok: false, issues: ["missing position"] } })
|
||||||
|
);
|
||||||
|
expect(verdict.shouldDefend).toBe(true);
|
||||||
|
expect(verdict.reasons.accountIssues).toEqual(["missing position"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores account validity before any snapshot has arrived", () => {
|
||||||
|
const verdict = evaluateDefense(
|
||||||
|
inputs({ hasAccountSnapshot: false, accountHealth: { ok: false, issues: ["x"] } })
|
||||||
|
);
|
||||||
|
expect(verdict.shouldDefend).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defends only after REST failures reach the threshold", () => {
|
||||||
|
const below = evaluateDefense(
|
||||||
|
inputs({ restUnhealthy: true, restConsecutiveErrors: REST_ERROR_DEFENSE_THRESHOLD - 1 })
|
||||||
|
);
|
||||||
|
expect(below.shouldDefend).toBe(false);
|
||||||
|
|
||||||
|
const at = evaluateDefense(
|
||||||
|
inputs({ restUnhealthy: true, restConsecutiveErrors: REST_ERROR_DEFENSE_THRESHOLD })
|
||||||
|
);
|
||||||
|
expect(at.shouldDefend).toBe(true);
|
||||||
|
expect(at.reasons.restUnhealthy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defends on a non-isolated margin mode only where it is enforced", () => {
|
||||||
|
expect(evaluateDefense(inputs({ marginMode: "cross" })).shouldDefend).toBe(true);
|
||||||
|
expect(
|
||||||
|
evaluateDefense(inputs({ marginMode: "cross", enforceIsolatedMargin: false })).shouldDefend
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not defend on an unknown margin mode", () => {
|
||||||
|
expect(evaluateDefense(inputs({ marginMode: null })).shouldDefend).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeDefenseReasons", () => {
|
||||||
|
it("names every active cause", () => {
|
||||||
|
const summary = describeDefenseReasons(
|
||||||
|
defenseReasonsFor({
|
||||||
|
depthStale: true,
|
||||||
|
depthAge: 7_000,
|
||||||
|
restUnhealthy: true,
|
||||||
|
restConsecutiveErrors: 4,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(summary).toContain(t("defense.reason.depth", { seconds: 7 }));
|
||||||
|
expect(summary).toContain(t("defense.reason.rest", { count: 4 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to unknown when nothing is flagged", () => {
|
||||||
|
expect(describeDefenseReasons(defenseReasonsFor({}))).toBe(t("defense.reason.unknown"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the Binance book reason when there is none", () => {
|
||||||
|
const summary = describeDefenseReasons(
|
||||||
|
defenseReasonsFor({ binanceUnhealthy: true, binanceHealthReason: null })
|
||||||
|
);
|
||||||
|
expect(summary).toBe(t("defense.reason.unknown"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Defense-mode decision logic for the Maker Points engine.
|
||||||
|
*
|
||||||
|
* Pure by design (mirrors maker-points-logic.ts / grid-logic.ts): it reads a
|
||||||
|
* snapshot of feed ages and health flags and returns a verdict. Acting on the
|
||||||
|
* verdict — cancelling orders, starting REST polling — stays in the engine,
|
||||||
|
* so the rule that decides "is our market data trustworthy" can be tested
|
||||||
|
* without a live adapter.
|
||||||
|
*/
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
/** A feed older than this is considered stale. */
|
||||||
|
export const DATA_STALE_THRESHOLD_MS = 5_000;
|
||||||
|
/**
|
||||||
|
* Account pushes can legitimately be sparse, so age alone does not trigger
|
||||||
|
* defense — it only triggers a REST probe. Defense follows a failed probe.
|
||||||
|
*/
|
||||||
|
export const ACCOUNT_DATA_STALE_THRESHOLD_MS = 20_000;
|
||||||
|
/** Consecutive REST failures before the venue is treated as down. */
|
||||||
|
export const REST_ERROR_DEFENSE_THRESHOLD = 3;
|
||||||
|
|
||||||
|
export interface DefenseInputs {
|
||||||
|
now: number;
|
||||||
|
/** Epoch ms of the last venue depth update; 0 when none has arrived yet. */
|
||||||
|
lastDepthTime: number;
|
||||||
|
/** Epoch ms of the last venue account update; 0 when none has arrived yet. */
|
||||||
|
lastAccountTime: number;
|
||||||
|
/** Epoch ms of the last Binance depth update; 0 when none has arrived yet. */
|
||||||
|
lastBinanceDepthTime: number;
|
||||||
|
binanceHealth: { healthy: boolean; reason?: string | null };
|
||||||
|
/** Result of validating the current account snapshot for the traded symbol. */
|
||||||
|
accountHealth: { ok: boolean; issues?: string[] };
|
||||||
|
hasAccountSnapshot: boolean;
|
||||||
|
/** Consecutive failures of the REST fallback that refreshes a stale account. */
|
||||||
|
accountProbeFailures: number;
|
||||||
|
accountProbeInFlight: boolean;
|
||||||
|
restUnhealthy: boolean;
|
||||||
|
restConsecutiveErrors: number;
|
||||||
|
restLastError: string | null;
|
||||||
|
/** Current margin mode as the venue reports it, or null when unknown. */
|
||||||
|
marginMode: string | null;
|
||||||
|
/** Margin mode is only enforced on StandX. */
|
||||||
|
enforceIsolatedMargin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why defense mode was entered; carried into the log line and the notification.
|
||||||
|
* A type alias rather than an interface so it satisfies the notification
|
||||||
|
* payload's index signature without a cast.
|
||||||
|
*/
|
||||||
|
export type DefenseReasons = {
|
||||||
|
depthStale: boolean;
|
||||||
|
binanceStale: boolean;
|
||||||
|
binanceUnhealthy: boolean;
|
||||||
|
binanceHealthReason: string | null;
|
||||||
|
accountStale: boolean;
|
||||||
|
accountInvalid: boolean;
|
||||||
|
restUnhealthy: boolean;
|
||||||
|
restConsecutiveErrors: number;
|
||||||
|
restLastError: string | null;
|
||||||
|
marginModeNotIsolated: boolean;
|
||||||
|
marginMode: string | null;
|
||||||
|
depthAge: number;
|
||||||
|
binanceAge: number;
|
||||||
|
accountAge: number;
|
||||||
|
accountIssues: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DefenseVerdict {
|
||||||
|
shouldDefend: boolean;
|
||||||
|
/** The account feed is old enough that the engine should refresh it over REST. */
|
||||||
|
needsAccountProbe: boolean;
|
||||||
|
reasons: DefenseReasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Age of a feed that has produced at least one update; 0 for one that has not. */
|
||||||
|
function feedAge(now: number, lastUpdate: number): number {
|
||||||
|
return lastUpdate > 0 ? now - lastUpdate : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStale(now: number, lastUpdate: number, threshold: number): boolean {
|
||||||
|
return lastUpdate > 0 && now - lastUpdate > threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateDefense(inputs: DefenseInputs): DefenseVerdict {
|
||||||
|
const { now } = inputs;
|
||||||
|
|
||||||
|
const depthStale = isStale(now, inputs.lastDepthTime, DATA_STALE_THRESHOLD_MS);
|
||||||
|
const binanceStale = isStale(now, inputs.lastBinanceDepthTime, DATA_STALE_THRESHOLD_MS);
|
||||||
|
const binanceUnhealthy = !inputs.binanceHealth.healthy;
|
||||||
|
|
||||||
|
const accountAge = feedAge(now, inputs.lastAccountTime);
|
||||||
|
const accountStaleByAge = isStale(now, inputs.lastAccountTime, ACCOUNT_DATA_STALE_THRESHOLD_MS);
|
||||||
|
// Defense waits for the REST fallback to have been tried and failed.
|
||||||
|
const accountStale =
|
||||||
|
accountStaleByAge && inputs.accountProbeFailures > 0 && !inputs.accountProbeInFlight;
|
||||||
|
|
||||||
|
const accountInvalid = inputs.hasAccountSnapshot && !inputs.accountHealth.ok;
|
||||||
|
const restUnhealthy =
|
||||||
|
inputs.restUnhealthy && inputs.restConsecutiveErrors >= REST_ERROR_DEFENSE_THRESHOLD;
|
||||||
|
const marginModeNotIsolated =
|
||||||
|
inputs.enforceIsolatedMargin && inputs.marginMode != null && inputs.marginMode !== "isolated";
|
||||||
|
|
||||||
|
const shouldDefend =
|
||||||
|
depthStale ||
|
||||||
|
binanceStale ||
|
||||||
|
binanceUnhealthy ||
|
||||||
|
accountStale ||
|
||||||
|
accountInvalid ||
|
||||||
|
restUnhealthy ||
|
||||||
|
marginModeNotIsolated;
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldDefend,
|
||||||
|
needsAccountProbe: accountStaleByAge,
|
||||||
|
reasons: {
|
||||||
|
depthStale,
|
||||||
|
binanceStale,
|
||||||
|
binanceUnhealthy,
|
||||||
|
binanceHealthReason: inputs.binanceHealth.reason ?? null,
|
||||||
|
accountStale,
|
||||||
|
accountInvalid,
|
||||||
|
restUnhealthy,
|
||||||
|
restConsecutiveErrors: inputs.restConsecutiveErrors,
|
||||||
|
restLastError: inputs.restLastError,
|
||||||
|
marginModeNotIsolated,
|
||||||
|
marginMode: inputs.marginMode,
|
||||||
|
depthAge: feedAge(now, inputs.lastDepthTime),
|
||||||
|
binanceAge: feedAge(now, inputs.lastBinanceDepthTime),
|
||||||
|
accountAge,
|
||||||
|
accountIssues: accountInvalid ? inputs.accountHealth.issues ?? [] : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing wrong; the baseline every single-cause reason set starts from. */
|
||||||
|
const NO_REASONS: DefenseReasons = {
|
||||||
|
depthStale: false,
|
||||||
|
binanceStale: false,
|
||||||
|
binanceUnhealthy: false,
|
||||||
|
binanceHealthReason: null,
|
||||||
|
accountStale: false,
|
||||||
|
accountInvalid: false,
|
||||||
|
restUnhealthy: false,
|
||||||
|
restConsecutiveErrors: 0,
|
||||||
|
restLastError: null,
|
||||||
|
marginModeNotIsolated: false,
|
||||||
|
marginMode: null,
|
||||||
|
depthAge: 0,
|
||||||
|
binanceAge: 0,
|
||||||
|
accountAge: 0,
|
||||||
|
accountIssues: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reason set for a defense trigger that fires outside the periodic check — a REST
|
||||||
|
* health event, a rejected margin mode — where only one or two causes are known.
|
||||||
|
*/
|
||||||
|
export function defenseReasonsFor(known: Partial<DefenseReasons>): DefenseReasons {
|
||||||
|
return { ...NO_REASONS, ...known };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable summary of what went stale, for the log and the alert. */
|
||||||
|
export function describeDefenseReasons(reasons: DefenseReasons): string {
|
||||||
|
const items: string[] = [];
|
||||||
|
const seconds = (ms: number) => Math.round(ms / 1000);
|
||||||
|
|
||||||
|
if (reasons.depthStale) items.push(t("defense.reason.depth", { seconds: seconds(reasons.depthAge) }));
|
||||||
|
if (reasons.accountStale) {
|
||||||
|
items.push(t("defense.reason.account", { seconds: seconds(reasons.accountAge) }));
|
||||||
|
}
|
||||||
|
if (reasons.accountInvalid) {
|
||||||
|
items.push(
|
||||||
|
t("defense.reason.accountInvalid", {
|
||||||
|
issues: reasons.accountIssues.join(",") || t("defense.reason.unknown"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (reasons.restUnhealthy) {
|
||||||
|
items.push(t("defense.reason.rest", { count: reasons.restConsecutiveErrors }));
|
||||||
|
}
|
||||||
|
if (reasons.marginModeNotIsolated) {
|
||||||
|
items.push(
|
||||||
|
t("defense.reason.marginMode", { mode: reasons.marginMode ?? t("defense.reason.unknown") })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (reasons.binanceStale) {
|
||||||
|
items.push(t("defense.reason.binanceDepth", { seconds: seconds(reasons.binanceAge) }));
|
||||||
|
}
|
||||||
|
if (reasons.binanceUnhealthy && reasons.binanceHealthReason) {
|
||||||
|
items.push(t("defense.reason.binanceBook", { reason: reasons.binanceHealthReason }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.length > 0 ? items.join(", ") : t("defense.reason.unknown");
|
||||||
|
}
|
||||||
+239
-452
File diff suppressed because it is too large
Load Diff
+171
-207
@@ -22,14 +22,16 @@ import {
|
|||||||
placeOrder,
|
placeOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import type { MakerEngineSnapshot } from "./maker-engine";
|
import type { MakerEngineSnapshot } from "./maker-engine";
|
||||||
import { makeOrderPlan } from "../core/lib/order-plan";
|
import { makeOrderPlan } from "../core/lib/order-plan";
|
||||||
import { safeCancelOrder } from "../core/lib/orders";
|
import { safeCancelOrder } from "../core/lib/orders";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
|
import { t } from "../i18n";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
|
|
||||||
interface DesiredOrder {
|
interface DesiredOrder {
|
||||||
side: "BUY" | "SELL";
|
side: "BUY" | "SELL";
|
||||||
@@ -38,6 +40,13 @@ interface DesiredOrder {
|
|||||||
reduceOnly: boolean;
|
reduceOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Spot wallet view the quoting logic reads; `baseWallet` may lag `baseAvailable` after a fill. */
|
||||||
|
export interface SpotBalances {
|
||||||
|
baseAvailable: number;
|
||||||
|
quoteAvailable: number;
|
||||||
|
baseWallet: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot {
|
export interface OffsetMakerEngineSnapshot extends MakerEngineSnapshot {
|
||||||
buyDepthSum10: number;
|
buyDepthSum10: number;
|
||||||
sellDepthSum10: number;
|
sellDepthSum10: number;
|
||||||
@@ -54,6 +63,8 @@ type MakerEvent = "update";
|
|||||||
type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void;
|
type MakerListener = (snapshot: OffsetMakerEngineSnapshot) => void;
|
||||||
|
|
||||||
const EPS = 1e-5;
|
const EPS = 1e-5;
|
||||||
|
/** Quantity step assumed until the exchange reports its own. */
|
||||||
|
const DEFAULT_QTY_STEP = 0.001;
|
||||||
|
|
||||||
export class OffsetMakerEngine {
|
export class OffsetMakerEngine {
|
||||||
private accountSnapshot: AccountSnapshot | null = null;
|
private accountSnapshot: AccountSnapshot | null = null;
|
||||||
@@ -71,11 +82,7 @@ export class OffsetMakerEngine {
|
|||||||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||||||
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
|
private readonly events = new StrategyEventEmitter<MakerEvent, OffsetMakerEngineSnapshot>();
|
||||||
private readonly sessionVolume = new SessionVolumeTracker();
|
private readonly sessionVolume = new SessionVolumeTracker();
|
||||||
private priceTick: number = 0.1;
|
private readonly precision: PrecisionSyncer;
|
||||||
private qtyStep: number = 0.001;
|
|
||||||
private minBaseAmount: number | null = null;
|
|
||||||
private minQuoteAmount: number | null = null;
|
|
||||||
private precisionSync: Promise<void> | null = null;
|
|
||||||
private marketType: "perp" | "spot" = "perp";
|
private marketType: "perp" | "spot" = "perp";
|
||||||
private baseAsset: string | null = null;
|
private baseAsset: string | null = null;
|
||||||
private quoteAsset: string | null = null;
|
private quoteAsset: string | null = null;
|
||||||
@@ -123,17 +130,31 @@ export class OffsetMakerEngine {
|
|||||||
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.priceTick = Math.max(1e-9, this.config.priceTick);
|
this.precision = createPrecisionSyncer(this.exchange, this.config, DEFAULT_QTY_STEP, (type, detail) =>
|
||||||
this.qtyStep = Math.max(1e-9, this.qtyStep);
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
const parsedSymbols = parseSymbolParts(this.config.symbol);
|
||||||
this.baseAsset = parsedSymbols.base ?? null;
|
this.baseAsset = parsedSymbols.base ?? null;
|
||||||
this.quoteAsset = parsedSymbols.quote ?? null;
|
this.quoteAsset = parsedSymbols.quote ?? null;
|
||||||
this.syncPrecision();
|
this.precision.start();
|
||||||
// Debounce window defaults to 3x refresh interval, min 1s
|
// Debounce window defaults to 3x refresh interval, min 1s
|
||||||
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3);
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
||||||
|
private get orderContext(): OrderContext {
|
||||||
|
return (this.orderContextCache ??= {
|
||||||
|
adapter: this.exchange,
|
||||||
|
symbol: this.config.symbol,
|
||||||
|
locks: this.locks,
|
||||||
|
timers: this.timers,
|
||||||
|
pendings: this.pending,
|
||||||
|
log: (type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private orderContextCache: OrderContext | null = null;
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -146,6 +167,7 @@ export class OffsetMakerEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: MakerEvent, handler: MakerListener): void {
|
on(event: MakerEvent, handler: MakerListener): void {
|
||||||
@@ -202,8 +224,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅账户失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }),
|
||||||
processFail: (error) => `账户推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.accountError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -231,8 +253,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅订单失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }),
|
||||||
processFail: (error) => `订单推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.orderError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -245,8 +267,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅深度失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }),
|
||||||
processFail: (error) => `深度推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.depthError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -259,8 +281,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅Ticker失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }),
|
||||||
processFail: (error) => `价格推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.tickerError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -269,6 +291,7 @@ export class OffsetMakerEngine {
|
|||||||
(klines) => {
|
(klines) => {
|
||||||
if (!Array.isArray(klines) || !klines.length) return;
|
if (!Array.isArray(klines) || !klines.length) return;
|
||||||
const latest = klines[klines.length - 1];
|
const latest = klines[klines.length - 1];
|
||||||
|
if (!latest) return;
|
||||||
this.lastKline = latest;
|
this.lastKline = latest;
|
||||||
const open = Number(latest.open);
|
const open = Number(latest.open);
|
||||||
const close = Number(latest.close);
|
const close = Number(latest.close);
|
||||||
@@ -278,8 +301,8 @@ export class OffsetMakerEngine {
|
|||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
{
|
{
|
||||||
subscribeFail: (error) => `订阅K线失败: ${String(error)}`,
|
subscribeFail: (error) => t("log.subscribe.klineFail", { error: String(error) }),
|
||||||
processFail: (error) => `K线推送处理异常: ${String(error)}`,
|
processFail: (error) => t("log.process.klineError", { error: String(error) }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -340,7 +363,9 @@ export class OffsetMakerEngine {
|
|||||||
const position = this.getPositionSnapshot();
|
const position = this.getPositionSnapshot();
|
||||||
const isSpotMarket = this.marketType === "spot";
|
const isSpotMarket = this.marketType === "spot";
|
||||||
const spotBalances = isSpotMarket ? this.getSpotBalances() : null;
|
const spotBalances = isSpotMarket ? this.getSpotBalances() : null;
|
||||||
const balancesForSpot = isSpotMarket ? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0 } : spotBalances;
|
const balancesForSpot = isSpotMarket
|
||||||
|
? spotBalances ?? { baseAvailable: 0, quoteAvailable: 0, baseWallet: 0 }
|
||||||
|
: spotBalances;
|
||||||
this.updateLiveCandle();
|
this.updateLiveCandle();
|
||||||
const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum);
|
const handledImbalance = await this.handleImbalanceExit(position, buySum, sellSum);
|
||||||
if (handledImbalance) {
|
if (handledImbalance) {
|
||||||
@@ -374,9 +399,9 @@ export class OffsetMakerEngine {
|
|||||||
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
const askPrice = safeAsk != null ? formatPriceToString(safeAsk, priceDecimals) : null;
|
||||||
const rawAbsPosition = Math.abs(position.positionAmt);
|
const rawAbsPosition = Math.abs(position.positionAmt);
|
||||||
const minSell =
|
const minSell =
|
||||||
Number.isFinite(this.minBaseAmount) && this.minBaseAmount! > 0
|
Number.isFinite(this.precision.minBaseAmount) && this.precision.minBaseAmount! > 0
|
||||||
? this.minBaseAmount!
|
? this.precision.minBaseAmount!
|
||||||
: Math.max(this.config.tradeAmount, this.qtyStep);
|
: Math.max(this.config.tradeAmount, this.precision.qtyStep);
|
||||||
let absPosition = rawAbsPosition;
|
let absPosition = rawAbsPosition;
|
||||||
const tinySpotPosition =
|
const tinySpotPosition =
|
||||||
isSpotMarket &&
|
isSpotMarket &&
|
||||||
@@ -392,20 +417,18 @@ export class OffsetMakerEngine {
|
|||||||
|
|
||||||
if (absPosition < EPS && isSpotMarket) {
|
if (absPosition < EPS && isSpotMarket) {
|
||||||
this.entryPricePendingLogged = false;
|
this.entryPricePendingLogged = false;
|
||||||
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
const maxBase = this.sellableBase(balancesForSpot);
|
||||||
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
|
||||||
const maxBase = Math.max(baseAvail, baseWallet);
|
|
||||||
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
|
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
|
||||||
// 无法卖出,跳过卖单,允许买单累计
|
// 无法卖出,跳过卖单,允许买单累计
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
if (!skipSellSide) {
|
if (!skipSellSide) {
|
||||||
this.tradeLog.push("info", "现货持仓低于最小卖单量,暂不挂卖单");
|
this.tradeLog.push("info", t("log.spotMaker.belowMinSellHold"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipBuySide && canEnter) {
|
if (!skipBuySide && canEnter) {
|
||||||
if (!allowSpotBuy) {
|
if (!allowSpotBuy) {
|
||||||
if (this.lastBuyPriceViable) {
|
if (this.lastBuyPriceViable) {
|
||||||
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
|
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
|
||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -422,21 +445,19 @@ export class OffsetMakerEngine {
|
|||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
const reason =
|
const reason =
|
||||||
buyAmount < EPS && isSpotMarket
|
buyAmount < EPS && isSpotMarket
|
||||||
? "现货可用报价资产不足,跳过买单"
|
? t("log.spotMaker.quoteBalanceShort")
|
||||||
: "跳过买单:价差不足以构造maker价格";
|
: t("log.spotMaker.spreadTooTightBuy");
|
||||||
this.tradeLog.push("info", reason);
|
this.tradeLog.push("info", reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipSellSide && canEnter) {
|
if (!skipSellSide && canEnter) {
|
||||||
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
const maxBase = this.sellableBase(balancesForSpot);
|
||||||
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
|
||||||
const maxBase = Math.max(baseAvail, baseWallet);
|
|
||||||
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
|
if (isSpotMarket && minSell > 0 && maxBase + EPS < minSell) {
|
||||||
// 持仓低于最小卖单量,跳过卖单,等待累积
|
// 持仓低于最小卖单量,跳过卖单,等待累积
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
|
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const desiredSellAmount =
|
const desiredSellAmount =
|
||||||
@@ -454,8 +475,8 @@ export class OffsetMakerEngine {
|
|||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
const reason =
|
const reason =
|
||||||
sellAmount < EPS && isSpotMarket
|
sellAmount < EPS && isSpotMarket
|
||||||
? "现货可用基础资产不足,跳过卖单"
|
? t("log.spotMaker.baseBalanceShort")
|
||||||
: "跳过卖单:价差不足以构造maker价格";
|
: t("log.spotMaker.spreadTooTightSell");
|
||||||
this.tradeLog.push("info", reason);
|
this.tradeLog.push("info", reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -465,23 +486,25 @@ export class OffsetMakerEngine {
|
|||||||
if (!skipBuySide && canEnter) {
|
if (!skipBuySide && canEnter) {
|
||||||
if (isSpotMarket && !allowSpotBuy) {
|
if (isSpotMarket && !allowSpotBuy) {
|
||||||
if (this.lastBuyPriceViable) {
|
if (this.lastBuyPriceViable) {
|
||||||
this.tradeLog.push("info", "现货买入仅在1m阳线,当前跳过买单");
|
this.tradeLog.push("info", t("log.spotMaker.buyOnlyOnGreenCandle"));
|
||||||
this.lastBuyPriceViable = false;
|
this.lastBuyPriceViable = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (bidPrice != null) {
|
||||||
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
desired.push({ side: "BUY", price: bidPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!skipSellSide && canEnter) {
|
if (!skipSellSide && canEnter) {
|
||||||
if (isSpotMarket && minSell > 0 && this.minBaseAmount != null) {
|
const belowMinSell =
|
||||||
const baseAvail = balancesForSpot?.baseAvailable ?? 0;
|
isSpotMarket &&
|
||||||
const baseWallet = balancesForSpot?.baseWallet ?? baseAvail;
|
minSell > 0 &&
|
||||||
if (Math.max(baseAvail, baseWallet) + EPS < minSell) {
|
this.precision.minBaseAmount != null &&
|
||||||
this.lastSellPriceViable = false;
|
this.sellableBase(balancesForSpot) + EPS < minSell;
|
||||||
this.tradeLog.push("info", "现货持仓低于最小卖单量,跳过卖单");
|
if (belowMinSell) {
|
||||||
}
|
this.lastSellPriceViable = false;
|
||||||
|
this.tradeLog.push("info", t("log.spotMaker.belowMinSellSkip"));
|
||||||
|
} else if (askPrice != null) {
|
||||||
|
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
||||||
}
|
}
|
||||||
desired.push({ side: "SELL", price: askPrice, amount: this.config.tradeAmount, reduceOnly: false });
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
const closeSide: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY";
|
||||||
@@ -521,7 +544,7 @@ export class OffsetMakerEngine {
|
|||||||
await this.enforceRateLimitStop();
|
await this.enforceRateLimitStop();
|
||||||
this.tradeLog.push("warn", `OffsetMakerEngine 429: ${String(error)}`);
|
this.tradeLog.push("warn", `OffsetMakerEngine 429: ${String(error)}`);
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `偏移做市循环异常: ${String(error)}`);
|
this.tradeLog.push("error", t("log.offsetMaker.tickFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -542,17 +565,11 @@ export class OffsetMakerEngine {
|
|||||||
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null;
|
||||||
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null;
|
||||||
try {
|
try {
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
quantity: absPosition,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
side,
|
|
||||||
absPosition,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice:
|
expectedPrice:
|
||||||
side === "SELL"
|
side === "SELL"
|
||||||
@@ -560,13 +577,13 @@ export class OffsetMakerEngine {
|
|||||||
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
: (closeBidPrice != null ? Number(closeBidPrice) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "限频强制平仓时订单已不存在");
|
this.tradeLog.push("order", t("log.spotMaker.rateLimitCloseMissing"));
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `限频强制平仓失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.rateLimitCloseFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,18 +601,18 @@ export class OffsetMakerEngine {
|
|||||||
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
unlockOperating(this.locks, this.timers, this.pending, "LIMIT");
|
||||||
this.openOrders = [];
|
this.openOrders = [];
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
this.tradeLog.push("order", "启动时清理历史挂单");
|
this.tradeLog.push("order", t("log.spotMaker.startupCleanup"));
|
||||||
this.initialOrderResetDone = true;
|
this.initialOrderResetDone = true;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "历史挂单已消失,跳过启动清理");
|
this.tradeLog.push("order", t("log.spotMaker.startupCleanupGone"));
|
||||||
this.initialOrderResetDone = true;
|
this.initialOrderResetDone = true;
|
||||||
this.openOrders = [];
|
this.openOrders = [];
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", `启动撤单失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.startupCancelFailed", { error: String(error) }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,32 +648,30 @@ export class OffsetMakerEngine {
|
|||||||
const closeSidePrice = side === "SELL" ? bid : ask;
|
const closeSidePrice = side === "SELL" ? bid : ask;
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
`深度极端不平衡(${buySum.toFixed(4)} vs ${sellSum.toFixed(4)}), 市价平仓 ${side}`
|
t("log.offsetMaker.imbalanceClose", {
|
||||||
|
buySum: buySum.toFixed(4),
|
||||||
|
sellSum: sellSum.toFixed(4),
|
||||||
|
side,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
quantity: absPosition,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
side,
|
|
||||||
absPosition,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(closeSidePrice) || null,
|
expectedPrice: Number(closeSidePrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "深度不平衡平仓时订单已不存在");
|
this.tradeLog.push("order", t("log.offsetMaker.imbalanceCloseMissing"));
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `深度不平衡平仓失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.offsetMaker.imbalanceCloseFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -676,7 +691,7 @@ export class OffsetMakerEngine {
|
|||||||
const newPrice = Number(t.price);
|
const newPrice = Number(t.price);
|
||||||
const oldPrice = Number(existing.price);
|
const oldPrice = Number(existing.price);
|
||||||
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue;
|
||||||
const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick;
|
const ticksDiff = Math.abs(newPrice - oldPrice) / this.precision.priceTick;
|
||||||
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0;
|
||||||
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs;
|
||||||
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
if (ticksDiff < this.minRepriceTicks || withinDwell) {
|
||||||
@@ -702,17 +717,21 @@ export class OffsetMakerEngine {
|
|||||||
() => {
|
() => {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
`撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}`
|
t("log.spotMaker.cancelMismatched", {
|
||||||
|
side: order.side,
|
||||||
|
price: order.price,
|
||||||
|
reduceOnly: order.reduceOnly,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
|
// 保持与原逻辑一致:成功撤销不立即修改本地 openOrders,等待订单流重建
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略");
|
this.tradeLog.push("order", t("log.spotMaker.cancelAlreadySettled"));
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
|
// 避免同一轮内重复操作同一张已出错的本地挂单,直接从本地缓存移除,等待下一次订单推送重建
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
@@ -725,40 +744,31 @@ export class OffsetMakerEngine {
|
|||||||
if (target.amount < EPS) continue;
|
if (target.amount < EPS) continue;
|
||||||
if (
|
if (
|
||||||
this.marketType === "spot" &&
|
this.marketType === "spot" &&
|
||||||
this.minBaseAmount != null &&
|
this.precision.minBaseAmount != null &&
|
||||||
target.side === "SELL" &&
|
target.side === "SELL" &&
|
||||||
target.amount + EPS < this.minBaseAmount
|
target.amount + EPS < this.precision.minBaseAmount
|
||||||
) {
|
) {
|
||||||
// Skip placing sells that would be bumped by venue minimums
|
// Skip placing sells that would be bumped by venue minimums
|
||||||
if (this.lastSellPriceViable) {
|
if (this.lastSellPriceViable) {
|
||||||
this.lastSellPriceViable = false;
|
this.lastSellPriceViable = false;
|
||||||
this.tradeLog.push("info", "现货卖单低于最小成交量,跳过挂单等待累积");
|
this.tradeLog.push("info", t("log.spotMaker.sellBelowMinNotional"));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
|
const reduceOnlyFlag = this.marketType === "spot" ? false : target.reduceOnly;
|
||||||
await placeOrder(
|
await placeOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: target.side,
|
||||||
this.openOrders,
|
price: target.price,
|
||||||
this.locks,
|
amount: target.amount,
|
||||||
this.timers,
|
reduceOnly: reduceOnlyFlag,
|
||||||
this.pending,
|
guard: {
|
||||||
target.side,
|
|
||||||
target.price, // 已经是字符串价格
|
|
||||||
target.amount,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
reduceOnlyFlag,
|
|
||||||
{
|
|
||||||
markPrice: this.getPositionSnapshot().markPrice,
|
markPrice: this.getPositionSnapshot().markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{
|
qtyStep: this.precision.qtyStep
|
||||||
priceTick: this.priceTick,
|
});
|
||||||
qtyStep: this.qtyStep,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Record last placed entry order timing and price
|
// Record last placed entry order timing and price
|
||||||
if (!target.reduceOnly) {
|
if (!target.reduceOnly) {
|
||||||
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
this.lastEntryOrderBySide[target.side] = { price: target.price, ts: Date.now() };
|
||||||
@@ -774,10 +784,10 @@ export class OffsetMakerEngine {
|
|||||||
if (isRateLimitError(dustError)) {
|
if (isRateLimitError(dustError)) {
|
||||||
throw dustError;
|
throw dustError;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", `小额市价平仓失败: ${String(dustError)}`);
|
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(dustError) }));
|
||||||
}
|
}
|
||||||
if (dustClosed) continue;
|
if (dustClosed) continue;
|
||||||
this.tradeLog.push("error", `挂单失败(${target.side} ${target.price}): ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.placeFailed", { side: target.side, price: target.price, error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -790,10 +800,10 @@ export class OffsetMakerEngine {
|
|||||||
this.lastSpotStopSkipped = false;
|
this.lastSpotStopSkipped = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const minStopQty = Number.isFinite(this.minBaseAmount) ? this.minBaseAmount! : null;
|
const minStopQty = Number.isFinite(this.precision.minBaseAmount) ? this.precision.minBaseAmount! : null;
|
||||||
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
if (minStopQty != null && minStopQty > 0 && absPosition + EPS < minStopQty) {
|
||||||
if (!this.lastSpotStopSkipped) {
|
if (!this.lastSpotStopSkipped) {
|
||||||
this.tradeLog.push("info", "现货持仓低于最小平仓数量,跳过止损检查");
|
this.tradeLog.push("info", t("log.spotMaker.belowMinCloseSkipStop"));
|
||||||
this.lastSpotStopSkipped = true;
|
this.lastSpotStopSkipped = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -802,34 +812,28 @@ export class OffsetMakerEngine {
|
|||||||
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
const pnl = computePositionPnl(position, bidPrice, askPrice);
|
||||||
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
const triggerStop = shouldStopLoss(position, bidPrice, askPrice, this.config.lossLimit);
|
||||||
if (!triggerStop) return;
|
if (!triggerStop) return;
|
||||||
this.tradeLog.push("stop", `现货止损,当前仓位=${absPosition.toFixed(6)} PnL=${pnl.toFixed(4)} USDT`);
|
this.tradeLog.push("stop", t("log.spotMaker.spotStop", { qty: absPosition.toFixed(6), pnl: pnl.toFixed(4) }));
|
||||||
try {
|
try {
|
||||||
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
|
// 尽力撤销所有未完成挂单,避免锁定基础资产导致余额不足
|
||||||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
|
await this.exchange.cancelAllOrders({ symbol: this.config.symbol }).catch(() => {});
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: "SELL",
|
||||||
this.openOrders,
|
quantity: absPosition,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
"SELL",
|
|
||||||
absPosition,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: bidPrice || null,
|
expectedPrice: bidPrice || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRateLimitError(error)) throw error;
|
if (isRateLimitError(error)) throw error;
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `现货止损失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.spotStopFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -840,7 +844,7 @@ export class OffsetMakerEngine {
|
|||||||
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
const hasEntryPrice = Number.isFinite(position.entryPrice) && Math.abs(position.entryPrice) > 1e-8;
|
||||||
if (!hasEntryPrice) {
|
if (!hasEntryPrice) {
|
||||||
if (!this.entryPricePendingLogged) {
|
if (!this.entryPricePendingLogged) {
|
||||||
this.tradeLog.push("info", "做市持仓均价未同步,等待账户快照刷新后再执行止损判断");
|
this.tradeLog.push("info", t("log.spotMaker.entryPricePending"));
|
||||||
this.entryPricePendingLogged = true;
|
this.entryPricePendingLogged = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -853,32 +857,29 @@ export class OffsetMakerEngine {
|
|||||||
if (triggerStop) {
|
if (triggerStop) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
`触发止损,方向=${position.positionAmt > 0 ? "多" : "空"} 当前亏损=${pnl.toFixed(4)} USDT`
|
t("log.spotMaker.stopTriggered", {
|
||||||
|
direction: position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"),
|
||||||
|
pnl: pnl.toFixed(4),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await this.flushOrders();
|
await this.flushOrders();
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: position.positionAmt > 0 ? "SELL" : "BUY",
|
||||||
this.openOrders,
|
quantity: absPosition,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
position.positionAmt > 0 ? "SELL" : "BUY",
|
|
||||||
absPosition,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
expectedPrice: Number(position.positionAmt > 0 ? bidPrice : askPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUnknownOrderError(error)) {
|
if (isUnknownOrderError(error)) {
|
||||||
this.tradeLog.push("order", "止损平仓时订单已不存在");
|
this.tradeLog.push("order", t("log.spotMaker.stopCloseMissing"));
|
||||||
} else {
|
} else {
|
||||||
this.tradeLog.push("error", `止损平仓失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.stopCloseFailed", { error: String(error) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -897,12 +898,12 @@ export class OffsetMakerEngine {
|
|||||||
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
|
// 与原逻辑保持一致:成功撤销不记录日志且不修改本地 openOrders
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.tradeLog.push("order", "订单已不存在,撤销跳过");
|
this.tradeLog.push("order", t("log.spotMaker.orderMissingOnCancel"));
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
this.tradeLog.push("error", `撤销订单失败: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.cancelFailed", { error: String(error) }));
|
||||||
this.pendingCancelOrders.delete(String(order.orderId));
|
this.pendingCancelOrders.delete(String(order.orderId));
|
||||||
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
|
// 与同步撤单路径保持一致,移除本地异常订单,等待订单流重建
|
||||||
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId);
|
||||||
@@ -911,49 +912,8 @@ 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 (Number.isFinite(precision.minBaseAmount)) {
|
|
||||||
this.minBaseAmount = precision.minBaseAmount!;
|
|
||||||
}
|
|
||||||
if (Number.isFinite(precision.minQuoteAmount)) {
|
|
||||||
this.minQuoteAmount = precision.minQuoteAmount!;
|
|
||||||
}
|
|
||||||
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 {
|
private getPriceDecimals(): number {
|
||||||
const tick = Math.max(1e-9, this.priceTick);
|
const tick = Math.max(1e-9, this.precision.priceTick);
|
||||||
const raw = Math.log10(1 / tick);
|
const raw = Math.log10(1 / tick);
|
||||||
if (!Number.isFinite(raw)) return 0;
|
if (!Number.isFinite(raw)) return 0;
|
||||||
return Math.max(0, Math.floor(raw + 1e-9));
|
return Math.max(0, Math.floor(raw + 1e-9));
|
||||||
@@ -963,10 +923,10 @@ export class OffsetMakerEngine {
|
|||||||
try {
|
try {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
this.events.emit("update", snapshot, (error) => {
|
this.events.emit("update", snapshot, (error) => {
|
||||||
this.tradeLog.push("error", `更新回调处理异常: ${String(error)}`);
|
this.tradeLog.push("error", t("log.spotMaker.updateHandlerError", { error: String(error) }));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", `快照或更新分发异常: ${String(err)}`);
|
this.tradeLog.push("error", t("log.spotMaker.snapshotDispatchError", { error: String(err) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,6 +972,16 @@ export class OffsetMakerEngine {
|
|||||||
return this.spotKlineUp === true || this.isLiveCandleUp();
|
return this.spotKlineUp === true || this.isLiveCandleUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base asset the venue will actually let us sell. Wallet balance can exceed the
|
||||||
|
* available figure right after a fill settles, so the larger of the two wins.
|
||||||
|
*/
|
||||||
|
private sellableBase(balances: SpotBalances | null): number {
|
||||||
|
const available = balances?.baseAvailable ?? 0;
|
||||||
|
const wallet = balances?.baseWallet ?? available;
|
||||||
|
return Math.max(available, wallet);
|
||||||
|
}
|
||||||
|
|
||||||
private isLiveCandleUp(): boolean {
|
private isLiveCandleUp(): boolean {
|
||||||
if (!this.liveCandle) return false;
|
if (!this.liveCandle) return false;
|
||||||
return this.liveCandle.close > this.liveCandle.open;
|
return this.liveCandle.close > this.liveCandle.open;
|
||||||
@@ -1083,7 +1053,7 @@ export class OffsetMakerEngine {
|
|||||||
if (!params.balances) return desired;
|
if (!params.balances) return desired;
|
||||||
if (params.side === "SELL") {
|
if (params.side === "SELL") {
|
||||||
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
const cap = Math.max(0, params.balances.baseAvailable, params.balances.baseWallet ?? 0);
|
||||||
if (this.minBaseAmount != null && cap + EPS < this.minBaseAmount) {
|
if (this.precision.minBaseAmount != null && cap + EPS < this.precision.minBaseAmount) {
|
||||||
return 0; // below venue min trade size; skip sell until enough balance
|
return 0; // below venue min trade size; skip sell until enough balance
|
||||||
}
|
}
|
||||||
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
return this.roundToStep(Math.max(0, Math.min(desired, cap)));
|
||||||
@@ -1096,7 +1066,7 @@ export class OffsetMakerEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private roundToStep(amount: number): number {
|
private roundToStep(amount: number): number {
|
||||||
const step = Math.max(1e-9, this.qtyStep);
|
const step = Math.max(1e-9, this.precision.qtyStep);
|
||||||
return Math.floor(amount / step) * step;
|
return Math.floor(amount / step) * step;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1107,7 +1077,7 @@ export class OffsetMakerEngine {
|
|||||||
topAsk: number | null
|
topAsk: number | null
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
if (!Number.isFinite(rawPrice) || rawPrice <= 0) return null;
|
||||||
const tick = Math.max(this.priceTick, 1e-9);
|
const tick = Math.max(this.precision.priceTick, 1e-9);
|
||||||
if (side === "BUY") {
|
if (side === "BUY") {
|
||||||
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
if (topAsk == null || !Number.isFinite(topAsk)) return rawPrice;
|
||||||
const maxPrice = Number(topAsk) - tick;
|
const maxPrice = Number(topAsk) - tick;
|
||||||
@@ -1145,17 +1115,11 @@ export class OffsetMakerEngine {
|
|||||||
if (absQty < EPS) return false;
|
if (absQty < EPS) return false;
|
||||||
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
const { topBid, topAsk } = getTopPrices(this.depthSnapshot);
|
||||||
try {
|
try {
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: target.side,
|
||||||
this.openOrders,
|
quantity: absQty,
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
target.side,
|
|
||||||
absQty,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice:
|
expectedPrice:
|
||||||
target.side === "SELL"
|
target.side === "SELL"
|
||||||
@@ -1163,15 +1127,15 @@ export class OffsetMakerEngine {
|
|||||||
: (topAsk != null ? Number(topAsk) : null),
|
: (topAsk != null ? Number(topAsk) : null),
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.qtyStep }
|
qtyStep: this.precision.qtyStep
|
||||||
);
|
});
|
||||||
this.tradeLog.push("order", `小额仓位使用市价平仓 ${target.side} 数量 ${absQty.toFixed(6)}`);
|
this.tradeLog.push("order", t("log.spotMaker.dustClose", { side: target.side, qty: absQty.toFixed(6) }));
|
||||||
return true;
|
return true;
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
if (isRateLimitError(closeError)) {
|
if (isRateLimitError(closeError)) {
|
||||||
throw closeError;
|
throw closeError;
|
||||||
}
|
}
|
||||||
this.tradeLog.push("error", `小额市价平仓失败: ${String(closeError)}`);
|
this.tradeLog.push("error", t("log.spotMaker.dustCloseFailed", { error: String(closeError) }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
basisConfig,
|
||||||
|
gridConfig,
|
||||||
|
isBasisStrategyEnabled,
|
||||||
|
liquidityMakerConfig,
|
||||||
|
makerConfig,
|
||||||
|
makerPointsConfig,
|
||||||
|
swingConfig,
|
||||||
|
tradingConfig,
|
||||||
|
} from "../config";
|
||||||
|
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||||||
|
import { isBasisSupportedExchangeId, type SupportedExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import type { TradeLogEntry } from "../logging/trade-log";
|
||||||
|
import { BasisArbEngine } from "./basis-arb-engine";
|
||||||
|
import { GridEngine } from "./grid-engine";
|
||||||
|
import { GuardianEngine } from "./guardian-engine";
|
||||||
|
import { LiquidityMakerEngine } from "./liquidity-maker-engine";
|
||||||
|
import { MakerEngine } from "./maker-engine";
|
||||||
|
import { MakerPointsEngine } from "./maker-points-engine";
|
||||||
|
import { OffsetMakerEngine } from "./offset-maker-engine";
|
||||||
|
import { SwingEngine } from "./swing-engine";
|
||||||
|
import { TrendEngine } from "./trend-engine";
|
||||||
|
import { STRATEGY_IDS, type StrategyId } from "./strategy-ids";
|
||||||
|
|
||||||
|
/** The slice of every engine snapshot that generic consumers (CLI, UI) rely on. */
|
||||||
|
export interface StrategySnapshot {
|
||||||
|
ready: boolean;
|
||||||
|
tradeLog: TradeLogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every strategy engine offers its host. Consumers depend on this instead of
|
||||||
|
* the nine concrete classes, so neither the runner nor the UI needs a union of
|
||||||
|
* snapshot types that grows with each new strategy.
|
||||||
|
*/
|
||||||
|
export interface StrategyEngine<TSnapshot extends StrategySnapshot = StrategySnapshot> {
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
getSnapshot(): TSnapshot;
|
||||||
|
on(event: "update", handler: (snapshot: TSnapshot) => void): void;
|
||||||
|
off(event: "update", handler: (snapshot: TSnapshot) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyDefinition {
|
||||||
|
id: StrategyId;
|
||||||
|
/** Prefix for non-interactive console output; not translated. */
|
||||||
|
consoleLabel: string;
|
||||||
|
labelKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
/** Market the adapter must be built for before the engine is constructed. */
|
||||||
|
symbol(): string;
|
||||||
|
createEngine(adapter: ExchangeAdapter): StrategyEngine;
|
||||||
|
/**
|
||||||
|
* Why this strategy cannot run in the current environment, or null when it can.
|
||||||
|
* The menu hides strategies with a reason; the CLI reports it. One predicate
|
||||||
|
* keeps those two surfaces from disagreeing.
|
||||||
|
*/
|
||||||
|
unavailableReason?(exchangeId: SupportedExchangeId): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyed by StrategyId so a new id in strategy-ids.ts is a compile error here
|
||||||
|
* until it gets a definition.
|
||||||
|
*/
|
||||||
|
const DEFINITIONS: Record<StrategyId, StrategyDefinition> = {
|
||||||
|
trend: {
|
||||||
|
id: "trend",
|
||||||
|
consoleLabel: "Trend Following",
|
||||||
|
labelKey: "app.strategy.trend.label",
|
||||||
|
descriptionKey: "app.strategy.trend.desc",
|
||||||
|
symbol: () => tradingConfig.symbol,
|
||||||
|
createEngine: (adapter) => new TrendEngine(tradingConfig, adapter),
|
||||||
|
},
|
||||||
|
swing: {
|
||||||
|
id: "swing",
|
||||||
|
consoleLabel: "Swing",
|
||||||
|
labelKey: "app.strategy.swing.label",
|
||||||
|
descriptionKey: "app.strategy.swing.desc",
|
||||||
|
symbol: () => swingConfig.symbol,
|
||||||
|
createEngine: (adapter) => new SwingEngine(swingConfig, adapter),
|
||||||
|
},
|
||||||
|
guardian: {
|
||||||
|
id: "guardian",
|
||||||
|
consoleLabel: "Guardian",
|
||||||
|
labelKey: "app.strategy.guardian.label",
|
||||||
|
descriptionKey: "app.strategy.guardian.desc",
|
||||||
|
symbol: () => tradingConfig.symbol,
|
||||||
|
createEngine: (adapter) => new GuardianEngine(tradingConfig, adapter),
|
||||||
|
},
|
||||||
|
maker: {
|
||||||
|
id: "maker",
|
||||||
|
consoleLabel: "Maker",
|
||||||
|
labelKey: "app.strategy.maker.label",
|
||||||
|
descriptionKey: "app.strategy.maker.desc",
|
||||||
|
symbol: () => makerConfig.symbol,
|
||||||
|
createEngine: (adapter) => new MakerEngine(makerConfig, adapter),
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
id: "grid",
|
||||||
|
consoleLabel: "Grid",
|
||||||
|
labelKey: "app.strategy.grid.label",
|
||||||
|
descriptionKey: "app.strategy.grid.desc",
|
||||||
|
symbol: () => gridConfig.symbol,
|
||||||
|
createEngine: (adapter) => new GridEngine(gridConfig, adapter),
|
||||||
|
},
|
||||||
|
"maker-points": {
|
||||||
|
id: "maker-points",
|
||||||
|
consoleLabel: "Maker Points",
|
||||||
|
labelKey: "app.strategy.makerPoints.label",
|
||||||
|
descriptionKey: "app.strategy.makerPoints.desc",
|
||||||
|
symbol: () => makerPointsConfig.symbol,
|
||||||
|
createEngine: (adapter) => new MakerPointsEngine(makerPointsConfig, adapter),
|
||||||
|
unavailableReason: (exchangeId) =>
|
||||||
|
exchangeId === "standx" ? null : "Maker Points strategy only supports the StandX exchange.",
|
||||||
|
},
|
||||||
|
"offset-maker": {
|
||||||
|
id: "offset-maker",
|
||||||
|
consoleLabel: "Offset Maker",
|
||||||
|
labelKey: "app.strategy.offset.label",
|
||||||
|
descriptionKey: "app.strategy.offset.desc",
|
||||||
|
symbol: () => makerConfig.symbol,
|
||||||
|
createEngine: (adapter) => new OffsetMakerEngine(makerConfig, adapter),
|
||||||
|
},
|
||||||
|
"liquidity-maker": {
|
||||||
|
id: "liquidity-maker",
|
||||||
|
consoleLabel: "Liquidity Maker",
|
||||||
|
labelKey: "app.strategy.liquidityMaker.label",
|
||||||
|
descriptionKey: "app.strategy.liquidityMaker.desc",
|
||||||
|
symbol: () => liquidityMakerConfig.symbol,
|
||||||
|
createEngine: (adapter) => new LiquidityMakerEngine(liquidityMakerConfig, adapter),
|
||||||
|
},
|
||||||
|
basis: {
|
||||||
|
id: "basis",
|
||||||
|
consoleLabel: "Basis Arbitrage",
|
||||||
|
labelKey: "app.strategy.basis.label",
|
||||||
|
descriptionKey: "app.strategy.basis.desc",
|
||||||
|
symbol: () => basisConfig.futuresSymbol,
|
||||||
|
createEngine: (adapter) => new BasisArbEngine(basisConfig, adapter),
|
||||||
|
unavailableReason: (exchangeId) => {
|
||||||
|
if (!isBasisStrategyEnabled()) {
|
||||||
|
return "Basis arbitrage strategy is disabled. Set ENABLE_BASIS_STRATEGY=true to enable it.";
|
||||||
|
}
|
||||||
|
if (!isBasisSupportedExchangeId(exchangeId)) {
|
||||||
|
return "Basis arbitrage strategy currently only supports the Aster, Nado, StandX, and Binance exchanges";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Menu order. */
|
||||||
|
export const STRATEGY_DEFINITIONS: readonly StrategyDefinition[] = STRATEGY_IDS.map((id) => DEFINITIONS[id]);
|
||||||
|
|
||||||
|
export function getStrategyDefinition(id: StrategyId): StrategyDefinition {
|
||||||
|
return DEFINITIONS[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyUnavailableReason(id: StrategyId, exchangeId: SupportedExchangeId): string | null {
|
||||||
|
return DEFINITIONS[id].unavailableReason?.(exchangeId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strategies runnable on this exchange, in menu order. */
|
||||||
|
export function availableStrategies(exchangeId: SupportedExchangeId): StrategyDefinition[] {
|
||||||
|
return STRATEGY_DEFINITIONS.filter((definition) => definition.unavailableReason?.(exchangeId) == null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* The canonical list of strategies. Dependency-free on purpose: CLI argument
|
||||||
|
* parsing imports this without dragging in every engine and its config.
|
||||||
|
*
|
||||||
|
* Adding a strategy starts here; `strategy/registry.ts` then fails to compile
|
||||||
|
* until the new id has a definition.
|
||||||
|
*/
|
||||||
|
/** Order is the interactive menu's order. */
|
||||||
|
export const STRATEGY_IDS = [
|
||||||
|
"trend",
|
||||||
|
"swing",
|
||||||
|
"guardian",
|
||||||
|
"maker",
|
||||||
|
"maker-points",
|
||||||
|
"grid",
|
||||||
|
"offset-maker",
|
||||||
|
"liquidity-maker",
|
||||||
|
"basis",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type StrategyId = (typeof STRATEGY_IDS)[number];
|
||||||
|
|
||||||
|
/** Spellings accepted on the command line beyond the canonical ids. */
|
||||||
|
const STRATEGY_ALIASES: Record<string, StrategyId> = {
|
||||||
|
offset: "offset-maker",
|
||||||
|
offsetmaker: "offset-maker",
|
||||||
|
makerpoints: "maker-points",
|
||||||
|
maker_points: "maker-points",
|
||||||
|
liquidity: "liquidity-maker",
|
||||||
|
liquiditymaker: "liquidity-maker",
|
||||||
|
liquidity_maker: "liquidity-maker",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isStrategyId(value: string): value is StrategyId {
|
||||||
|
return (STRATEGY_IDS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns the strategy the input names, or null when it names none. */
|
||||||
|
export function parseStrategyId(raw: string): StrategyId | null {
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (isStrategyId(normalized)) return normalized;
|
||||||
|
return STRATEGY_ALIASES[normalized] ?? null;
|
||||||
|
}
|
||||||
@@ -2,13 +2,14 @@ import type { ExchangeAdapter } from "../exchanges/adapter";
|
|||||||
import type { AccountSnapshot, Depth, Order, Ticker } from "../exchanges/types";
|
import type { AccountSnapshot, Depth, Order, Ticker } from "../exchanges/types";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||||
import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator";
|
import { marketClose, placeMarketOrder, placeStopLossOrder, unlockOperating } from "../core/order-coordinator";
|
||||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors";
|
import { extractMessage, isRateLimitError, isUnknownOrderError } from "../utils/errors";
|
||||||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||||||
import { computePositionPnl } from "../utils/pnl";
|
import { computePositionPnl } from "../utils/pnl";
|
||||||
import { getMidOrLast, getTopPrices } from "../utils/price";
|
import { getMidOrLast, getTopPrices } from "../utils/price";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -93,7 +94,7 @@ export class SwingEngine {
|
|||||||
private lastError: string | null = null;
|
private lastError: string | null = null;
|
||||||
|
|
||||||
private ordersSnapshotReady = false;
|
private ordersSnapshotReady = false;
|
||||||
private precisionSync: Promise<void> | null = null;
|
private readonly precision: PrecisionSyncer;
|
||||||
private swingState: SwingState = createInitialSwingState();
|
private swingState: SwingState = createInitialSwingState();
|
||||||
|
|
||||||
// Stop-loss placement de-bounce
|
// Stop-loss placement de-bounce
|
||||||
@@ -128,10 +129,26 @@ export class SwingEngine {
|
|||||||
});
|
});
|
||||||
this.binanceRsi.start();
|
this.binanceRsi.start();
|
||||||
|
|
||||||
this.syncPrecision();
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
||||||
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
||||||
|
private get orderContext(): OrderContext {
|
||||||
|
return (this.orderContextCache ??= {
|
||||||
|
adapter: this.exchange,
|
||||||
|
symbol: this.config.symbol,
|
||||||
|
locks: this.locks,
|
||||||
|
timers: this.timers,
|
||||||
|
pendings: this.pending,
|
||||||
|
log: (type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private orderContextCache: OrderContext | null = null;
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -144,6 +161,7 @@ export class SwingEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
// Binance tracker is external IO; stop it too.
|
// Binance tracker is external IO; stop it too.
|
||||||
this.binanceRsi.stop();
|
this.binanceRsi.stop();
|
||||||
}
|
}
|
||||||
@@ -343,24 +361,18 @@ export class SwingEngine {
|
|||||||
if (Math.abs(position.positionAmt) > EPS) {
|
if (Math.abs(position.positionAmt) > EPS) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await placeMarketOrder(
|
await placeMarketOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
amount: this.config.tradeAmount,
|
||||||
this.locks,
|
reduceOnly: false,
|
||||||
this.timers,
|
guard: {
|
||||||
this.pending,
|
|
||||||
side,
|
|
||||||
this.config.tradeAmount,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
false,
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.config.qtyStep }
|
qtyStep: this.config.qtyStep
|
||||||
);
|
});
|
||||||
this.tradeLog.push("open", `${reason}: ${side} (market)`);
|
this.tradeLog.push("open", `${reason}: ${side} (market)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
|
this.tradeLog.push("error", `Open failed: ${extractMessage(err)}`);
|
||||||
@@ -375,23 +387,17 @@ export class SwingEngine {
|
|||||||
side === "SELL"
|
side === "SELL"
|
||||||
? Number(this.depthSnapshot?.bids?.[0]?.[0])
|
? Number(this.depthSnapshot?.bids?.[0]?.[0])
|
||||||
: Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
: Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
quantity: Math.abs(position.positionAmt),
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
side,
|
|
||||||
Math.abs(position.positionAmt),
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
|
expectedPrice: Number.isFinite(expected) ? expected : Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.config.qtyStep }
|
qtyStep: this.config.qtyStep
|
||||||
);
|
});
|
||||||
this.tradeLog.push("close", `${reason}: ${side} (market close)`);
|
this.tradeLog.push("close", `${reason}: ${side} (market close)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isUnknownOrderError(err)) {
|
if (isUnknownOrderError(err)) {
|
||||||
@@ -454,24 +460,19 @@ export class SwingEngine {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const qty = Math.abs(position.positionAmt);
|
const qty = Math.abs(position.positionAmt);
|
||||||
await placeStopLossOrder(
|
await placeStopLossOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: stopSide,
|
||||||
this.openOrders,
|
stopPrice: stopPrice,
|
||||||
this.locks,
|
quantity: qty,
|
||||||
this.timers,
|
lastPrice: lastPrice,
|
||||||
this.pending,
|
guard: {
|
||||||
stopSide,
|
|
||||||
stopPrice,
|
|
||||||
qty,
|
|
||||||
lastPrice,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
this.lastStopAttempt = { side: stopSide, price: stopPrice, at: Date.now() };
|
||||||
@@ -585,39 +586,4 @@ export class SwingEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
|
||||||
`Synced precision: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", `Precision sync failed: ${extractMessage(error)}`);
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-134
@@ -25,14 +25,16 @@ import {
|
|||||||
placeTrailingStopOrder,
|
placeTrailingStopOrder,
|
||||||
unlockOperating,
|
unlockOperating,
|
||||||
} from "../core/order-coordinator";
|
} from "../core/order-coordinator";
|
||||||
import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
import type { OrderContext, OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator";
|
||||||
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
||||||
import { formatPriceToString } from "../utils/math";
|
import { formatPriceToString } from "../utils/math";
|
||||||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||||||
import { decryptCopyright } from "../utils/copyright";
|
import { decryptCopyright } from "../utils/copyright";
|
||||||
import { isRateLimitError } from "../utils/errors";
|
import { isRateLimitError } from "../utils/errors";
|
||||||
import { RateLimitController } from "../core/lib/rate-limit";
|
import { RateLimitController } from "../core/lib/rate-limit";
|
||||||
|
import type { TrendLabel } from "../utils/format";
|
||||||
import { StrategyEventEmitter } from "./common/event-emitter";
|
import { StrategyEventEmitter } from "./common/event-emitter";
|
||||||
|
import { createPrecisionSyncer, type PrecisionSyncer } from "./common/precision-syncer";
|
||||||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||||||
import { SessionVolumeTracker } from "./common/session-volume";
|
import { SessionVolumeTracker } from "./common/session-volume";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@@ -43,7 +45,7 @@ export interface TrendEngineSnapshot {
|
|||||||
lastPrice: number | null;
|
lastPrice: number | null;
|
||||||
sma30: number | null;
|
sma30: number | null;
|
||||||
bollingerBandwidth: number | null;
|
bollingerBandwidth: number | null;
|
||||||
trend: "做多" | "做空" | "无信号";
|
trend: TrendLabel;
|
||||||
position: PositionSnapshot;
|
position: PositionSnapshot;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
unrealized: number;
|
unrealized: number;
|
||||||
@@ -124,17 +126,33 @@ export class TrendEngine {
|
|||||||
.digest("hex");
|
.digest("hex");
|
||||||
|
|
||||||
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
private readonly listeners = new Map<TrendEngineEvent, Set<TrendEngineListener>>();
|
||||||
private precisionSync: Promise<void> | null = null;
|
private readonly precision: PrecisionSyncer;
|
||||||
|
|
||||||
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) {
|
||||||
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
this.tradeLog = createTradeLog(this.config.maxLogEntries);
|
||||||
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) =>
|
||||||
this.tradeLog.push(type, detail)
|
this.tradeLog.push(type, detail)
|
||||||
);
|
);
|
||||||
this.syncPrecision();
|
this.precision = createPrecisionSyncer(this.exchange, this.config, this.config.qtyStep, (type, detail) =>
|
||||||
|
this.tradeLog.push(type, detail)
|
||||||
|
);
|
||||||
|
this.precision.start();
|
||||||
this.bootstrap();
|
this.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bundles the fixed order-routing state; rebuilt lazily on first use. */
|
||||||
|
private get orderContext(): OrderContext {
|
||||||
|
return (this.orderContextCache ??= {
|
||||||
|
adapter: this.exchange,
|
||||||
|
symbol: this.config.symbol,
|
||||||
|
locks: this.locks,
|
||||||
|
timers: this.timers,
|
||||||
|
pendings: this.pending,
|
||||||
|
log: (type, detail) => this.tradeLog.push(type, detail),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private orderContextCache: OrderContext | null = null;
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
@@ -147,6 +165,7 @@ export class TrendEngine {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
}
|
}
|
||||||
|
this.precision.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
on(event: TrendEngineEvent, handler: TrendEngineListener): void {
|
||||||
@@ -485,24 +504,18 @@ export class TrendEngine {
|
|||||||
|
|
||||||
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
|
private async submitMarketOrder(side: "BUY" | "SELL", price: number, reason: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await placeMarketOrder(
|
await placeMarketOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
amount: this.config.tradeAmount,
|
||||||
this.locks,
|
reduceOnly: false,
|
||||||
this.timers,
|
guard: {
|
||||||
this.pending,
|
|
||||||
side,
|
|
||||||
this.config.tradeAmount,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
false,
|
|
||||||
{
|
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
expectedPrice: Number(this.tickerSnapshot?.lastPrice) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.config.qtyStep }
|
qtyStep: this.config.qtyStep
|
||||||
);
|
});
|
||||||
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
this.tradeLog.push("open", `${reason}: ${side} @ ${price}`);
|
||||||
this.lastOpenPlan = { side, price };
|
this.lastOpenPlan = { side, price };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -743,17 +756,11 @@ export class TrendEngine {
|
|||||||
return { closed: false, pnl };
|
return { closed: false, pnl };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await marketClose(
|
await marketClose(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: direction === "long" ? "SELL" : "BUY",
|
||||||
this.openOrders,
|
quantity: Math.abs(position.positionAmt),
|
||||||
this.locks,
|
guard: {
|
||||||
this.timers,
|
|
||||||
this.pending,
|
|
||||||
direction === "long" ? "SELL" : "BUY",
|
|
||||||
Math.abs(position.positionAmt),
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
expectedPrice: Number(
|
expectedPrice: Number(
|
||||||
direction === "long"
|
direction === "long"
|
||||||
@@ -762,8 +769,8 @@ export class TrendEngine {
|
|||||||
) || null,
|
) || null,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ qtyStep: this.config.qtyStep }
|
qtyStep: this.config.qtyStep
|
||||||
);
|
});
|
||||||
result.closed = true;
|
result.closed = true;
|
||||||
this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" }));
|
this.tradeLog.push("close", t("log.trend.stopClose", { side: direction === "long" ? "SELL" : "BUY" }));
|
||||||
// 记录止损时间以便短期内抑制再次入场
|
// 记录止损时间以便短期内抑制再次入场
|
||||||
@@ -806,24 +813,19 @@ export class TrendEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await placeStopLossOrder(
|
await placeStopLossOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
stopPrice: stopPrice,
|
||||||
this.locks,
|
quantity: quantity,
|
||||||
this.timers,
|
lastPrice: lastPrice,
|
||||||
this.pending,
|
guard: {
|
||||||
side,
|
|
||||||
stopPrice,
|
|
||||||
quantity,
|
|
||||||
lastPrice,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
this.lastStopAttempt = { side, price: stopPrice, at: Date.now() };
|
this.lastStopAttempt = { side, price: stopPrice, at: Date.now() };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) }));
|
this.tradeLog.push("error", t("log.trend.placeStopFail", { error: String(err) }));
|
||||||
@@ -866,24 +868,19 @@ export class TrendEngine {
|
|||||||
if (quantity <= minQty) {
|
if (quantity <= minQty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const order = await placeStopLossOrder(
|
const order = await placeStopLossOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
stopPrice: nextStopPrice,
|
||||||
this.locks,
|
quantity: quantity,
|
||||||
this.timers,
|
lastPrice: lastPrice,
|
||||||
this.pending,
|
guard: {
|
||||||
side,
|
|
||||||
nextStopPrice,
|
|
||||||
quantity,
|
|
||||||
lastPrice,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
if (order) {
|
if (order) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"stop",
|
"stop",
|
||||||
@@ -909,24 +906,19 @@ export class TrendEngine {
|
|||||||
(side === "SELL" && existingStopPrice >= lastPrice) ||
|
(side === "SELL" && existingStopPrice >= lastPrice) ||
|
||||||
(side === "BUY" && existingStopPrice <= lastPrice);
|
(side === "BUY" && existingStopPrice <= lastPrice);
|
||||||
if (!restoreInvalid) {
|
if (!restoreInvalid) {
|
||||||
const restored = await placeStopLossOrder(
|
const restored = await placeStopLossOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
stopPrice: existingStopPrice,
|
||||||
this.locks,
|
quantity: quantity,
|
||||||
this.timers,
|
lastPrice: lastPrice,
|
||||||
this.pending,
|
guard: {
|
||||||
side,
|
|
||||||
existingStopPrice,
|
|
||||||
quantity,
|
|
||||||
lastPrice,
|
|
||||||
(t, d) => this.tradeLog.push(t, d),
|
|
||||||
{
|
|
||||||
markPrice: position.markPrice,
|
markPrice: position.markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
if (restored) {
|
if (restored) {
|
||||||
this.tradeLog.push(
|
this.tradeLog.push(
|
||||||
"order",
|
"order",
|
||||||
@@ -954,65 +946,24 @@ export class TrendEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await placeTrailingStopOrder(
|
await placeTrailingStopOrder(this.orderContext, {
|
||||||
this.exchange,
|
openOrders: this.openOrders,
|
||||||
this.config.symbol,
|
side: side,
|
||||||
this.openOrders,
|
activationPrice: activationPrice,
|
||||||
this.locks,
|
quantity: quantity,
|
||||||
this.timers,
|
callbackRate: this.config.trailingCallbackRate,
|
||||||
this.pending,
|
guard: {
|
||||||
side,
|
|
||||||
activationPrice,
|
|
||||||
quantity,
|
|
||||||
this.config.trailingCallbackRate,
|
|
||||||
(type, detail) => this.tradeLog.push(type, detail),
|
|
||||||
{
|
|
||||||
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
markPrice: getPosition(this.accountSnapshot, this.config.symbol).markPrice,
|
||||||
maxPct: this.config.maxCloseSlippagePct,
|
maxPct: this.config.maxCloseSlippagePct,
|
||||||
},
|
},
|
||||||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
priceTick: this.config.priceTick,
|
||||||
);
|
qtyStep: this.config.qtyStep
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.tradeLog.push("error", t("log.trend.trailingFail", { error: String(err) }));
|
this.tradeLog.push("error", t("log.trend.trailingFail", { error: String(err) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
|
||||||
t("log.trend.precisionSynced", { priceTick: precision.priceTick, qtyStep: precision.qtyStep })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.tradeLog.push("error", t("log.trend.precisionFailed", { error: extractMessage(error) }));
|
|
||||||
this.precisionSync = null;
|
|
||||||
setTimeout(() => this.syncPrecision(), 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitUpdate(): void {
|
private emitUpdate(): void {
|
||||||
try {
|
try {
|
||||||
const snapshot = this.buildSnapshot();
|
const snapshot = this.buildSnapshot();
|
||||||
@@ -1028,13 +979,14 @@ export class TrendEngine {
|
|||||||
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
const position = getPosition(this.accountSnapshot, this.config.symbol);
|
||||||
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
const price = this.tickerSnapshot ? Number(this.tickerSnapshot.lastPrice) : null;
|
||||||
const sma30 = this.lastSma30;
|
const sma30 = this.lastSma30;
|
||||||
const trend = price == null || sma30 == null
|
const trend: TrendLabel =
|
||||||
? "无信号"
|
price == null || sma30 == null
|
||||||
: price > sma30
|
? "none"
|
||||||
? "做多"
|
: price > sma30
|
||||||
: price < sma30
|
? "long"
|
||||||
? "做空"
|
: price < sma30
|
||||||
: "无信号";
|
? "short"
|
||||||
|
: "none";
|
||||||
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
|
const pnl = price != null ? computePositionPnl(position, price, price) : 0;
|
||||||
return {
|
return {
|
||||||
ready: this.isReady(),
|
ready: this.isReady(),
|
||||||
|
|||||||
+25
-78
@@ -9,93 +9,40 @@ import { OffsetMakerApp } from "./OffsetMakerApp";
|
|||||||
import { LiquidityMakerApp } from "./LiquidityMakerApp";
|
import { LiquidityMakerApp } from "./LiquidityMakerApp";
|
||||||
import { GridApp } from "./GridApp";
|
import { GridApp } from "./GridApp";
|
||||||
import { BasisApp } from "./BasisApp";
|
import { BasisApp } from "./BasisApp";
|
||||||
import { isBasisStrategyEnabled } from "../config";
|
|
||||||
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
|
import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright";
|
||||||
import { resolveExchangeId } from "../exchanges/create-adapter";
|
import { resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { availableStrategies } from "../strategy/registry";
|
||||||
|
import type { StrategyId } from "../strategy/strategy-ids";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface StrategyOption {
|
type StrategyView = React.ComponentType<{ onExit: () => void }>;
|
||||||
id: "trend" | "swing" | "guardian" | "maker" | "maker-points" | "offset-maker" | "liquidity-maker" | "basis" | "grid";
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
component: React.ComponentType<{ onExit: () => void }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_STRATEGIES: StrategyOption[] = [
|
/**
|
||||||
{
|
* The only strategy knowledge the UI owns: which screen renders which engine.
|
||||||
id: "trend",
|
* Typed as a total Record, so adding a strategy to the registry fails to compile
|
||||||
label: t("app.strategy.trend.label"),
|
* here until it has a view.
|
||||||
description: t("app.strategy.trend.desc"),
|
*/
|
||||||
component: TrendApp,
|
const STRATEGY_VIEWS: Record<StrategyId, StrategyView> = {
|
||||||
},
|
trend: TrendApp,
|
||||||
{
|
swing: SwingApp,
|
||||||
id: "swing",
|
guardian: GuardianApp,
|
||||||
label: t("app.strategy.swing.label"),
|
maker: MakerApp,
|
||||||
description: t("app.strategy.swing.desc"),
|
"maker-points": MakerPointsApp,
|
||||||
component: SwingApp,
|
grid: GridApp,
|
||||||
},
|
"offset-maker": OffsetMakerApp,
|
||||||
{
|
"liquidity-maker": LiquidityMakerApp,
|
||||||
id: "guardian",
|
basis: BasisApp,
|
||||||
label: t("app.strategy.guardian.label"),
|
};
|
||||||
description: t("app.strategy.guardian.desc"),
|
|
||||||
component: GuardianApp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "maker",
|
|
||||||
label: t("app.strategy.maker.label"),
|
|
||||||
description: t("app.strategy.maker.desc"),
|
|
||||||
component: MakerApp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "grid",
|
|
||||||
label: t("app.strategy.grid.label"),
|
|
||||||
description: t("app.strategy.grid.desc"),
|
|
||||||
component: GridApp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "offset-maker",
|
|
||||||
label: t("app.strategy.offset.label"),
|
|
||||||
description: t("app.strategy.offset.desc"),
|
|
||||||
component: OffsetMakerApp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "liquidity-maker",
|
|
||||||
label: t("app.strategy.liquidityMaker.label"),
|
|
||||||
description: t("app.strategy.liquidityMaker.desc"),
|
|
||||||
component: LiquidityMakerApp,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [cursor, setCursor] = useState(0);
|
const [cursor, setCursor] = useState(0);
|
||||||
const [selected, setSelected] = useState<StrategyOption | null>(null);
|
const [selected, setSelected] = useState<StrategyId | null>(null);
|
||||||
const copyright = useMemo(() => loadCopyrightFragments(), []);
|
const copyright = useMemo(() => loadCopyrightFragments(), []);
|
||||||
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []);
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
const strategies = useMemo(() => {
|
const strategies = useMemo(() => availableStrategies(exchangeId), [exchangeId]);
|
||||||
const next: StrategyOption[] = [...BASE_STRATEGIES];
|
|
||||||
if (exchangeId === "standx") {
|
|
||||||
const gridIndex = next.findIndex((s) => s.id === "grid");
|
|
||||||
const insertAt = gridIndex === -1 ? next.length : gridIndex;
|
|
||||||
next.splice(insertAt, 0, {
|
|
||||||
id: "maker-points" as const,
|
|
||||||
label: t("app.strategy.makerPoints.label"),
|
|
||||||
description: t("app.strategy.makerPoints.desc"),
|
|
||||||
component: MakerPointsApp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isBasisStrategyEnabled()) {
|
|
||||||
next.push({
|
|
||||||
id: "basis" as const,
|
|
||||||
label: t("app.strategy.basis.label"),
|
|
||||||
description: t("app.strategy.basis.desc"),
|
|
||||||
component: BasisApp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}, [exchangeId]);
|
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(input, key) => {
|
(input, key) => {
|
||||||
@@ -107,7 +54,7 @@ export function App() {
|
|||||||
} else if (key.return) {
|
} else if (key.return) {
|
||||||
const strategy = strategies[cursor];
|
const strategy = strategies[cursor];
|
||||||
if (strategy) {
|
if (strategy) {
|
||||||
setSelected(strategy);
|
setSelected(strategy.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -115,7 +62,7 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const Selected = selected.component;
|
const Selected = STRATEGY_VIEWS[selected];
|
||||||
return <Selected onExit={() => setSelected(null)} />;
|
return <Selected onExit={() => setSelected(null)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +83,9 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<Box key={strategy.id} flexDirection="column" marginBottom={1}>
|
<Box key={strategy.id} flexDirection="column" marginBottom={1}>
|
||||||
<Text color={active ? "greenBright" : undefined}>
|
<Text color={active ? "greenBright" : undefined}>
|
||||||
{active ? "➤" : " "} {strategy.label}
|
{active ? "➤" : " "} {t(strategy.labelKey)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color="gray"> {strategy.description}</Text>
|
<Text color="gray"> {t(strategy.descriptionKey)}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+7
-47
@@ -1,59 +1,19 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { basisConfig } from "../config";
|
import { basisConfig } from "../config";
|
||||||
import { getExchangeDisplayName, isBasisSupportedExchangeId, resolveExchangeId } from "../exchanges/create-adapter";
|
import type { BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine";
|
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface BasisAppProps {
|
interface BasisAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function BasisApp({ onExit }: BasisAppProps) {
|
export function BasisApp({ onExit }: BasisAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<BasisArbSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<BasisArbSnapshot>("basis", {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
onExit
|
||||||
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 (!isBasisSupportedExchangeId(exchangeId)) {
|
|
||||||
setError(new Error(t("basis.onlyAster")));
|
|
||||||
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+34
-50
@@ -1,61 +1,26 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { gridConfig } from "../config";
|
import { gridConfig } from "../config";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
import type { GridEngineSnapshot } from "../strategy/grid-engine";
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine";
|
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface GridAppProps {
|
interface GridAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function GridApp({ onExit }: GridAppProps) {
|
export function GridApp({ onExit }: GridAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<GridEngineSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<GridEngineSnapshot>("grid", {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
onExit,
|
||||||
const engineRef = useRef<GridEngine | null>(null);
|
cloneSnapshot: (next) => ({
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
...next,
|
||||||
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
desiredOrders: [...next.desiredOrders],
|
||||||
|
gridLines: [...next.gridLines],
|
||||||
useInput(
|
tradeLog: [...next.tradeLog],
|
||||||
(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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -90,15 +55,17 @@ export function GridApp({ onExit }: GridAppProps) {
|
|||||||
{ key: "level", header: "#", align: "right", minWidth: 3 },
|
{ key: "level", header: "#", align: "right", minWidth: 3 },
|
||||||
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||||
{ key: "side", header: "Side", minWidth: 4 },
|
{ key: "side", header: "Side", minWidth: 4 },
|
||||||
{ key: "active", header: "Active", minWidth: 6 },
|
{ key: "state", header: "State", minWidth: 11 },
|
||||||
{ key: "hasOrder", header: "Order", minWidth: 5 },
|
{ key: "hasOrder", header: "Order", minWidth: 5 },
|
||||||
|
{ key: "hold", header: "Hold", align: "right", minWidth: 8 },
|
||||||
];
|
];
|
||||||
const gridRows = snapshot.gridLines.map((line) => ({
|
const gridRows = snapshot.gridLines.map((line) => ({
|
||||||
level: line.level,
|
level: line.level,
|
||||||
price: formatNumber(line.price, 4),
|
price: formatNumber(line.price, 4),
|
||||||
side: line.side,
|
side: line.side,
|
||||||
active: line.active ? "yes" : "no",
|
state: line.state,
|
||||||
hasOrder: line.hasOrder ? "yes" : "no",
|
hasOrder: line.hasOrder ? "yes" : "no",
|
||||||
|
hold: line.holdQty > 0 ? formatNumber(line.holdQty, 4) : "-",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const desiredColumns: TableColumn[] = [
|
const desiredColumns: TableColumn[] = [
|
||||||
@@ -141,6 +108,23 @@ export function GridApp({ onExit }: GridAppProps) {
|
|||||||
count: snapshot.gridLines.length,
|
count: snapshot.gridLines.length,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t("grid.anchorLine", {
|
||||||
|
anchor: formatNumber(snapshot.anchorPrice, 4),
|
||||||
|
version: snapshot.gridVersion,
|
||||||
|
})}
|
||||||
|
{snapshot.shiftPhase ? (
|
||||||
|
<Text color="yellow"> | {t("grid.shiftState", { phase: snapshot.shiftPhase })}</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
<Text color={snapshot.stopProtection.uncoveredQty > 0 ? "yellow" : "gray"}>
|
||||||
|
{t("grid.stopProtection", {
|
||||||
|
uncovered: formatNumber(snapshot.stopProtection.uncoveredQty, 6),
|
||||||
|
stop: snapshot.stopProtection.exchangeStop
|
||||||
|
? `${snapshot.stopProtection.exchangeStop.side} @ ${formatNumber(snapshot.stopProtection.exchangeStop.stopPrice, 4)}`
|
||||||
|
: t("grid.stopProtection.none"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
<Text color="gray">
|
<Text color="gray">
|
||||||
{t("grid.dataStatus")}
|
{t("grid.dataStatus")}
|
||||||
{feedEntries.map((entry, index) => (
|
{feedEntries.map((entry, index) => (
|
||||||
|
|||||||
+7
-44
@@ -1,11 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { tradingConfig } from "../config";
|
import type { GuardianEngineSnapshot } from "../strategy/guardian-engine";
|
||||||
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 { formatNumber } from "../utils/format";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface GuardianAppProps {
|
interface GuardianAppProps {
|
||||||
@@ -13,45 +11,10 @@ interface GuardianAppProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const READY_MESSAGE = t("guardian.readyMessage");
|
const READY_MESSAGE = t("guardian.readyMessage");
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function GuardianApp({ onExit }: GuardianAppProps) {
|
export function GuardianApp({ onExit }: GuardianAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<GuardianEngineSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<GuardianEngineSnapshot>("guardian", {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
onExit
|
||||||
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,56 +1,19 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { liquidityMakerConfig } from "../config";
|
import type { LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import { LiquidityMakerEngine, type LiquidityMakerEngineSnapshot } from "../strategy/liquidity-maker-engine";
|
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface LiquidityMakerAppProps {
|
interface LiquidityMakerAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) {
|
export function LiquidityMakerApp({ onExit }: LiquidityMakerAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<LiquidityMakerEngineSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<LiquidityMakerEngineSnapshot>("liquidity-maker", {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
onExit
|
||||||
const engineRef = useRef<LiquidityMakerEngine | null>(null);
|
});
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
|
||||||
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
|
||||||
|
|
||||||
useInput(
|
|
||||||
(input, key) => {
|
|
||||||
if (key.escape) {
|
|
||||||
engineRef.current?.stop();
|
|
||||||
onExit();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive: inputSupported }
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const adapter = buildAdapterFromEnv({ exchangeId, symbol: liquidityMakerConfig.symbol });
|
|
||||||
const engine = new LiquidityMakerEngine(liquidityMakerConfig, adapter);
|
|
||||||
engineRef.current = engine;
|
|
||||||
setSnapshot(engine.getSnapshot());
|
|
||||||
const handler = (next: LiquidityMakerEngineSnapshot) => {
|
|
||||||
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
|
||||||
};
|
|
||||||
engine.on("update", handler);
|
|
||||||
engine.start();
|
|
||||||
return () => {
|
|
||||||
engine.off("update", handler);
|
|
||||||
engine.stop();
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
}, [exchangeId]);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+5
-44
@@ -1,10 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { makerConfig } from "../config";
|
import { type MakerEngineSnapshot } from "../strategy/maker-engine";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine";
|
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
@@ -12,45 +10,8 @@ interface MakerAppProps {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function MakerApp({ onExit }: MakerAppProps) {
|
export function MakerApp({ onExit }: MakerAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<MakerEngineSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<MakerEngineSnapshot>("maker", { onExit });
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const engineRef = useRef<MakerEngine | 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: makerConfig.symbol });
|
|
||||||
const engine = new MakerEngine(makerConfig, adapter);
|
|
||||||
engineRef.current = engine;
|
|
||||||
setSnapshot(engine.getSnapshot());
|
|
||||||
const handler = (next: MakerEngineSnapshot) => {
|
|
||||||
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,59 +1,19 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { makerPointsConfig } from "../config";
|
import type { MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine";
|
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface MakerPointsAppProps {
|
interface MakerPointsAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
export function MakerPointsApp({ onExit }: MakerPointsAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<MakerPointsSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<MakerPointsSnapshot>("maker-points", {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
onExit
|
||||||
const engineRef = useRef<MakerPointsEngine | null>(null);
|
});
|
||||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
|
||||||
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
|
||||||
|
|
||||||
useInput(
|
|
||||||
(input, key) => {
|
|
||||||
if (key.escape) {
|
|
||||||
engineRef.current?.stop();
|
|
||||||
onExit();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive: inputSupported }
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
if (exchangeId !== "standx") {
|
|
||||||
throw new Error("Maker Points strategy only supports the StandX exchange.");
|
|
||||||
}
|
|
||||||
const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerPointsConfig.symbol });
|
|
||||||
const engine = new MakerPointsEngine(makerPointsConfig, adapter);
|
|
||||||
engineRef.current = engine;
|
|
||||||
setSnapshot(engine.getSnapshot());
|
|
||||||
const handler = (next: MakerPointsSnapshot) => {
|
|
||||||
setSnapshot({ ...next, tradeLog: [...next.tradeLog] });
|
|
||||||
};
|
|
||||||
engine.on("update", handler);
|
|
||||||
engine.start();
|
|
||||||
return () => {
|
|
||||||
engine.off("update", handler);
|
|
||||||
engine.stop();
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
}
|
|
||||||
}, [exchangeId]);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,56 +1,19 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { makerConfig } from "../config";
|
import type { OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine";
|
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
interface OffsetMakerAppProps {
|
interface OffsetMakerAppProps {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
|
export function OffsetMakerApp({ onExit }: OffsetMakerAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<OffsetMakerEngineSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<OffsetMakerEngineSnapshot>("offset-maker", {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
onExit
|
||||||
const engineRef = useRef<OffsetMakerEngine | 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: makerConfig.symbol });
|
|
||||||
const engine = new OffsetMakerEngine(makerConfig, adapter);
|
|
||||||
engineRef.current = engine;
|
|
||||||
setSnapshot(engine.getSnapshot());
|
|
||||||
const handler = (next: OffsetMakerEngineSnapshot) => {
|
|
||||||
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+8
-44
@@ -1,11 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { swingConfig } from "../config";
|
import type { SwingEngineSnapshot } from "../strategy/swing-engine";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import { SwingEngine, type SwingEngineSnapshot } from "../strategy/swing-engine";
|
|
||||||
import { formatNumber } from "../utils/format";
|
import { formatNumber } from "../utils/format";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
const READY_MESSAGE = t("swing.readyMessage");
|
const READY_MESSAGE = t("swing.readyMessage");
|
||||||
@@ -14,45 +12,11 @@ interface SwingAppProps {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function SwingApp({ onExit }: SwingAppProps) {
|
export function SwingApp({ onExit }: SwingAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<SwingEngineSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<SwingEngineSnapshot>("swing", {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
onExit,
|
||||||
const engineRef = useRef<SwingEngine | null>(null);
|
cloneSnapshot: (next) => ({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] }),
|
||||||
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: swingConfig.symbol });
|
|
||||||
const engine = new SwingEngine(swingConfig, adapter);
|
|
||||||
engineRef.current = engine;
|
|
||||||
setSnapshot(engine.getSnapshot());
|
|
||||||
const handler = (next: SwingEngineSnapshot) => {
|
|
||||||
setSnapshot({ ...next, tradeLog: [...next.tradeLog], openOrders: [...next.openOrders] });
|
|
||||||
};
|
|
||||||
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+7
-44
@@ -1,11 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React from "react";
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import { tradingConfig } from "../config";
|
import type { TrendEngineSnapshot } from "../strategy/trend-engine";
|
||||||
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
|
||||||
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
|
||||||
import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine";
|
|
||||||
import { formatNumber, formatTrendLabel } from "../utils/format";
|
import { formatNumber, formatTrendLabel } from "../utils/format";
|
||||||
import { DataTable, type TableColumn } from "./components/DataTable";
|
import { DataTable, type TableColumn } from "./components/DataTable";
|
||||||
|
import { useStrategyEngine } from "./useStrategyEngine";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
const READY_MESSAGE = t("trend.readyMessage");
|
const READY_MESSAGE = t("trend.readyMessage");
|
||||||
@@ -14,45 +12,10 @@ interface TrendAppProps {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
|
||||||
|
|
||||||
export function TrendApp({ onExit }: TrendAppProps) {
|
export function TrendApp({ onExit }: TrendAppProps) {
|
||||||
const [snapshot, setSnapshot] = useState<TrendEngineSnapshot | null>(null);
|
const { snapshot, error, exchangeName } = useStrategyEngine<TrendEngineSnapshot>("trend", {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
onExit
|
||||||
const engineRef = useRef<TrendEngine | 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 TrendEngine(tradingConfig, adapter);
|
|
||||||
engineRef.current = engine;
|
|
||||||
setSnapshot(engine.getSnapshot());
|
|
||||||
const handler = (next: TrendEngineSnapshot) => {
|
|
||||||
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useInput } from "ink";
|
||||||
|
import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter";
|
||||||
|
import { buildAdapterFromEnv } from "../exchanges/resolve-from-env";
|
||||||
|
import {
|
||||||
|
getStrategyDefinition,
|
||||||
|
strategyUnavailableReason,
|
||||||
|
type StrategyEngine,
|
||||||
|
type StrategySnapshot,
|
||||||
|
} from "../strategy/registry";
|
||||||
|
import type { StrategyId } from "../strategy/strategy-ids";
|
||||||
|
|
||||||
|
const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY);
|
||||||
|
|
||||||
|
export interface UseStrategyEngineOptions<TSnapshot extends StrategySnapshot> {
|
||||||
|
/** Called when the user presses Escape, after the engine is stopped. */
|
||||||
|
onExit: () => void;
|
||||||
|
/**
|
||||||
|
* Copies the mutable parts of a snapshot so React sees a new value. Defaults to
|
||||||
|
* a shallow copy with a fresh tradeLog; screens that render other engine-owned
|
||||||
|
* arrays must copy those too.
|
||||||
|
*/
|
||||||
|
cloneSnapshot?: (snapshot: TSnapshot) => TSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStrategyEngineResult<TSnapshot extends StrategySnapshot> {
|
||||||
|
snapshot: TSnapshot | null;
|
||||||
|
error: Error | null;
|
||||||
|
exchangeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultClone<TSnapshot extends StrategySnapshot>(snapshot: TSnapshot): TSnapshot {
|
||||||
|
return { ...snapshot, tradeLog: [...snapshot.tradeLog] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns a strategy engine for the lifetime of a screen: builds the adapter, wires
|
||||||
|
* the update subscription into React state, stops the engine on unmount or Escape.
|
||||||
|
*
|
||||||
|
* Availability is read from the registry, so a screen cannot disagree with the
|
||||||
|
* menu or the CLI about where its strategy may run.
|
||||||
|
*/
|
||||||
|
export function useStrategyEngine<TSnapshot extends StrategySnapshot>(
|
||||||
|
strategyId: StrategyId,
|
||||||
|
options: UseStrategyEngineOptions<TSnapshot>
|
||||||
|
): UseStrategyEngineResult<TSnapshot> {
|
||||||
|
const { onExit, cloneSnapshot } = options;
|
||||||
|
const [snapshot, setSnapshot] = useState<TSnapshot | null>(null);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const engineRef = useRef<StrategyEngine<TSnapshot> | null>(null);
|
||||||
|
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||||
|
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||||
|
|
||||||
|
const cloneRef = useRef(cloneSnapshot);
|
||||||
|
cloneRef.current = cloneSnapshot;
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
engineRef.current?.stop();
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: inputSupported }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const blocked = strategyUnavailableReason(strategyId, exchangeId);
|
||||||
|
if (blocked) {
|
||||||
|
setError(new Error(blocked));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const definition = getStrategyDefinition(strategyId);
|
||||||
|
const adapter = buildAdapterFromEnv({ exchangeId, symbol: definition.symbol() });
|
||||||
|
const engine = definition.createEngine(adapter) as StrategyEngine<TSnapshot>;
|
||||||
|
engineRef.current = engine;
|
||||||
|
setSnapshot(engine.getSnapshot());
|
||||||
|
|
||||||
|
const handler = (next: TSnapshot) => {
|
||||||
|
setSnapshot((cloneRef.current ?? defaultClone)(next));
|
||||||
|
};
|
||||||
|
engine.on("update", handler);
|
||||||
|
engine.start();
|
||||||
|
return () => {
|
||||||
|
engine.off("update", handler);
|
||||||
|
engine.stop();
|
||||||
|
engineRef.current = null;
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [exchangeId, strategyId]);
|
||||||
|
|
||||||
|
return { snapshot, error, exchangeName };
|
||||||
|
}
|
||||||
+4
-3
@@ -1,10 +1,11 @@
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export type TrendLabel = "做多" | "做空" | "无信号";
|
/** Direction the trend engine reports; a domain value, not display text. */
|
||||||
|
export type TrendLabel = "long" | "short" | "none";
|
||||||
|
|
||||||
export function formatTrendLabel(trend: TrendLabel): string {
|
export function formatTrendLabel(trend: TrendLabel): string {
|
||||||
if (trend === "做多") return t("trend.label.long");
|
if (trend === "long") return t("trend.label.long");
|
||||||
if (trend === "做空") return t("trend.label.short");
|
if (trend === "short") return t("trend.label.short");
|
||||||
return t("trend.label.none");
|
return t("trend.label.none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
|
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
|
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
|
||||||
|
|
||||||
@@ -68,18 +69,18 @@ export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | nu
|
|||||||
if (!status.expired) {
|
if (!status.expired) {
|
||||||
if (status.remainingMs != null && status.remainingMs < 3600_000) {
|
if (status.remainingMs != null && status.remainingMs < 3600_000) {
|
||||||
const mins = Math.ceil(status.remainingMs / 60_000);
|
const mins = Math.ceil(status.remainingMs / 60_000);
|
||||||
return `StandX Token 将在 ${mins} 分钟后过期`;
|
return t("token.expiringSoon", { minutes: mins });
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (status.state) {
|
switch (status.state) {
|
||||||
case "expired":
|
case "expired":
|
||||||
return "StandX Token 已过期,正在取消所有挂单";
|
return t("token.expiredCancelling");
|
||||||
case "expired_with_position":
|
case "expired_with_position":
|
||||||
return "StandX Token 已过期,仅保留平仓/止损逻辑";
|
return t("token.expiredWithPosition");
|
||||||
case "silent":
|
case "silent":
|
||||||
return "StandX Token 已过期,进入静默数据接收模式";
|
return t("token.expiredSilent");
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { t } from "../src/i18n";
|
||||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||||
import type { AccountSnapshot, Depth, Kline, Order, Ticker } from "../src/exchanges/types";
|
import type { AccountSnapshot, Depth, Kline, Order, Ticker } from "../src/exchanges/types";
|
||||||
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
|
import { MakerPointsEngine } from "../src/strategy/maker-points-engine";
|
||||||
@@ -103,7 +104,11 @@ describe("MakerPointsEngine Binance depth health defense", () => {
|
|||||||
expect((engine as any).defenseMode).toBe(true);
|
expect((engine as any).defenseMode).toBe(true);
|
||||||
|
|
||||||
const logs = ((engine as any).tradeLog.all() as Array<{ detail: string }>).map((entry) => entry.detail);
|
const logs = ((engine as any).tradeLog.all() as Array<{ detail: string }>).map((entry) => entry.detail);
|
||||||
expect(logs.some((detail) => detail.includes("Binance簿记异常(orderbook_not_ready)"))).toBe(true);
|
expect(
|
||||||
|
logs.some((detail) =>
|
||||||
|
detail.includes(t("defense.reason.binanceBook", { reason: "orderbook_not_ready" }))
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
engine.stop();
|
engine.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
+604
-535
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import {
|
||||||
|
loadGridState,
|
||||||
|
saveGridState,
|
||||||
|
clearGridState,
|
||||||
|
migrateV1ToV2,
|
||||||
|
type StoredGridStateV2,
|
||||||
|
} from "../src/strategy/common/grid-storage";
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let prevDataDir: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "grid-storage-test-"));
|
||||||
|
prevDataDir = process.env.GRID_DATA_DIR;
|
||||||
|
process.env.GRID_DATA_DIR = tmpDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (prevDataDir == null) delete process.env.GRID_DATA_DIR;
|
||||||
|
else process.env.GRID_DATA_DIR = prevDataDir;
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeV2(symbol: string): StoredGridStateV2 {
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
symbol,
|
||||||
|
exchangeId: "aster",
|
||||||
|
gridVersion: 3,
|
||||||
|
anchorPrice: 150,
|
||||||
|
lowerPrice: 100,
|
||||||
|
upperPrice: 200,
|
||||||
|
gridLevels: 5,
|
||||||
|
orderSize: 0.1,
|
||||||
|
maxPositionSize: 0.4,
|
||||||
|
direction: "neutral",
|
||||||
|
gridMode: "geometric",
|
||||||
|
levels: {
|
||||||
|
"1": { phase: "holding", exitTarget: 2, holdQty: 0.1 },
|
||||||
|
"3": { phase: "exit_placed", exitTarget: 2, holdQty: 0.1, exitOrderId: "o-9" },
|
||||||
|
},
|
||||||
|
intents: [
|
||||||
|
{
|
||||||
|
orderId: "o-9",
|
||||||
|
clientOrderId: "grid-3-X-3-2-abc",
|
||||||
|
intent: "EXIT",
|
||||||
|
side: "BUY",
|
||||||
|
price: "141.4",
|
||||||
|
qty: 0.1,
|
||||||
|
level: 3,
|
||||||
|
target: 2,
|
||||||
|
gridVersion: 3,
|
||||||
|
createdAt: 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inflight: null,
|
||||||
|
shift: { phase: "closing", targetAnchor: 210, startedAt: 2000 },
|
||||||
|
exchangeStop: { orderId: "stop-1", side: "SELL", stopPrice: 99 },
|
||||||
|
updatedAt: 3000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("grid-storage v2", () => {
|
||||||
|
it("round-trips a v2 snapshot", async () => {
|
||||||
|
const snapshot = makeV2("BTCUSDT");
|
||||||
|
await saveGridState(snapshot);
|
||||||
|
const loaded = await loadGridState("BTCUSDT");
|
||||||
|
expect(loaded).toEqual(snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown symbols", async () => {
|
||||||
|
expect(await loadGridState("NONE")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps entries for other symbols on clear", async () => {
|
||||||
|
await saveGridState(makeV2("BTCUSDT"));
|
||||||
|
await saveGridState(makeV2("ETHUSDT"));
|
||||||
|
await clearGridState("BTCUSDT");
|
||||||
|
expect(await loadGridState("BTCUSDT")).toBeNull();
|
||||||
|
expect(await loadGridState("ETHUSDT")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the file when the last symbol is cleared", async () => {
|
||||||
|
await saveGridState(makeV2("BTCUSDT"));
|
||||||
|
await clearGridState("BTCUSDT");
|
||||||
|
await expect(fs.stat(path.join(tmpDir, "grid-record.json"))).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("grid-storage v1 migration", () => {
|
||||||
|
const v1Entry = {
|
||||||
|
symbol: "BTCUSDT",
|
||||||
|
lowerPrice: 100,
|
||||||
|
upperPrice: 200,
|
||||||
|
gridLevels: 5,
|
||||||
|
orderSize: 0.1,
|
||||||
|
maxPositionSize: 0.4,
|
||||||
|
direction: "both",
|
||||||
|
levels: {
|
||||||
|
"0": { state: "filled", sourceLevel: 0, targetLevel: 1 },
|
||||||
|
"1": { state: "exit_placed", sourceLevel: 1, targetLevel: 2, exitOrderId: "legacy-1" },
|
||||||
|
"2": { state: "idle", sourceLevel: 2, targetLevel: null },
|
||||||
|
},
|
||||||
|
updatedAt: 1234,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("loads v1 entries as migrated v2", async () => {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tmpDir, "grid-record.json"),
|
||||||
|
JSON.stringify({ BTCUSDT: v1Entry }),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const loaded = await loadGridState("BTCUSDT");
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.schemaVersion).toBe(2);
|
||||||
|
expect(loaded!.gridVersion).toBe(1);
|
||||||
|
expect(loaded!.anchorPrice).toBeNull();
|
||||||
|
expect(loaded!.levels["0"]).toEqual({ phase: "holding", exitTarget: 1, holdQty: 0.1 });
|
||||||
|
expect(loaded!.levels["1"]).toEqual({
|
||||||
|
phase: "exit_placed",
|
||||||
|
exitTarget: 2,
|
||||||
|
holdQty: 0.1,
|
||||||
|
exitOrderId: "legacy-1",
|
||||||
|
});
|
||||||
|
expect(loaded!.levels["2"]).toBeUndefined();
|
||||||
|
expect(loaded!.intents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrateV1ToV2 preserves config fingerprint fields", () => {
|
||||||
|
const migrated = migrateV1ToV2(v1Entry as any);
|
||||||
|
expect(migrated.symbol).toBe("BTCUSDT");
|
||||||
|
expect(migrated.direction).toBe("both");
|
||||||
|
expect(migrated.orderSize).toBe(0.1);
|
||||||
|
expect(migrated.gridLevels).toBe(5);
|
||||||
|
expect(migrated.gridMode).toBe("geometric");
|
||||||
|
expect(migrated.lowerPrice).toBe(100);
|
||||||
|
expect(migrated.upperPrice).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { t } from "../src/i18n";
|
||||||
|
|
||||||
|
const SRC = join(import.meta.dirname, "..", "src");
|
||||||
|
const I18N_FILE = join(SRC, "i18n", "index.ts");
|
||||||
|
|
||||||
|
function walk(dir: string, out: string[] = []): string[] {
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const full = join(dir, entry);
|
||||||
|
if (statSync(full).isDirectory()) {
|
||||||
|
walk(full, out);
|
||||||
|
} else if (/\.tsx?$/.test(entry)) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A string or template literal containing a CJK character. */
|
||||||
|
const CJK_IN_LITERAL = /["`][^"`\n]*[一-龥][^"`\n]*["`]/;
|
||||||
|
|
||||||
|
describe("i18n coverage", () => {
|
||||||
|
it("keeps user-facing text out of source files", () => {
|
||||||
|
// Chinese literals outside the translation table cannot be shown in English,
|
||||||
|
// which is how the order log, grid events and defense alerts stayed
|
||||||
|
// untranslatable for so long.
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const file of walk(SRC)) {
|
||||||
|
if (file === I18N_FILE) continue;
|
||||||
|
if (file.endsWith(".test.ts") || file.endsWith(".test.tsx")) continue;
|
||||||
|
const lines = readFileSync(file, "utf8").split("\n");
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*")) return;
|
||||||
|
if (CJK_IN_LITERAL.test(line)) {
|
||||||
|
offenders.push(`${file.slice(SRC.length + 1)}:${index + 1} ${line.trim()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every key both a zh and an en translation", () => {
|
||||||
|
const source = readFileSync(I18N_FILE, "utf8");
|
||||||
|
const table = source.slice(
|
||||||
|
source.indexOf("const translations"),
|
||||||
|
source.indexOf("const formatTemplate")
|
||||||
|
);
|
||||||
|
const keys = [...table.matchAll(/^ {2}"([\w.]+)":/gm)].map((m) => m[1]!);
|
||||||
|
expect(keys.length).toBeGreaterThan(400);
|
||||||
|
|
||||||
|
const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index);
|
||||||
|
expect(duplicates).toEqual([]);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(t(key, {}, "zh"), `${key} missing zh`).not.toBe(key);
|
||||||
|
expect(t(key, {}, "en"), `${key} missing en`).not.toBe(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes placeholders in both languages", () => {
|
||||||
|
expect(t("log.order.closePlaced", { side: "BUY" }, "zh")).toContain("BUY");
|
||||||
|
expect(t("log.order.closePlaced", { side: "BUY" }, "en")).toContain("BUY");
|
||||||
|
expect(t("log.order.closePlaced", { side: "BUY" }, "en")).not.toContain("{side}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unknown placeholder visible rather than printing undefined", () => {
|
||||||
|
expect(t("log.order.closePlaced", {}, "en")).toContain("{side}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IsolatedMarginGuard } from "../src/strategy/common/isolated-margin-guard";
|
||||||
|
import type { AccountSnapshot } from "../src/exchanges/types";
|
||||||
|
import { t } from "../src/i18n";
|
||||||
|
|
||||||
|
const SYMBOL = "BTC-USD";
|
||||||
|
|
||||||
|
function snapshotWithMode(mode: string | null): AccountSnapshot {
|
||||||
|
return {
|
||||||
|
positions: [{ symbol: SYMBOL, ...(mode ? { marginType: mode } : {}) }],
|
||||||
|
} as unknown as AccountSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGuard(options: {
|
||||||
|
enabled?: boolean;
|
||||||
|
initialMode?: string | null;
|
||||||
|
/** Modes the account reports on successive polls. */
|
||||||
|
polledModes?: Array<string | null>;
|
||||||
|
changeMarginMode?: (params: { symbol: string; marginMode: "isolated" | "cross" }) => Promise<void>;
|
||||||
|
omitCapabilities?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const logs: Array<[string, string]> = [];
|
||||||
|
let current = snapshotWithMode("initialMode" in options ? options.initialMode! : "cross");
|
||||||
|
const polled = [...(options.polledModes ?? [])];
|
||||||
|
const queryAccountSnapshot = vi.fn(async () => snapshotWithMode(polled.shift() ?? "cross"));
|
||||||
|
const changeMarginMode = vi.fn(options.changeMarginMode ?? (async () => {}));
|
||||||
|
|
||||||
|
const guard = new IsolatedMarginGuard({
|
||||||
|
symbol: SYMBOL,
|
||||||
|
enabled: options.enabled ?? true,
|
||||||
|
log: (type, detail) => logs.push([type, detail]),
|
||||||
|
currentSnapshot: () => current,
|
||||||
|
changeMarginMode: options.omitCapabilities ? undefined : changeMarginMode,
|
||||||
|
queryAccountSnapshot: options.omitCapabilities ? undefined : queryAccountSnapshot,
|
||||||
|
applySnapshot: (next) => {
|
||||||
|
current = next;
|
||||||
|
},
|
||||||
|
// No real waiting in tests.
|
||||||
|
sleep: async () => {},
|
||||||
|
});
|
||||||
|
return { guard, logs, changeMarginMode, queryAccountSnapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IsolatedMarginGuard", () => {
|
||||||
|
it("is inert on venues without a per-symbol margin mode", async () => {
|
||||||
|
const { guard, changeMarginMode } = makeGuard({ enabled: false });
|
||||||
|
expect(await guard.ensureIsolated()).toBe(true);
|
||||||
|
expect(guard.currentMode()).toBeNull();
|
||||||
|
expect(changeMarginMode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when already isolated", async () => {
|
||||||
|
const { guard, changeMarginMode } = makeGuard({ initialMode: "isolated" });
|
||||||
|
expect(await guard.ensureIsolated()).toBe(true);
|
||||||
|
expect(changeMarginMode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises the reported mode", async () => {
|
||||||
|
const { guard } = makeGuard({ initialMode: " ISOLATED " });
|
||||||
|
expect(guard.currentMode()).toBe("isolated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unknown mode as null", async () => {
|
||||||
|
const { guard } = makeGuard({ initialMode: null });
|
||||||
|
expect(guard.currentMode()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches and confirms through a snapshot poll", async () => {
|
||||||
|
const { guard, logs, changeMarginMode } = makeGuard({
|
||||||
|
initialMode: "cross",
|
||||||
|
polledModes: ["cross", "isolated"],
|
||||||
|
});
|
||||||
|
expect(await guard.ensureIsolated()).toBe(true);
|
||||||
|
expect(changeMarginMode).toHaveBeenCalledWith({ symbol: SYMBOL, marginMode: "isolated" });
|
||||||
|
expect(logs.some(([, detail]) => detail === t("log.margin.switched"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up after the confirm attempts run out", async () => {
|
||||||
|
const { guard, logs, queryAccountSnapshot } = makeGuard({ polledModes: [] });
|
||||||
|
expect(await guard.ensureIsolated()).toBe(false);
|
||||||
|
expect(queryAccountSnapshot).toHaveBeenCalledTimes(10);
|
||||||
|
expect(logs.some(([type]) => type === "warn")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failure when the venue rejects the change", async () => {
|
||||||
|
const { guard, logs } = makeGuard({
|
||||||
|
changeMarginMode: async () => {
|
||||||
|
throw new Error("rejected");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await guard.ensureIsolated()).toBe(false);
|
||||||
|
expect(logs.some(([type]) => type === "error")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the adapter cannot change margin mode", async () => {
|
||||||
|
const { guard } = makeGuard({ omitCapabilities: true });
|
||||||
|
expect(await guard.ensureIsolated()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares one in-flight switch across concurrent ticks", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const { guard, changeMarginMode } = makeGuard({
|
||||||
|
polledModes: ["isolated"],
|
||||||
|
changeMarginMode: async () => {
|
||||||
|
await gate;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = guard.ensureIsolated();
|
||||||
|
// A tick arriving mid-switch must not fire a second change request.
|
||||||
|
const second = await guard.ensureIsolated();
|
||||||
|
expect(second).toBe(false);
|
||||||
|
release();
|
||||||
|
expect(await first).toBe(true);
|
||||||
|
expect(changeMarginMode).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a fresh attempt after the previous one settles", async () => {
|
||||||
|
const { guard, changeMarginMode } = makeGuard({ polledModes: [] });
|
||||||
|
expect(await guard.ensureIsolated()).toBe(false);
|
||||||
|
expect(await guard.ensureIsolated()).toBe(false);
|
||||||
|
expect(changeMarginMode).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,6 +50,6 @@ describe("LighterSigner", () => {
|
|||||||
expect(signed.txHash.length).toBeGreaterThan(0);
|
expect(signed.txHash.length).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
expect(typeof signed.signature).toBe("string");
|
expect(typeof signed.signature).toBe("string");
|
||||||
expect(signed.signature.length).toBeGreaterThan(0);
|
expect(signed.signature?.length ?? 0).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,10 +60,11 @@ describe("MakerPointsEngine defense-mode REST polling", () => {
|
|||||||
price: "100",
|
price: "100",
|
||||||
origQty: "1",
|
origQty: "1",
|
||||||
executedQty: "0",
|
executedQty: "0",
|
||||||
|
stopPrice: "0",
|
||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
updateTime: Date.now(),
|
updateTime: Date.now(),
|
||||||
reduceOnly: "false",
|
reduceOnly: false,
|
||||||
closePosition: "false",
|
closePosition: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterAll } from "vitest";
|
||||||
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
import type { ExchangeAdapter } from "../src/exchanges/adapter";
|
||||||
import type { Order } from "../src/exchanges/types";
|
import type { Order } from "../src/exchanges/types";
|
||||||
import type { OrderLockMap, OrderTimerMap, OrderPendingMap } from "../src/core/order-coordinator";
|
import { t } from "../src/i18n";
|
||||||
|
import type {
|
||||||
|
OrderContext,
|
||||||
|
OrderLockMap,
|
||||||
|
OrderPendingMap,
|
||||||
|
OrderTimerMap,
|
||||||
|
} from "../src/core/order-coordinator";
|
||||||
import {
|
import {
|
||||||
deduplicateOrders,
|
deduplicateOrders,
|
||||||
placeOrder,
|
placeOrder,
|
||||||
@@ -60,132 +66,81 @@ describe("order-coordinator", () => {
|
|||||||
process.env.EXCHANGE = originalExchange;
|
process.env.EXCHANGE = originalExchange;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deduplicates orders by type and side", async () => {
|
/** One order context plus handles on the pieces the assertions poke at. */
|
||||||
|
function createContext() {
|
||||||
const adapter = createMockExchange();
|
const adapter = createMockExchange();
|
||||||
const locks: OrderLockMap = {};
|
const locks: OrderLockMap = {};
|
||||||
const timers: OrderTimerMap = {};
|
const timers: OrderTimerMap = {};
|
||||||
const pending: OrderPendingMap = {};
|
const pending: OrderPendingMap = {};
|
||||||
const log = vi.fn();
|
const log = vi.fn();
|
||||||
|
const ctx: OrderContext = { adapter, symbol: "BTCUSDT", locks, timers, pendings: pending, log };
|
||||||
|
return { ctx, adapter, locks, timers, pending, log };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("deduplicates orders by type and side", async () => {
|
||||||
|
const { ctx, adapter, log } = createContext();
|
||||||
const openOrders: Order[] = [
|
const openOrders: Order[] = [
|
||||||
{ ...baseOrder, orderId: 1 },
|
{ ...baseOrder, orderId: 1 },
|
||||||
{ ...baseOrder, orderId: 2 },
|
{ ...baseOrder, orderId: 2 },
|
||||||
];
|
];
|
||||||
await deduplicateOrders(adapter, "BTCUSDT", openOrders, locks, timers, pending, "LIMIT", "BUY", log);
|
await deduplicateOrders(ctx, openOrders, "LIMIT", "BUY");
|
||||||
expect(adapter.cancelOrders).toHaveBeenCalledWith({ symbol: "BTCUSDT", orderIdList: [2] });
|
expect(adapter.cancelOrders).toHaveBeenCalledWith({ symbol: "BTCUSDT", orderIdList: [2] });
|
||||||
expect(log).toHaveBeenCalledWith("order", expect.stringContaining("去重撤销重复"));
|
expect(log).toHaveBeenCalledWith("order", t("log.order.dedupeCancelled", { type: "LIMIT", ids: "2" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places limit orders and records pending id", async () => {
|
it("places limit orders and records pending id", async () => {
|
||||||
const adapter = createMockExchange();
|
const { ctx, adapter, pending } = createContext();
|
||||||
const locks: OrderLockMap = {};
|
await placeOrder(ctx, { openOrders: [], side: "BUY", price: "100", amount: 1, reduceOnly: false });
|
||||||
const timers: OrderTimerMap = {};
|
|
||||||
const pending: OrderPendingMap = {};
|
|
||||||
const log = vi.fn();
|
|
||||||
await placeOrder(
|
|
||||||
adapter,
|
|
||||||
"BTCUSDT",
|
|
||||||
[],
|
|
||||||
locks,
|
|
||||||
timers,
|
|
||||||
pending,
|
|
||||||
"BUY",
|
|
||||||
100,
|
|
||||||
1,
|
|
||||||
log,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(pending.MARKET).toBeUndefined();
|
expect(pending.MARKET).toBeUndefined();
|
||||||
expect(pending.LIMIT).toBe(String(baseOrder.orderId));
|
expect(pending.LIMIT).toBe(String(baseOrder.orderId));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places market order and unlocks after completion", async () => {
|
it("places market order and unlocks after completion", async () => {
|
||||||
const adapter = createMockExchange();
|
const { ctx, adapter, pending } = createContext();
|
||||||
const locks: OrderLockMap = {};
|
await placeMarketOrder(ctx, { openOrders: [], side: "SELL", amount: 1, reduceOnly: true });
|
||||||
const timers: OrderTimerMap = {};
|
|
||||||
const pending: OrderPendingMap = {};
|
|
||||||
const log = vi.fn();
|
|
||||||
await placeMarketOrder(
|
|
||||||
adapter,
|
|
||||||
"BTCUSDT",
|
|
||||||
[],
|
|
||||||
locks,
|
|
||||||
timers,
|
|
||||||
pending,
|
|
||||||
"SELL",
|
|
||||||
1,
|
|
||||||
log,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(pending.MARKET).toBe(String(baseOrder.orderId));
|
expect(pending.MARKET).toBe(String(baseOrder.orderId));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places stop loss order only when valid", async () => {
|
it("places stop loss order only when valid", async () => {
|
||||||
const adapter = createMockExchange();
|
const { ctx, adapter, log } = createContext();
|
||||||
const locks: OrderLockMap = {};
|
await placeStopLossOrder(ctx, {
|
||||||
const timers: OrderTimerMap = {};
|
openOrders: [],
|
||||||
const pending: OrderPendingMap = {};
|
side: "SELL",
|
||||||
const log = vi.fn();
|
stopPrice: 99,
|
||||||
await placeStopLossOrder(
|
quantity: 1,
|
||||||
adapter,
|
lastPrice: 100,
|
||||||
"BTCUSDT",
|
});
|
||||||
[],
|
|
||||||
locks,
|
|
||||||
timers,
|
|
||||||
pending,
|
|
||||||
"SELL",
|
|
||||||
99,
|
|
||||||
1,
|
|
||||||
100,
|
|
||||||
log
|
|
||||||
);
|
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(log).toHaveBeenCalledWith("stop", expect.stringContaining("STOP_MARKET"));
|
expect(log).toHaveBeenCalledWith("stop", t("log.order.stopPlaced", { side: "SELL", stopPrice: 99 }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places trailing stop order", async () => {
|
it("places trailing stop order", async () => {
|
||||||
const adapter = createMockExchange();
|
const { ctx, adapter, log } = createContext();
|
||||||
const locks: OrderLockMap = {};
|
await placeTrailingStopOrder(ctx, {
|
||||||
const timers: OrderTimerMap = {};
|
openOrders: [],
|
||||||
const pending: OrderPendingMap = {};
|
side: "SELL",
|
||||||
const log = vi.fn();
|
activationPrice: 101,
|
||||||
await placeTrailingStopOrder(
|
quantity: 1,
|
||||||
adapter,
|
callbackRate: 0.2,
|
||||||
"BTCUSDT",
|
});
|
||||||
[],
|
|
||||||
locks,
|
|
||||||
timers,
|
|
||||||
pending,
|
|
||||||
"SELL",
|
|
||||||
101,
|
|
||||||
1,
|
|
||||||
0.2,
|
|
||||||
log
|
|
||||||
);
|
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(log).toHaveBeenCalledWith("order", expect.stringContaining("挂动态止盈单"));
|
expect(log).toHaveBeenCalledWith(
|
||||||
|
"order",
|
||||||
|
t("log.order.trailingPlaced", { side: "SELL", activation: 101, callbackRate: 0.2 })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("market close cancels open orders before placing close order", async () => {
|
it("market close cancels open orders before placing close order", async () => {
|
||||||
const adapter = createMockExchange();
|
const { ctx, adapter, log } = createContext();
|
||||||
const locks: OrderLockMap = {};
|
await marketClose(ctx, {
|
||||||
const timers: OrderTimerMap = {};
|
openOrders: [{ ...baseOrder, orderId: 2 }],
|
||||||
const pending: OrderPendingMap = {};
|
side: "SELL",
|
||||||
const log = vi.fn();
|
quantity: 1,
|
||||||
await marketClose(
|
});
|
||||||
adapter,
|
|
||||||
"BTCUSDT",
|
|
||||||
[{ ...baseOrder, orderId: 2 }],
|
|
||||||
locks,
|
|
||||||
timers,
|
|
||||||
pending,
|
|
||||||
"SELL",
|
|
||||||
1,
|
|
||||||
log
|
|
||||||
);
|
|
||||||
expect(adapter.createOrder).toHaveBeenCalled();
|
expect(adapter.createOrder).toHaveBeenCalled();
|
||||||
expect(log).toHaveBeenCalledWith("close", expect.stringContaining("市价平仓"));
|
expect(log).toHaveBeenCalledWith("close", t("log.order.closePlaced", { side: "SELL" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unlockOperating clears timers and pending", () => {
|
it("unlockOperating clears timers and pending", () => {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { PrecisionSyncer } from "../src/strategy/common/precision-syncer";
|
||||||
|
import type { ExchangeAdapter, ExchangePrecision } from "../src/exchanges/adapter";
|
||||||
|
|
||||||
|
function makeExchange(getPrecision?: () => Promise<ExchangePrecision | null>): ExchangeAdapter {
|
||||||
|
return { id: "stub", getPrecision } as unknown as ExchangeAdapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES = {
|
||||||
|
synced: (p: ExchangePrecision) => `synced ${p.priceTick}/${p.qtyStep}`,
|
||||||
|
failed: (error: unknown) => `failed ${String(error)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("PrecisionSyncer", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("seeds from config and writes exchange precision through to config", async () => {
|
||||||
|
const config = { priceTick: 0.1, qtyStep: 0.001 };
|
||||||
|
const syncer = new PrecisionSyncer(
|
||||||
|
makeExchange(async () => ({ priceTick: 0.01, qtyStep: 0.1 })),
|
||||||
|
config,
|
||||||
|
{ priceTick: config.priceTick, qtyStep: config.qtyStep },
|
||||||
|
() => {},
|
||||||
|
MESSAGES
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(syncer.priceTick).toBe(0.1);
|
||||||
|
syncer.start();
|
||||||
|
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.01));
|
||||||
|
|
||||||
|
expect(syncer.qtyStep).toBe(0.1);
|
||||||
|
expect(config.priceTick).toBe(0.01);
|
||||||
|
expect(config.qtyStep).toBe(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs only when an increment actually moves", async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const syncer = new PrecisionSyncer(
|
||||||
|
makeExchange(async () => ({ priceTick: 0.1, qtyStep: 0.001 })),
|
||||||
|
{ priceTick: 0.1, qtyStep: 0.001 },
|
||||||
|
{ priceTick: 0.1, qtyStep: 0.001 },
|
||||||
|
(_type, detail) => logs.push(detail),
|
||||||
|
MESSAGES
|
||||||
|
);
|
||||||
|
|
||||||
|
syncer.start();
|
||||||
|
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.1));
|
||||||
|
expect(logs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-positive increments from the exchange", async () => {
|
||||||
|
const syncer = new PrecisionSyncer(
|
||||||
|
makeExchange(async () => ({ priceTick: 0, qtyStep: Number.NaN })),
|
||||||
|
{ priceTick: 0.5, qtyStep: 0.25 },
|
||||||
|
{ priceTick: 0.5, qtyStep: 0.25 },
|
||||||
|
() => {},
|
||||||
|
MESSAGES
|
||||||
|
);
|
||||||
|
|
||||||
|
syncer.start();
|
||||||
|
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.5));
|
||||||
|
expect(syncer.qtyStep).toBe(0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries after a failure until the exchange answers", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const syncer = new PrecisionSyncer(
|
||||||
|
makeExchange(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) throw new Error("boom");
|
||||||
|
return { priceTick: 0.05, qtyStep: 0.5 };
|
||||||
|
}),
|
||||||
|
{ priceTick: 1, qtyStep: 1 },
|
||||||
|
{ priceTick: 1, qtyStep: 1 },
|
||||||
|
() => {},
|
||||||
|
MESSAGES
|
||||||
|
);
|
||||||
|
|
||||||
|
syncer.start();
|
||||||
|
await vi.waitFor(() => expect(attempts).toBe(1));
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.05));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop() cancels the pending retry so a dead engine stops polling", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const syncer = new PrecisionSyncer(
|
||||||
|
makeExchange(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
throw new Error("boom");
|
||||||
|
}),
|
||||||
|
{ priceTick: 1, qtyStep: 1 },
|
||||||
|
{ priceTick: 1, qtyStep: 1 },
|
||||||
|
() => {},
|
||||||
|
MESSAGES
|
||||||
|
);
|
||||||
|
|
||||||
|
syncer.start();
|
||||||
|
await vi.waitFor(() => expect(attempts).toBe(1));
|
||||||
|
syncer.stop();
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("start() is idempotent while a sync is in flight", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const syncer = new PrecisionSyncer(
|
||||||
|
makeExchange(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
return { priceTick: 0.2, qtyStep: 0.2 };
|
||||||
|
}),
|
||||||
|
{ priceTick: 1, qtyStep: 1 },
|
||||||
|
{ priceTick: 1, qtyStep: 1 },
|
||||||
|
() => {},
|
||||||
|
MESSAGES
|
||||||
|
);
|
||||||
|
|
||||||
|
syncer.start();
|
||||||
|
syncer.start();
|
||||||
|
syncer.start();
|
||||||
|
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refresh() refetches after a completed sync", async () => {
|
||||||
|
let tick = 0.2;
|
||||||
|
let attempts = 0;
|
||||||
|
const syncer = new PrecisionSyncer(
|
||||||
|
makeExchange(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
return { priceTick: tick, qtyStep: 1 };
|
||||||
|
}),
|
||||||
|
{ priceTick: 1, qtyStep: 1 },
|
||||||
|
{ priceTick: 1, qtyStep: 1 },
|
||||||
|
() => {},
|
||||||
|
MESSAGES
|
||||||
|
);
|
||||||
|
|
||||||
|
syncer.start();
|
||||||
|
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.2));
|
||||||
|
tick = 0.4;
|
||||||
|
syncer.refresh();
|
||||||
|
await vi.waitFor(() => expect(syncer.priceTick).toBe(0.4));
|
||||||
|
expect(attempts).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is inert when the adapter cannot report precision", async () => {
|
||||||
|
const syncer = new PrecisionSyncer(
|
||||||
|
makeExchange(undefined),
|
||||||
|
{ priceTick: 0.3, qtyStep: 0.3 },
|
||||||
|
{ priceTick: 0.3, qtyStep: 0.3 },
|
||||||
|
() => {},
|
||||||
|
MESSAGES
|
||||||
|
);
|
||||||
|
|
||||||
|
syncer.start();
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(syncer.priceTick).toBe(0.3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
ReconnectScheduler,
|
||||||
|
exponentialBackoff,
|
||||||
|
fixedBackoff,
|
||||||
|
linearBackoff,
|
||||||
|
} from "../src/exchanges/reconnect-scheduler";
|
||||||
|
|
||||||
|
describe("backoff policies", () => {
|
||||||
|
it("fixed returns the same delay every attempt", () => {
|
||||||
|
const policy = fixedBackoff(3000);
|
||||||
|
expect([1, 2, 5].map(policy)).toEqual([3000, 3000, 3000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exponential doubles from the base and caps", () => {
|
||||||
|
const policy = exponentialBackoff(1000, 8000);
|
||||||
|
expect([1, 2, 3, 4, 5].map(policy)).toEqual([1000, 2000, 4000, 8000, 8000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("linear grows by the base and caps", () => {
|
||||||
|
const policy = linearBackoff(2000, 30_000);
|
||||||
|
expect([1, 2, 3, 20].map(policy)).toEqual([2000, 4000, 6000, 30_000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReconnectScheduler", () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("reconnects after the backoff delay", async () => {
|
||||||
|
const connect = vi.fn();
|
||||||
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(1000) });
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
expect(connect).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses repeated schedule() calls into one pending attempt", async () => {
|
||||||
|
const connect = vi.fn();
|
||||||
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(1000) });
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
scheduler.schedule();
|
||||||
|
scheduler.schedule();
|
||||||
|
expect(scheduler.pending).toBe(true);
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grows the delay across consecutive failures", async () => {
|
||||||
|
const delays: number[] = [];
|
||||||
|
const scheduler = new ReconnectScheduler({
|
||||||
|
connect: async () => {
|
||||||
|
throw new Error("refused");
|
||||||
|
},
|
||||||
|
backoff: exponentialBackoff(1000, 60_000),
|
||||||
|
onSchedule: (delay) => delays.push(delay),
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await vi.advanceTimersByTimeAsync(4000);
|
||||||
|
expect(delays.slice(0, 3)).toEqual([1000, 2000, 4000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the backoff once the socket opens", async () => {
|
||||||
|
const delays: number[] = [];
|
||||||
|
let failing = true;
|
||||||
|
const scheduler = new ReconnectScheduler({
|
||||||
|
connect: async () => {
|
||||||
|
if (failing) throw new Error("refused");
|
||||||
|
},
|
||||||
|
backoff: exponentialBackoff(1000, 60_000),
|
||||||
|
onSchedule: (delay) => delays.push(delay),
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
expect(scheduler.attemptCount).toBe(2);
|
||||||
|
|
||||||
|
// A successful open must clear the counter, or the next transient blip
|
||||||
|
// would wait as long as the last outage did.
|
||||||
|
failing = false;
|
||||||
|
scheduler.onConnected();
|
||||||
|
expect(scheduler.attemptCount).toBe(0);
|
||||||
|
|
||||||
|
delays.length = 0;
|
||||||
|
scheduler.schedule();
|
||||||
|
expect(delays[0]).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a synchronous connect failure and retries", async () => {
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
let calls = 0;
|
||||||
|
const scheduler = new ReconnectScheduler({
|
||||||
|
connect: () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) throw new Error("boom");
|
||||||
|
},
|
||||||
|
backoff: fixedBackoff(500),
|
||||||
|
onError: (error) => errors.push(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours shouldReconnect", async () => {
|
||||||
|
const connect = vi.fn();
|
||||||
|
let running = false;
|
||||||
|
const scheduler = new ReconnectScheduler({
|
||||||
|
connect,
|
||||||
|
backoff: fixedBackoff(100),
|
||||||
|
shouldReconnect: () => running,
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(connect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
running = true;
|
||||||
|
scheduler.schedule();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel() drops the pending attempt but keeps the scheduler usable", async () => {
|
||||||
|
const connect = vi.fn();
|
||||||
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
scheduler.cancel();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(connect).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(connect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop() is permanent", async () => {
|
||||||
|
const connect = vi.fn();
|
||||||
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
scheduler.stop();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
scheduler.schedule();
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(connect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reconnect when the timer fires after stop()", async () => {
|
||||||
|
const connect = vi.fn();
|
||||||
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
||||||
|
|
||||||
|
scheduler.schedule();
|
||||||
|
scheduler.stop();
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
expect(connect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
STRATEGY_DEFINITIONS,
|
||||||
|
availableStrategies,
|
||||||
|
getStrategyDefinition,
|
||||||
|
strategyUnavailableReason,
|
||||||
|
} from "../src/strategy/registry";
|
||||||
|
import { STRATEGY_IDS, isStrategyId, parseStrategyId } from "../src/strategy/strategy-ids";
|
||||||
|
|
||||||
|
describe("strategy ids", () => {
|
||||||
|
it("resolves canonical ids and documented aliases", () => {
|
||||||
|
expect(parseStrategyId("trend")).toBe("trend");
|
||||||
|
expect(parseStrategyId(" GRID ")).toBe("grid");
|
||||||
|
expect(parseStrategyId("offset")).toBe("offset-maker");
|
||||||
|
expect(parseStrategyId("offsetmaker")).toBe("offset-maker");
|
||||||
|
expect(parseStrategyId("makerpoints")).toBe("maker-points");
|
||||||
|
expect(parseStrategyId("maker_points")).toBe("maker-points");
|
||||||
|
expect(parseStrategyId("liquidity")).toBe("liquidity-maker");
|
||||||
|
expect(parseStrategyId("liquidity_maker")).toBe("liquidity-maker");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown names", () => {
|
||||||
|
expect(parseStrategyId("nope")).toBeNull();
|
||||||
|
expect(parseStrategyId("")).toBeNull();
|
||||||
|
expect(isStrategyId("nope")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("strategy registry", () => {
|
||||||
|
it("defines every id exactly once, in menu order", () => {
|
||||||
|
expect(STRATEGY_DEFINITIONS.map((d) => d.id)).toEqual([...STRATEGY_IDS]);
|
||||||
|
expect(new Set(STRATEGY_DEFINITIONS.map((d) => d.id)).size).toBe(STRATEGY_IDS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives every strategy a console label and i18n keys", () => {
|
||||||
|
for (const definition of STRATEGY_DEFINITIONS) {
|
||||||
|
expect(definition.consoleLabel).toBeTruthy();
|
||||||
|
expect(definition.labelKey).toMatch(/^app\.strategy\./);
|
||||||
|
expect(definition.descriptionKey).toMatch(/^app\.strategy\./);
|
||||||
|
expect(typeof definition.symbol()).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gates maker-points to StandX", () => {
|
||||||
|
expect(strategyUnavailableReason("maker-points", "standx")).toBeNull();
|
||||||
|
expect(strategyUnavailableReason("maker-points", "aster")).toContain("StandX");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the menu and the CLI on one availability rule", () => {
|
||||||
|
// The menu shows exactly what startStrategy would accept — the two used to
|
||||||
|
// disagree, so basis appeared on exchanges where the runner then threw.
|
||||||
|
for (const exchangeId of ["aster", "standx", "backpack"] as const) {
|
||||||
|
const shown = availableStrategies(exchangeId).map((d) => d.id);
|
||||||
|
const runnable = STRATEGY_IDS.filter((id) => strategyUnavailableReason(id, exchangeId) == null);
|
||||||
|
expect(shown).toEqual(runnable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides strategies whose environment gate is closed", () => {
|
||||||
|
const shown = availableStrategies("backpack").map((d) => d.id);
|
||||||
|
expect(shown).not.toContain("maker-points");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes an engine factory per strategy", () => {
|
||||||
|
for (const id of STRATEGY_IDS) {
|
||||||
|
expect(typeof getStrategyDefinition(id).createEngine).toBe("function");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||||
|
import { standxTokenConfig } from "../src/config";
|
||||||
|
import { t } from "../src/i18n";
|
||||||
|
import { TokenExpiryGuard } from "../src/strategy/common/token-expiry-guard";
|
||||||
|
|
||||||
|
const HOUR_MS = 3_600_000;
|
||||||
|
const original = standxTokenConfig.expiryTimestamp;
|
||||||
|
|
||||||
|
/** The config field is read on every call, so tests set it directly. */
|
||||||
|
function setExpiry(atMs: number | null): void {
|
||||||
|
standxTokenConfig.expiryTimestamp = atMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGuard() {
|
||||||
|
const logs: Array<[string, string]> = [];
|
||||||
|
const notifications: unknown[] = [];
|
||||||
|
const cancelAllOrders = vi.fn(async () => {});
|
||||||
|
const onOrdersCancelled = vi.fn();
|
||||||
|
const guard = new TokenExpiryGuard({
|
||||||
|
log: (type, detail) => logs.push([type, detail]),
|
||||||
|
notify: (n) => notifications.push(n),
|
||||||
|
cancelAllOrders,
|
||||||
|
onOrdersCancelled,
|
||||||
|
});
|
||||||
|
return { guard, logs, notifications, cancelAllOrders, onOrdersCancelled };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TokenExpiryGuard", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
standxTokenConfig.expiryTimestamp = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays out of the way when no expiry is configured", async () => {
|
||||||
|
setExpiry(null);
|
||||||
|
const { guard, cancelAllOrders } = makeGuard();
|
||||||
|
const decision = await guard.evaluate({ positionAmt: 1, openOrderCount: 3 });
|
||||||
|
expect(decision).toEqual({ halt: false, closeOnly: false });
|
||||||
|
expect(cancelAllOrders).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing while the token is still valid", async () => {
|
||||||
|
setExpiry(Date.now() + HOUR_MS * 24);
|
||||||
|
const { guard, cancelAllOrders, notifications } = makeGuard();
|
||||||
|
const decision = await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
|
||||||
|
expect(decision).toEqual({ halt: false, closeOnly: false });
|
||||||
|
expect(cancelAllOrders).not.toHaveBeenCalled();
|
||||||
|
expect(notifications).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels once and keeps ticking while a position is still open", async () => {
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard, cancelAllOrders, onOrdersCancelled } = makeGuard();
|
||||||
|
|
||||||
|
const first = await guard.evaluate({ positionAmt: 2, openOrderCount: 4 });
|
||||||
|
expect(first).toEqual({ halt: false, closeOnly: true });
|
||||||
|
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onOrdersCancelled).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await guard.evaluate({ positionAmt: 2, openOrderCount: 4 });
|
||||||
|
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and notifies exactly once per episode", async () => {
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard, logs, notifications } = makeGuard();
|
||||||
|
|
||||||
|
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
|
||||||
|
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
|
||||||
|
await guard.evaluate({ positionAmt: 2, openOrderCount: 1 });
|
||||||
|
|
||||||
|
expect(notifications).toHaveLength(1);
|
||||||
|
expect(logs.filter(([type]) => type === "warn")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("halts the tick once nothing is left to manage", async () => {
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard } = makeGuard();
|
||||||
|
expect((await guard.evaluate({ positionAmt: 0, openOrderCount: 0 })).halt).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces the silent mode only on entry", async () => {
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard, logs } = makeGuard();
|
||||||
|
await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
|
||||||
|
await guard.evaluate({ positionAmt: 0, openOrderCount: 0 });
|
||||||
|
const entryLogs = logs.filter(
|
||||||
|
([type, detail]) => type === "info" && detail === t("log.token.silentEntered")
|
||||||
|
);
|
||||||
|
expect(entryLogs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries the cancel on the next tick when it fails", async () => {
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard, cancelAllOrders, logs } = makeGuard();
|
||||||
|
cancelAllOrders.mockRejectedValueOnce(new Error("network down"));
|
||||||
|
|
||||||
|
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
|
||||||
|
expect(logs.some(([type]) => type === "error")).toBe(true);
|
||||||
|
|
||||||
|
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
|
||||||
|
expect(cancelAllOrders).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an already-gone order as a successful cancel", async () => {
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard, cancelAllOrders } = makeGuard();
|
||||||
|
cancelAllOrders.mockRejectedValueOnce(new Error("Unknown order sent."));
|
||||||
|
|
||||||
|
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
|
||||||
|
await guard.evaluate({ positionAmt: 1, openOrderCount: 2 });
|
||||||
|
expect(cancelAllOrders).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the cancel when there is nothing resting", async () => {
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard, cancelAllOrders } = makeGuard();
|
||||||
|
await guard.evaluate({ positionAmt: 1, openOrderCount: 0 });
|
||||||
|
expect(cancelAllOrders).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes closeOnlyMode for the engine's close-reason label", async () => {
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard } = makeGuard();
|
||||||
|
expect(guard.closeOnlyMode).toBe(false);
|
||||||
|
await guard.evaluate({ positionAmt: 3, openOrderCount: 0 });
|
||||||
|
expect(guard.closeOnlyMode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-arms every latch once a fresh token arrives", async () => {
|
||||||
|
// The five latches must reset together; a stale one would silently suppress
|
||||||
|
// the log, alert, or cancel for the next expiry.
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
const { guard, notifications, cancelAllOrders, logs } = makeGuard();
|
||||||
|
|
||||||
|
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
|
||||||
|
expect(guard.closeOnlyMode).toBe(true);
|
||||||
|
expect(notifications).toHaveLength(1);
|
||||||
|
|
||||||
|
setExpiry(Date.now() + HOUR_MS * 24);
|
||||||
|
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
|
||||||
|
expect(guard.closeOnlyMode).toBe(false);
|
||||||
|
expect(guard.currentState).toBe("active");
|
||||||
|
|
||||||
|
setExpiry(Date.now() - HOUR_MS);
|
||||||
|
await guard.evaluate({ positionAmt: 5, openOrderCount: 1 });
|
||||||
|
expect(notifications).toHaveLength(2);
|
||||||
|
expect(cancelAllOrders).toHaveBeenCalledTimes(2);
|
||||||
|
expect(logs.filter(([type]) => type === "warn")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-1
@@ -25,5 +25,10 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// docs/ holds vendored third-party samples (ccxt examples); they are reference
|
||||||
|
// material, not compilation units, and their errors mask real ones in src/.
|
||||||
|
"include": ["index.ts", "src/**/*", "tests/**/*", "scripts/**/*"],
|
||||||
|
"exclude": ["node_modules", "docs"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user