2 Commits
18 changed files with 4182 additions and 1913 deletions
+8
View File
@@ -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)
+129 -52
View File
@@ -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
GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05
``` ```
4. 启动:
- `GRID_ORDER_SIZE` 与 `GRID_MAX_POSITION_SIZE` 需遵循「最大仓位 ÷ 单笔数量 ≥ 网格数」的原则,这样策略才能补齐全部挂单。本例 50 ÷ 5 = 10,但网格数为 20,意味着策略只会在离现价最近的上下各 10 个位置挂单,与仓位上限保持一致。
## 网格机制概览
- **几何等比网格**:所有网格价格基于上下边界按等比方式分布。
- **基于现价的挂单排序**:重启或行情驱动时,会优先在现价附近补挂,避免远端挂单未成交。
- **双向模式**`GRID_DIRECTION=both` 表示买卖两侧都开仓;设置为 `long` 或 `short` 则只在对应方向发起新仓,反方向挂单会自动带上 `reduceOnly`。
- **风控**
- 跌破下界 * (1 - STOP_LOSS_PCT) 或突破上界 * (1 + STOP_LOSS_PCT) 时,策略撤销所有限价单并用市价平仓。
- 若 `GRID_AUTO_RESTART_ENABLED=true`,当价格回到边界内 `RESTART_TRIGGER_PCT` 范围时会自动重启网格。
- **持仓限制**`GRID_MAX_POSITION_SIZE` 是总持仓上限,用于控制网格在极端走势中不会累积过量仓位。
## 运行命令
安装依赖后,使用 CLI 直接启动网格策略:
```bash ```bash
bun install bun install
bun run index.ts --strategy grid --exchange aster bun run index.ts --strategy grid --exchange aster
``` ```
或运行 `bun start` 后在菜单选择「基础网格策略」。
若要在 Ink Dashboard 中运行并交互,直接执行 建议先用 dry-run 模拟运行(连接真实行情但不真正下单,验证建格方向与参数)
```bash ```bash
bun start ritmex-bot strategy run --strategy grid --dry-run
# 或未全局安装时:bunx ritmex-bot strategy run --strategy grid --dry-run
``` ```
然后在菜单中选择 “基础网格策略”。
## 监控与调优 ## 参数总表
界面主要包括: ### 基础参数
- 当前买一/卖一、开仓方向、挂单/持仓概况。
- 最近日志(订单状态、风控触发等)。
- 触发止损后会清空网格并记录原因。
调参建议: | 环境变量 | 默认值 | 说明 |
1. **缩短区间**:想拉高单格盈利,可缩小上下边界并减少网格数。 |---|---|---|
2. **更精细挂单**:适当提高 `GRID_LEVELS` 并降低 `GRID_ORDER_SIZE`,但同时记得调大 `GRID_MAX_POSITION_SIZE`。 | `GRID_LOWER_PRICE` / `GRID_UPPER_PRICE` | 必填 | 网格上下边界(计价货币) |
3. **调节平仓容忍度**`GRID_MAX_CLOSE_SLIPPAGE_PCT` 控制平仓单相对标记价的最大偏移,确保 reduce-only 订单不会被交易所拒绝。 | `GRID_LEVELS` | 10 | 网格线数量(≥2),几何等比分布 |
4. **只做单边**:若只想高抛低吸不反手,可设 `GRID_DIRECTION=long`,卖单会变成 `reduceOnly`。 | `GRID_ORDER_SIZE` | TRADE_AMOUNT | 每条线的下单数量(标的资产) |
| `GRID_MAX_POSITION_SIZE` | orderSize×(levels1) | 单方向最大持仓(中性模式下多、空两侧分别约束) |
| `GRID_DIRECTION` | both | `long`(只做多)/ `short`(只做空)/ `both`(中性双向) |
| `GRID_REFRESH_INTERVAL_MS` | 1000 | 引擎轮询间隔,每轮最多新挂 1 笔限价单 |
| `GRID_PRICE_TICK` / `GRID_QTY_STEP` | PRICE_TICK / QTY_STEP | 价格与数量精度;交易所支持时会自动同步为交易所精度 |
## 中断恢复行为 ### 风控参数
策略重启后会: | 环境变量 | 默认值 | 说明 |
- 重新订阅账户、订单、深度、ticker; |---|---|---|
- 基于当前持仓和开放订单重新计算网格,只补挂缺失部分; | `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 ± 该比例) |
### 高级参数
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GRID_USE_REDUCE_ONLY` | false | 平仓单是否携带 reduceOnly。默认不带(部分交易所会拒绝与反向挂单共存的 reduce-only 限价单);策略靠意图登记自治区分开/平仓,无需此标志 |
| `GRID_RECONCILE_INTERVAL_MS` | 30000 | 支持 REST 查单的交易所的周期对账间隔 |
| `GRID_DATA_DIR` | ./data | 状态持久化目录(`grid-record.json` |
## 三种交易模式
`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 @ 下界×(1stopLossPct),净空时挂 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` 或小仓位验证参数与手续费结构,再逐步放大资金规模。
祝交易顺利! 祝交易顺利!
+1
View File
@@ -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",
+16
View File
@@ -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);
+2
View File
@@ -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;
+4
View File
@@ -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) => {
+4
View File
@@ -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")
+4
View File
@@ -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);
} }
+4
View File
@@ -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));
+4
View File
@@ -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));
+10
View File
@@ -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": {
+68 -27
View File
@@ -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 → v2filled→holding、exit_placed→exit_placedholdQty 取 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");
} }
+754 -1276
View File
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
+21 -2
View File
@@ -90,15 +90,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 +143,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) => (
+617 -548
View File
File diff suppressed because it is too large Load Diff
+145
View File
@@ -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);
});
});